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:
53
lib/simpleshop_theme/images/optimize_worker.ex
Normal file
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
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
|
||||
|
||||
Reference in New Issue
Block a user