mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +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,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.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 };
|
||||
}
|
||||
Reference in New Issue
Block a user