Image.Components

Phoenix.Component wrappers (<.image> and <.picture>) that emit URLs in the documented URL grammars of four major image CDNs plus the IIIF Image API 3.0 standard:

Point the host= attribute at your real Cloudflare / Cloudinary / imgix / ImageKit account and the URLs <.image> produces hit those services directly. There is no Elixir-side image processing in the request path, no proxy server you have to run, and no operational dependency on the rest of the elixir-image libraries — just URL string construction in your render template, exactly like every other Phoenix.Component.

Use it directly with your CDN account

<.image
  src="/cat.jpg"
  provider={:cloudflare}
  host="https://imagedelivery.net/<your-account-hash>"
  width={600}
  fit={:cover}
  format={:webp}
  quality={80}
/>

That's it — the rendered <img src="…"> URL is the one Cloudflare Images itself parses and transforms. Same template against any of the four providers; just change provider= and host=.

Installation

def deps do
  [
    {:image_components, "~> 0.1"}
  ]
end

That's the whole runtime requirement. image_components brings in phoenix_live_view (for the component machinery) and image_plug (for the canonical Pipeline IR struct that the URL builders consume — image_plug is not invoked at runtime, the struct is just a convenient data carrier shared with the server-side library).

If you also want to self-host the image-processing service — for development, for tests, or as your production origin — mount image_plug somewhere on your Phoenix endpoint and set host= accordingly. See Local server in dev, native CDN in prod for the recipe. The components don't care whether the URL ends up at the real CDN's edge or at your own image_plug mount; both speak the same URL grammar.

Quick start

