# Plan: Automatic Image Optimization Pipeline

> **Status:** Complete - See [PROGRESS.md](../../PROGRESS.md) for current status.

This document contains implementation details for reference.

---

## 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 :berrypod, Oban,
  engine: Oban.Engines.Lite,  # SQLite support
  repo: Berrypod.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 Berrypod.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}
      |> Berrypod.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}
    |> Berrypod.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 Berrypod.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/berrypod/images/optimizer.ex`

```elixir
defmodule Berrypod.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 Berrypod.{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/berrypod/workers/image_variants.ex`

```elixir
defmodule Berrypod.Workers.ImageVariants do
  use Oban.Worker,
    queue: :images,
    max_attempts: 3,
    unique: [period: 60]  # Prevent duplicate jobs

  alias Berrypod.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/berrypod/media.ex`

```elixir
alias Berrypod.Images.Optimizer
alias Berrypod.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/berrypod/images/variant_cache.ex`

```elixir
defmodule Berrypod.Images.VariantCache do
  @moduledoc """
  Ensures all image variants exist on startup.
  Enqueues Oban jobs for any missing variants.
  """

  use GenServer
  require Logger

  alias Berrypod.Repo
  alias Berrypod.Media.Image, as: ImageSchema
  alias Berrypod.Images.Optimizer
  alias Berrypod.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/berrypod_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 Berrypod.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/berrypod_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/berrypod/images/optimizer.ex` - Core optimization logic
- `lib/berrypod/images/variant_cache.ex` - Startup recovery GenServer
- `lib/berrypod/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/berrypod/media/image.ex` - Add source_width/height, remove thumbnail_data
- `lib/berrypod/media.ex` - Convert to WebP, enqueue Oban job
- `lib/berrypod/application.ex` - Add Oban + VariantCache to supervision
- `lib/berrypod_web/components/shop_components.ex` - Responsive image component
- `lib/berrypod_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/berrypod/media/image.ex` - Add source_width/height, variants_status; remove thumbnail_data

**Tests:** `mix ecto.migrate && mix test test/berrypod/media_test.exs` (if exists)

**Manual verification:**
```bash
# Check migration applied
sqlite3 berrypod_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/berrypod/images/optimizer.ex`
- `test/berrypod/images/optimizer_test.exs`
- `test/support/fixtures/image_fixtures.ex`
- `test/fixtures/sample_1200x800.jpg` (test image)

**Tests:** `mix test test/berrypod/images/optimizer_test.exs`

**Manual verification:**
```elixir
# In iex -S mix
alias Berrypod.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/berrypod/workers/image_variants.ex`
- `test/berrypod/workers/image_variants_test.exs`

**Files to modify:**
- `lib/berrypod/application.ex` - Add Oban to supervision tree

**Tests:** `mix test test/berrypod/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/berrypod/media.ex` - Update upload_image to use optimizer + enqueue worker

**Tests:** `mix test test/berrypod/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 berrypod_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/berrypod/images/variant_cache.ex`

**Files to modify:**
- `lib/berrypod/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/berrypod_web/components/shop_components_test.exs`

**Files to modify:**
- `lib/berrypod_web/components/shop_components.ex` - Add responsive_image component

**Tests:** `mix test test/berrypod_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/berrypod_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/berrypod/images/
   mix test test/berrypod/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/
├── berrypod/
│   ├── images/
│   │   └── optimizer_test.exs       # Core optimization logic
│   └── workers/
│       └── image_variants_test.exs  # Oban worker
├── berrypod_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 Berrypod.ImageFixtures do
  alias Berrypod.Repo
  alias Berrypod.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} = Berrypod.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(Berrypod.Images.Optimizer.cache_dir(), "#{id}-#{width}.#{ext}")
  end

  def cleanup_cache do
    cache_dir = Berrypod.Images.Optimizer.cache_dir()
    if File.exists?(cache_dir), do: File.rm_rf!(cache_dir)
  end
end
```

**`test/berrypod/images/optimizer_test.exs`:**
```elixir
defmodule Berrypod.Images.OptimizerTest do
  use Berrypod.DataCase, async: false

  alias Berrypod.Images.Optimizer
  import Berrypod.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/berrypod/workers/image_variants_test.exs`:**
```elixir
defmodule Berrypod.Workers.ImageVariantsTest do
  use Berrypod.DataCase, async: false
  use Oban.Testing, repo: Berrypod.Repo

  alias Berrypod.Workers.ImageVariants
  alias Berrypod.Media.Image
  import Berrypod.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/berrypod_web/components/shop_components_test.exs`:**
```elixir
defmodule BerrypodWeb.ShopComponentsTest do
  use BerrypodWeb.ConnCase, async: true

  import Phoenix.LiveViewTest
  alias BerrypodWeb.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 |
