mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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:
@@ -247,3 +247,91 @@ describe("buildEncoderArgs color space", () => {
|
||||
expect(args).not.toContain("-video_track_timescale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEncoderPreset HDR", () => {
|
||||
it("returns h265 with 10-bit for HDR HLG", () => {
|
||||
const preset = getEncoderPreset("standard", "mp4", { transfer: "hlg" });
|
||||
expect(preset.codec).toBe("h265");
|
||||
expect(preset.pixelFormat).toBe("yuv420p10le");
|
||||
expect(preset.hdr).toEqual({ transfer: "hlg" });
|
||||
});
|
||||
|
||||
it("returns h265 with 10-bit for HDR PQ", () => {
|
||||
const preset = getEncoderPreset("high", "mp4", { transfer: "pq" });
|
||||
expect(preset.codec).toBe("h265");
|
||||
expect(preset.pixelFormat).toBe("yuv420p10le");
|
||||
expect(preset.hdr).toEqual({ transfer: "pq" });
|
||||
});
|
||||
|
||||
it("avoids ultrafast preset for HDR (upgrades to fast)", () => {
|
||||
const preset = getEncoderPreset("draft", "mp4", { transfer: "hlg" });
|
||||
expect(preset.preset).toBe("fast");
|
||||
});
|
||||
|
||||
it("ignores HDR for webm format", () => {
|
||||
const preset = getEncoderPreset("standard", "webm", { transfer: "hlg" });
|
||||
expect(preset.codec).toBe("vp9");
|
||||
expect(preset.hdr).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores HDR for mov format", () => {
|
||||
const preset = getEncoderPreset("standard", "mov", { transfer: "pq" });
|
||||
expect(preset.codec).toBe("prores");
|
||||
expect(preset.hdr).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEncoderArgs HDR color space", () => {
|
||||
const baseOptions = { fps: 30, width: 1920, height: 1080 };
|
||||
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
|
||||
|
||||
it("keeps bt709 color tags when HDR flag is set but frames are still Chrome sRGB captures", () => {
|
||||
// HDR flag gives H.265 + 10-bit encoding but pixels are still sRGB/bt709.
|
||||
// Tagging as bt2020 causes orange shift — so we tag truthfully as bt709.
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||
const paramIdx = args.indexOf("-x265-params");
|
||||
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
|
||||
expect(args[paramIdx + 1]).toContain("transfer=bt709");
|
||||
});
|
||||
|
||||
it("uses bt709 when HDR is not set", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||
});
|
||||
|
||||
it("uses range conversion (not colorspace) for HDR CPU encoding", () => {
|
||||
// Chrome screenshots are sRGB — we don't convert primaries (causes color shifts).
|
||||
// Just range-convert and let the bt2020 container metadata + 10-bit handle the rest.
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
const vfIdx = args.indexOf("-vf");
|
||||
expect(vfIdx).toBeGreaterThan(-1);
|
||||
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
||||
expect(args[vfIdx + 1]).not.toContain("colorspace");
|
||||
});
|
||||
|
||||
it("uses same range conversion for SDR CPU encoding", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
const vfIdx = args.indexOf("-vf");
|
||||
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user