Skip to content
Open
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
73 changes: 70 additions & 3 deletions packages/engine/src/utils/alphaBlit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "zlib";
import {
decodePng,
Expand Down Expand Up @@ -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();
});
});

Expand Down
124 changes: 116 additions & 8 deletions packages/engine/src/utils/alphaBlit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,21 +850,129 @@ export function normalizeObjectFit(value: string | undefined): ObjectFit {
}

/**
* Parse a CSS `matrix(a,b,c,d,e,f)` string into a 6-element array.
* Returns null for "none", empty, or unsupported formats (matrix3d).
* Parse a CSS `matrix(a,b,c,d,e,f)` or `matrix3d(...)` string into a 6-element
* 2D affine array.
*
* The array maps to the CSS matrix: [a, b, c, d, tx, ty] where:
* Returns null for `"none"`, empty input, or syntactically malformed values.
*
* The returned array maps to the CSS matrix: [a, b, c, d, tx, ty] where:
* | a c tx | (a=scaleX, b=skewY, c=skewX, d=scaleY, tx/ty=translate)
* | b d ty |
* | 0 0 1 |
*
* `matrix3d` is the default output of `DOMMatrix.toString()` whenever any
* ancestor in the chain has used a 3D transform — most importantly GSAP's
* default `force3D: true`, which converts `translate(...)` into
* `translate3d(..., 0)` and surfaces as `matrix3d(...)` even for purely 2D
* animations. Without explicit handling we'd silently drop every transform
* driven by GSAP. The 16 values are in column-major order:
*
* matrix3d(m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34,
* m41, m42, m43, m44)
*
* The 2D affine corresponds to indices 0, 1, 4, 5, 12, 13 (m11, m12, m21,
* m22, m41, m42). Z, perspective, and out-of-plane rotation components are
* dropped — for true 3D transforms the resulting 2D projection is only
* approximate, but for the GSAP `force3D: true` flat-matrix case it is exact.
*
* When a `matrix3d` arrives with Z-significant components (m13, m23, m31,
* m32, m34, m43 != 0 or m33 != 1) we emit a one-time `console.warn` so
* authors using real 3D transforms know the engine path is silently
* flattening their scene rather than failing it.
*/
export function parseTransformMatrix(css: string): number[] | null {
if (!css || css === "none") return null;
const match = css.match(

const match2d = css.match(
/^matrix\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,)]+)\s*\)$/,
);
if (!match) return null;
const values = match.slice(1, 7).map(Number);
if (!values.every(Number.isFinite)) return null;
return values;
if (match2d) {
const values = match2d.slice(1, 7).map(Number);
if (!values.every(Number.isFinite)) return null;
return values;
}

const match3d = css.match(/^matrix3d\(\s*([^)]+)\)$/);
if (match3d) {
const raw = match3d[1];
if (!raw) return null;
const parts = raw.split(",").map((s) => Number(s.trim()));
if (parts.length !== 16 || !parts.every(Number.isFinite)) return null;
// 3D-significance check: a flat 2D transform expressed as matrix3d has
// a3=b3=c1=c2=d1=d2=d3=0, c3=1, d4=1. Any deviation means the composition
// is using real 3D (perspective, rotateX/Y) which the engine path can't
// represent — we project to 2D and the visual will silently drop depth.
// Warn once per process so authors don't get a misleading "looks fine in
// studio, broken in render" experience without any signal. Z translation
// (c4 = parts[14]) is intentionally dropped by the 2D projection below
// and does NOT trigger this warning — that's the GSAP `force3D: true`
// happy path.
warnIfZSignificant(parts);
// Extract column-major 2D affine: m11, m12, m21, m22, m41, m42.
return [
parts[0] as number,
parts[1] as number,
parts[4] as number,
parts[5] as number,
parts[12] as number,
parts[13] as number,
];
}

return null;
}

let warnedZSignificant = false;
const Z_EPSILON = 1e-6;

