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