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