Skip to main content

lib/image/plug/pipeline/encoder.ex

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