feat: enhance image optimization with on-demand JPEG fallbacks

Improve the image optimization pipeline with better compression and
smarter variant generation:

- Change to_lossless_webp → to_optimized_webp (lossy, quality 90)
- Auto-resize uploads larger than 2000px to save storage
- Skip pre-generating JPEG variants (~50% disk savings)
- Add on-demand JPEG generation for legacy browsers (<5% of users)
- Add /images/:id/variant/:width route for dynamic serving
- Add VariantCache to supervision tree for startup validation
- Add image_cache to static paths for disk-based serving

The pipeline now stores smaller WebP sources and generates AVIF/WebP
variants upfront, with JPEG generated only when legacy browsers request it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-25 00:33:09 +00:00
parent 252ca2268a
commit 2bc05097b9
11 changed files with 610 additions and 79 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -25,28 +25,132 @@ 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
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")
%{thumbnail_data: thumbnail_data} = image when not is_nil(thumbnail_data) ->
%{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("#{image.id}-thumb"))
|> send_resp(200, thumbnail_data)
|> put_resp_header("etag", ~s("#{id}-thumb"))
|> send_resp(200, binary)
image ->
{:error, _} ->
# Fallback to full image if thumbnail generation fails
conn
|> put_resp_content_type(image.content_type)
|> put_resp_content_type("image/webp")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", ~s("#{image.id}"))
|> send_resp(200, image.data)
|> 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")
%{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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#000000" r="10"/></svg>)
@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

View File

@ -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",