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>
191 lines
4.3 KiB
Elixir
191 lines
4.3 KiB
Elixir
defmodule SimpleshopTheme.Media do
|
|
@moduledoc """
|
|
The Media context for managing images and file uploads.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
alias SimpleshopTheme.Repo
|
|
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
|
alias SimpleshopTheme.Images.Optimizer
|
|
alias SimpleshopTheme.Images.OptimizeWorker
|
|
|
|
@doc """
|
|
Uploads an image and stores it in the database.
|
|
|
|
For non-SVG images:
|
|
- Converts to lossless WebP for storage (26-41% smaller than PNG)
|
|
- Extracts source dimensions for responsive variant generation
|
|
- Enqueues background job to generate optimized variants (AVIF, WebP, JPEG at multiple sizes)
|
|
|
|
## Examples
|
|
|
|
iex> upload_image(%{image_type: "logo", filename: "logo.png", ...})
|
|
{:ok, %Image{}}
|
|
|
|
"""
|
|
def upload_image(attrs) do
|
|
attrs = prepare_image_attrs(attrs)
|
|
|
|
case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
|
|
{:ok, image} ->
|
|
# Enqueue background job for non-SVG images
|
|
unless image.is_svg do
|
|
OptimizeWorker.enqueue(image.id)
|
|
end
|
|
|
|
{:ok, image}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
# Prepares image attributes, converting to lossless WebP and extracting dimensions
|
|
defp prepare_image_attrs(%{data: data, content_type: content_type} = attrs)
|
|
when is_binary(data) do
|
|
if is_svg?(content_type, attrs[:filename]) do
|
|
attrs
|
|
else
|
|
case Optimizer.to_optimized_webp(data) do
|
|
{:ok, webp_data, width, height} ->
|
|
attrs
|
|
|> Map.put(:data, webp_data)
|
|
|> Map.put(:content_type, "image/webp")
|
|
|> Map.put(:file_size, byte_size(webp_data))
|
|
|> Map.put(:source_width, width)
|
|
|> Map.put(:source_height, height)
|
|
|> Map.put(:variants_status, "pending")
|
|
|
|
{:error, _reason} ->
|
|
# If conversion fails, store original image
|
|
attrs
|
|
end
|
|
end
|
|
end
|
|
|
|
defp prepare_image_attrs(attrs), do: attrs
|
|
|
|
defp is_svg?(content_type, filename) do
|
|
content_type == "image/svg+xml" or
|
|
String.ends_with?(filename || "", ".svg")
|
|
end
|
|
|
|
@doc """
|
|
Uploads an image from a LiveView upload entry.
|
|
|
|
This handles consuming the upload and extracting metadata from the entry.
|
|
|
|
## Examples
|
|
|
|
iex> upload_from_entry(socket, :logo_upload, fn path, entry -> ... end)
|
|
{:ok, %Image{}}
|
|
|
|
"""
|
|
def upload_from_entry(path, entry, image_type) do
|
|
file_binary = File.read!(path)
|
|
|
|
upload_image(%{
|
|
image_type: image_type,
|
|
filename: entry.client_name,
|
|
content_type: entry.client_type,
|
|
file_size: entry.client_size,
|
|
data: file_binary
|
|
})
|
|
end
|
|
|
|
@doc """
|
|
Gets a single image by ID.
|
|
|
|
## Examples
|
|
|
|
iex> get_image(id)
|
|
%Image{}
|
|
|
|
iex> get_image("nonexistent")
|
|
nil
|
|
|
|
"""
|
|
def get_image(id) do
|
|
Repo.get(ImageSchema, id)
|
|
end
|
|
|
|
@doc """
|
|
Gets the current logo image.
|
|
|
|
## Examples
|
|
|
|
iex> get_logo()
|
|
%Image{}
|
|
|
|
"""
|
|
def get_logo do
|
|
Repo.one(from i in ImageSchema, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1)
|
|
end
|
|
|
|
@doc """
|
|
Gets the current header image.
|
|
|
|
## Examples
|
|
|
|
iex> get_header()
|
|
%Image{}
|
|
|
|
"""
|
|
def get_header do
|
|
Repo.one(from i in ImageSchema, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1)
|
|
end
|
|
|
|
@doc """
|
|
Deletes an image.
|
|
|
|
## Examples
|
|
|
|
iex> delete_image(image)
|
|
{:ok, %Image{}}
|
|
|
|
"""
|
|
def delete_image(%ImageSchema{} = image) do
|
|
Repo.delete(image)
|
|
end
|
|
|
|
@doc """
|
|
Lists all images of a specific type.
|
|
|
|
## Examples
|
|
|
|
iex> list_images_by_type("logo")
|
|
[%Image{}, ...]
|
|
|
|
"""
|
|
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
|