defmodule Image.Components do @moduledoc """ Phoenix.Component wrappers for the `image` / `image_plug` ecosystem. Exposes two LiveView-friendly components — `<.image>` and `<.picture>` — that take per-transform attributes (width, height, fit, format, blur, brightness, …), build a canonical `Image.Plug.Pipeline`, and project that pipeline onto the URL grammar of one of five supported providers via `Image.Components.URL`: four commercial image CDNs (Cloudflare Images, Cloudinary, imgix, ImageKit) plus [IIIF Image API 3.0](https://iiif.io/api/image/3.0/). Use this module like any other Phoenix.Component: defmodule MyAppWeb.PageLive do use MyAppWeb, :live_view import Image.Components def render(assigns) do ~H\"\"\" <.image src="/uploads/cat.jpg" provider={:cloudflare} width={600} fit={:cover} /> \"\"\" end end Each component renders a plain HTML element (`` or ``); the only "magic" is constructing the URL. There is no JavaScript and no LiveView-specific behavior. ## Provider feature gaps Not every transform is expressible in every CDN's URL grammar. Operations that don't have a faithful equivalent are silently dropped from the URL projection — see `Image.Components.URL` for the per-provider coverage table. """ use Phoenix.Component alias Image.Plug.Pipeline alias Image.Plug.Pipeline.Ops alias Image.Components.URL @providers ~w(cloudflare cloudinary imgix imagekit iiif)a @doc """ Renders an `` whose `src` is built by projecting the attribute set onto the configured CDN provider's URL grammar. ### Attributes * `src` — the canonical, *untransformed* source path or URL (e.g. `/uploads/cat.jpg`). Required. * `provider` — `:cloudflare`, `:cloudinary`, `:imgix`, or `:imagekit`. Required. * `host` — optional URL prefix (e.g. `"https://cdn.example.com"` or `"/img"`). Defaults to `""` (relative URL). * `width`, `height` — pixel dimensions. Optional. * `fit` — one of `:contain`, `:cover`, `:crop`, `:pad`, `:scale_down`, `:squeeze`. Optional. * `gravity` — `:center`, `:auto`, `:face`, `:north`, `:south`, `:east`, `:west`, `:north_east`, `:north_west`, `:south_east`, `:south_west`, or `{:xy, x, y}`. Optional. * `dpr` — device-pixel-ratio multiplier. Optional. * `face_zoom` — float in `[0.0, 1.0]` controlling how tightly a face-aware crop hugs the detected face. `0.0` is a loose crop with lots of context, `1.0` hugs the bounding box. Only meaningful with `gravity={:face}`. Optional. * `format` — `:auto`, `:jpeg`, `:png`, `:webp`, `:avif`. Optional. * `quality` — `1..100`. Optional. * `blur`, `sharpen` — sigma (float ≥ 0). Optional. * `brightness`, `contrast`, `saturation`, `gamma` — multipliers where `1.0` means no change. Optional. * `vignette` — strength in `[0.0, 1.0]`. Only Cloudinary's URL grammar carries vignette; other providers drop it. Optional. * `tint` — colour as a hex string (`"#aabbcc"` or `"aabbcc"`) or an `[r, g, b]` integer list. Only imgix's `monochrome=` carries this; other providers drop it. Optional. * `cloudinary_account` — the Cloudinary `` segment. Defaults to `"demo"`. * `imagekit_endpoint` — the ImageKit per-account endpoint segment. Defaults to `"demo"`. * `class`, `alt`, plus any other arbitrary HTML attributes — passed through to the rendered `` via `:rest`. ### Returns * Renders a single `` element. ### Examples <.image src="/cat.jpg" provider={:cloudflare} width={600} fit={:cover} /> <.image src="/cat.jpg" provider={:imgix} host="https://my-source.imgix.net" width={800} format={:webp} quality={80} blur={5.0} /> """ attr(:src, :string, required: true) attr(:provider, :atom, values: @providers, required: true) attr(:host, :string, default: "") attr(:width, :integer, default: nil) attr(:height, :integer, default: nil) attr(:fit, :atom, default: nil) attr(:gravity, :any, default: nil) attr(:dpr, :integer, default: nil) attr(:face_zoom, :float, default: nil) attr(:format, :atom, default: nil) attr(:quality, :integer, default: nil) attr(:blur, :float, default: nil) attr(:sharpen, :float, default: nil) attr(:brightness, :float, default: nil) attr(:contrast, :float, default: nil) attr(:saturation, :float, default: nil) attr(:gamma, :float, default: nil) attr(:vignette, :float, default: nil) attr(:tint, :any, default: nil) # IIIF-specific. `:region` defines a sub-rectangle of the source # image (`:full`, `{:pixels, x, y, w, h}`, or `{:percent, x, y, w, h}`). # `:iiif_quality` is one of `:default`, `:color`, `:gray`, # `:bitonal` per the IIIF spec — distinct from `:quality` (which # is the compression quality `1..100`). Both are honoured by the # `:iiif` provider; other providers ignore them. attr(:region, :any, default: nil) attr(:iiif_quality, :atom, values: [:default, :color, :gray, :bitonal, nil], default: nil) attr(:cloudinary_account, :string, default: "demo") attr(:imagekit_endpoint, :string, default: "demo") # IIIF server prefix segment; e.g. `"/iiif/3"` for an Image API # 3.0 server. Only used when `provider: :iiif`. attr(:iiif_prefix, :string, default: "/iiif/3") # Fallback format extension when `Format.type` is `:auto`. IIIF # requires an explicit format in the URL. attr(:iiif_format, :atom, default: :jpeg) attr(:rest, :global, include: ~w(alt class srcset sizes loading decoding fetchpriority)) def image(assigns) do pipeline = build_pipeline(assigns) url = build_url(assigns.provider, pipeline, source_path: assigns.src, host: assigns.host, cloudinary_account: assigns.cloudinary_account, imagekit_endpoint: assigns.imagekit_endpoint, iiif_prefix: assigns[:iiif_prefix] || "/iiif/3", iiif_format: assigns[:iiif_format] || :jpeg ) assigns = assign(assigns, :__src, url) ~H""" """ end @doc """ Renders a `` element with format-specific `` rows that share the rest of the transform set, plus a fallback ``. ### Attributes Same as `image/1`, with one extra: * `formats` — list of formats to emit as `` rows. Defaults to `[:avif, :webp]`. The fallback `` uses `format` if given, otherwise the original format. ### Returns * Renders a `` element with one `` per requested format and a single `` fallback. ### Examples <.picture src="/cat.jpg" provider={:cloudflare} width={600} formats={[:avif, :webp]} /> """ attr(:src, :string, required: true) attr(:provider, :atom, values: @providers, required: true) attr(:host, :string, default: "") attr(:formats, :list, default: [:avif, :webp]) attr(:width, :integer, default: nil) attr(:height, :integer, default: nil) attr(:fit, :atom, default: nil) attr(:gravity, :any, default: nil) attr(:dpr, :integer, default: nil) attr(:face_zoom, :float, default: nil) attr(:format, :atom, default: nil) attr(:quality, :integer, default: nil) attr(:blur, :float, default: nil) attr(:sharpen, :float, default: nil) attr(:brightness, :float, default: nil) attr(:contrast, :float, default: nil) attr(:saturation, :float, default: nil) attr(:gamma, :float, default: nil) attr(:vignette, :float, default: nil) attr(:tint, :any, default: nil) attr(:region, :any, default: nil) attr(:iiif_quality, :atom, values: [:default, :color, :gray, :bitonal, nil], default: nil) attr(:cloudinary_account, :string, default: "demo") attr(:imagekit_endpoint, :string, default: "demo") attr(:iiif_prefix, :string, default: "/iiif/3") attr(:iiif_format, :atom, default: :jpeg) attr(:rest, :global, include: ~w(alt class loading decoding fetchpriority)) def picture(assigns) do base = Map.drop(assigns, [:formats, :rest]) common_url_options = picture_url_options(assigns) sources = Enum.map(assigns.formats, fn fmt -> url = build_url( assigns.provider, build_pipeline(%{base | format: fmt}), common_url_options ) %{format: fmt, url: url, mime: mime(fmt)} end) fallback_url = build_url(assigns.provider, build_pipeline(base), common_url_options) assigns = assign(assigns, sources: sources, fallback_url: fallback_url) ~H""" """ end # ─── shared internals ───────────────────────────────────────────── @doc false # Build the canonical IR from the component's flat attribute # set. Public-but-undocumented for the playground; not part of # the stable API. def build_pipeline(assigns) do resize_fields = [ width: assigns[:width], height: assigns[:height], fit: assigns[:fit], gravity: assigns[:gravity], dpr: assigns[:dpr], face_zoom: assigns[:face_zoom] ] |> Enum.reject(fn {_k, v} -> is_nil(v) end) adjust_fields = [ brightness: assigns[:brightness], contrast: assigns[:contrast], saturation: assigns[:saturation], gamma: assigns[:gamma] ] |> Enum.reject(fn {_k, v} -> is_nil(v) end) format_fields = [type: assigns[:format], quality: assigns[:quality]] |> Enum.reject(fn {_k, v} -> is_nil(v) end) ops = [] |> maybe_prepend(resize_fields, fn fs -> struct(Ops.Resize, fs) end) |> maybe_prepend(adjust_fields, fn fs -> struct(Ops.Adjust, fs) end) |> maybe_prepend_blur(assigns[:blur]) |> maybe_prepend_sharpen(assigns[:sharpen]) |> maybe_prepend_vignette(assigns[:vignette]) |> maybe_prepend_tint(assigns[:tint]) |> maybe_apply_iiif_quality(assigns[:iiif_quality]) |> maybe_prepend_region(assigns[:region]) output = case format_fields do [] -> nil fs -> struct(Ops.Format, fs) end %Pipeline{ops: ops, output: output} end defp maybe_prepend(ops, [], _builder), do: ops defp maybe_prepend(ops, fields, builder), do: [builder.(fields) | ops] defp maybe_prepend_blur(ops, nil), do: ops defp maybe_prepend_blur(ops, sigma) when sigma > 0, do: [%Ops.Blur{sigma: sigma * 1.0} | ops] defp maybe_prepend_blur(ops, _), do: ops defp maybe_prepend_sharpen(ops, nil), do: ops defp maybe_prepend_sharpen(ops, sigma) when sigma > 0, do: [%Ops.Sharpen{sigma: sigma * 1.0} | ops] defp maybe_prepend_sharpen(ops, _), do: ops defp maybe_prepend_vignette(ops, nil), do: ops defp maybe_prepend_vignette(ops, strength) when strength > 0, do: [%Ops.Vignette{strength: strength * 1.0} | ops] defp maybe_prepend_vignette(ops, _), do: ops defp maybe_prepend_tint(ops, nil), do: ops defp maybe_prepend_tint(ops, color) do case normalise_color(color) do nil -> ops [_, _, _] = rgb -> [%Ops.Tint{color: rgb} | ops] end end # `Ops.Tint`'s type is `[non_neg_integer()]`. Accept the # convenience forms — hex string ("#aabbcc" or "aabbcc"), # already-an-RGB-list — and normalise to a 3-element int # list. Anything else is silently dropped. defp normalise_color([r, g, b]) when is_integer(r) and is_integer(g) and is_integer(b), do: [r, g, b] defp normalise_color("#" <> rest), do: normalise_color(rest) defp normalise_color(<>) do with {:ok, ri} <- parse_hex_byte(r), {:ok, gi} <- parse_hex_byte(g), {:ok, bi} <- parse_hex_byte(b) do [ri, gi, bi] else _ -> nil end end defp normalise_color(_), do: nil defp parse_hex_byte(<<_::binary-2>> = pair) do case Integer.parse(pair, 16) do {n, ""} when n in 0..255 -> {:ok, n} _ -> :error end end # IIIF quality — `:gray` ⇒ ensure an `Ops.Adjust{saturation: 0.0}` # is in the pipeline; `:bitonal` ⇒ ensure an `Ops.Posterize{levels: # 2}` is. `:default` / `:color` / `nil` are no-ops. Other # providers ignore the resulting ops if their URL grammar can't # carry them; for `:iiif`, this is what # `Image.Components.URL.iiif/2`'s quality detector reads. defp maybe_apply_iiif_quality(ops, nil), do: ops defp maybe_apply_iiif_quality(ops, :default), do: ops defp maybe_apply_iiif_quality(ops, :color), do: ops defp maybe_apply_iiif_quality(ops, :gray) do {existing_adjust, others} = pop_op(ops, Ops.Adjust) base = existing_adjust || %Ops.Adjust{} [%{base | saturation: 0.0} | others] end defp maybe_apply_iiif_quality(ops, :bitonal) do {_existing_posterize, others} = pop_op(ops, Ops.Posterize) [%Ops.Posterize{levels: 2} | others] end defp pop_op(ops, module) do {match, rest} = Enum.split_with(ops, fn o -> o.__struct__ == module end) {List.first(match), rest} end # IIIF region — `:full` (or nil) is a no-op. `{:pixels, x, y, w, h}` # / `{:percent, x, y, w, h}` build an `Ops.Crop` op, IF that # struct exists in the loaded `image_plug`. When `Ops.Crop` is # not yet defined (Phase 2a of the IIIF rollout) this silently # drops the region — `Image.Components.URL.iiif/2`'s `iiif_region/1` # then emits `full`. defp maybe_prepend_region(ops, nil), do: ops defp maybe_prepend_region(ops, :full), do: ops defp maybe_prepend_region(ops, {units, x, y, w, h}) when units in [:pixels, :percent] do if Code.ensure_loaded?(Ops.Crop) do [struct(Ops.Crop, x: x, y: y, width: w, height: h, units: units) | ops] else ops end end defp maybe_prepend_region(ops, _), do: ops defp picture_url_options(assigns) do [ source_path: assigns.src, host: assigns.host, cloudinary_account: assigns.cloudinary_account, imagekit_endpoint: assigns.imagekit_endpoint, iiif_prefix: assigns[:iiif_prefix] || "/iiif/3", iiif_format: assigns[:iiif_format] || :jpeg ] end defp build_url(:cloudflare, pipeline, options), do: URL.cloudflare(pipeline, options) defp build_url(:cloudinary, pipeline, options), do: URL.cloudinary(pipeline, options) defp build_url(:imgix, pipeline, options), do: URL.imgix(pipeline, options) defp build_url(:imagekit, pipeline, options), do: URL.imagekit(pipeline, options) defp build_url(:iiif, pipeline, options), do: URL.iiif(pipeline, options) defp mime(:avif), do: "image/avif" defp mime(:webp), do: "image/webp" defp mime(:jpeg), do: "image/jpeg" defp mime(:png), do: "image/png" defp mime(_), do: nil end