defmodule PhoenixImage.Optimizer do @moduledoc """ Internal image processing engine used by `PhoenixImage.Plug`. """ @doc """ Fetches and processes an image from `source`. Supported options: - `:width` positive integer - `:height` positive integer - `:quality` integer in `1..100` - `:format` `:webp | :avif | :jpg | :png` - `:upscale` boolean, default `false` - `:max_upscale_factor` float, default `2.0` Returns: - `{:ok, binary, mime_type, metadata}` on success - `{:error, reason}` on fetch/processing failures """ def process(source, options \\ []) do with {:ok, binary} <- fetch_image(source) do do_process(binary, options) end end defp fetch_image(source) do case Req.get(source) do {:ok, %{status: 200, body: body}} -> {:ok, body} {:ok, %{status: status}} -> {:error, "Failed to fetch image: status #{status}"} {:error, reason} -> {:error, "Failed to fetch image: #{inspect(reason)}"} end end defp do_process(binary, options) do width = Keyword.get(options, :width) height = Keyword.get(options, :height) quality = Keyword.get(options, :quality, 80) format = Keyword.get(options, :format, :webp) upscale = Keyword.get(options, :upscale, false) max_upscale_factor = Keyword.get(options, :max_upscale_factor, 2.0) suffix = suffix_for(format) with {:ok, image} <- Image.from_binary(binary), :ok <- check_format_support(suffix), {:ok, image, meta} <- resize_image(image, width, height, upscale, max_upscale_factor), {:ok, processed_binary} <- Image.write(image, :memory, suffix: suffix, quality: quality) do {:ok, processed_binary, MIME.type(String.trim_leading(suffix, ".")), meta} else {:error, :unsupported_format} -> {:error, "Unsupported format: #{format}. Your libvips installation might be missing the required encoder (e.g., libwebp, libavif)."} {:error, reason} -> {:error, "Image processing failed: #{inspect(reason)}"} end end defp check_format_support(suffix) do case Vix.Vips.Foreign.get_suffixes() do {:ok, suffixes} -> if suffix in suffixes, do: :ok, else: {:error, :unsupported_format} _ -> # If we can't get suffixes, we proceed and let Image.write/3 handle it :ok end end defp resize_image(image, nil, nil, _upscale, _max_factor), do: {:ok, image, %{upscale_skipped: false}} defp resize_image(image, width, height, upscale, max_factor) when is_integer(width) and is_integer(height) do {effective_width, effective_height, skipped?} = constrain_requested_size(image, width, height, upscale, max_factor) # Fit within the requested box while preserving aspect ratio. case Image.thumbnail(image, "#{effective_width}x#{effective_height}", crop: :none) do {:ok, resized} -> {:ok, resized, %{upscale_skipped: skipped?}} error -> error end end defp resize_image(image, width, nil, upscale, max_factor) when is_integer(width) do {effective_width, skipped?} = constrain_width(image, width, upscale, max_factor) # Resize while maintaining aspect ratio, specifying width case Image.thumbnail(image, effective_width) do {:ok, resized} -> {:ok, resized, %{upscale_skipped: skipped?}} error -> error end end defp resize_image(image, nil, height, upscale, max_factor) when is_integer(height) do {effective_height, skipped?} = constrain_height(image, height, upscale, max_factor) # Resize while maintaining aspect ratio, specifying height # Using a large width ensures height is the constraining factor case Image.thumbnail(image, 100_000_000, height: effective_height) do {:ok, resized} -> {:ok, resized, %{upscale_skipped: skipped?}} error -> error end end defp constrain_requested_size(image, width, height, upscale, max_factor) do source_width = Image.width(image) source_height = Image.height(image) ratio = upscale_ratio(upscale, max_factor) max_width = trunc(Float.floor(source_width * ratio)) max_height = trunc(Float.floor(source_height * ratio)) effective_width = min(width, max_width) effective_height = min(height, max_height) skipped? = effective_width < width or effective_height < height {effective_width, effective_height, skipped?} end defp constrain_width(image, width, upscale, max_factor) do source_width = Image.width(image) max_width = trunc(Float.floor(source_width * upscale_ratio(upscale, max_factor))) effective_width = min(width, max_width) {effective_width, effective_width < width} end defp constrain_height(image, height, upscale, max_factor) do source_height = Image.height(image) max_height = trunc(Float.floor(source_height * upscale_ratio(upscale, max_factor))) effective_height = min(height, max_height) {effective_height, effective_height < height} end defp upscale_ratio(true, max_factor) when is_number(max_factor) and max_factor > 1.0, do: max_factor defp upscale_ratio(_, _), do: 1.0 defp suffix_for(:webp), do: ".webp" defp suffix_for(:avif), do: ".avif" defp suffix_for(:jpg), do: ".jpg" defp suffix_for(:png), do: ".png" defp suffix_for(_), do: ".webp" end