Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions packages/engine/scripts/generate-lut-reference.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 0 additions & 1 deletion packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions packages/engine/src/utils/alphaBlit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading