Skip to main content

lib/image/plug/provider/image_kit/options.ex

defmodule Image.Plug.Provider.ImageKit.Options do
  @moduledoc """
  Parses an [ImageKit transform string](https://imagekit.io/docs/transformations)
  into a canonical `Image.Plug.Pipeline`.

  ImageKit's transform vocabulary is medium-sized (~50 keys). v0.1
  implements the common subset that maps cleanly onto the canonical
  IR:

  * Sizing: `w`, `h`, `dpr`, `c` / `cm` (crop mode), `fo` (focus =
    gravity), `x` / `y` (focal point — derived together with
    `fo-custom` or `fo-xy_center`).

  * Output: `q`, `f`.

  * Effects: `bg` (background), `e-blur`, `e-sharpen`, `e-contrast`,
    `e-grayscale`, `e-usm` (unsharp mask).

  * Geometry: `rt` (rotate, multiples of 90), `b` (border
    `b-<W>-<color>`).

  * Overlays: `oi` (overlay-image base form).

  Multiple chained transform stages (`tr:w-200:rt-90`) are flattened
  to a single comma-joined list by the URL recogniser. The canonical
  IR doesn't model chained transforms in v0.1, so order-dependent
  multi-stage recipes collapse to last-write-wins. Documented in
  `guides/image_kit_conformance.md`.

  ImageKit-only features that don't fit the canonical IR
  (`e-shadow`, `e-gradient`, AI tags) raise `:unsupported_option`.
  """

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

  require Logger

  # ImageKit `c-` / `cm-` modes → canonical fit + (optional) gravity hint.
  @c_to_fit %{
    "maintain_ratio" => :contain,
    "force" => :squeeze,
    "at_least" => :scale_down,
    "at_max" => :scale_down,
    "at_max_enlarge" => :contain,
    "extract" => :crop,
    "pad_extract" => :pad,
    "pad_resize" => :pad
  }

  @fo_to_gravity %{
    "center" => :center,
    "centre" => :center,
    "top" => :north,
    "bottom" => :south,
    "left" => :west,
    "right" => :east,
    "top_left" => :north_west,
    "top_right" => :north_east,
    "bottom_left" => :south_west,
    "bottom_right" => :south_east,
    "auto" => :auto,
    "face" => :face,
    "custom" => :focalpoint,
    "xy_center" => :focalpoint
  }

  @f_to_atom %{
    "jpg" => :jpeg,
    "jpeg" => :jpeg,
    "png" => :png,
    "webp" => :webp,
    "avif" => :avif,
    "auto" => :auto
  }

  @unsupported_effects %{
    "gradient" =>
      "imagekit `e-gradient` needs a gradient overlay helper in the Image library — see TODO.md",
    "removedotbg" =>
      "imagekit `e-removedotbg` is a third-party AI background-removal call; not implemented",
    "bgremove" => "imagekit `e-bgremove` is an AI background-removal call; not implemented",
    "changebg" => "imagekit `e-changebg` is a generative-AI call; not implemented",
    "edit" => "imagekit `e-edit` is a generative-AI call; not implemented",
    "upscale" => "imagekit `e-upscale` is a model-driven super-resolution call; not implemented"
  }

  @unsupported_keys %{
    "ot" => "imagekit overlay-text (`ot-`) is not implemented in v0.1",
    "obg" => "imagekit overlay-background (`obg-`) is not implemented in v0.1",
    "t" => "imagekit named transformation (`t-`) is a server-side alias not modelled by the IR"
  }

  @doc """
  Parses an ImageKit transform string into an `Image.Plug.Pipeline`.

  ### Arguments

  * `transform_string` — the comma-joined transform string emitted
    by `Image.Plug.Provider.ImageKit.URL` (multiple stages already
    flattened to one comma-separated list).

  * `parser_options` — keyword list. `:strict?` (default `true`)
    controls whether unknown keys raise.

  ### Returns

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

  * `{:error, %Image.Plug.Error{}}` on the first malformed entry.

  ### Examples

      iex> alias Image.Plug.Provider.ImageKit.Options
      iex> {:ok, pipeline} = Options.parse("w-200,c-extract,f-webp,q-80")
      iex> [resize] = pipeline.ops
      iex> {resize.width, resize.fit}
      {200, :crop}
      iex> pipeline.output.type
      :webp

  """
  @spec parse(String.t(), keyword()) :: {:ok, Pipeline.t()} | {:error, Error.t()}
  def parse(transform_string, parser_options \\ []) when is_binary(transform_string) do
    strict? = Keyword.get(parser_options, :strict?, true)

    transform_string
    |> split_entries()
    |> reduce_entries(strict?)
  end

  defp split_entries(""), do: []

  defp split_entries(string) do
    string
    |> String.split(",", trim: true)
    |> Enum.map(&parse_entry/1)
  end

  # An entry is `<prefix>-<value>` where prefix is a short token.
  # `e-<name>[-<value>]` may also appear without a value
  # (`e-grayscale`).
  defp parse_entry(entry) do
    case String.split(entry, "-", parts: 2) do
      [prefix, value] -> {prefix, value}
      [prefix] -> {prefix, ""}
    end
  end

  defp reduce_entries(entries, strict?) do
    initial =
      {:ok,
       %{
         resize: nil,
         adjust: nil,
         output: %Ops.Format{},
         draw_layer: nil,
         appended: [],
         focal_point: %{x: nil, y: nil},
         aspect_ratio: nil
       }}

    entries
    |> Enum.reduce_while(initial, fn {key, value}, {:ok, acc} ->
      case apply_entry(key, value, acc, strict?) do
        {:ok, acc} -> {:cont, {:ok, acc}}
        {:error, _} = error -> {:halt, error}
      end
    end)
    |> finalise()
  end

  defp finalise({:error, _} = error), do: error

  defp finalise({:ok, acc}) do
    pipeline =
      Pipeline.new(provider: Image.Plug.Provider.ImageKit)
      |> Pipeline.put_output(acc.output)
      |> append_in_canonical_order(acc |> apply_aspect_ratio() |> apply_focal_point())

    {:ok, pipeline}
  end

  # When `ar-W-H` was specified along with exactly one of `w`/`h`,
  # derive the missing dimension from the ratio. When both are
  # already set, the user has been explicit; leave them alone.
  # When neither is set, `ar` alone is a no-op (ImageKit's docs
  # say `ar` only takes effect with one of `w`/`h`).
  defp apply_aspect_ratio(%{aspect_ratio: nil} = acc), do: acc

  defp apply_aspect_ratio(%{aspect_ratio: {w_part, h_part}, resize: resize} = acc) do
    case resize do
      %Ops.Resize{width: w, height: nil} when is_integer(w) ->
        derived = max(round(w * h_part / w_part), 1)
        %{acc | resize: %{resize | height: derived}}

      %Ops.Resize{width: nil, height: h} when is_integer(h) ->
        derived = max(round(h * w_part / h_part), 1)
        %{acc | resize: %{resize | width: derived}}

      _ ->
        acc
    end
  end

  defp apply_focal_point(
         %{resize: %Ops.Resize{gravity: :focalpoint} = resize, focal_point: fp} = acc
       )
       when not is_nil(fp.x) and not is_nil(fp.y) do
    %{acc | resize: %{resize | gravity: {:xy, fp.x, fp.y}}}
  end

  defp apply_focal_point(%{resize: %Ops.Resize{gravity: :focalpoint} = resize} = acc) do
    %{acc | resize: %{resize | gravity: :center}}
  end

  defp apply_focal_point(acc), do: acc

  defp append_in_canonical_order(pipeline, acc) do
    [
      find_one(acc.appended, Ops.Rotate),
      find_one(acc.appended, Ops.Flip),
      acc.resize,
      find_one(acc.appended, Ops.Background),
      find_one(acc.appended, Ops.Border),
      acc.adjust,
      find_one(acc.appended, Ops.Enhance),
      find_one(acc.appended, Ops.Sharpen),
      find_one(acc.appended, Ops.Blur),
      find_one(acc.appended, Ops.DropShadow),
      acc.draw_layer
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.reduce(pipeline, &Pipeline.append(&2, &1))
  end

  defp find_one(appended, module) do
    Enum.find(appended, fn op -> op.__struct__ == module end)
  end

  defp ensure_resize(nil), do: %Ops.Resize{}
  defp ensure_resize(%Ops.Resize{} = resize), do: resize

  defp ensure_adjust(nil), do: %Ops.Adjust{}
  defp ensure_adjust(%Ops.Adjust{} = adjust), do: adjust

  defp replace_or_append(appended, %module{} = op) do
    case Enum.find_index(appended, fn existing -> existing.__struct__ == module end) do
      nil -> appended ++ [op]
      index -> List.replace_at(appended, index, op)
    end
  end

  defp invalid(key, value) do
    Error.new(:invalid_option, "invalid value for imagekit option",
      details: %{key: key, value: value}
    )
  end

  defp unsupported(key, message) do
    Error.new(:unsupported_option, message, details: %{key: key})
  end

  defp parse_pos_integer(key, value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} when integer > 0 -> {:ok, integer}
      _ -> {:error, invalid(key, value)}
    end
  end

  defp parse_non_neg_integer(key, value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} when integer >= 0 -> {:ok, integer}
      _ -> {:error, invalid(key, value)}
    end
  end

  # Range-bounded integer parser. Returns `{:ok, n}` or `:error`
  # without wrapping in a typed error so callers can produce
  # context-specific messages.
  defp parse_int_in_range_value(value, range) when is_binary(value) do
    case Integer.parse(value) do
      {n, ""} -> if n in range, do: {:ok, n}, else: :error
      _ -> :error
    end
  end

  defp parse_int_in_range_value(_value, _range), do: :error

  # Parses ImageKit's `e-shadow` parameter string. Format is
  # any combination of `bl-<n>` (blur radius), `st-<n>`
  # (strength / opacity 0..100), `x-<n>` (dx pixels),
  # `y-<n>` (dy pixels), `c-<hex>` (colour), separated by
  # underscores. Order is not significant; missing components
  # use the defaults from `%Ops.DropShadow{}`. A bare
  # `e-shadow` returns the default struct.
  @shadow_defaults %{color: [0, 0, 0], opacity: 0.5, sigma: 5.0, dx: 0, dy: 10}

  defp parse_shadow_params("") do
    {:ok, @shadow_defaults}
  end

  defp parse_shadow_params(value) when is_binary(value) do
    pairs = parse_shadow_pairs(value)

    Enum.reduce_while(pairs, {:ok, @shadow_defaults}, fn {key, val}, {:ok, acc} ->
      case apply_shadow_pair(key, val, acc) do
        {:ok, _} = success -> {:cont, success}
        {:error, _} = error -> {:halt, error}
      end
    end)
  end

  # Each component is `<key>-<value>`; the components are joined
  # by `_`. The negative-number-friendly split below drops empty
  # tokens introduced by ImageKit's `y--3` syntax (which means
  # "y = -3", not "y = (no value), then -3").
  defp parse_shadow_pairs(value) do
    value
    |> String.split("_", trim: true)
    |> Enum.map(fn token ->
      case String.split(token, "-", parts: 2) do
        [key, val] -> {key, val}
        [key] -> {key, ""}
      end
    end)
  end

  defp apply_shadow_pair("bl", val, acc) do
    case parse_int_in_range_value(val, 0..100) do
      {:ok, n} -> {:ok, %{acc | sigma: max(n / 2.0, 0.5)}}
      :error -> {:error, invalid("e-shadow:bl", val)}
    end
  end

  defp apply_shadow_pair("st", val, acc) do
    case parse_int_in_range_value(val, 0..100) do
      {:ok, n} -> {:ok, %{acc | opacity: n / 100.0}}
      :error -> {:error, invalid("e-shadow:st", val)}
    end
  end

  defp apply_shadow_pair("x", val, acc) do
    with {:ok, n} <- parse_int("e-shadow:x", val), do: {:ok, %{acc | dx: n}}
  end

  defp apply_shadow_pair("y", val, acc) do
    with {:ok, n} <- parse_int("e-shadow:y", val), do: {:ok, %{acc | dy: n}}
  end

  defp apply_shadow_pair("c", val, acc) do
    case Color.new(val) do
      {:ok, %Color.SRGB{r: r, g: g, b: b}} ->
        {:ok, %{acc | color: [round(r * 255), round(g * 255), round(b * 255)]}}

      _ ->
        {:error, invalid("e-shadow:c", val)}
    end
  end

  defp apply_shadow_pair(_key, _val, acc), do: {:ok, acc}

  defp parse_int(key, value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} -> {:ok, integer}
      _ -> {:error, invalid(key, value)}
    end
  end

  defp parse_unit_float(key, value) when is_binary(value) do
    case Float.parse(value) do
      {float, ""} when float >= 0.0 and float <= 1.0 -> {:ok, float}
      _ -> {:error, invalid(key, value)}
    end
  end

  # ---------- per-key parsers ----------
  # Sizing -----------------------------------------------------------

  defp apply_entry("w", value, acc, _strict?) do
    with {:ok, integer} <- parse_pos_integer("w", value) do
      {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:width, integer)}}
    end
  end

  defp apply_entry("h", value, acc, _strict?) do
    with {:ok, integer} <- parse_pos_integer("h", value) do
      {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:height, integer)}}
    end
  end

  # ImageKit `ar-W-H` — sets the target aspect ratio. Only
  # takes effect when exactly one of `w`/`h` is also given.
  defp apply_entry("ar", value, acc, _strict?) do
    case String.split(value, "-", parts: 2) do
      [w_str, h_str] ->
        with {:ok, w_part} <- parse_pos_integer("ar", w_str),
             {:ok, h_part} <- parse_pos_integer("ar", h_str) do
          {:ok, %{acc | aspect_ratio: {w_part, h_part}}}
        end

      _ ->
        {:error, invalid("ar", value)}
    end
  end

  # ImageKit `z-<n>` — face-zoom factor in `[0.0, 1.0]`. Stored
  # on the IR's Resize op (`face_zoom` field); the interpreter
  # does not yet act on it (matches Cloudflare's `face-zoom`
  # behaviour). Parsed so users with `z-` in URLs aren't
  # rejected.
  defp apply_entry("z", value, acc, _strict?) do
    case Float.parse(value) do
      {f, ""} when f >= 0.0 and f <= 1.0 ->
        resize = ensure_resize(acc.resize) |> Map.put(:face_zoom, f)
        {:ok, %{acc | resize: resize}}

      _ ->
        {:error, invalid("z", value)}
    end
  end

  defp apply_entry("dpr", value, acc, _strict?) do
    with {:ok, integer} <- parse_pos_integer("dpr", value) do
      capped = min(integer, 3)
      resize = ensure_resize(acc.resize) |> Map.put(:dpr, capped)
      {:ok, %{acc | resize: resize, output: %{acc.output | dpr: capped}}}
    end
  end

  defp apply_entry("c", value, acc, _strict?), do: apply_crop_mode(value, acc)
  defp apply_entry("cm", value, acc, _strict?), do: apply_crop_mode(value, acc)

  defp apply_entry("fo", value, acc, _strict?) do
    case Map.fetch(@fo_to_gravity, value) do
      {:ok, gravity} ->
        {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:gravity, gravity)}}

      :error ->
        {:error, invalid("fo", value)}
    end
  end

  defp apply_entry("x", value, acc, _strict?) do
    with {:ok, x} <- parse_unit_float("x", value) do
      {:ok, %{acc | focal_point: %{acc.focal_point | x: x}}}
    end
  end

  defp apply_entry("y", value, acc, _strict?) do
    with {:ok, y} <- parse_unit_float("y", value) do
      {:ok, %{acc | focal_point: %{acc.focal_point | y: y}}}
    end
  end

  # Output -----------------------------------------------------------

  defp apply_entry("q", value, acc, _strict?) do
    with {:ok, quality} <- parse_pos_integer("q", value) do
      if quality in 1..100 do
        {:ok, %{acc | output: %{acc.output | quality: quality}}}
      else
        {:error, invalid("q", value)}
      end
    end
  end

  defp apply_entry("f", value, acc, _strict?) do
    case Map.fetch(@f_to_atom, value) do
      {:ok, atom} -> {:ok, %{acc | output: %{acc.output | type: atom}}}
      :error -> {:error, invalid("f", value)}
    end
  end

  # Encoder flags ----------------------------------------------------
  #
  # ImageKit `lo-true` — lossless mode (WebP / AVIF / PNG only).
  defp apply_entry("lo", "true", acc, _strict?) do
    {:ok, %{acc | output: %{acc.output | lossy: false}}}
  end

  defp apply_entry("lo", "false", acc, _strict?) do
    {:ok, %{acc | output: %{acc.output | lossy: true}}}
  end

  defp apply_entry("lo", value, _acc, _strict?), do: {:error, invalid("lo", value)}

  # ImageKit `pr-true` — progressive JPEG.
  defp apply_entry("pr", "true", acc, _strict?) do
    {:ok, %{acc | output: %{acc.output | progressive: true}}}
  end

  defp apply_entry("pr", "false", acc, _strict?) do
    {:ok, %{acc | output: %{acc.output | progressive: false}}}
  end

  defp apply_entry("pr", value, _acc, _strict?), do: {:error, invalid("pr", value)}

  # ImageKit `cp-<n>` — chroma subsampling. `1` (4:1:1 / 4:2:0)
  # is the typical low-bandwidth setting; `0` selects libvips'
  # auto behaviour. Values `2`/`3` (4:2:2 / 4:4:4) collapse to
  # `:off` (force full chroma).
  defp apply_entry("cp", value, acc, _strict?) do
    case parse_int_in_range_value(value, 0..3) do
      {:ok, 0} ->
        {:ok, %{acc | output: %{acc.output | chroma_subsampling: :auto}}}

      {:ok, 1} ->
        {:ok, %{acc | output: %{acc.output | chroma_subsampling: :on}}}

      {:ok, _} ->
        {:ok, %{acc | output: %{acc.output | chroma_subsampling: :off}}}

      :error ->
        {:error, invalid("cp", value)}
    end
  end

  # Effects ----------------------------------------------------------

  defp apply_entry("bg", value, acc, _strict?) when is_binary(value) and value != "" do
    color = if String.starts_with?(value, "#"), do: value, else: "#" <> value
    {:ok, %{acc | appended: replace_or_append(acc.appended, %Ops.Background{color: color})}}
  end

  defp apply_entry("e", value, acc, _strict?) when is_binary(value) do
    case String.split(value, "-", parts: 2) do
      [name] -> apply_effect(name, "", acc)
      [name, sub_value] -> apply_effect(name, sub_value, acc)
    end
  end

  # Geometry ---------------------------------------------------------

  defp apply_entry("rt", value, acc, _strict?) do
    with {:ok, angle} <- parse_int("rt", value) do
      cond do
        rem(angle, 90) == 0 ->
          op = %Ops.Rotate{angle: rem(angle, 360)}
          {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

        true ->
          {:error, invalid("rt", value)}
      end
    end
  end

  defp apply_entry("b", value, acc, _strict?) do
    # Either `b-<W>-<color>` (which our split-on-first-`-` lands as
    # `value = "<W>-<color>"`) or `b-<W>_<color>` (the underscore
    # form ImageKit also accepts).
    parts =
      cond do
        String.contains?(value, "_") -> String.split(value, "_", parts: 2)
        String.contains?(value, "-") -> String.split(value, "-", parts: 2)
        true -> [value]
      end

    case parts do
      [width_str, color] ->
        with {:ok, width} <- parse_non_neg_integer("b", width_str) do
          op = %Ops.Border{
            color: if(String.starts_with?(color, "#"), do: color, else: "#" <> color),
            top: width,
            right: width,
            bottom: width,
            left: width
          }

          {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
        end

      _ ->
        {:error, invalid("b", value)}
    end
  end

  # Overlays ---------------------------------------------------------

  defp apply_entry("oi", value, acc, _strict?) when is_binary(value) and value != "" do
    case Image.Plug.Source.path("/" <> value) do
      {:ok, source} ->
        layer = %Ops.Draw.Layer{source: source}
        {:ok, %{acc | draw_layer: %Ops.Draw{layers: [layer]}}}

      {:error, _} = error ->
        error
    end
  end

  # Unsupported keys -------------------------------------------------

  defp apply_entry(key, _value, _acc, _strict?) when is_map_key(@unsupported_keys, key) do
    {:error, unsupported(key, Map.fetch!(@unsupported_keys, key))}
  end

  # Unknown keys -----------------------------------------------------

  defp apply_entry(key, _value, acc, false) do
    Logger.debug(fn -> "image_plug: ignoring unknown imagekit option #{inspect(key)}" end)
    {:ok, acc}
  end

  defp apply_entry(key, value, _acc, true) do
    {:error,
     Error.new(:unknown_option, "unknown imagekit option key", details: %{key: key, value: value})}
  end

  # ---------- helpers ----------

  defp apply_crop_mode(value, acc) do
    case Map.fetch(@c_to_fit, value) do
      {:ok, fit} ->
        {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:fit, fit)}}

      :error ->
        {:error, invalid("c", value)}
    end
  end

  defp apply_effect("blur", "", acc), do: apply_effect("blur", "100", acc)

  defp apply_effect("blur", value, acc) do
    with {:ok, n} <- parse_non_neg_integer("e-blur", value) do
      sigma = min(n, 2000) / 100.0
      op = %Ops.Blur{sigma: sigma}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  defp apply_effect("sharpen", "", acc), do: apply_effect("sharpen", "100", acc)

  defp apply_effect("sharpen", value, acc) do
    with {:ok, n} <- parse_non_neg_integer("e-sharpen", value) do
      sigma = min(n, 100) / 10.0
      op = %Ops.Sharpen{sigma: sigma}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  defp apply_effect("usm", value, acc) do
    # Unsharp mask: usm-<radius>-<sigma>-<amount>-<threshold>.
    # We only honour the sigma-equivalent (second component); the
    # rest tweak the kernel and aren't modelled by the IR.
    case String.split(value, "-") do
      [_radius, sigma_str | _] ->
        with {:ok, n} <- parse_non_neg_integer("e-usm", sigma_str) do
          sigma = min(n, 100) / 10.0
          op = %Ops.Sharpen{sigma: sigma}
          {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
        end

      _ ->
        # Bare `e-usm` with no parameters → default unsharp.
        {:ok, %{acc | appended: replace_or_append(acc.appended, %Ops.Sharpen{sigma: 1.0})}}
    end
  end

  defp apply_effect("contrast", _value, acc) do
    # ImageKit's `e-contrast` is a single boolean toggle (auto-contrast
    # on the input). Approximate by setting Adjust contrast to 1.1
    # (a mild boost). Not exact; documented as ⚠️ in the conformance
    # guide.
    adjust = ensure_adjust(acc.adjust) |> Map.put(:contrast, 1.1)
    {:ok, %{acc | adjust: adjust}}
  end

  defp apply_effect(grayscale, _, acc) when grayscale in ~w(grayscale greyscale) do
    adjust = ensure_adjust(acc.adjust) |> Map.put(:saturation, 0.0)
    {:ok, %{acc | adjust: adjust}}
  end

  # ImageKit `e-retouch` maps to `Image.enhance/2`. The hosted
  # version is ML-driven; we approximate.
  defp apply_effect("retouch", _value, acc) do
    op = %Ops.Enhance{}
    {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
  end

  # ImageKit `e-shadow` accepts an optional dash-separated tuple
  # of `bl-<blur>-st-<strength>-x-<dx>-y-<dy>-c-<hex>` (in any
  # order) per the ImageKit docs. Bare `e-shadow` uses sensible
  # defaults; missing components fall back to the same defaults.
  defp apply_effect("shadow", value, acc) do
    with {:ok, params} <- parse_shadow_params(value) do
      op = %Ops.DropShadow{
        color: params.color,
        opacity: params.opacity,
        sigma: params.sigma,
        dx: params.dx,
        dy: params.dy
      }

      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  defp apply_effect(name, _value, _acc) when is_map_key(@unsupported_effects, name) do
    {:error, unsupported("e-#{name}", Map.fetch!(@unsupported_effects, name))}
  end

  defp apply_effect(name, value, _acc) do
    {:error,
     Error.new(:unknown_option, "unknown imagekit effect",
       details: %{key: "e-#{name}", value: value}
     )}
  end
end