# Variants and presets

> What each preset is for, which ones you must request up front, and the one choice that cannot be undone.

A **preset** is a named recipe. A **variant** is the file that recipe produced
for one asset. You choose presets at upload; you address variants by name
afterwards.

## The image presets

| preset | size | fit | quality | what it is for |
|---|---|---|---|---|
| `thumb` | 256×256 | **cover** | 80 | grids, avatars, anything square. The only cropping preset |
| `sm` | 640 | inside | 80 | list rows, mobile cards |
| `md` | 1280 | inside | 75 | article body, tablet |
| `lg` | 1920 | inside | 80 | hero, full-width desktop |
| `xl` | 3840 | inside | 82 ¹ | retina hero, print-ish, zoom |
| `original` | — | — | — | **the bytes you uploaded**, untouched |

`fit: cover` crops to fill the box. `fit: inside` scales to fit and never crops,
so the numbers are a *bound*, not a size — a 4:3 photo at `lg` comes out
1920×1440.

¹ **`xl` reuses a ≤ 4K base byte-for-byte.** 4K (3840 px long side) is the cap
for listing photos. When the uploaded base is a clean WebP no larger than
3840 px (single frame, no EXIF orientation, no EXIF/XMP block), the server
stores it as `xl` unchanged: same sha256, no second lossy pass. Anything else
is re-encoded at quality 82: a larger base (resized to 3840), JPEG, PNG, HEIC,
an animated WebP, or a WebP carrying EXIF/XMP (the re-encode strips metadata,
so GPS never reaches a public URL). The SDK's default browser compressor
(3840 px WebP) produces a base that qualifies. URLs, keys and response shapes
are unchanged. `xl` is still its own stored object, as large as the base.
Assets stored before this rule keep the `xl` they have.

### ⚠️ The default is `["original"]` — NOT the ladder

Passing no `presets` at all gives you **the original and nothing else**. No
`thumb`, no `lg`, no responsive anything.

That is deliberate on the server's side — a bare POST must never silently spend
4× the storage budget — and the SDK matches it (`DEFAULT_UPLOAD_PRESETS =
["original"]`). If you want the responsive ladder, **ask for it by name**:

```ts
await nt.upload(file, { presets: ["original", "thumb", "md", "lg"] });
```

The failure this prevents is a surprise bill. The failure it *causes*, if you
do not know about it, is a `srcSetFor()` that returns almost nothing.

### ⭐ Which ladder to ask for — the strategy in one table

Asking for the ladder is not only a storage decision. **A stored ladder is what
`/t/` can short-circuit to**, so it also decides what a *delivery* mistake can
cost you later. The two choices, and who each is for:

| you ask for | you get | the risk it carries |
|---|---|---|
| `["original", "thumb", "sm", "md", "lg", "xl"]` | named variants (`getAssetUrl(a,"md")`), no cold transform on the hot widths | a numeric `quality` in a `/t/` URL now silently serves a **second** lossy pass |
| `["original"]` only | one pass, always — nothing to short-circuit to | `getAssetUrl(a,"md")` 404s; every `/t/` width pays one cold transform |

**Since `@nitida/asset-client` 0.24.0 this is no longer a recommendation you can
forget: `quality` is not in `TransformOptions`, so a `/t/` URL with one is a
compile error.** The single narrow exception —a measured byte budget on an asset
with no ladder— has its own door, `getByteBudgetTransformUrl`, which requires the
asset's `presets` (a bare `{ sha }` does not compile) and throws if that asset
turns out to have a ladder, or if `presets` is missing: unknown is not permission. Omitting it forces the master path, so the ladder
becomes a pure latency win with no quality cost — which is the combination you
want. See [the transform benchmark](/guides/transform-benchmark/) for the
numbers, and `x-transform-source` on any response to check which path you got.

A surface that only ever renders through `/t/` — no `getAssetUrl(a, "<preset>")`
call anywhere — can legitimately upload `["original"]` and skip the ladder
entirely. It is the safer default for an archive; the ladder is the right
default for a gallery.

## The video presets

| preset | what it is | who reads it |
|---|---|---|
| `poster` | frame at 10% of duration, WebP q80 | the `<video poster>` attribute |
| `video` | H.264 CRF 23, capped 1920 wide, AAC 128k, faststart | progressive playback, download |
| `aiproxy` | 5 fps, 720p, CRF 28 | **a model**, never a person |
| `probe` | still JPEGs at even offsets | quality sampling, thumbnails at known times |

