simpleshop_theme/docs/plans/image-optimization.md
jamey d97918d66a docs: consolidate project tracking into PROGRESS.md
- Create PROGRESS.md as single source of truth for status
- Slim ROADMAP.md to vision only (~100 lines, down from ~500)
- Expand CLAUDE.md with streams, auth routing, forms, workflow
- Convert AGENTS.md to stub pointing to CLAUDE.md
- Update plan files with status headers, remove progress trackers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:06:07 +00:00

41 KiB
Raw Blame History

Plan: Automatic Image Optimization Pipeline

Status: Complete - See 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)

# images table - ONLY stores originals
field :data, :binary              # Original image as lossless WebP
field :source_width, :integer     # Original dimensions
field :source_height, :integer
field :variants_status, Ecto.Enum, values: [:pending, :processing, :complete, :failed]

# REMOVED: available_widths - computed from source_width
# REMOVED: thumbnail_data - derived, stored on disk

Why Lossless WebP for Storage?

Format Lossless Size vs PNG Encoding Speed
PNG Baseline Fast
WebP 26-41% smaller Fast
AVIF ~20% smaller Very slow

WebP lossless is the optimal choice: best compression for photos, fast encoding.

Disk Cache (Derived, Regenerable)

priv/static/image_cache/           # Gitignored, regenerated on startup
├── {uuid}-thumb.jpg               # 200px thumbnail for admin UI
├── {uuid}-400.avif
├── {uuid}-400.webp
├── {uuid}-400.jpg
├── {uuid}-800.avif
├── {uuid}-800.webp
├── {uuid}-800.jpg
├── {uuid}-1200.avif               # Only if source >= 1200px
├── {uuid}-1200.webp
└── {uuid}-1200.jpg

Size Selection Logic

Only generate widths ≤ source dimensions (no upscaling):

@all_widths [400, 800, 1200]

def applicable_widths(source_width) do
  @all_widths
  |> Enum.filter(&(&1 <= source_width))
  |> case do
    [] -> [source_width]  # Source smaller than 400px
    widths -> widths
  end
end

Disk Space Analysis

Per-image storage breakdown (1200px source):

Format 400w 800w 1200w Subtotal
AVIF ~15KB ~40KB ~70KB ~125KB
WebP ~25KB ~60KB ~100KB ~185KB
JPEG ~40KB ~100KB ~180KB ~320KB
Thumbnail - - - ~10KB
Total ~640KB

At scale:

  • 1 shop × 100 products = ~64MB
  • 10,000 shops × 100 products = ~640GB

Alternative: Skip pre-generating JPEG (on-demand only)

  • JPEG is ~50% of total storage
  • Savings: 320GB at 10K shops scale
  • Trade-off: First JPEG request takes ~100ms to generate
  • Only affects <5% of users (ancient browsers)

Recommendation: Pre-generate all formats because:

  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

# config/config.exs
config :simpleshop_theme, Oban,
  engine: Oban.Engines.Lite,  # SQLite support
  repo: SimpleshopTheme.Repo,
  plugins: [
    # Prune completed jobs after 60 seconds - keeps DB lean
    {Oban.Plugins.Pruner, max_age: 60}
  ],
  queues: [images: 2]

With max_age: 60, the Oban tables typically contain:

  • 0-10 active jobs during processing
  • Failed jobs kept for debugging (can add separate max_age)
  • Completed jobs deleted within 1 minute

Job Worker

defmodule SimpleshopTheme.Workers.ImageVariants do
  use Oban.Worker, queue: :images, max_attempts: 3

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"image_id" => image_id}}) do
    case Images.Optimizer.process_for_image(image_id) do
      {:ok, _} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

Upload Flow

def upload_image(attrs) do
  # Convert to lossless WebP before storing
  attrs = convert_to_lossless_webp(attrs)

  case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
    {:ok, image} ->
      # Enqueue async variant generation
      %{image_id: image.id}
      |> SimpleshopTheme.Workers.ImageVariants.new()
      |> Oban.insert()

      {:ok, image}

    error ->
      error
  end
end

Startup Recovery

Still needed for cache deletion scenarios:

