Skip to main content

lib/phoenix_image/optimizer.ex

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