Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/guides/common-mistakes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
</Accordion>

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

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

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

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

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

`--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).
3. **SDR was forced.** `--sdr` disables HDR even when HDR sources are present.

If you need HDR regardless of source metadata, use `--hdr` to force it.

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

See [HDR Rendering](/guides/hdr) for the full source requirements and verification steps.
</Accordion>
Expand Down
30 changes: 15 additions & 15 deletions docs/guides/hdr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ title: HDR Rendering
description: "Render compositions to HDR10 MP4 (BT.2020 PQ or HLG, 10-bit H.265) when sources contain HDR video or images."
---

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

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

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

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

HDR output requires `--format mp4`. If you also pass `--format mov` or `--format webm`, Hyperframes logs a warning and falls back to SDR.
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.
</Step>
<Step title="Verify the output is HDR">
Use `ffprobe` to confirm the encoded stream carries HDR color tagging and HDR10 metadata:
Expand All @@ -40,14 +40,14 @@ Hyperframes can render to HDR10 MP4 (H.265 10-bit, BT.2020) when your compositio

## How HDR Mode Works

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

<Steps>
<Step title="Probes every video and image source">
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.
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`.
</Step>
<Step title="Picks the dominant HDR transfer">
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.
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.
</Step>
<Step title="Encodes to H.265 10-bit BT.2020">
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.
Expand Down Expand Up @@ -89,7 +89,7 @@ Hyperframes supports HDR still images delivered as **16-bit PNGs** tagged with B
src="./assets/hdr-photo.png" />
```

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

<Note>
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.
Expand All @@ -113,7 +113,7 @@ This is the same pipeline that handles compositions where, for example, an HDR d
| `mov` | No — falls back to SDR |
| `webm` | No — falls back to SDR |

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

## Verifying HDR Output

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

## Docker Rendering

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

```bash Terminal
npx hyperframes render --hdr --docker --output output.mp4
npx hyperframes render --docker --output output.mp4
```

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).
Expand All @@ -161,7 +161,7 @@ The container runs the same probe → composite → encode pipeline as the local

## Limitations

- **MP4 only** — `--hdr` with `--format mov` or `--format webm` falls back to SDR
- **MP4 only** — HDR output with `--format mov` or `--format webm` falls back to SDR
- **HDR images: 16-bit PNG only** — other formats (JPEG, WebP, AVIF, APNG) are not decoded as HDR and fall through the SDR DOM path
- **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.
- **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.
Expand All @@ -172,7 +172,7 @@ The container runs the same probe → composite → encode pipeline as the local

| Symptom | Likely cause |
|---------|--------------|
| Output looks identical to SDR | Source media is SDR — `--hdr` is a no-op without an HDR source. Run `ffprobe` on your inputs |
| 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 |
| 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 |
| Docker render is much slower than local | Expected — the container falls back to software WebGL for SDR DOM capture. Pixel output is the same |
| Used `--format webm` and got SDR | Expected — HDR output is MP4 only |
Expand All @@ -185,7 +185,7 @@ The container runs the same probe → composite → encode pipeline as the local
Local vs Docker, quality presets, workers
</Card>
<Card title="CLI" icon="terminal" href="/packages/cli">
Full `render` command reference including `--hdr`
Full `render` command reference including HDR auto-detect, `--hdr`, and `--sdr`
</Card>
<Card title="Engine: HDR APIs" icon="gear" href="/packages/engine#hdr-apis">
Public HDR utilities exported from `@hyperframes/engine`
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--hdr` | — | off | Detect HDR sources and output HDR10 (MP4 only). See [HDR Rendering](/guides/hdr) |
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected (MP4 only). See [HDR Rendering](/guides/hdr) |
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
| `--quiet` | — | off | Suppress verbose output |

Expand Down
3 changes: 2 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,8 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` |
| `--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) |
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
| `--workers` | 1-8 | 4 | Parallel render workers |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
Expand Down
4 changes: 2 additions & 2 deletions docs/packages/engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ await server.close();

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.

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

### Color space utilities

Expand Down Expand Up @@ -344,7 +344,7 @@ const pqRgb = float16ToPqRgb(rgba16, width, height, bytesPerRow);
```

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

## The `window.__hf` Protocol
Expand Down
25 changes: 18 additions & 7 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const examples: Example[] = [
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"],
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
Expand Down Expand Up @@ -82,7 +82,12 @@ export default defineCommand({
},
hdr: {
type: "boolean",
description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020",
description: "Force HDR output even if no HDR sources are detected",
default: false,
},
sdr: {
type: "boolean",
description: "Force SDR output even if HDR sources are detected",
default: false,
},
crf: {
Expand Down Expand Up @@ -293,6 +298,12 @@ export default defineCommand({
}
}

// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
if (args.hdr && args.sdr) {
console.error("Error: --hdr and --sdr are mutually exclusive.");
process.exit(1);
}

// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
Expand All @@ -301,7 +312,7 @@ export default defineCommand({
format,
workers,
gpu: useGpu,
hdr: args.hdr ?? false,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
videoBitrate,
quiet,
Expand All @@ -313,7 +324,7 @@ export default defineCommand({
format,
workers,
gpu: useGpu,
hdr: args.hdr ?? false,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
videoBitrate,
quiet,
Expand All @@ -329,7 +340,7 @@ interface RenderOptions {
format: "mp4" | "webm" | "mov";
workers?: number;
gpu: boolean;
hdr: boolean;
hdrMode: "auto" | "force-hdr" | "force-sdr";
crf?: number;
videoBitrate?: string;
quiet: boolean;
Expand Down Expand Up @@ -453,7 +464,7 @@ async function renderDocker(
format: options.format,
workers: options.workers,
gpu: options.gpu,
hdr: options.hdr,
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
quiet: options.quiet,
Expand Down Expand Up @@ -519,7 +530,7 @@ async function renderLocal(
format: options.format,
workers: options.workers,
useGpu: options.gpu,
hdr: options.hdr,
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
});
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/registry/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ function readCache<T>(path: string): T | undefined {
}

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

// ── Fetchers ────────────────────────────────────────────────────────────────
Expand Down
26 changes: 17 additions & 9 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const BASE: DockerRenderOptions = {
quality: "standard",
format: "mp4",
gpu: false,
hdr: false,
hdrMode: "auto",
crf: undefined,
videoBitrate: undefined,
quiet: false,
Expand Down Expand Up @@ -57,9 +57,8 @@ describe("buildDockerRunArgs", () => {
...FIXED_INPUT,
options: {
...BASE,
workers: 4,
gpu: true,
hdr: true,
hdrMode: "force-hdr",
crf: 18,
videoBitrate: undefined,
quiet: true,
Expand Down Expand Up @@ -88,8 +87,6 @@ describe("buildDockerRunArgs", () => {
"standard",
"--format",
"mp4",
"--workers",
"4",
"--crf",
"18",
"--quiet",
Expand All @@ -102,17 +99,28 @@ describe("buildDockerRunArgs", () => {
// Regression for the original PR feedback: --hdr was silently dropped from
// the docker arg array. Keep this assertion explicit (in addition to the
// snapshot above) so the failure message points directly at the flag.
it("forwards --hdr to the container when hdr is enabled", () => {
it("forwards --hdr to the container when hdrMode is force-hdr", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, hdr: true },
options: { ...BASE, hdrMode: "force-hdr" },
});
expect(args).toContain("--hdr");
expect(args).not.toContain("--sdr");
});

it("omits --hdr when hdr is disabled", () => {
it("forwards --sdr to the container when hdrMode is force-sdr", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, hdrMode: "force-sdr" },
});
expect(args).toContain("--sdr");
expect(args).not.toContain("--hdr");
});

it("omits --hdr and --sdr when hdrMode is auto", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--hdr");
expect(args).not.toContain("--sdr");
});

it("requests host GPU passthrough only when gpu is enabled", () => {
Expand Down Expand Up @@ -140,7 +148,7 @@ describe("buildDockerRunArgs", () => {
format: "webm",
workers: 8,
gpu: true,
hdr: true,
hdrMode: "force-hdr",
crf: 16,
videoBitrate: undefined,
quiet: true,
Expand Down
Loading
Loading