defmodule SimpleshopTheme.Images.Optimizer do @moduledoc """ Generates optimized image variants. Only creates sizes ≤ source dimensions. """ require Logger alias SimpleshopTheme.Repo alias SimpleshopTheme.Media.Image, as: ImageSchema @all_widths [400, 800, 1200] @formats [:avif, :webp, :jpg] @thumb_size 200 @cache_dir "priv/static/image_cache" def cache_dir, do: @cache_dir def all_widths, do: @all_widths @doc """ Convert uploaded image to lossless WebP for storage. Returns {:ok, webp_data, width, height} or {:error, reason}. """ def to_lossless_webp(image_data) when is_binary(image_data) do with {:ok, image} <- Image.from_binary(image_data), {width, height, _} <- Image.shape(image), {:ok, webp_data} <- Image.write(image, :memory, suffix: ".webp", quality: 100) do {:ok, webp_data, width, height} end rescue e -> {:error, Exception.message(e)} end @doc """ Compute applicable widths from source dimensions. Only returns widths that are <= source_width (no upscaling). """ def applicable_widths(source_width) when is_integer(source_width) do @all_widths |> Enum.filter(&(&1 <= source_width)) |> case do [] -> [source_width] widths -> widths end end @doc """ Process image and generate all applicable variants. Called by Oban worker. """ def process_for_image(image_id) do case Repo.get(ImageSchema, image_id) do nil -> {:error, :not_found} %{data: nil} -> {:error, :no_data} %{is_svg: true} = image -> Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"})) {:ok, :svg_skipped} %{data: data, source_width: width} = image -> File.mkdir_p!(@cache_dir) with {:ok, vips_image} <- Image.from_binary(data) do widths = applicable_widths(width) tasks = [ Task.async(fn -> generate_thumbnail(vips_image, image_id) end) | for w <- widths, fmt <- @formats do Task.async(fn -> generate_variant(vips_image, image_id, w, fmt) end) end ] Task.await_many(tasks, :timer.seconds(120)) Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"})) {:ok, widths} end end end defp generate_thumbnail(image, id) do path = Path.join(@cache_dir, "#{id}-thumb.jpg") return_if_exists(path, fn -> with {:ok, thumb} <- Image.thumbnail(image, @thumb_size), {:ok, _} <- Image.write(thumb, path, quality: 80) do :ok end end) end defp generate_variant(image, id, width, format) do path = Path.join(@cache_dir, "#{id}-#{width}.#{format_ext(format)}") return_if_exists(path, fn -> with {:ok, resized} <- Image.thumbnail(image, width), {:ok, _} <- write_format(resized, path, format) do :ok end end) end defp return_if_exists(path, generate_fn) do if File.exists?(path), do: {:ok, :cached}, else: generate_fn.() end defp format_ext(:jpg), do: "jpg" defp format_ext(:webp), do: "webp" defp format_ext(:avif), do: "avif" defp write_format(image, path, :avif) do Image.write(image, path, effort: 5, minimize_file_size: true) end defp write_format(image, path, :webp) do Image.write(image, path, effort: 6, minimize_file_size: true) end defp write_format(image, path, :jpg) do Image.write(image, path, quality: 80, minimize_file_size: true) end @doc """ Check if disk variants exist for an image. """ def disk_variants_exist?(image_id, source_width) do widths = applicable_widths(source_width) thumb = File.exists?(Path.join(@cache_dir, "#{image_id}-thumb.jpg")) variants = Enum.all?(widths, fn w -> Enum.all?(@formats, fn fmt -> File.exists?(Path.join(@cache_dir, "#{image_id}-#{w}.#{format_ext(fmt)}")) end) end) thumb and variants end end