From 28e1bf93000ccc44d4fa4364a418cc7c033b2abb Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 21:08:13 -0700 Subject: [PATCH 1/2] 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. --- packages/engine/src/services/chunkEncoder.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 98e104c8..a56eb316 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -377,6 +377,7 @@ describe("buildEncoderArgs HDR color space", () => { expect.stringContaining("HDR is not supported with codec=h264"), ); warnSpy.mockRestore(); + }); it("uses range conversion for HDR CPU encoding", () => { From 16e2fa4f303dd50fa4d2a8d77d1eca2674c31bf2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 08:04:35 -0700 Subject: [PATCH 2/2] test(shader-transitions): add midpoint (p=0.5) regression invariants for all shaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing smoke tests cover only the endpoints (p=0 ≈ from, p=1 ≈ to), which miss a class of regressions that surface specifically at the midpoint — where the transition is most visible to viewers — and let shaders silently rot in CI: • A shader becomes a no-op (returns input as-is) • A shader prematurely completes (returns target at midpoint) • A shader doesn't write to the output buffer at all • A shader loses determinism (Math.random / Date.now / leaked state) Add four invariants every shader must satisfy at p=0.5, applied via a describe loop over ALL_SHADERS so any new transition added to the registry automatically picks up the same coverage: 1. output ≠ from catches no-ops 2. output ≠ to catches premature completion 3. output is non-zero catches blank output 4. output is deterministic catches accidental non-determinism Uses two distinct uniform input colors (40000/30000/20000 vs 10000/10000/10000) so equality checks have distinct byte patterns to compare against. Even shaders that warp UVs (which would be no-ops on uniform input alone) produce mix16(from, to, 0.5) at every pixel, distinct from both inputs. 60 new tests (4 invariants × 15 shaders), all passing. Follow-up to plans/hdr-followups.md Chunk 9G. --- .../src/utils/shaderTransitions.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/engine/src/utils/shaderTransitions.test.ts b/packages/engine/src/utils/shaderTransitions.test.ts index bb5eaaa4..0d8ecc4b 100644 --- a/packages/engine/src/utils/shaderTransitions.test.ts +++ b/packages/engine/src/utils/shaderTransitions.test.ts @@ -576,6 +576,70 @@ describe("all transitions smoke test", () => { } }); +// ── all transitions: midpoint regressions (p=0.5) ─────────────────────────── +// +// Endpoint smoke tests above lock down p=0 (≈from) and p=1 (≈to). They miss +// regressions where a shader becomes a no-op, prematurely completes, returns +// garbage, or accidentally introduces non-determinism — specifically at the +// midpoint where the transition is most visible to viewers. Four invariants +// every shader must satisfy at p=0.5: +// +// 1. Output ≠ from catches "shader is a no-op, returns input as-is" +// 2. Output ≠ to catches "shader prematurely completes at midpoint" +// 3. Output is non-zero catches "shader didn't write anything to the buf" +// 4. Output is deterministic — catches accidental Math.random / Date.now / +// uninitialized-state regressions that would surface as flaky CI. +// +// Two distinct uniform colors give buffer-equality checks distinct byte +// patterns to compare against. Even shaders that warp UVs (which would be +// no-ops on uniform input alone) produce mix16(from, to, 0.5) = (25000, 20000, +// 15000), distinct from both inputs at every pixel. +describe("all transitions: midpoint regressions (p=0.5)", () => { + for (const name of ALL_SHADERS) { + describe(name, () => { + const w = 8; + const h = 8; + const from = makeBuffer(w, h, 40000, 30000, 20000); + const to = makeBuffer(w, h, 10000, 10000, 10000); + const zeros = Buffer.alloc(w * h * 6); + + it("output ≠ from (not a no-op at midpoint)", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out = Buffer.alloc(w * h * 6); + fn?.(from, to, out, w, h, 0.5); + expect(out.equals(from)).toBe(false); + }); + + it("output ≠ to (not premature completion at midpoint)", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out = Buffer.alloc(w * h * 6); + fn?.(from, to, out, w, h, 0.5); + expect(out.equals(to)).toBe(false); + }); + + it("output is non-zero (shader actually wrote pixels)", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out = Buffer.alloc(w * h * 6); + fn?.(from, to, out, w, h, 0.5); + expect(out.equals(zeros)).toBe(false); + }); + + it("output is deterministic across repeated calls", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out1 = Buffer.alloc(w * h * 6); + const out2 = Buffer.alloc(w * h * 6); + fn?.(from, to, out1, w, h, 0.5); + fn?.(from, to, out2, w, h, 0.5); + expect(out2.equals(out1)).toBe(true); + }); + }); + } +}); + // ── hdrToLinear / linearToHdr roundtrip ──────────────────────────────────── describe("hdrToLinear / linearToHdr", () => {