Building an OG image lab for feed previews

· · 33 views
Table of Contents

Every feed we know about can carry an og_image_url — the Open Graph image a site advertises for itself. We started stashing it on the Feed schema as part of a broader enrichment pass, alongside language and average entry length:

alter table(:feeds) do
  add :language, :string, size: 10
  add :categories_json, :jsonb, default: "[]"
  add :avg_entry_length, :integer
  add :og_image_url, :text
end

Storing a URL is easy. Deciding what to do with it is not. An OG image is authored for a Facebook share card: 1200x630, whatever weight the publisher felt like shipping, sometimes a 400 KB PNG. When you drop that straight into a recommendations sidebar you get layout jank, blurry thumbnails, and a page that pulls a megabyte of images it will render at 96 pixels wide. I wanted a place to see the transforms before committing to them. That place is the Admin Image Lab.

What the lab actually is

The lab is deliberately thin. The interesting LiveView lives in ImagePlaygroundWeb.PlaygroundLive (the upstream image_playground project, vendored under lib/image_playground*/). Our admin route is a wrapper that does nothing but run that LiveView inside our auth pipeline:

defmodule BoringRSSWeb.Admin.ImageLabLive do
  @moduledoc """
  Admin wrapper for the Image Lab (image_playground).
  Runs inside the BoringRSS admin auth pipeline and delegates all
  LiveView logic to `ImagePlaygroundWeb.PlaygroundLive`.
  """
  use BoringRSSWeb, :live_view

  @impl true
  def mount(params, session, socket),
    do: ImagePlaygroundWeb.PlaygroundLive.mount(params, session, socket)
end

render/3 and handle_event/3 delegate the same way. This is the whole trick: the playground is a self-contained tool, and I did not want to fork it just to bolt on our session handling. Mounting it at /image-lab behind the admin :image_lab pipeline — the one that also forwards the CDN image plugs at /img, /cloudinary, /imgix, /imagekit and /iiif — gives us the full tool with zero maintenance surface of our own.

One struct, everything downstream

The design decision I keep coming back to is that the playground has a single source of truth. The whole UI projects from one %Image.Plug.Pipeline{} struct:

defp fresh_pipeline do
  %Pipeline{
    ops: [%Ops.Resize{width: 600, fit: :contain}],
    output: %Ops.Format{type: :auto, quality: 80}
  }
end

Every control on the page fires a phx-change="update_control", and a pile of apply_field/3 clauses mutate exactly one op on that pipeline:

defp apply_field(p, "width", v), do: put_resize(p, :width, parse_int(v))
defp apply_field(p, "quality", v), do: put_format(p, :quality, parse_int(v) || 80)
defp apply_field(p, "blur", v), do: put_op(p, %Ops.Blur{sigma: parse_float(v) || 0.0})
defp apply_field(p, _, _), do: p

That last catch-all clause is the kind of small mercy you appreciate at 2am: unknown form fields (CSRF tokens, hidden inputs) are ignored instead of crashing the LiveView.

The operations on offer

The lab covers exactly the operations that matter for turning a share card into a thumbnail:

  • Resize — width, height, fit (contain / cover / crop / pad / scale_down / squeeze), gravity, DPR, and a face-zoom knob that only bites when gravity is face.
  • Format — auto/jpeg/png/webp/avif, quality, and metadata handling (keep / none / copyright).
  • Effects — blur and sharpen (both sigma-based).
  • Adjust — brightness, contrast, saturation, gamma.
  • Stylize — vignette and tint.

The stylize controls come with an honest warning in the UI, and it is the most useful thing in the whole tool: vignette is only expressible in Cloudinary's URL grammar and tint only in imgix's monochrome=, so other providers drop them silently. That is exactly the sort of cross-CDN gotcha you want to discover in a lab, not in production.

Why five CDN URLs, not one

The payoff panel builds the same transform as a URL for every provider at once:

cdn_urls = [
  {:cloudflare, "Cloudflare",
   URL.cloudflare(pipeline, source_path: source_path, host: provider_host(:cloudflare))},
  {:cloudinary, "Cloudinary",
   URL.cloudinary(pipeline, source_path: source_path, host: provider_host(:cloudinary))},
  {:imgix, "imgix", URL.imgix(pipeline, source_path: source_path, host: provider_host(:imgix))},
  # imagekit, iiif ...
]

Click a row and that URL becomes the live preview source next to the untouched original; the panel also emits the matching <.image>/<.picture> HEEx so what you see is what you paste. Because the preview and the snippet both project from pipeline_to_image_attrs/1, they cannot drift — a property the playground's authors clearly cared about, and the reason I trust the output.

How this feeds recommendations

The lab is a bench, not a pipeline stage. Nothing here writes to the database. What it does is let me settle the recipe — say, fit=cover, width 600, format=auto, quality 70 — that we then apply to feeds.og_image_url when a feed shows up as a suggestion.

That column does not sit still in Postgres. The nightly GraphSyncWorker replicates it into the Neo4j recommendation graph as a property on each Feed node:

select: %{
  feedId: f.id,
  feedTitle: f.title,
  language: f.language,
  ogImageUrl: f.og_image_url,
  avgEntryLength: f.avg_entry_length
}

So when the recommender surfaces a feed, its OG image is already attached to the node the query returns. The Image Lab is where I decide how that raw share card becomes a crisp, byte-conscious thumbnail before it reaches a reader's sidebar. Small feature, but it turned "guess a transform and redeploy" into "drop an image, drag a slider, copy the URL."