# In VariantCache GenServer init
defp ensure_all_variants do
  # Find images with missing disk variants
  ImageSchema
  |> where([i], i.variants_status == :complete)
  |> where([i], i.is_svg == false)
  |> Repo.all()
  |> Enum.reject(&disk_variants_exist?/1)
  |> Enum.each(fn image ->
    # Re-enqueue for processing
    %{image_id: image.id}
    |> SimpleshopTheme.Workers.ImageVariants.new()
    |> Oban.insert()
  end)
end

Implementation Steps

Step 1: Add Oban Dependency

File: mix.exs

defp deps do
  [
    # ... existing deps
    {:oban, "~> 2.18"}
  ]
end

Step 2: Migration - Update Image Schema

File: priv/repo/migrations/YYYYMMDDHHMMSS_add_image_metadata.exs

defmodule SimpleshopTheme.Repo.Migrations.AddImageMetadata do
  use Ecto.Migration

  def change do
    alter table(:images) do
      add :source_width, :integer
      add :source_height, :integer
      add :variants_status, :string, default: "pending"

      # Remove thumbnail_data - now derived to disk
      remove :thumbnail_data
    end

    # Oban tables for job queue
    Oban.Migration.up(version: 12)
  end
end

Step 3: Image Optimizer Module

File: lib/simpleshop_theme/images/optimizer.ex

defmodule SimpleshopTheme.Images.Optimizer do
  @moduledoc """
  Generates optimized image variants. Only creates sizes ≤ source dimensions.
  """

  require Logger

  @all_widths [400, 800, 1200]
  @formats [:avif, :webp, :jpg]
  @thumb_size 200
  @cache_dir "priv/static/image_cache"

  def cache_dir, do: @cache_dir
  def all_widths, do: @all_widths

  @doc """
  Convert uploaded image to lossless WebP for storage.
  """
  def to_lossless_webp(image_data) when is_binary(image_data) do
    with {:ok, image} <- Image.from_binary(image_data),
         {width, height, _} <- Image.shape(image),
         {:ok, webp_data} <- Image.write(image, :memory, suffix: ".webp", quality: 100) do
      {:ok, webp_data, width, height}
    end
  end

  @doc """
  Compute applicable widths from source dimensions.
  """
  def applicable_widths(source_width) do
    @all_widths
    |> Enum.filter(&(&1 <= source_width))
    |> case do
      [] -> [source_width]
      widths -> widths
    end
  end

  @doc """
  Process image and generate all applicable variants.
  Called by Oban worker.
  """
  def process_for_image(image_id) do
    alias SimpleshopTheme.{Repo, Media.Image}

    case Repo.get(Image, image_id) do
      nil ->
        {:error, :not_found}

      %{data: nil} ->
        {:error, :no_data}

      %{is_svg: true} = image ->
        # SVGs don't need variants
        Repo.update!(Image.changeset(image, %{variants_status: :complete}))
        {:ok, :svg_skipped}

      %{data: data, source_width: width} = image ->
        File.mkdir_p!(@cache_dir)

        with {:ok, vips_image} <- Image.from_binary(data) do
          widths = applicable_widths(width)

          # Generate thumbnail + variants in parallel
          tasks = [
            Task.async(fn -> generate_thumbnail(vips_image, image_id) end)
            | for w <- widths, fmt <- @formats do
                Task.async(fn -> generate_variant(vips_image, image_id, w, fmt) end)
              end
          ]

          Task.await_many(tasks, :timer.seconds(120))

          # Mark complete
          Repo.update!(Image.changeset(image, %{variants_status: :complete}))
          {:ok, widths}
        end
    end
  end

  defp generate_thumbnail(image, id) do
    path = Path.join(@cache_dir, "#{id}-thumb.jpg")
    return_if_exists(path, fn ->
      with {:ok, thumb} <- Image.thumbnail(image, @thumb_size),
           {:ok, _} <- Image.write(thumb, path, quality: 80) do
        :ok
      end
    end)
  end

  defp generate_variant(image, id, width, format) do
    path = Path.join(@cache_dir, "#{id}-#{width}.#{format_ext(format)}")
    return_if_exists(path, fn ->
      with {:ok, resized} <- Image.thumbnail(image, width),
           {:ok, _} <- write_format(resized, path, format) do
        :ok
      end
    end)
  end

  defp return_if_exists(path, generate_fn) do
    if File.exists?(path), do: {:ok, :cached}, else: generate_fn.()
  end

  defp format_ext(:jpg), do: "jpg"
  defp format_ext(:webp), do: "webp"
  defp format_ext(:avif), do: "avif"

  defp write_format(image, path, :avif) do
    Image.write(image, path, effort: 5, minimize_file_size: true)
  end

  defp write_format(image, path, :webp) do
    Image.write(image, path, effort: 6, minimize_file_size: true)
  end

  defp write_format(image, path, :jpg) do
    Image.write(image, path, quality: 80, minimize_file_size: true)
  end

  @doc """
  Check if disk variants exist for an image.
  """
  def disk_variants_exist?(image_id, source_width) do
    widths = applicable_widths(source_width)
    thumb = File.exists?(Path.join(@cache_dir, "#{image_id}-thumb.jpg"))

    variants = Enum.all?(widths, fn w ->
      Enum.all?(@formats, fn fmt ->
        File.exists?(Path.join(@cache_dir, "#{image_id}-#{w}.#{format_ext(fmt)}"))
      end)
    end)

    thumb and variants
  end
