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