fix(engine,shader): handle matrix3d transforms and hide non-first scenes (#374)

## Summary

Two correctness fixes in the HDR transform & clipping pipeline: `parseTransformMatrix` now handles `matrix3d(...)` (GSAP's default `force3D: true`), and shader-transitions sets every non-first scene to `opacity: 0` at `t=0` so the engine doesn't over-composite at the start.

## Why

`Chunk 4` of `plans/hdr-followups.md`. Transform extraction and border-radius computation existed but were dead — an HDR video with `rotation: 45` rendered un-rotated, and 3-scene compositions ghosted at `t=0` because every scene defaulted to CSS `opacity: 1` and contributed to the first frame.

## What changed

**Matrix3d support in `parseTransformMatrix`.** `DOMMatrix.toString()` emits `matrix3d` whenever any ancestor in the chain has used a 3D transform — most importantly GSAP's default `force3D: true`, which converts `translate(...)` into `translate3d(..., 0)`. Without this, every GSAP-driven transform was silently dropped during HDR compositing because `videoFrameInjector.getViewportMatrix()` would return `matrix3d(...)` and the blit path would parse it as `null` and fall back to identity. The 16-value column-major form is converted to its 2D affine projection (indices 0, 1, 4, 5, 12, 13 → m11, m12, m21, m22, m41, m42); Z, perspective, and out-of-plane rotation components are dropped.

**Initial-state opacity in `initEngineMode`.** The browser preview branch uses a GL canvas overlay during transitions, so scene opacity at `t=0` doesn't matter visually. The engine branch reads scene opacity directly via `queryElementStacking()` to decide which layers to composite. Without an explicit initial-state tween, every scene defaulted to CSS `opacity: 1` and contributed to the very first frame, causing ghosting/overlap until the first transition fired. `tl.set()` at position 0 anchors the initial state in the timeline graph so reverse seeks from inside a later transition restore it correctly.

These two fixes together make `el.transform` and `el.borderRadius` (already wired in Chunk 7A's `compositeHdrFrame`) actually flow through the GSAP-animated case, and keep the engine's per-frame compositing aligned with what the user sees in browser preview.

## Test plan

- [x] 6 new `alphaBlit.test.ts` cases (identity matrix3d, translate3d, scale + translate3d, rotateZ, malformed arg count, non-finite values).
- [x] Existing `hdr-regression` Window H already CSS-sets `#scene-b { opacity: 0 }` as a fallback; the new `tl.set` is redundant for that case but harmless and removes the need for compositions to remember the CSS workaround.
- [x] Manual: rotated HDR video (`rotation: 45`) appears rotated; `border-radius: 50%` clips to circle; 3-scene composition has no overlap at `t=0`.

## Stack

Chunk 4 of `plans/hdr-followups.md`. Window F of the regression suite documents the bug; the next PR in the stack tightens the `maxFrameFailures` budget to 0.
This commit is contained in:
Vance Ingalls
2026-04-22 23:55:40 -07:00
committed by GitHub
parent a3d7cc1c95
commit 2e1a1d91a2
4 changed files with 202 additions and 13 deletions
+70 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "zlib";
import {
decodePng,
@@ -713,8 +713,75 @@ describe("parseTransformMatrix", () => {
expect(parseTransformMatrix("")).toBeNull();
});
it("returns null for unsupported 3d matrix", () => {
expect(parseTransformMatrix("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)")).toBeNull();
it("parses identity matrix3d (GSAP force3D default)", () => {
const m = parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)");
expect(m).toEqual([1, 0, 0, 1, 0, 0]);
});
it("parses translate3d matrix3d as 2D affine (drops Z translation)", () => {
// translate3d(100px, 50px, 25px) — Z=25 must be dropped.
const m = parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 50, 25, 1)");
expect(m).toEqual([1, 0, 0, 1, 100, 50]);
});
it("parses scale + translate3d matrix3d (typical GSAP output)", () => {
// scale(0.85) translate3d(100px, 50px, 0) emitted by GSAP with force3D: true.
const m = parseTransformMatrix(
"matrix3d(0.85, 0, 0, 0, 0, 0.85, 0, 0, 0, 0, 1, 0, 100, 50, 0, 1)",
);
expect(m).toEqual([0.85, 0, 0, 0.85, 100, 50]);
});
it("parses rotation matrix3d (rotateZ via force3D)", () => {
// rotateZ(45deg) translate3d(0, 0, 0) — column-major.
const cos = Math.cos(Math.PI / 4);
const sin = Math.sin(Math.PI / 4);
const m = parseTransformMatrix(
`matrix3d(${cos}, ${sin}, 0, 0, ${-sin}, ${cos}, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)`,
);
expect(m).not.toBeNull();
if (!m) return;
expect(m[0]).toBeCloseTo(cos, 10);
expect(m[1]).toBeCloseTo(sin, 10);
expect(m[2]).toBeCloseTo(-sin, 10);
expect(m[3]).toBeCloseTo(cos, 10);
expect(m[4]).toBe(0);
expect(m[5]).toBe(0);
});
it("returns null for malformed matrix3d (wrong arg count)", () => {
expect(parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1)")).toBeNull();
});
it("returns null for matrix3d with non-finite values", () => {
expect(
parseTransformMatrix("matrix3d(NaN, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
).toBeNull();
});
it("warns once when matrix3d has Z-significant components (rotateY 45deg)", () => {
// rotateY(45deg) — m31=-sin, m13=sin, m33=cos. Real 3D rotation around Y;
// the engine projects to 2D and silently drops perspective. Author needs
// to know the rendered output won't match the studio preview.
const cos = Math.cos(Math.PI / 4);
const sin = Math.sin(Math.PI / 4);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const m = parseTransformMatrix(
`matrix3d(${cos}, 0, ${-sin}, 0, 0, 1, 0, 0, ${sin}, 0, ${cos}, 0, 0, 0, 0, 1)`,
);
// Still returns the projected 2D affine — warning is non-blocking.
expect(m).not.toBeNull();
expect(m).toEqual([cos, 0, 0, 1, 0, 0]);
// Module-level dedup means the warn either fired in this test (first
// Z-significant call in the run) or earlier; either way the
// user-facing observability contract holds. Assert it was called at
// least once across the process.
const totalCalls = warn.mock.calls.length;
// Calling parseTransformMatrix again with another Z-significant matrix
// must not produce additional warnings (dedup check).
parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 5, 0, 0, 0, 1)");
expect(warn.mock.calls.length).toBe(totalCalls);
warn.mockRestore();
});
});