end

Step 4: Oban Worker

File: lib/simpleshop_theme/workers/image_variants.ex

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

  alias SimpleshopTheme.Images.Optimizer

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"image_id" => image_id}}) do
    case Optimizer.process_for_image(image_id) do
      {:ok, _} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

Step 5: Update Media Module

File: lib/simpleshop_theme/media.ex

alias SimpleshopTheme.Images.Optimizer
alias SimpleshopTheme.Workers.ImageVariants

def upload_image(attrs) do
  # Convert to lossless WebP before storing
  with {:ok, webp_data, width, height} <- Optimizer.to_lossless_webp(attrs.data) do
    attrs =
      attrs
      |> Map.put(:data, webp_data)
      |> Map.put(:source_width, width)
      |> Map.put(:source_height, height)
      |> Map.put(:variants_status, :pending)

    case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
      {:ok, image} ->
        # Enqueue async variant generation
        %{image_id: image.id}
        |> ImageVariants.new()
        |> Oban.insert()

        {:ok, image}

      error ->
        error
    end
  end
end

# Thumbnail served from disk cache
def get_thumbnail_path(image_id) do
  Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg")
end

Step 6: Startup Recovery GenServer

File: lib/simpleshop_theme/images/variant_cache.ex

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

  use GenServer
  require Logger

  alias SimpleshopTheme.Repo
  alias SimpleshopTheme.Media.Image, as: ImageSchema
  alias SimpleshopTheme.Images.Optimizer
  alias SimpleshopTheme.Workers.ImageVariants
  import Ecto.Query

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @impl true
  def init(_) do
    # Run async to not block startup
    Task.start(fn -> ensure_all_variants() end)
    {:ok, %{}}
  end

  defp ensure_all_variants do
    Logger.info("Checking image variant cache...")
    File.mkdir_p!(Optimizer.cache_dir())

    # 1. Find incomplete images (status != complete)
    incomplete =
      ImageSchema
      |> where([i], i.variants_status != :complete or is_nil(i.variants_status))
      |> where([i], i.is_svg == false)
      |> Repo.all()

    # 2. Find complete images with missing disk files (cache was deleted)
    complete_missing =
      ImageSchema
      |> where([i], i.variants_status == :complete)
      |> where([i], i.is_svg == false)
      |> Repo.all()
      |> Enum.reject(fn img ->
        Optimizer.disk_variants_exist?(img.id, img.source_width)
      end)

    to_process = incomplete ++ complete_missing

    if to_process == [] do
      Logger.info("Image cache: all variants up to date")
    else
      Logger.info("Image cache: enqueueing #{length(to_process)} images for processing")

      Enum.each(to_process, fn image ->
        # Reset status and enqueue
        Repo.update!(ImageSchema.changeset(image, %{variants_status: :pending}))

        %{image_id: image.id}
        |> ImageVariants.new()
        |> Oban.insert()
      end)
    end
  end
