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.

View File

@@ -414,7 +414,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
end
defp cart_item_image(product) do
ProductImage.direct_url(Product.primary_image(product), 400)
ProductImage.url(Product.primary_image(product), 400)
end
# Shared delivery line used by both cart_drawer and order_summary.

View File

@@ -1046,15 +1046,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available)
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-"
assigns =
assigns
|> assign(:available_widths, available)
|> assign(:default_width, default_width)
|> assign(:separator, separator)
~H"""
<picture>
@@ -1069,7 +1064,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
sizes={@sizes}
/>
<img
src={"#{@src}#{@separator}#{@default_width}.jpg"}
src={"#{@src}-#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes}
alt={@alt}
@@ -1163,12 +1158,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
end
defp build_srcset(base, widths, format) do
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(base, "/"), do: "", else: "-"
widths
|> Enum.sort()
|> Enum.map_join(", ", &"#{base}#{separator}#{&1}.#{format} #{&1}w")
|> Enum.map_join(", ", &"#{base}-#{&1}.#{format} #{&1}w")
end
end

View File

@@ -382,7 +382,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|> Enum.with_index()
|> Enum.map(fn {product, idx} ->
image = Product.primary_image(product)
%{product: product, image_url: ProductImage.direct_url(image, 400), idx: idx}
%{product: product, image_url: ProductImage.url(image, 400), idx: idx}
end)
)
@@ -880,7 +880,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
"/images/#{logo_image.id}/recolored/#{clean_color}"
end
defp logo_url(logo_image, _), do: "/images/#{logo_image.id}"
defp logo_url(logo_image, _), do: "/image_cache/#{logo_image.id}.webp"
# Logo content that links to home, except when already on home page.
# This follows accessibility best practices - current page should not be a link.
@@ -970,7 +970,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
defp header_background_style(settings, header_image) do
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
"background-image: url('/images/#{header_image.id}'); " <>
"background-image: url('/image_cache/#{header_image.id}.webp'); " <>
"background-size: #{settings.header_zoom}%; " <>
"background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <>
"background-repeat: no-repeat; z-index: 0;"

View File

@@ -213,7 +213,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
{nil, nil}
Map.get(image, :image_id) ->
{"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
{"/image_cache/#{image.image_id}", ProductImage.source_width(image)}
Map.get(image, :src) ->
{Map.get(image, :src), ProductImage.source_width(image)}

View File

@@ -4,168 +4,6 @@ defmodule SimpleshopThemeWeb.ImageController do
alias SimpleshopTheme.Media
alias SimpleshopTheme.Media.SVGRecolorer
@doc """
Serves an image from the database by ID.
Images are served with aggressive caching headers since they are
immutable once uploaded.
"""
def show(conn, %{"id" => id}) do
etag = ~s("#{id}")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
image ->
conn
|> put_resp_content_type(image.content_type)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, image.data)
end
end
end
@doc """
Serves a thumbnail of an image from the disk cache.
Thumbnails are generated by the background image optimization pipeline.
If the thumbnail doesn't exist on disk yet (still processing), generates
it on-demand and saves it for future requests.
"""
def thumbnail(conn, %{"id" => id}) do
etag = ~s("#{id}-thumb")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
thumb_path = Media.get_thumbnail_path(id)
if File.exists?(thumb_path) do
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, thumb_path)
else
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
%{data: data} when is_binary(data) ->
case generate_thumbnail_on_demand(data, thumb_path) do
{:ok, binary} ->
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, binary)
{:error, _} ->
conn
|> put_resp_content_type("image/webp")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_resp(200, data)
end
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end
end
end
defp generate_thumbnail_on_demand(image_data, thumb_path) do
with {:ok, image} <- Image.from_binary(image_data),
{:ok, thumb} <- Image.thumbnail(image, 200),
{:ok, binary} <- Image.write(thumb, :memory, suffix: ".jpg", quality: 80) do
# Ensure cache directory exists and save for future requests
File.mkdir_p!(Path.dirname(thumb_path))
File.write!(thumb_path, binary)
{:ok, binary}
end
rescue
_ -> {:error, :thumbnail_generation_failed}
end
@supported_formats %{"avif" => :avif, "webp" => :webp, "jpg" => :jpg}
@doc """
Serves an image variant at the specified width and format.
Supports AVIF, WebP, and JPEG formats. If the variant doesn't exist on disk
(e.g., cache was deleted), it will be generated on-demand and cached.
JPEG variants are never pre-generated (to save disk space), so they are
always generated on first request.
## URL format
/images/:id/variant/:width.:format
Examples:
- `/images/abc123/variant/800.avif`
- `/images/abc123/variant/400.webp`
- `/images/abc123/variant/1200.jpg`
"""
def variant(conn, %{"id" => id, "width" => width_with_ext}) do
alias SimpleshopTheme.Images.Optimizer
with {width, format} <- parse_width_and_format(width_with_ext),
true <- width in Optimizer.all_widths() do
etag = ~s("#{id}-#{width}.#{format}")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
case Media.get_image(id) do
%{data: data} when is_binary(data) ->
case Optimizer.generate_variant_on_demand(data, id, width, format) do
{:ok, path} ->
conn
|> put_resp_content_type(format_content_type(format))
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, path)
{:error, _reason} ->
send_resp(conn, 500, "Failed to generate variant")
end
nil ->
send_resp(conn, 404, "Image not found")
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end
else
:error -> send_resp(conn, 400, "Invalid width or format")
false -> send_resp(conn, 400, "Width not supported")
end
end
defp parse_width_and_format(width_with_ext) do
case String.split(width_with_ext, ".") do
[width_str, ext] when is_map_key(@supported_formats, ext) ->
case Integer.parse(width_str) do
{width, ""} -> {width, @supported_formats[ext]}
_ -> :error
end
_ ->
:error
end
end
defp format_content_type(:avif), do: "image/avif"
defp format_content_type(:webp), do: "image/webp"
defp format_content_type(:jpg), do: "image/jpeg"
@doc """
Serves an SVG image recolored with the specified color.
@@ -208,18 +46,4 @@ defmodule SimpleshopThemeWeb.ImageController do
"#" <> color
end
end
defp etag_match?(conn, etag) do
case Plug.Conn.get_req_header(conn, "if-none-match") do
[^etag] -> true
_ -> false
end
end
defp send_not_modified(conn, etag) do
conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(304, "")
end
end

