Skip to content

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 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,
});
}

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

aiproxy is cheap on purpose: if nothing downstream analyses the video with a model, omitting it saves you the encode.

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, …).

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.