2026-01-21 21:56:30 +00:00
# Plan: Automatic Image Optimization Pipeline
**Location:** `docs/plans/image-optimization.md`
**Purpose:** Track implementation progress - update this file after each phase completes.
---
## Progress Tracker
2026-01-25 00:34:04 +00:00
**Current Phase:** Complete
2026-01-21 21:56:30 +00:00
**Last Updated:** 2026-01-21
| Phase | Status | Commit |
|-------|--------|--------|
2026-01-25 00:34:04 +00:00
| 1. Oban dependency + config | ✅ Complete | dbadd2a |
| 2. Migration + Schema | ✅ Complete | cefec1a |
| 3. Optimizer module | ✅ Complete | 2b5b749 |
| 4. Oban worker | ✅ Complete | 2b5b749 |
| 5. Media module integration | ✅ Complete | (pending commit) |
| 6. VariantCache GenServer | ✅ Complete | (pending commit) |
| 7. Responsive image component | ✅ Complete | (pending commit) |
| 8. ImageController disk serving | ✅ Complete | (pending commit) |
| 9. Mix task for mockups | ✅ Complete | (pending commit) |
| 10. Final integration + Lighthouse | ✅ Complete | (pending commit) |
2026-01-21 21:56:30 +00:00
**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:**
1. Original images stored in SQLite as **lossless WebP** blobs (26-41% smaller than PNG)
2. All derived formats (responsive variants, thumbnails) generated to disk cache
3. Disk cache is regenerable - can be deleted and recreated from DB
4. Only generate sizes ≤ source dimensions (no upscaling)
5. Available widths computed dynamically from `source_width` (not stored)
---
## Architecture
### Database Schema (Source of Truth)
```elixir
# 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):
```elixir
@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:
1. Consistent latency for all users
2. 640GB is ~$15-30/month cloud storage
3. Simpler architecture (no on-demand generation)
4. 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
```elixir
# 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
```elixir
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
```elixir
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:
```elixir
# 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`
```elixir
defp deps do
[
# ... existing deps
{:oban, "~> 2.18"}
]
end
```
### Step 2: Migration - Update Image Schema
**File:** `priv/repo/migrations/YYYYMMDDHHMMSS_add_image_metadata.exs`
```elixir
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`
```elixir
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`
```elixir
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`
```elixir
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`
```elixir
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`
```elixir
@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`
```elixir
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`
```elixir
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 logic
- `lib/simpleshop_theme/images/variant_cache.ex` - Startup recovery GenServer
- `lib/simpleshop_theme/workers/image_variants.ex` - Oban worker
- `lib/mix/tasks/optimize_images.ex` - Mockup batch processing
- `priv/repo/migrations/*_add_image_metadata.exs` - Schema + Oban tables
### Modify:
- `mix.exs` - Add Oban dependency
- `config/config.exs` - Oban configuration
- `lib/simpleshop_theme/media/image.ex` - Add source_width/height, remove thumbnail_data
- `lib/simpleshop_theme/media.ex` - Convert to WebP, enqueue Oban job
- `lib/simpleshop_theme/application.ex` - Add Oban + VariantCache to supervision
- `lib/simpleshop_theme_web/components/shop_components.ex` - Responsive image component
- `lib/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 widths
- `variants_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
- `--force` flag for full regeneration
---
## Phased Implementation (with Checkpoints)
Each phase is a self-contained unit. After each phase:
1. Run the specified tests
2. Perform manual verification
3. Get user confirmation
4. Make semantic commit
5. 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:**
```bash
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 migration
- `lib/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:**
```bash
# 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.ex`
- `test/simpleshop_theme/images/optimizer_test.exs`
- `test/support/fixtures/image_fixtures.ex`
- `test/fixtures/sample_1200x800.jpg` (test image)
**Tests:** `mix test test/simpleshop_theme/images/optimizer_test.exs`
**Manual verification:**
```elixir
# 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.ex`
- `test/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:**
```bash
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:**
1. Go to `/admin/theme`
2. Upload a new logo image
3. Check `priv/static/image_cache/` for generated variants
4. 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:**
```bash
# 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:**
```elixir
# 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:**
1. Upload an image via admin
2. Navigate to `/images/{id}/thumbnail`
3. 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.ex`
- `test/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:**
```bash
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:**
1. Start fresh: `rm -rf priv/static/image_cache/ && mix ecto.reset`
2. Run: `mix phx.server`
3. Upload logo, header images via admin
4. Verify variants generated
5. Check shop pages use responsive images
6. Run Lighthouse:
```bash
npx lighthouse http://localhost:4000 --output=html --view
```
7. Verify Performance score improved
**Commit:** `chore: finalize image optimization pipeline`
---
## Verification Steps
1. **Run migration:**
```bash
mix ecto.migrate
```
2. **Process existing mockups:**
```bash
mix optimize_images
```
3. **Upload a test image via admin UI**
- Should complete in ~1-2 seconds
- Check `priv/static/image_cache/` for generated files
4. **Test startup recovery:**
```bash
rm -rf priv/static/image_cache/
mix phx.server
# Check logs for regeneration
```
5. **Verify responsive images in browser:**
- DevTools → Network → Img
- Check that AVIF loads (Content-Type header)
- Resize to mobile, reload, verify smaller variants
6. **Run tests:**
```bash
mix test test/simpleshop_theme/images/
mix test test/simpleshop_theme/workers/
```
7. **Re-run Lighthouse:**
```bash
npx lighthouse http://localhost:4000 --output=html --view
```
---
## Testing Strategy
### Principles
1. **Test behavior, not implementation** - focus on inputs/outputs
2. **DRY via shared fixtures** - one `image_fixture()` function
3. **Avoid redundancy** - don't test the same logic in multiple places
4. **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`:**
```elixir
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`:**
```elixir
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`:**
```elixir
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`:**
```elixir
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`:**
```elixir
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 |