Video presets are filtered separately: `/assets/process` keeps only
`{poster, video, aiproxy, probe}` for a video and **silently drops `original`**.

## The two presets you cannot ask for

`hls` and `mp3` are real presets — `hasPreset(asset, "hls")` is a legitimate
question and `variants[]` carries both. **Neither is orderable.**

| | what makes it | can you request it |
|---|---|---|
| `hls` | the adaptive ladder, built when a video transcodes | **no** |
| `mp3` | emitted automatically alongside any audio original, so iOS Safari can play a voice note Chrome recorded as opus | **no** |

```ts
await nt.assets.regenerate(id, { presets: ["hls"] });   // HTTP 400
await nt.upload(file,          { presets: ["mp3"] });   // HTTP 400
```

Three agents evaluating this SDK wrote exactly those lines, independently, in
one afternoon — because the type accepted them. It no longer does: the write
methods take `RequestablePreset`, the read helpers keep `VariantPreset`, and a
CI check reads the server's own schemas to keep the two honest.

The mirror image: **`probe` can be requested but is not a `VariantPreset`**,
because indexed stills never reach the compact `presets` string. Ask for it in
`upload` / `regenerate`; do not expect `hasPreset(asset, "probe")` to answer it.

⚠️ **You do NOT need to upload the master twice.** The `original` *variant* is
dropped, but the raw upload is kept: `dto.rawUrl` serves the master you sent.
Verified 2026-08-17 on a production video — **9 197 163 B, `video/mp4`, 200**,
with range support. (The deliverable `video` variant was *larger*: 15 875 972 B.)
Cleanup keeps `raw/` for as long as an asset row exists.

An earlier version of this line told you to re-upload under a non-video key
(`clip.mp4.bin`). That doubles your storage for nothing.

## ⚠️ The choice that is annoying to undo — not impossible

> **CORRECTED 2026-08-17.** This section used to be titled *"The choice you
> cannot undo"* and claimed that a variant not requested at first ingest **can
> never be added later**, because "the cleanup job reaps `raw/` once the asset
> row exists". **That is backwards.** The cleanup job keeps `raw/` *precisely
> because* the row exists:
>
> the rule is *"referenced by an asset row → keep"*, and that set covers
> **every** row, soft-deleted ones included.
>
> Verified against production on an asset uploaded **2026-06-16** with
> `presets:"q"` (thumb only): its `raw` object still answers **HTTP 200**, two
> months later. `regenerate()` reads from it. There is a recovery path.

**So: ask for the presets you need at first ingest — but if you get it wrong,
nothing is lost.**

What getting it wrong actually costs:

| what you call | what you get |
|---|---|
| `getAssetUrl(asset, "md")` | **404** — that variant was never generated |
| `/t/…width=1280/<sha>.webp` | **200** — generated on demand from `raw` |
| `hasPreset(asset, "lg")` | `false`, correctly |

One API tells you it does not exist while the other hands it to you. **That
incoherence is the real cost**, plus a cold generation the first time each size
is asked for. Fix it whenever you like:

```ts
await nt.assets.regenerate(assetId, { presets: ["original", "md", "lg"] });
```

### The incident this section was written for, told correctly

A backup run archived **97 files "successfully"** after copying
`presets: ["thumb"]` from a *delivery* migration example. Every upload returned
200, and none had an `original` variant — so the operator concluded the bytes
were gone.

**They were not.** Nobody checked `raw`. The lesson that survives is not "you
cannot recover", it is: **a preset list copied from an example is a decision you
did not make**, and **"N uploaded, 0 failed" measures the upload, not the
outcome.** Round-trip the SHA-256 of what you can actually read back.

```ts
// Delivery only. The source bytes are gone.
presets: ["thumb", "md"]

// Delivery + you can still get the source back.
presets: ["original", "thumb", "md"]
```

If the bytes matter — if this is a backup, an archive, or anything a user could
ask for back — `"original"` is not optional.

## Addressing a variant

```ts
nt.urlFor({ sha }, "lg");        // one variant
nt.srcSetFor(asset);             // srcset across the presets that exist
```

`srcSetFor` needs the asset's `presets` field, because it only lists variants
that were actually generated. Building a `srcset` from the full ladder when you
only requested three of them produces 404s at the widths you skipped.

## Which presets exist on an asset you already have

**Read `dto.presets`.** It is a compact string of one-character codes.

:::caution
`aq.assets.variants(id)` returns `[]` — with a runtime key *and* with an admin
key — even when the row holds variants. Believing it makes a healthy asset look
empty. Use `dto.presets` with `hasPreset`.
:::