consolidate image serving and clean up pipeline

Move all image URL logic into ProductImage.url/2 and thumbnail_url/1,
remove dead on-demand generation code from Optimizer, strip controller
routes down to SVG recolor only, fix mockup startup check to verify all
variant formats, and isolate test image cache directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-16 17:47:41 +00:00
parent 81e94d0d65
commit bb358f890b
21 changed files with 134 additions and 428 deletions

View File

@@ -8,6 +8,7 @@ defmodule SimpleshopTheme.Cart do
"""
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProductImage
@session_key "cart"
@@ -148,17 +149,9 @@ defmodule SimpleshopTheme.Cart do
defp format_variant_options(_), do: nil
defp variant_image_url(product) do
# Get first image from preloaded images
case product.images do
[first | _] ->
if first.image_id do
"/images/#{first.image_id}/variant/400.webp"
else
first.src
end
_ ->
nil
[first | _] -> ProductImage.url(first, 400)
_ -> nil
end
end

View File

@@ -9,16 +9,26 @@ defmodule SimpleshopTheme.Images.Optimizer do
alias SimpleshopTheme.Media.Image, as: ImageSchema
@all_widths [400, 800, 1200]
# JPEG is generated on-demand to save ~50% disk space
# Only affects <5% of users (legacy browsers without AVIF/WebP support)
@pregenerated_formats [:avif, :webp]
@pregenerated_formats [:avif, :webp, :jpg]
@thumb_size 200
@max_stored_width 2000
@storage_quality 90
def cache_dir, do: Application.app_dir(:simpleshop_theme, "priv/static/image_cache")
def cache_dir do
Application.get_env(:simpleshop_theme, :image_cache_dir) ||
Application.app_dir(:simpleshop_theme, "priv/static/image_cache")
end
def all_widths, do: @all_widths
@doc """
Returns the expected disk path for a variant file.
Used to check the cache without loading the BLOB from the database.
"""
def variant_path(image_id, width, format) do
Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
end
@doc """
Convert uploaded image to optimized WebP for storage.
Images larger than #{@max_stored_width}px are resized down.
@@ -73,6 +83,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
%{data: data, source_width: width} = image ->
File.mkdir_p!(cache_dir())
# Write source WebP to disk so it can be served by Plug.Static
source_path = Path.join(cache_dir(), "#{image_id}.webp")
unless File.exists?(source_path), do: File.write!(source_path, data)
with {:ok, vips_image} <- Image.from_binary(data) do
widths = applicable_widths(width)
@@ -135,10 +149,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
@doc """
Check if disk variants exist for an image.
Only checks pre-generated formats (AVIF, WebP). JPEG is generated on-demand.
"""
def disk_variants_exist?(image_id, source_width) do
widths = applicable_widths(source_width)
source = File.exists?(Path.join(cache_dir(), "#{image_id}.webp"))
thumb = File.exists?(Path.join(cache_dir(), "#{image_id}-thumb.jpg"))
variants =
@@ -148,34 +162,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
end)
end)
thumb and variants
end
@doc """
Generate a variant on-demand. Returns path to generated file.
Supports all formats: :avif, :webp, :jpg
Used as fallback if cache files are deleted, and for JPEG legacy browser support.
"""
def generate_variant_on_demand(image_data, image_id, width, format)
when is_binary(image_data) and format in [:avif, :webp, :jpg] do
path = Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
if File.exists?(path) do
{:ok, path}
else
File.mkdir_p!(cache_dir())
with {:ok, vips_image} <- Image.from_binary(image_data),
{:ok, resized} <- Image.thumbnail(vips_image, width),
{:ok, _} <- write_format(resized, path, format) do
{:ok, path}
end
end
end
# Backward compatibility alias
def generate_jpeg_on_demand(image_data, image_id, width) do
generate_variant_on_demand(image_data, image_id, width, :jpg)
source and thumb and variants
end
@doc """

View File

@@ -51,37 +51,56 @@ defmodule SimpleshopTheme.Images.VariantCache do
end
defp ensure_database_image_variants do
incomplete =
# Only load IDs and source_width for the disk check — avoids loading BLOBs
incomplete_ids =
ImageSchema
|> where([i], i.variants_status != "complete" or is_nil(i.variants_status))
|> where([i], i.is_svg == false)
|> select([i], {i.id, i.source_width})
|> Repo.all()
complete_missing =
complete_missing_ids =
ImageSchema
|> where([i], i.variants_status == "complete")
|> where([i], i.is_svg == false)
|> where([i], not is_nil(i.source_width))
|> select([i], {i.id, i.source_width})
|> Repo.all()
|> Enum.reject(fn img ->
Optimizer.disk_variants_exist?(img.id, img.source_width)
|> Enum.reject(fn {id, source_width} ->
Optimizer.disk_variants_exist?(id, source_width)
end)
to_process = incomplete ++ complete_missing
to_process = incomplete_ids ++ complete_missing_ids
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"
"[VariantCache] Processing #{length(to_process)} images with missing variants..."
)
Enum.each(to_process, fn image ->
image
|> ImageSchema.changeset(%{variants_status: "pending"})
|> Repo.update!()
# Process directly instead of round-tripping through Oban — more reliable at startup
to_process
|> Task.async_stream(
fn {id, _source_width} ->
case Optimizer.process_for_image(id) do
{:ok, _} ->
:ok
OptimizeWorker.enqueue(image.id)
{:error, reason} ->
Logger.warning("[VariantCache] Failed to process #{id}: #{inspect(reason)}")
end
end,
max_concurrency: 4,
timeout: :timer.seconds(30),
on_timeout: :kill_task
)
|> Enum.count(fn
{:ok, :ok} -> true
_ -> false
end)
|> then(fn count ->
Logger.info("[VariantCache] Processed #{count}/#{length(to_process)} image variants")
end)
end
end
@@ -111,7 +130,12 @@ defmodule SimpleshopTheme.Images.VariantCache do
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"))
expected =
["#{basename}-thumb.jpg"] ++
for w <- [400, 800, 1200], ext <- ["avif", "webp", "jpg"], do: "#{basename}-#{w}.#{ext}"
Enum.all?(expected, &File.exists?(Path.join(dir, &1)))
end
defp ensure_product_image_downloads do

View File

@@ -170,31 +170,4 @@ defmodule SimpleshopTheme.Media do
def list_images_by_type(type) do
Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
end
@doc """
Gets the path to a thumbnail on disk.
## Examples
iex> get_thumbnail_path("abc123-def456")
"priv/static/image_cache/abc123-def456-thumb.jpg"
"""
def get_thumbnail_path(image_id) do
Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg")
end
@doc """
Gets the path to a variant on disk.
## Examples
iex> get_variant_path("abc123-def456", 800, :webp)
"priv/static/image_cache/abc123-def456-800.webp"
"""
def get_variant_path(image_id, width, format) do
ext = Atom.to_string(format)
Path.join(Optimizer.cache_dir(), "#{image_id}-#{width}.#{ext}")
end
end

View File

@@ -183,7 +183,7 @@ defmodule SimpleshopTheme.Products do
)
|> Repo.one()
|> case do
{id, _src} when not is_nil(id) -> "/images/#{id}/variant/400.webp"
{id, _src} when not is_nil(id) -> "/image_cache/#{id}-400.webp"
{_, src} when is_binary(src) -> src
_ -> nil
end

View File

@@ -40,30 +40,28 @@ defmodule SimpleshopTheme.Products.ProductImage do
# ---------------------------------------------------------------------------
@doc """
Returns the display URL for a product image at the given size.
Prefers local image_id (responsive optimized), falls back to CDN src.
Returns the URL for a product image variant at the given width.
Prefers local image_id (static file), falls back to CDN src.
Handles mockup URL patterns that need size suffixes.
"""
def display_url(image, size \\ 800)
def url(image, width \\ 800)
def display_url(%{image_id: id}, size) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp"
def url(%{image_id: id}, width) when not is_nil(id),
do: "/image_cache/#{id}-#{width}.webp"
def display_url(%{src: src}, _size) when is_binary(src), do: src
def display_url(_, _), do: nil
def url(%{src: "/mockups/" <> _ = src}, width), do: "#{src}-#{width}.webp"
def url(%{src: src}, _width) when is_binary(src), do: src
def url(_, _), do: nil
@doc """
Returns a fully resolved URL for an image at the given size.
Unlike `display_url/2`, handles mockup URL patterns that need size suffixes.
Use for `<img>` tags where the URL must resolve to an actual file.
Returns the URL for the pre-generated 200px thumbnail.
Used for small previews (admin lists, cart items).
"""
def direct_url(image, size \\ 800)
def thumbnail_url(%{image_id: id}) when not is_nil(id),
do: "/image_cache/#{id}-thumb.jpg"
def direct_url(%{image_id: id}, size) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp"
def direct_url(%{src: "/mockups/" <> _ = src}, size), do: "#{src}-#{size}.webp"
def direct_url(%{src: src}, _size) when is_binary(src), do: src
def direct_url(_, _), do: nil
def thumbnail_url(%{src: src}) when is_binary(src), do: src
def thumbnail_url(_), do: nil
@doc """
Returns the source width from the linked Media.Image, if preloaded.