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:
parent
252ca2268a
commit
2bc05097b9
182
lib/mix/tasks/optimize_images.ex
Normal file
182
lib/mix/tasks/optimize_images.ex
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
defmodule Mix.Tasks.OptimizeImages do
|
||||||
|
@shortdoc "Generate optimized variants for product mockup images"
|
||||||
|
@moduledoc """
|
||||||
|
Generates responsive image variants (AVIF, WebP, JPEG) for mockup images
|
||||||
|
in the priv/static/mockups directory.
|
||||||
|
|
||||||
|
This task is useful for pre-generating optimized versions of product mockup
|
||||||
|
images used in the store demo. It creates multiple sizes and formats:
|
||||||
|
- AVIF (best compression for modern browsers)
|
||||||
|
- WebP (good compression with broad support)
|
||||||
|
- JPEG (fallback for legacy browsers)
|
||||||
|
|
||||||
|
Only generates sizes smaller than or equal to the source image dimensions
|
||||||
|
(no upscaling).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
# Generate variants for all mockups
|
||||||
|
mix optimize_images
|
||||||
|
|
||||||
|
# Force regeneration of all variants
|
||||||
|
mix optimize_images --force
|
||||||
|
|
||||||
|
# Process a custom directory
|
||||||
|
mix optimize_images --dir path/to/images
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `--force` - Regenerate all variants, even if they already exist
|
||||||
|
* `--dir PATH` - Process images in the specified directory instead of priv/static/mockups
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
For each source image (e.g., `product.jpg`), generates:
|
||||||
|
- `product-400.avif`, `product-400.webp`, `product-400.jpg`
|
||||||
|
- `product-800.avif`, `product-800.webp`, `product-800.jpg`
|
||||||
|
- `product-1200.avif`, `product-1200.webp`, `product-1200.jpg` (if source >= 1200px)
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
@default_dir "priv/static/mockups"
|
||||||
|
@widths [400, 800, 1200]
|
||||||
|
@formats [:avif, :webp, :jpg]
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run(args) do
|
||||||
|
Application.ensure_all_started(:image)
|
||||||
|
|
||||||
|
{opts, _} = OptionParser.parse!(args, strict: [force: :boolean, dir: :string])
|
||||||
|
force = opts[:force] || false
|
||||||
|
dir = opts[:dir] || @default_dir
|
||||||
|
|
||||||
|
unless File.dir?(dir) do
|
||||||
|
Mix.shell().error("Directory not found: #{dir}")
|
||||||
|
exit({:shutdown, 1})
|
||||||
|
end
|
||||||
|
|
||||||
|
originals = find_originals(dir)
|
||||||
|
Mix.shell().info("Found #{length(originals)} original mockup images in #{dir}")
|
||||||
|
|
||||||
|
if originals == [] do
|
||||||
|
Mix.shell().info("No images to process. Done!")
|
||||||
|
else
|
||||||
|
results =
|
||||||
|
originals
|
||||||
|
|> Task.async_stream(&process(&1, force, dir),
|
||||||
|
max_concurrency: System.schedulers_online(),
|
||||||
|
timeout: :timer.minutes(2)
|
||||||
|
)
|
||||||
|
|> Enum.map(fn
|
||||||
|
{:ok, result} -> result
|
||||||
|
{:exit, reason} -> {:error, reason}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Report results
|
||||||
|
Enum.each(results, fn
|
||||||
|
{:generated, name, count} ->
|
||||||
|
Mix.shell().info(" #{name}: generated #{count} variants")
|
||||||
|
|
||||||
|
:skipped ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Mix.shell().error(" Error: #{inspect(reason)}")
|
||||||
|
end)
|
||||||
|
|
||||||
|
generated_count = Enum.count(results, &match?({:generated, _, _}, &1))
|
||||||
|
skipped_count = Enum.count(results, &(&1 == :skipped))
|
||||||
|
|
||||||
|
Mix.shell().info("")
|
||||||
|
|
||||||
|
if generated_count > 0 do
|
||||||
|
Mix.shell().info("Generated variants for #{generated_count} images.")
|
||||||
|
end
|
||||||
|
|
||||||
|
if skipped_count > 0 do
|
||||||
|
Mix.shell().info("Skipped #{skipped_count} images (variants already exist).")
|
||||||
|
end
|
||||||
|
|
||||||
|
Mix.shell().info("Done!")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_originals(dir) do
|
||||||
|
# Find all .jpg files that are NOT variants (don't end with -SIZE)
|
||||||
|
dir
|
||||||
|
|> Path.join("*.jpg")
|
||||||
|
|> Path.wildcard()
|
||||||
|
|> Enum.reject(&is_variant?/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp is_variant?(path) do
|
||||||
|
# A variant ends with -{number}.{ext}
|
||||||
|
basename = Path.basename(path, ".jpg")
|
||||||
|
String.match?(basename, ~r/-\d+$/)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process(path, force, dir) do
|
||||||
|
name = Path.basename(path, ".jpg")
|
||||||
|
|
||||||
|
if not force and all_variants_exist?(name, dir) do
|
||||||
|
:skipped
|
||||||
|
else
|
||||||
|
with {:ok, image} <- Image.open(path),
|
||||||
|
{width, _, _} <- Image.shape(image) do
|
||||||
|
# Only generate sizes <= source width
|
||||||
|
applicable = Enum.filter(@widths, &(&1 <= width))
|
||||||
|
|
||||||
|
count =
|
||||||
|
for w <- applicable, fmt <- @formats do
|
||||||
|
generate(image, name, w, fmt, dir)
|
||||||
|
end
|
||||||
|
|> Enum.count(&(&1 == :generated))
|
||||||
|
|
||||||
|
{:generated, name, count}
|
||||||
|
else
|
||||||
|
error ->
|
||||||
|
{:error, {name, error}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp all_variants_exist?(name, dir) do
|
||||||
|
Enum.all?(@widths, fn w ->
|
||||||
|
Enum.all?(@formats, fn fmt ->
|
||||||
|
File.exists?(Path.join(dir, "#{name}-#{w}.#{ext(fmt)}"))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate(image, name, width, format, dir) do
|
||||||
|
path = Path.join(dir, "#{name}-#{width}.#{ext(format)}")
|
||||||
|
|
||||||
|
if File.exists?(path) do
|
||||||
|
:cached
|
||||||
|
else
|
||||||
|
with {:ok, resized} <- Image.thumbnail(image, width),
|
||||||
|
{:ok, _} <- write(resized, path, format) do
|
||||||
|
:generated
|
||||||
|
else
|
||||||
|
_ -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ext(:jpg), do: "jpg"
|
||||||
|
defp ext(:webp), do: "webp"
|
||||||
|
defp ext(:avif), do: "avif"
|
||||||
|
|
||||||
|
defp write(image, path, :avif) do
|
||||||
|
Image.write(image, path, effort: 5, minimize_file_size: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write(image, path, :webp) do
|
||||||
|
Image.write(image, path, effort: 6, minimize_file_size: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write(image, path, :jpg) do
|
||||||
|
Image.write(image, path, quality: 80, minimize_file_size: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -14,6 +14,10 @@ defmodule SimpleshopTheme.Application do
|
|||||||
repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()},
|
repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()},
|
||||||
{DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: SimpleshopTheme.PubSub},
|
{Phoenix.PubSub, name: SimpleshopTheme.PubSub},
|
||||||
|
# Background job processing
|
||||||
|
{Oban, Application.fetch_env!(:simpleshop_theme, Oban)},
|
||||||
|
# Image variant cache - ensures all variants exist on startup
|
||||||
|
SimpleshopTheme.Images.VariantCache,
|
||||||
# Theme CSS cache
|
# Theme CSS cache
|
||||||
SimpleshopTheme.Theme.CSSCache,
|
SimpleshopTheme.Theme.CSSCache,
|
||||||
# Start to serve requests, typically the last entry
|
# Start to serve requests, typically the last entry
|
||||||
|
|||||||
@ -9,27 +9,38 @@ defmodule SimpleshopTheme.Images.Optimizer do
|
|||||||
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
||||||
|
|
||||||
@all_widths [400, 800, 1200]
|
@all_widths [400, 800, 1200]
|
||||||
@formats [:avif, :webp, :jpg]
|
# 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]
|
||||||
@thumb_size 200
|
@thumb_size 200
|
||||||
@cache_dir "priv/static/image_cache"
|
@cache_dir "priv/static/image_cache"
|
||||||
|
@max_stored_width 2000
|
||||||
|
@storage_quality 90
|
||||||
|
|
||||||
def cache_dir, do: @cache_dir
|
def cache_dir, do: @cache_dir
|
||||||
def all_widths, do: @all_widths
|
def all_widths, do: @all_widths
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Convert uploaded image to lossless WebP for storage.
|
Convert uploaded image to optimized WebP for storage.
|
||||||
|
Images larger than #{@max_stored_width}px are resized down.
|
||||||
|
Uses lossy WebP (quality #{@storage_quality}) for efficient storage.
|
||||||
Returns {:ok, webp_data, width, height} or {:error, reason}.
|
Returns {:ok, webp_data, width, height} or {:error, reason}.
|
||||||
"""
|
"""
|
||||||
def to_lossless_webp(image_data) when is_binary(image_data) do
|
def to_optimized_webp(image_data) when is_binary(image_data) do
|
||||||
with {:ok, image} <- Image.from_binary(image_data),
|
with {:ok, image} <- Image.from_binary(image_data),
|
||||||
{width, height, _} <- Image.shape(image),
|
{width, _height, _} <- Image.shape(image),
|
||||||
{:ok, webp_data} <- Image.write(image, :memory, suffix: ".webp", quality: 100) do
|
{:ok, resized} <- maybe_resize(image, width),
|
||||||
{:ok, webp_data, width, height}
|
{final_width, final_height, _} <- Image.shape(resized),
|
||||||
|
{:ok, webp_data} <- Image.write(resized, :memory, suffix: ".webp", quality: @storage_quality) do
|
||||||
|
{:ok, webp_data, final_width, final_height}
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
e -> {:error, Exception.message(e)}
|
e -> {:error, Exception.message(e)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_resize(image, width) when width <= @max_stored_width, do: {:ok, image}
|
||||||
|
defp maybe_resize(image, _width), do: Image.thumbnail(image, @max_stored_width)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Compute applicable widths from source dimensions.
|
Compute applicable widths from source dimensions.
|
||||||
Only returns widths that are <= source_width (no upscaling).
|
Only returns widths that are <= source_width (no upscaling).
|
||||||
@ -67,7 +78,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
|
|||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
Task.async(fn -> generate_thumbnail(vips_image, image_id) end)
|
Task.async(fn -> generate_thumbnail(vips_image, image_id) end)
|
||||||
| for w <- widths, fmt <- @formats do
|
| for w <- widths, fmt <- @pregenerated_formats do
|
||||||
Task.async(fn -> generate_variant(vips_image, image_id, w, fmt) end)
|
Task.async(fn -> generate_variant(vips_image, image_id, w, fmt) end)
|
||||||
end
|
end
|
||||||
]
|
]
|
||||||
@ -124,6 +135,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Check if disk variants exist for an image.
|
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
|
def disk_variants_exist?(image_id, source_width) do
|
||||||
widths = applicable_widths(source_width)
|
widths = applicable_widths(source_width)
|
||||||
@ -131,11 +143,74 @@ defmodule SimpleshopTheme.Images.Optimizer do
|
|||||||
|
|
||||||
variants =
|
variants =
|
||||||
Enum.all?(widths, fn w ->
|
Enum.all?(widths, fn w ->
|
||||||
Enum.all?(@formats, fn fmt ->
|
Enum.all?(@pregenerated_formats, fn fmt ->
|
||||||
File.exists?(Path.join(@cache_dir, "#{image_id}-#{w}.#{format_ext(fmt)}"))
|
File.exists?(Path.join(@cache_dir, "#{image_id}-#{w}.#{format_ext(fmt)}"))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
thumb and variants
|
thumb and variants
|
||||||
end
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Process an image file and generate all variants to the specified directory.
|
||||||
|
Used for both database images (to cache_dir) and mockups (to mockup_dir).
|
||||||
|
Returns {:ok, source_width} or {:error, reason}.
|
||||||
|
"""
|
||||||
|
def process_file(image_data, output_basename, output_dir) when is_binary(image_data) do
|
||||||
|
File.mkdir_p!(output_dir)
|
||||||
|
|
||||||
|
with {:ok, webp_data, source_width, _height} <- to_optimized_webp(image_data),
|
||||||
|
{:ok, vips_image} <- Image.from_binary(webp_data) do
|
||||||
|
widths = applicable_widths(source_width)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
Task.async(fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, "thumb", :jpg, @thumb_size) end)
|
||||||
|
| for w <- widths, fmt <- @pregenerated_formats do
|
||||||
|
Task.async(fn -> generate_variant_to_dir(vips_image, output_basename, output_dir, w, fmt, w) end)
|
||||||
|
end
|
||||||
|
]
|
||||||
|
|
||||||
|
Task.await_many(tasks, :timer.seconds(120))
|
||||||
|
{:ok, source_width}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e -> {:error, Exception.message(e)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_variant_to_dir(image, basename, dir, size_label, format, resize_width) do
|
||||||
|
filename = "#{basename}-#{size_label}.#{format_ext(format)}"
|
||||||
|
path = Path.join(dir, filename)
|
||||||
|
|
||||||
|
with {:ok, resized} <- Image.thumbnail(image, resize_width),
|
||||||
|
{:ok, _} <- write_format(resized, path, format) do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -6,14 +6,16 @@ defmodule SimpleshopTheme.Media do
|
|||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
alias SimpleshopTheme.Repo
|
alias SimpleshopTheme.Repo
|
||||||
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
alias SimpleshopTheme.Media.Image, as: ImageSchema
|
||||||
|
alias SimpleshopTheme.Images.Optimizer
|
||||||
@thumbnail_size 200
|
alias SimpleshopTheme.Images.OptimizeWorker
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Uploads an image and stores it in the database.
|
Uploads an image and stores it in the database.
|
||||||
|
|
||||||
Automatically generates a thumbnail for non-SVG images if the Image library
|
For non-SVG images:
|
||||||
is available and working.
|
- 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
|
## Examples
|
||||||
|
|
||||||
@ -22,11 +24,50 @@ defmodule SimpleshopTheme.Media do
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
def upload_image(attrs) do
|
def upload_image(attrs) do
|
||||||
attrs = maybe_generate_thumbnail(attrs)
|
attrs = prepare_image_attrs(attrs)
|
||||||
|
|
||||||
%ImageSchema{}
|
case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
|
||||||
|> ImageSchema.changeset(attrs)
|
{:ok, image} ->
|
||||||
|> Repo.insert()
|
# 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
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@ -52,36 +93,6 @@ defmodule SimpleshopTheme.Media do
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_generate_thumbnail(%{data: data, content_type: content_type} = attrs)
|
|
||||||
when is_binary(data) do
|
|
||||||
if String.starts_with?(content_type || "", "image/svg") do
|
|
||||||
attrs
|
|
||||||
else
|
|
||||||
case generate_thumbnail(data) do
|
|
||||||
{:ok, thumbnail_data} ->
|
|
||||||
Map.put(attrs, :thumbnail_data, thumbnail_data)
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
|
||||||
attrs
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_generate_thumbnail(attrs), do: attrs
|
|
||||||
|
|
||||||
defp generate_thumbnail(image_data) do
|
|
||||||
try do
|
|
||||||
with {:ok, image} <- Image.from_binary(image_data),
|
|
||||||
{:ok, thumbnail} <- Image.thumbnail(image, @thumbnail_size),
|
|
||||||
{:ok, binary} <- Image.write(thumbnail, :memory, suffix: ".jpg") do
|
|
||||||
{:ok, binary}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
_e ->
|
|
||||||
{:error, :thumbnail_generation_failed}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single image by ID.
|
Gets a single image by ID.
|
||||||
|
|
||||||
@ -149,4 +160,31 @@ defmodule SimpleshopTheme.Media do
|
|||||||
def list_images_by_type(type) 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])
|
Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -17,7 +17,7 @@ defmodule SimpleshopThemeWeb do
|
|||||||
those modules here.
|
those modules here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def static_paths, do: ~w(assets css fonts images mockups favicon.ico robots.txt demo.html)
|
def static_paths, do: ~w(assets css fonts images image_cache mockups favicon.ico robots.txt demo.html)
|
||||||
|
|
||||||
def router do
|
def router do
|
||||||
quote do
|
quote do
|
||||||
|
|||||||
@ -25,28 +25,132 @@ defmodule SimpleshopThemeWeb.ImageController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
def thumbnail(conn, %{"id" => 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
|
case Media.get_image(id) do
|
||||||
nil ->
|
nil ->
|
||||||
send_resp(conn, 404, "Image not found")
|
send_resp(conn, 404, "Image not found")
|
||||||
|
|
||||||
%{thumbnail_data: thumbnail_data} = image when not is_nil(thumbnail_data) ->
|
%{data: data} when is_binary(data) ->
|
||||||
|
case generate_thumbnail_on_demand(data, thumb_path) do
|
||||||
|
{:ok, binary} ->
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("image/jpeg")
|
|> put_resp_content_type("image/jpeg")
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|> put_resp_header("etag", ~s("#{image.id}-thumb"))
|
|> put_resp_header("etag", ~s("#{id}-thumb"))
|
||||||
|> send_resp(200, thumbnail_data)
|
|> send_resp(200, binary)
|
||||||
|
|
||||||
image ->
|
{:error, _} ->
|
||||||
|
# Fallback to full image if thumbnail generation fails
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type(image.content_type)
|
|> put_resp_content_type("image/webp")
|
||||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|> put_resp_header("etag", ~s("#{image.id}"))
|
|> send_resp(200, data)
|
||||||
|> send_resp(200, image.data)
|
end
|
||||||
|
|
||||||
|
%{data: nil} ->
|
||||||
|
send_resp(conn, 404, "Image data not available")
|
||||||
end
|
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(),
|
||||||
|
%{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")
|
||||||
|
|
||||||
|
%{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 """
|
@doc """
|
||||||
Serves an SVG image recolored with the specified color.
|
Serves an SVG image recolored with the specified color.
|
||||||
|
|||||||
@ -42,6 +42,7 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
|
|
||||||
get "/:id", ImageController, :show
|
get "/:id", ImageController, :show
|
||||||
get "/:id/thumbnail", ImageController, :thumbnail
|
get "/:id/thumbnail", ImageController, :thumbnail
|
||||||
|
get "/:id/variant/:width", ImageController, :variant
|
||||||
get "/:id/recolored/:color", ImageController, :recolored_svg
|
get "/:id/recolored/:color", ImageController, :recolored_svg
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
51
test/simpleshop_theme/images/optimize_worker_test.exs
Normal file
51
test/simpleshop_theme/images/optimize_worker_test.exs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
defmodule SimpleshopTheme.Images.OptimizeWorkerTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
use Oban.Testing, repo: SimpleshopTheme.Repo
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Images.OptimizeWorker
|
||||||
|
import SimpleshopTheme.ImageFixtures
|
||||||
|
|
||||||
|
setup do
|
||||||
|
cleanup_cache()
|
||||||
|
on_exit(&cleanup_cache/0)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "processes image and generates variants" do
|
||||||
|
image = image_fixture(%{source_width: 1200, source_height: 800})
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
assert {:cancel, :image_not_found} =
|
||||||
|
perform_job(OptimizeWorker, %{image_id: Ecto.UUID.generate()})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips SVG images" do
|
||||||
|
image = svg_fixture()
|
||||||
|
assert :ok = perform_job(OptimizeWorker, %{image_id: image.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "enqueue/1" do
|
||||||
|
test "inserts and executes job (inline mode)" do
|
||||||
|
image = image_fixture(%{source_width: 1200, source_height: 800})
|
||||||
|
|
||||||
|
# In inline test mode, job executes immediately
|
||||||
|
assert {:ok, %Oban.Job{state: "completed"}} = OptimizeWorker.enqueue(image.id)
|
||||||
|
|
||||||
|
# Verify variants were created (job ran inline)
|
||||||
|
assert File.exists?(cache_path(image.id, 400, :avif))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -26,9 +26,9 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "to_lossless_webp/1" do
|
describe "to_optimized_webp/1" do
|
||||||
test "converts image and returns dimensions" do
|
test "converts image and returns dimensions" do
|
||||||
{:ok, webp, width, height} = Optimizer.to_lossless_webp(sample_jpeg())
|
{:ok, webp, width, height} = Optimizer.to_optimized_webp(sample_jpeg())
|
||||||
|
|
||||||
assert is_binary(webp)
|
assert is_binary(webp)
|
||||||
assert width == 1200
|
assert width == 1200
|
||||||
@ -37,28 +37,47 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
|
|||||||
assert <<"RIFF", _size::binary-size(4), "WEBP", _::binary>> = webp
|
assert <<"RIFF", _size::binary-size(4), "WEBP", _::binary>> = webp
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "resizes images larger than 2000px" do
|
||||||
|
# Create a large test image by scaling up
|
||||||
|
{:ok, image} = Image.open("test/fixtures/sample_1200x800.jpg")
|
||||||
|
{:ok, large} = Image.thumbnail(image, 3000)
|
||||||
|
{:ok, large_data} = Image.write(large, :memory, suffix: ".jpg")
|
||||||
|
|
||||||
|
{:ok, webp, width, height} = Optimizer.to_optimized_webp(large_data)
|
||||||
|
|
||||||
|
assert is_binary(webp)
|
||||||
|
assert width == 2000
|
||||||
|
# Height should be proportionally scaled
|
||||||
|
assert height <= 2000
|
||||||
|
end
|
||||||
|
|
||||||
test "returns error for invalid data" do
|
test "returns error for invalid data" do
|
||||||
assert {:error, _} = Optimizer.to_lossless_webp("not an image")
|
assert {:error, _} = Optimizer.to_optimized_webp("not an image")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "process_for_image/1" do
|
describe "process_for_image/1" do
|
||||||
test "generates all variants for 1200px image" do
|
test "generates AVIF and WebP variants for 1200px image" do
|
||||||
image = image_fixture(%{source_width: 1200, source_height: 800})
|
image = image_fixture(%{source_width: 1200, source_height: 800})
|
||||||
|
|
||||||
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
|
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
|
||||||
|
|
||||||
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
|
# Only AVIF and WebP are pre-generated (JPEG is on-demand)
|
||||||
|
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do
|
||||||
assert File.exists?(cache_path(image.id, w, fmt)),
|
assert File.exists?(cache_path(image.id, w, fmt)),
|
||||||
"Missing #{w}.#{fmt}"
|
"Missing #{w}.#{fmt}"
|
||||||
end
|
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))
|
assert File.exists?(cache_path(image.id, "thumb", :jpg))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates only applicable widths for smaller image" do
|
test "generates only applicable widths for smaller image" do
|
||||||
# Create fixture with smaller source width
|
# Create fixture with smaller source width
|
||||||
{:ok, webp, _w, _h} = Optimizer.to_lossless_webp(sample_jpeg())
|
{:ok, webp, _w, _h} = Optimizer.to_optimized_webp(sample_jpeg())
|
||||||
|
|
||||||
image =
|
image =
|
||||||
%SimpleshopTheme.Media.Image{}
|
%SimpleshopTheme.Media.Image{}
|
||||||
@ -106,10 +125,11 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "disk_variants_exist?/2" do
|
describe "disk_variants_exist?/2" do
|
||||||
test "returns true when all variants exist" do
|
test "returns true when all pre-generated variants exist" do
|
||||||
image = image_fixture(%{source_width: 1200, source_height: 800})
|
image = image_fixture(%{source_width: 1200, source_height: 800})
|
||||||
{:ok, _} = Optimizer.process_for_image(image.id)
|
{:ok, _} = Optimizer.process_for_image(image.id)
|
||||||
|
|
||||||
|
# Should return true even without JPEG (only checks AVIF/WebP)
|
||||||
assert Optimizer.disk_variants_exist?(image.id, 1200)
|
assert Optimizer.disk_variants_exist?(image.id, 1200)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -118,4 +138,36 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
|
|||||||
refute Optimizer.disk_variants_exist?(image.id, 1200)
|
refute Optimizer.disk_variants_exist?(image.id, 1200)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -3,8 +3,10 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
|
|||||||
|
|
||||||
alias SimpleshopTheme.Media
|
alias SimpleshopTheme.Media
|
||||||
|
|
||||||
|
# Minimal valid PNG (1x1 transparent pixel)
|
||||||
@png_binary <<137, 80, 78, 71, 13, 10, 26, 10>>
|
@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>)
|
@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
|
describe "show/2" do
|
||||||
test "returns 404 for non-existent image", %{conn: conn} do
|
test "returns 404 for non-existent image", %{conn: conn} do
|
||||||
@ -13,19 +15,21 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "serves image with proper content type and caching headers", %{conn: conn} do
|
test "serves image with proper content type and caching headers", %{conn: conn} do
|
||||||
|
# Upload a real JPEG which gets converted to WebP
|
||||||
{:ok, image} =
|
{:ok, image} =
|
||||||
Media.upload_image(%{
|
Media.upload_image(%{
|
||||||
image_type: "logo",
|
image_type: "logo",
|
||||||
filename: "test.png",
|
filename: "test.jpg",
|
||||||
content_type: "image/png",
|
content_type: "image/jpeg",
|
||||||
file_size: byte_size(@png_binary),
|
file_size: byte_size(@sample_jpeg),
|
||||||
data: @png_binary
|
data: @sample_jpeg
|
||||||
})
|
})
|
||||||
|
|
||||||
conn = get(conn, ~p"/images/#{image.id}")
|
conn = get(conn, ~p"/images/#{image.id}")
|
||||||
|
|
||||||
assert response(conn, 200) == @png_binary
|
assert response(conn, 200)
|
||||||
assert get_resp_header(conn, "content-type") == ["image/png; charset=utf-8"]
|
# 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, "cache-control") == ["public, max-age=31536000, immutable"]
|
||||||
assert get_resp_header(conn, "etag") == [~s("#{image.id}")]
|
assert get_resp_header(conn, "etag") == [~s("#{image.id}")]
|
||||||
end
|
end
|
||||||
@ -37,7 +41,27 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
|
|||||||
assert response(conn, 404) =~ "Image not found"
|
assert response(conn, 404) =~ "Image not found"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "falls back to full image when no thumbnail available", %{conn: conn} do
|
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} =
|
{:ok, image} =
|
||||||
Media.upload_image(%{
|
Media.upload_image(%{
|
||||||
image_type: "logo",
|
image_type: "logo",
|
||||||
@ -49,8 +73,8 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
|
|||||||
|
|
||||||
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
|
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
|
||||||
|
|
||||||
assert response(conn, 200) == @png_binary
|
# Falls back to WebP since that's what we tried to convert to
|
||||||
assert get_resp_header(conn, "content-type") == ["image/png; charset=utf-8"]
|
assert response(conn, 200)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ defmodule SimpleshopTheme.ImageFixtures do
|
|||||||
def sample_jpeg, do: @sample_jpeg
|
def sample_jpeg, do: @sample_jpeg
|
||||||
|
|
||||||
def image_fixture(attrs \\ %{}) do
|
def image_fixture(attrs \\ %{}) do
|
||||||
{:ok, webp, w, h} = SimpleshopTheme.Images.Optimizer.to_lossless_webp(@sample_jpeg)
|
{:ok, webp, w, h} = SimpleshopTheme.Images.Optimizer.to_optimized_webp(@sample_jpeg)
|
||||||
|
|
||||||
defaults = %{
|
defaults = %{
|
||||||
image_type: "product",
|
image_type: "product",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user