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:
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,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.

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