View File

@@ -126,7 +126,7 @@ defmodule SimpleshopThemeWeb.Admin.ProductShow do
class="aspect-square rounded bg-base-200 overflow-hidden"
>
<img
src={ProductImage.display_url(image, 400)}
src={ProductImage.url(image, 400)}
alt={image.alt || @product.title}
class="w-full h-full object-cover"
loading="lazy"

View File

@@ -212,7 +212,7 @@ defmodule SimpleshopThemeWeb.Admin.Products do
url =
if image do
ProductImage.display_url(image, 80)
ProductImage.thumbnail_url(image)
end
alt = (image && image.alt) || assigns.product.title

View File

@@ -472,7 +472,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
|> case do
[] -> []

View File

@@ -159,7 +159,7 @@
<%= if @logo_image do %>
<div class="relative w-16 h-10 bg-base-100 border border-base-300 rounded-lg flex items-center justify-center overflow-hidden">
<img
src={"/images/#{@logo_image.id}"}
src={"/image_cache/#{@logo_image.id}.webp"}
alt="Current logo"
class="max-w-full max-h-full object-contain"
/>
@@ -299,7 +299,7 @@
<%= if @header_image do %>
<div class="relative w-full h-[60px] bg-base-100 border border-base-300 rounded-lg mt-2 overflow-hidden">
<img
src={"/images/#{@header_image.id}"}
src={"/image_cache/#{@header_image.id}.webp"}
alt="Current header background"
class="w-full h-full object-cover"
/>

View File

@@ -20,6 +20,11 @@ defmodule SimpleshopThemeWeb.Router do
plug :accepts, ["json"]
end
# Lightweight pipeline for SVG recoloring — no session, CSRF, auth, or layout
pipeline :image do
plug :put_secure_browser_headers
end
pipeline :printify_webhook do
plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook
end
@@ -88,13 +93,10 @@ defmodule SimpleshopThemeWeb.Router do
post "/cart", CartController, :update
end
# Image serving routes (public, no auth required)
# SVG recoloring (dynamic — can't be pre-generated to disk)
scope "/images", SimpleshopThemeWeb do
pipe_through :browser
pipe_through :image
get "/:id", ImageController, :show
get "/:id/thumbnail", ImageController, :thumbnail
get "/:id/variant/:width", ImageController, :variant
get "/:id/recolored/:color", ImageController, :recolored_svg
end