# Video and HLS

> Poster, progressive MP4, the adaptive ladder, and the AI proxy — plus the two mistakes everyone makes first.

import { Code } from '@astrojs/starlight/components';
import videoExample from '../../../../examples/03-video-and-hls.ts?raw';

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.

<Code code={videoExample} lang="ts" title="video-and-hls.ts" />

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

| 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

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:

```ts
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.

## Two mistakes everyone makes first

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

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

The rungs are not fixed. The job probes the source and keeps every rung that
fits inside it:

```ts
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.

:::caution[A rung heavier than its own source means you are on the fallback path]
Measured on one asset: the 1080p rung came out at 5090 kbps against a 4866 kbps
source. That is the signature of a ladder re-encoded from the already-encoded
`-v.mp4`, not of the ladder being wrong. Don't "fix" it by trimming rungs —
trimming saves about **$0.45 per month per thousand videos**, and the rungs are
what make playback adapt. Rebuild it from the raw instead.
:::

:::tip[Skip the ladder for download-only clips]
A reel that is downloaded and never streamed does not need HLS. Passing
`video: { hls: false }` skips a whole second job that builds renditions nobody
watches, and the on-demand route still self-heals HLS if someone ever streams
it. The raw is kept for as long as the asset row lives — both cleanup passes
skip any sha with a live row — so that self-heal normally still has the full
resolution to work from.
:::

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

```ts
// ❌ 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:

```ts
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);
}
```

:::note[Why not hls.js's own recommendation]
Its documented check is `canPlayType` plus `ManagedMediaSource`, which breaks
iOS before 17.1: that engine has native HLS, no `ManagedMediaSource`, and no
MSE. It would be routed to hls.js, which cannot start there, and the user falls
all the way through to the progressive MP4. Hence the `|| !MediaSource` arm —
native if the engine is Apple's, *or* if there is no MSE to fall back on.
:::

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

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

`@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"`:

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

```tsx
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 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 `302`s to the cached master afterwards.

:::danger[Fall back on a TIMER, not on an `error` event]
A `202` is a success. **No `error` event ever fires**, so a player that only
swaps sources in an error handler spins forever on a ladder that is still
transcoding. Race a timer against `canplay`: if the HLS source has not become
playable within a few seconds, swap to the progressive MP4 — which is produced
up front and is always there. Cancel the timer the instant `canplay` fires, so
healthy HLS keeps its adaptive bitrate instead of being cut off.
:::