@hyperframes/engine 0.7.36 → 0.7.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/services/browserManager.d.ts +1 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/drawElementService.d.ts +149 -0
- package/dist/services/drawElementService.d.ts.map +1 -0
- package/dist/services/drawElementService.js +957 -0
- package/dist/services/drawElementService.js.map +1 -0
- package/dist/services/frameCapture.d.ts +112 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +792 -31
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/screenshotService.d.ts +21 -4
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +81 -11
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/threeDProjection.d.ts +80 -0
- package/dist/services/threeDProjection.d.ts.map +1 -0
- package/dist/services/threeDProjection.js +980 -0
- package/dist/services/threeDProjection.js.map +1 -0
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +29 -22
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
// fallow-ignore-file complexity code-duplication
|
|
2
|
+
/**
|
|
3
|
+
* 3D-context projection for drawElementImage fast capture.
|
|
4
|
+
*
|
|
5
|
+
* drawElementImage cannot paint CSS 3D rendering contexts: backface-visibility
|
|
6
|
+
* is ignored (flip cards capture their mirrored backface, even at rest),
|
|
7
|
+
* siblings of the context drop out of the capture, and the context's
|
|
8
|
+
* background is lost (standalone repro: spikes/de-3d-probe.mjs; Chrome
|
|
9
|
+
* 148–151). There is no capture-side workaround — layoutsubtree children
|
|
10
|
+
* never paint to screen, so CDP screenshots are blind during fast capture,
|
|
11
|
+
* and drawElementImage only accepts immediate canvas children.
|
|
12
|
+
*
|
|
13
|
+
* Instead the composition is rewritten in-page before capture starts:
|
|
14
|
+
* each 3D context (a `perspective` container, or a bare `preserve-3d`
|
|
15
|
+
* subtree) is replaced by a WebGL canvas that projects the context's leaf
|
|
16
|
+
* quads with the same math Blink's compositor uses. Leaf content is
|
|
17
|
+
* rasterized once via SVG foreignObject (fonts and images inlined as data
|
|
18
|
+
* URLs — SVG-as-image loads no external resources), uploaded as textures,
|
|
19
|
+
* and re-projected every frame from the live computed `transform` matrices,
|
|
20
|
+
* so GSAP keeps driving the (hidden) original elements and the projection
|
|
21
|
+
* follows. Backface-visibility maps to GL face culling.
|
|
22
|
+
*
|
|
23
|
+
* The inserted canvases are picked up by instrumentAcceleratedCanvases
|
|
24
|
+
* (preserveDrawingBuffer forced) and composited under the DOM paint by
|
|
25
|
+
* captureDrawElementFrame — zero new capture-path code.
|
|
26
|
+
*
|
|
27
|
+
* Fidelity (spikes/de-3d-webgl-probe.mjs, flip card vs Blink 3D):
|
|
28
|
+
* 74–77 dB at rest, 46–58 dB mid-flip — edge-AA differences only.
|
|
29
|
+
*
|
|
30
|
+
* v1 limitations (documented, fall back to the 3D gate when hit):
|
|
31
|
+
* - layout offsets inside a context are measured once at init; contexts
|
|
32
|
+
* whose layout (not transform) animates will drift.
|
|
33
|
+
* - background-image inlining covers <img> and computed background-image
|
|
34
|
+
* url(...) values; other external references rasterize empty.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Run inside the page (page.evaluate) after page-ready and BEFORE
|
|
38
|
+
* injectDrawElementCanvas. Self-contained: no outer-scope references.
|
|
39
|
+
*/
|
|
40
|
+
export async function initThreeDProjectionInPage() {
|
|
41
|
+
const root = document.querySelector("[data-composition-id]");
|
|
42
|
+
if (!root)
|
|
43
|
+
return { ok: true, groups: 0, quads: 0 };
|
|
44
|
+
// ── discovery ──────────────────────────────────────────────────────────
|
|
45
|
+
// A computed matrix3d whose 3x3 part mixes z with x/y (or that carries a
|
|
46
|
+
// perspective row) renders WRONG under drawElementImage — the rotation is
|
|
47
|
+
// silently dropped (flat rotateX captures unsquashed; see
|
|
48
|
+
// spikes/de-3d-flat-test.mjs). translateZ-only matrix3d without
|
|
49
|
+
// perspective is a visual no-op and safe to leave alone.
|
|
50
|
+
const isThreeDMatrix = (transform) => {
|
|
51
|
+
if (!transform.startsWith("matrix3d"))
|
|
52
|
+
return false;
|
|
53
|
+
// parse the argument list only — the "3" in "matrix3d" is NOT a value
|
|
54
|
+
const n = (transform.slice(transform.indexOf("(") + 1).match(/-?[\d.e+-]+/g) ?? []).map(Number);
|
|
55
|
+
if (n.length !== 16)
|
|
56
|
+
return false;
|
|
57
|
+
const eps = 1e-6;
|
|
58
|
+
// column-major: rotation cross terms + perspective row
|
|
59
|
+
const cross = [n[2], n[6], n[8], n[9], n[3], n[7], n[11]];
|
|
60
|
+
if (cross.some((v) => Math.abs(v ?? 0) > eps))
|
|
61
|
+
return true;
|
|
62
|
+
return Math.abs((n[10] ?? 1) - 1) > eps; // z scale from X/Y rotation
|
|
63
|
+
};
|
|
64
|
+
const isContextRoot = (el) => {
|
|
65
|
+
const cs = getComputedStyle(el);
|
|
66
|
+
if (cs.perspective !== "none")
|
|
67
|
+
return true;
|
|
68
|
+
// bare preserve-3d without a perspective ancestor still forms a 3D
|
|
69
|
+
// rendering context (orthographic) and still breaks the capture
|
|
70
|
+
if (cs.transformStyle === "preserve-3d") {
|
|
71
|
+
const parent = el.parentElement;
|
|
72
|
+
if (!parent || getComputedStyle(parent).perspective === "none")
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
const groupRoots = [];
|
|
78
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
79
|
+
let node = walker.nextNode();
|
|
80
|
+
while (node) {
|
|
81
|
+
const el = node;
|
|
82
|
+
if (groupRoots.some((g) => g.contains(el))) {
|
|
83
|
+
node = walker.nextNode();
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (isContextRoot(el))
|
|
87
|
+
groupRoots.push(el);
|
|
88
|
+
node = walker.nextNode();
|
|
89
|
+
}
|
|
90
|
+
// Standalone 3D-tweened elements: no perspective container, no
|
|
91
|
+
// preserve-3d — just GSAP rotationX/rotationY (gen_os scene entrances).
|
|
92
|
+
// Two sources: elements already at a 3D matrix at t=0 (from()/fromTo()
|
|
93
|
+
// immediateRender), and the producer stub's record of every tween target
|
|
94
|
+
// whose vars carried 3D keys (catches to()-style tweens that are still
|
|
95
|
+
// flat at init).
|
|
96
|
+
const selfQuadEls = new Set();
|
|
97
|
+
const claimed = (el) => groupRoots.some((g) => g === el || g.contains(el) || el.contains(g));
|
|
98
|
+
{
|
|
99
|
+
const w3d = window;
|
|
100
|
+
for (const target of w3d.__hf3dTweenTargets ?? []) {
|
|
101
|
+
let els = [];
|
|
102
|
+
if (typeof target === "string") {
|
|
103
|
+
try {
|
|
104
|
+
els = Array.from(document.querySelectorAll(target));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
els = [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (target instanceof HTMLElement) {
|
|
111
|
+
els = [target];
|
|
112
|
+
}
|
|
113
|
+
else if (Array.isArray(target)) {
|
|
114
|
+
els = target.filter((t) => t instanceof HTMLElement);
|
|
115
|
+
}
|
|
116
|
+
for (const el of els) {
|
|
117
|
+
if (root.contains(el) && !claimed(el))
|
|
118
|
+
selfQuadEls.add(el);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const scan = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
122
|
+
let n2 = scan.nextNode();
|
|
123
|
+
while (n2) {
|
|
124
|
+
const el = n2;
|
|
125
|
+
if (!claimed(el) && !selfQuadEls.has(el) && isThreeDMatrix(getComputedStyle(el).transform)) {
|
|
126
|
+
// skip descendants of an already-recorded self quad
|
|
127
|
+
let covered = false;
|
|
128
|
+
for (const s of selfQuadEls) {
|
|
129
|
+
if (s.contains(el)) {
|
|
130
|
+
covered = true;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!covered)
|
|
135
|
+
selfQuadEls.add(el);
|
|
136
|
+
}
|
|
137
|
+
n2 = scan.nextNode();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (groupRoots.length === 0 && selfQuadEls.size === 0) {
|
|
141
|
+
return { ok: true, groups: 0, quads: 0 };
|
|
142
|
+
}
|
|
143
|
+
// Quad textures are rasterized ONCE — a GSAP-animated element inside a
|
|
144
|
+
// quad's subtree would freeze at its init state (gen_os scene entrances
|
|
145
|
+
// 3D-rotate whole scenes whose content keeps animating; measured: golf
|
|
146
|
+
// dropped from 46 dB to 24 dB without this guard). Resolve every tween
|
|
147
|
+
// target the stub saw; quads containing one fall back to the baseline
|
|
148
|
+
// route.
|
|
149
|
+
const animatedEls = new Set();
|
|
150
|
+
{
|
|
151
|
+
const wAll = window;
|
|
152
|
+
for (const target of wAll.__hfAllTweenTargets ?? []) {
|
|
153
|
+
if (typeof target === "string") {
|
|
154
|
+
try {
|
|
155
|
+
for (const el of Array.from(document.querySelectorAll(target))) {
|
|
156
|
+
if (el instanceof HTMLElement)
|
|
157
|
+
animatedEls.add(el);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* invalid selector */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (target instanceof HTMLElement) {
|
|
165
|
+
animatedEls.add(target);
|
|
166
|
+
}
|
|
167
|
+
else if (Array.isArray(target)) {
|
|
168
|
+
for (const t of target)
|
|
169
|
+
if (t instanceof HTMLElement)
|
|
170
|
+
animatedEls.add(t);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const hasAnimatedStrictDescendant = (el) => {
|
|
175
|
+
for (const a of animatedEls) {
|
|
176
|
+
if (a !== el && el.contains(a))
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
};
|
|
181
|
+
// ── shared rasterization helpers ───────────────────────────────────────
|
|
182
|
+
const toDataUrl = async (url) => {
|
|
183
|
+
const resp = await fetch(url);
|
|
184
|
+
const blob = await resp.blob();
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
const r = new FileReader();
|
|
187
|
+
r.onload = () => resolve(String(r.result));
|
|
188
|
+
r.onerror = () => reject(new Error(`FileReader failed for ${url}`));
|
|
189
|
+
r.readAsDataURL(blob);
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
// All @font-face rules with their src urls re-fetched as data URLs, so the
|
|
193
|
+
// SVG image (which loads no external resources) can still use the fonts.
|
|
194
|
+
const buildFontCss = async () => {
|
|
195
|
+
const rules = [];
|
|
196
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
197
|
+
let cssRules;
|
|
198
|
+
try {
|
|
199
|
+
cssRules = sheet.cssRules;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
continue; // cross-origin sheet
|
|
203
|
+
}
|
|
204
|
+
for (const rule of Array.from(cssRules)) {
|
|
205
|
+
if (!(rule instanceof CSSFontFaceRule))
|
|
206
|
+
continue;
|
|
207
|
+
let text = rule.cssText;
|
|
208
|
+
const urls = Array.from(text.matchAll(/url\((['"]?)([^'")]+)\1\)/g));
|
|
209
|
+
for (const m of urls) {
|
|
210
|
+
const src = m[2];
|
|
211
|
+
if (!src)
|
|
212
|
+
continue;
|
|
213
|
+
try {
|
|
214
|
+
const data = await toDataUrl(new URL(src, document.baseURI).href);
|
|
215
|
+
text = text.replace(m[0], `url(${data})`);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// unfetchable font src — leave as-is; SVG will skip it
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
rules.push(text);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return rules.join("\n");
|
|
225
|
+
};
|
|
226
|
+
const fontCss = await buildFontCss();
|
|
227
|
+
const URL_VALUE_PATTERN = /url\((['"]?)(?!data:)([^'")]+)\1\)/g;
|
|
228
|
+
// Copy computed styles onto the clone tree and inline external resources.
|
|
229
|
+
const inlineCloneStyles = async (orig, clone) => {
|
|
230
|
+
const origEls = [
|
|
231
|
+
orig,
|
|
232
|
+
...Array.from(orig.querySelectorAll("*")),
|
|
233
|
+
];
|
|
234
|
+
const cloneEls = [
|
|
235
|
+
clone,
|
|
236
|
+
...Array.from(clone.querySelectorAll("*")),
|
|
237
|
+
];
|
|
238
|
+
for (let i = 0; i < origEls.length; i++) {
|
|
239
|
+
const source = origEls[i];
|
|
240
|
+
const target = cloneEls[i];
|
|
241
|
+
if (!source || !target || !target.style)
|
|
242
|
+
continue;
|
|
243
|
+
const cs = getComputedStyle(source);
|
|
244
|
+
let cssText = "";
|
|
245
|
+
for (const prop of Array.from(cs)) {
|
|
246
|
+
cssText += `${prop}:${cs.getPropertyValue(prop)};`;
|
|
247
|
+
}
|
|
248
|
+
target.setAttribute("style", cssText);
|
|
249
|
+
target.removeAttribute("class");
|
|
250
|
+
const bg = cs.getPropertyValue("background-image");
|
|
251
|
+
if (bg && bg !== "none") {
|
|
252
|
+
let inlined = bg;
|
|
253
|
+
for (const m of Array.from(bg.matchAll(URL_VALUE_PATTERN))) {
|
|
254
|
+
const src = m[2];
|
|
255
|
+
if (!src)
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
inlined = inlined.replace(m[0], `url(${await toDataUrl(src)})`);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* leave */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
target.style.backgroundImage = inlined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const img of Array.from(clone.querySelectorAll("img"))) {
|
|
268
|
+
const src = img.getAttribute("src");
|
|
269
|
+
if (src && !src.startsWith("data:")) {
|
|
270
|
+
try {
|
|
271
|
+
img.setAttribute("src", await toDataUrl(new URL(src, document.baseURI).href));
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
img.removeAttribute("src");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
const rasterizeQuad = async (el, shell) => {
|
|
280
|
+
const w = Math.max(1, Math.ceil(el.offsetWidth));
|
|
281
|
+
const h = Math.max(1, Math.ceil(el.offsetHeight));
|
|
282
|
+
const clone = el.cloneNode(true);
|
|
283
|
+
await inlineCloneStyles(el, clone);
|
|
284
|
+
if (shell) {
|
|
285
|
+
// shell quads carry only the element's own paint — element children
|
|
286
|
+
// are projected as their own quads
|
|
287
|
+
for (const child of Array.from(clone.children)) {
|
|
288
|
+
child.style.display = "none";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// rasterize untransformed, fully visible, at the texture origin —
|
|
292
|
+
// live transform and opacity are applied at draw time per frame
|
|
293
|
+
clone.style.transform = "none";
|
|
294
|
+
clone.style.position = "static";
|
|
295
|
+
clone.style.top = "0";
|
|
296
|
+
clone.style.left = "0";
|
|
297
|
+
clone.style.margin = "0";
|
|
298
|
+
clone.style.visibility = "visible";
|
|
299
|
+
clone.style.opacity = "1";
|
|
300
|
+
clone.style.clipPath = "none";
|
|
301
|
+
clone.style.backfaceVisibility = "visible";
|
|
302
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
303
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
|
|
304
|
+
(fontCss ? `<style>${fontCss.replace(/&/g, "&").replace(/</g, "<")}</style>` : "") +
|
|
305
|
+
`<foreignObject width="100%" height="100%">${xml}</foreignObject></svg>`;
|
|
306
|
+
const img = new Image();
|
|
307
|
+
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
308
|
+
await img.decode();
|
|
309
|
+
return img;
|
|
310
|
+
};
|
|
311
|
+
// ── matrix helpers (CSS convention: x right, y down, z toward viewer) ──
|
|
312
|
+
const ident = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
313
|
+
const mul = (a, b) => {
|
|
314
|
+
const o = new Array(16).fill(0);
|
|
315
|
+
for (let r = 0; r < 4; r++)
|
|
316
|
+
for (let c = 0; c < 4; c++)
|
|
317
|
+
for (let k = 0; k < 4; k++)
|
|
318
|
+
o[r * 4 + c] = (o[r * 4 + c] ?? 0) + (a[r * 4 + k] ?? 0) * (b[k * 4 + c] ?? 0);
|
|
319
|
+
return o;
|
|
320
|
+
};
|
|
321
|
+
const translate = (x, y, z = 0) => {
|
|
322
|
+
const m = ident();
|
|
323
|
+
m[3] = x;
|
|
324
|
+
m[7] = y;
|
|
325
|
+
m[11] = z;
|
|
326
|
+
return m;
|
|
327
|
+
};
|
|
328
|
+
const perspectiveMat = (d) => {
|
|
329
|
+
const m = ident();
|
|
330
|
+
m[14] = -1 / d;
|
|
331
|
+
return m;
|
|
332
|
+
};
|
|
333
|
+
/** Parse computed `transform` ("none" | matrix(...) | matrix3d(...)) into row-major Mat4. */
|
|
334
|
+
const parseTransform = (value) => {
|
|
335
|
+
if (!value || value === "none")
|
|
336
|
+
return ident();
|
|
337
|
+
// parse the argument list only — the "3" in "matrix3d" is NOT a value
|
|
338
|
+
const nums = (value.slice(value.indexOf("(") + 1).match(/-?[\d.e+-]+/g) ?? []).map(Number);
|
|
339
|
+
const at = (i) => nums[i] ?? 0;
|
|
340
|
+
if (value.startsWith("matrix3d") && nums.length === 16) {
|
|
341
|
+
// CSS matrix3d is column-major; convert to row-major
|
|
342
|
+
// fallow-ignore-next-line code-duplication
|
|
343
|
+
return [
|
|
344
|
+
at(0),
|
|
345
|
+
at(4),
|
|
346
|
+
at(8),
|
|
347
|
+
at(12),
|
|
348
|
+
at(1),
|
|
349
|
+
at(5),
|
|
350
|
+
at(9),
|
|
351
|
+
at(13),
|
|
352
|
+
at(2),
|
|
353
|
+
at(6),
|
|
354
|
+
at(10),
|
|
355
|
+
at(14),
|
|
356
|
+
at(3),
|
|
357
|
+
at(7),
|
|
358
|
+
at(11),
|
|
359
|
+
at(15),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
if (nums.length === 6) {
|
|
363
|
+
return [at(0), at(2), 0, at(4), at(1), at(3), 0, at(5), 0, 0, 1, 0, 0, 0, 0, 1];
|
|
364
|
+
}
|
|
365
|
+
return ident();
|
|
366
|
+
};
|
|
367
|
+
const parseOrigin = (value) => {
|
|
368
|
+
const nums = (value.match(/-?[\d.]+/g) ?? []).map(Number);
|
|
369
|
+
return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0];
|
|
370
|
+
};
|
|
371
|
+
// WebGL1 requires transpose=false in uniformMatrix4fv — convert here.
|
|
372
|
+
const colMajor = (m) => {
|
|
373
|
+
const at = (i) => m[i] ?? 0;
|
|
374
|
+
// fallow-ignore-next-line code-duplication
|
|
375
|
+
return new Float32Array([
|
|
376
|
+
at(0),
|
|
377
|
+
at(4),
|
|
378
|
+
at(8),
|
|
379
|
+
at(12),
|
|
380
|
+
at(1),
|
|
381
|
+
at(5),
|
|
382
|
+
at(9),
|
|
383
|
+
at(13),
|
|
384
|
+
at(2),
|
|
385
|
+
at(6),
|
|
386
|
+
at(10),
|
|
387
|
+
at(14),
|
|
388
|
+
at(3),
|
|
389
|
+
at(7),
|
|
390
|
+
at(11),
|
|
391
|
+
at(15),
|
|
392
|
+
]);
|
|
393
|
+
};
|
|
394
|
+
const groups = [];
|
|
395
|
+
// Every element that needs its transform projected per-frame rather than
|
|
396
|
+
// baked into a texture: preserve-3d members, elements at a 3D matrix now,
|
|
397
|
+
// and the stub's 3D tween targets.
|
|
398
|
+
const threeDNodes = new Set(selfQuadEls);
|
|
399
|
+
{
|
|
400
|
+
const scan3 = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
401
|
+
let n3 = scan3.nextNode();
|
|
402
|
+
while (n3) {
|
|
403
|
+
const el = n3;
|
|
404
|
+
const cs = getComputedStyle(el);
|
|
405
|
+
if (cs.transformStyle === "preserve-3d" || isThreeDMatrix(cs.transform)) {
|
|
406
|
+
threeDNodes.add(el);
|
|
407
|
+
}
|
|
408
|
+
n3 = scan3.nextNode();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const subtreeHasThreeD = (el) => {
|
|
412
|
+
for (const node3 of threeDNodes) {
|
|
413
|
+
if (el !== node3 && el.contains(node3))
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
};
|
|
418
|
+
// Does the element paint anything of its own (background, border, or
|
|
419
|
+
// direct text), apart from its element children?
|
|
420
|
+
const hasOwnPaint = (el) => {
|
|
421
|
+
const cs = getComputedStyle(el);
|
|
422
|
+
if (cs.backgroundColor !== "rgba(0, 0, 0, 0)" && cs.backgroundColor !== "transparent") {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
if (cs.backgroundImage !== "none")
|
|
426
|
+
return true;
|
|
427
|
+
if (Number.parseFloat(cs.borderTopWidth) > 0 || Number.parseFloat(cs.borderLeftWidth) > 0) {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
for (const child of Array.from(el.childNodes)) {
|
|
431
|
+
if (child.nodeType === Node.TEXT_NODE && (child.textContent ?? "").trim() !== "") {
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
436
|
+
};
|
|
437
|
+
const collectQuads = (parent, chain, out) => {
|
|
438
|
+
for (const child of Array.from(parent.children)) {
|
|
439
|
+
const cs = getComputedStyle(child);
|
|
440
|
+
if (cs.display === "none")
|
|
441
|
+
continue;
|
|
442
|
+
if (child instanceof HTMLCanvasElement)
|
|
443
|
+
continue;
|
|
444
|
+
// recurse when the child itself is a 3D scene member OR contains one —
|
|
445
|
+
// its descendants' transforms must be projected live, not baked into
|
|
446
|
+
// a texture at init state.
|
|
447
|
+
if (cs.transformStyle === "preserve-3d" || subtreeHasThreeD(child)) {
|
|
448
|
+
if (hasOwnPaint(child))
|
|
449
|
+
out.push({ el: child, chain: [...chain, child], shell: true });
|
|
450
|
+
collectQuads(child, [...chain, child], out);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
out.push({ el: child, chain: [...chain, child], shell: false });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const VS = "attribute vec3 p; attribute vec2 t; uniform mat4 m; varying vec2 v;" +
|
|
458
|
+
"void main(){ gl_Position = m * vec4(p,1.0); v = t; }";
|
|
459
|
+
// textures are premultiplied — scaling all four channels applies opacity
|
|
460
|
+
const FS = "precision mediump float; uniform sampler2D s; uniform float a; varying vec2 v;" +
|
|
461
|
+
"void main(){ gl_FragColor = texture2D(s, v) * a; }";
|
|
462
|
+
// Context groups project their descendants; standalone 3D-tweened
|
|
463
|
+
// elements project themselves as a single quad.
|
|
464
|
+
const groupSpecs = [
|
|
465
|
+
...groupRoots.map((g) => ({ g, self: false })),
|
|
466
|
+
...Array.from(selfQuadEls).map((g) => ({ g, self: true })),
|
|
467
|
+
];
|
|
468
|
+
for (const { g, self } of groupSpecs) {
|
|
469
|
+
const quadDescs = [];
|
|
470
|
+
if (self) {
|
|
471
|
+
quadDescs.push({ el: g, chain: [], shell: false });
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
collectQuads(g, [], quadDescs);
|
|
475
|
+
}
|
|
476
|
+
if (quadDescs.length === 0)
|
|
477
|
+
continue;
|
|
478
|
+
// Measure untransformed layout geometry once: zero out every transform in
|
|
479
|
+
// the group, force layout, record offsets/sizes, restore. gen_os comps
|
|
480
|
+
// animate transform/opacity only, so these stay valid for the render.
|
|
481
|
+
const allChainEls = Array.from(new Set([g, ...quadDescs.flatMap((q) => q.chain)]));
|
|
482
|
+
const savedTransforms = allChainEls.map((el) => el.style.transform);
|
|
483
|
+
for (const el of allChainEls)
|
|
484
|
+
el.style.transform = "none";
|
|
485
|
+
void g.offsetWidth; // force layout
|
|
486
|
+
const geoOffsets = new Map();
|
|
487
|
+
const geoSizes = new Map();
|
|
488
|
+
for (const el of allChainEls) {
|
|
489
|
+
const parent = el.parentElement === g ? g : el.parentElement;
|
|
490
|
+
const pRect = parent.getBoundingClientRect();
|
|
491
|
+
const r = el.getBoundingClientRect();
|
|
492
|
+
geoOffsets.set(el, [r.left - pRect.left, r.top - pRect.top]);
|
|
493
|
+
geoSizes.set(el, [el.offsetWidth, el.offsetHeight]);
|
|
494
|
+
}
|
|
495
|
+
for (let i = 0; i < allChainEls.length; i++) {
|
|
496
|
+
const el = allChainEls[i];
|
|
497
|
+
if (el)
|
|
498
|
+
el.style.transform = savedTransforms[i] ?? "";
|
|
499
|
+
}
|
|
500
|
+
// Degenerate 3D markup guard: gen_os flip cards are inline <span>s —
|
|
501
|
+
// transforms on non-replaced inline boxes are not transformable per
|
|
502
|
+
// spec, the boxes measure 0×0, and Blink renders the (centered, flexed)
|
|
503
|
+
// text as overflow of an empty box. A texture quad cannot reproduce
|
|
504
|
+
// that; route the whole render to the baseline path instead.
|
|
505
|
+
for (const desc of quadDescs) {
|
|
506
|
+
const leafSize = geoSizes.get(desc.el) ?? [0, 0];
|
|
507
|
+
const inlineInChain = [g, ...desc.chain].some((el) => getComputedStyle(el).display === "inline");
|
|
508
|
+
if (leafSize[0] < 1 || leafSize[1] < 1 || inlineInChain) {
|
|
509
|
+
return {
|
|
510
|
+
ok: false,
|
|
511
|
+
groups: 0,
|
|
512
|
+
quads: 0,
|
|
513
|
+
reason: "degenerate 3D geometry (zero-size or inline-box quad) — " +
|
|
514
|
+
"quad projection cannot reproduce Blink's lenient rendering",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// shell textures exclude element children, so only leaves can bake
|
|
518
|
+
// a stale animation state
|
|
519
|
+
if (!desc.shell && hasAnimatedStrictDescendant(desc.el)) {
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
groups: 0,
|
|
523
|
+
quads: 0,
|
|
524
|
+
reason: "3D quad contains GSAP-animated descendants — static texture " +
|
|
525
|
+
"would freeze them at init state",
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const gw = g.offsetWidth;
|
|
530
|
+
const gh = g.offsetHeight;
|
|
531
|
+
const pad = Math.ceil(Math.max(gw, gh) * 0.25);
|
|
532
|
+
const canvasW = gw + pad * 2;
|
|
533
|
+
const canvasH = gh + pad * 2;
|
|
534
|
+
// The canvas lives NEXT TO the group (in its offsetParent), positioned
|
|
535
|
+
// at the group's untransformed layout box. The group's own animated
|
|
536
|
+
// transform is composed into the projection matrices per frame instead
|
|
537
|
+
// of being inherited from the DOM — the group subtree is fully hidden.
|
|
538
|
+
const anchor = g.offsetParent ?? g.parentElement ?? root;
|
|
539
|
+
const canvas = document.createElement("canvas");
|
|
540
|
+
canvas.setAttribute("data-hf-3d", "");
|
|
541
|
+
canvas.width = canvasW;
|
|
542
|
+
canvas.height = canvasH;
|
|
543
|
+
canvas.style.cssText =
|
|
544
|
+
`position:absolute;left:${g.offsetLeft - pad}px;top:${g.offsetTop - pad}px;` +
|
|
545
|
+
`width:${canvasW}px;height:${canvasH}px;pointer-events:none;` +
|
|
546
|
+
`z-index:${getComputedStyle(g).zIndex === "auto" ? "0" : getComputedStyle(g).zIndex};`;
|
|
547
|
+
const gl = canvas.getContext("webgl", {
|
|
548
|
+
alpha: true,
|
|
549
|
+
antialias: true,
|
|
550
|
+
premultipliedAlpha: true,
|
|
551
|
+
// instrumentAcceleratedCanvases forces this too, but the module must
|
|
552
|
+
// not depend on instrumentation order: the composite drawImage happens
|
|
553
|
+
// after a paint yield, and a non-preserved buffer reads blank there.
|
|
554
|
+
preserveDrawingBuffer: true,
|
|
555
|
+
});
|
|
556
|
+
if (!gl)
|
|
557
|
+
return { ok: false, groups: 0, quads: 0, reason: "webgl unavailable" };
|
|
558
|
+
const sh = (type, src) => {
|
|
559
|
+
const s = gl.createShader(type);
|
|
560
|
+
if (!s)
|
|
561
|
+
throw new Error("createShader failed");
|
|
562
|
+
gl.shaderSource(s, src);
|
|
563
|
+
gl.compileShader(s);
|
|
564
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
565
|
+
throw new Error(String(gl.getShaderInfoLog(s)));
|
|
566
|
+
}
|
|
567
|
+
return s;
|
|
568
|
+
};
|
|
569
|
+
const prog = gl.createProgram();
|
|
570
|
+
if (!prog)
|
|
571
|
+
throw new Error("createProgram failed");
|
|
572
|
+
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
|
|
573
|
+
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
|
|
574
|
+
gl.linkProgram(prog);
|
|
575
|
+
gl.useProgram(prog);
|
|
576
|
+
gl.enable(gl.CULL_FACE);
|
|
577
|
+
gl.cullFace(gl.BACK);
|
|
578
|
+
// the y-flip in the NDC mapping reverses winding: local CCW arrives CW
|
|
579
|
+
gl.frontFace(gl.CW);
|
|
580
|
+
gl.enable(gl.BLEND);
|
|
581
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
582
|
+
const cs = getComputedStyle(g);
|
|
583
|
+
const perspective = cs.perspective === "none" ? 0 : Number.parseFloat(cs.perspective);
|
|
584
|
+
const pOrigin = parseOrigin(cs.perspectiveOrigin);
|
|
585
|
+
// Neutralize the 3D rendering context on the live (hidden) elements.
|
|
586
|
+
// clip-path alone is NOT enough: the context still exists in the layer
|
|
587
|
+
// tree and drawElementImage keeps dropping the group's earlier siblings
|
|
588
|
+
// and background (measured on the fast-capture-3d test comp: headline
|
|
589
|
+
// sibling missing with clip-path-only hiding). The projection math has
|
|
590
|
+
// already captured perspective above and composes per-element computed
|
|
591
|
+
// transforms itself, so the DOM context is no longer needed. GSAP only
|
|
592
|
+
// writes `transform`, so these stick.
|
|
593
|
+
g.style.perspective = "none";
|
|
594
|
+
// capture authored backface flags BEFORE neutralizing them — the quads
|
|
595
|
+
// below read these for GL culling
|
|
596
|
+
const backfaceHiddenByEl = new Map();
|
|
597
|
+
for (const desc of quadDescs) {
|
|
598
|
+
backfaceHiddenByEl.set(desc.el, getComputedStyle(desc.el).backfaceVisibility === "hidden");
|
|
599
|
+
}
|
|
600
|
+
for (const el of allChainEls) {
|
|
601
|
+
el.style.transformStyle = "flat";
|
|
602
|
+
// backface-visibility:hidden + a 3D matrix poisons the capture even
|
|
603
|
+
// on a flat element (the face is "facing away") — culling is the
|
|
604
|
+
// GL renderer's job now
|
|
605
|
+
el.style.backfaceVisibility = "visible";
|
|
606
|
+
}
|
|
607
|
+
const quads = [];
|
|
608
|
+
for (const desc of quadDescs) {
|
|
609
|
+
const img = await rasterizeQuad(desc.el, desc.shell);
|
|
610
|
+
const tex = gl.createTexture();
|
|
611
|
+
if (!tex)
|
|
612
|
+
throw new Error("createTexture failed");
|
|
613
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
614
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
615
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
|
616
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
617
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
618
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
619
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
620
|
+
const size = geoSizes.get(desc.el) ?? [desc.el.offsetWidth, desc.el.offsetHeight];
|
|
621
|
+
const [qw, qh] = size;
|
|
622
|
+
// quad in element-local coords, origin at the element's top-left, CCW
|
|
623
|
+
const verts = new Float32Array([
|
|
624
|
+
0,
|
|
625
|
+
0,
|
|
626
|
+
0,
|
|
627
|
+
0,
|
|
628
|
+
1,
|
|
629
|
+
qw,
|
|
630
|
+
0,
|
|
631
|
+
0,
|
|
632
|
+
1,
|
|
633
|
+
1,
|
|
634
|
+
qw,
|
|
635
|
+
qh,
|
|
636
|
+
0,
|
|
637
|
+
1,
|
|
638
|
+
0,
|
|
639
|
+
0,
|
|
640
|
+
0,
|
|
641
|
+
0,
|
|
642
|
+
0,
|
|
643
|
+
1,
|
|
644
|
+
qw,
|
|
645
|
+
qh,
|
|
646
|
+
0,
|
|
647
|
+
1,
|
|
648
|
+
0,
|
|
649
|
+
0,
|
|
650
|
+
qh,
|
|
651
|
+
0,
|
|
652
|
+
0,
|
|
653
|
+
0,
|
|
654
|
+
]);
|
|
655
|
+
const buf = gl.createBuffer();
|
|
656
|
+
if (!buf)
|
|
657
|
+
throw new Error("createBuffer failed");
|
|
658
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
659
|
+
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
|
|
660
|
+
quads.push({
|
|
661
|
+
el: desc.el,
|
|
662
|
+
chain: desc.chain,
|
|
663
|
+
offsets: desc.chain.map((el) => geoOffsets.get(el) ?? [0, 0]),
|
|
664
|
+
tex,
|
|
665
|
+
buf,
|
|
666
|
+
backfaceHidden: backfaceHiddenByEl.get(desc.el) ?? false,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
// Hide via clip-path, NOT visibility/opacity: GSAP autoAlpha tweens
|
|
670
|
+
// write inline visibility and opacity on these same elements every
|
|
671
|
+
// frame and would un-hide them. clip-path is never animated by the
|
|
672
|
+
// composition, paints nothing, and keeps layout + computed styles
|
|
673
|
+
// (transform/opacity/visibility) fully readable for the projection.
|
|
674
|
+
g.style.clipPath = "inset(100%)";
|
|
675
|
+
anchor.appendChild(canvas);
|
|
676
|
+
groups.push({
|
|
677
|
+
rootEl: g,
|
|
678
|
+
gl,
|
|
679
|
+
prog,
|
|
680
|
+
quads,
|
|
681
|
+
perspective,
|
|
682
|
+
perspOrigin: [pOrigin[0], pOrigin[1]],
|
|
683
|
+
pad,
|
|
684
|
+
canvasW,
|
|
685
|
+
canvasH,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// ── per-frame update ───────────────────────────────────────────────────
|
|
689
|
+
// Depth: raw z values blow past the |z| <= w clip volume (near/far-plane
|
|
690
|
+
// clipping collapses the quad to a sliver), so z is scaled into a safe
|
|
691
|
+
// range while keeping ordering for the depth test.
|
|
692
|
+
const Z_SCALE = 1 / 100000;
|
|
693
|
+
// Perspective-carrying transforms (GSAP transformPerspective / authored
|
|
694
|
+
// perspective()) poison drawElementImage even on clip-path-hidden
|
|
695
|
+
// elements: the group's EARLIER DOM siblings drop out of the capture
|
|
696
|
+
// (spikes/de-3d-flat-test.mjs; reproduced on the fast-capture-3d comp —
|
|
697
|
+
// headline missing while footer survived). Read the matrix for the
|
|
698
|
+
// projection, then strip it from the live element. GSAP rewrites its
|
|
699
|
+
// value on every seek so tweened elements stay fresh; static values are
|
|
700
|
+
// served from the cache once stripped.
|
|
701
|
+
const strippedTransforms = new WeakMap();
|
|
702
|
+
// Any 3D-ness in a live matrix poisons the capture: perspective rows drop
|
|
703
|
+
// earlier siblings, rotation cross terms render wrong, and combined with
|
|
704
|
+
// backface-visibility they reproduce the full bug. The projection only
|
|
705
|
+
// needs the COMPUTED value, so strip live 3D matrices after reading.
|
|
706
|
+
// GSAP rewrites tweened values on every seek (fresh next frame); static
|
|
707
|
+
// stylesheet values are served from the cache once stripped.
|
|
708
|
+
const isThreeDMat4 = (m) => {
|
|
709
|
+
const eps = 1e-9;
|
|
710
|
+
return (Math.abs(m[2] ?? 0) > eps ||
|
|
711
|
+
Math.abs(m[6] ?? 0) > eps ||
|
|
712
|
+
Math.abs(m[8] ?? 0) > eps ||
|
|
713
|
+
Math.abs(m[9] ?? 0) > eps ||
|
|
714
|
+
Math.abs((m[10] ?? 1) - 1) > eps ||
|
|
715
|
+
Math.abs(m[12] ?? 0) > eps ||
|
|
716
|
+
Math.abs(m[13] ?? 0) > eps ||
|
|
717
|
+
Math.abs(m[14] ?? 0) > eps ||
|
|
718
|
+
Math.abs((m[15] ?? 1) - 1) > eps);
|
|
719
|
+
};
|
|
720
|
+
const readTransform = (el) => {
|
|
721
|
+
const value = getComputedStyle(el).transform;
|
|
722
|
+
if (value === "none") {
|
|
723
|
+
return strippedTransforms.get(el) ?? ident();
|
|
724
|
+
}
|
|
725
|
+
const m = parseTransform(value);
|
|
726
|
+
if (isThreeDMat4(m)) {
|
|
727
|
+
strippedTransforms.set(el, m);
|
|
728
|
+
el.style.transform = "none";
|
|
729
|
+
}
|
|
730
|
+
return m;
|
|
731
|
+
};
|
|
732
|
+
const update = () => {
|
|
733
|
+
for (const grp of groups) {
|
|
734
|
+
const { gl, prog, pad, canvasW, canvasH } = grp;
|
|
735
|
+
gl.viewport(0, 0, canvasW, canvasH);
|
|
736
|
+
gl.clearColor(0, 0, 0, 0);
|
|
737
|
+
gl.clearDepth(1);
|
|
738
|
+
gl.enable(gl.DEPTH_TEST);
|
|
739
|
+
gl.depthFunc(gl.LEQUAL);
|
|
740
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
741
|
+
gl.useProgram(prog);
|
|
742
|
+
// canvas px → clip space, y flipped, z scaled into the clip volume
|
|
743
|
+
const ndc = [
|
|
744
|
+
2 / canvasW,
|
|
745
|
+
0,
|
|
746
|
+
0,
|
|
747
|
+
-1,
|
|
748
|
+
0,
|
|
749
|
+
-2 / canvasH,
|
|
750
|
+
0,
|
|
751
|
+
1,
|
|
752
|
+
0,
|
|
753
|
+
0,
|
|
754
|
+
Z_SCALE,
|
|
755
|
+
0,
|
|
756
|
+
0,
|
|
757
|
+
0,
|
|
758
|
+
0,
|
|
759
|
+
1,
|
|
760
|
+
];
|
|
761
|
+
// group border-box coords → canvas coords
|
|
762
|
+
const toCanvas = translate(pad, pad);
|
|
763
|
+
let view = mul(ndc, toCanvas);
|
|
764
|
+
// the group's OWN transform (the canvas sits at its untransformed
|
|
765
|
+
// layout box, outside the hidden subtree, so nothing is inherited)
|
|
766
|
+
{
|
|
767
|
+
const gCs = getComputedStyle(grp.rootEl);
|
|
768
|
+
const gT = readTransform(grp.rootEl);
|
|
769
|
+
const [gox, goy, goz] = parseOrigin(gCs.transformOrigin);
|
|
770
|
+
view = mul(view, mul(translate(gox, goy, goz), mul(gT, translate(-gox, -goy, -goz))));
|
|
771
|
+
}
|
|
772
|
+
// perspective applied around the group's perspective-origin
|
|
773
|
+
if (grp.perspective > 0) {
|
|
774
|
+
const [px, py] = grp.perspOrigin;
|
|
775
|
+
view = mul(view, mul(translate(px, py), mul(perspectiveMat(grp.perspective), translate(-px, -py))));
|
|
776
|
+
}
|
|
777
|
+
const mLoc = gl.getUniformLocation(prog, "m");
|
|
778
|
+
const aLoc = gl.getUniformLocation(prog, "a");
|
|
779
|
+
const pLoc = gl.getAttribLocation(prog, "p");
|
|
780
|
+
const tLoc = gl.getAttribLocation(prog, "t");
|
|
781
|
+
const gAlphaCs = getComputedStyle(grp.rootEl);
|
|
782
|
+
const groupAlpha = Number.parseFloat(gAlphaCs.opacity) || 0;
|
|
783
|
+
for (const quad of grp.quads) {
|
|
784
|
+
// skip invisible quads (autoAlpha visibility inherits down to here)
|
|
785
|
+
const quadCs = getComputedStyle(quad.el);
|
|
786
|
+
if (quadCs.visibility === "hidden" || quadCs.display === "none")
|
|
787
|
+
continue;
|
|
788
|
+
// compose transform chain + accumulated opacity: group child → quad
|
|
789
|
+
let m = view;
|
|
790
|
+
let alpha = groupAlpha;
|
|
791
|
+
for (let i = 0; i < quad.chain.length; i++) {
|
|
792
|
+
const el = quad.chain[i];
|
|
793
|
+
if (!el)
|
|
794
|
+
continue;
|
|
795
|
+
const [ox, oy] = quad.offsets[i] ?? [0, 0];
|
|
796
|
+
const elCs = getComputedStyle(el);
|
|
797
|
+
alpha *= Number.parseFloat(elCs.opacity) || 0;
|
|
798
|
+
const t = readTransform(el);
|
|
799
|
+
const [tox, toy, toz] = parseOrigin(elCs.transformOrigin);
|
|
800
|
+
m = mul(m, mul(translate(ox, oy), mul(translate(tox, toy, toz), mul(t, translate(-tox, -toy, -toz)))));
|
|
801
|
+
}
|
|
802
|
+
if (alpha <= 0.001)
|
|
803
|
+
continue;
|
|
804
|
+
gl.uniform1f(aLoc, Math.min(1, alpha));
|
|
805
|
+
if (quad.backfaceHidden)
|
|
806
|
+
gl.enable(gl.CULL_FACE);
|
|
807
|
+
else
|
|
808
|
+
gl.disable(gl.CULL_FACE);
|
|
809
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, quad.buf);
|
|
810
|
+
gl.enableVertexAttribArray(pLoc);
|
|
811
|
+
gl.vertexAttribPointer(pLoc, 3, gl.FLOAT, false, 20, 0);
|
|
812
|
+
gl.enableVertexAttribArray(tLoc);
|
|
813
|
+
gl.vertexAttribPointer(tLoc, 2, gl.FLOAT, false, 20, 12);
|
|
814
|
+
gl.bindTexture(gl.TEXTURE_2D, quad.tex);
|
|
815
|
+
gl.uniformMatrix4fv(mLoc, false, colMajor(m));
|
|
816
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
window.__hf3d = { update };
|
|
821
|
+
update();
|
|
822
|
+
return {
|
|
823
|
+
ok: true,
|
|
824
|
+
groups: groups.length,
|
|
825
|
+
quads: groups.reduce((n, grp) => n + grp.quads.length, 0),
|
|
826
|
+
selfQuads: selfQuadEls.size,
|
|
827
|
+
stubTargets: (window.__hf3dTweenTargets ?? [])
|
|
828
|
+
.length,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Initialize 3D projection on a fast-capture page. Returns the in-page
|
|
833
|
+
* result; on failure the caller should fall back to the baseline route
|
|
834
|
+
* (same contract as the video gate).
|
|
835
|
+
*/
|
|
836
|
+
export async function initThreeDProjection(page) {
|
|
837
|
+
try {
|
|
838
|
+
return await page.evaluate(initThreeDProjectionInPage);
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
return { ok: false, groups: 0, quads: 0, reason: e instanceof Error ? e.message : String(e) };
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Detect CSS effects that drawElementImage cannot reproduce faithfully, so the
|
|
846
|
+
* comp can fall back to screenshot capture instead of rendering damaged frames.
|
|
847
|
+
*
|
|
848
|
+
* - `backdrop-filter` (blur/etc.) samples the pixels BEHIND the element from
|
|
849
|
+
* the compositor backdrop. drawElementImage captures the element subtree in
|
|
850
|
+
* isolation with no backdrop, so the filtered region is wrong (measured
|
|
851
|
+
* 18–49 dB across the community eval). Fundamental single-element-capture
|
|
852
|
+
* limit, not a tunable bug.
|
|
853
|
+
* - `filter: blur()` / `filter: drop-shadow()` render differently through the
|
|
854
|
+
* paint-record path than the full compositor (Chromium inconsistency,
|
|
855
|
+
* drop-shadow-on-SVG especially; ~29 dB).
|
|
856
|
+
* - A WebGL context (custom GLSL shader, animated via GSAP uniforms with no
|
|
857
|
+
* rAF) freezes under seek-based capture: the accel-canvas drawImage
|
|
858
|
+
* composite captures whatever the GL last drew, which never advances per
|
|
859
|
+
* seek (~19 dB). The composite reliably handles 2d canvases (sentinel-paint
|
|
860
|
+
* refresh) but not GL that only redraws on its own loop — so any WebGL
|
|
861
|
+
* context is treated as a fallback signal here.
|
|
862
|
+
*
|
|
863
|
+
* Scans computed styles under the composition root + the accel-canvas registry.
|
|
864
|
+
* Returns the first matched effect name (for logging) or null.
|
|
865
|
+
*/
|
|
866
|
+
export async function detectCssEffectRisk(page) {
|
|
867
|
+
try {
|
|
868
|
+
// MUST NOT seek the timeline here. Driving `window.__hf.seek` to sample
|
|
869
|
+
// frames renders GSAP `.from()` / overlapping tweens out of forward order and
|
|
870
|
+
// permanently corrupts their lazily-cached start values (GSAP records them on
|
|
871
|
+
// first render). Because this runs BEFORE the gate decision, that corruption
|
|
872
|
+
// then shifts unrelated transformed elements for EVERY comp entering the
|
|
873
|
+
// drawElement branch — DE-routed and screenshot-fallback alike (measured on
|
|
874
|
+
// 1e5a1165: the Pong scene's scale/rotation tween lands wrong, 19.8 dB / 27
|
|
875
|
+
// damaged frames; the fix restored it to baseline parity, 43 dB / 0). So we
|
|
876
|
+
// detect the same effects seek-free, four ways:
|
|
877
|
+
return await page.evaluate(() => {
|
|
878
|
+
const root = document.querySelector("[data-composition-id]");
|
|
879
|
+
if (!root)
|
|
880
|
+
return null;
|
|
881
|
+
// (1) Computed styles at the current (init/t0) state — effects present in
|
|
882
|
+
// the base render.
|
|
883
|
+
const scanComputed = () => {
|
|
884
|
+
const els = [root, ...Array.from(root.querySelectorAll("*"))];
|
|
885
|
+
for (const el of els) {
|
|
886
|
+
const cs = getComputedStyle(el);
|
|
887
|
+
const bf = cs.backdropFilter || cs.webkitBackdropFilter || "";
|
|
888
|
+
if (bf && bf !== "none")
|
|
889
|
+
return "backdrop-filter";
|
|
890
|
+
const f = cs.filter || "";
|
|
891
|
+
if (f && f !== "none" && f.indexOf("blur(") !== -1)
|
|
892
|
+
return "filter:blur";
|
|
893
|
+
if (f && f !== "none" && f.indexOf("drop-shadow(") !== -1)
|
|
894
|
+
return "filter:drop-shadow";
|
|
895
|
+
const mbm = cs.mixBlendMode || "";
|
|
896
|
+
if (mbm && mbm !== "normal")
|
|
897
|
+
return "mix-blend-mode";
|
|
898
|
+
const an = cs.animationName || "";
|
|
899
|
+
const ad = cs.animationDuration || "0s";
|
|
900
|
+
if (an && an !== "none" && ad !== "0s" && parseFloat(ad) > 0)
|
|
901
|
+
return "css-animation";
|
|
902
|
+
}
|
|
903
|
+
return null;
|
|
904
|
+
};
|
|
905
|
+
// (2) Stylesheet rules — an effect applied later via a class swap won't be
|
|
906
|
+
// computed at t0, but the rule that declares it exists up front. This
|
|
907
|
+
// replaces the old per-frame scrub scan seek-free (conservative: a declared
|
|
908
|
+
// rule ⇒ gate, even if applied only mid-timeline).
|
|
909
|
+
const scanStyleSheets = () => {
|
|
910
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
911
|
+
let rules = null;
|
|
912
|
+
try {
|
|
913
|
+
rules = sheet.cssRules;
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
continue; // cross-origin sheet — unreadable, skip
|
|
917
|
+
}
|
|
918
|
+
for (const rule of Array.from(rules ?? [])) {
|
|
919
|
+
const txt = rule.cssText || "";
|
|
920
|
+
if (/backdrop-filter\s*:\s*(?!none)/i.test(txt))
|
|
921
|
+
return "backdrop-filter";
|
|
922
|
+
if (/(?:^|[^-])filter\s*:[^;{}]*blur\(/i.test(txt))
|
|
923
|
+
return "filter:blur";
|
|
924
|
+
if (/(?:^|[^-])filter\s*:[^;{}]*drop-shadow\(/i.test(txt)) {
|
|
925
|
+
return "filter:drop-shadow";
|
|
926
|
+
}
|
|
927
|
+
if (/mix-blend-mode\s*:\s*(?!normal)/i.test(txt))
|
|
928
|
+
return "mix-blend-mode";
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return null;
|
|
932
|
+
};
|
|
933
|
+
// (3) GSAP tween vars — effects animated by GSAP writing inline
|
|
934
|
+
// filter / backdropFilter / mixBlendMode (never present in a stylesheet).
|
|
935
|
+
const scanTweenVars = () => {
|
|
936
|
+
const walk = (tl) => {
|
|
937
|
+
if (typeof tl.getChildren !== "function")
|
|
938
|
+
return null;
|
|
939
|
+
for (const c of tl.getChildren(false, true, true)) {
|
|
940
|
+
const sub = walk(c);
|
|
941
|
+
if (sub)
|
|
942
|
+
return sub;
|
|
943
|
+
const vars = c.vars || {};
|
|
944
|
+
for (const k of Object.keys(vars)) {
|
|
945
|
+
const kl = k.toLowerCase();
|
|
946
|
+
if (kl.includes("blend"))
|
|
947
|
+
return "mix-blend-mode";
|
|
948
|
+
if (kl.includes("backdrop"))
|
|
949
|
+
return "backdrop-filter";
|
|
950
|
+
if (kl === "filter" && /blur\(/i.test(String(vars[k] ?? "")))
|
|
951
|
+
return "filter:blur";
|
|
952
|
+
if (kl === "filter" && /drop-shadow\(/i.test(String(vars[k] ?? ""))) {
|
|
953
|
+
return "filter:drop-shadow";
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return null;
|
|
958
|
+
};
|
|
959
|
+
const tls = window.__timelines || {};
|
|
960
|
+
for (const tl of Object.values(tls)) {
|
|
961
|
+
const r = walk(tl);
|
|
962
|
+
if (r)
|
|
963
|
+
return r;
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
};
|
|
967
|
+
// (4) WebGL context — seek-invariant, recorded at context creation.
|
|
968
|
+
const scanWebgl = () => {
|
|
969
|
+
const aw = window;
|
|
970
|
+
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
|
|
971
|
+
return accel.length > 0 ? "webgl-context" : null;
|
|
972
|
+
};
|
|
973
|
+
return scanComputed() || scanStyleSheets() || scanTweenVars() || scanWebgl();
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
//# sourceMappingURL=threeDProjection.js.map
|