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