mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 20:07:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user