Video and HLS
Video is where this stops being an image CDN. A single upload produces a poster frame, a web-safe progressive MP4, an adaptive HLS ladder, and — if you ask for it — a proxy encoded for a model to watch rather than a person.
/** * Video: upload, poster, progressive MP4 and the adaptive HLS ladder. * * The two things that bite people here: * 1. A video is served from a STORED VARIANT, never from `/t/` (a transform * URL on a stored video answers 410). * 2. A video finalizes ASYNCHRONOUSLY. `upload()` returns once the bytes are * in and a background transcode job is dispatched; the row flips to * `ready` a minute or two later. Budget a real timeout — the default * 5 min is a floor, not a promise. */import { NitidaClient } from "@nitida/sdk";
const nt = new NitidaClient({ endpoint: process.env.NITIDA_ENDPOINT ?? "https://assets.example.com", apiKey: process.env.NITIDA_RUNTIME_KEY, tenantCode: "demo", tenantId: Number(process.env.NITIDA_TENANT_ID ?? 1),});
export async function uploadVideo(file: File) { const result = await nt.upload(file, { // `aiproxy` is the low-cost proxy an LLM analyses (5 fps, 720p, CRF 28) — // omit it if nothing downstream reads the video with a model. presets: ["original", "poster", "video", "aiproxy"], // A transcode plus the HLS ladder runs 1–2 min. Give it room. timeoutMs: 10 * 60_000, });
const asset = { sha: result.sha256 };
return { assetId: result.assetId, // The progressive MP4, off the stored variant ladder. mp4: nt.urlFor(asset, "video"), // The poster frame (sampled at 10% of the duration). poster: nt.urlFor(asset, "poster"), // The adaptive ladder's master playlist. hls: nt.streamingUrl(asset), };}
/** * Download-only clips — a reel that is never streamed — should skip the HLS * ladder. It is a second background job building 240p→1080p renditions nobody * watches. The on-demand route still self-heals HLS if someone ever does. */export async function uploadDownloadOnlyClip(file: File) { return nt.upload(file, { presets: ["original", "poster", "video"], video: { hls: false }, timeoutMs: 10 * 60_000, });}Video finalizes asynchronously
Section titled “Video finalizes asynchronously”upload() returns once the bytes are stored and a transcode job is dispatched.
The row flips to ready a minute or two later, when the job finishes. Anything
that filters on status === "ready" — which is what a storefront should do —
will not show the asset until then.
Budget for it. The default poll timeout is five minutes, which a 4K source can
beat; pass a real timeoutMs.
The five presets
Section titled “The five presets”| preset | what it is | who it is for |
|---|---|---|
poster |
frame sampled at 10% of the duration, WebP q80 | the <video poster> attribute |
video |
libx264 CRF 23, capped at 1920 wide, AAC 128k, +faststart |
progressive playback and download |
aiproxy |
5 fps, 720p, CRF 28 | a model reading the video — never a human |
probe |
still JPEGs at evenly spaced offsets | quality sampling, thumbnails at known times |
hls |
the adaptive ladder — a master.m3u8 plus a playlist + segments per rung |
streaming, and any consumer that needs to know which resolutions exist |
aiproxy is cheap on purpose: if nothing downstream analyses the video with a
model, omitting it saves you the encode.
Ask the row what the ladder has — do not download the asset to find out
Section titled “Ask the row what the ladder has — do not download the asset to find out”The ladder used to be a ghost: the CDN served up to seven rungs and
assets.variants recorded none of them. Anything planning a composition
believed a clip had one representation, and the only way to learn otherwise was
to fetch the asset.
Closing that took two separate things, and this page used to claim the first one finished the job:
- 2026-08-19 — the write path. Every ladder built from then on records itself. That fixed new transcodes and not one existing ladder, because the cache key never changed: the processor sees the master, logs an idempotent skip, and returns.
- 2026-08-21 — the backfill. Measured before running it: 2 of 2 106 videos carried the variant. The CDN was probed for every one and 378 ladders were recorded from what was actually there. The other 1 728 have no ladder at all and are now stamped as probed, so “no ladder” is distinguishable from “nobody looked”.
So hasPreset(asset, "hls") is now worth asking. It answers whether there is a
ladder, and the hls entry of variants says what is in it:
import { getHlsLadder, hasPreset, hlsLadderAlignment } from "@nitida/sdk";
const asset = await aq.assets.get(id); // `variants` rides on GET /assets/:idif (!hasPreset(asset, "hls")) return; // no ladder — use the progressive `video`
const ladder = getHlsLadder(asset)!;ladder.url; // the master.m3u8 — the only URL a player should getladder.height; // the ladder's CEILING. There is no upscale above it.ladder.rungs; // in MASTER ORDER — rungs[0] is what every client opens on
const { switchable, aligned, ceilingHeight } = hlsLadderAlignment(ladder.rungs);switchable: false means the ladder cannot adapt at all — one segment offers
zero switch points, so whichever rung the player opens on is the rung it
finishes on. aligned: false means the rungs cut at different timestamps and
therefore cannot be substituted mid-stream. aligned: null is unknown, not
bad: fewer than two rungs were measured.
An asset with no hls entry and a metadata.hlsProbedAt stamp has genuinely no
ladder. One with neither has simply never been asked.
Two mistakes everyone makes first
Section titled “Two mistakes everyone makes first”Using a transform URL for a stored video → the poster, or 410
Section titled “Using a transform URL for a stored video → the poster, or 410”Videos come from the stored variant, not from /t/. Call
urlFor(asset, "video"), never transform(asset, …).
The failure is confusing because it has two faces, and the harmless-looking one is worse:
| the video has | /t/… answers |
|---|---|
a poster variant |
200 image/webp, header x-transform-source: poster |
| no poster | 410 |
A 410 at least tells you something is wrong. The 200 does not: you asked for
a video and got a still frame, with a perfectly ordinary image response. If
a player is silently showing one frozen frame, this is why.
Transforming a poster on purpose is legitimate — it is how you get a themed
thumbnail out of a video. Just never expect playable bytes from /t/.
Forgetting setTenantId → 404
Section titled “Forgetting setTenantId → 404”The variant prefix is the tenant id in base36, not decimal. Tenant 10 is
/a/v/, tenant 12 is /c/v/. Building a video URL without setting the tenant
id produces a path that has never existed, and the CDN answers 404 — which
reads like a missing file rather than a missing config.
The ladder’s ceiling is whatever source it probes
Section titled “The ladder’s ceiling is whatever source it probes”The rungs are not fixed. The job probes the source and keeps every rung that fits inside it:
const rungs = LADDER.filter((r) => r.height <= srcHeight); // LADDER tops out at 2160pWhich source it probes depends on when the ladder is built, and that is the whole story:
| Built | Source | Ceiling |
|---|---|---|
| At ingest (the default) | the raw bytes you uploaded | 2160p from a 4K master |
| On demand, later | the raw if it is still there, else the -v.mp4 |
1080p, second-generation |
So 4K streams work, as long as the ladder is built from the raw — which is
what the automatic dispatch at ingest does. Set video: { hls: false } on a 4K
master and you are betting on the fallback path instead.
What is capped unconditionally is the progressive MP4: scale='min(1920,iw)'.
urlFor(asset, "video") never returns more than 1080p, whatever you uploaded.
4K is an HLS-only capability here.
Playing it — the snippet everyone copies is now wrong
Section titled “Playing it — the snippet everyone copies is now wrong”An .m3u8 is not a video file. A <video src={master}> plays only where the
browser has native HLS; everywhere else it needs a Media Source Extensions
player such as hls.js. So every integration starts with the same question, and
the answer that the whole web repeats stopped being true in April 2026:
// ❌ the classic test — it means "Apple" to everyone who wrote itif (video.canPlayType("application/vnd.apple.mpegurl")) { /* native */ }Chrome 147 added native HLS, so canPlayType now answers "maybe" there
too and this sends Chrome down the native branch. It does not throw — it
degrades. Measured on Chromium 151 against a 17-second hero loop: 426×240 for
the first ~8 seconds, because the first segment is 8.33 s long and Chrome’s
adaptive logic cannot revise its guess until it finishes one. Half the loop
plays at 240p, full-screen. The native branch also skips whatever progressive
MP4 fallback you wrote.
Use the engine, not the codec support:
export function prefersNativeHls(video: HTMLVideoElement): boolean { // No native HLS at all → definitely hls.js. if (video.canPlayType("application/vnd.apple.mpegurl") === "") return false; // Apple's engine (ManagedMediaSource), or an engine with no MSE to fall back on. return "ManagedMediaSource" in globalThis || !("MediaSource" in globalThis);}When you do use hls.js, tell it not to guess
Section titled “When you do use hls.js, tell it not to guess”hls.js defaults to a fixed starting rung and a conservative bandwidth estimate, which is how a fast connection still opens at 240p:
new Hls({ startLevel: -1, // choose from the measured estimate, not a constant testBandwidth: true, // measure before committing to a rung abrEwmaDefaultEstimate: 1_000_000,});With Video.js you don’t write that predicate
Section titled “With Video.js you don’t write that predicate”@videojs/react picks the engine for you. HlsVideo resolves to hls.js or to
the browser’s native player with this rule, and preferPlayback defaults to
"mse":
const useMse = Hls.isSupported() && type === M3U8 && preferPlayback !== "native";That already covers the iOS < 17.1 case the naive check gets wrong —
Hls.isSupported() is false without MSE, so it falls through to native. What it
does not do is prefer Apple’s engine where both work: on a modern iPhone you
get hls.js over ManagedMediaSource. That works, but native playback there buys
hardware decoding, battery, and AirPlay. Pass preferPlayback="native" if you
want it.
The hls.js tuning goes through config:
import { HlsVideo } from "@videojs/react/media/hls-video";import { Video } from "@videojs/react/video";
// Defaults open at a fixed rung before measuring anything — on a real property// clip that meant demanding a 4.43 MiB 1080p segment first, which stalls on cellular.const HLS_CONFIG = { capLevelToPlayerSize: false, startLevel: -1, // pick from the measured estimate testBandwidth: true, // measure before committing abrEwmaDefaultEstimate: 1_000_000,};
const hls = getHlsStreamingUrl(asset); // adaptive, up to the ladder's ceilingconst mp4 = getAssetUrl(asset, "video"); // progressive fallback, always ≤ 1080p
return isHls ? <HlsVideo src={hls} poster={poster} config={HLS_CONFIG} playsInline crossOrigin="anonymous" /> : <Video src={mp4} poster={poster} playsInline crossOrigin="anonymous" />;The first request can answer 202
Section titled “The first request can answer 202”The ladder is built by a job. The first request for a master playlist returns
202 Accepted while that job runs (typically 1–3 minutes for a 90-second
source) and 302s to the cached master afterwards.