simpleshop_theme/lib/mix/tasks/optimize_images.ex

183 lines
4.9 KiB
Elixir
Raw Normal View History

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