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
+137
View File
@@ -0,0 +1,137 @@
/**
* HDR Color Space Utilities
*
* Centralized HDR detection, transfer type handling, and FFmpeg color
* parameter generation for the HDR rendering pipeline.
*/
import type { VideoColorSpace } from "./ffprobe.js";
export type HdrTransfer = "hlg" | "pq";
/**
* Check if a video's color space indicates HDR content.
* Re-exported from videoFrameExtractor for backward compatibility.
*/
export function isHdrColorSpace(cs: VideoColorSpace | null): boolean {
if (!cs) return false;
return (
cs.colorPrimaries.includes("bt2020") ||
cs.colorSpace.includes("bt2020") ||
cs.colorTransfer === "smpte2084" ||
cs.colorTransfer === "arib-std-b67"
);
}
/**
* Determine the HDR transfer function from a video's color space metadata.
*
* IMPORTANT: Callers must gate on `isHdrColorSpace(cs)` first. This function
* assumes the input has already been classified as HDR and defaults ambiguous
* inputs to "hlg" — calling it with an SDR color space silently returns "hlg",
* which is wrong for SDR.
*
* Returns "pq" for SMPTE 2084, "hlg" for ARIB STD-B67, defaults to "hlg".
*/
export function detectTransfer(cs: VideoColorSpace | null): HdrTransfer {
if (cs?.colorTransfer === "smpte2084") return "pq";
return "hlg";
}
/**
* HDR static metadata for the encoded stream.
*
* `masterDisplay` is the SMPTE ST 2086 mastering-display color volume string
* accepted by x265 (`G(Gx,Gy)B(Bx,By)R(Rx,Ry)WP(WPx,WPy)L(Lmax,Lmin)`).
* Chromaticity values are scaled by 50000 (0.00002 cd/m² per unit) and
* luminance values by 10000 (0.0001 cd/m² per unit).
*
* `maxCll` is the CTA-861.3 Content Light Level pair `MaxCLL,MaxFALL` in
* cd/m². Without these SEI messages, downstream players (Apple QuickTime,
* YouTube, HDR TVs) treat the stream as SDR BT.2020 and tone-map incorrectly
* — see packages/producer/scripts/hdr-smoke.ts for the regression assertion.
*/
export interface HdrMasteringMetadata {
masterDisplay: string;
maxCll: string;
}
/**
* Default HDR10 mastering metadata: P3-D65 primaries inside a BT.2020
* container, mastered for 0.00011000 cd/m² with MaxCLL=1000, MaxFALL=400.
*
* These are conservative defaults that match how most HDR10 grading suites
* (Premiere, DaVinci Resolve) tag content when per-frame measured values
* aren't available. A future PR can plumb measured MaxCLL through `--hdr-opt`.
*/
export const DEFAULT_HDR10_MASTERING: HdrMasteringMetadata = {
masterDisplay: "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
maxCll: "1000,400",
};
export interface HdrEncoderColorParams {
colorPrimaries: string;
colorTrc: string;
colorspace: string;
pixelFormat: string;
/**
* Full x265-params string including color tagging and HDR static metadata.
* Pass directly to `-x265-params` (concatenate with other options via `:`).
*/
x265ColorParams: string;
/** The mastering metadata that was baked into `x265ColorParams`. */
mastering: HdrMasteringMetadata;
}
/**
* Get FFmpeg encoder color parameters for a given HDR transfer function.
*
* The returned `x265ColorParams` includes both color tagging
* (`colorprim`/`transfer`/`colormatrix`) and HDR static metadata
* (`master-display`/`max-cll`). Without the static metadata the encoded
* stream is rejected as SDR by most HDR-aware players and CDNs.
*/
export function getHdrEncoderColorParams(
transfer: HdrTransfer,
mastering: HdrMasteringMetadata = DEFAULT_HDR10_MASTERING,
): HdrEncoderColorParams {
const colorTrc = transfer === "pq" ? "smpte2084" : "arib-std-b67";
const tagging = `colorprim=bt2020:transfer=${colorTrc}:colormatrix=bt2020nc`;
const metadata = `master-display=${mastering.masterDisplay}:max-cll=${mastering.maxCll}`;
return {
colorPrimaries: "bt2020",
colorTrc,
colorspace: "bt2020nc",
pixelFormat: "yuv420p10le",
x265ColorParams: `${tagging}:${metadata}`,
mastering,
};
}
export interface CompositionHdrInfo {
hasHdr: boolean;
dominantTransfer: HdrTransfer | null;
}
/**
* Analyze a set of video color spaces to determine if the composition
* contains HDR content and what the dominant transfer function is.
*/
export function analyzeCompositionHdr(
colorSpaces: Array<VideoColorSpace | null>,
): CompositionHdrInfo {
let hasPq = false;
let hasHdr = false;
for (const cs of colorSpaces) {
if (!isHdrColorSpace(cs)) continue;
hasHdr = true;
if (cs?.colorTransfer === "smpte2084") hasPq = true;
}
if (!hasHdr) return { hasHdr: false, dominantTransfer: null };
// PQ takes priority — it's the more common HDR10 format
const dominantTransfer: HdrTransfer = hasPq ? "pq" : "hlg";
return { hasHdr: true, dominantTransfer };
}