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