183 lines
4.9 KiB
Elixir
183 lines
4.9 KiB
Elixir
|
|
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
|