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] # JPEG is generated on-demand to save ~50% disk space # Only affects <5% of users (legacy browsers without AVIF/WebP support) @pregenerated_formats [:avif, :webp] @thumb_size 200 @cache_dir "priv/static/image_cache" @max_stored_width 2000 @storage_quality 90 def cache_dir, do: @cache_dir def all_widths, do: @all_widths @doc """ Convert uploaded image to optimized WebP for storage. Images larger than #{@max_stored_width}px are resized down. Uses lossy WebP (quality #{@storage_quality}) for efficient storage. Returns {:ok, webp_data, width, height} or {:error, reason}. """ def to_optimized_webp(image_data) when is_binary(image_data) do with {:ok, image} <- Image.from_binary(image_data), {width, _height, _} <- Image.shape(image), {:ok, resized} <- maybe_resize(image, width), {final_width, final_height, _} <- Image.shape(resized), {:ok, webp_data} <- Image.write(resized, :memory, suffix: ".webp", quality: @storage_quality) do {:ok, webp_data, final_width, final_height} end rescue e -> {:error, Exception.message(e)} end defp maybe_resize(image, width) when width <= @max_stored_width, do: {:ok, image} defp maybe_resize(image, _width), do: Image.thumbnail(image, @max_stored_width) @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 <- @pregenerated_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. Only checks pre-generated formats (AVIF, WebP). JPEG is generated on-demand. """ 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?(@pregenerated_formats, fn fmt -> File.exists?(Path.join(@cache_dir, "#{image_id}-#{w}.#{format_ext(fmt)}")) end) end) thumb and variants end @doc """ Generate a variant on-demand. Returns path to generated file. Supports all formats: :avif, :webp, :jpg Used as fallback if cache files are deleted, and for JPEG legacy browser support. """ def generate_variant_on_demand(image_data, image_id, width, format) when is_binary(image_data) and format in [:avif, :webp, :jpg] do path = Path.join(@cache_dir, "#{image_id}-#{width}.#{format_ext(format)}") if File.exists?(path) do {:ok, path} else File.mkdir_p!(@cache_dir) with {:ok, vips_image} <- Image.from_binary(image_data), {:ok, resized} <- Image.thumbnail(vips_image, width), {:ok, _} <- write_format(resized, path, format) do {:ok, path} end end end # Backward compatibility alias def generate_jpeg_on_demand(image_data, image_id, width) do generate_variant_on_demand(image_data, image_id, width, :jpg) end @doc """ Process an image file and generate all variants to the specified directory. Used for both database images (to cache_dir) and mockups (to mockup_dir). Returns {:ok, source_width} or {:error, reason}. """ def process_file(image_data, output_basename, output_dir) when is_binary(image_data) do File.mkdir_p!(output_dir) with {:ok, webp_data, source_width, _height} <- to_optimized_webp(image_data), {:ok, vips_image} <- Image.from_binary(webp_data) do widths = applicable_widths(source_width) tasks = [ Task.async(fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, "thumb", :jpg, @thumb_size) end) | for w <- widths, fmt <- @pregenerated_formats do Task.async(fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, w, fmt, w) end) end ] Task.await_many(tasks, :timer.seconds(120)) {:ok, source_width} end rescue e -> {:error, Exception.message(e)} end defp generate_variant_to_dir(image, basename, dir, size_label, format, resize_width) do filename = "#{basename}-#{size_label}.#{format_ext(format)}" path = Path.join(dir, filename) with {:ok, resized} <- Image.thumbnail(image, resize_width), {:ok, _} <- write_format(resized, path, format) do :ok end end end