# Image metadata

URL: /docs/plugins/payload-images/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.

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](#why-two-formats)). The low tiers are
**[BlurHash](https://github.com/woltapp/blurhash)** strings; the top two are a **micro-webp**:

| Tier  | Stored as    | Size       | Looks like                                       |
| ----- | ------------ | ---------- | ------------------------------------------------ |
| `xs`  | blurhash 2×2 | 12 chars   | a corner-to-corner gradient                      |
| `sm`  | blurhash 4×3 | 28 chars   | the classic blurhash (the default)               |
| `md`  | blurhash 6×4 | 52 chars   | soft composition blobs                           |
| `lg`  | blurhash 8×6 | 100 chars  | clear composition                                |
| `xl`  | blurhash 9×9 | 166 chars  | maximum blurhash detail — still very much a blur |
| `2xl` | webp 32px    | \~0.5–1 KB | shapes and light read clearly                    |
| `3xl` | webp 64px    | \~1–3 KB   | a 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 render](/screenshots/half-n-half-sm.png)

`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 legible](/screenshots/half-n-half-3xl.png)

### 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](#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`:

```ts
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')`](/docs/plugins/payload-images/rendering) declares the same thing for
you.)

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

| `context` key       | `X-Blurhash` header        | Values                                              | Meaning                            |
| ------------------- | -------------------------- | --------------------------------------------------- | ---------------------------------- |
| `blur.quality`      | `q=`                       | `xs` `sm` `md` `lg` `xl` `2xl` `3xl` — default `sm` | which placeholder tier to render   |
| `image.aspectRatio` | ratio prefix (e.g. `16:9`) | `W:H`                                               | crop 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:

```jsonc
{
  "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 ratio](/screenshots/square-sample.png)

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:

| Layer        | Stored as                       | Meaning                                                                                                               |
| ------------ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Crop         | `cropLeft/Top/Right/Bottom` (%) | "only this region may ever be used" — everything outside is trimmed at transform time                                 |
| Focal point  | `focalX`/`focalY` (%)           | crop windows center here                                                                                              |
| Hotspot size | `focalSize` (%)                 | 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](/docs/plugins/payload-images/caching#revalidation) 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 modes](/screenshots/focus-n-crop.png)

## 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:

```sh
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.
