Skip to main content

lib/image/plug/pipeline/interpreter.ex

defmodule Image.Plug.Pipeline.Interpreter do
  @moduledoc """
  Executes a normalised pipeline against an open `Vix.Vips.Image`.

  The interpreter is a single `Enum.reduce_while/3` over the op list
  with one `apply_op/2` clause per op kind. There is no implicit
  reordering — `Image.Plug.Pipeline.Normaliser` runs first, so the
  ops arrive in canonical order with no-ops already folded away.
  """

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

  @doc """
  Runs the pipeline.

  ### Arguments

  * `pipeline` is a normalised `Image.Plug.Pipeline` struct.

  * `image` is an open `Vix.Vips.Image`.

  * `options` is a keyword list of context the interpreter may
    need for ops that reference external resources.

  ### Options

  * `:resolve_layer_source` — a 1-arity function
    `fn %Image.Plug.Source{} -> {:ok, Vix.Vips.Image.t()} | {:error, term()} end`
    used by the `Image.Plug.Pipeline.Ops.Draw` clause to fetch
    overlay images. The plug passes a closure over its configured
    `Image.Plug.SourceResolver`. If omitted and a Draw op is
    encountered, the interpreter returns `:invalid_option`.

  ### Returns

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

  * `{:error, %Image.Plug.Error{}}` on the first failed op.

  """
  @spec execute(Pipeline.t(), Vix.Vips.Image.t(), keyword()) ::
          {:ok, Vix.Vips.Image.t()} | {:error, Error.t()}
  def execute(pipeline, image, options \\ [])

  def execute(%Pipeline{ops: ops}, %Vix.Vips.Image{} = image, options) when is_list(options) do
    Enum.reduce_while(ops, {:ok, image}, fn op, {:ok, acc} ->
      case apply_op(op, acc, options) do
        {:ok, _} = success -> {:cont, success}
        {:error, _} = error -> {:halt, error}
      end
    end)
  end

  # ---------- Resize ----------

  defp apply_op(%Ops.Resize{} = resize, image, _options) do
    do_resize(resize, image)
  end

  # ---------- Rotate / Flip ----------

  defp apply_op(%Ops.Rotate{angle: 0}, image, _options), do: {:ok, image}

  defp apply_op(%Ops.Rotate{angle: angle}, image, _options) do
    case Image.rotate(image, angle * 1.0) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("rotate", reason)}
    end
  end

  defp apply_op(%Ops.Flip{direction: :horizontal}, image, _options) do
    case Image.flip(image, :horizontal) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("flip", reason)}
    end
  end

  defp apply_op(%Ops.Flip{direction: :vertical}, image, _options) do
    case Image.flip(image, :vertical) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("flip", reason)}
    end
  end

  defp apply_op(%Ops.Flip{direction: :both}, image, _options) do
    with {:ok, h} <- Image.flip(image, :horizontal),
         {:ok, _} = success <- Image.flip(h, :vertical) do
      success
    else
      {:error, reason} -> {:error, op_error("flip", reason)}
    end
  end

  # ---------- Trim ----------

  defp apply_op(%Ops.Trim{mode: :border, threshold: t}, image, _options) do
    case Image.trim(image, threshold: t) do
      {:ok, _} = success ->
        success

      {:error, reason} ->
        # `Image.trim/2` returns an `%Image.Error{}` whose message
        # contains "uncropped" when the image is uniform background.
        # Treat that as a no-op rather than a pipeline failure.
        if uncropped?(reason) do
          {:ok, image}
        else
          {:error, op_error("trim", reason)}
        end
    end
  end

  defp apply_op(
         %Ops.Trim{mode: :explicit, top: top, right: right, bottom: bottom, left: left},
         image,
         _options
       ) do
    width = Image.width(image)
    height = Image.height(image)
    crop_w = max(width - left - right, 1)
    crop_h = max(height - top - bottom, 1)

    case Image.crop(image, left, top, crop_w, crop_h) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("trim", reason)}
    end
  end

  # ---------- Crop ----------

  # Pixel coordinates pass straight through to `Image.crop/5`. The
  # source dimensions are read at apply time so an out-of-bounds
  # region can be clamped (the IIIF spec wants a 400 with a "could
  # be returned smaller than requested" note rather than a hard
  # rejection).
  defp apply_op(%Ops.Crop{units: :pixels, x: x, y: y, width: w, height: h}, image, _options) do
    do_crop(image, trunc(x), trunc(y), trunc(w), trunc(h))
  end

  # Percentage coordinates resolve against the actual source
  # dimensions at apply time. Same op can therefore be reused
  # across sources of different sizes — useful for IIIF's
  # `pct:x,y,w,h` region form.
  defp apply_op(%Ops.Crop{units: :percent, x: x, y: y, width: w, height: h}, image, _options) do
    src_w = Image.width(image)
    src_h = Image.height(image)
    px = trunc(src_w * x / 100)
    py = trunc(src_h * y / 100)
    pw = trunc(src_w * w / 100)
    ph = trunc(src_h * h / 100)
    do_crop(image, px, py, pw, ph)
  end

  # ---------- Adjust ----------

  defp apply_op(%Ops.Adjust{} = adjust, image, _options) do
    do_adjust(adjust, image)
  end

  # ---------- Sharpen / Blur ----------

  defp apply_op(%Ops.Sharpen{sigma: sigma}, image, _options) when sigma > 0 do
    case Image.sharpen(image, sigma: sigma) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("sharpen", reason)}
    end
  end

  defp apply_op(%Ops.Sharpen{}, image, _options), do: {:ok, image}

  defp apply_op(%Ops.Blur{sigma: sigma}, image, _options) when sigma > 0 do
    case Image.blur(image, sigma: sigma) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("blur", reason)}
    end
  end

  defp apply_op(%Ops.Blur{}, image, _options), do: {:ok, image}

  # ---------- Background ----------

  defp apply_op(%Ops.Background{color: color}, image, _options) do
    case Image.flatten(image, background_color: color) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("background", reason)}
    end
  end

  # ---------- Colorspace ----------

  defp apply_op(%Ops.Colorspace{target: target}, image, _options) do
    case Image.to_colorspace(image, target) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("colorspace", reason)}
    end
  end

  # ---------- IccTransform ----------

  defp apply_op(%Ops.IccTransform{profile: profile, intent: intent}, image, _options) do
    case Image.to_colorspace(image, profile, intent: intent) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("icc_transform", reason)}
    end
  end

  # ---------- ReplaceColor ----------

  defp apply_op(%Ops.ReplaceColor{} = replace, image, _options) do
    case Image.replace_color(image, replace_color_options(replace)) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("replace_color", reason)}
    end
  end

  # ---------- Border ----------

  defp apply_op(%Ops.Border{} = border, image, _options) do
    do_border(border, image)
  end

  # ---------- Enhance ----------

  defp apply_op(%Ops.Enhance{}, image, _options) do
    case Image.enhance(image) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("enhance", reason)}
    end
  end

  # ---------- Vignette ----------

  defp apply_op(%Ops.Vignette{strength: +0.0}, image, _options), do: {:ok, image}

  defp apply_op(%Ops.Vignette{strength: strength}, image, _options) do
    case Image.vignette(image, strength: strength) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("vignette", reason)}
    end
  end

  # ---------- Sepia ----------

  defp apply_op(%Ops.Sepia{strength: +0.0}, image, _options), do: {:ok, image}

  defp apply_op(%Ops.Sepia{strength: strength}, image, _options) do
    case Image.sepia(image, strength) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("sepia", reason)}
    end
  end

  # ---------- Tint ----------

  defp apply_op(%Ops.Tint{color: color}, image, _options) do
    case Image.tint(image, color) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("tint", reason)}
    end
  end

  # ---------- Opacity ----------

  defp apply_op(%Ops.Opacity{factor: 1.0}, image, _options), do: {:ok, image}

  defp apply_op(%Ops.Opacity{factor: factor}, image, _options) do
    case Image.opacity(image, factor) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("opacity", reason)}
    end
  end

  # ---------- Pixelate ----------

  defp apply_op(%Ops.Pixelate{scale: scale}, image, _options) when scale >= 1.0,
    do: {:ok, image}

  defp apply_op(%Ops.Pixelate{scale: scale}, image, _options) do
    case Image.pixelate(image, scale) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("pixelate", reason)}
    end
  end

  # ---------- PixelateFaces ----------
  #
  # Falls back to no-op when `:image_vision` isn't loaded —
  # users get a normal image (rather than an error) so the
  # request still succeeds. Document as ⚠️ in the conformance
  # guides.
  defp apply_op(%Ops.PixelateFaces{scale: scale}, image, _options) when scale >= 1.0,
    do: {:ok, image}

  defp apply_op(%Ops.PixelateFaces{scale: scale}, image, _options) do
    case Image.Plug.FaceAware.pixelate_faces(image, scale) do
      {:ok, _} = success -> success
      {:error, :unavailable} -> {:ok, image}
      {:error, reason} -> {:error, op_error("pixelate_faces", reason)}
    end
  end

  # ---------- Posterize ----------

  defp apply_op(%Ops.Posterize{levels: 256}, image, _options), do: {:ok, image}

  defp apply_op(%Ops.Posterize{levels: levels}, image, _options) do
    case Image.posterize(image, levels) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("posterize", reason)}
    end
  end

  # ---------- Rounded ----------

  defp apply_op(%Ops.Rounded{radius: :max}, image, _options) do
    radius = div(min(Image.width(image), Image.height(image)), 2)

    case Image.rounded(image, radius: radius) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("rounded", reason)}
    end
  end

  defp apply_op(%Ops.Rounded{radius: radius}, image, _options) when is_integer(radius) do
    case Image.rounded(image, radius: radius) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("rounded", reason)}
    end
  end

  # ---------- DropShadow ----------

  defp apply_op(%Ops.DropShadow{} = shadow, image, _options) do
    case Image.drop_shadow(image,
           color: shadow.color,
           opacity: shadow.opacity,
           sigma: shadow.sigma,
           dx: shadow.dx,
           dy: shadow.dy
         ) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("drop_shadow", reason)}
    end
  end

  # ---------- Fade ----------

  defp apply_op(%Ops.Fade{edges: edges, length: length}, image, _options) do
    case Image.fade(image, edges: edges, length: length) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("fade", reason)}
    end
  end

  # ---------- Orientation ----------

  defp apply_op(%Ops.Orientation{value: value}, image, _options) do
    case Image.set_orientation(image, value) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("orientation", reason)}
    end
  end

  # ---------- Segment (placeholder) ----------

  defp apply_op(%Ops.Segment{}, image, _options) do
    # Subject segmentation lands in a later milestone. Carry the
    # image through unchanged.
    {:ok, image}
  end

  # ---------- Draw (overlays / watermarks) ----------

  defp apply_op(%Ops.Draw{layers: []}, _image, _options) do
    {:error, Error.new(:not_implemented, "draw op has no layers")}
  end

  defp apply_op(%Ops.Draw{layers: layers}, image, options) do
    case Keyword.fetch(options, :resolve_layer_source) do
      {:ok, resolver} when is_function(resolver, 1) ->
        compose_layers(image, layers, resolver)

      _ ->
        {:error,
         Error.new(
           :invalid_option,
           "interpreter cannot run a Draw op without a :resolve_layer_source function"
         )}
    end
  end

  defp apply_op(op, _image, _options) do
    {:error,
     Error.new(:not_implemented, "interpreter does not yet handle this op kind",
       details: %{op: inspect(op.__struct__)}
     )}
  end

  defp op_error(name, reason) do
    Error.new(:pipeline_failed, "#{name} failed", details: %{reason: format_reason(reason)})
  end

  defp uncropped?(%{message: message}) when is_binary(message), do: message =~ "uncropped"

  # ---------- Crop helpers ----------

  # Clamp to the source bounds so out-of-range regions (which
  # IIIF's spec permits the server to silently truncate) don't
  # crash libvips.
  defp do_crop(image, x, y, w, h) do
    src_w = Image.width(image)
    src_h = Image.height(image)
    clamp_x = x |> max(0) |> min(src_w - 1)
    clamp_y = y |> max(0) |> min(src_h - 1)
    clamp_w = w |> max(1) |> min(src_w - clamp_x)
    clamp_h = h |> max(1) |> min(src_h - clamp_y)

    case Image.crop(image, clamp_x, clamp_y, clamp_w, clamp_h) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("crop", reason)}
    end
  end

  # ---------- Adjust helpers ----------

  defp do_adjust(adjust, image) do
    image
    |> apply_brightness(adjust.brightness)
    |> apply_then(&apply_contrast(&1, adjust.contrast))
    |> apply_then(&apply_saturation(&1, adjust.saturation))
    |> apply_then(&apply_gamma(&1, adjust.gamma))
  end

  defp apply_then({:ok, image}, fun), do: fun.(image)
  defp apply_then({:error, _} = error, _fun), do: error

  defp apply_brightness(image, multiplier) when multiplier == 1.0, do: {:ok, image}

  defp apply_brightness(image, multiplier) do
    case Image.brightness(image, multiplier) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("brightness", reason)}
    end
  end

  defp apply_contrast(image, multiplier) when multiplier == 1.0, do: {:ok, image}

  defp apply_contrast(image, multiplier) do
    case Image.contrast(image, multiplier) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("contrast", reason)}
    end
  end

  defp apply_saturation(image, multiplier) when multiplier == 1.0, do: {:ok, image}

  defp apply_saturation(image, multiplier) do
    case Image.saturation(image, multiplier) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("saturation", reason)}
    end
  end

  defp apply_gamma(image, multiplier) when multiplier == 1.0, do: {:ok, image}

  defp apply_gamma(image, multiplier) do
    case Image.gamma(image, multiplier) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("gamma", reason)}
    end
  end

  # ---------- Draw helpers ----------

  defp compose_layers(base_image, layers, resolver) do
    Enum.reduce_while(layers, {:ok, base_image}, fn layer, {:ok, base} ->
      case render_layer(base, layer, resolver) do
        {:ok, _} = success -> {:cont, success}
        {:error, _} = error -> {:halt, error}
      end
    end)
  end

  defp render_layer(base, %Ops.Draw.Layer{} = layer, resolver) do
    with {:ok, overlay} <- resolver.(layer.source),
         {:ok, sized} <- maybe_resize_layer(overlay, layer),
         {:ok, rotated} <- maybe_rotate_layer(sized, layer.rotate),
         {x, y} = layer_position(base, rotated, layer.position),
         {:ok, _} = success <- Image.compose(base, rotated, x: x, y: y) do
      success
    else
      {:error, %Error{} = error} -> {:error, error}
      {:error, reason} -> {:error, op_error("draw", reason)}
    end
  end

  defp maybe_resize_layer(overlay, %Ops.Draw.Layer{width: nil, height: nil}), do: {:ok, overlay}

  defp maybe_resize_layer(overlay, %Ops.Draw.Layer{width: w, height: h, fit: fit}) do
    target_w = w || Image.width(overlay)
    target_h = h || Image.height(overlay)

    case Image.thumbnail(overlay, "#{target_w}x#{target_h}", fit: thumbnail_fit_for_layer(fit)) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("draw layer resize", reason)}
    end
  end

  defp thumbnail_fit_for_layer(:cover), do: :cover
  defp thumbnail_fit_for_layer(:crop), do: :cover
  defp thumbnail_fit_for_layer(_other), do: :contain

  defp maybe_rotate_layer(image, 0), do: {:ok, image}

  defp maybe_rotate_layer(image, angle) when angle in [90, 180, 270] do
    case Image.rotate(image, angle * 1.0) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("draw layer rotate", reason)}
    end
  end

  defp maybe_rotate_layer(image, _other), do: {:ok, image}

  defp layer_position(base, overlay, nil) do
    {center_x(Image.width(base), Image.width(overlay)),
     center_y(Image.height(base), Image.height(overlay))}
  end

  defp layer_position(base, overlay, {:offset, sides}) do
    base_w = Image.width(base)
    base_h = Image.height(base)
    overlay_w = Image.width(overlay)
    overlay_h = Image.height(overlay)

    x =
      cond do
        is_integer(sides[:left]) -> sides[:left]
        is_integer(sides[:right]) -> max(base_w - overlay_w - sides[:right], 0)
        true -> center_x(base_w, overlay_w)
      end

    y =
      cond do
        is_integer(sides[:top]) -> sides[:top]
        is_integer(sides[:bottom]) -> max(base_h - overlay_h - sides[:bottom], 0)
        true -> center_y(base_h, overlay_h)
      end

    {x, y}
  end

  # ---------- ReplaceColor helpers ----------

  defp replace_color_options(%Ops.ReplaceColor{
         to: to,
         from: from,
         threshold: threshold,
         less_than: less_than,
         greater_than: greater_than,
         blend?: blend?
       }) do
    base = [replace_with: to, blend: blend?]

    cond do
      not is_nil(less_than) and not is_nil(greater_than) ->
        [{:less_than, less_than}, {:greater_than, greater_than} | base]

      true ->
        [{:color, from}, {:threshold, threshold} | base]
    end
  end

  # ---------- Border helpers ----------

  defp do_border(%Ops.Border{} = border, image) do
    width = Image.width(image) + border.left + border.right
    height = Image.height(image) + border.top + border.bottom

    case Image.embed(image, width, height,
           x: border.left,
           y: border.top,
           background_color: border.color
         ) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("border", reason)}
    end
  end

  # `size_pct` — resize by a percentage of the source dimensions.
  # Maps to IIIF's `pct:N` size form. Computes the target pixel
  # dimensions from the source size at apply time, then delegates
  # to the standard width/height path so all the same fit /
  # gravity / face-aware logic applies.
  defp do_resize(%Ops.Resize{size_pct: pct} = resize, image)
       when is_number(pct) and pct > 0 do
    src_w = Image.width(image)
    src_h = Image.height(image)
    new_w = max(trunc(src_w * pct / 100), 1)
    new_h = max(trunc(src_h * pct / 100), 1)
    do_resize(%{resize | size_pct: nil, width: new_w, height: new_h}, image)
  end

  defp do_resize(%Ops.Resize{width: width, height: height} = resize, image)
       when not is_nil(width) or not is_nil(height) do
    {target_width, target_height} = resolve_dimensions(resize, image)
    dimensions = "#{target_width}x#{target_height}"

    # Face-aware pre-crop. When the caller asked for
    # `gravity: :face` and `:image_vision` is loaded, narrow
    # the source to the largest detected face (with padding
    # derived from `face_zoom`) before the regular thumbnail
    # resize pass. The thumbnail then sees a face-centred
    # region and produces a face-centred thumbnail. Falls
    # through to the normal flow when face detection isn't
    # available or no face is detected — the existing
    # `:attention` saliency crop takes over.
    {prepared_image, prepared_resize} = maybe_face_aware_precrop(image, resize)

    options = thumbnail_options(prepared_resize)

    case Image.thumbnail(prepared_image, dimensions, options) do
      {:ok, thumbnailed} ->
        thumbnailed
        |> maybe_compass_crop(prepared_resize, target_width, target_height)
        |> apply_then(&maybe_pad(prepared_resize, &1, target_width, target_height))

      {:error, reason} ->
        {:error,
         Error.new(:pipeline_failed, "Image.thumbnail failed",
           details: %{reason: format_reason(reason)}
         )}
    end
  end

  defp do_resize(_resize, image) do
    {:ok, image}
  end

  # Pre-crop helper. Returns the (possibly narrowed) image
  # plus the (possibly modified) Resize op.  When the face
  # crop succeeds we drop the gravity to `:center` so the
  # downstream thumbnail does a centred resize on the cropped
  # region rather than re-running attention saliency.
  defp maybe_face_aware_precrop(image, %Ops.Resize{gravity: :face, face_zoom: face_zoom} = resize) do
    case Image.Plug.FaceAware.face_crop(image, face_zoom) do
      {:ok, cropped} ->
        {cropped, %{resize | gravity: :center}}

      {:error, _reason} ->
        {image, resize}
    end
  end

  defp maybe_face_aware_precrop(image, resize), do: {image, resize}

  defp resolve_dimensions(%Ops.Resize{width: width, height: height, dpr: dpr}, image) do
    {source_width, source_height} = {Image.width(image), Image.height(image)}

    target_width =
      case width do
        :auto -> source_width
        nil -> infer_from(height, source_height, source_width)
        n when is_integer(n) -> n
      end

    target_height =
      case height do
        nil -> infer_from(width_for_aspect(width, source_width), source_width, source_height)
        n when is_integer(n) -> n
      end

    {target_width * dpr, target_height * dpr}
  end

  defp width_for_aspect(:auto, source_width), do: source_width
  defp width_for_aspect(nil, source_width), do: source_width
  defp width_for_aspect(n, _source_width) when is_integer(n), do: n

  defp infer_from(other_dim, source_other_dim, source_dim) do
    max(round(source_dim * other_dim / source_other_dim), 1)
  end

  defp thumbnail_options(%Ops.Resize{fit: :contain}) do
    [fit: :contain]
  end

  defp thumbnail_options(%Ops.Resize{fit: :cover, gravity: gravity}) do
    [fit: :cover, crop: gravity_to_crop(gravity)]
  end

  defp thumbnail_options(%Ops.Resize{fit: :crop, gravity: gravity}) do
    [fit: :cover, crop: gravity_to_crop(gravity), resize: :down]
  end

  defp thumbnail_options(%Ops.Resize{fit: :scale_down}) do
    [fit: :contain, resize: :down]
  end

  defp thumbnail_options(%Ops.Resize{fit: :pad}) do
    [fit: :contain]
  end

  defp thumbnail_options(%Ops.Resize{fit: :squeeze}) do
    [fit: :fill]
  end

  # libvips' `thumbnail` only takes `:none | :center | :entropy |
  # :attention | :low | :high` for `:crop`. Compass directions and
  # explicit `{:xy, _, _}` focal points are handled in two steps:
  # thumbnail with `crop: :none` and then `Image.crop/5` to the
  # requested position. `gravity_to_crop/1` returns `:center` for
  # those gravities; `maybe_compass_crop/4` finishes the job.
  defp gravity_to_crop(:center), do: :center
  defp gravity_to_crop(:auto), do: :attention
  defp gravity_to_crop(:face), do: :attention
  defp gravity_to_crop(_other), do: :center

  # When the gravity is a compass direction or a normalised x/y, the
  # thumbnail above produces an oversized image (because we passed
  # `crop: :center` to libvips) and we then crop to position. For
  # `:center` and `:auto`/`:face`/`:entropy` libvips already cropped
  # correctly so this is a no-op.
  defp maybe_compass_crop(image, %Ops.Resize{fit: fit, gravity: gravity}, target_w, target_h)
       when fit in [:cover, :crop] and
              gravity not in [:center, :auto, :face, :entropy, :attention] do
    width = Image.width(image)
    height = Image.height(image)
    {x, y} = focal_offset(gravity, width, height, target_w, target_h)

    case Image.crop(image, x, y, min(target_w, width), min(target_h, height)) do
      {:ok, _} = success -> success
      {:error, reason} -> {:error, op_error("crop", reason)}
    end
  end

  defp maybe_compass_crop(image, _resize, _target_w, _target_h), do: {:ok, image}

  defp focal_offset(:north, w, _h, tw, _th), do: {center_x(w, tw), 0}
  defp focal_offset(:south, w, h, tw, th), do: {center_x(w, tw), max(h - th, 0)}
  defp focal_offset(:east, w, h, tw, th), do: {max(w - tw, 0), center_y(h, th)}
  defp focal_offset(:west, _w, h, _tw, th), do: {0, center_y(h, th)}
  defp focal_offset(:north_east, w, _h, tw, _th), do: {max(w - tw, 0), 0}
  defp focal_offset(:north_west, _w, _h, _tw, _th), do: {0, 0}
  defp focal_offset(:south_east, w, h, tw, th), do: {max(w - tw, 0), max(h - th, 0)}
  defp focal_offset(:south_west, _w, h, _tw, th), do: {0, max(h - th, 0)}

  defp focal_offset({:xy, fx, fy}, w, h, tw, th) do
    x = round((w - tw) * fx) |> clamp_offset(w - tw)
    y = round((h - th) * fy) |> clamp_offset(h - th)
    {x, y}
  end

  defp focal_offset(_other, w, h, tw, th), do: {center_x(w, tw), center_y(h, th)}

  defp center_x(w, tw), do: max(div(w - tw, 2), 0)
  defp center_y(h, th), do: max(div(h - th, 2), 0)
  defp clamp_offset(value, _max) when value < 0, do: 0
  defp clamp_offset(value, max) when value > max, do: max(max, 0)
  defp clamp_offset(value, _max), do: value

  defp maybe_pad(%Ops.Resize{fit: :pad}, image, width, height) do
    case Image.embed(image, width, height, background_color: :white) do
      {:ok, _} = success ->
        success

      {:error, reason} ->
        {:error,
         Error.new(:pipeline_failed, "Image.embed (pad) failed",
           details: %{reason: format_reason(reason)}
         )}
    end
  end

  defp maybe_pad(_resize, image, _width, _height), do: {:ok, image}

  # Image.write/3 / Image.thumbnail/3 / Image.embed/4 always return
  # `{:error, %Image.Error{message: binary}}` on failure. The
  # `:uncropped` atom from Image.trim/2 is handled by the caller
  # before reaching here.
  defp format_reason(%{message: message}) when is_binary(message), do: message
  defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
end