Skip to content

Commit 8e5593b

Browse files
vanceingallsclaude
andauthored
feat(render): auto-detect HDR from media probes, add --sdr flag (heygen-com#526)
* feat(render): auto-detect HDR from media probes, add --sdr flag Replace the --hdr opt-in model with automatic detection. When no flags are passed, the renderer probes all video/image sources and enables HDR output if any HDR color space is detected. Existing --hdr flag becomes a force override. New --sdr flag forces SDR output. Behavior matrix: (no flags) + HDR content → HDR output (no flags) + SDR content → SDR output --hdr → force HDR (defaults to HLG if no HDR sources) --sdr → force SDR (skips probing) --hdr --sdr → error Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: align HDR auto-detect docs and tests --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4d0e262 commit 8e5593b

11 files changed

Lines changed: 140 additions & 69 deletions

File tree

docs/guides/common-mistakes.mdx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,11 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
190190
</Accordion>
191191

192192
<Accordion title="Expected HDR output but got SDR">
193-
**Symptom:** Rendered with `--hdr`, but the output looks the same as SDR or `ffprobe` reports `color_transfer=bt709`.
193+
**Symptom:** Expected an HDR render, but the output looks the same as SDR or `ffprobe` reports `color_transfer=bt709`.
194194

195-
**Cause:** `--hdr` is a *detection* flag, not a *force* flag. Hyperframes only switches to HDR encoding when a source `<video>` or `<img>` is tagged with BT.2020 / PQ / HLG color metadata. Two common reasons HDR is not engaged:
195+
**Cause:** By default, Hyperframes only switches to HDR encoding when a source `<video>` or `<img>` is tagged with BT.2020 / PQ / HLG color metadata. Common reasons HDR is not engaged:
196196

197-
1. **All sources are SDR.** `--hdr` is a no-op on SDR-only compositions. Verify with `ffprobe`:
197+
1. **All sources are SDR.** Auto-detect leaves SDR-only compositions in SDR. Verify with `ffprobe`:
198198

199199
```bash Terminal
200200
ffprobe -v error -show_streams source.mp4 | grep color_transfer
@@ -204,7 +204,11 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
204204

205205
2. **Wrong output format.** HDR output requires MP4. `--format mov` and `--format webm` fall back to SDR — Hyperframes logs a warning when this happens.
206206

207-
`--docker` works the same as local rendering — `--hdr` is forwarded into the container and produces the same HDR10 MP4 output (slower, since the container falls back to software WebGL for SDR DOM capture).
207+
3. **SDR was forced.** `--sdr` disables HDR even when HDR sources are present.
208+
209+
If you need HDR regardless of source metadata, use `--hdr` to force it.
210+
211+
`--docker` works the same as local rendering — auto-detect, `--hdr`, and `--sdr` are all forwarded into the container and produce the same output decisions (slower, since the container falls back to software WebGL for SDR DOM capture).
208212

209213
See [HDR Rendering](/guides/hdr) for the full source requirements and verification steps.
210214
</Accordion>

docs/guides/hdr.mdx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ title: HDR Rendering
33
description: "Render compositions to HDR10 MP4 (BT.2020 PQ or HLG, 10-bit H.265) when sources contain HDR video or images."
44
---
55

6-
Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your composition references HDR video or HDR still images. HDR is opt-in via the `--hdr` flag — it auto-detects HDR sources and falls back to SDR when none are present.
6+
Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your composition references HDR video or HDR still images. HDR is auto-detected by default from your media sources and falls back to SDR when none are present.
77

88
<Note>
9-
The `--hdr` flag does not *force* HDR. It enables HDR detection. If your composition contains only SDR media, the flag is a no-op and you get a normal SDR render.
9+
By default, Hyperframes probes your media and enables HDR only when HDR sources are present. Use `--hdr` to force HDR even without HDR sources, or `--sdr` to force SDR even when HDR sources are present.
1010
</Note>
1111

1212
## Quickstart
@@ -20,12 +20,12 @@ Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your compositio
2020

2121
See [Source Media](#source-media-requirements) for full details.
2222
</Step>
23-
<Step title="Render with --hdr">
23+
<Step title="Render normally">
2424
```bash Terminal
25-
npx hyperframes render --hdr --output output.mp4
25+
npx hyperframes render --output output.mp4
2626
```
2727

28-
HDR output requires `--format mp4`. If you also pass `--format mov` or `--format webm`, Hyperframes logs a warning and falls back to SDR.
28+
HDR output requires `--format mp4`. If Hyperframes detects HDR sources, it renders HDR automatically. If you also pass `--format mov` or `--format webm`, Hyperframes logs a warning and falls back to SDR.
2929
</Step>
3030
<Step title="Verify the output is HDR">
3131
Use `ffprobe` to confirm the encoded stream carries HDR color tagging and HDR10 metadata:
@@ -40,14 +40,14 @@ Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your compositio
4040

4141
## How HDR Mode Works
4242

43-
When `--hdr` is set, the producer:
43+
During render, the producer:
4444

4545
<Steps>
4646
<Step title="Probes every video and image source">
47-
Runs `ffprobe` on each `<video>` and `<img>` source to read its color space (primaries, transfer function, matrix). Probing is gated on `--hdr` to avoid `ffprobe` overhead on SDR-only renders.
47+
Runs `ffprobe` on each `<video>` and `<img>` source to read its color space (primaries, transfer function, matrix). This probe drives the default auto-detect behavior and is skipped only when you explicitly force SDR with `--sdr`.
4848
</Step>
4949
<Step title="Picks the dominant HDR transfer">
50-
If any source uses PQ (`smpte2084`), the output uses **PQ**. Otherwise, if any source uses HLG (`arib-std-b67`), the output uses **HLG**. If no HDR sources are found, the flag is a no-op and you get an SDR render.
50+
If any source uses PQ (`smpte2084`), the output uses **PQ**. Otherwise, if any source uses HLG (`arib-std-b67`), the output uses **HLG**. If no HDR sources are found, the render stays SDR.
5151
</Step>
5252
<Step title="Encodes to H.265 10-bit BT.2020">
5353
The video encoder switches to `libx265` with `-pix_fmt yuv420p10le`, color tagging `colorprim=bt2020:transfer=<smpte2084|arib-std-b67>:colormatrix=bt2020nc`, and HDR10 static metadata (`master-display` and `max-cll`). Without that metadata, players (QuickTime, YouTube, HDR TVs) tone-map the stream as if it were SDR BT.2020 — which looks wrong.
@@ -89,7 +89,7 @@ Hyperframes supports HDR still images delivered as **16-bit PNGs** tagged with B
8989
src="./assets/hdr-photo.png" />
9090
```
9191

92-
When `--hdr` is set, the image is decoded once to 16-bit linear-light RGB and composited natively into the HDR output.
92+
When HDR is enabled, the image is decoded once to 16-bit linear-light RGB and composited natively into the HDR output.
9393

9494
<Note>
9595
HDR `<img>` decoding is limited to **16-bit PNG**. JPEG, WebP, AVIF, and APNG are not recognized as HDR sources — they load through the normal SDR DOM path. For HDR motion, use a `<video>` element.
@@ -113,7 +113,7 @@ This is the same pipeline that handles compositions where, for example, an HDR d
113113
| `mov` | No — falls back to SDR |
114114
| `webm` | No — falls back to SDR |
115115

116-
If you set `--hdr` together with `--format mov` or `--format webm`, Hyperframes logs a message and produces the equivalent SDR render. There is no error — the render still completes — so check the logs (or your verification step) to confirm you got HDR.
116+
If HDR is enabled and you also pass `--format mov` or `--format webm`, Hyperframes logs a message and produces the equivalent SDR render. There is no error — the render still completes — so check the logs (or your verification step) to confirm you got HDR.
117117

118118
## Verifying HDR Output
119119

@@ -147,10 +147,10 @@ For HLG renders the only difference is `color_transfer=arib-std-b67` — the res
147147

148148
## Docker Rendering
149149

150-
`--hdr` is forwarded into the Docker render pipeline, so you can produce HDR10 MP4 output from the containerized renderer:
150+
Docker uses the same auto-detect logic as local rendering, so you can produce HDR10 MP4 output from the containerized renderer without extra flags:
151151

152152
```bash Terminal
153-
npx hyperframes render --hdr --docker --output output.mp4
153+
npx hyperframes render --docker --output output.mp4
154154
```
155155

156156
The container runs the same probe → composite → encode pipeline as the local renderer. Verify the output with the same `ffprobe` checks described in [Verifying HDR output](#verifying-hdr-output).
@@ -161,7 +161,7 @@ The container runs the same probe → composite → encode pipeline as the local
161161

162162
## Limitations
163163

164-
- **MP4 only**`--hdr` with `--format mov` or `--format webm` falls back to SDR
164+
- **MP4 only**HDR output with `--format mov` or `--format webm` falls back to SDR
165165
- **HDR images: 16-bit PNG only** — other formats (JPEG, WebP, AVIF, APNG) are not decoded as HDR and fall through the SDR DOM path
166166
- **H.265 only — H.264 is stripped** — calling the encoder with `codec: "h264"` and `hdr: { transfer }` is rejected; the encoder logs a warning, drops `hdr`, and tags the output as SDR/BT.709. `libx264` cannot encode HDR, so the alternative would be a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block in the bitstream) which confuses HDR-aware players.
167167
- **GPU H.265 emits color tags but no static mastering metadata**`useGpu: true` with HDR (nvenc, videotoolbox, qsv, vaapi) tags the stream with BT.2020 + the correct transfer (smpte2084 / arib-std-b67) but does **not** embed `master-display` or `max-cll` SEI. ffmpeg does not let those flags pass through hardware encoders. The output is suitable for previews and authoring but not for HDR10-aware delivery (Apple TV, YouTube, Netflix). For spec-compliant HDR10 production output, leave `useGpu: false` so the SW `libx265` path embeds the mastering metadata.
@@ -172,7 +172,7 @@ The container runs the same probe → composite → encode pipeline as the local
172172

173173
| Symptom | Likely cause |
174174
|---------|--------------|
175-
| Output looks identical to SDR | Source media is SDR`--hdr` is a no-op without an HDR source. Run `ffprobe` on your inputs |
175+
| Output looks identical to SDR | Source media is SDR, or SDR was forced with `--sdr`. Run `ffprobe` on your inputs and check the render logs |
176176
| Output is "kind of HDR" but tone-mapped wrong on YouTube/QuickTime | Missing HDR10 static metadata on the encoded stream. Verify with the ffprobe snippet above |
177177
| Docker render is much slower than local | Expected — the container falls back to software WebGL for SDR DOM capture. Pixel output is the same |
178178
| Used `--format webm` and got SDR | Expected — HDR output is MP4 only |
@@ -185,7 +185,7 @@ The container runs the same probe → composite → encode pipeline as the local
185185
Local vs Docker, quality presets, workers
186186
</Card>
187187
<Card title="CLI" icon="terminal" href="/packages/cli">
188-
Full `render` command reference including `--hdr`
188+
Full `render` command reference including HDR auto-detect, `--hdr`, and `--sdr`
189189
</Card>
190190
<Card title="Engine: HDR APIs" icon="gear" href="/packages/engine#hdr-apis">
191191
Public HDR utilities exported from `@hyperframes/engine`

docs/guides/rendering.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
122122
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
123123
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
124124
| `--gpu` || off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
125-
| `--hdr` || off | Detect HDR sources and output HDR10 (MP4 only). See [HDR Rendering](/guides/hdr) |
125+
| `--hdr` || off | Force HDR output even if no HDR sources are detected (MP4 only). See [HDR Rendering](/guides/hdr) |
126+
| `--sdr` || off | Force SDR output even if HDR sources are detected |
126127
| `--docker` || off | Use Docker for [deterministic rendering](/concepts/determinism) |
127128
| `--quiet` || off | Suppress verbose output |
128129

docs/packages/cli.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,8 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
540540
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
541541
| `--crf` | 0-51 || Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
542542
| `--video-bitrate` | e.g. `10M`, `5000k` || Target video bitrate. Mutually exclusive with `--crf` |
543-
| `--hdr` || off | Detect HDR sources and output HDR10 (H.265 10-bit, BT.2020 PQ/HLG). MP4 only. SDR-only compositions are unaffected. See [HDR Rendering](/guides/hdr) |
543+
| `--hdr` || off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
544+
| `--sdr` || off | Force SDR output even if HDR sources are detected |
544545
| `--workers` | 1-8 | 4 | Parallel render workers |
545546
| `--gpu` || off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
546547
| `--docker` || off | Use Docker for [deterministic rendering](/concepts/determinism) |

docs/packages/engine.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ await server.close();
282282

283283
The engine exports two layers of HDR support: **color-space utilities** that classify sources and configure the FFmpeg encoder, and a **WebGPU readback runtime** for capturing CSS-animated DOM directly into HDR.
284284

285-
For end-to-end HDR rendering (HDR video and image sources composited into an HDR10 MP4) use the [producer](/packages/producer) or the CLI's `--hdr` flag — see [HDR Rendering](/guides/hdr). The APIs below are for custom integrations.
285+
For end-to-end HDR rendering (HDR video and image sources composited into an HDR10 MP4) use the [producer](/packages/producer) or the CLI render pipeline with HDR auto-detect / `--hdr` / `--sdr` — see [HDR Rendering](/guides/hdr). The APIs below are for custom integrations.
286286

287287
### Color space utilities
288288

@@ -344,7 +344,7 @@ const pqRgb = float16ToPqRgb(rgba16, width, height, bytesPerRow);
344344
```
345345

346346
<Warning>
347-
This path requires **headed Chrome with `--enable-unsafe-webgpu`** — WebGPU is unavailable in `chrome-headless-shell`. It is *not* used by the default `--hdr` render pipeline (which extracts HDR pixels from sources via FFmpeg and composites in Node). Use it only for advanced custom pipelines that need CSS animations driving HDR pixel output.
347+
This path requires **headed Chrome with `--enable-unsafe-webgpu`** — WebGPU is unavailable in `chrome-headless-shell`. It is *not* used by the default HDR-aware render pipeline (which extracts HDR pixels from sources via FFmpeg and composites in Node). Use it only for advanced custom pipelines that need CSS animations driving HDR pixel output.
348348
</Warning>
349349

350350
## The `window.__hf` Protocol

packages/cli/src/commands/render.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const examples: Example[] = [
99
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
1010
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
1111
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
12-
["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"],
12+
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
1313
];
1414
import { cpus, freemem, tmpdir } from "node:os";
1515
import { resolve, dirname, join, basename } from "node:path";
@@ -82,7 +82,12 @@ export default defineCommand({
8282
},
8383
hdr: {
8484
type: "boolean",
85-
description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020",
85+
description: "Force HDR output even if no HDR sources are detected",
86+
default: false,
87+
},
88+
sdr: {
89+
type: "boolean",
90+
description: "Force SDR output even if HDR sources are detected",
8691
default: false,
8792
},
8893
crf: {
@@ -293,6 +298,12 @@ export default defineCommand({
293298
}
294299
}
295300

301+
// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
302+
if (args.hdr && args.sdr) {
303+
console.error("Error: --hdr and --sdr are mutually exclusive.");
304+
process.exit(1);
305+
}
306+
296307
// ── Render ────────────────────────────────────────────────────────────
297308
if (useDocker) {
298309
await renderDocker(project.dir, outputPath, {
@@ -301,7 +312,7 @@ export default defineCommand({
301312
format,
302313
workers,
303314
gpu: useGpu,
304-
hdr: args.hdr ?? false,
315+
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
305316
crf,
306317
videoBitrate,
307318
quiet,
@@ -313,7 +324,7 @@ export default defineCommand({
313324
format,
314325
workers,
315326
gpu: useGpu,
316-
hdr: args.hdr ?? false,
327+
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
317328
crf,
318329
videoBitrate,
319330
quiet,
@@ -329,7 +340,7 @@ interface RenderOptions {
329340
format: "mp4" | "webm" | "mov";
330341
workers?: number;
331342
gpu: boolean;
332-
hdr: boolean;
343+
hdrMode: "auto" | "force-hdr" | "force-sdr";
333344
crf?: number;
334345
videoBitrate?: string;
335346
quiet: boolean;
@@ -453,7 +464,7 @@ async function renderDocker(
453464
format: options.format,
454465
workers: options.workers,
455466
gpu: options.gpu,
456-
hdr: options.hdr,
467+
hdrMode: options.hdrMode,
457468
crf: options.crf,
458469
videoBitrate: options.videoBitrate,
459470
quiet: options.quiet,
@@ -519,7 +530,7 @@ async function renderLocal(
519530
format: options.format,
520531
workers: options.workers,
521532
useGpu: options.gpu,
522-
hdr: options.hdr,
533+
hdrMode: options.hdrMode,
523534
crf: options.crf,
524535
videoBitrate: options.videoBitrate,
525536
});

packages/cli/src/registry/remote.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,14 @@ function readCache<T>(path: string): T | undefined {
5858
}
5959

6060
function writeCache<T>(path: string, data: T): void {
61-
mkdirSync(dirname(path), { recursive: true });
62-
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
63-
writeFileSync(path, JSON.stringify(entry), "utf-8");
61+
try {
62+
mkdirSync(dirname(path), { recursive: true });
63+
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
64+
writeFileSync(path, JSON.stringify(entry), "utf-8");
65+
} catch {
66+
// Cache writes are opportunistic. A read-only home directory or sandboxed
67+
// environment should not make the registry appear unreachable.
68+
}
6469
}
6570

6671
// ── Fetchers ────────────────────────────────────────────────────────────────

packages/cli/src/utils/dockerRunArgs.test.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const BASE: DockerRenderOptions = {
66
quality: "standard",
77
format: "mp4",
88
gpu: false,
9-
hdr: false,
9+
hdrMode: "auto",
1010
crf: undefined,
1111
videoBitrate: undefined,
1212
quiet: false,
@@ -57,9 +57,8 @@ describe("buildDockerRunArgs", () => {
5757
...FIXED_INPUT,
5858
options: {
5959
...BASE,
60-
workers: 4,
6160
gpu: true,
62-
hdr: true,
61+
hdrMode: "force-hdr",
6362
crf: 18,
6463
videoBitrate: undefined,
6564
quiet: true,
@@ -88,8 +87,6 @@ describe("buildDockerRunArgs", () => {
8887
"standard",
8988
"--format",
9089
"mp4",
91-
"--workers",
92-
"4",
9390
"--crf",
9491
"18",
9592
"--quiet",
@@ -102,17 +99,28 @@ describe("buildDockerRunArgs", () => {
10299
// Regression for the original PR feedback: --hdr was silently dropped from
103100
// the docker arg array. Keep this assertion explicit (in addition to the
104101
// snapshot above) so the failure message points directly at the flag.
105-
it("forwards --hdr to the container when hdr is enabled", () => {
102+
it("forwards --hdr to the container when hdrMode is force-hdr", () => {
106103
const args = buildDockerRunArgs({
107104
...FIXED_INPUT,
108-
options: { ...BASE, hdr: true },
105+
options: { ...BASE, hdrMode: "force-hdr" },
109106
});
110107
expect(args).toContain("--hdr");
108+
expect(args).not.toContain("--sdr");
111109
});
112110

113-
it("omits --hdr when hdr is disabled", () => {
111+
it("forwards --sdr to the container when hdrMode is force-sdr", () => {
112+
const args = buildDockerRunArgs({
113+
...FIXED_INPUT,
114+
options: { ...BASE, hdrMode: "force-sdr" },
115+
});
116+
expect(args).toContain("--sdr");
117+
expect(args).not.toContain("--hdr");
118+
});
119+
120+
it("omits --hdr and --sdr when hdrMode is auto", () => {
114121
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
115122
expect(args).not.toContain("--hdr");
123+
expect(args).not.toContain("--sdr");
116124
});
117125

118126
it("requests host GPU passthrough only when gpu is enabled", () => {
@@ -140,7 +148,7 @@ describe("buildDockerRunArgs", () => {
140148
format: "webm",
141149
workers: 8,
142150
gpu: true,
143-
hdr: true,
151+
hdrMode: "force-hdr",
144152
crf: 16,
145153
videoBitrate: undefined,
146154
quiet: true,

0 commit comments

Comments
 (0)