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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user