In a LiveView or function 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}
      format={:webp}
      quality={80}
    />

    <.picture
      src="/uploads/cat.jpg"
      provider={:cloudflare}
      formats={[:avif, :webp]}
      width={600}
    />
    """
  end
end

The components render plain HTML (<img> and <picture>); the only "magic" is that src= (or each <source srcset=>) is built by Image.Components.URL.<provider>/2. There is no JavaScript and no LiveView-specific behaviour.

Required configuration per provider

Each CDN needs a provider= and a host=. Two providers also need an account/endpoint segment in the URL path; the components default both to "demo" (a public test account on each service) so the quick-start examples Just Work, but you'll override them once you point at your own account.

Provider provider=host= (your CDN's edge) Account segment attribute
Cloudflare Images:cloudflare"https://imagedelivery.net/<account-hash>" (hosted form) or "https://your-zone.example.com" (zone form) n/a — the account hash is in the host
Cloudinary:cloudinary"https://res.cloudinary.com"cloudinary_account="<your-cloud-name>"
imgix:imgix"https://<your-source>.imgix.net" n/a — the source is in the host
ImageKit:imagekit"https://ik.imagekit.io"imagekit_endpoint="<your-endpoint>"
IIIF:iiif"https://iiif.example.org" (your IIIF server's base) iiif_prefix="/iiif/3" (the version prefix the server publishes; default "/iiif/3")

A typical app sets these via Application config so render templates stay clean:

# config/runtime.exs
config :my_app, :image_cdn,
  provider:           :cloudinary,
  host:               System.fetch_env!("CDN_HOST"),
  cloudinary_account: System.fetch_env!("CLOUDINARY_CLOUD_NAME")

…and read it in a thin per-app wrapper component:

defmodule MyAppWeb.Components.Image do
  use Phoenix.Component
  import Image.Components, only: [image: 1]

  attr :src, :string, required: true
  attr :rest, :global, include: ~w(width height fit gravity dpr face_zoom format
                                    quality blur sharpen brightness contrast
                                    saturation gamma vignette tint alt class srcset
                                    sizes loading decoding)

  def img(assigns) do
    cdn = Application.fetch_env!(:my_app, :image_cdn)
    assigns = assign(assigns,
      provider: cdn[:provider],
      host: cdn[:host],
      cloudinary_account: cdn[:cloudinary_account] || "demo",
      imagekit_endpoint:  cdn[:imagekit_endpoint]  || "demo"
    )

    ~H"""
    <.image
      src={@src}
      provider={@provider}
      host={@host}
      cloudinary_account={@cloudinary_account}
      imagekit_endpoint={@imagekit_endpoint}
      {@rest}
    />
    """
  end
end

Then everywhere else in your app:

<.img src="/cat.jpg" width={600} fit={:cover} alt="A cat" />

The full per-environment recipe (different host= in dev/test/prod, conditionally mounting image_plug for local development) is in the environments guide.

URLs without rendering

If you only need URLs, skip the components and call the projector directly:

alias Image.Components.URL
alias Image.Plug.Pipeline
alias Image.Plug.Pipeline.Ops

pipeline = %Pipeline{
  ops: [%Ops.Resize{width: 600, fit: :cover, gravity: :face}],
  output: %Ops.Format{type: :webp, quality: 80}
}

URL.cloudflare(pipeline, source_path: "/cat.jpg", host: "/img")
# => "/img/cdn-cgi/image/width=600,fit=cover,gravity=face,format=webp,quality=80/cat.jpg"

Provider semantic differences

Adjust effects (brightness, contrast, saturation, gamma) have one IR — multipliers where 1.0 = no change — but the four CDNs encode them differently:

Similarly: vignette survives only into Cloudinary (e_vignette:N); tint survives only into imgix (monochrome=<hex>). The other CDNs drop these silently.

Provider feature gaps

IR op Cloudflare Cloudinary imgix ImageKit IIIF
Resize ✓ (fit: :cover —)
Format ✓ (:auto →fallback)
Adjust ✓ (raw mult.) ✓ (centred) ✓ (centred) gray only
Blur
Sharpen
Vignette
Tint ✓ (mono only)
Rotate ✓ (any 0..360)
Trim
Background
face_zoom
Crop
Posterize{2} ✓ (→ bitonal)

Empty cells = no equivalent in that grammar. IIIF is the only entry that actually expresses sub-region cropping in URL form (region segment); CDN providers express crop indirectly via fit modes.

Components

<.image>

Renders a single <img> whose src is the projected URL.

<.image
  src="/uploads/cat.jpg"
  provider={:cloudflare}
  host="/img"
  width={600}
  height={400}
  fit={:cover}
  gravity={:face}
  face_zoom={0.6}
  format={:webp}
  quality={80}
  blur={2.5}
  brightness={1.1}
  contrast={1.2}
  alt="A cat"
  class="rounded-lg"
/>

See Image.Components.image/1 for the full attribute reference.

<.picture>

Renders a <picture> with one <source srcset=> per format in :formats (default [:avif, :webp]) plus a fallback <img>.

<.picture
  src="/uploads/cat.jpg"
  provider={:cloudflare}
  formats={[:avif, :webp]}
  width={1200}
  fit={:cover}
/>

See Image.Components.picture/1 for the full attribute reference.

Adding a new CDN provider

A provider is a single function from Image.Plug.Pipeline.t() plus an options keyword list to a URL string. To add a new CDN — say Bunny.net Image Optimizer, or an internal one — write a module with one public function per CDN you support, mirroring Image.Components.URL:

defmodule MyApp.URL do
  alias Image.Plug.Pipeline
  alias Image.Plug.Pipeline.Ops

  @spec bunny(Pipeline.t(), keyword()) :: String.t()
  def bunny(%Pipeline{} = pipeline, options \\ []) do
    query = pipeline |> bunny_options() |> URI.encode_query()
    source = Keyword.get(options, :source_path, "/sample.jpg")
    host = Keyword.get(options, :host, "")

    if query == "", do: "#{host}#{source}", else: "#{host}#{source}?#{query}"
  end

  defp bunny_options(pipeline) do
    resize = Enum.find(pipeline.ops, &match?(%Ops.Resize{}, &1))
    output = pipeline.output

    []
    |> opt("width",  resize && resize.width)
    |> opt("height", resize && resize.height)
    |> opt("aspect_ratio", resize && resize.fit && bunny_fit(resize.fit))
    |> opt("quality", output && output.quality)
    # …add per-op tokens as you support them.
  end

  defp opt(acc, _key, nil), do: acc
  defp opt(acc, key, value), do: acc ++ [{key, to_string(value)}]

  defp bunny_fit(:cover), do: "1:1"
  defp bunny_fit(_), do: nil
end

Then expose it through your own component, or extend the <.image> you wrap in your app:

defp build_url(:bunny, pipeline, options), do: MyApp.URL.bunny(pipeline, options)
defp build_url(other, pipeline, options), do: apply(Image.Components.URL, other, [pipeline, options])

The provider behaviour is informal — there is no @behaviour to implement. Each builder takes (pipeline, options) and returns a string; the components dispatch on the provider= atom. Keep your builder in your app's namespace if it's app-specific, or release it as a small companion package that depends on image_components for the IR types and adds <provider>/2 to the surface.

When the new CDN's URL grammar can't faithfully express an IR op, drop it silently — every shipped builder does the same. Don't approximate; the provider you pick should be the contract, and the URL it produces should be the truth of what that CDN can carry.

If your new CDN warrants two-way compatibility (URL parsing as well as URL building) so the in-process image_plug can serve it during development, the parser side lives in image_plug — see its provider modules for examples of the inverse mapping.

Guides

For source resolution (file vs HTTP vs S3 vs custom), see image_plug's sources guide.

Testing

The test suite has three layers, each at a different point on the speed/coverage trade-off.

For the cross-SDK suite, install the Node helper deps once:

cd test/support/cross_sdk && npm install
mix test --include cross_sdk

Run all three layers together:

mix test --include cross_sdk --include live_cdn

Playground

image_playground is a Phoenix LiveView app that drives this library and the four provider mounts in image_plug. Drop an image, tweak transforms with sliders, and watch the four CDN URLs and the equivalent HEEx call update live next to a rendered preview.

License

Apache-2.0.