Files
bDS2/lib/bds/media/thumbnails.ex
2026-05-01 17:49:50 +02:00

168 lines
4.9 KiB
Elixir

defmodule BDS.Media.Thumbnails do
@moduledoc false
import BDS.Media.FileOps,
only: [
delete_file_if_present: 2,
image_mime?: 1,
progress_callback: 1,
report_rebuild_progress: 4,
report_rebuild_started: 3
]
import Ecto.Query
alias BDS.Media.Media
alias BDS.Projects
alias BDS.Repo
@type rebuild_opts :: keyword()
@spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()}
def thumbnail_paths(%Media{id: id}) do
prefix = String.slice(id, 0, 2)
%{
small: Path.join(["thumbnails", prefix, "#{id}-small.webp"]),
medium: Path.join(["thumbnails", prefix, "#{id}-medium.webp"]),
large: Path.join(["thumbnails", prefix, "#{id}-large.webp"]),
ai: Path.join(["thumbnails", prefix, "#{id}-ai.jpg"])
}
end
@spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()}
def regenerate_thumbnails(media_id) do
case Repo.get(Media, media_id) do
nil ->
{:error, :not_found}
media ->
project = Projects.get_project!(media.project_id)
:ok = ensure_thumbnails(project, media)
{:ok, media}
end
end
@spec regenerate_missing_thumbnails(String.t(), rebuild_opts()) ::
%{processed: non_neg_integer(), generated: non_neg_integer(), failed: non_neg_integer()}
def regenerate_missing_thumbnails(project_id, opts \\ []) do
project = Projects.get_project!(project_id)
on_progress = progress_callback(opts)
media_items =
Repo.all(
from(media in Media,
where: media.project_id == ^project_id,
order_by: [asc: media.created_at]
)
)
|> Enum.filter(fn media ->
String.starts_with?(media.mime_type || "", "image/") and
not String.contains?(media.mime_type || "", "svg")
end)
total_media = length(media_items)
:ok = report_rebuild_started(on_progress, total_media, "image assets")
media_items
|> Enum.with_index(1)
|> Enum.reduce(%{processed: 0, generated: 0, failed: 0}, fn {media, index}, acc ->
missing_paths =
media
|> thumbnail_paths()
|> Enum.map(fn {_size, relative_path} ->
Path.join(Projects.project_data_dir(project), relative_path)
end)
|> Enum.reject(&File.exists?/1)
next_acc =
if missing_paths == [] do
%{acc | processed: acc.processed + 1}
else
try do
:ok = ensure_thumbnails(project, media)
%{
processed: acc.processed + 1,
generated: acc.generated + length(missing_paths),
failed: acc.failed
}
rescue
_error ->
%{acc | processed: acc.processed + 1, failed: acc.failed + 1}
end
end
:ok = report_rebuild_progress(on_progress, index, total_media, "image assets")
next_acc
end)
end
@spec ensure_thumbnails(BDS.Projects.Project.t(), Media.t()) :: :ok
def ensure_thumbnails(project, media) do
if image_mime?(media.mime_type) do
source_path = Path.join(Projects.project_data_dir(project), media.file_path)
case Image.open(source_path) do
{:ok, image} ->
image
|> Image.autorotate!()
|> write_all_thumbnails(project, media)
{:error, _reason} ->
:ok
end
end
:ok
end
@spec delete_thumbnail_files(String.t(), Media.t()) :: :ok
def delete_thumbnail_files(project_id, media) do
Enum.each(Map.values(thumbnail_paths(media)), fn path ->
delete_file_if_present(project_id, path)
end)
:ok
end
defp write_all_thumbnails(image, project, media) do
thumbnail_paths(media)
|> Enum.each(fn {size, relative_path} ->
destination = Path.join(Projects.project_data_dir(project), relative_path)
:ok = File.mkdir_p(Path.dirname(destination))
image
|> render_thumbnail(size)
|> write_thumbnail(destination, size)
end)
:ok
end
defp render_thumbnail(image, :small), do: bounded_thumbnail(image, 150, 150)
defp render_thumbnail(image, :medium), do: bounded_thumbnail(image, 400, 400)
defp render_thumbnail(image, :large), do: bounded_thumbnail(image, 800, 800)
defp render_thumbnail(image, :ai) do
image
|> Image.thumbnail!("448x448", fit: :contain, resize: :both, autorotate: false)
|> Image.embed!(448, 448, x: :center, y: :center, background_color: :black)
end
defp bounded_thumbnail(image, width, height) do
Image.thumbnail!(image, "#{width}x#{height}", fit: :contain, resize: :down, autorotate: false)
end
defp write_thumbnail(image, destination, :ai) do
flattened = Image.flatten!(image, background_color: :black)
Image.write!(flattened, destination, quality: 85, strip_metadata: true)
:ok
end
defp write_thumbnail(image, destination, _size) do
Image.write!(image, destination, quality: 80, strip_metadata: true)
:ok
end
end