feat: enhance image optimization with on-demand JPEG fallbacks

Improve the image optimization pipeline with better compression and
smarter variant generation:

- Change to_lossless_webp → to_optimized_webp (lossy, quality 90)
- Auto-resize uploads larger than 2000px to save storage
- Skip pre-generating JPEG variants (~50% disk savings)
- Add on-demand JPEG generation for legacy browsers (<5% of users)
- Add /images/:id/variant/:width route for dynamic serving
- Add VariantCache to supervision tree for startup validation
- Add image_cache to static paths for disk-based serving

The pipeline now stores smaller WebP sources and generates AVIF/WebP
variants upfront, with JPEG generated only when legacy browsers request it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 00:33:09 +00:00
parent 252ca2268a
commit 2bc05097b9
11 changed files with 610 additions and 79 deletions

View File

@@ -25,29 +25,133 @@ defmodule SimpleshopThemeWeb.ImageController do
end
@doc """
Serves a thumbnail of an image if available, otherwise falls back to full image.
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
case Media.get_image(id) do
thumb_path = Media.get_thumbnail_path(id)
if File.exists?(thumb_path) do
# Serve from disk cache
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", ~s("#{id}-thumb"))
|> send_file(200, thumb_path)
else
# Thumbnail not yet generated - generate on-demand
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", ~s("#{id}-thumb"))
|> send_resp(200, binary)
{:error, _} ->
# Fallback to full image if thumbnail generation fails
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
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(),
%{data: data} when is_binary(data) <- Media.get_image(id) do
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", ~s("#{id}-#{width}.#{format}"))
|> send_file(200, path)
{:error, _reason} ->
send_resp(conn, 500, "Failed to generate variant")
end
else
:error ->
send_resp(conn, 400, "Invalid width or format")
false ->
send_resp(conn, 400, "Width not supported")
nil ->
send_resp(conn, 404, "Image not found")
%{thumbnail_data: thumbnail_data} = image when not is_nil(thumbnail_data) ->
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", ~s("#{image.id}-thumb"))
|> send_resp(200, thumbnail_data)
image ->
conn
|> put_resp_content_type(image.content_type)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", ~s("#{image.id}"))
|> send_resp(200, image.data)
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
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.

View File

@@ -42,6 +42,7 @@ defmodule SimpleshopThemeWeb.Router do
get "/:id", ImageController, :show
get "/:id/thumbnail", ImageController, :thumbnail
get "/:id/variant/:width", ImageController, :variant
get "/:id/recolored/:color", ImageController, :recolored_svg
end