end

Step 7: Responsive Image Component

File: lib/simpleshop_theme_web/components/shop_components.ex

@doc """
Renders a responsive <picture> element with AVIF, WebP, and JPEG sources.
Computes available widths from source_width (no separate storage needed).

## Examples

    <.responsive_image
      src="/image_cache/abc123"
      source_width={1200}
      alt="Product image"
      priority={true}
    />
"""
attr :src, :string, required: true, doc: "Base path without size/extension"
attr :alt, :string, required: true
attr :source_width, :integer, required: true, doc: "Original image width"
attr :sizes, :string, default: "100vw"
attr :class, :string, default: ""
attr :width, :integer, default: nil
attr :height, :integer, default: nil
attr :priority, :boolean, default: false

def responsive_image(assigns) do
  alias SimpleshopTheme.Images.Optimizer

  # Compute available widths from source dimensions
  available = Optimizer.applicable_widths(assigns.source_width)
  default_width = Enum.max(available)

  assigns =
    assigns
    |> assign(:available_widths, available)
    |> assign(:default_width, default_width)

  ~H"""
  <picture>
    <source
      type="image/avif"
      srcset={build_srcset(@src, @available_widths, "avif")}
      sizes={@sizes}
    />
    <source
      type="image/webp"
      srcset={build_srcset(@src, @available_widths, "webp")}
      sizes={@sizes}
    />
    <img
      src={"#{@src}-#{@default_width}.jpg"}
      srcset={build_srcset(@src, @available_widths, "jpg")}
      sizes={@sizes}
      alt={@alt}
      width={@width}
      height={@height}
      loading={if @priority, do: "eager", else: "lazy"}
      decoding={if @priority, do: "sync", else: "async"}
      fetchpriority={if @priority, do: "high", else: nil}
      class={@class}
    />
  </picture>
  """
end

defp build_srcset(base, widths, format) do
  widths
  |> Enum.sort()
  |> Enum.map(&"#{base}-#{&1}.#{format} #{&1}w")
  |> Enum.join(", ")
end

Step 8: Update Thumbnail Serving

File: lib/simpleshop_theme_web/controllers/image_controller.ex

def thumbnail(conn, %{"id" => id}) do
  thumb_path = Path.join("priv/static/image_cache", "#{id}-thumb.jpg")

  if File.exists?(thumb_path) do
    conn
    |> put_resp_content_type("image/jpeg")
    |> put_resp_header("cache-control", "public, max-age=31536000, immutable")
    |> send_file(200, thumb_path)
  else
    # Fallback: try to generate on-demand
    case Media.get_image(id) do
      %{data: data} when is_binary(data) ->
        # Generate just the thumbnail
        with {:ok, image} <- Image.from_binary(data),
             {:ok, thumb} <- Image.thumbnail(image, 200),
             {:ok, binary} <- Image.write(thumb, :memory, suffix: ".jpg") do
          # Save for next time
          File.write!(thumb_path, binary)

          conn
          |> put_resp_content_type("image/jpeg")
          |> put_resp_header("cache-control", "public, max-age=31536000, immutable")
          |> send_resp(200, binary)
        else
          _ -> send_resp(conn, 404, "Thumbnail not available")
        end

      _ ->
        send_resp(conn, 404, "Image not found")
    end
  end
end

Step 9: Mix Task for Mockups

File: lib/mix/tasks/optimize_images.ex

