Skip to main content

lib/image/plug/pipeline/normaliser.ex

defmodule Image.Plug.Pipeline.Normaliser do
  @moduledoc """
  Reorders, folds, and validates a pipeline so that two requests with
  the same semantic effect produce identical fingerprints — and so
  that order-sensitive libvips operations land in a position where
  they actually work.

  ### Canonical operation order

  Mirrors the order
  [Sharp](https://github.com/lovell/sharp/blob/main/src/pipeline.cc)
  applies operations internally (Sharp wraps the same libvips
  primitives this library uses). The order encodes years of
  experience with libvips' constraints — most importantly that
  resize must run early enough to benefit from libvips'
  shrink-on-load fast path, and that operations like blur, sharpen,
  and modulate must run in a specific bracket around resize.

  The ordering this module enforces:

  1. `%Trim{}` — trim has to run before resize so the resize sees
     only the meaningful pixels.

  2. `%Background{}` — flatten alpha against the chosen background
     before any colour-shifting op runs.

  3. `%Resize{}` — runs as early as possible so libvips can
     shrink-on-load.

  4. `%Rotate{}` and `%Flip{}` — Cloudflare's rotate is free-angle
     and runs post-resize.

  5. `%Border{}` — embedded after resize because it grows the canvas.

  6. `%Adjust{}` — Sharp's "modulate" stage. Runs after geometry
     so the new pixels participate in tone shifts.

  7. `%Colorspace{}` — runs after `%Adjust{}` (whose multipliers
     expect RGB) and before `%ReplaceColor{}` (whose chroma-key
     match operates on the post-conversion bytes).

  8. `%ReplaceColor{}` — colour-substitution. Runs after Adjust so
     tone shifts on the source colour have already settled, and
     before the sigma-based ops so blur/sharpen operate on the
     post-replace pixels.

  9. `%Blur{}` — Sharp explicitly runs blur before sharpen.

  10. `%Sharpen{}`.

  11. `%Draw{}` — composite layers go on top of the finished base.

  12. `%Pipeline.Ops.Segment{}` — placeholder; ordered last for now.

  ### Cardinality

  These ops must appear at most once per pipeline. The provider's
  options parser already de-duplicates per request, but the
  normaliser is the source of truth so any future programmatic
  pipeline-builder gets the same guarantee:

  * `Resize`, `Trim`, `Flip`, `Rotate`, `Background`, `Border`,
    `Adjust`, `Colorspace`, `ReplaceColor`, `Sharpen`, `Blur`,
    `Segment`.

  `Draw` may appear at most once but holds an arbitrary list of
  layers internally, so multiple overlay requests collapse onto
  layers of one Draw op.

  ### No-op folding

  Drops ops whose fields make them semantically inert:

  * `%Resize{width: nil, height: nil}`

  * `%Rotate{angle: 0}`

  * `%Flip{direction: nil}`

  * `%Adjust{}` with every multiplier `1.0`

  * `%Sharpen{sigma: 0}`, `%Blur{sigma: 0}`

  * `%Border{}` with every side `0`

  * `%Trim{mode: :explicit}` with every side `0`

  ### Idempotence

  `normalise(normalise(p)) == normalise(p)` for every input.

  ### Errors

  Returns `{:error, %Image.Plug.Error{tag: :invalid_option}}` when
  the pipeline contains more than one of an op kind that must be
  unique.
  """

  alias Image.Plug.{Error, Pipeline}
  alias Image.Plug.Pipeline.Ops

  # The canonical position of each op kind. Lower = earlier in the
  # pipeline. The interpreter is a straight reduce; this list is the
  # only place that knows about ordering.
  @order [
    # Metadata override comes first — `Image.set_orientation/2`
    # only mutates the EXIF tag and doesn't touch pixels, so its
    # position relative to other ops is irrelevant; placing it
    # first keeps the pipeline trace readable.
    {Ops.Orientation, 5},
    {Ops.Trim, 10},
    {Ops.Background, 20},
    # Crop is an absolute extract of a sub-rectangle and runs
    # before Resize so the IIIF spec order
    # `region → size → rotation → quality → format` is preserved
    # on round-trips through the IIIF provider.
    {Ops.Crop, 25},
    {Ops.Resize, 30},
    {Ops.Rotate, 40},
    {Ops.Flip, 50},
    {Ops.Border, 60},
    {Ops.Adjust, 70},
    # Enhance subsumes brightness / contrast / saturation
    # tweaks but produces a globally normalised image; place
    # it just after Adjust so user-explicit adjustments still
    # apply on top.
    {Ops.Enhance, 71},
    {Ops.Colorspace, 72},
    # Profile-driven colourspace conversion runs alongside the
    # named-mode Colorspace op (`:srgb` etc).
    {Ops.IccTransform, 72},
    # Single-pass colour transforms after Adjust/Colorspace.
    {Ops.Sepia, 73},
    {Ops.Tint, 74},
    {Ops.ReplaceColor, 75},
    # Pixel-domain effects.
    {Ops.Posterize, 76},
    {Ops.Pixelate, 77},
    {Ops.PixelateFaces, 78},
    {Ops.Blur, 80},
    {Ops.Sharpen, 90},
    {Ops.Draw, 100},
    {Ops.Segment, 110},
    # Mask / silhouette / alpha adjustments come at the end so
    # they see the fully-rendered colour image.
    {Ops.Vignette, 195},
    {Ops.Fade, 200},
    {Ops.Rounded, 210},
    {Ops.DropShadow, 220},
    {Ops.Opacity, 230}
  ]

  @order_map Map.new(@order)

  # Op kinds that must appear at most once per pipeline.
  @single_instance_ops [
    Ops.Orientation,
    Ops.Trim,
    Ops.Background,
    Ops.Resize,
    Ops.Rotate,
    Ops.Flip,
    Ops.Border,
    Ops.Adjust,
    Ops.Enhance,
    Ops.Colorspace,
    Ops.IccTransform,
    Ops.Sepia,
    Ops.Tint,
    Ops.ReplaceColor,
    Ops.Posterize,
    Ops.Pixelate,
    Ops.PixelateFaces,
    Ops.Blur,
    Ops.Sharpen,
    Ops.Draw,
    Ops.Segment,
    Ops.Vignette,
    Ops.Fade,
    Ops.Rounded,
    Ops.DropShadow,
    Ops.Opacity
  ]

  @doc """
  Normalises a pipeline. See the moduledoc for the rules applied.

  ### Arguments

  * `pipeline` is an `Image.Plug.Pipeline` struct.

  ### Returns

  * `{:ok, pipeline}` on success.

  * `{:error, %Image.Plug.Error{tag: :invalid_option}}` on a
    cardinality violation.

  ### Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops.{Resize, Rotate, Sharpen}
      iex> p =
      ...>   Pipeline.new()
      ...>   |> Pipeline.append(%Sharpen{sigma: 1.0})
      ...>   |> Pipeline.append(%Resize{width: 200})
      ...>   |> Pipeline.append(%Rotate{angle: 90})
      iex> {:ok, normalised} = Image.Plug.Pipeline.Normaliser.normalise(p)
      iex> Enum.map(normalised.ops, & &1.__struct__)
      [Image.Plug.Pipeline.Ops.Resize,
       Image.Plug.Pipeline.Ops.Rotate,
       Image.Plug.Pipeline.Ops.Sharpen]

  """
  @spec normalise(Pipeline.t()) :: {:ok, Pipeline.t()} | {:error, Error.t()}
  def normalise(%Pipeline{} = pipeline) do
    with {:ok, ops} <- normalise_ops(pipeline.ops) do
      {:ok, %{pipeline | ops: ops}}
    end
  end

  defp normalise_ops(ops) do
    ops = Enum.reject(ops, &noop?/1)

    case validate_cardinalities(ops) do
      :ok -> {:ok, sort(ops)}
      {:error, _} = error -> error
    end
  end

  defp validate_cardinalities(ops) do
    counts =
      Enum.reduce(ops, %{}, fn op, acc ->
        Map.update(acc, op.__struct__, 1, &(&1 + 1))
      end)

    Enum.reduce_while(@single_instance_ops, :ok, fn module, _acc ->
      case Map.get(counts, module, 0) do
        n when n <= 1 ->
          {:cont, :ok}

        n ->
          {:halt,
           {:error,
            Error.new(:invalid_option, "pipeline contains multiple #{inspect(module)} ops",
              details: %{op: module, count: n}
            )}}
      end
    end)
  end

  # Stable sort by canonical position. Ops without a position (which
  # should not happen in practice — every op kind in the IR is in
  # `@order`) sort last, after every documented op.
  defp sort(ops) do
    Enum.sort_by(ops, fn op ->
      Map.get(@order_map, op.__struct__, 999)
    end)
  end

  defp noop?(%Ops.Resize{width: nil, height: nil, size_pct: nil}), do: true
  defp noop?(%Ops.Resize{width: nil, height: nil, size_pct: pct}) when pct in [0, +0.0], do: true
  defp noop?(%Ops.Rotate{angle: angle}) when angle == 0 or angle == +0.0, do: true
  defp noop?(%Ops.Flip{direction: nil}), do: true
  defp noop?(%Ops.Sharpen{sigma: sigma}) when sigma in [0, +0.0], do: true
  defp noop?(%Ops.Blur{sigma: sigma}) when sigma in [0, +0.0], do: true

  defp noop?(%Ops.Adjust{
         brightness: brightness,
         contrast: contrast,
         gamma: gamma,
         saturation: saturation
       }) do
    [brightness, contrast, gamma, saturation]
    |> Enum.all?(fn multiplier -> multiplier == 1.0 end)
  end

  defp noop?(%Ops.Border{top: 0, right: 0, bottom: 0, left: 0}), do: true
  defp noop?(%Ops.Sepia{strength: s}) when s in [0, +0.0], do: true
  defp noop?(%Ops.Vignette{strength: s}) when s in [0, +0.0], do: true
  defp noop?(%Ops.Opacity{factor: 1.0}), do: true
  defp noop?(%Ops.Posterize{levels: 256}), do: true
  defp noop?(%Ops.Pixelate{scale: s}) when s >= 1.0, do: true

  defp noop?(%Ops.Trim{mode: :explicit, top: 0, right: 0, bottom: 0, left: 0}), do: true

  # Crop with zero or negative dimensions extracts no area and is
  # treated as a no-op. The IIIF spec requires servers to reject
  # such regions; the normaliser drops them quietly so a stray
  # `region={:pixels, 0, 0, 0, 0}` doesn't crash the pipeline.
  defp noop?(%Ops.Crop{width: w, height: h}) when w <= 0 or h <= 0, do: true

  defp noop?(_), do: false
end