mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user