Payload Plugins
Pluginspayload-images

Image metadata

One upload-time decode gives every render a blur placeholder, a color palette, alpha flags, and a subject-aware crop — no extra files, no extra requests.

For AI / LLMs: View Markdown

Every upload gets one Sharp decode that stamps a render's needs onto the doc: a ladder of placeholder tiers, a color palette, alpha flags, and a crop that stays on the subject.

Reads pull these straight off the document. No extra files, no extra requests.

This page walks each field and how reads consume it.

Placeholders

A blurred preview paints from the first render and is covered when the real image loads. No pop-in.

The upload hook stores the image as a ladder of placeholder tiers — two formats behind one quality knob (why below). The low tiers are BlurHash strings; the top two are a micro-webp:

TierStored asSizeLooks like
xsblurhash 2×212 charsa corner-to-corner gradient
smblurhash 4×328 charsthe classic blurhash (the default)
mdblurhash 6×452 charssoft composition blobs
lgblurhash 8×6100 charsclear composition
xlblurhash 9×9166 charsmaximum blurhash detail — still very much a blur
2xlwebp 32px~0.5–1 KBshapes and light read clearly
3xlwebp 64px~1–3 KBa heavily blurred thumbnail

The Focus & crop component's half & half preview shows any tier against the real pixels, cropped to the same render. sm — 28 characters — reads as pure atmosphere:

Half & half preview at the sm tier: real image left, the 28-character blurhash placeholder right, both cropped to the same 3:2 renderHalf & half preview at the sm tier: real image left, the 28-character blurhash placeholder right, both cropped to the same 3:2 render

3xl — a ~1–3 KB micro-webp — already carries the composition:

Half & half preview at the 3xl tier: real image left, the 64px micro-webp placeholder right, the lighthouse clearly legibleHalf & half preview at the 3xl tier: real image left, the 64px micro-webp placeholder right, the lighthouse clearly legible

Why two formats

Neither format spans the whole quality range. Their strengths are opposites, so the ladder switches codecs at the point where one stops earning its bytes.

  • BlurHash is performance-shaped. A placeholder is a handful of cosine components, not pixels — 12–166 characters that render at any size and crop in coefficient space (pure string math, microseconds, no image decode). That's also its ceiling: past ~9×9 components, extra bytes buy almost no extra detail. Blurhash never resolves beyond soft blobs, no matter how much you spend.
  • webp is quality-shaped. Real pixels carry real composition — at 64px the subject is already legible. But that costs real bytes: 2xl runs ~0.5–1 KB and 3xl ~1–3 KB, versus tens of characters for a blurhash. Every read that declares a webp-tier blur crops it with a Sharp decode on your server (milliseconds, not microseconds — the placeholder virtual does the crop per read). Stretched over the render box, the browser's upscaling smooths the micro-image into the blur you see.

So the ladder runs blurhash from xs to xl — its 9×9 best — and switches to micro-webp for 2xl/3xl, exactly where more quality stops being possible in blurhash. Consumers never pick a codec: one quality knob, and the tier you ask for determines the format, its cost, and its crop path. Reads that want near-free placeholders stay in the blurhash tiers; reads that want a recognizable preview pay for a micro-webp — 2xl ~0.5–1 KB, 3xl ~1–3 KB. That's a choice per read, not per project.

The placeholder virtual

On read, the virtual placeholder field does all the placeholder work in the field hook, next to the data. Consumers select one field and paint what they get back.

The field is on by default: an undeclared read gets the sm tier, cropped to any declared render ratio — except for alpha/SVG docs, which never cover a placeholder and so skip the implicit default. context: { blur: false } opts any read out entirely. Declaring a blur picks the tier: blurhash tiers crop in coefficient space (pure string math, microseconds); webp tiers crop the stored micro-image (a ~1 KB Sharp decode, milliseconds). It's the same crop the transform endpoint makes, applied to the placeholder — no files, no extra cache rows.

Declare the blur with a read context:

const image = await payload.findByID({
  collection: 'images',
  id,
  depth: 0,
  select: { alt: true, width: true, height: true, placeholder: true, variantVersion: true },
  context: { image: { aspectRatio: '16:9' }, blur: { quality: 'sm' } }, // placeholder arrives as a finished data URI
})

(imageFor(...).blur('sm') declares the same thing for you.)

The context keys — and their REST-header equivalents on a X-Blurhash read — are:

context keyX-Blurhash headerValuesMeaning
blur.qualityq=xs sm md lg xl 2xl 3xl — default smwhich placeholder tier to render
image.aspectRatioratio prefix (e.g. 16:9)W:Hcrop the placeholder to this ratio

