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 Cloud Run 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 { AquienpzClient } from "@aquienpz/sdk";
const nt = new AquienpzClient({ 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 Cloud Run 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 four presets
Section titled “The four 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 |
aiproxy is cheap on purpose: if nothing downstream analyses the video with a
model, omitting it saves you the encode.
Two mistakes everyone makes first
Section titled “Two mistakes everyone makes first”Using a transform URL for a stored video → 410
Section titled “Using a transform URL for a stored video → 410”Videos come from the stored variant, not from /t/. Call
urlFor(asset, "video"), never transform(asset, …).
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.