defmodule Mix.Tasks.OptimizeImages do
  @shortdoc "Generate optimized variants for product mockups"
  @moduledoc """
  Generates responsive image variants (AVIF, WebP, JPEG) for mockup images.

  ## Usage

      mix optimize_images
      mix optimize_images --force
      mix optimize_images --dir path/to/images
  """

  use Mix.Task

  @default_dir "priv/static/mockups"
  @widths [400, 800, 1200]
  @formats [:avif, :webp, :jpg]

  def run(args) do
    Application.ensure_all_started(:image)

    {opts, _} = OptionParser.parse!(args, strict: [force: :boolean, dir: :string])
    force = opts[:force] || false
    dir = opts[:dir] || @default_dir

    originals = find_originals(dir)
    IO.puts("Found #{length(originals)} original mockup images")

    originals
    |> Task.async_stream(&process(&1, force, dir),
         max_concurrency: System.schedulers_online(),
         timeout: :timer.minutes(2))
    |> Enum.each(fn
      {:ok, {:generated, name, count}} ->
        IO.puts("  #{name}: generated #{count} variants")
      {:ok, :skipped} ->
        :ok
      {:exit, reason} ->
        IO.puts("  Error: #{inspect(reason)}")
    end)

    IO.puts("Done!")
  end

  defp find_originals(dir) do
    dir
    |> Path.join("*.jpg")
    |> Path.wildcard()
    |> Enum.reject(&is_variant?/1)
  end

  defp is_variant?(path) do
    String.match?(Path.basename(path, ".jpg"), ~r/-\d+$/)
  end

  defp process(path, force, dir) do
    name = Path.basename(path, ".jpg")

    if not force and all_variants_exist?(name, dir) do
      :skipped
    else
      with {:ok, image} <- Image.open(path),
           {width, _, _} <- Image.shape(image) do

        applicable = Enum.filter(@widths, &(&1 <= width))

        count =
          for w <- applicable, fmt <- @formats do
            generate(image, name, w, fmt, dir)
          end
          |> Enum.count(&(&1 == :generated))

        {:generated, name, count}
      end
    end
  end

  defp all_variants_exist?(name, dir) do
    Enum.all?(@widths, fn w ->
      Enum.all?(@formats, fn fmt ->
        File.exists?(Path.join(dir, "#{name}-#{w}.#{ext(fmt)}"))
      end)
    end)
  end

  defp generate(image, name, width, format, dir) do
    path = Path.join(dir, "#{name}-#{width}.#{ext(format)}")

    if File.exists?(path) do
      :cached
    else
      {:ok, resized} = Image.thumbnail(image, width)
      {:ok, _} = write(resized, path, format)
      :generated
    end
  end

  defp ext(:jpg), do: "jpg"
  defp ext(:webp), do: "webp"
  defp ext(:avif), do: "avif"

  defp write(image, path, :avif), do: Image.write(image, path, effort: 5, minimize_file_size: true)
  defp write(image, path, :webp), do: Image.write(image, path, effort: 6, minimize_file_size: true)
  defp write(image, path, :jpg), do: Image.write(image, path, quality: 80, minimize_file_size: true)
end

File Changes Summary

Create:

  • lib/simpleshop_theme/images/optimizer.ex - Core optimization 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:

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:

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

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

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:

# Delete the cache and restart
rm -rf priv/static/image_cache/
mix phx.server
# Check logs for "Image cache: enqueueing X images for processing"
# Check that cache directory is recreated with variants

Commit: feat: add variant cache startup recovery


Phase 7: Responsive Image Component

Files to create:

  • test/simpleshop_theme_web/components/shop_components_test.exs

Files to modify:

  • lib/simpleshop_theme_web/components/shop_components.ex - Add responsive_image component

Tests: mix test test/simpleshop_theme_web/components/shop_components_test.exs

Manual verification:

# In iex -S mix
# Verify the component renders correct HTML

Commit: feat: add responsive image component


Phase 8: ImageController Disk Serving

Files to modify:

  • lib/simpleshop_theme_web/controllers/image_controller.ex - Update thumbnail to serve from disk

Tests: Existing controller tests should pass

Manual verification:

  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:

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:
    npx lighthouse http://localhost:4000 --output=html --view
    
  7. Verify Performance score improved

Commit: chore: finalize image optimization pipeline


Verification Steps

  1. Run migration:

    mix ecto.migrate
    
  2. Process existing mockups:

    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:

    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:

    mix test test/simpleshop_theme/images/
    mix test test/simpleshop_theme/workers/
    
  7. Re-run Lighthouse:

    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:

