feat: optimize mockup images with WebP and auto-regeneration

Convert mockup source images from JPG to WebP format for 76% size
reduction (20MB → 4.7MB). Variants are now auto-generated on startup
via Oban, keeping the same DRY approach as database images.

Changes:
- Add OptimizeWorker.enqueue_mockup/1 for filesystem images
- Extend VariantCache to check mockup sources on startup
- Update MockupGenerator to save source as optimized WebP
- Update .gitignore to ignore generated variants
- Convert 55 source mockups from JPG to WebP

The mockup pipeline now uses the same code paths as database images:
- Optimizer.to_optimized_webp/1 for source conversion
- Optimizer.process_file/3 for variant generation
- OptimizeWorker for Oban background processing
- VariantCache for startup cache validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-25 00:30:42 +00:00
parent 2b5b749a69
commit 252ca2268a
114 changed files with 185 additions and 7 deletions

11
.gitignore vendored
View File

@ -45,11 +45,20 @@ simpleshop_theme-*.tar
/priv/static/*.gz /priv/static/*.gz
# Digested fonts have 32-char hash before extension # Digested fonts have 32-char hash before extension
/priv/static/fonts/*-????????????????????????????????.woff2 /priv/static/fonts/*-????????????????????????????????.woff2
/priv/static/mockups/*-*.jpg
/priv/static/images/*-*.svg /priv/static/images/*-*.svg
/priv/static/images/*-*.svg.gz /priv/static/images/*-*.svg.gz
/priv/static/images/*.gz /priv/static/images/*.gz
# Generated mockup variants (auto-generated on startup via Oban)
# Source .webp files are tracked, variants are regenerated
/priv/static/mockups/*-400.*
/priv/static/mockups/*-800.*
/priv/static/mockups/*-1200.*
/priv/static/mockups/*-thumb.jpg
# Generated image variants cache (regenerated from source images)
/priv/static/image_cache/
# In case you use Node.js/npm, you want to ignore these. # In case you use Node.js/npm, you want to ignore these.
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/

View File

@ -0,0 +1,53 @@
defmodule SimpleshopTheme.Images.OptimizeWorker do
@moduledoc """
Oban worker for processing image variants in the background.
Handles both database images and filesystem mockups.
"""
use Oban.Worker, queue: :images, max_attempts: 3
alias SimpleshopTheme.Images.Optimizer
@impl Oban.Worker
def perform(%Oban.Job{args: %{"type" => "mockup", "source_path" => source_path}}) do
output_dir = Path.dirname(source_path)
basename = Path.basename(source_path, Path.extname(source_path))
case File.read(source_path) do
{:ok, data} ->
case Optimizer.process_file(data, basename, output_dir) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
def perform(%Oban.Job{args: %{"image_id" => image_id}}) do
case Optimizer.process_for_image(image_id) do
{:ok, _} -> :ok
{:error, :not_found} -> {:cancel, :image_not_found}
{:error, :no_data} -> {:cancel, :no_image_data}
{:error, reason} -> {:error, reason}
end
end
@doc """
Enqueue a database image for optimization.
"""
def enqueue(image_id) do
%{image_id: image_id}
|> new()
|> Oban.insert()
end
@doc """
Enqueue a mockup file for variant generation.
"""
def enqueue_mockup(source_path) when is_binary(source_path) do
%{type: "mockup", source_path: source_path}
|> new()
|> Oban.insert()
end
end

View File

@ -0,0 +1,101 @@
defmodule SimpleshopTheme.Images.VariantCache do
@moduledoc """
Ensures all image variants exist on startup.
This GenServer runs at startup and checks for:
1. Database images with incomplete variants_status or missing disk files
2. Mockup source files missing their generated variants
For any images missing variants, it enqueues Oban jobs to regenerate them.
"""
use GenServer
require Logger
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Media.Image, as: ImageSchema
alias SimpleshopTheme.Images.{Optimizer, OptimizeWorker}
import Ecto.Query
@mockup_dir "priv/static/mockups"
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
Task.start(fn -> ensure_all_variants() end)
{:ok, %{}}
end
defp ensure_all_variants do
Logger.info("[VariantCache] Checking image variant cache...")
File.mkdir_p!(Optimizer.cache_dir())
ensure_database_image_variants()
ensure_mockup_variants()
end
defp ensure_database_image_variants do
incomplete =
ImageSchema
|> where([i], i.variants_status != "complete" or is_nil(i.variants_status))
|> where([i], i.is_svg == false)
|> Repo.all()
complete_missing =
ImageSchema
|> where([i], i.variants_status == "complete")
|> where([i], i.is_svg == false)
|> where([i], not is_nil(i.source_width))
|> 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("[VariantCache] All database image variants up to date")
else
Logger.info("[VariantCache] Enqueueing #{length(to_process)} database images for processing")
Enum.each(to_process, fn image ->
image
|> ImageSchema.changeset(%{variants_status: "pending"})
|> Repo.update!()
OptimizeWorker.enqueue(image.id)
end)
end
end
defp ensure_mockup_variants do
if File.dir?(@mockup_dir) do
sources =
Path.wildcard(Path.join(@mockup_dir, "*.webp"))
|> Enum.reject(&is_variant?/1)
missing = Enum.reject(sources, &mockup_variants_exist?/1)
if missing == [] do
Logger.info("[VariantCache] All mockup variants up to date")
else
Logger.info("[VariantCache] Enqueueing #{length(missing)} mockups for processing")
Enum.each(missing, &OptimizeWorker.enqueue_mockup/1)
end
end
end
defp is_variant?(path) do
basename = Path.basename(path) |> Path.rootname()
String.match?(basename, ~r/-(400|800|1200|thumb)$/)
end
defp mockup_variants_exist?(source_path) do
basename = Path.basename(source_path) |> Path.rootname()
dir = Path.dirname(source_path)
File.exists?(Path.join(dir, "#{basename}-800.webp"))
end
end

View File

@ -328,20 +328,35 @@ defmodule SimpleshopTheme.Printify.MockupGenerator do
end end
@doc """ @doc """
Download mockup images to the output directory. Download mockup images, save as WebP source, and generate variants.
Sources are saved for regeneration on startup via VariantCache.
""" """
def download_mockups(product_slug, mockup_urls) do def download_mockups(product_slug, mockup_urls) do
alias SimpleshopTheme.Images.Optimizer
File.mkdir_p!(@output_dir) File.mkdir_p!(@output_dir)
mockup_urls mockup_urls
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.map(fn {url, index} -> |> Enum.map(fn {url, index} ->
output_path = Path.join(@output_dir, "#{product_slug}-#{index}.jpg") basename = "#{product_slug}-#{index}"
IO.puts(" Downloading mockup #{index} to #{output_path}...") source_path = Path.join(@output_dir, "#{basename}.webp")
IO.puts(" Processing mockup #{index}...")
case Client.download_file(url, output_path) do temp_path = Path.join(System.tmp_dir!(), "#{basename}-temp.jpg")
{:ok, path} -> {:ok, path}
{:error, reason} -> {:error, {url, reason}} with {:ok, _} <- Client.download_file(url, temp_path),
{:ok, image_data} <- File.read(temp_path),
{:ok, webp_data, source_width, _} <- Optimizer.to_optimized_webp(image_data),
:ok <- File.write(source_path, webp_data),
{:ok, _} <- Optimizer.process_file(webp_data, basename, @output_dir) do
File.rm(temp_path)
IO.puts(" Saved source + variants for #{basename} (#{source_width}px)")
{:ok, basename, source_width}
else
{:error, reason} ->
File.rm(temp_path)
{:error, {url, reason}}
end end
end) end)
end end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Some files were not shown because too many files have changed in this diff Show More