A read that declares a blur with no tier defaults to sm, as does a read that declares nothing. Only context: { blur: false } returns null — for reads on surfaces that would never paint a placeholder. A doc with no stored tiers renders without a placeholder. The field's return type is string | null.

<ResponsiveImage> is passive here: it paints the string it was handed as its own background-image — zero network, zero client JS, native swap when the real pixels load.

Non-React consumers get the same placeholder: send X-Blurhash: 16:9; q=md on a REST read and placeholder comes back as a data URI cropped to that ratio at that tier (16:9 maps to image.aspectRatio, q=md to blur.quality). Decoding client-side with a stock blurhash library instead? Add format=hash and the cropped raw hash string comes back (blurhash tiers only — webp tiers degrade to xl).

Palette & alpha

The same decode extracts a Sanity-style color palette and alpha flags, stored on the doc and riding through reads and relationship population:

{
  "palette": {
    "dominant":  { "background": "#8a6748", "foreground": "#ffffff", "title": "#ffffff", "population": 0.31 },
    "vibrant":   { "background": "#d97a2b", "foreground": "#ffffff", "title": "#ffffff", "population": 0.08 },
    "muted": null, "darkVibrant": null, "darkMuted": null,
    "lightVibrant": null, "lightMuted": null // same swatch shape when present; null when nothing matches
  },
  "hasAlpha": false,  // the file carries an alpha channel
  "isOpaque": true    // no sampled pixel is actually transparent
}

Use palette.dominant.background as a solid-color placeholder behind tiny thumbs (cheaper than a blurhash PNG), foreground/title for contrast-safe text over the image, and the alpha flags for format decisions (e.g. don't JPEG-flatten a transparent logo).

Focal point, hotspot & crop

Optimization is only half the job: a 16:9 hero and a 1:1 thumbnail of the same photo need different crops, and a naive center-crop lops off heads.

One square upload rendered at natural, 16:9, 1:1, and 9:16 — the focal point (28%/72%, on the apple) keeps the subject in frame at every ratioOne square upload rendered at natural, 16:9, 1:1, and 9:16 — the focal point (28%/72%, on the apple) keeps the subject in frame at every ratio

Three stored, non-destructive layers (Sanity's model) shape every crop. The original file is never modified, unlike Payload's built-in destructive "Edit image" crop:

LayerStored asMeaning
CropcropLeft/Top/Right/Bottom (%)"only this region may ever be used" — everything outside is trimmed at transform time
Focal pointfocalX/focalY (%)crop windows center here
Hotspot sizefocalSize (%)the circle's diameter: how much context must stay in frame — smaller = tighter zoom, 100 = the classic maximal window
  • Set automatically, refined once. On a fresh upload with no editor-set focal, the hook sets focalX/focalY from Sharp's saliency (attention) analysis when that analysis yields a confident attention point — one comfortably inside the frame. Low-saliency images (flat colors, smooth gradients) report edge-pinned points, which are discarded rather than guessed, so the focal stays at Payload's default (50/50). The Focus & crop component does the rest: click/drag sets the focal point, the circle's edge handle sizes the hotspot, the corner handles draw the crop (everything outside dims). Ratio tiles preview the real crop, the placeholder, or half & half — live, with the exact geometry the endpoint uses.
  • It rides along automatically. The endpoint reads all three layers from the document on any fit=cover request that pins both dimensions (w + h — which every ratio-declared render does), so <ResponsiveImage>, the virtual URLs, raw endpoint calls, and the placeholder virtual all honor them with nothing extra to pass. A width-only cover request and non-cover fits honor the crop rect but resize without the focal window.
  • No upscaling, even zoomed. A tight hotspot crops a small pixel region; the endpoint serves that region at its native resolution (CSS scales it up) rather than inventing pixels.
  • Change any layer and stale renders refresh. Edits purge that image's cached variants and bump its v token, so anything already served refetches — Caching has the full story.

The Focus & crop component: the hotspot circle with its zoom handle over the image, crop corner handles, and live ratio preview tiles with normal/placeholder modesThe Focus & crop component: the hotspot circle with its zoom handle over the image, crop corner handles, and live ratio preview tiles with normal/placeholder modes

Backfilling an existing library

All of the above is stamped by the upload hook. Images uploaded before the plugin (or before a feature) carry none of it. Reads degrade gracefully — no placeholder, no palette, nothing breaks — until you stamp them once with the bundled command:

payload images:backfill                    # only images missing metadata (idempotent)
payload images:backfill --force            # regenerate every image
payload images:backfill --focal            # also set the saliency focal on docs still at 50/50
payload images:backfill --collection media # a renamed images collection

--focal is opt-in because it changes crops on a live site (busts caches, regenerates variants); everything else is additive metadata that alters no rendered pixels.

On this page