Detailed implementation plan for automatic image optimization: - Lossless WebP storage in SQLite (26-41% smaller than PNG) - AVIF/WebP/JPEG responsive variants generated to disk cache - Oban for durable async processing with SQLite - 10-phase incremental implementation with checkpoints - Comprehensive test coverage strategy Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
42 KiB
Plan: Automatic Image Optimization Pipeline
Location: docs/plans/image-optimization.md
Purpose: Track implementation progress - update this file after each phase completes.
Progress Tracker
Current Phase: Not started Last Updated: 2026-01-21
| Phase | Status | Commit |
|---|---|---|
| 1. Oban dependency + config | ⬜ Pending | - |
| 2. Migration + Schema | ⬜ Pending | - |
| 3. Optimizer module | ⬜ Pending | - |
| 4. Oban worker | ⬜ Pending | - |
| 5. Media module integration | ⬜ Pending | - |
| 6. VariantCache GenServer | ⬜ Pending | - |
| 7. Responsive image component | ⬜ Pending | - |
| 8. ImageController disk serving | ⬜ Pending | - |
| 9. Mix task for mockups | ⬜ Pending | - |
| 10. Final integration + Lighthouse | ⬜ Pending | - |
Legend: ⬜ Pending | 🔄 In Progress | ✅ Complete | ❌ Blocked
Overview
Build a reusable image processing pipeline that generates optimized, responsive image variants for all uploaded content (logos, header backgrounds) and product mockups.
Key architectural principles:
- Original images stored in SQLite as lossless WebP blobs (26-41% smaller than PNG)
- All derived formats (responsive variants, thumbnails) generated to disk cache
- Disk cache is regenerable - can be deleted and recreated from DB
- Only generate sizes ≤ source dimensions (no upscaling)
- Available widths computed dynamically from
source_width(not stored)
Architecture
Database Schema (Source of Truth)
# images table - ONLY stores originals
field :data, :binary # Original image as lossless WebP
field :source_width, :integer # Original dimensions
field :source_height, :integer
field :variants_status, Ecto.Enum, values: [:pending, :processing, :complete, :failed]
# REMOVED: available_widths - computed from source_width
# REMOVED: thumbnail_data - derived, stored on disk
Why Lossless WebP for Storage?
| Format | Lossless Size vs PNG | Encoding Speed |
|---|---|---|
| PNG | Baseline | Fast |
| WebP | 26-41% smaller | Fast |
| AVIF | ~20% smaller | Very slow |
WebP lossless is the optimal choice: best compression for photos, fast encoding.
Disk Cache (Derived, Regenerable)
priv/static/image_cache/ # Gitignored, regenerated on startup
├── {uuid}-thumb.jpg # 200px thumbnail for admin UI
├── {uuid}-400.avif
├── {uuid}-400.webp
├── {uuid}-400.jpg
├── {uuid}-800.avif
├── {uuid}-800.webp
├── {uuid}-800.jpg
├── {uuid}-1200.avif # Only if source >= 1200px
├── {uuid}-1200.webp
└── {uuid}-1200.jpg
Size Selection Logic
Only generate widths ≤ source dimensions (no upscaling):
@all_widths [400, 800, 1200]
def applicable_widths(source_width) do
@all_widths
|> Enum.filter(&(&1 <= source_width))
|> case do
[] -> [source_width] # Source smaller than 400px
widths -> widths
end
end
Disk Space Analysis
Per-image storage breakdown (1200px source):
| Format | 400w | 800w | 1200w | Subtotal |
|---|---|---|---|---|
| AVIF | ~15KB | ~40KB | ~70KB | ~125KB |
| WebP | ~25KB | ~60KB | ~100KB | ~185KB |
| JPEG | ~40KB | ~100KB | ~180KB | ~320KB |
| Thumbnail | - | - | - | ~10KB |
| Total | ~640KB |
At scale:
- 1 shop × 100 products = ~64MB
- 10,000 shops × 100 products = ~640GB
Alternative: Skip pre-generating JPEG (on-demand only)
- JPEG is ~50% of total storage
- Savings: 320GB at 10K shops scale
- Trade-off: First JPEG request takes ~100ms to generate
- Only affects <5% of users (ancient browsers)
Recommendation: Pre-generate all formats because:
- Consistent latency for all users
- 640GB is ~$15-30/month cloud storage
- Simpler architecture (no on-demand generation)
- Can add JPEG pruning later if needed
Processing Strategy: Oban with Aggressive Pruning
Using Oban for durable job processing with SQLite. Jobs are pruned aggressively to prevent database bloat.
Why Oban over Synchronous?
| Aspect | Synchronous | Oban |
|---|---|---|
| User experience | Waits 1-2s | Instant response |
| Crash recovery | Startup retry | Automatic retry |
| Visibility | Logs only | Status tracking |
| Complexity | Simple | Moderate |
For a production-quality system, Oban's durability is worth the small complexity cost.
Oban Configuration
# config/config.exs
config :simpleshop_theme, Oban,
engine: Oban.Engines.Lite, # SQLite support
repo: SimpleshopTheme.Repo,
plugins: [
# Prune completed jobs after 60 seconds - keeps DB lean
{Oban.Plugins.Pruner, max_age: 60}
],
queues: [images: 2]
With max_age: 60, the Oban tables typically contain:
- 0-10 active jobs during processing
- Failed jobs kept for debugging (can add separate max_age)
- Completed jobs deleted within 1 minute
Job Worker
defmodule SimpleshopTheme.Workers.ImageVariants do
use Oban.Worker, queue: :images, max_attempts: 3
@impl Oban.Worker
def perform(%Oban.Job{args: %{"image_id" => image_id}}) do
case Images.Optimizer.process_for_image(image_id) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
Upload Flow
def upload_image(attrs) do
# Convert to lossless WebP before storing
attrs = convert_to_lossless_webp(attrs)
case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
{:ok, image} ->
# Enqueue async variant generation
%{image_id: image.id}
|> SimpleshopTheme.Workers.ImageVariants.new()
|> Oban.insert()
{:ok, image}
error ->
error
end
end
Startup Recovery
Still needed for cache deletion scenarios:
# In VariantCache GenServer init
defp ensure_all_variants do
# Find images with missing disk variants
ImageSchema
|> where([i], i.variants_status == :complete)
|> where([i], i.is_svg == false)
|> Repo.all()
|> Enum.reject(&disk_variants_exist?/1)
|> Enum.each(fn image ->
# Re-enqueue for processing
%{image_id: image.id}
|> SimpleshopTheme.Workers.ImageVariants.new()
|> Oban.insert()
end)
end
Implementation Steps
Step 1: Add Oban Dependency
File: mix.exs
defp deps do
[
# ... existing deps
{:oban, "~> 2.18"}
]
end
Step 2: Migration - Update Image Schema
File: priv/repo/migrations/YYYYMMDDHHMMSS_add_image_metadata.exs
defmodule SimpleshopTheme.Repo.Migrations.AddImageMetadata do
use Ecto.Migration
def change do
alter table(:images) do
add :source_width, :integer
add :source_height, :integer
add :variants_status, :string, default: "pending"
# Remove thumbnail_data - now derived to disk
remove :thumbnail_data
end
# Oban tables for job queue
Oban.Migration.up(version: 12)
end
end
Step 3: Image Optimizer Module
File: lib/simpleshop_theme/images/optimizer.ex
defmodule SimpleshopTheme.Images.Optimizer do
@moduledoc """
Generates optimized image variants. Only creates sizes ≤ source dimensions.
"""
require Logger
@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.
"""
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
end
@doc """
Compute applicable widths from source dimensions.
"""
def applicable_widths(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
alias SimpleshopTheme.{Repo, Media.Image}
case Repo.get(Image, image_id) do
nil ->
{:error, :not_found}
%{data: nil} ->
{:error, :no_data}
%{is_svg: true} = image ->
# SVGs don't need variants
Repo.update!(Image.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)
# Generate thumbnail + variants in parallel
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))
# Mark complete
Repo.update!(Image.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
Step 4: Oban Worker
File: lib/simpleshop_theme/workers/image_variants.ex
defmodule SimpleshopTheme.Workers.ImageVariants do
use Oban.Worker,
queue: :images,
max_attempts: 3,
unique: [period: 60] # Prevent duplicate jobs
alias SimpleshopTheme.Images.Optimizer
@impl Oban.Worker
def perform(%Oban.Job{args: %{"image_id" => image_id}}) do
case Optimizer.process_for_image(image_id) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
Step 5: Update Media Module
File: lib/simpleshop_theme/media.ex
alias SimpleshopTheme.Images.Optimizer
alias SimpleshopTheme.Workers.ImageVariants
def upload_image(attrs) do
# Convert to lossless WebP before storing
with {:ok, webp_data, width, height} <- Optimizer.to_lossless_webp(attrs.data) do
attrs =
attrs
|> Map.put(:data, webp_data)
|> Map.put(:source_width, width)
|> Map.put(:source_height, height)
|> Map.put(:variants_status, :pending)
case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
{:ok, image} ->
# Enqueue async variant generation
%{image_id: image.id}
|> ImageVariants.new()
|> Oban.insert()
{:ok, image}
error ->
error
end
end
end
# Thumbnail served from disk cache
def get_thumbnail_path(image_id) do
Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg")
end
Step 6: Startup Recovery GenServer
File: lib/simpleshop_theme/images/variant_cache.ex
defmodule SimpleshopTheme.Images.VariantCache do
@moduledoc """
Ensures all image variants exist on startup.
Enqueues Oban jobs for any missing variants.
"""
use GenServer
require Logger
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Media.Image, as: ImageSchema
alias SimpleshopTheme.Images.Optimizer
alias SimpleshopTheme.Workers.ImageVariants
import Ecto.Query
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@impl true
def init(_) do
# Run async to not block startup
Task.start(fn -> ensure_all_variants() end)
{:ok, %{}}
end
defp ensure_all_variants do
Logger.info("Checking image variant cache...")
File.mkdir_p!(Optimizer.cache_dir())
# 1. Find incomplete images (status != complete)
incomplete =
ImageSchema
|> where([i], i.variants_status != :complete or is_nil(i.variants_status))
|> where([i], i.is_svg == false)
|> Repo.all()
# 2. Find complete images with missing disk files (cache was deleted)
complete_missing =
ImageSchema
|> where([i], i.variants_status == :complete)
|> where([i], i.is_svg == false)
|> Repo.all()
|> Enum.reject(fn img ->
Optimizer.disk_variants_exist?(img.id, img.source_width)
end)
to_process = incomplete ++ complete_missing
if to_process == [] do
Logger.info("Image cache: all variants up to date")
else
Logger.info("Image cache: enqueueing #{length(to_process)} images for processing")
Enum.each(to_process, fn image ->
# Reset status and enqueue
Repo.update!(ImageSchema.changeset(image, %{variants_status: :pending}))
%{image_id: image.id}
|> ImageVariants.new()
|> Oban.insert()
end)
end
end
end
Step 7: Responsive Image Component
File: lib/simpleshop_theme_web/components/shop_components.ex
@doc """
Renders a responsive <picture> element with AVIF, WebP, and JPEG sources.
Computes available widths from source_width (no separate storage needed).
## Examples
<.responsive_image
src="/image_cache/abc123"
source_width={1200}
alt="Product image"
priority={true}
/>
"""
attr :src, :string, required: true, doc: "Base path without size/extension"
attr :alt, :string, required: true
attr :source_width, :integer, required: true, doc: "Original image width"
attr :sizes, :string, default: "100vw"
attr :class, :string, default: ""
attr :width, :integer, default: nil
attr :height, :integer, default: nil
attr :priority, :boolean, default: false
def responsive_image(assigns) do
alias SimpleshopTheme.Images.Optimizer
# Compute available widths from source dimensions
available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available)
assigns =
assigns
|> assign(:available_widths, available)
|> assign(:default_width, default_width)
~H"""
<picture>
<source
type="image/avif"
srcset={build_srcset(@src, @available_widths, "avif")}
sizes={@sizes}
/>
<source
type="image/webp"
srcset={build_srcset(@src, @available_widths, "webp")}
sizes={@sizes}
/>
<img
src={"#{@src}-#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes}
alt={@alt}
width={@width}
height={@height}
loading={if @priority, do: "eager", else: "lazy"}
decoding={if @priority, do: "sync", else: "async"}
fetchpriority={if @priority, do: "high", else: nil}
class={@class}
/>
</picture>
"""
end
defp build_srcset(base, widths, format) do
widths
|> Enum.sort()
|> Enum.map(&"#{base}-#{&1}.#{format} #{&1}w")
|> Enum.join(", ")
end
Step 8: Update Thumbnail Serving
File: lib/simpleshop_theme_web/controllers/image_controller.ex
def thumbnail(conn, %{"id" => id}) do
thumb_path = Path.join("priv/static/image_cache", "#{id}-thumb.jpg")
if File.exists?(thumb_path) do
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_file(200, thumb_path)
else
# Fallback: try to generate on-demand
case Media.get_image(id) do
%{data: data} when is_binary(data) ->
# Generate just the thumbnail
with {:ok, image} <- Image.from_binary(data),
{:ok, thumb} <- Image.thumbnail(image, 200),
{:ok, binary} <- Image.write(thumb, :memory, suffix: ".jpg") do
# Save for next time
File.write!(thumb_path, binary)
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_resp(200, binary)
else
_ -> send_resp(conn, 404, "Thumbnail not available")
end
_ ->
send_resp(conn, 404, "Image not found")
end
end
end
Step 9: Mix Task for Mockups
File: lib/mix/tasks/optimize_images.ex
defmodule Mix.Tasks.OptimizeImages do
@shortdoc "Generate optimized variants for product mockups"
@moduledoc """
Generates responsive image variants (AVIF, WebP, JPEG) for mockup images.
## Usage
mix optimize_images
mix optimize_images --force
mix optimize_images --dir path/to/images
"""
use Mix.Task
@default_dir "priv/static/mockups"
@widths [400, 800, 1200]
@formats [:avif, :webp, :jpg]
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
originals = find_originals(dir)
IO.puts("Found #{length(originals)} original mockup images")
originals
|> Task.async_stream(&process(&1, force, dir),
max_concurrency: System.schedulers_online(),
timeout: :timer.minutes(2))
|> Enum.each(fn
{:ok, {:generated, name, count}} ->
IO.puts(" #{name}: generated #{count} variants")
{:ok, :skipped} ->
:ok
{:exit, reason} ->
IO.puts(" Error: #{inspect(reason)}")
end)
IO.puts("Done!")
end
defp find_originals(dir) do
dir
|> Path.join("*.jpg")
|> Path.wildcard()
|> Enum.reject(&is_variant?/1)
end
defp is_variant?(path) do
String.match?(Path.basename(path, ".jpg"), ~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
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}
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
{:ok, resized} = Image.thumbnail(image, width)
{:ok, _} = write(resized, path, format)
:generated
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)
defp write(image, path, :webp), do: Image.write(image, path, effort: 6, minimize_file_size: true)
defp write(image, path, :jpg), do: Image.write(image, path, quality: 80, minimize_file_size: true)
end
File Changes Summary
Create:
lib/simpleshop_theme/images/optimizer.ex- Core optimization logiclib/simpleshop_theme/images/variant_cache.ex- Startup recovery GenServerlib/simpleshop_theme/workers/image_variants.ex- Oban workerlib/mix/tasks/optimize_images.ex- Mockup batch processingpriv/repo/migrations/*_add_image_metadata.exs- Schema + Oban tables
Modify:
mix.exs- Add Oban dependencyconfig/config.exs- Oban configurationlib/simpleshop_theme/media/image.ex- Add source_width/height, remove thumbnail_datalib/simpleshop_theme/media.ex- Convert to WebP, enqueue Oban joblib/simpleshop_theme/application.ex- Add Oban + VariantCache to supervisionlib/simpleshop_theme_web/components/shop_components.ex- Responsive image componentlib/simpleshop_theme_web/controllers/image_controller.ex- Serve thumbnails from disk.gitignore- Add/priv/static/image_cache/
Key Design Decisions
1. Database stores ONLY originals as lossless WebP
- 26-41% smaller than PNG
source_width/height: for computing applicable widthsvariants_status: tracks processing state- NO
thumbnail_data- derived to disk - NO
available_widths- computed from source_width
2. Only generate sizes ≤ source (no upscaling)
- Small logos might only get 400w
- Large images get 400, 800, 1200
- Component computes srcset dynamically from source_width
3. Oban for durable async processing
- Instant upload response (no user wait)
- Automatic retry on failure
- Aggressive pruning (60s max_age) keeps DB lean
- Startup recovery for deleted cache
4. Task.async for parallelism within Oban jobs
- Oban: handles job scheduling, persistence, retry
- Task.async: handles parallel execution within a single job
- This is the standard pattern - Oban for durability, Task for concurrency
- If the job fails, all 10 variants retry together (acceptable since they share source)
5. Idempotent generation
- Checks if file exists before generating
- Safe to run multiple times
--forceflag for full regeneration
Phased Implementation (with Checkpoints)
Each phase is a self-contained unit. After each phase:
- Run the specified tests
- Perform manual verification
- Get user confirmation
- Make semantic commit
- Update Progress Tracker above
Phase 1: Oban Dependency + Config
Files to modify:
mix.exs- Add{:oban, "~> 2.18"}config/config.exs- Add Oban config
Tests: mix deps.get && mix compile
Manual verification:
mix phx.server
# Server should start without errors
Commit: feat: add oban dependency for background jobs
Phase 2: Migration + Schema Changes
Files to create/modify:
priv/repo/migrations/*_add_image_metadata.exs- New migrationlib/simpleshop_theme/media/image.ex- Add source_width/height, variants_status; remove thumbnail_data
Tests: mix ecto.migrate && mix test test/simpleshop_theme/media_test.exs (if exists)
Manual verification:
# Check migration applied
sqlite3 simpleshop_theme_dev.db ".schema images"
# Should show new columns: source_width, source_height, variants_status
# Should NOT have: thumbnail_data
Commit: feat: add image metadata fields for optimization pipeline
Phase 3: Optimizer Module
Files to create:
lib/simpleshop_theme/images/optimizer.extest/simpleshop_theme/images/optimizer_test.exstest/support/fixtures/image_fixtures.extest/fixtures/sample_1200x800.jpg(test image)
Tests: mix test test/simpleshop_theme/images/optimizer_test.exs
Manual verification:
# In iex -S mix
alias SimpleshopTheme.Images.Optimizer
Optimizer.applicable_widths(1500) # Should return [400, 800, 1200]
Optimizer.applicable_widths(300) # Should return [300]
Commit: feat: add image optimizer module
Phase 4: Oban Worker
Files to create:
lib/simpleshop_theme/workers/image_variants.extest/simpleshop_theme/workers/image_variants_test.exs
Files to modify:
lib/simpleshop_theme/application.ex- Add Oban to supervision tree
Tests: mix test test/simpleshop_theme/workers/image_variants_test.exs
Manual verification:
mix phx.server
# Check logs for "Oban started" or similar
Commit: feat: add image variants oban worker
Phase 5: Media Module Integration
Files to modify:
lib/simpleshop_theme/media.ex- Update upload_image to use optimizer + enqueue worker
Tests: mix test test/simpleshop_theme/media_test.exs (existing tests should pass)
Manual verification:
- Go to
/admin/theme - Upload a new logo image
- Check
priv/static/image_cache/for generated variants - Check database:
sqlite3 simpleshop_theme_dev.db "SELECT id, source_width, variants_status FROM images ORDER BY inserted_at DESC LIMIT 1"
Commit: feat: integrate optimizer with image uploads
Phase 6: VariantCache GenServer
Files to create:
lib/simpleshop_theme/images/variant_cache.ex
Files to modify:
lib/simpleshop_theme/application.ex- Add VariantCache to supervision tree
Tests: Manual (startup behavior)
Manual verification:
# Delete the cache and restart
rm -rf priv/static/image_cache/
mix phx.server
# Check logs for "Image cache: enqueueing X images for processing"
# Check that cache directory is recreated with variants
Commit: feat: add variant cache startup recovery
Phase 7: Responsive Image Component
Files to create:
test/simpleshop_theme_web/components/shop_components_test.exs
Files to modify:
lib/simpleshop_theme_web/components/shop_components.ex- Add responsive_image component
Tests: mix test test/simpleshop_theme_web/components/shop_components_test.exs
Manual verification:
# In iex -S mix
# Verify the component renders correct HTML
Commit: feat: add responsive image component
Phase 8: ImageController Disk Serving
Files to modify:
lib/simpleshop_theme_web/controllers/image_controller.ex- Update thumbnail to serve from disk
Tests: Existing controller tests should pass
Manual verification:
- Upload an image via admin
- Navigate to
/images/{id}/thumbnail - Check browser DevTools → Network → verify served from disk (fast response, correct headers)
Commit: feat: serve thumbnails from disk cache
Phase 9: Mix Task for Mockups
Files to create:
lib/mix/tasks/optimize_images.extest/mix/tasks/optimize_images_test.exs
Files to modify:
.gitignore- Add/priv/static/image_cache/and variant patterns
Tests: mix test test/mix/tasks/optimize_images_test.exs
Manual verification:
mix optimize_images
# Should show "Found X original mockup images"
# Check priv/static/mockups/ for generated variants (.avif, .webp, .jpg at various sizes)
ls -la priv/static/mockups/
Commit: feat: add mix task for mockup image optimization
Phase 10: Final Integration + Lighthouse
Tests: mix test (full test suite)
Manual verification:
- Start fresh:
rm -rf priv/static/image_cache/ && mix ecto.reset - Run:
mix phx.server - Upload logo, header images via admin
- Verify variants generated
- Check shop pages use responsive images
- Run Lighthouse:
npx lighthouse http://localhost:4000 --output=html --view - Verify Performance score improved
Commit: chore: finalize image optimization pipeline
Verification Steps
-
Run migration:
mix ecto.migrate -
Process existing mockups:
mix optimize_images -
Upload a test image via admin UI
- Should complete in ~1-2 seconds
- Check
priv/static/image_cache/for generated files
-
Test startup recovery:
rm -rf priv/static/image_cache/ mix phx.server # Check logs for regeneration -
Verify responsive images in browser:
- DevTools → Network → Img
- Check that AVIF loads (Content-Type header)
- Resize to mobile, reload, verify smaller variants
-
Run tests:
mix test test/simpleshop_theme/images/ mix test test/simpleshop_theme/workers/ -
Re-run Lighthouse:
npx lighthouse http://localhost:4000 --output=html --view
Testing Strategy
Principles
- Test behavior, not implementation - focus on inputs/outputs
- DRY via shared fixtures - one
image_fixture()function - Avoid redundancy - don't test the same logic in multiple places
- Real library calls - use actual Image library, minimal mocking
Test Files to Create
test/
├── simpleshop_theme/
│ ├── images/
│ │ └── optimizer_test.exs # Core optimization logic
│ └── workers/
│ └── image_variants_test.exs # Oban worker
├── simpleshop_theme_web/
│ └── components/
│ └── shop_components_test.exs # responsive_image component
├── mix/
│ └── tasks/
│ └── optimize_images_test.exs # Mix task tests
├── support/
│ └── fixtures/
│ └── image_fixtures.ex # Shared test helpers
└── fixtures/
└── sample_1200x800.jpg # Test image file
Test Implementation
test/support/fixtures/image_fixtures.ex:
defmodule SimpleshopTheme.ImageFixtures do
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(<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect/></svg>)
%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 = if format == :jpg, do: "jpg", else: Atom.to_string(format)
Path.join(SimpleshopTheme.Images.Optimizer.cache_dir(), "#{id}-#{width}.#{ext}")
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
test/simpleshop_theme/images/optimizer_test.exs:
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
assert <<_::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)
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
# Verify files exist
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
assert File.exists?(cache_path(image.id, w, fmt)),
"Missing #{w}.#{fmt}"
end
# Thumbnail exists
assert File.exists?(cache_path(image.id, "thumb", :jpg))
end
test "generates only applicable widths for smaller image" do
# Create a smaller test image
image = image_fixture(source_width: 600)
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()
{:ok, _} = Optimizer.process_for_image(image.id)
# Get file modification time
path = cache_path(image.id, 400, :avif)
{:ok, %{mtime: mtime1}} = File.stat(path)
# Process again
Process.sleep(1000) # Ensure different mtime if regenerated
{: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)
{: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()
refute Optimizer.disk_variants_exist?(image.id, 1200)
end
end
end
test/simpleshop_theme/workers/image_variants_test.exs:
defmodule SimpleshopTheme.Workers.ImageVariantsTest do
use SimpleshopTheme.DataCase, async: false
use Oban.Testing, repo: SimpleshopTheme.Repo
alias SimpleshopTheme.Workers.ImageVariants
alias SimpleshopTheme.Media.Image
import SimpleshopTheme.ImageFixtures
setup do
cleanup_cache()
on_exit(&cleanup_cache/0)
:ok
end
test "processes image and marks complete" do
image = image_fixture(variants_status: :pending)
assert :ok = perform_job(ImageVariants, %{image_id: image.id})
updated = Repo.get!(Image, image.id)
assert updated.variants_status == :complete
end
test "handles missing image gracefully" do
assert {:error, :not_found} =
perform_job(ImageVariants, %{image_id: Ecto.UUID.generate()})
end
test "skips SVG and marks complete" do
image = svg_fixture()
assert :ok = perform_job(ImageVariants, %{image_id: image.id})
updated = Repo.get!(Image, image.id)
assert updated.variants_status == :complete
end
end
test/simpleshop_theme_web/components/shop_components_test.exs:
defmodule SimpleshopThemeWeb.ShopComponentsTest do
use SimpleshopThemeWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias SimpleshopThemeWeb.ShopComponents
describe "responsive_image/1" do
test "builds srcset with all widths for 1200px source" do
html =
render_component(&ShopComponents.responsive_image/1, %{
src: "/images/test",
source_width: 1200,
alt: "Test image"
})
# AVIF source
assert html =~ ~s(type="image/avif")
assert html =~ "/images/test-400.avif 400w"
assert html =~ "/images/test-800.avif 800w"
assert html =~ "/images/test-1200.avif 1200w"
# WebP source
assert html =~ ~s(type="image/webp")
# Fallback img
assert html =~ ~s(src="/images/test-1200.jpg")
assert html =~ ~s(alt="Test image")
end
test "excludes widths larger than source" do
html =
render_component(&ShopComponents.responsive_image/1, %{
src: "/images/test",
source_width: 600,
alt: "Small image"
})
assert html =~ "400w"
refute html =~ "800w"
refute html =~ "1200w"
end
test "uses source width when smaller than 400" do
html =
render_component(&ShopComponents.responsive_image/1, %{
src: "/images/tiny",
source_width: 200,
alt: "Tiny"
})
assert html =~ "200w"
refute html =~ "400w"
end
test "sets lazy loading by default" do
html =
render_component(&ShopComponents.responsive_image/1, %{
src: "/images/test",
source_width: 1200,
alt: "Test"
})
assert html =~ ~s(loading="lazy")
assert html =~ ~s(decoding="async")
refute html =~ ~s(fetchpriority)
end
test "sets eager loading when priority=true" do
html =
render_component(&ShopComponents.responsive_image/1, %{
src: "/images/test",
source_width: 1200,
alt: "Test",
priority: true
})
assert html =~ ~s(loading="eager")
assert html =~ ~s(decoding="sync")
assert html =~ ~s(fetchpriority="high")
end
test "includes width/height when provided" do
html =
render_component(&ShopComponents.responsive_image/1, %{
src: "/images/test",
source_width: 1200,
alt: "Test",
width: 600,
height: 400
})
assert html =~ ~s(width="600")
assert html =~ ~s(height="400")
end
end
end
test/mix/tasks/optimize_images_test.exs:
defmodule Mix.Tasks.OptimizeImagesTest do
use ExUnit.Case, async: false
import ExUnit.CaptureIO
@test_dir "test/fixtures/mockups_test"
setup do
# Create test directory with a sample image
File.mkdir_p!(@test_dir)
# Copy sample image to test directory
sample = File.read!("test/fixtures/sample_1200x800.jpg")
File.write!(Path.join(@test_dir, "test-product.jpg"), sample)
on_exit(fn ->
File.rm_rf!(@test_dir)
end)
:ok
end
describe "run/1" do
test "generates variants for mockup images" do
output = capture_io(fn ->
Mix.Tasks.OptimizeImages.run(["--dir", @test_dir])
end)
assert output =~ "Found 1 original mockup images"
assert output =~ "test-product: generated"
assert output =~ "Done!"
# Verify generated files
assert File.exists?(Path.join(@test_dir, "test-product-400.avif"))
assert File.exists?(Path.join(@test_dir, "test-product-400.webp"))
assert File.exists?(Path.join(@test_dir, "test-product-400.jpg"))
assert File.exists?(Path.join(@test_dir, "test-product-800.avif"))
assert File.exists?(Path.join(@test_dir, "test-product-800.webp"))
assert File.exists?(Path.join(@test_dir, "test-product-800.jpg"))
assert File.exists?(Path.join(@test_dir, "test-product-1200.avif"))
assert File.exists?(Path.join(@test_dir, "test-product-1200.webp"))
assert File.exists?(Path.join(@test_dir, "test-product-1200.jpg"))
end
test "skips already generated variants" do
# First run
capture_io(fn -> Mix.Tasks.OptimizeImages.run(["--dir", @test_dir]) end)
# Second run should skip
output = capture_io(fn ->
Mix.Tasks.OptimizeImages.run(["--dir", @test_dir])
end)
assert output =~ "Found 1 original mockup images"
refute output =~ "generated" # Should skip, not generate
end
test "regenerates with --force flag" do
# First run
capture_io(fn -> Mix.Tasks.OptimizeImages.run(["--dir", @test_dir]) end)
# Get mtime of generated file
path = Path.join(@test_dir, "test-product-400.avif")
{:ok, %{mtime: mtime1}} = File.stat(path)
Process.sleep(1000)
# Force regeneration
output = capture_io(fn ->
Mix.Tasks.OptimizeImages.run(["--dir", @test_dir, "--force"])
end)
assert output =~ "generated"
{:ok, %{mtime: mtime2}} = File.stat(path)
assert mtime2 > mtime1, "File should have been regenerated"
end
test "excludes variant files from processing" do
# Create a file that looks like a variant (should be ignored)
variant_path = Path.join(@test_dir, "test-product-400.jpg")
File.write!(variant_path, "fake variant")
output = capture_io(fn ->
Mix.Tasks.OptimizeImages.run(["--dir", @test_dir])
end)
# Should only find the original, not the fake variant
assert output =~ "Found 1 original mockup images"
end
test "handles empty directory" do
empty_dir = Path.join(@test_dir, "empty")
File.mkdir_p!(empty_dir)
output = capture_io(fn ->
Mix.Tasks.OptimizeImages.run(["--dir", empty_dir])
end)
assert output =~ "Found 0 original mockup images"
assert output =~ "Done!"
end
end
end
What's NOT Tested (Intentionally)
- VariantCache GenServer - Startup behavior is tested via integration/manual testing
- ImageController thumbnail - Covered by existing controller tests or integration
Test Coverage Goals
| Module | Coverage Goal | Notes |
|---|---|---|
| Optimizer | 90%+ | Core logic, all edge cases |
| ImageVariants worker | 80%+ | Happy path + error handling |
| responsive_image component | 90%+ | All attribute combinations |
| Mix.Tasks.OptimizeImages | 80%+ | Variant generation, skip logic, force flag |
| VariantCache | Manual | Startup behavior |