defmodule Image.Plug.Pipeline.Encoder do
@moduledoc """
Serialises a transformed `Vix.Vips.Image` into bytes for the HTTP
response.
Returns `{:ok, body, content_type}` where `body` is one of:
* `{:stream, Enumerable.t()}` — preferred. Backed by
`Image.stream!/2`, which wraps `Vix.Vips.Image.write_to_stream/2`
so libvips emits encoded bytes chunk-by-chunk.
* `{:bytes, iodata()}` — fallback for callers that need the
response fully buffered (HEAD requests, hosts that disable
chunked transfer, the `format=json` output).
### Canonical streaming pipeline
The full source-to-client chain mirrors the canonical shape
documented in the
[`Image` library's stream test suite](https://github.com/kipcole9/image/blob/main/test/stream_image_test.exs):
path
|> File.stream!(2048, []) # Image.Plug.SourceResolver.File
|> Image.open() # ditto
|> ...transforms... # Image.Plug.Pipeline.Interpreter
|> Image.stream!(suffix: ext) # this module's `:stream` body
|> Enum.reduce_while(conn, fn chunk, conn ->
case Plug.Conn.chunk(conn, chunk) do
{:ok, conn} -> {:cont, conn}
{:error, :closed} -> {:halt, conn}
end
end) # Image.Plug.Plug.send_body/4
`Image.write(image, conn, suffix: ext)` does the chunked-write
loop internally and is functionally identical to that
`reduce_while` block. We keep the loop in our own plug so we
retain explicit control over the conn for header manipulation,
error fallbacks, and telemetry.
### Format support
Supports JPEG (baseline + progressive), PNG, WebP, AVIF, TIFF,
JP2, GIF, PDF, the Accept-driven `:auto` selection, and the
small `:json` metadata endpoint.
### AVIF fallback
If libvips lacks AVIF write support, requests for `format=avif`
encode to WebP and the response is tagged with the
`x-image-plug-format-fallback: avif->webp` header. The plug
forwards the header set by the encoder. Detection runs once at
application boot via `Image.Plug.Capabilities.probe/0`.
"""
alias Image.Plug.{Capabilities, Error}
alias Image.Plug.Pipeline.Ops
@typedoc """
The encoded body. Streaming form is preferred; the bytes form is
used when buffering is requested or required.
"""
@type body :: {:stream, Enumerable.t()} | {:bytes, iodata()}
@typedoc """
Optional response headers the plug should add. Keys are lowercase
binaries.
"""
@type extra_headers :: [{String.t(), String.t()}]
@doc """
Encodes the working image according to the pipeline's `Ops.Format`.
### Arguments
* `image` is the transformed `Vix.Vips.Image`.
* `format` is the pipeline's `Image.Plug.Pipeline.Ops.Format`
struct. The encoder reads `:type`, `:quality`, and `:metadata`.
* `encode_options` is a keyword list:
### Options
* `:buffer` — `:stream` (default) or `:bytes`. Controls whether the
body is returned as a stream or as buffered iodata. The `:json`
output is always buffered regardless of this setting.
* `:source_content_type` — the source image's MIME type. Used by
the `:auto` selector as the final fallback when neither AVIF nor
WebP is acceptable.
* `:accept` — the request's `Accept` header value (a single string
or `nil`). Used by the `:auto` selector to negotiate the output
format.
### Returns
* `{:ok, body, content_type}` on success.
* `{:ok, body, content_type, extra_headers}` when the encoder
needs to add response headers (currently only the AVIF fallback).
* `{:error, %Image.Plug.Error{}}` on encode failure.
"""
@spec encode(Vix.Vips.Image.t(), Ops.Format.t(), keyword()) ::
{:ok, body(), String.t()}
| {:ok, body(), String.t(), extra_headers()}
| {:error, Error.t()}
def encode(image, format, encode_options \\ [])
# ---------- :auto ----------
def encode(%Vix.Vips.Image{} = image, %Ops.Format{type: :auto} = format, encode_options) do
chosen = pick_auto_format(encode_options)
encode(image, %{format | type: chosen}, encode_options)
end
# ---------- :avif (with soft fallback) ----------
def encode(%Vix.Vips.Image{} = image, %Ops.Format{type: :avif} = format, encode_options) do
if Capabilities.avif_write?() do
do_encode_raster(image, format, encode_options)
else
fallback = %{format | type: :webp}
case do_encode_raster(image, fallback, encode_options) do
{:ok, body, content_type} ->
{:ok, body, content_type, [{"x-image-plug-format-fallback", "avif->webp"}]}
{:error, _} = error ->
error
end
end
end
# ---------- raster formats ----------
def encode(%Vix.Vips.Image{} = image, %Ops.Format{type: type} = format, encode_options)
when type in [:jpeg, :baseline_jpeg, :png, :webp, :gif, :tiff, :jp2, :pdf] do
do_encode_raster(image, format, encode_options)
end
# ---------- json ----------
def encode(%Vix.Vips.Image{} = image, %Ops.Format{type: :json}, _encode_options) do
body = %{
"width" => Image.width(image),
"height" => Image.height(image),
"bands" => Image.bands(image),
"has_alpha" => Image.has_alpha?(image)
}
iodata = :json.encode(body)
{:ok, {:bytes, iodata}, "application/json"}
end
def encode(_image, %Ops.Format{type: type}, _encode_options) do
{:error,
Error.new(:unsupported_output_format, "encoder does not support this format",
details: %{requested: type}
)}
end
# ---------- raster encode helper ----------
defp do_encode_raster(image, %Ops.Format{} = format, encode_options) do
buffer = Keyword.get(encode_options, :buffer, :stream)
{suffix, content_type, write_options} = format_settings(format)
case prepare_metadata(image, format.metadata) do
{:ok, prepared} ->
encode_buffered_or_streamed(prepared, suffix, content_type, write_options, buffer)
{:error, reason} ->
{:error, encode_error(reason)}
end
rescue
e in Image.Error ->
{:error, encode_error(e)}
end
defp encode_buffered_or_streamed(image, suffix, content_type, write_options, :stream) do
stream = Image.stream!(image, [{:suffix, suffix} | write_options])
{:ok, {:stream, stream}, content_type}
end
defp encode_buffered_or_streamed(image, suffix, content_type, write_options, :bytes) do
case Image.write(image, :memory, [{:suffix, suffix} | write_options]) do
{:ok, bytes} -> {:ok, {:bytes, bytes}, content_type}
{:error, reason} -> {:error, encode_error(reason)}
end
end
defp format_settings(%Ops.Format{type: :jpeg} = f) do
{".jpg", "image/jpeg",
[quality: f.quality]
|> append_progressive(f.progressive)
|> append_chroma_subsampling(f.chroma_subsampling)
|> append_strip_metadata(f.metadata)}
end
defp format_settings(%Ops.Format{type: :baseline_jpeg} = f) do
{".jpg", "image/jpeg",
[quality: f.quality, progressive: false]
|> append_chroma_subsampling(f.chroma_subsampling)
|> append_strip_metadata(f.metadata)}
end
defp format_settings(%Ops.Format{type: :png} = f) do
{".png", "image/png",
[]
|> append_progressive(f.progressive)
|> append_lossy(f.lossy)
|> append_strip_metadata(f.metadata)}
end
defp format_settings(%Ops.Format{type: :webp} = f) do
{".webp", "image/webp",
[quality: f.quality]
|> append_lossy(f.lossy)
|> append_strip_metadata(f.metadata)}
end
defp format_settings(%Ops.Format{type: :avif} = f) do
{".avif", "image/avif",
[quality: f.quality]
|> append_lossy(f.lossy)
|> append_chroma_subsampling(f.chroma_subsampling)
|> append_strip_metadata(f.metadata)}
end
# GIF — single-frame export. libvips' GIF writer ignores `quality`
# (GIF is palette-based, not DCT/quality-based) so it's not
# forwarded.
defp format_settings(%Ops.Format{type: :gif} = f) do
{".gif", "image/gif", append_strip_metadata([], f.metadata)}
end
# TIFF — required by IIIF for archival workflows. libvips'
# TIFF writer accepts a `:compression` option (`:lzw`, `:deflate`,
# `:jpeg`, `:none`); default is LZW which gives a good size/CPU
# trade-off for non-photo content.
defp format_settings(%Ops.Format{type: :tiff} = f) do
{".tif", "image/tiff",
[compression: tiff_compression(f.compression), quality: f.quality]
|> append_strip_metadata(f.metadata)}
end
# JPEG 2000 — required by IIIF Compliance Level 2. Available
# only when libvips was built with `libopenjp2` (true on most
# modern bookworm builds; check via `Image.Plug.Capabilities`).
defp format_settings(%Ops.Format{type: :jp2} = f) do
{".jp2", "image/jp2",
[quality: f.quality]
|> append_strip_metadata(f.metadata)}
end
# PDF — single-page export. Useful for IIIF document servers; not
# appropriate for general image CDNs. libvips uses Cairo to write
# PDF, available on most Linux builds.
defp format_settings(%Ops.Format{type: :pdf} = f) do
{".pdf", "application/pdf", append_strip_metadata([], f.metadata)}
end
defp tiff_compression(:fast), do: :lzw
defp tiff_compression(nil), do: :lzw
defp tiff_compression(other) when is_atom(other), do: other
defp append_progressive(opts, nil), do: opts
defp append_progressive(opts, value) when is_boolean(value), do: opts ++ [progressive: value]
defp append_lossy(opts, nil), do: opts
defp append_lossy(opts, value) when is_boolean(value), do: opts ++ [lossy: value]
defp append_chroma_subsampling(opts, nil), do: opts
defp append_chroma_subsampling(opts, mode) when mode in [:auto, :on, :off],
do: opts ++ [chroma_subsampling: mode]
# `:copyright` metadata is materialised by `prepare_metadata/2`
# before encoding (`Image.minimize_metadata/2` rewrites only the
# copyright field onto the image), so the encoder asks libvips
# to preserve everything that survives.
defp append_strip_metadata(opts, :keep), do: opts
defp append_strip_metadata(opts, :none), do: opts ++ [strip_metadata: true]
defp append_strip_metadata(opts, :copyright), do: opts
# `:copyright` runs `Image.minimize_metadata/2` with a `:keep`
# list so the encoded output retains only the copyright field.
# `:keep` and `:none` need no pre-encode mutation — libvips
# handles them via the `strip_metadata` write option.
defp prepare_metadata(image, :keep), do: {:ok, image}
defp prepare_metadata(image, :none), do: {:ok, image}
# Always preserve orientation alongside copyright — otherwise
# an image stored with `EXIF Orientation = 6` would deliver
# rotated incorrectly through the plug. Callers who want every
# tag stripped (including orientation) should pass
# `metadata=:none`.
#
# `Image.minimize_metadata/2` calls `Image.exif/1` internally,
# which raises a `WithClauseError` on certain images whose
# EXIF blob is TIFF-formatted but missing the `"Exif\\0\\0"`
# prefix. Treat any unexpected failure as "fall back to leaving
# metadata alone" so a malformed EXIF blob never breaks the
# encode path.
defp prepare_metadata(image, :copyright) do
# `Image.minimize_metadata/2`'s `:keep` list reads from the
# source EXIF blob, but `Image.set_orientation/2` mutates a
# live header field that isn't part of that blob. Snapshot
# the live `orientation` header before minimisation and
# restore it after so an explicit `or=N`-driven override
# survives the metadata strip.
#
# `Image.minimize_metadata/2` calls `Image.exif/1`, which
# returns `{:error, %Image.Error{reason: :invalid_exif}}`
# on images whose EXIF blob is TIFF-formatted but missing
# the `"Exif\\0\\0"` prefix (some PNGs from libpng's older
# `iTXt` paths land here). Treat any failure as "leave
# metadata alone" so a malformed source doesn't 500 the
# request.
orientation = read_live_orientation(image)
case Image.minimize_metadata(image, keep: [:copyright, :orientation]) do
{:ok, prepared} -> restore_orientation(prepared, orientation)
{:error, _} -> {:ok, image}
end
rescue
_ -> {:ok, image}
end
defp read_live_orientation(image) do
case Vix.Vips.Image.header_value(image, "orientation") do
{:ok, n} when is_integer(n) -> n
_ -> nil
end
end
defp restore_orientation(image, nil), do: {:ok, image}
defp restore_orientation(image, orientation) when is_integer(orientation) do
Image.set_orientation(image, orientation)
end
defp pick_auto_format(encode_options) do
accept = Keyword.get(encode_options, :accept) || ""
source_content_type = Keyword.get(encode_options, :source_content_type, "image/jpeg")
cond do
Capabilities.avif_write?() and accepts?(accept, "image/avif") -> :avif
accepts?(accept, "image/webp") -> :webp
true -> source_content_type_to_format(source_content_type)
end
end
defp accepts?(accept, mime) when is_binary(accept) and is_binary(mime) do
# Cheap substring match. The Accept grammar allows `*/*` and
# `image/*` and weighted entries, but Cloudflare's behaviour is
# likewise simple "if the literal type appears, use it"; we
# match that.
String.contains?(accept, mime) or
String.contains?(accept, "image/*") or
String.contains?(accept, "*/*")
end
defp source_content_type_to_format("image/png"), do: :png
defp source_content_type_to_format("image/webp"), do: :webp
defp source_content_type_to_format("image/avif"), do: :avif
defp source_content_type_to_format("image/gif"), do: :png
defp source_content_type_to_format("image/svg+xml"), do: :png
# JPEG is the safest "everything renders this" fallback per Cloudflare.
defp source_content_type_to_format(_other), do: :jpeg
defp encode_error(%{message: message}) when is_binary(message) do
Error.new(:pipeline_failed, "encode failed", details: %{reason: message})
end
defp encode_error(reason) do
Error.new(:pipeline_failed, "encode failed", details: %{reason: inspect(reason)})
end
end