defmodule SimpleshopTheme.ImageFixtures do
  alias SimpleshopTheme.Repo
  alias SimpleshopTheme.Media.Image

  @sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg")

  def sample_jpeg, do: @sample_jpeg

  def image_fixture(attrs \\ %{}) do
    {:ok, webp, w, h} = SimpleshopTheme.Images.Optimizer.to_lossless_webp(@sample_jpeg)

    defaults = %{
      image_type: "product",
      filename: "test.jpg",
      content_type: "image/webp",
      file_size: byte_size(webp),
      data: webp,
      source_width: w,
      source_height: h,
      variants_status: :pending,
      is_svg: false
    }

    %Image{}
    |> Image.changeset(Map.merge(defaults, attrs))
    |> Repo.insert!()
  end

  def svg_fixture do
    svg = ~s(<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect/></svg>)

    %Image{}
    |> Image.changeset(%{
      image_type: "logo",
      filename: "logo.svg",
      content_type: "image/svg+xml",
      file_size: byte_size(svg),
      data: svg,
      is_svg: true,
      svg_content: svg,
      variants_status: :pending
    })
    |> Repo.insert!()
  end

  def cache_path(id, width, format) do
    ext = if format == :jpg, do: "jpg", else: Atom.to_string(format)
    Path.join(SimpleshopTheme.Images.Optimizer.cache_dir(), "#{id}-#{width}.#{ext}")
  end

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

test/simpleshop_theme/images/optimizer_test.exs:

defmodule SimpleshopTheme.Images.OptimizerTest do
  use SimpleshopTheme.DataCase, async: false

  alias SimpleshopTheme.Images.Optimizer
  import SimpleshopTheme.ImageFixtures

  setup do
    cleanup_cache()
    on_exit(&cleanup_cache/0)
    :ok
  end

  describe "applicable_widths/1" do
    test "returns all widths for large source" do
      assert [400, 800, 1200] = Optimizer.applicable_widths(1500)
    end

    test "excludes widths larger than source" do
      assert [400, 800] = Optimizer.applicable_widths(900)
      assert [400] = Optimizer.applicable_widths(500)
    end

    test "returns source width when smaller than minimum" do
      assert [300] = Optimizer.applicable_widths(300)
      assert [50] = Optimizer.applicable_widths(50)
    end
  end

  describe "to_lossless_webp/1" do
    test "converts image and returns dimensions" do
      {:ok, webp, width, height} = Optimizer.to_lossless_webp(sample_jpeg())

      assert is_binary(webp)
      assert width == 1200
      assert height == 800
      # WebP magic bytes
      assert <<_::binary-size(4), "WEBP", _::binary>> = webp
    end

    test "returns error for invalid data" do
      assert {:error, _} = Optimizer.to_lossless_webp("not an image")
    end
  end

  describe "process_for_image/1" do
    test "generates all variants for 1200px image" do
      image = image_fixture(source_width: 1200)

      assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)

      # Verify files exist
      for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
        assert File.exists?(cache_path(image.id, w, fmt)),
               "Missing #{w}.#{fmt}"
      end

      # Thumbnail exists
      assert File.exists?(cache_path(image.id, "thumb", :jpg))
    end

    test "generates only applicable widths for smaller image" do
      # Create a smaller test image
      image = image_fixture(source_width: 600)

      assert {:ok, [400]} = Optimizer.process_for_image(image.id)

      assert File.exists?(cache_path(image.id, 400, :avif))
      refute File.exists?(cache_path(image.id, 800, :avif))
    end

    test "skips SVG images" do
      image = svg_fixture()
      assert {:ok, :svg_skipped} = Optimizer.process_for_image(image.id)
    end

    test "returns error for missing image" do
      assert {:error, :not_found} = Optimizer.process_for_image(Ecto.UUID.generate())
    end

    test "is idempotent - skips existing files" do
      image = image_fixture()
      {:ok, _} = Optimizer.process_for_image(image.id)

      # Get file modification time
      path = cache_path(image.id, 400, :avif)
      {:ok, %{mtime: mtime1}} = File.stat(path)

      # Process again
      Process.sleep(1000)  # Ensure different mtime if regenerated
      {:ok, _} = Optimizer.process_for_image(image.id)

      {:ok, %{mtime: mtime2}} = File.stat(path)
      assert mtime1 == mtime2, "File was regenerated"
    end
  end

  describe "disk_variants_exist?/2" do
    test "returns true when all variants exist" do
      image = image_fixture(source_width: 1200)
      {:ok, _} = Optimizer.process_for_image(image.id)

      assert Optimizer.disk_variants_exist?(image.id, 1200)
    end

    test "returns false when variants missing" do
      image = image_fixture()
      refute Optimizer.disk_variants_exist?(image.id, 1200)
    end
  end
