Skip to content
scroll to zoom · drag to pan

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.

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.

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.

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

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

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.

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

Section titled “Which presets exist on an asset you already have”

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