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>
11
.gitignore
vendored
@ -45,11 +45,20 @@ simpleshop_theme-*.tar
|
||||
/priv/static/*.gz
|
||||
# Digested fonts have 32-char hash before extension
|
||||
/priv/static/fonts/*-????????????????????????????????.woff2
|
||||
/priv/static/mockups/*-*.jpg
|
||||
/priv/static/images/*-*.svg
|
||||
/priv/static/images/*-*.svg.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.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
53
lib/simpleshop_theme/images/optimize_worker.ex
Normal 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
|
||||
101
lib/simpleshop_theme/images/variant_cache.ex
Normal 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
|
||||
@ -328,20 +328,35 @@ defmodule SimpleshopTheme.Printify.MockupGenerator do
|
||||
end
|
||||
|
||||
@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
|
||||
alias SimpleshopTheme.Images.Optimizer
|
||||
|
||||
File.mkdir_p!(@output_dir)
|
||||
|
||||
mockup_urls
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {url, index} ->
|
||||
output_path = Path.join(@output_dir, "#{product_slug}-#{index}.jpg")
|
||||
IO.puts(" Downloading mockup #{index} to #{output_path}...")
|
||||
basename = "#{product_slug}-#{index}"
|
||||
source_path = Path.join(@output_dir, "#{basename}.webp")
|
||||
IO.puts(" Processing mockup #{index}...")
|
||||
|
||||
case Client.download_file(url, output_path) do
|
||||
{:ok, path} -> {:ok, path}
|
||||
{:error, reason} -> {:error, {url, reason}}
|
||||
temp_path = Path.join(System.tmp_dir!(), "#{basename}-temp.jpg")
|
||||
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 351 KiB |
BIN
priv/static/mockups/autumn-leaves-notebook-1.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 313 KiB |
BIN
priv/static/mockups/autumn-leaves-notebook-2.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 329 KiB |
BIN
priv/static/mockups/autumn-leaves-notebook-3.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 420 KiB |
BIN
priv/static/mockups/autumn-leaves-notebook-4.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 258 KiB |
BIN
priv/static/mockups/blue-waves-laptop-sleeve-1.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 262 KiB |
BIN
priv/static/mockups/blue-waves-laptop-sleeve-2.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 512 KiB |
BIN
priv/static/mockups/blue-waves-laptop-sleeve-3.webp
Normal file
|
After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 359 KiB |
BIN
priv/static/mockups/botanical-illustration-print-1.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 507 KiB |
BIN
priv/static/mockups/botanical-illustration-print-2.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 883 KiB |
BIN
priv/static/mockups/botanical-illustration-print-3.webp
Normal file
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 274 KiB |
BIN
priv/static/mockups/fern-leaf-mug-1.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 268 KiB |
BIN
priv/static/mockups/fern-leaf-mug-2.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 220 KiB |
BIN
priv/static/mockups/fern-leaf-mug-3.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 914 KiB |
BIN
priv/static/mockups/fern-leaf-mug-4.webp
Normal file
|
After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 141 KiB |
BIN
priv/static/mockups/forest-light-hoodie-1.webp
Normal file
|
After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 129 KiB |
BIN
priv/static/mockups/forest-light-hoodie-2.webp
Normal file
|
After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 52 KiB |
BIN
priv/static/mockups/forest-silhouette-tshirt-1.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 47 KiB |
BIN
priv/static/mockups/forest-silhouette-tshirt-2.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 67 KiB |
BIN
priv/static/mockups/forest-silhouette-tshirt-3.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 51 KiB |
BIN
priv/static/mockups/forest-silhouette-tshirt-4.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 107 KiB |
BIN
priv/static/mockups/geometric-abstract-print-1.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 413 KiB |
BIN
priv/static/mockups/geometric-abstract-print-2.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 860 KiB |
BIN
priv/static/mockups/geometric-abstract-print-3.webp
Normal file
|
After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 399 KiB |
BIN
priv/static/mockups/monstera-leaf-notebook-1.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 386 KiB |
BIN
priv/static/mockups/monstera-leaf-notebook-2.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 376 KiB |
BIN
priv/static/mockups/monstera-leaf-notebook-3.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 418 KiB |
BIN
priv/static/mockups/monstera-leaf-notebook-4.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 123 KiB |
BIN
priv/static/mockups/monstera-leaf-phone-case-1.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 108 KiB |
BIN
priv/static/mockups/monstera-leaf-phone-case-2.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 96 KiB |
BIN
priv/static/mockups/monstera-leaf-phone-case-3.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 131 KiB |
BIN
priv/static/mockups/monstera-leaf-phone-case-4.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 142 KiB |
BIN
priv/static/mockups/mountain-sunrise-print-1.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 422 KiB |
BIN
priv/static/mockups/mountain-sunrise-print-2.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 864 KiB |
BIN
priv/static/mockups/mountain-sunrise-print-3.webp
Normal file
|
After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 520 KiB |
BIN
priv/static/mockups/night-sky-blanket-1.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 417 KiB |
BIN
priv/static/mockups/night-sky-blanket-2.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 919 KiB |
BIN
priv/static/mockups/night-sky-blanket-3.webp
Normal file
|
After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 654 KiB |
BIN
priv/static/mockups/night-sky-blanket-4.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 165 KiB |
BIN
priv/static/mockups/ocean-waves-cushion-1.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 162 KiB |
BIN
priv/static/mockups/ocean-waves-cushion-2.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 442 KiB |
BIN
priv/static/mockups/ocean-waves-cushion-3.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 191 KiB |
BIN
priv/static/mockups/ocean-waves-print-1.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 436 KiB |
BIN
priv/static/mockups/ocean-waves-print-2.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 869 KiB |
BIN
priv/static/mockups/ocean-waves-print-3.webp
Normal file
|
After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 225 KiB |
BIN
priv/static/mockups/sunset-gradient-tote-1.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 195 KiB |
BIN
priv/static/mockups/sunset-gradient-tote-2.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 428 KiB |
BIN
priv/static/mockups/sunset-gradient-tote-3.webp
Normal file
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 407 KiB |
BIN
priv/static/mockups/sunset-gradient-tote-4.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |