feat(engine): add HDR video output pipeline (#265)

## Summary

Adds the ability to render HDR video output (H.265 10-bit, BT.2020) from HyperFrames compositions. When the renderer detects HDR source video, it automatically switches to the HDR output pipeline — no flags needed.

## What it does

- **Auto-detection** — Probes each video source with `ffprobe`. If any has bt2020/PQ/HLG color metadata, the output switches to H.265 10-bit with correct color tags. SDR-only compositions are unaffected (H.264, bt709).
- **HLG pass-through** — Native HLG pixels from FFmpeg extraction are piped directly to the encoder without conversion. This avoids brightness loss from HLG→linear→PQ conversion (which requires an OOTF system gamma we can't reliably apply).
- **Encoder HDR support** — Both chunk and streaming encoders accept HDR presets: `libx265`, `yuv420p10le`, BT.2020 color primaries, `hvc1` codec tag (required for Apple playback).
- **WebGPU HDR capture (gated)** — A complete WebGPU float16 readback pipeline is implemented and tested but gated behind headed Chrome (headless doesn't expose WebGPU). Ready for future use with WebGPU canvas content.
- **HDR utilities** — `detectTransfer()` (PQ vs HLG), `getHdrEncoderColorParams()`, `analyzeCompositionHdr()`. 15 unit tests.

## Key design decisions

| Decision | Why |
|----------|-----|
| No `--hdr` flag | SDR content encoded as HDR causes orange shift in browsers. Auto-detect eliminates this. |
| HLG pass-through (not HLG→PQ) | Conversion loses brightness without OOTF. Pass-through matches source exactly. |
| `hvc1` codec tag | Apple QuickTime requires `hvc1` (not `hev1`) for HEVC playback. |
| 1-hour streaming timeout | HDR capture at ~6fps needs more time than the default 10-minute FFmpeg timeout. |

## Files changed

| File | What changed |
|------|-------------|
| `packages/engine/src/utils/hdr.ts` | **NEW** — HDR detection, transfer types, encoder params (15 tests) |
| `packages/engine/src/services/hdrCapture.ts` | **NEW** — WebGPU readback, HLG conversion, PQ encode |
| `packages/engine/src/services/streamingEncoder.ts` | HDR presets, raw rgb48le input, color tags |
| `packages/engine/src/services/chunkEncoder.ts` | HDR presets, conditional color tags |
| `packages/producer/src/services/renderOrchestrator.ts` | Auto-detection loop, HDR pass-through capture path |

## How to test

Render a composition with an HDR video source. The output should be H.265 10-bit with HDR metadata visible in `ffprobe` (bt2020, arib-std-b67 or smpte2084). Plays correctly in QuickTime and on HDR displays.

## Stack position

**2 of 6** — Stacked on #258 (SDR/HDR normalization). Provides the encoder infrastructure that phases 1-5 build on.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-04-19 15:10:59 -07:00
committed by GitHub
parent d1f992570a
commit 5a3fde19d4
19 changed files with 1794 additions and 318 deletions
@@ -0,0 +1,160 @@
/**
* buildStreamingArgs unit tests.
*
* These tests focus on the FFmpeg CLI shape rather than spawning the encoder
* — they're the cheap regression net for the HDR static-metadata bug
* (side_data=[none] in the encoded MP4) reproduced by
* packages/producer/scripts/hdr-smoke.ts. Without these assertions, future
* refactors of the x265-params string can silently strip
* master-display / max-cll and ship as SDR BT.2020 again.
*/
import { describe, expect, it } from "vitest";
import { buildStreamingArgs, type StreamingEncoderOptions } from "./streamingEncoder.js";
import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
const baseHdrPq: StreamingEncoderOptions = {
fps: 30,
width: 1920,
height: 1080,
codec: "h265",
preset: "medium",
quality: 23,
pixelFormat: "yuv420p10le",
useGpu: false,
rawInputFormat: "rgb48le",
hdr: { transfer: "pq" },
};
const baseHdrHlg: StreamingEncoderOptions = {
...baseHdrPq,
hdr: { transfer: "hlg" },
};
const baseSdr: StreamingEncoderOptions = {
fps: 30,
width: 1920,
height: 1080,
codec: "h264",
preset: "medium",
quality: 23,
useGpu: false,
};
function getX265ParamsValue(args: string[]): string | undefined {
const idx = args.indexOf("-x265-params");
return idx === -1 ? undefined : args[idx + 1];
}
describe("buildStreamingArgs", () => {
describe("HDR PQ (libx265)", () => {
it("emits master-display and max-cll in -x265-params", () => {
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
const x265 = getX265ParamsValue(args);
expect(x265).toBeDefined();
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
expect(x265).toContain("colorprim=bt2020");
expect(x265).toContain("transfer=smpte2084");
expect(x265).toContain("colormatrix=bt2020nc");
});
it("tags the output stream with bt2020 / smpte2084 / tv range", () => {
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
expect(args).toContain("-colorspace:v");
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
});
it("uses libx265 with -tag:v hvc1 for QuickTime compatibility", () => {
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
const cvIdx = args.indexOf("-c:v");
expect(cvIdx).toBeGreaterThan(-1);
expect(args[cvIdx + 1]).toBe("libx265");
expect(args).toContain("-tag:v");
expect(args[args.indexOf("-tag:v") + 1]).toBe("hvc1");
});
it("keeps the aq-mode prefix even with master-display present", () => {
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
const x265 = getX265ParamsValue(args);
expect(x265?.startsWith("aq-mode=3")).toBe(true);
});
it("uses the simpler aq-mode prefix on ultrafast preset", () => {
const args = buildStreamingArgs({ ...baseHdrPq, preset: "ultrafast" }, "/tmp/out.mp4");
const x265 = getX265ParamsValue(args);
expect(x265?.startsWith("aq-mode=3:")).toBe(true);
expect(x265).not.toContain("aq-strength");
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
});
});
describe("HDR HLG (libx265)", () => {
it("emits master-display, max-cll, and the HLG transfer", () => {
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
const x265 = getX265ParamsValue(args);
expect(x265).toContain("transfer=arib-std-b67");
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
});
it("tags the output stream with arib-std-b67", () => {
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
});
});
describe("HDR raw input tagging", () => {
it("tags the rawvideo input with the matching color metadata", () => {
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
const inputColorTrcIdx = args.indexOf("-color_trc");
expect(inputColorTrcIdx).toBeGreaterThan(-1);
expect(args[inputColorTrcIdx + 1]).toBe("smpte2084");
const inputPrimariesIdx = args.indexOf("-color_primaries");
expect(inputPrimariesIdx).toBeGreaterThan(-1);
expect(args[inputPrimariesIdx + 1]).toBe("bt2020");
// Pix_fmt of the raw input must match the buffer we hand FFmpeg.
expect(args.indexOf("rgb48le")).toBeGreaterThan(-1);
});
it("does not strip the input color tags when bitrate is set instead of CRF", () => {
const args = buildStreamingArgs({ ...baseHdrPq, bitrate: "20M" }, "/tmp/out.mp4");
const x265 = getX265ParamsValue(args);
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
expect(args).toContain("-b:v");
expect(args[args.indexOf("-b:v") + 1]).toBe("20M");
});
});
describe("SDR fallback", () => {
it("does NOT emit HDR mastering metadata for SDR encodes", () => {
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
const x264 = args[args.indexOf("-x264-params") + 1];
expect(x264).toContain("colorprim=bt709");
expect(x264).toContain("transfer=bt709");
expect(x264).toContain("colormatrix=bt709");
expect(x264).not.toContain("master-display");
expect(x264).not.toContain("max-cll");
});
it("tags SDR output with bt709 and tv range", () => {
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
});
});
describe("output path", () => {
it("places the output path last after -y", () => {
const args = buildStreamingArgs(baseHdrPq, "/tmp/some-output.mp4");
expect(args[args.length - 2]).toBe("-y");
expect(args[args.length - 1]).toBe("/tmp/some-output.mp4");
});
});
});