mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user