@hyperframes/engine 0.7.37 → 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.
Files changed (33) hide show
  1. package/dist/config.d.ts +25 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +43 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/browserManager.d.ts +1 -1
  10. package/dist/services/browserManager.d.ts.map +1 -1
  11. package/dist/services/browserManager.js.map +1 -1
  12. package/dist/services/drawElementService.d.ts +149 -0
  13. package/dist/services/drawElementService.d.ts.map +1 -0
  14. package/dist/services/drawElementService.js +957 -0
  15. package/dist/services/drawElementService.js.map +1 -0
  16. package/dist/services/frameCapture.d.ts +112 -0
  17. package/dist/services/frameCapture.d.ts.map +1 -1
  18. package/dist/services/frameCapture.js +792 -31
  19. package/dist/services/frameCapture.js.map +1 -1
  20. package/dist/services/screenshotService.d.ts +21 -4
  21. package/dist/services/screenshotService.d.ts.map +1 -1
  22. package/dist/services/screenshotService.js +81 -11
  23. package/dist/services/screenshotService.js.map +1 -1
  24. package/dist/services/threeDProjection.d.ts +80 -0
  25. package/dist/services/threeDProjection.d.ts.map +1 -0
  26. package/dist/services/threeDProjection.js +980 -0
  27. package/dist/services/threeDProjection.js.map +1 -0
  28. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  29. package/dist/services/videoFrameInjector.js +29 -22
  30. package/dist/services/videoFrameInjector.js.map +1 -1
  31. package/dist/types.d.ts +28 -0
  32. package/dist/types.d.ts.map +1 -1
  33. 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, "&amp;").replace(/</g, "&lt;")}</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