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:
parent
81e94d0d65
commit
bb358f890b
@ -40,3 +40,6 @@ config :phoenix_live_view,
|
||||
|
||||
# Use inline testing mode for Oban
|
||||
config :simpleshop_theme, Oban, testing: :inline
|
||||
|
||||
# Isolate image cache so test cleanup doesn't wipe the dev cache
|
||||
config :simpleshop_theme, :image_cache_dir, Path.expand("../tmp/test_image_cache", __DIR__)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;"
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
[] -> []
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -17,13 +17,9 @@ defmodule SimpleshopTheme.Images.OptimizeWorkerTest do
|
||||
|
||||
assert :ok = perform_job(OptimizeWorker, %{image_id: image.id})
|
||||
|
||||
# Verify pre-generated variants were created (AVIF and WebP only, not JPEG)
|
||||
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do
|
||||
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
|
||||
assert File.exists?(cache_path(image.id, w, fmt))
|
||||
end
|
||||
|
||||
# JPEG is generated on-demand, not pre-generated
|
||||
refute File.exists?(cache_path(image.id, 400, :jpg))
|
||||
end
|
||||
|
||||
test "cancels for missing image" do
|
||||
|
||||
@ -62,16 +62,14 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
|
||||
|
||||
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
|
||||
|
||||
# Only AVIF and WebP are pre-generated (JPEG is on-demand)
|
||||
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do
|
||||
# Source WebP for Plug.Static serving
|
||||
assert File.exists?(Path.join(Optimizer.cache_dir(), "#{image.id}.webp"))
|
||||
|
||||
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
|
||||
assert File.exists?(cache_path(image.id, w, fmt)),
|
||||
"Missing #{w}.#{fmt}"
|
||||
end
|
||||
|
||||
# JPEG should NOT be pre-generated
|
||||
refute File.exists?(cache_path(image.id, 400, :jpg))
|
||||
|
||||
# Thumbnail is still pre-generated as JPEG
|
||||
assert File.exists?(cache_path(image.id, "thumb", :jpg))
|
||||
end
|
||||
|
||||
@ -138,36 +136,4 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
|
||||
refute Optimizer.disk_variants_exist?(image.id, 1200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_jpeg_on_demand/3" do
|
||||
test "generates JPEG variant and caches to disk" do
|
||||
image = image_fixture(%{source_width: 1200, source_height: 800})
|
||||
|
||||
# JPEG shouldn't exist yet
|
||||
refute File.exists?(cache_path(image.id, 400, :jpg))
|
||||
|
||||
# Generate on-demand
|
||||
{:ok, path} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 400)
|
||||
|
||||
assert File.exists?(path)
|
||||
assert path == cache_path(image.id, 400, :jpg)
|
||||
end
|
||||
|
||||
test "returns cached path if JPEG already exists" do
|
||||
image = image_fixture(%{source_width: 1200, source_height: 800})
|
||||
|
||||
# Generate first time
|
||||
{:ok, path1} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800)
|
||||
{:ok, %{mtime: mtime1}} = File.stat(path1)
|
||||
|
||||
Process.sleep(1100)
|
||||
|
||||
# Generate second time - should return cached
|
||||
{:ok, path2} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800)
|
||||
{:ok, %{mtime: mtime2}} = File.stat(path2)
|
||||
|
||||
assert path1 == path2
|
||||
assert mtime1 == mtime2, "File was regenerated instead of cached"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -67,28 +67,49 @@ defmodule SimpleshopTheme.Products.ProductImageTest do
|
||||
# Display helpers
|
||||
# =============================================================================
|
||||
|
||||
describe "display_url/2" do
|
||||
describe "url/2" do
|
||||
test "prefers local image_id over src" do
|
||||
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.display_url(image) == "/images/abc-123/variant/800.webp"
|
||||
assert ProductImage.url(image) == "/image_cache/abc-123-800.webp"
|
||||
end
|
||||
|
||||
test "accepts custom size" do
|
||||
test "accepts custom width" do
|
||||
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.display_url(image, 400) == "/images/abc-123/variant/400.webp"
|
||||
assert ProductImage.url(image, 400) == "/image_cache/abc-123-400.webp"
|
||||
end
|
||||
|
||||
test "handles mockup URLs with size suffix" do
|
||||
image = %{image_id: nil, src: "/mockups/product-1"}
|
||||
assert ProductImage.url(image, 800) == "/mockups/product-1-800.webp"
|
||||
end
|
||||
|
||||
test "falls back to src when no image_id" do
|
||||
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.display_url(image) == "https://cdn.example.com/img.jpg"
|
||||
assert ProductImage.url(image) == "https://cdn.example.com/img.jpg"
|
||||
end
|
||||
|
||||
test "returns nil when neither image_id nor src" do
|
||||
assert ProductImage.display_url(%{image_id: nil, src: nil}) == nil
|
||||
assert ProductImage.url(%{image_id: nil, src: nil}) == nil
|
||||
end
|
||||
|
||||
test "returns nil for nil input" do
|
||||
assert ProductImage.display_url(nil) == nil
|
||||
assert ProductImage.url(nil) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "thumbnail_url/1" do
|
||||
test "returns thumb path for local image" do
|
||||
image = %{image_id: "abc-123"}
|
||||
assert ProductImage.thumbnail_url(image) == "/image_cache/abc-123-thumb.jpg"
|
||||
end
|
||||
|
||||
test "falls back to src when no image_id" do
|
||||
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.thumbnail_url(image) == "https://cdn.example.com/img.jpg"
|
||||
end
|
||||
|
||||
test "returns nil for nil input" do
|
||||
assert ProductImage.thumbnail_url(nil) == nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -3,81 +3,9 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
|
||||
|
||||
alias SimpleshopTheme.Media
|
||||
|
||||
# Minimal valid PNG (1x1 transparent pixel)
|
||||
@png_binary <<137, 80, 78, 71, 13, 10, 26, 10>>
|
||||
@svg_content ~s(<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#000000" r="10"/></svg>)
|
||||
@sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg")
|
||||
|
||||
describe "show/2" do
|
||||
test "returns 404 for non-existent image", %{conn: conn} do
|
||||
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}")
|
||||
assert response(conn, 404) =~ "Image not found"
|
||||
end
|
||||
|
||||
test "serves image with proper content type and caching headers", %{conn: conn} do
|
||||
# Upload a real JPEG which gets converted to WebP
|
||||
{:ok, image} =
|
||||
Media.upload_image(%{
|
||||
image_type: "logo",
|
||||
filename: "test.jpg",
|
||||
content_type: "image/jpeg",
|
||||
file_size: byte_size(@sample_jpeg),
|
||||
data: @sample_jpeg
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/images/#{image.id}")
|
||||
|
||||
assert response(conn, 200)
|
||||
# Image is converted to WebP for storage
|
||||
assert get_resp_header(conn, "content-type") == ["image/webp; charset=utf-8"]
|
||||
assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
|
||||
assert get_resp_header(conn, "etag") == [~s("#{image.id}")]
|
||||
end
|
||||
end
|
||||
|
||||
describe "thumbnail/2" do
|
||||
test "returns 404 for non-existent image", %{conn: conn} do
|
||||
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/thumbnail")
|
||||
assert response(conn, 404) =~ "Image not found"
|
||||
end
|
||||
|
||||
test "generates thumbnail on-demand and serves as JPEG", %{conn: conn} do
|
||||
# Upload a real image to test thumbnail generation
|
||||
{:ok, image} =
|
||||
Media.upload_image(%{
|
||||
image_type: "logo",
|
||||
filename: "test.jpg",
|
||||
content_type: "image/jpeg",
|
||||
file_size: byte_size(@sample_jpeg),
|
||||
data: @sample_jpeg
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
|
||||
|
||||
assert response(conn, 200)
|
||||
# Thumbnail is served as JPEG
|
||||
assert get_resp_header(conn, "content-type") == ["image/jpeg; charset=utf-8"]
|
||||
assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
|
||||
end
|
||||
|
||||
test "falls back to full image when image data is invalid", %{conn: conn} do
|
||||
# This uses an invalid PNG header that can't be processed
|
||||
{:ok, image} =
|
||||
Media.upload_image(%{
|
||||
image_type: "logo",
|
||||
filename: "test.png",
|
||||
content_type: "image/png",
|
||||
file_size: byte_size(@png_binary),
|
||||
data: @png_binary
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
|
||||
|
||||
# Falls back to WebP since that's what we tried to convert to
|
||||
assert response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "recolored_svg/2" do
|
||||
test "returns 404 for non-existent image", %{conn: conn} do
|
||||
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/recolored/ff6600")
|
||||
@ -88,10 +16,10 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
|
||||
{:ok, image} =
|
||||
Media.upload_image(%{
|
||||
image_type: "logo",
|
||||
filename: "test.png",
|
||||
content_type: "image/png",
|
||||
file_size: byte_size(@png_binary),
|
||||
data: @png_binary
|
||||
filename: "test.jpg",
|
||||
content_type: "image/jpeg",
|
||||
file_size: byte_size(@sample_jpeg),
|
||||
data: @sample_jpeg
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/images/#{image.id}/recolored/ff6600")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user