end

test/simpleshop_theme/workers/image_variants_test.exs:

defmodule SimpleshopTheme.Workers.ImageVariantsTest do
  use SimpleshopTheme.DataCase, async: false
  use Oban.Testing, repo: SimpleshopTheme.Repo

  alias SimpleshopTheme.Workers.ImageVariants
  alias SimpleshopTheme.Media.Image
  import SimpleshopTheme.ImageFixtures

  setup do
    cleanup_cache()
    on_exit(&cleanup_cache/0)
    :ok
  end

  test "processes image and marks complete" do
    image = image_fixture(variants_status: :pending)

    assert :ok = perform_job(ImageVariants, %{image_id: image.id})

    updated = Repo.get!(Image, image.id)
    assert updated.variants_status == :complete
  end

  test "handles missing image gracefully" do
    assert {:error, :not_found} =
             perform_job(ImageVariants, %{image_id: Ecto.UUID.generate()})
  end

  test "skips SVG and marks complete" do
    image = svg_fixture()

    assert :ok = perform_job(ImageVariants, %{image_id: image.id})

    updated = Repo.get!(Image, image.id)
    assert updated.variants_status == :complete
  end
end

test/simpleshop_theme_web/components/shop_components_test.exs:

defmodule SimpleshopThemeWeb.ShopComponentsTest do
  use SimpleshopThemeWeb.ConnCase, async: true

  import Phoenix.LiveViewTest
  alias SimpleshopThemeWeb.ShopComponents

  describe "responsive_image/1" do
    test "builds srcset with all widths for 1200px source" do
      html =
        render_component(&ShopComponents.responsive_image/1, %{
          src: "/images/test",
          source_width: 1200,
          alt: "Test image"
        })

      # AVIF source
      assert html =~ ~s(type="image/avif")
      assert html =~ "/images/test-400.avif 400w"
      assert html =~ "/images/test-800.avif 800w"
      assert html =~ "/images/test-1200.avif 1200w"

      # WebP source
      assert html =~ ~s(type="image/webp")

      # Fallback img
      assert html =~ ~s(src="/images/test-1200.jpg")
      assert html =~ ~s(alt="Test image")
    end

    test "excludes widths larger than source" do
      html =
        render_component(&ShopComponents.responsive_image/1, %{
          src: "/images/test",
          source_width: 600,
          alt: "Small image"
        })

      assert html =~ "400w"
      refute html =~ "800w"
      refute html =~ "1200w"
    end

    test "uses source width when smaller than 400" do
      html =
        render_component(&ShopComponents.responsive_image/1, %{
          src: "/images/tiny",
          source_width: 200,
          alt: "Tiny"
        })

      assert html =~ "200w"
      refute html =~ "400w"
    end

    test "sets lazy loading by default" do
      html =
        render_component(&ShopComponents.responsive_image/1, %{
          src: "/images/test",
          source_width: 1200,
          alt: "Test"
        })

      assert html =~ ~s(loading="lazy")
      assert html =~ ~s(decoding="async")
      refute html =~ ~s(fetchpriority)
    end

    test "sets eager loading when priority=true" do
      html =
        render_component(&ShopComponents.responsive_image/1, %{
          src: "/images/test",
          source_width: 1200,
          alt: "Test",
          priority: true
        })

      assert html =~ ~s(loading="eager")
      assert html =~ ~s(decoding="sync")
      assert html =~ ~s(fetchpriority="high")
    end

    test "includes width/height when provided" do
      html =
        render_component(&ShopComponents.responsive_image/1, %{
          src: "/images/test",
          source_width: 1200,
          alt: "Test",
          width: 600,
          height: 400
        })

      assert html =~ ~s(width="600")
      assert html =~ ~s(height="400")
    end
  end
