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>
1543 lines
42 KiB
Markdown
1543 lines
42 KiB
Markdown
# 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:**
|
||
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 |
|