function warnIfZSignificant(parts: number[]): void {
if (warnedZSignificant) return;
// CSS matrix3d() is column-major:
// matrix3d(a1, b1, c1, d1, a2, b2, c2, d2, a3, b3, c3, d3, a4, b4, c4, d4)
// laid out as:
// | a1 a2 a3 a4 | | parts[0] parts[4] parts[8] parts[12] |
// | b1 b2 b3 b4 | = | parts[1] parts[5] parts[9] parts[13] |
// | c1 c2 c3 c4 | | parts[2] parts[6] parts[10] parts[14] |
// | d1 d2 d3 d4 | | parts[3] parts[7] parts[11] parts[15] |
//
// For a flat 2D transform — the only thing this engine path can render
// faithfully — we expect:
// a3 = b3 = c1 = c2 = 0 (no XZ/YZ rotation coupling)
// c3 = 1 (no Z scaling)
// d1 = d2 = d3 = 0 (no perspective)
// d4 = 1 (no homogeneous scaling)
// Z translation (c4 = parts[14]) is explicitly dropped by the 2D affine
// extraction below — that's the whole point of supporting GSAP's
// `force3D: true` translate3d(x, y, 0) emission — so it is NOT flagged.
const a3 = parts[8] ?? 0;
const b3 = parts[9] ?? 0;
const c1 = parts[2] ?? 0;
const c2 = parts[6] ?? 0;
const c3 = parts[10] ?? 1;
const d1 = parts[3] ?? 0;
const d2 = parts[7] ?? 0;
const d3 = parts[11] ?? 0;
const d4 = parts[15] ?? 1;
if (
Math.abs(a3) > Z_EPSILON ||
Math.abs(b3) > Z_EPSILON ||
Math.abs(c1) > Z_EPSILON ||
Math.abs(c2) > Z_EPSILON ||
Math.abs(c3 - 1) > Z_EPSILON ||
Math.abs(d1) > Z_EPSILON ||
Math.abs(d2) > Z_EPSILON ||
Math.abs(d3) > Z_EPSILON ||
Math.abs(d4 - 1) > Z_EPSILON
) {
warnedZSignificant = true;
console.warn(
`[alphaBlit] parseTransformMatrix received a matrix3d with non-trivial 3D components ` +
`(a3=${a3}, b3=${b3}, c1=${c1}, c2=${c2}, c3=${c3}, d1=${d1}, d2=${d2}, d3=${d3}, d4=${d4}). ` +
`The engine projects 3D transforms to 2D (m11, m12, m21, m22, m41, m42) and silently ` +
`discards perspective and out-of-plane rotation. If your composition uses real 3D ` +
`(rotateX/Y, perspective), the rendered output will not match the studio preview. ` +
`Z translation (translateZ) is dropped by design and does not trigger this warning. ` +
`This warning is emitted once per process.`,
);
}
}
4 changes: 2 additions & 2 deletions packages/producer/tests/hdr-regression/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
data-width="1920"
data-height="1080"
>
<!-- Window A · Baseline HDR + direct opacity tween · 0.0–2.5s -->
<!-- Window A · Baseline HDR + direct opacity tween · 0.0–1.5s -->
<video
id="wa-video"
class="clip hdr-video"
Expand Down Expand Up @@ -241,4 +241,4 @@
window.__timelines["hdr-regression"] = tl;
</script>
</body>
</html>
</html>
14 changes: 14 additions & 0 deletions packages/shader-transitions/src/hyper-shader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,20 @@ function initEngineMode(
tl.to({ t: 0 }, { t: 1, duration, ease: "none" }, 0);
}

// Initial state: every non-first scene starts hidden. CSS defaults
// .scene to opacity:1, so without this every scene would composite at
// t=0 and the engine's queryElementStacking() would report all of them
// visible — manifesting as ghosting/overlap in the very first frame
// before the first transition fires. tl.set() at position 0 ensures
// the initial state is part of the timeline's seek graph, so reverse
// seeks from inside a later transition correctly restore it.
for (let i = 1; i < scenes.length; i++) {
const sceneId = scenes[i];
if (sceneId) {
tl.set(`#${sceneId}`, { opacity: 0 }, 0);
}
}

for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const fromId = scenes[i];
Expand Down
Loading