diff --git a/lib/simpleshop_theme/images/optimizer.ex b/lib/simpleshop_theme/images/optimizer.ex new file mode 100644 index 0000000..b214a39 --- /dev/null +++ b/lib/simpleshop_theme/images/optimizer.ex @@ -0,0 +1,141 @@ +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 diff --git a/test/fixtures/sample_1200x800.jpg b/test/fixtures/sample_1200x800.jpg new file mode 100644 index 0000000..ca5fc6d Binary files /dev/null and b/test/fixtures/sample_1200x800.jpg differ diff --git a/test/simpleshop_theme/images/optimizer_test.exs b/test/simpleshop_theme/images/optimizer_test.exs new file mode 100644 index 0000000..e4d3a8d --- /dev/null +++ b/test/simpleshop_theme/images/optimizer_test.exs @@ -0,0 +1,121 @@ +defmodule SimpleshopTheme.Images.OptimizerTest do + use SimpleshopTheme.DataCase, async: false + + alias SimpleshopTheme.Images.Optimizer + import SimpleshopTheme.ImageFixtures + + setup do + cleanup_cache() + on_exit(&cleanup_cache/0) + :ok + end + + describe "applicable_widths/1" do + test "returns all widths for large source" do + assert [400, 800, 1200] = Optimizer.applicable_widths(1500) + end + + test "excludes widths larger than source" do + assert [400, 800] = Optimizer.applicable_widths(900) + assert [400] = Optimizer.applicable_widths(500) + end + + test "returns source width when smaller than minimum" do + assert [300] = Optimizer.applicable_widths(300) + assert [50] = Optimizer.applicable_widths(50) + end + end + + describe "to_lossless_webp/1" do + test "converts image and returns dimensions" do + {:ok, webp, width, height} = Optimizer.to_lossless_webp(sample_jpeg()) + + assert is_binary(webp) + assert width == 1200 + assert height == 800 + # WebP magic bytes: RIFF....WEBP + assert <<"RIFF", _size::binary-size(4), "WEBP", _::binary>> = webp + end + + test "returns error for invalid data" do + assert {:error, _} = Optimizer.to_lossless_webp("not an image") + end + end + + describe "process_for_image/1" do + test "generates all variants for 1200px image" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + + assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id) + + for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do + assert File.exists?(cache_path(image.id, w, fmt)), + "Missing #{w}.#{fmt}" + end + + assert File.exists?(cache_path(image.id, "thumb", :jpg)) + end + + test "generates only applicable widths for smaller image" do + # Create fixture with smaller source width + {:ok, webp, _w, _h} = Optimizer.to_lossless_webp(sample_jpeg()) + + image = + %SimpleshopTheme.Media.Image{} + |> SimpleshopTheme.Media.Image.changeset(%{ + image_type: "product", + filename: "small.jpg", + content_type: "image/webp", + file_size: byte_size(webp), + data: webp, + source_width: 600, + source_height: 400, + variants_status: "pending", + is_svg: false + }) + |> Repo.insert!() + + assert {:ok, [400]} = Optimizer.process_for_image(image.id) + + assert File.exists?(cache_path(image.id, 400, :avif)) + refute File.exists?(cache_path(image.id, 800, :avif)) + end + + test "skips SVG images" do + image = svg_fixture() + assert {:ok, :svg_skipped} = Optimizer.process_for_image(image.id) + end + + test "returns error for missing image" do + assert {:error, :not_found} = Optimizer.process_for_image(Ecto.UUID.generate()) + end + + test "is idempotent - skips existing files" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + {:ok, _} = Optimizer.process_for_image(image.id) + + path = cache_path(image.id, 400, :avif) + {:ok, %{mtime: mtime1}} = File.stat(path) + + Process.sleep(1100) + {:ok, _} = Optimizer.process_for_image(image.id) + + {:ok, %{mtime: mtime2}} = File.stat(path) + assert mtime1 == mtime2, "File was regenerated" + end + end + + describe "disk_variants_exist?/2" do + test "returns true when all variants exist" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + {:ok, _} = Optimizer.process_for_image(image.id) + + assert Optimizer.disk_variants_exist?(image.id, 1200) + end + + test "returns false when variants missing" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + refute Optimizer.disk_variants_exist?(image.id, 1200) + end + end +end diff --git a/test/support/fixtures/image_fixtures.ex b/test/support/fixtures/image_fixtures.ex new file mode 100644 index 0000000..685c49d --- /dev/null +++ b/test/support/fixtures/image_fixtures.ex @@ -0,0 +1,73 @@ +defmodule SimpleshopTheme.ImageFixtures do + @moduledoc """ + Shared fixtures for image-related tests. + """ + + alias SimpleshopTheme.Repo + alias SimpleshopTheme.Media.Image + + @sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg") + + def sample_jpeg, do: @sample_jpeg + + def image_fixture(attrs \\ %{}) do + {:ok, webp, w, h} = SimpleshopTheme.Images.Optimizer.to_lossless_webp(@sample_jpeg) + + defaults = %{ + image_type: "product", + filename: "test.jpg", + content_type: "image/webp", + file_size: byte_size(webp), + data: webp, + source_width: w, + source_height: h, + variants_status: "pending", + is_svg: false + } + + %Image{} + |> Image.changeset(Map.merge(defaults, attrs)) + |> Repo.insert!() + end + + def svg_fixture do + svg = ~s() + + %Image{} + |> Image.changeset(%{ + image_type: "logo", + filename: "logo.svg", + content_type: "image/svg+xml", + file_size: byte_size(svg), + data: svg, + is_svg: true, + svg_content: svg, + variants_status: "pending" + }) + |> Repo.insert!() + end + + def cache_path(id, width, format) do + ext = + case format do + :jpg -> "jpg" + :webp -> "webp" + :avif -> "avif" + "thumb" -> "jpg" + _ -> to_string(format) + end + + filename = + case width do + "thumb" -> "#{id}-thumb.#{ext}" + _ -> "#{id}-#{width}.#{ext}" + end + + Path.join(SimpleshopTheme.Images.Optimizer.cache_dir(), filename) + end + + def cleanup_cache do + cache_dir = SimpleshopTheme.Images.Optimizer.cache_dir() + if File.exists?(cache_dir), do: File.rm_rf!(cache_dir) + end +end