From 6a071e757916279d97dea7ea5182a0bd04d8a07a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 20:54:47 -0700 Subject: [PATCH 1/5] docs(hdr-regression): document maxFrameFailures budget rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address jrusso1020's nit on PR #365 (non-blocking review): both READMEs now explain where the tolerance values come from. - hdr-regression/README.md: add a budget-breakdown table that derives the 30 frames from the deltas in PRs #369 (window C fix → 5) and #375 (window F fix → 0). The table doubles as a contract: if a future change forces the budget back up, exactly one bucket has regressed and the table tells you which one to investigate first. - hdr-hlg-regression/README.md: add a 'Tolerance' section explaining why 0 is the right floor (HLG is a pure pass-through path, HEVC over rgb48le is byte-deterministic on the same fixture, so any drift is a real regression). The regeneration command for generate-hdr-photo-pq.py was already documented at README lines 67-71, so no changes needed there. --- .../engine/scripts/generate-lut-reference.py | 168 ++++++++++++++++++ .../tests/hdr-regression/src/index.html | 2 +- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100755 packages/engine/scripts/generate-lut-reference.py diff --git a/packages/engine/scripts/generate-lut-reference.py b/packages/engine/scripts/generate-lut-reference.py new file mode 100755 index 000000000..1c3ec19cf --- /dev/null +++ b/packages/engine/scripts/generate-lut-reference.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Regenerate the sRGB → BT.2020 (HLG/PQ) LUT reference values pinned by +packages/engine/src/utils/alphaBlit.test.ts. + +This is a paste-helper for the *very rare* case the LUT genuinely needs to +shift — e.g. a spec update changes one of the OETF constants, or we change +the SDR-white reference level in the PQ branch. The reference values in +alphaBlit.test.ts are byte-exact integers, and updating ~12 hand-edited +literals (or all 256 of them, if the test grows) is exactly the kind of +mechanical churn we want to keep out of the diff. + +Usage: + # Regenerate the probe table that lives in alphaBlit.test.ts (paste over + # the SRGB_TO_HDR_REFERENCE literal): + python3 packages/engine/scripts/generate-lut-reference.py --probes + + # Dump the full 256-entry LUTs as JSON (for ad-hoc analysis or new tests): + python3 packages/engine/scripts/generate-lut-reference.py + + # Override the probe set: + python3 packages/engine/scripts/generate-lut-reference.py --probes \ + --probe-indices 0,32,64,128,192,255 + +## How to use this when the LUT changes + +1. Edit buildSrgbToHdrLut() in packages/engine/src/utils/alphaBlit.ts. +2. Mirror the same edit here (constants, branch logic — keep them in sync). +3. Run with --probes and paste the output over SRGB_TO_HDR_REFERENCE in + alphaBlit.test.ts. Update the asymmetric-R/G/B and BT.2408-invariant + tests by hand if those probe values shifted. +4. Re-run `bun test src/utils/alphaBlit.test.ts` to confirm the engine LUT + and the test-pinned values still agree. + +## Why Python (not TS)? + +A standalone script avoids dragging the engine's bun/Node/build environment +into a one-off codegen flow, and matches the existing fixture-generation +pattern at packages/producer/tests/hdr-regression/scripts/generate-hdr-photo-pq.py. +Python's math.log / math.pow are libm-backed and produce IEEE-754-equivalent +results to JS's Math.log / Math.pow for these inputs — see js_round_nonneg +below for the one rounding quirk we have to match by hand. + +## Drift contract + +This file MIRRORS buildSrgbToHdrLut() in alphaBlit.ts. If the two diverge, +this script silently emits wrong values. Any change to one MUST be reflected +in the other; run the script and the test suite together to catch drift. +""" + +import argparse +import json +import math +import sys +from collections.abc import Iterable + +# HLG OETF constants (Rec. 2100) — keep in sync with alphaBlit.ts +HLG_A = 0.17883277 +HLG_B = 1 - 4 * HLG_A +HLG_C = 0.5 - HLG_A * math.log(4 * HLG_A) + +# PQ (SMPTE 2084) OETF constants — keep in sync with alphaBlit.ts +PQ_M1 = 0.1593017578125 +PQ_M2 = 78.84375 +PQ_C1 = 0.8359375 +PQ_C2 = 18.8515625 +PQ_C3 = 18.6875 +PQ_MAX_NITS = 10000.0 +SDR_NITS = 203.0 # BT.2408 SDR-reference white in PQ + + +def js_round_nonneg(x: float) -> int: + """ + Match JS Math.round semantics for non-negative inputs. + + JS Math.round rounds half toward +∞ (Math.round(0.5) === 1). Python's + built-in round() uses banker's rounding (round half to even, so + round(0.5) === 0 and round(2.5) === 2), which would diverge from + Math.round for the ~ten or so probe values that fall on a half-integer + after signal*65535. This helper is only correct for x >= 0 — that's + fine because signal is always in [0, 1] here. + """ + return int(math.floor(x + 0.5)) + + +def srgb_eotf(i: int) -> float: + """sRGB 8-bit code value → linear light in [0, 1] relative to SDR white.""" + v = i / 255 + return v / 12.92 if v <= 0.04045 else math.pow((v + 0.055) / 1.055, 2.4) + + +def hlg_oetf(linear: float) -> float: + if linear <= 1 / 12: + return math.sqrt(3 * linear) + return HLG_A * math.log(12 * linear - HLG_B) + HLG_C + + +def pq_oetf(linear: float) -> float: + # Place SDR-reference white at 203 nits within the 10000-nit PQ peak. + # This is what reserves headroom for HDR highlights above SDR-white. + lp = max(0.0, (linear * SDR_NITS) / PQ_MAX_NITS) + lm1 = math.pow(lp, PQ_M1) + return math.pow((PQ_C1 + PQ_C2 * lm1) / (1.0 + PQ_C3 * lm1), PQ_M2) + + +def build_lut(transfer: str) -> list[int]: + out: list[int] = [] + for i in range(256): + linear = srgb_eotf(i) + signal = hlg_oetf(linear) if transfer == "hlg" else pq_oetf(linear) + out.append(min(65535, js_round_nonneg(signal * 65535))) + return out + + +# Mirror SRGB_TO_HDR_REFERENCE indices in alphaBlit.test.ts. Endpoints +# (0, 1, 254, 255) catch off-by-one regressions; mid-range values (32, 64, +# 96, 128, 160, 192, 224) sample the middle of both transfer curves. +DEFAULT_PROBES: tuple[int, ...] = (0, 1, 10, 32, 64, 96, 128, 160, 192, 224, 254, 255) + + +def emit_json(hlg: list[int], pq: list[int]) -> None: + print(json.dumps({"size": 256, "hlg": hlg, "pq": pq}, indent=2)) + + +def emit_probes(hlg: list[int], pq: list[int], probes: Iterable[int]) -> None: + # Output is paste-ready TS for SRGB_TO_HDR_REFERENCE in alphaBlit.test.ts. + print("const SRGB_TO_HDR_REFERENCE: readonly SrgbHdrProbe[] = [") + for i in probes: + if not 0 <= i <= 255: + raise ValueError(f"probe index {i} out of range [0, 255]") + print(f" {{ srgb: {i}, hlg: {hlg[i]}, pq: {pq[i]} }},") + print("];") + + +def parse_indices(s: str) -> list[int]: + return [int(x.strip()) for x in s.split(",") if x.strip()] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Regenerate sRGB → BT.2020 (HLG/PQ) LUT reference values.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--probes", + action="store_true", + help="Emit a TS snippet ready to paste over SRGB_TO_HDR_REFERENCE.", + ) + parser.add_argument( + "--probe-indices", + type=parse_indices, + default=list(DEFAULT_PROBES), + help="Comma-separated probe indices (default mirrors alphaBlit.test.ts).", + ) + args = parser.parse_args() + + hlg = build_lut("hlg") + pq = build_lut("pq") + + if args.probes: + emit_probes(hlg, pq, args.probe_indices) + else: + emit_json(hlg, pq) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/producer/tests/hdr-regression/src/index.html b/packages/producer/tests/hdr-regression/src/index.html index 824f3713f..48a7f837f 100644 --- a/packages/producer/tests/hdr-regression/src/index.html +++ b/packages/producer/tests/hdr-regression/src/index.html @@ -241,4 +241,4 @@ window.__timelines["hdr-regression"] = tl; - + \ No newline at end of file From cd53d7866d0314d1c3fdd2a13b3c3ed2b598bfe7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 21:26:00 -0700 Subject: [PATCH 2/5] docs(hdr-regression): document maxFrameFailures budget rationale --- .../engine/scripts/generate-lut-reference.py | 168 ------------------ 1 file changed, 168 deletions(-) delete mode 100755 packages/engine/scripts/generate-lut-reference.py diff --git a/packages/engine/scripts/generate-lut-reference.py b/packages/engine/scripts/generate-lut-reference.py deleted file mode 100755 index 1c3ec19cf..000000000 --- a/packages/engine/scripts/generate-lut-reference.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -""" -Regenerate the sRGB → BT.2020 (HLG/PQ) LUT reference values pinned by -packages/engine/src/utils/alphaBlit.test.ts. - -This is a paste-helper for the *very rare* case the LUT genuinely needs to -shift — e.g. a spec update changes one of the OETF constants, or we change -the SDR-white reference level in the PQ branch. The reference values in -alphaBlit.test.ts are byte-exact integers, and updating ~12 hand-edited -literals (or all 256 of them, if the test grows) is exactly the kind of -mechanical churn we want to keep out of the diff. - -Usage: - # Regenerate the probe table that lives in alphaBlit.test.ts (paste over - # the SRGB_TO_HDR_REFERENCE literal): - python3 packages/engine/scripts/generate-lut-reference.py --probes - - # Dump the full 256-entry LUTs as JSON (for ad-hoc analysis or new tests): - python3 packages/engine/scripts/generate-lut-reference.py - - # Override the probe set: - python3 packages/engine/scripts/generate-lut-reference.py --probes \ - --probe-indices 0,32,64,128,192,255 - -## How to use this when the LUT changes - -1. Edit buildSrgbToHdrLut() in packages/engine/src/utils/alphaBlit.ts. -2. Mirror the same edit here (constants, branch logic — keep them in sync). -3. Run with --probes and paste the output over SRGB_TO_HDR_REFERENCE in - alphaBlit.test.ts. Update the asymmetric-R/G/B and BT.2408-invariant - tests by hand if those probe values shifted. -4. Re-run `bun test src/utils/alphaBlit.test.ts` to confirm the engine LUT - and the test-pinned values still agree. - -## Why Python (not TS)? - -A standalone script avoids dragging the engine's bun/Node/build environment -into a one-off codegen flow, and matches the existing fixture-generation -pattern at packages/producer/tests/hdr-regression/scripts/generate-hdr-photo-pq.py. -Python's math.log / math.pow are libm-backed and produce IEEE-754-equivalent -results to JS's Math.log / Math.pow for these inputs — see js_round_nonneg -below for the one rounding quirk we have to match by hand. - -## Drift contract - -This file MIRRORS buildSrgbToHdrLut() in alphaBlit.ts. If the two diverge, -this script silently emits wrong values. Any change to one MUST be reflected -in the other; run the script and the test suite together to catch drift. -""" - -import argparse -import json -import math -import sys -from collections.abc import Iterable - -# HLG OETF constants (Rec. 2100) — keep in sync with alphaBlit.ts -HLG_A = 0.17883277 -HLG_B = 1 - 4 * HLG_A -HLG_C = 0.5 - HLG_A * math.log(4 * HLG_A) - -# PQ (SMPTE 2084) OETF constants — keep in sync with alphaBlit.ts -PQ_M1 = 0.1593017578125 -PQ_M2 = 78.84375 -PQ_C1 = 0.8359375 -PQ_C2 = 18.8515625 -PQ_C3 = 18.6875 -PQ_MAX_NITS = 10000.0 -SDR_NITS = 203.0 # BT.2408 SDR-reference white in PQ - - -def js_round_nonneg(x: float) -> int: - """ - Match JS Math.round semantics for non-negative inputs. - - JS Math.round rounds half toward +∞ (Math.round(0.5) === 1). Python's - built-in round() uses banker's rounding (round half to even, so - round(0.5) === 0 and round(2.5) === 2), which would diverge from - Math.round for the ~ten or so probe values that fall on a half-integer - after signal*65535. This helper is only correct for x >= 0 — that's - fine because signal is always in [0, 1] here. - """ - return int(math.floor(x + 0.5)) - - -def srgb_eotf(i: int) -> float: - """sRGB 8-bit code value → linear light in [0, 1] relative to SDR white.""" - v = i / 255 - return v / 12.92 if v <= 0.04045 else math.pow((v + 0.055) / 1.055, 2.4) - - -def hlg_oetf(linear: float) -> float: - if linear <= 1 / 12: - return math.sqrt(3 * linear) - return HLG_A * math.log(12 * linear - HLG_B) + HLG_C - - -def pq_oetf(linear: float) -> float: - # Place SDR-reference white at 203 nits within the 10000-nit PQ peak. - # This is what reserves headroom for HDR highlights above SDR-white. - lp = max(0.0, (linear * SDR_NITS) / PQ_MAX_NITS) - lm1 = math.pow(lp, PQ_M1) - return math.pow((PQ_C1 + PQ_C2 * lm1) / (1.0 + PQ_C3 * lm1), PQ_M2) - - -def build_lut(transfer: str) -> list[int]: - out: list[int] = [] - for i in range(256): - linear = srgb_eotf(i) - signal = hlg_oetf(linear) if transfer == "hlg" else pq_oetf(linear) - out.append(min(65535, js_round_nonneg(signal * 65535))) - return out - - -# Mirror SRGB_TO_HDR_REFERENCE indices in alphaBlit.test.ts. Endpoints -# (0, 1, 254, 255) catch off-by-one regressions; mid-range values (32, 64, -# 96, 128, 160, 192, 224) sample the middle of both transfer curves. -DEFAULT_PROBES: tuple[int, ...] = (0, 1, 10, 32, 64, 96, 128, 160, 192, 224, 254, 255) - - -def emit_json(hlg: list[int], pq: list[int]) -> None: - print(json.dumps({"size": 256, "hlg": hlg, "pq": pq}, indent=2)) - - -def emit_probes(hlg: list[int], pq: list[int], probes: Iterable[int]) -> None: - # Output is paste-ready TS for SRGB_TO_HDR_REFERENCE in alphaBlit.test.ts. - print("const SRGB_TO_HDR_REFERENCE: readonly SrgbHdrProbe[] = [") - for i in probes: - if not 0 <= i <= 255: - raise ValueError(f"probe index {i} out of range [0, 255]") - print(f" {{ srgb: {i}, hlg: {hlg[i]}, pq: {pq[i]} }},") - print("];") - - -def parse_indices(s: str) -> list[int]: - return [int(x.strip()) for x in s.split(",") if x.strip()] - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Regenerate sRGB → BT.2020 (HLG/PQ) LUT reference values.", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--probes", - action="store_true", - help="Emit a TS snippet ready to paste over SRGB_TO_HDR_REFERENCE.", - ) - parser.add_argument( - "--probe-indices", - type=parse_indices, - default=list(DEFAULT_PROBES), - help="Comma-separated probe indices (default mirrors alphaBlit.test.ts).", - ) - args = parser.parse_args() - - hlg = build_lut("hlg") - pq = build_lut("pq") - - if args.probes: - emit_probes(hlg, pq, args.probe_indices) - else: - emit_json(hlg, pq) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From f65beabd4b47c8b1ca839702b320114c4e6bf0cf Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 05:15:54 -0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(engine):=20wire=20options.hdr=20throug?= =?UTF-8?q?h=20chunkEncoder=20+=20dynamic=20SDR=E2=86=92HDR=20transfer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 3 of HDR follow-ups. Three independent fixes that share a common thread: HDR config flowing correctly from the EngineConfig down through the encoders. 3A. chunkEncoder respects options.hdr (BT.2020 + mastering metadata) Previously buildEncoderArgs hard-coded BT.709 color tags and the bt709 VUI block in -x265-params, even when callers passed an HDR EncoderOptions. Today this is harmless because renderOrchestrator routes native-HDR content to streamingEncoder and only feeds chunkEncoder sRGB Chrome screenshots — but the contract was a lie. Now: when options.hdr is set, the libx265 software path emits bt2020nc + the matching transfer (smpte2084 for PQ, arib-std-b67 for HLG) at the codec level *and* embeds master-display + max-cll SEI in -x265-params via getHdrEncoderColorParams. libx264 still tags BT.709 inside -x264-params (libx264 has no HDR support) but the codec-level color flags flip so the container describes pixels truthfully. GPU H.265 (nvenc/videotoolbox/qsv/vaapi) gets the BT.2020 tags but no -x265-params block, so static mastering metadata is omitted — acceptable for previews, not HDR-aware delivery. 3B. convertSdrToHdr accepts a target transfer videoFrameExtractor.convertSdrToHdr was hard-coded to transfer=arib-std-b67 (HLG) regardless of the surrounding composition's dominant transfer. extractAllVideoFrames now calls analyzeCompositionHdr first, then passes the dominant transfer ("pq" or "hlg") into convertSdrToHdr so an SDR clip mixed into a PQ timeline gets converted with smpte2084, not arib-std-b67. 3C. EngineConfig.hdr type matches its declared shape The IIFE for the hdr field returned undefined when PRODUCER_HDR_TRANSFER wasn't "hlg" or "pq", but the field is typed as { transfer: HdrTransfer } | false. Returning false matches the type and avoids a downstream undefined check. Tests - chunkEncoder.test.ts: replaced the previous "HDR options ignored" assertions with 8 new specs covering BT.2020 + transfer tagging, master-display/max-cll embedding, libx264 fallback behavior, GPU H.265 + HDR (tags but no x265-params), and range conversion for both SDR and HDR CPU paths. - All 313 engine unit tests pass (5 new HDR specs). Follow-ups (separate PRs): - Producer regression suite runs in CI; not exercising HDR-tagged chunkEncoder yet because no live caller sets options.hdr there. --- packages/engine/src/config.ts | 2 +- .../engine/src/services/chunkEncoder.test.ts | 105 ++++++++++++++++-- packages/engine/src/services/chunkEncoder.ts | 62 ++++++++--- .../src/services/videoFrameExtractor.ts | 35 ++++-- 4 files changed, 163 insertions(+), 41 deletions(-) diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index b0ea98d96..ea8055c4b 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -182,7 +182,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { hdr: (() => { const raw = env("PRODUCER_HDR_TRANSFER"); if (raw === "hlg" || raw === "pq") return { transfer: raw }; - return undefined; + return false; })(), hdrAutoDetect: envBool("PRODUCER_HDR_AUTO_DETECT", DEFAULT_CONFIG.hdrAutoDetect), diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index cf914959a..5eb5ae428 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -285,35 +285,92 @@ 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. + it("emits BT.2020 + arib-std-b67 tags for HDR HLG (h265 SW)", () => { + // When options.hdr is set, the caller asserts the input pixels are + // already in the BT.2020 color space — tag the output truthfully so + // HDR-aware players apply the right transform. const args = buildEncoderArgs( { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } }, inputArgs, "out.mp4", ); + 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("arib-std-b67"); + const paramIdx = args.indexOf("-x265-params"); + expect(args[paramIdx + 1]).toContain("colorprim=bt2020"); + expect(args[paramIdx + 1]).toContain("transfer=arib-std-b67"); + expect(args[paramIdx + 1]).toContain("colormatrix=bt2020nc"); + }); + + it("emits BT.2020 + smpte2084 tags for HDR PQ (h265 SW)", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, + inputArgs, + "out.mp4", + ); + 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"); + const paramIdx = args.indexOf("-x265-params"); + expect(args[paramIdx + 1]).toContain("colorprim=bt2020"); + expect(args[paramIdx + 1]).toContain("transfer=smpte2084"); + expect(args[paramIdx + 1]).toContain("colormatrix=bt2020nc"); + }); + + it("embeds HDR static mastering metadata in x265-params when HDR is set", () => { + // master-display + max-cll SEI messages are required so HDR-aware + // players (Apple QuickTime, YouTube, HDR TVs) treat the stream as + // HDR10 instead of falling back to SDR BT.2020 tone-mapping. + const args = buildEncoderArgs( + { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, + inputArgs, + "out.mp4", + ); + const paramIdx = args.indexOf("-x265-params"); + expect(args[paramIdx + 1]).toContain("master-display="); + expect(args[paramIdx + 1]).toContain("max-cll="); + }); + + it("uses bt709 when HDR is not set (SDR Chrome captures)", () => { + 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_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"); + expect(args[paramIdx + 1]).not.toContain("master-display"); }); - it("uses bt709 when HDR is not set", () => { + it("does not embed HDR mastering metadata 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"); + const paramIdx = args.indexOf("-x265-params"); + expect(args[paramIdx + 1]).not.toContain("master-display"); + expect(args[paramIdx + 1]).not.toContain("max-cll"); }); - 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. + it("keeps bt709 x264-params tagging even when HDR is requested (libx264 has no HDR support)", () => { + // libx264 cannot embed HDR static metadata. The codec-level color tags + // still flip to BT.2020 (so containers describe pixels correctly), but + // the x264-params VUI block stays bt709 since x264 doesn't speak HDR. + const args = buildEncoderArgs( + { ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, + inputArgs, + "out.mp4", + ); + const paramIdx = args.indexOf("-x264-params"); + expect(args[paramIdx + 1]).toContain("colorprim=bt709"); + expect(args[paramIdx + 1]).not.toContain("master-display"); + }); + + it("uses range conversion for HDR CPU encoding", () => { const args = buildEncoderArgs( { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } }, inputArgs, @@ -322,7 +379,6 @@ describe("buildEncoderArgs HDR color space", () => { 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", () => { @@ -334,4 +390,29 @@ describe("buildEncoderArgs HDR color space", () => { const vfIdx = args.indexOf("-vf"); expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv"); }); + + it("tags BT.2020 + transfer for HDR GPU H.265 (no mastering metadata via -x265-params)", () => { + // GPU encoders (nvenc, videotoolbox, qsv, vaapi) still emit the BT.2020 + // color tags via the codec-level -colorspace/-color_primaries/-color_trc + // flags, but cannot accept x265-params, so HDR static mastering metadata + // (master-display, max-cll) is not embedded. Acceptable for previews, + // not for HDR-aware delivery. + const args = buildEncoderArgs( + { + ...baseOptions, + codec: "h265", + preset: "medium", + quality: 23, + useGpu: true, + hdr: { transfer: "pq" }, + }, + inputArgs, + "out.mp4", + "nvenc", + ); + 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.indexOf("-x265-params")).toBe(-1); + }); }); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index b7658d36f..da3d0bfae 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -10,7 +10,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSy import { join, dirname } from "path"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js"; -import { type HdrTransfer } from "../utils/hdr.js"; +import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js"; import { runFfmpeg } from "../utils/runFfmpeg.js"; import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; @@ -130,8 +130,14 @@ export function buildEncoderArgs( // Encoder-specific params: anti-banding + color space tagging. // aq-mode=3 redistributes bits to dark flat areas (gradients). + // For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static + // mastering metadata via x265-params; libx264 only carries BT.709 tags + // since HDR through H.264 is not supported by this encoder path. const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params"; - const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709"; + const colorParams = + codec === "h265" && options.hdr + ? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams + : "colorprim=bt709:transfer=bt709:colormatrix=bt709"; if (preset === "ultrafast") { args.push(xParamsFlag, `aq-mode=3:${colorParams}`); } else { @@ -157,22 +163,44 @@ export function buildEncoderArgs( } // Color space metadata — tags the output so players interpret colors correctly. - // Chrome screenshots are always sRGB/bt709 pixels regardless of --hdr flag. - // We tag truthfully as bt709 even for HDR output — the --hdr flag gives - // H.265 + 10-bit encoding (better quality/compression) without lying about - // the color space. Tagging as bt2020 when pixels are bt709 causes browsers - // to apply the wrong color transform, producing visible orange/warm shifts. + // + // Default (no options.hdr): Chrome screenshots are sRGB/bt709 pixels and + // we tag them truthfully as bt709. Tagging as bt2020 when pixels are bt709 + // causes browsers to apply the wrong color transform, producing visible + // orange/warm shifts. + // + // HDR (options.hdr provided): the caller asserts the input pixels are + // already in the BT.2020 color space (e.g. extracted HDR video frames or a + // pre-tagged source). We tag the output as BT.2020 + the corresponding + // transfer (smpte2084 for PQ, arib-std-b67 for HLG). HDR static mastering + // metadata (master-display, max-cll) is embedded only in the SW libx265 + // path above; GPU H.265 + HDR carries the color tags but not the static + // metadata, which is acceptable for previews but not for HDR-aware delivery. if (codec === "h264" || codec === "h265") { - args.push( - "-colorspace:v", - "bt709", - "-color_primaries:v", - "bt709", - "-color_trc:v", - "bt709", - "-color_range", - "tv", - ); + if (options.hdr) { + const transferTag = options.hdr.transfer === "pq" ? "smpte2084" : "arib-std-b67"; + args.push( + "-colorspace:v", + "bt2020nc", + "-color_primaries:v", + "bt2020", + "-color_trc:v", + transferTag, + "-color_range", + "tv", + ); + } else { + args.push( + "-colorspace:v", + "bt709", + "-color_primaries:v", + "bt709", + "-color_trc:v", + "bt709", + "-color_range", + "tv", + ); + } // Range conversion: Chrome's full-range RGB → limited/TV range. if (gpuEncoder === "vaapi") { diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index a78ac6408..543546fda 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -10,7 +10,11 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from "fs"; import { join } from "path"; import { parseHTML } from "linkedom"; import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js"; -import { isHdrColorSpace as isHdrColorSpaceUtil } from "../utils/hdr.js"; +import { + analyzeCompositionHdr, + isHdrColorSpace as isHdrColorSpaceUtil, + type HdrTransfer, +} from "../utils/hdr.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; import { runFfmpeg } from "../utils/runFfmpeg.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; @@ -250,21 +254,28 @@ export async function extractVideoFramesRange( } /** - * Convert an SDR video to HDR color space (HLG / BT.2020) so it can be - * composited alongside HDR content without looking washed out. + * Convert an SDR (BT.709) video to BT.2020 wide-gamut so it can be composited + * alongside HDR content without looking washed out. * - * Uses zscale for color space conversion with a nominal peak luminance of - * 600 nits — high enough that SDR content doesn't appear too dark next to - * HDR, matching the approach used by HeyGen's Rio pipeline. + * Uses FFmpeg's `colorspace` filter to remap BT.709 → BT.2020 (no real tone + * mapping — just a primaries swap so the input fits inside the wider HDR + * gamut), then re-tags the stream with the caller's target HDR transfer + * function (PQ for HDR10, HLG for broadcast HDR). The output transfer must + * match the dominant transfer of the surrounding HDR content; otherwise the + * downstream encoder will tag the final video with the wrong curve. */ async function convertSdrToHdr( inputPath: string, outputPath: string, + targetTransfer: HdrTransfer, signal?: AbortSignal, config?: Partial>, ): Promise { const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout; + // smpte2084 = PQ (HDR10), arib-std-b67 = HLG. + const colorTrc = targetTransfer === "pq" ? "smpte2084" : "arib-std-b67"; + const args = [ "-i", inputPath, @@ -273,7 +284,7 @@ async function convertSdrToHdr( "-color_primaries", "bt2020", "-color_trc", - "arib-std-b67", + colorTrc, "-colorspace", "bt2020nc", "-c:v", @@ -401,8 +412,9 @@ export async function extractAllVideoFrames( }), ); - const hasAnyHdr = videoColorSpaces.some(isHdrColorSpaceUtil); - if (hasAnyHdr) { + const hdrInfo = analyzeCompositionHdr(videoColorSpaces); + if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) { + const targetTransfer = hdrInfo.dominantTransfer; const convertDir = join(options.outputDir, "_hdr_normalized"); mkdirSync(convertDir, { recursive: true }); @@ -410,12 +422,13 @@ export async function extractAllVideoFrames( if (signal?.aborted) break; const cs = videoColorSpaces[i] ?? null; if (!isHdrColorSpaceUtil(cs)) { - // SDR video in a mixed timeline — convert to HDR color space + // SDR video in a mixed timeline — convert to the dominant HDR transfer + // so the encoder tags the final video correctly (PQ vs HLG). const entry = resolvedVideos[i]; if (!entry) continue; const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`); try { - await convertSdrToHdr(entry.videoPath, convertedPath, signal, config); + await convertSdrToHdr(entry.videoPath, convertedPath, targetTransfer, signal, config); entry.videoPath = convertedPath; } catch (err) { errors.push({ From 0c87c4b5300ee64706fef9a2442052005f97d57c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 21:08:13 -0700 Subject: [PATCH 4/5] fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error PR #370 review feedback (jrusso1020): - chunkEncoder: when codec=h264 and hdr is set, log a warning and strip hdr instead of emitting a half-HDR file (BT.2020 container tags + BT.709 VUI inside the bitstream). libx264 has no HDR support; the only honest output is SDR/BT.709. Caller is told to use codec=h265. - videoFrameExtractor: comment at the convertSdrToHdr call site clarifying that dominantTransfer is majority-wins; mixing PQ and HLG sources in a single composition is caller-error and the minority transfer's videos will be converted with the wrong curve. Render two compositions if you need both transfers. - docs/guides/hdr.mdx: limitations section now documents (a) H.264 + HDR is rejected at the encoder layer, and (b) GPU H.265 (nvenc, videotoolbox, qsv, vaapi) emits BT.2020 + transfer tags but does NOT embed master-display or max-cll SEI, since ffmpeg won't pass x265-params through hardware encoders. Acceptable for previews, not for HDR10 delivery. --- docs/guides/hdr.mdx | 2 ++ .../engine/src/services/chunkEncoder.test.ts | 19 ++++++++++++++----- packages/engine/src/services/chunkEncoder.ts | 12 ++++++++++++ .../src/services/videoFrameExtractor.ts | 6 ++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/guides/hdr.mdx b/docs/guides/hdr.mdx index 9446d4d62..5774cfae6 100644 --- a/docs/guides/hdr.mdx +++ b/docs/guides/hdr.mdx @@ -163,6 +163,8 @@ The container runs the same probe → composite → encode pipeline as the local - **MP4 only** — `--hdr` with `--format mov` or `--format webm` falls back to SDR - **HDR images: 16-bit PNG only** — other formats (JPEG, WebP, AVIF, APNG) are not decoded as HDR and fall through the SDR DOM path +- **H.265 only — H.264 is stripped** — calling the encoder with `codec: "h264"` and `hdr: { transfer }` is rejected; the encoder logs a warning, drops `hdr`, and tags the output as SDR/BT.709. `libx264` cannot encode HDR, so the alternative would be a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block in the bitstream) which confuses HDR-aware players. +- **GPU H.265 emits color tags but no static mastering metadata** — `useGpu: true` with HDR (nvenc, videotoolbox, qsv, vaapi) tags the stream with BT.2020 + the correct transfer (smpte2084 / arib-std-b67) but does **not** embed `master-display` or `max-cll` SEI. ffmpeg does not let those flags pass through hardware encoders. The output is suitable for previews and authoring but not for HDR10-aware delivery (Apple TV, YouTube, Netflix). For spec-compliant HDR10 production output, leave `useGpu: false` so the SW `libx265` path embeds the mastering metadata. - **Player support** — the [``](/packages/player) web component plays back the encoded MP4 in the browser and inherits whatever HDR support the host browser provides; it does not implement its own HDR pipeline - **Headed Chrome HDR DOM capture** — the engine ships a separate WebGPU-based capture path for rendering CSS-animated DOM directly into HDR (`initHdrReadback`, `launchHdrBrowser`). It requires headed Chrome with `--enable-unsafe-webgpu` and is not used by the default render pipeline. See [Engine: HDR](/packages/engine#hdr-apis) if you are building a custom integration. diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 5eb5ae428..98e104c8f 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js"; describe("ENCODER_PRESETS", () => { @@ -356,10 +356,12 @@ describe("buildEncoderArgs HDR color space", () => { expect(args[paramIdx + 1]).not.toContain("max-cll"); }); - it("keeps bt709 x264-params tagging even when HDR is requested (libx264 has no HDR support)", () => { - // libx264 cannot embed HDR static metadata. The codec-level color tags - // still flip to BT.2020 (so containers describe pixels correctly), but - // the x264-params VUI block stays bt709 since x264 doesn't speak HDR. + it("strips HDR and tags as SDR/BT.709 when codec=h264 (libx264 has no HDR support)", () => { + // libx264 cannot encode HDR. Rather than emit a "half-HDR" file (BT.2020 + // container tags + BT.709 VUI inside the bitstream — confusing to HDR-aware + // players), we strip hdr and tag the whole output as SDR/BT.709. The caller + // gets a warning telling them to use codec=h265 for real HDR output. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const args = buildEncoderArgs( { ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, inputArgs, @@ -368,6 +370,13 @@ describe("buildEncoderArgs HDR color space", () => { const paramIdx = args.indexOf("-x264-params"); expect(args[paramIdx + 1]).toContain("colorprim=bt709"); expect(args[paramIdx + 1]).not.toContain("master-display"); + 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"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("HDR is not supported with codec=h264"), + ); + warnSpy.mockRestore(); }); it("uses range conversion for HDR CPU encoding", () => { diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index da3d0bfae..a66e40020 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -88,6 +88,18 @@ export function buildEncoderArgs( useGpu = false, } = options; + // libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd + // produce a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block + // inside the bitstream) which confuses HDR-aware players. Strip hdr and + // log a warning so the caller picks h265 (the SDR-tagged output is honest). + if (options.hdr && codec === "h264") { + console.warn( + "[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " + + "Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.", + ); + options = { ...options, hdr: false }; + } + const args: string[] = [...inputArgs, "-r", String(fps)]; const shouldUseGpu = useGpu && gpuEncoder !== null; diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 543546fda..3980e3a1c 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -414,6 +414,12 @@ export async function extractAllVideoFrames( const hdrInfo = analyzeCompositionHdr(videoColorSpaces); if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) { + // dominantTransfer is "majority wins" — if a composition mixes PQ and HLG + // sources (rare but legal), the minority transfer's videos get converted + // with the wrong curve. We treat this as caller-error: a single composition + // should not mix PQ and HLG sources, the orchestrator picks one transfer + // for the whole render, and any source not on that curve is normalized to + // it. If you need both transfers, render two separate compositions. const targetTransfer = hdrInfo.dominantTransfer; const convertDir = join(options.outputDir, "_hdr_normalized"); mkdirSync(convertDir, { recursive: true }); From 7b268a907db3521373732e7093d76b8648c7561d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 21:11:57 -0700 Subject: [PATCH 5/5] fix(engine): use undefined (not boolean false) when stripping HDR for libx264 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hdr field is { transfer: HdrTransfer } | undefined — not boolean. Setting it to false produced a TS2322 type error. undefined is the correct sentinel and matches how callers represent SDR throughout the codebase. --- packages/engine/src/services/chunkEncoder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index a66e40020..f44361ea9 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -97,7 +97,7 @@ export function buildEncoderArgs( "[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " + "Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.", ); - options = { ...options, hdr: false }; + options = { ...options, hdr: undefined }; } const args: string[] = [...inputArgs, "-r", String(fps)];