Variants and presets
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
Section titled “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
Section titled “⚠️ 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:
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
Section titled “⭐ 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 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
Section titled “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
Section titled “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 |
await nt.assets.regenerate(id, { presets: ["hls"] }); // HTTP 400await nt.upload(file, { presets: ["mp3"] }); // HTTP 400Three 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
Section titled “⚠️ 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 keepsraw/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): itsrawobject 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:
await nt.assets.regenerate(assetId, { presets: ["original", "md", "lg"] });The incident this section was written for, told correctly
Section titled “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.
// 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
Section titled “Addressing a variant”nt.urlFor({ sha }, "lg"); // one variantnt.srcSetFor(asset); // srcset across the presets that existsrcSetFor 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
Section titled “Which presets exist on an asset you already have”Read dto.presets. It is a compact string of one-character codes.