Skip to content
scroll to zoom · drag to pan

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-and-hls.ts
/**
* 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,
});
}

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.

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/:id
if (!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 get
ladder.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.

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

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

Which 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 it
if (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 ceiling
const 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 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.