test(engine): lock down sRGB→BT.2020 LUT with byte-exact reference values (#377)

## Summary

Add a 12-row reference table covering the full sRGB range with byte-exact 16-bit HLG and PQ signal values, plus three guard tests, locking down the `buildSrgbToHdrLut()` math.

## Why

`Chunk 9F` of `plans/hdr-followups.md`. The matrix-free fast path through `blitRgba8OverRgb48le` runs every DOM pixel through `buildSrgbToHdrLut()` (sRGB EOTF → linear → HDR OETF → 16-bit). Any drift in the EOTF/OETF math — constant changes, branch swaps, rounding-mode regressions — would silently corrupt every text / UI / overlay pixel composited onto an HDR frame.

Existing tests covered structural invariants (transparent passthrough, opaque overwrite, alpha blending, channel symmetry, HLG ≠ PQ) but no byte-exact reference values, so a uniform scale or constant tweak could pass everything.

## What changed

- 12-row reference table in `alphaBlit.test.ts` covering black, shadow, mid-grays, highlight, near-white, and white with exact 16-bit HLG and PQ signal values.
- Three guard tests:
  - **Asymmetric R/G/B (HLG):** each channel hits the LUT independently.
  - **Asymmetric R/G/B (PQ):** same, on the PQ path.
  - **BT.2408 SDR-white invariant:** PQ caps sRGB 255 at 38055 (~203 nits), well below HLG's 65535. This is the load-bearing detail that makes PQ headroom work — locking the exact value prevents a future "fix" that would re-scale PQ to peak-at-SDR-white and clip every real HDR pixel.

Reference values mirror `buildSrgbToHdrLut()` exactly and were verified against the existing HLG mid-gray comment in the file.

## Test plan

- [x] All new tests pass against the current LUT.
- [x] Existing `alphaBlit.test.ts` invariants unchanged.

## Stack

Chunk 9F of `plans/hdr-followups.md`. Test-only change, independent of all other chunks.
This commit is contained in:
Vance Ingalls
2026-04-23 10:37:45 -07:00
committed by GitHub
parent 3089c8ee3a
commit bb9e6bdf05
4 changed files with 274 additions and 2 deletions
@@ -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
@@ -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
+106
View File
@@ -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", () => {