From 2e7650118bd2049b6e844415159035b0ac9cbb73 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 20:54:47 -0700 Subject: [PATCH] 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 ++++++++++++++++++ .../engine/src/services/chunkEncoder.test.ts | 1 - .../src/services/videoFrameExtractor.ts | 1 - packages/engine/src/utils/alphaBlit.test.ts | 106 +++++++++++ 4 files changed, 274 insertions(+), 2 deletions(-) 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/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index fff55302d..6a2fe93c5 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -439,7 +439,6 @@ describe("buildEncoderArgs HDR color space", () => { expect(args[paramIdx + 1]).not.toContain("max-cll"); }); - 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 diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 0464aa24d..b16627af9 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -418,7 +418,6 @@ 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 diff --git a/packages/engine/src/utils/alphaBlit.test.ts b/packages/engine/src/utils/alphaBlit.test.ts index ae99bb33b..4edc6a527 100644 --- a/packages/engine/src/utils/alphaBlit.test.ts +++ b/packages/engine/src/utils/alphaBlit.test.ts @@ -629,6 +629,112 @@ describe("blitRgba8OverRgb48le with PQ transfer", () => { }); }); +// ── sRGB → BT.2020 reference values (locks down the per-channel LUT) ───────── +// +// Probes computed by mirroring buildSrgbToHdrLut() (sRGB EOTF → linear → HDR +// OETF → 16-bit). Values are byte-exact integers — any drift in the EOTF/OETF +// math (constant changes, branch swaps, rounding-mode regressions) is caught +// immediately, on the matrix-free fast path through blitRgba8OverRgb48le where +// every DOM pixel goes through getSrgbToHdrLut(). +// +// Two key invariants the table enforces: +// +// 1. HLG: sRGB 255 → 65535 (white maps to white in HLG signal space). +// +// 2. PQ: sRGB 255 → 38055 (≪ 65535). NOT a bug — SDR white is placed at +// 203 nits per BT.2408, normalized against PQ's 10000-nit peak. This is +// what lets HDR highlights live above SDR-reference-white in a PQ frame. +// Never "fix" PQ to map sRGB 255 → 65535. +// +// To regenerate after an *intentional* LUT change (transfer-function constant, +// BT.709→BT.2020 matrix tuning, SDR-white nit reference, OOTF), run: +// +// python3 packages/engine/scripts/generate-lut-reference.py --probes +// +// and paste the output over the SRGB_TO_HDR_REFERENCE literal below. Update +// the script's mirrored OETF/EOTF constants in lockstep with alphaBlit.ts so +// the generator stays the source of truth. + +interface SrgbHdrProbe { + srgb: number; + hlg: number; + pq: number; +} + +const SRGB_TO_HDR_REFERENCE: readonly SrgbHdrProbe[] = [ + { srgb: 0, hlg: 0, pq: 0 }, + { srgb: 1, hlg: 1978, pq: 3315 }, + { srgb: 10, hlg: 6254, pq: 8300 }, + { srgb: 32, hlg: 13642, pq: 13884 }, + { srgb: 64, hlg: 25702, pq: 19848 }, + { srgb: 96, hlg: 38011, pq: 24379 }, + { srgb: 128, hlg: 46484, pq: 28037 }, + { srgb: 160, hlg: 52745, pq: 31104 }, + { srgb: 192, hlg: 57772, pq: 33743 }, + { srgb: 224, hlg: 61994, pq: 36057 }, + { srgb: 254, hlg: 65428, pq: 37994 }, + { srgb: 255, hlg: 65535, pq: 38055 }, +]; + +describe("blitRgba8OverRgb48le: sRGB → BT.2020 reference values", () => { + it.each(SRGB_TO_HDR_REFERENCE)( + "sRGB $srgb → HLG $hlg, PQ $pq (grayscale, opaque)", + ({ srgb, hlg, pq }) => { + const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0); + const domHlg = makeDomRgba(1, 1, srgb, srgb, srgb, 255); + blitRgba8OverRgb48le(domHlg, canvasHlg, 1, 1, "hlg"); + // All three channels should hit the same LUT slot. + expect(canvasHlg.readUInt16LE(0)).toBe(hlg); + expect(canvasHlg.readUInt16LE(2)).toBe(hlg); + expect(canvasHlg.readUInt16LE(4)).toBe(hlg); + + const canvasPq = makeHdrFrame(1, 1, 0, 0, 0); + const domPq = makeDomRgba(1, 1, srgb, srgb, srgb, 255); + blitRgba8OverRgb48le(domPq, canvasPq, 1, 1, "pq"); + expect(canvasPq.readUInt16LE(0)).toBe(pq); + expect(canvasPq.readUInt16LE(2)).toBe(pq); + expect(canvasPq.readUInt16LE(4)).toBe(pq); + }, + ); + + it("HLG: asymmetric R/G/B maps each channel independently through the LUT", () => { + // R=64, G=128, B=192 → independent LUT lookups per channel. + const canvas = makeHdrFrame(1, 1, 0, 0, 0); + const dom = makeDomRgba(1, 1, 64, 128, 192, 255); + blitRgba8OverRgb48le(dom, canvas, 1, 1, "hlg"); + expect(canvas.readUInt16LE(0)).toBe(25702); + expect(canvas.readUInt16LE(2)).toBe(46484); + expect(canvas.readUInt16LE(4)).toBe(57772); + }); + + it("PQ: asymmetric R/G/B maps each channel independently through the LUT", () => { + const canvas = makeHdrFrame(1, 1, 0, 0, 0); + const dom = makeDomRgba(1, 1, 64, 128, 192, 255); + blitRgba8OverRgb48le(dom, canvas, 1, 1, "pq"); + expect(canvas.readUInt16LE(0)).toBe(19848); + expect(canvas.readUInt16LE(2)).toBe(28037); + expect(canvas.readUInt16LE(4)).toBe(33743); + }); + + it("PQ caps SDR-reference-white well below HLG signal peak (BT.2408 invariant)", () => { + // sRGB 255 (SDR white) → HLG 65535 (top of HLG signal range) + // → PQ 38055 (~58% of PQ signal, ~203 nits) + // The gap is what lets PQ carry HDR highlights above SDR reference level. + // Locking the exact PQ value here prevents a future "fix" that would + // re-scale PQ to peak-at-SDR-white (which would clip every real HDR pixel). + const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0); + const canvasPq = makeHdrFrame(1, 1, 0, 0, 0); + const dom = makeDomRgba(1, 1, 255, 255, 255, 255); + + blitRgba8OverRgb48le(dom, canvasHlg, 1, 1, "hlg"); + blitRgba8OverRgb48le(dom, canvasPq, 1, 1, "pq"); + + expect(canvasHlg.readUInt16LE(0)).toBe(65535); + expect(canvasPq.readUInt16LE(0)).toBe(38055); + expect(canvasPq.readUInt16LE(0)).toBeLessThan(canvasHlg.readUInt16LE(0)); + }); +}); + // ── blitRgb48leRegion tests ────────────────────────────────────────────────── describe("blitRgb48leRegion", () => {