Files
hyperframes/packages/engine/src/utils/hdr.ts
T
Vance Ingalls 5a3fde19d4 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)
2026-04-19 15:10:59 -07:00

138 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.0001–1000 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 };
}