end

test/mix/tasks/optimize_images_test.exs:

defmodule Mix.Tasks.OptimizeImagesTest do
  use ExUnit.Case, async: false

  import ExUnit.CaptureIO

  @test_dir "test/fixtures/mockups_test"

  setup do
    # Create test directory with a sample image
    File.mkdir_p!(@test_dir)

    # Copy sample image to test directory
    sample = File.read!("test/fixtures/sample_1200x800.jpg")
    File.write!(Path.join(@test_dir, "test-product.jpg"), sample)

    on_exit(fn ->
      File.rm_rf!(@test_dir)
    end)

    :ok
  end

  describe "run/1" do
    test "generates variants for mockup images" do
      output = capture_io(fn ->
        Mix.Tasks.OptimizeImages.run(["--dir", @test_dir])
      end)

      assert output =~ "Found 1 original mockup images"
      assert output =~ "test-product: generated"
      assert output =~ "Done!"

      # Verify generated files
      assert File.exists?(Path.join(@test_dir, "test-product-400.avif"))
      assert File.exists?(Path.join(@test_dir, "test-product-400.webp"))
      assert File.exists?(Path.join(@test_dir, "test-product-400.jpg"))
      assert File.exists?(Path.join(@test_dir, "test-product-800.avif"))
      assert File.exists?(Path.join(@test_dir, "test-product-800.webp"))
      assert File.exists?(Path.join(@test_dir, "test-product-800.jpg"))
      assert File.exists?(Path.join(@test_dir, "test-product-1200.avif"))
      assert File.exists?(Path.join(@test_dir, "test-product-1200.webp"))
      assert File.exists?(Path.join(@test_dir, "test-product-1200.jpg"))
    end

    test "skips already generated variants" do
      # First run
      capture_io(fn -> Mix.Tasks.OptimizeImages.run(["--dir", @test_dir]) end)

      # Second run should skip
      output = capture_io(fn ->
        Mix.Tasks.OptimizeImages.run(["--dir", @test_dir])
      end)

      assert output =~ "Found 1 original mockup images"
      refute output =~ "generated"  # Should skip, not generate
    end

    test "regenerates with --force flag" do
      # First run
      capture_io(fn -> Mix.Tasks.OptimizeImages.run(["--dir", @test_dir]) end)

      # Get mtime of generated file
      path = Path.join(@test_dir, "test-product-400.avif")
      {:ok, %{mtime: mtime1}} = File.stat(path)

      Process.sleep(1000)

      # Force regeneration
      output = capture_io(fn ->
        Mix.Tasks.OptimizeImages.run(["--dir", @test_dir, "--force"])
      end)

      assert output =~ "generated"

      {:ok, %{mtime: mtime2}} = File.stat(path)
      assert mtime2 > mtime1, "File should have been regenerated"
    end

    test "excludes variant files from processing" do
      # Create a file that looks like a variant (should be ignored)
      variant_path = Path.join(@test_dir, "test-product-400.jpg")
      File.write!(variant_path, "fake variant")

      output = capture_io(fn ->
        Mix.Tasks.OptimizeImages.run(["--dir", @test_dir])
      end)

      # Should only find the original, not the fake variant
      assert output =~ "Found 1 original mockup images"
    end

    test "handles empty directory" do
      empty_dir = Path.join(@test_dir, "empty")
      File.mkdir_p!(empty_dir)

      output = capture_io(fn ->
        Mix.Tasks.OptimizeImages.run(["--dir", empty_dir])
      end)

      assert output =~ "Found 0 original mockup images"
      assert output =~ "Done!"
    end
  end
end

What's NOT Tested (Intentionally)

  • VariantCache GenServer - Startup behavior is tested via integration/manual testing
  • ImageController thumbnail - Covered by existing controller tests or integration

Test Coverage Goals

Module Coverage Goal Notes
Optimizer 90%+ Core logic, all edge cases
ImageVariants worker 80%+ Happy path + error handling
responsive_image component 90%+ All attribute combinations
Mix.Tasks.OptimizeImages 80%+ Variant generation, skip logic, force flag
VariantCache Manual Startup behavior