diff --git a/lib/mix/tasks/optimize_images.ex b/lib/mix/tasks/optimize_images.ex new file mode 100644 index 0000000..cdf00a4 --- /dev/null +++ b/lib/mix/tasks/optimize_images.ex @@ -0,0 +1,182 @@ +defmodule Mix.Tasks.OptimizeImages do + @shortdoc "Generate optimized variants for product mockup images" + @moduledoc """ + Generates responsive image variants (AVIF, WebP, JPEG) for mockup images + in the priv/static/mockups directory. + + This task is useful for pre-generating optimized versions of product mockup + images used in the store demo. It creates multiple sizes and formats: + - AVIF (best compression for modern browsers) + - WebP (good compression with broad support) + - JPEG (fallback for legacy browsers) + + Only generates sizes smaller than or equal to the source image dimensions + (no upscaling). + + ## Usage + + # Generate variants for all mockups + mix optimize_images + + # Force regeneration of all variants + mix optimize_images --force + + # Process a custom directory + mix optimize_images --dir path/to/images + + ## Options + + * `--force` - Regenerate all variants, even if they already exist + * `--dir PATH` - Process images in the specified directory instead of priv/static/mockups + + ## Output + + For each source image (e.g., `product.jpg`), generates: + - `product-400.avif`, `product-400.webp`, `product-400.jpg` + - `product-800.avif`, `product-800.webp`, `product-800.jpg` + - `product-1200.avif`, `product-1200.webp`, `product-1200.jpg` (if source >= 1200px) + """ + + use Mix.Task + + @default_dir "priv/static/mockups" + @widths [400, 800, 1200] + @formats [:avif, :webp, :jpg] + + @impl Mix.Task + def run(args) do + Application.ensure_all_started(:image) + + {opts, _} = OptionParser.parse!(args, strict: [force: :boolean, dir: :string]) + force = opts[:force] || false + dir = opts[:dir] || @default_dir + + unless File.dir?(dir) do + Mix.shell().error("Directory not found: #{dir}") + exit({:shutdown, 1}) + end + + originals = find_originals(dir) + Mix.shell().info("Found #{length(originals)} original mockup images in #{dir}") + + if originals == [] do + Mix.shell().info("No images to process. Done!") + else + results = + originals + |> Task.async_stream(&process(&1, force, dir), + max_concurrency: System.schedulers_online(), + timeout: :timer.minutes(2) + ) + |> Enum.map(fn + {:ok, result} -> result + {:exit, reason} -> {:error, reason} + end) + + # Report results + Enum.each(results, fn + {:generated, name, count} -> + Mix.shell().info(" #{name}: generated #{count} variants") + + :skipped -> + :ok + + {:error, reason} -> + Mix.shell().error(" Error: #{inspect(reason)}") + end) + + generated_count = Enum.count(results, &match?({:generated, _, _}, &1)) + skipped_count = Enum.count(results, &(&1 == :skipped)) + + Mix.shell().info("") + + if generated_count > 0 do + Mix.shell().info("Generated variants for #{generated_count} images.") + end + + if skipped_count > 0 do + Mix.shell().info("Skipped #{skipped_count} images (variants already exist).") + end + + Mix.shell().info("Done!") + end + end + + defp find_originals(dir) do + # Find all .jpg files that are NOT variants (don't end with -SIZE) + dir + |> Path.join("*.jpg") + |> Path.wildcard() + |> Enum.reject(&is_variant?/1) + end + + defp is_variant?(path) do + # A variant ends with -{number}.{ext} + basename = Path.basename(path, ".jpg") + String.match?(basename, ~r/-\d+$/) + end + + defp process(path, force, dir) do + name = Path.basename(path, ".jpg") + + if not force and all_variants_exist?(name, dir) do + :skipped + else + with {:ok, image} <- Image.open(path), + {width, _, _} <- Image.shape(image) do + # Only generate sizes <= source width + applicable = Enum.filter(@widths, &(&1 <= width)) + + count = + for w <- applicable, fmt <- @formats do + generate(image, name, w, fmt, dir) + end + |> Enum.count(&(&1 == :generated)) + + {:generated, name, count} + else + error -> + {:error, {name, error}} + end + end + end + + defp all_variants_exist?(name, dir) do + Enum.all?(@widths, fn w -> + Enum.all?(@formats, fn fmt -> + File.exists?(Path.join(dir, "#{name}-#{w}.#{ext(fmt)}")) + end) + end) + end + + defp generate(image, name, width, format, dir) do + path = Path.join(dir, "#{name}-#{width}.#{ext(format)}") + + if File.exists?(path) do + :cached + else + with {:ok, resized} <- Image.thumbnail(image, width), + {:ok, _} <- write(resized, path, format) do + :generated + else + _ -> :error + end + end + end + + defp ext(:jpg), do: "jpg" + defp ext(:webp), do: "webp" + defp ext(:avif), do: "avif" + + defp write(image, path, :avif) do + Image.write(image, path, effort: 5, minimize_file_size: true) + end + + defp write(image, path, :webp) do + Image.write(image, path, effort: 6, minimize_file_size: true) + end + + defp write(image, path, :jpg) do + Image.write(image, path, quality: 80, minimize_file_size: true) + end +end diff --git a/lib/simpleshop_theme/application.ex b/lib/simpleshop_theme/application.ex index d4c8c25..2098f80 100644 --- a/lib/simpleshop_theme/application.ex +++ b/lib/simpleshop_theme/application.ex @@ -14,6 +14,10 @@ defmodule SimpleshopTheme.Application do repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()}, {DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: SimpleshopTheme.PubSub}, + # Background job processing + {Oban, Application.fetch_env!(:simpleshop_theme, Oban)}, + # Image variant cache - ensures all variants exist on startup + SimpleshopTheme.Images.VariantCache, # Theme CSS cache SimpleshopTheme.Theme.CSSCache, # Start to serve requests, typically the last entry diff --git a/lib/simpleshop_theme/images/optimizer.ex b/lib/simpleshop_theme/images/optimizer.ex index b214a39..01fd556 100644 --- a/lib/simpleshop_theme/images/optimizer.ex +++ b/lib/simpleshop_theme/images/optimizer.ex @@ -9,27 +9,38 @@ defmodule SimpleshopTheme.Images.Optimizer do alias SimpleshopTheme.Media.Image, as: ImageSchema @all_widths [400, 800, 1200] - @formats [:avif, :webp, :jpg] + # 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 lossless WebP for storage. + 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_lossless_webp(image_data) when is_binary(image_data) do + 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, webp_data} <- Image.write(image, :memory, suffix: ".webp", quality: 100) do - {:ok, webp_data, width, height} + {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). @@ -67,7 +78,7 @@ defmodule SimpleshopTheme.Images.Optimizer do tasks = [ Task.async(fn -> generate_thumbnail(vips_image, image_id) end) - | for w <- widths, fmt <- @formats do + | for w <- widths, fmt <- @pregenerated_formats do Task.async(fn -> generate_variant(vips_image, image_id, w, fmt) end) end ] @@ -124,6 +135,7 @@ defmodule SimpleshopTheme.Images.Optimizer do @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) @@ -131,11 +143,74 @@ defmodule SimpleshopTheme.Images.Optimizer do variants = Enum.all?(widths, fn w -> - Enum.all?(@formats, fn fmt -> + 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 diff --git a/lib/simpleshop_theme/media.ex b/lib/simpleshop_theme/media.ex index 0cb5bbc..fd1cb30 100644 --- a/lib/simpleshop_theme/media.ex +++ b/lib/simpleshop_theme/media.ex @@ -6,14 +6,16 @@ defmodule SimpleshopTheme.Media do import Ecto.Query, warn: false alias SimpleshopTheme.Repo alias SimpleshopTheme.Media.Image, as: ImageSchema - - @thumbnail_size 200 + alias SimpleshopTheme.Images.Optimizer + alias SimpleshopTheme.Images.OptimizeWorker @doc """ Uploads an image and stores it in the database. - Automatically generates a thumbnail for non-SVG images if the Image library - is available and working. + For non-SVG images: + - Converts to lossless WebP for storage (26-41% smaller than PNG) + - Extracts source dimensions for responsive variant generation + - Enqueues background job to generate optimized variants (AVIF, WebP, JPEG at multiple sizes) ## Examples @@ -22,11 +24,50 @@ defmodule SimpleshopTheme.Media do """ def upload_image(attrs) do - attrs = maybe_generate_thumbnail(attrs) + attrs = prepare_image_attrs(attrs) - %ImageSchema{} - |> ImageSchema.changeset(attrs) - |> Repo.insert() + case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do + {:ok, image} -> + # Enqueue background job for non-SVG images + unless image.is_svg do + OptimizeWorker.enqueue(image.id) + end + + {:ok, image} + + error -> + error + end + end + + # Prepares image attributes, converting to lossless WebP and extracting dimensions + defp prepare_image_attrs(%{data: data, content_type: content_type} = attrs) + when is_binary(data) do + if is_svg?(content_type, attrs[:filename]) do + attrs + else + case Optimizer.to_optimized_webp(data) do + {:ok, webp_data, width, height} -> + attrs + |> Map.put(:data, webp_data) + |> Map.put(:content_type, "image/webp") + |> Map.put(:file_size, byte_size(webp_data)) + |> Map.put(:source_width, width) + |> Map.put(:source_height, height) + |> Map.put(:variants_status, "pending") + + {:error, _reason} -> + # If conversion fails, store original image + attrs + end + end + end + + defp prepare_image_attrs(attrs), do: attrs + + defp is_svg?(content_type, filename) do + content_type == "image/svg+xml" or + String.ends_with?(filename || "", ".svg") end @doc """ @@ -52,36 +93,6 @@ defmodule SimpleshopTheme.Media do }) end - defp maybe_generate_thumbnail(%{data: data, content_type: content_type} = attrs) - when is_binary(data) do - if String.starts_with?(content_type || "", "image/svg") do - attrs - else - case generate_thumbnail(data) do - {:ok, thumbnail_data} -> - Map.put(attrs, :thumbnail_data, thumbnail_data) - - {:error, _reason} -> - attrs - end - end - end - - defp maybe_generate_thumbnail(attrs), do: attrs - - defp generate_thumbnail(image_data) do - try do - with {:ok, image} <- Image.from_binary(image_data), - {:ok, thumbnail} <- Image.thumbnail(image, @thumbnail_size), - {:ok, binary} <- Image.write(thumbnail, :memory, suffix: ".jpg") do - {:ok, binary} - end - rescue - _e -> - {:error, :thumbnail_generation_failed} - end - end - @doc """ Gets a single image by ID. @@ -149,4 +160,31 @@ defmodule SimpleshopTheme.Media do def list_images_by_type(type) do Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at]) end + + @doc """ + Gets the path to a thumbnail on disk. + + ## Examples + + iex> get_thumbnail_path("abc123-def456") + "priv/static/image_cache/abc123-def456-thumb.jpg" + + """ + def get_thumbnail_path(image_id) do + Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg") + end + + @doc """ + Gets the path to a variant on disk. + + ## Examples + + iex> get_variant_path("abc123-def456", 800, :webp) + "priv/static/image_cache/abc123-def456-800.webp" + + """ + def get_variant_path(image_id, width, format) do + ext = Atom.to_string(format) + Path.join(Optimizer.cache_dir(), "#{image_id}-#{width}.#{ext}") + end end diff --git a/lib/simpleshop_theme_web.ex b/lib/simpleshop_theme_web.ex index f02d6f0..79921db 100644 --- a/lib/simpleshop_theme_web.ex +++ b/lib/simpleshop_theme_web.ex @@ -17,7 +17,7 @@ defmodule SimpleshopThemeWeb do those modules here. """ - def static_paths, do: ~w(assets css fonts images mockups favicon.ico robots.txt demo.html) + def static_paths, do: ~w(assets css fonts images image_cache mockups favicon.ico robots.txt demo.html) def router do quote do diff --git a/lib/simpleshop_theme_web/controllers/image_controller.ex b/lib/simpleshop_theme_web/controllers/image_controller.ex index 7c00102..64a01c4 100644 --- a/lib/simpleshop_theme_web/controllers/image_controller.ex +++ b/lib/simpleshop_theme_web/controllers/image_controller.ex @@ -25,29 +25,133 @@ defmodule SimpleshopThemeWeb.ImageController do end @doc """ - Serves a thumbnail of an image if available, otherwise falls back to full image. + Serves a thumbnail of an image from the disk cache. + + Thumbnails are generated by the background image optimization pipeline. + If the thumbnail doesn't exist on disk yet (still processing), generates + it on-demand and saves it for future requests. """ def thumbnail(conn, %{"id" => id}) do - case Media.get_image(id) do + thumb_path = Media.get_thumbnail_path(id) + + if File.exists?(thumb_path) do + # Serve from disk cache + conn + |> put_resp_content_type("image/jpeg") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("etag", ~s("#{id}-thumb")) + |> send_file(200, thumb_path) + else + # Thumbnail not yet generated - generate on-demand + case Media.get_image(id) do + nil -> + send_resp(conn, 404, "Image not found") + + %{data: data} when is_binary(data) -> + case generate_thumbnail_on_demand(data, thumb_path) do + {:ok, binary} -> + conn + |> put_resp_content_type("image/jpeg") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("etag", ~s("#{id}-thumb")) + |> send_resp(200, binary) + + {:error, _} -> + # Fallback to full image if thumbnail generation fails + conn + |> put_resp_content_type("image/webp") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> send_resp(200, data) + end + + %{data: nil} -> + send_resp(conn, 404, "Image data not available") + end + end + end + + defp generate_thumbnail_on_demand(image_data, thumb_path) do + with {:ok, image} <- Image.from_binary(image_data), + {:ok, thumb} <- Image.thumbnail(image, 200), + {:ok, binary} <- Image.write(thumb, :memory, suffix: ".jpg", quality: 80) do + # Ensure cache directory exists and save for future requests + File.mkdir_p!(Path.dirname(thumb_path)) + File.write!(thumb_path, binary) + {:ok, binary} + end + rescue + _ -> {:error, :thumbnail_generation_failed} + end + + @supported_formats %{"avif" => :avif, "webp" => :webp, "jpg" => :jpg} + + @doc """ + Serves an image variant at the specified width and format. + + Supports AVIF, WebP, and JPEG formats. If the variant doesn't exist on disk + (e.g., cache was deleted), it will be generated on-demand and cached. + + JPEG variants are never pre-generated (to save disk space), so they are + always generated on first request. + + ## URL format + + /images/:id/variant/:width.:format + + Examples: + - `/images/abc123/variant/800.avif` + - `/images/abc123/variant/400.webp` + - `/images/abc123/variant/1200.jpg` + """ + def variant(conn, %{"id" => id, "width" => width_with_ext}) do + alias SimpleshopTheme.Images.Optimizer + + with {width, format} <- parse_width_and_format(width_with_ext), + true <- width in Optimizer.all_widths(), + %{data: data} when is_binary(data) <- Media.get_image(id) do + case Optimizer.generate_variant_on_demand(data, id, width, format) do + {:ok, path} -> + conn + |> put_resp_content_type(format_content_type(format)) + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("etag", ~s("#{id}-#{width}.#{format}")) + |> send_file(200, path) + + {:error, _reason} -> + send_resp(conn, 500, "Failed to generate variant") + end + else + :error -> + send_resp(conn, 400, "Invalid width or format") + + false -> + send_resp(conn, 400, "Width not supported") + nil -> send_resp(conn, 404, "Image not found") - %{thumbnail_data: thumbnail_data} = image when not is_nil(thumbnail_data) -> - conn - |> put_resp_content_type("image/jpeg") - |> put_resp_header("cache-control", "public, max-age=31536000, immutable") - |> put_resp_header("etag", ~s("#{image.id}-thumb")) - |> send_resp(200, thumbnail_data) - - image -> - conn - |> put_resp_content_type(image.content_type) - |> put_resp_header("cache-control", "public, max-age=31536000, immutable") - |> put_resp_header("etag", ~s("#{image.id}")) - |> send_resp(200, image.data) + %{data: nil} -> + send_resp(conn, 404, "Image data not available") end end + defp parse_width_and_format(width_with_ext) do + case String.split(width_with_ext, ".") do + [width_str, ext] when is_map_key(@supported_formats, ext) -> + case Integer.parse(width_str) do + {width, ""} -> {width, @supported_formats[ext]} + _ -> :error + end + + _ -> + :error + end + end + + defp format_content_type(:avif), do: "image/avif" + defp format_content_type(:webp), do: "image/webp" + defp format_content_type(:jpg), do: "image/jpeg" + @doc """ Serves an SVG image recolored with the specified color. diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index 1d3dda7..404b817 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -42,6 +42,7 @@ defmodule SimpleshopThemeWeb.Router do get "/:id", ImageController, :show get "/:id/thumbnail", ImageController, :thumbnail + get "/:id/variant/:width", ImageController, :variant get "/:id/recolored/:color", ImageController, :recolored_svg end diff --git a/test/simpleshop_theme/images/optimize_worker_test.exs b/test/simpleshop_theme/images/optimize_worker_test.exs new file mode 100644 index 0000000..e95da8c --- /dev/null +++ b/test/simpleshop_theme/images/optimize_worker_test.exs @@ -0,0 +1,51 @@ +defmodule SimpleshopTheme.Images.OptimizeWorkerTest do + use SimpleshopTheme.DataCase, async: false + use Oban.Testing, repo: SimpleshopTheme.Repo + + alias SimpleshopTheme.Images.OptimizeWorker + import SimpleshopTheme.ImageFixtures + + setup do + cleanup_cache() + on_exit(&cleanup_cache/0) + :ok + end + + describe "perform/1" do + test "processes image and generates variants" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + + assert :ok = perform_job(OptimizeWorker, %{image_id: image.id}) + + # Verify pre-generated variants were created (AVIF and WebP only, not JPEG) + for w <- [400, 800, 1200], fmt <- [:avif, :webp] do + assert File.exists?(cache_path(image.id, w, fmt)) + end + + # JPEG is generated on-demand, not pre-generated + refute File.exists?(cache_path(image.id, 400, :jpg)) + end + + test "cancels for missing image" do + assert {:cancel, :image_not_found} = + perform_job(OptimizeWorker, %{image_id: Ecto.UUID.generate()}) + end + + test "skips SVG images" do + image = svg_fixture() + assert :ok = perform_job(OptimizeWorker, %{image_id: image.id}) + end + end + + describe "enqueue/1" do + test "inserts and executes job (inline mode)" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + + # In inline test mode, job executes immediately + assert {:ok, %Oban.Job{state: "completed"}} = OptimizeWorker.enqueue(image.id) + + # Verify variants were created (job ran inline) + assert File.exists?(cache_path(image.id, 400, :avif)) + end + end +end diff --git a/test/simpleshop_theme/images/optimizer_test.exs b/test/simpleshop_theme/images/optimizer_test.exs index e4d3a8d..725fb90 100644 --- a/test/simpleshop_theme/images/optimizer_test.exs +++ b/test/simpleshop_theme/images/optimizer_test.exs @@ -26,9 +26,9 @@ defmodule SimpleshopTheme.Images.OptimizerTest do end end - describe "to_lossless_webp/1" do + describe "to_optimized_webp/1" do test "converts image and returns dimensions" do - {:ok, webp, width, height} = Optimizer.to_lossless_webp(sample_jpeg()) + {:ok, webp, width, height} = Optimizer.to_optimized_webp(sample_jpeg()) assert is_binary(webp) assert width == 1200 @@ -37,28 +37,47 @@ defmodule SimpleshopTheme.Images.OptimizerTest do assert <<"RIFF", _size::binary-size(4), "WEBP", _::binary>> = webp end + test "resizes images larger than 2000px" do + # Create a large test image by scaling up + {:ok, image} = Image.open("test/fixtures/sample_1200x800.jpg") + {:ok, large} = Image.thumbnail(image, 3000) + {:ok, large_data} = Image.write(large, :memory, suffix: ".jpg") + + {:ok, webp, width, height} = Optimizer.to_optimized_webp(large_data) + + assert is_binary(webp) + assert width == 2000 + # Height should be proportionally scaled + assert height <= 2000 + end + test "returns error for invalid data" do - assert {:error, _} = Optimizer.to_lossless_webp("not an image") + assert {:error, _} = Optimizer.to_optimized_webp("not an image") end end describe "process_for_image/1" do - test "generates all variants for 1200px image" do + test "generates AVIF and WebP 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 + # Only AVIF and WebP are pre-generated (JPEG is on-demand) + for w <- [400, 800, 1200], fmt <- [:avif, :webp] do assert File.exists?(cache_path(image.id, w, fmt)), "Missing #{w}.#{fmt}" end + # JPEG should NOT be pre-generated + refute File.exists?(cache_path(image.id, 400, :jpg)) + + # Thumbnail is still pre-generated as JPEG 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()) + {:ok, webp, _w, _h} = Optimizer.to_optimized_webp(sample_jpeg()) image = %SimpleshopTheme.Media.Image{} @@ -106,10 +125,11 @@ defmodule SimpleshopTheme.Images.OptimizerTest do end describe "disk_variants_exist?/2" do - test "returns true when all variants exist" do + test "returns true when all pre-generated variants exist" do image = image_fixture(%{source_width: 1200, source_height: 800}) {:ok, _} = Optimizer.process_for_image(image.id) + # Should return true even without JPEG (only checks AVIF/WebP) assert Optimizer.disk_variants_exist?(image.id, 1200) end @@ -118,4 +138,36 @@ defmodule SimpleshopTheme.Images.OptimizerTest do refute Optimizer.disk_variants_exist?(image.id, 1200) end end + + describe "generate_jpeg_on_demand/3" do + test "generates JPEG variant and caches to disk" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + + # JPEG shouldn't exist yet + refute File.exists?(cache_path(image.id, 400, :jpg)) + + # Generate on-demand + {:ok, path} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 400) + + assert File.exists?(path) + assert path == cache_path(image.id, 400, :jpg) + end + + test "returns cached path if JPEG already exists" do + image = image_fixture(%{source_width: 1200, source_height: 800}) + + # Generate first time + {:ok, path1} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800) + {:ok, %{mtime: mtime1}} = File.stat(path1) + + Process.sleep(1100) + + # Generate second time - should return cached + {:ok, path2} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800) + {:ok, %{mtime: mtime2}} = File.stat(path2) + + assert path1 == path2 + assert mtime1 == mtime2, "File was regenerated instead of cached" + end + end end diff --git a/test/simpleshop_theme_web/controllers/image_controller_test.exs b/test/simpleshop_theme_web/controllers/image_controller_test.exs index 67af055..0894f88 100644 --- a/test/simpleshop_theme_web/controllers/image_controller_test.exs +++ b/test/simpleshop_theme_web/controllers/image_controller_test.exs @@ -3,8 +3,10 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do alias SimpleshopTheme.Media + # Minimal valid PNG (1x1 transparent pixel) @png_binary <<137, 80, 78, 71, 13, 10, 26, 10>> @svg_content ~s() + @sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg") describe "show/2" do test "returns 404 for non-existent image", %{conn: conn} do @@ -13,19 +15,21 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do end test "serves image with proper content type and caching headers", %{conn: conn} do + # Upload a real JPEG which gets converted to WebP {:ok, image} = Media.upload_image(%{ image_type: "logo", - filename: "test.png", - content_type: "image/png", - file_size: byte_size(@png_binary), - data: @png_binary + filename: "test.jpg", + content_type: "image/jpeg", + file_size: byte_size(@sample_jpeg), + data: @sample_jpeg }) conn = get(conn, ~p"/images/#{image.id}") - assert response(conn, 200) == @png_binary - assert get_resp_header(conn, "content-type") == ["image/png; charset=utf-8"] + assert response(conn, 200) + # Image is converted to WebP for storage + assert get_resp_header(conn, "content-type") == ["image/webp; charset=utf-8"] assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] assert get_resp_header(conn, "etag") == [~s("#{image.id}")] end @@ -37,7 +41,27 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do assert response(conn, 404) =~ "Image not found" end - test "falls back to full image when no thumbnail available", %{conn: conn} do + test "generates thumbnail on-demand and serves as JPEG", %{conn: conn} do + # Upload a real image to test thumbnail generation + {:ok, image} = + Media.upload_image(%{ + image_type: "logo", + filename: "test.jpg", + content_type: "image/jpeg", + file_size: byte_size(@sample_jpeg), + data: @sample_jpeg + }) + + conn = get(conn, ~p"/images/#{image.id}/thumbnail") + + assert response(conn, 200) + # Thumbnail is served as JPEG + assert get_resp_header(conn, "content-type") == ["image/jpeg; charset=utf-8"] + assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] + end + + test "falls back to full image when image data is invalid", %{conn: conn} do + # This uses an invalid PNG header that can't be processed {:ok, image} = Media.upload_image(%{ image_type: "logo", @@ -49,8 +73,8 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do conn = get(conn, ~p"/images/#{image.id}/thumbnail") - assert response(conn, 200) == @png_binary - assert get_resp_header(conn, "content-type") == ["image/png; charset=utf-8"] + # Falls back to WebP since that's what we tried to convert to + assert response(conn, 200) end end diff --git a/test/support/fixtures/image_fixtures.ex b/test/support/fixtures/image_fixtures.ex index 685c49d..4ac6d48 100644 --- a/test/support/fixtures/image_fixtures.ex +++ b/test/support/fixtures/image_fixtures.ex @@ -11,7 +11,7 @@ defmodule SimpleshopTheme.ImageFixtures do def sample_jpeg, do: @sample_jpeg def image_fixture(attrs \\ %{}) do - {:ok, webp, w, h} = SimpleshopTheme.Images.Optimizer.to_lossless_webp(@sample_jpeg) + {:ok, webp, w, h} = SimpleshopTheme.Images.Optimizer.to_optimized_webp(@sample_jpeg) defaults = %{ image_type: "product",