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