@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.
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,957 @@
1
+ // fallow-ignore-file code-duplication complexity
2
+ /**
3
+ * DrawElement Capture Service
4
+ *
5
+ * `canvas.drawElementImage(element, x, y)` reads DOM paint records directly into
6
+ * a canvas, bypassing the full compositor pipeline. Requires the Chrome flag
7
+ * `--enable-features=CanvasDrawElement` (already added globally) and a
8
+ * `<canvas layoutsubtree>` wrapper around the composition root.
9
+ *
10
+ * Performance: ~46% faster than Page.captureScreenshot on local GPU.
11
+ * Alpha: pixel-perfect (PSNR=∞) on GPU. Falls back to screenshot in Docker
12
+ * (SwiftShader) when transparent output is requested — SwiftShader drops promoted
13
+ * compositor sub-layers on a transparent canvas destination (Chromium bug, filed
14
+ * Blink>Canvas, 2026-06-08).
15
+ */
16
+ /**
17
+ * Resolve which capture mode to use when `useDrawElement` is true.
18
+ *
19
+ * Cases that fall back to screenshot (see docs/fast-capture-limitations.md):
20
+ * - SwiftShader (software rasterizer, i.e. Docker/CI with no GPU): drawElement
21
+ * yields NO speedup here and is slightly slower. Its entire advantage is
22
+ * skipping the GPU→CPU screenshot readback IPC — on SwiftShader there is no
23
+ * GPU, so both paths block on identical software rasterization (measured
24
+ * parity: font-variant-numeric baseline 7822ms vs fast 7979ms, page-side
25
+ * draw/readback/encode all ~0ms). The drawElement path only adds a per-frame
26
+ * CDP round-trip on top of the same raster cost, and on a transparent
27
+ * destination additionally drops promoted sub-layers (Chromium bug
28
+ * 521434899). The speedup is real only on a hardware GPU (macOS 1.6×), so
29
+ * SwiftShader always routes to the platform baseline.
30
+ *
31
+ * The former <video> gate (a proxy for the word-by-word caption opacity pattern)
32
+ * was removed once Chrome 151 fixed crbug 521861819: video + nested-fade comps now
33
+ * render correctly on the drawElement path (verified PSNR=inf vs baseline). 151 is
34
+ * the pinned floor. See docs/fast-capture-limitations.md Lim 2.
35
+ */
36
+ export function resolveDrawElementCaptureMode(isSwiftShader, transparent) {
37
+ // `transparent` is retained for call-site clarity; SwiftShader blocks
38
+ // unconditionally now (no GPU egress to skip — parity at best), which
39
+ // subsumes the former transparent-only SwiftShader case.
40
+ void transparent;
41
+ if (isSwiftShader)
42
+ return "screenshot";
43
+ return "drawelement";
44
+ }
45
+ /**
46
+ * Instrument `HTMLCanvasElement.getContext` before any page script runs.
47
+ *
48
+ * Accelerated canvas contexts (webgl/webgl2/webgpu) present via compositor
49
+ * texture swap — the canvas element never repaints, so its paint record never
50
+ * invalidates and drawElementImage serves the FIRST frame's snapshot for the
51
+ * whole render (confirmed: typegpu comp frozen at t=0, 21 dB; 2d canvas is
52
+ * unaffected at 56 dB). The fix is to composite those canvases manually:
53
+ * this wrapper records them in `window.__hf_accel_canvases` so
54
+ * captureDrawElementFrame can hide them from paint records and drawImage
55
+ * their live content underneath the drawElementImage output.
56
+ *
57
+ * WebGL contexts additionally get `preserveDrawingBuffer: true` forced —
58
+ * without it the drawing buffer is cleared after each compositor present and
59
+ * drawImage(glCanvas) reads blank.
60
+ *
61
+ * Must be registered via page.evaluateOnNewDocument BEFORE navigation.
62
+ */
63
+ export function instrumentAcceleratedCanvases() {
64
+ const w = window;
65
+ w.__hf_accel_canvases = [];
66
+ w.__hf_canvas_2d = [];
67
+ const orig = HTMLCanvasElement.prototype.getContext;
68
+ // oxlint-disable-next-line no-explicit-any
69
+ HTMLCanvasElement.prototype.getContext = function (type, attrs) {
70
+ const isGl = type === "webgl" || type === "webgl2" || type === "experimental-webgl";
71
+ const isAccel = isGl || type === "webgpu";
72
+ const finalAttrs = isGl ? { ...attrs, preserveDrawingBuffer: true } : attrs;
73
+ // oxlint-disable-next-line no-explicit-any
74
+ const ctx = orig.call(this, type, finalAttrs);
75
+ if (ctx && isAccel) {
76
+ const list = w.__hf_accel_canvases ?? [];
77
+ if (!list.includes(this))
78
+ list.push(this);
79
+ w.__hf_accel_canvases = list;
80
+ }
81
+ // 2d canvases are tracked separately: their paint records DO refresh on
82
+ // macOS (the sentinel forces a paint each frame), but under BeginFrame
83
+ // pacing (Linux headless-shell) canvas bitmap changes never dirty the
84
+ // record and the capture freezes at the first frame — so the BeginFrame
85
+ // path composites these too (see captureDrawElementFrame).
86
+ if (ctx && type === "2d") {
87
+ const list2d = w.__hf_canvas_2d ?? [];
88
+ if (!list2d.includes(this))
89
+ list2d.push(this);
90
+ w.__hf_canvas_2d = list2d;
91
+ }
92
+ return ctx;
93
+ };
94
+ }
95
+ /**
96
+ * Detect whether the page is running on SwiftShader (software rasterizer).
97
+ *
98
+ * Returns true inside Docker headless-shell with --use-angle=swiftshader.
99
+ * Returns false on macOS / Linux with a real GPU.
100
+ * Call once after window.__hf is ready; cache result on session.
101
+ */
102
+ export async function detectSwiftShader(page) {
103
+ return page.evaluate(() => {
104
+ const canvas = document.createElement("canvas");
105
+ const gl = canvas.getContext("webgl") ||
106
+ canvas.getContext("experimental-webgl");
107
+ if (!gl)
108
+ return false;
109
+ const ext = gl.getExtension("WEBGL_debug_renderer_info");
110
+ if (!ext)
111
+ return false;
112
+ const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
113
+ return renderer.toLowerCase().includes("swiftshader");
114
+ });
115
+ }
116
+ /**
117
+ * Inject a `<canvas layoutsubtree>` around the composition root.
118
+ *
119
+ * The canvas must wrap `[data-composition-id]` for drawElementImage to read
120
+ * its paint records. Idempotent — skips injection if `__hf_de_canvas` exists.
121
+ * Must be called after window.__hf is ready (so the composition root is in the DOM).
122
+ */
123
+ export async function injectDrawElementCanvas(page, width, height) {
124
+ await page.evaluate(({ w, h }) => {
125
+ const root = document.querySelector("[data-composition-id]");
126
+ if (!root || document.getElementById("__hf_de_canvas"))
127
+ return;
128
+ // Record the root's base opacity now (timeline at 0, before any entrance/
129
+ // outro tween) so the per-frame capture can correct drawElementImage's stale
130
+ // opacity by the ratio current/base. (Transform is corrected unconditionally
131
+ // and needs no base; see captureDrawElementFrame.)
132
+ try {
133
+ window.__HF_ROOT_BASE_OPACITY__ =
134
+ parseFloat(getComputedStyle(root).opacity) || 1;
135
+ }
136
+ catch {
137
+ /* leave undefined → ratio defaults to 1 */
138
+ }
139
+ const parent = root.parentNode;
140
+ if (!parent)
141
+ throw new Error("drawElement: composition root has no parent node");
142
+ const canvas = document.createElement("canvas");
143
+ canvas.id = "__hf_de_canvas";
144
+ canvas.setAttribute("layoutsubtree", "");
145
+ canvas.width = w;
146
+ canvas.height = h;
147
+ canvas.style.cssText = "display:block;position:absolute;top:0;left:0;z-index:0";
148
+ parent.insertBefore(canvas, root);
149
+ canvas.appendChild(root);
150
+ // Invalidation sentinel: a canvas child OUTSIDE the captured root.
151
+ // Toggling its `left` each capture dirties the layoutsubtree so a paint
152
+ // (and a fresh snapshot) is guaranteed even for static frames — without
153
+ // ever appearing in drawElementImage(root) output.
154
+ const tick = document.createElement("div");
155
+ tick.id = "__hf_de_tick";
156
+ tick.style.cssText =
157
+ "position:absolute;left:0px;top:0;width:1px;height:1px;background:#000;opacity:0.01;pointer-events:none";
158
+ canvas.appendChild(tick);
159
+ }, { w: width, h: height });
160
+ }
161
+ /**
162
+ * Capture one frame via canvas.drawElementImage, synchronized to the canvas
163
+ * `paint` event.
164
+ *
165
+ * `drawElementImage` draws from a snapshot recorded at the paint event; called
166
+ * outside one it returns the PREVIOUS frame's snapshot (WICG html-in-canvas).
167
+ * Capturing unsynchronized therefore yields one-frame-stale content, or an
168
+ * `InvalidStateError: No cached paint record` when no paint has landed since
169
+ * the last DOM mutation (the intermittent macOS crash). The fix is the API's
170
+ * intended usage: force an invalidation, await the canvas `paint` event, and
171
+ * draw inside its handler — the snapshot is then the CURRENT frame. Measured
172
+ * cost of the paint wait is ~1.3 ms/frame; the encode dominates.
173
+ *
174
+ * Encoding MUST match what the downstream encoder expects:
175
+ * - "png" → `toDataURL("image/png")` — preserves alpha (transparent output).
176
+ * - "jpeg" → `toDataURL("image/jpeg", q)` — opaque output. The producer's
177
+ * streaming encoder pipes frames to ffmpeg as mjpeg; feeding it PNG bytes
178
+ * makes ffmpeg's jpeg decoder fail ("Can not process SOS before SOF").
179
+ *
180
+ * Alpha (png) is preserved correctly on GPU (PSNR=∞ vs captureScreenshot). Do
181
+ * NOT call in Docker with transparent output — use the screenshot fallback
182
+ * instead (see routing in frameCapture.ts initializeSession).
183
+ */
184
+ export async function captureDrawElementFrame(page, width, height, format = "jpeg", quality = 80,
185
+ // Await the canvas `paint` event before drawing. Required on hosts with a
186
+ // free-running compositor (macOS / screenshot-launched browsers) where the
187
+ // capture call is unsynchronized with painting. MUST be false under
188
+ // BeginFrame control (Linux headless-shell): there, paints happen only on
189
+ // the per-frame HeadlessExperimental.beginFrame already issued before this
190
+ // call (snapshot is fresh), and no further paint would ever arrive — the
191
+ // wait would burn the fallback timeout on every frame.
192
+ syncToPaintEvent = true) {
193
+ const dataUrl = await page.evaluate(({ w, h, fmt, q, sync, }) => {
194
+ const canvas = document.getElementById("__hf_de_canvas");
195
+ const root = document.querySelector("[data-composition-id]");
196
+ if (!canvas || !root)
197
+ throw new Error("drawElement canvas not initialized");
198
+ const ctx = canvas.getContext("2d");
199
+ if (!ctx)
200
+ throw new Error("drawElement: 2d context unavailable");
201
+ const aw = window;
202
+ // Re-project CSS 3D contexts for THIS frame (threeDProjection.ts) so
203
+ // their WebGL canvases are fresh before being drawImage-composited
204
+ // below. Must run before the paint wait for the same reason as the
205
+ // canvas hiding: the awaited paint should reflect the final state.
206
+ aw.__hf3d?.update();
207
+ const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
208
+ // Under BeginFrame pacing (sync=false) 2d canvas bitmaps also freeze in
209
+ // the paint records — composite them the same way. On paint-synced hosts
210
+ // (sync=true) the per-frame sentinel paint refreshes them natively.
211
+ if (!sync) {
212
+ for (const c of (aw.__hf_canvas_2d ?? []).filter((c2) => root.contains(c2))) {
213
+ if (!accel.includes(c))
214
+ accel.push(c);
215
+ }
216
+ // Stable z among composited canvases: document order.
217
+ accel.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
218
+ }
219
+ for (const c of accel) {
220
+ if (c.style.visibility !== "hidden")
221
+ c.style.visibility = "hidden";
222
+ }
223
+ return new Promise((resolveCapture, rejectCapture) => {
224
+ let settled = false;
225
+ const drawAndEncode = () => {
226
+ if (settled)
227
+ return;
228
+ settled = true;
229
+ try {
230
+ ctx.clearRect(0, 0, w, h);
231
+ // drawElementImage only paints the captured subtree. A background
232
+ // set on <body>/<html> (the common authoring pattern) lives OUTSIDE
233
+ // [data-composition-id], so without this fill those pixels stay
234
+ // transparent — and the jpeg encode below turns them black.
235
+ // Resolve the nearest non-transparent ancestor background-color and
236
+ // paint it first, matching what captureScreenshot composites.
237
+ // (Resolved per frame: compositions may set body background from JS.)
238
+ let bg = "";
239
+ for (let el = root.parentElement; el; el = el.parentElement) {
240
+ const c = getComputedStyle(el).backgroundColor;
241
+ if (c && c !== "transparent" && c !== "rgba(0, 0, 0, 0)") {
242
+ bg = c;
243
+ break;
244
+ }
245
+ }
246
+ // Opaque (jpeg) output with no author background anywhere:
247
+ // Page.captureScreenshot composites over the browser's default
248
+ // white viewport, but a cleared canvas encodes to BLACK in jpeg.
249
+ // Fill white for parity (transparent comps rendered to an opaque
250
+ // container — e.g. the webm-transparency test forced to mp4).
251
+ // png keeps true transparency.
252
+ if (!bg && fmt === "jpeg")
253
+ bg = "#fff";
254
+ if (bg) {
255
+ ctx.fillStyle = bg;
256
+ ctx.fillRect(0, 0, w, h);
257
+ }
258
+ // Composite live accelerated-canvas content UNDER the DOM paint.
259
+ // The canvases are visibility:hidden (transparent holes in the
260
+ // drawElementImage output), so DOM content above them (captions,
261
+ // overlays) still paints on top. Constraint: an opaque background
262
+ // on the composition root or an ancestor between root and the
263
+ // canvas would paint over this — backgrounds belong on <body> or
264
+ // inside the canvas for GPU comps.
265
+ const rootRect = root.getBoundingClientRect();
266
+ // fallow-ignore-next-line code-duplication
267
+ for (const c of accel) {
268
+ if (c.hasAttribute("data-hf-3d"))
269
+ continue;
270
+ const r = c.getBoundingClientRect();
271
+ try {
272
+ ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
273
+ }
274
+ catch {
275
+ // Zero-sized or not-yet-configured canvas — skip this frame.
276
+ }
277
+ }
278
+ // drawElementImage does not reflect post-paint changes to compositor-
279
+ // applied properties on the captured element ITSELF — opacity, transform,
280
+ // and filter on the root are applied by its parent at composite time and
281
+ // never enter the root's content snapshot (baked at the load-time/base
282
+ // value). Re-apply them to the 2D context, comparing against the base
283
+ // recorded at injection so a static root is a no-op (no double-apply / no
284
+ // regression) and an animated root is corrected.
285
+ // Two distinct behaviours, both measured:
286
+ // • opacity — the content snapshot DOES bake the root's load-time
287
+ // opacity, so correct by the ratio current/base (static ⇒ ratio 1 ⇒
288
+ // no-op; animated ⇒ corrected).
289
+ // • transform — the snapshot NEVER bakes the root's own transform (even
290
+ // a static transform renders unscaled), so apply the current matrix
291
+ // unconditionally about the transform-origin (no transform ⇒ no-op).
292
+ // filter is intentionally NOT corrected: the per-frame sentinel repaint
293
+ // already bakes it into the snapshot (correcting it double-applies).
294
+ const __rw = window;
295
+ let __appliedAlpha = false;
296
+ let __appliedTransform = false;
297
+ if (__rw.__HF_ROOT_PROPS__) {
298
+ try {
299
+ const rcs = getComputedStyle(root);
300
+ const baseOp = __rw.__HF_ROOT_BASE_OPACITY__ ?? 1;
301
+ const curOp = parseFloat(rcs.opacity);
302
+ if (baseOp > 0.001 && Number.isFinite(curOp)) {
303
+ const ratio = curOp / baseOp;
304
+ if (Math.abs(ratio - 1) > 0.002) {
305
+ ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
306
+ __appliedAlpha = true;
307
+ }
308
+ }
309
+ const curTransform = rcs.transform;
310
+ if (curTransform && curTransform !== "none") {
311
+ const m = new DOMMatrix(curTransform);
312
+ const origin = rcs.transformOrigin.split(" ");
313
+ const ox = parseFloat(origin[0] ?? "0") || 0;
314
+ const oy = parseFloat(origin[1] ?? "0") || 0;
315
+ ctx.translate(ox, oy);
316
+ ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f);
317
+ ctx.translate(-ox, -oy);
318
+ __appliedTransform = true;
319
+ }
320
+ }
321
+ catch {
322
+ /* leave context unchanged → uncorrected (no worse than before) */
323
+ }
324
+ }
325
+ ctx.drawElementImage(root, 0, 0);
326
+ if (__appliedAlpha)
327
+ ctx.globalAlpha = 1;
328
+ if (__appliedTransform)
329
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
330
+ // 3D-projection canvases (threeDProjection.ts) composite OVER the
331
+ // DOM paint: their content replaces clip-path-hidden foreground
332
+ // elements, and the under-pass above would bury them beneath the
333
+ // composition root's own background.
334
+ // fallow-ignore-next-line code-duplication
335
+ for (const c of accel) {
336
+ if (!c.hasAttribute("data-hf-3d"))
337
+ continue;
338
+ const r = c.getBoundingClientRect();
339
+ try {
340
+ ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
341
+ }
342
+ catch {
343
+ // Zero-sized canvas — skip this frame.
344
+ }
345
+ }
346
+ }
347
+ catch (e) {
348
+ rejectCapture(e instanceof Error ? e : new Error(String(e)));
349
+ return;
350
+ }
351
+ // Encode OUTSIDE the paint handler — heavy canvas work inside the
352
+ // paint event can stall the renderer.
353
+ setTimeout(() => {
354
+ try {
355
+ resolveCapture(fmt === "png"
356
+ ? canvas.toDataURL("image/png")
357
+ : canvas.toDataURL("image/jpeg", q / 100));
358
+ }
359
+ catch (e) {
360
+ rejectCapture(e instanceof Error ? e : new Error(String(e)));
361
+ }
362
+ }, 0);
363
+ };
364
+ if (!sync) {
365
+ // BeginFrame mode: the per-frame beginFrame already painted a fresh
366
+ // snapshot before this call — draw immediately.
367
+ drawAndEncode();
368
+ return;
369
+ }
370
+ const onPaint = () => {
371
+ canvas.removeEventListener("paint", onPaint);
372
+ drawAndEncode();
373
+ };
374
+ canvas.addEventListener("paint", onPaint);
375
+ // Force an invalidation so a paint is guaranteed even when this frame's
376
+ // seek produced no paint-level change (static scene, or transform-only
377
+ // GSAP updates that are compositor-side and never repaint). The sentinel
378
+ // is a 1x1 canvas child OUTSIDE the captured root (see
379
+ // injectDrawElementCanvas): toggling its background is a PAINT-level
380
+ // change (layout/transform toggles do NOT fire the paint event), so a
381
+ // paint + fresh snapshot follow promptly — without the sentinel ever
382
+ // appearing in drawElementImage(root) output.
383
+ const tick = document.getElementById("__hf_de_tick");
384
+ if (tick) {
385
+ tick.style.backgroundColor =
386
+ tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
387
+ }
388
+ // Safety net: if the paint event doesn't arrive (feature drift /
389
+ // throttled page), fall back to an unsynchronized draw after 250 ms —
390
+ // worst case one-frame-stale content rather than a hung render.
391
+ setTimeout(() => {
392
+ canvas.removeEventListener("paint", onPaint);
393
+ drawAndEncode();
394
+ }, 250);
395
+ });
396
+ }, { w: width, h: height, fmt: format, q: quality, sync: syncToPaintEvent });
397
+ const base64 = dataUrl.split(",")[1];
398
+ if (!base64)
399
+ throw new Error("drawElement: toDataURL returned no base64 payload");
400
+ return Buffer.from(base64, "base64");
401
+ }
402
+ const workerEncodeStates = new WeakMap();
403
+ // Pages that already have the `__hfFrameReady` binding installed. The binding
404
+ // survives navigation and cannot be cleanly removed, so its lifetime is
405
+ // tracked separately from WorkerEncodeState (which is recreated per session).
406
+ // Without this, a re-init after cleanup would call exposeFunction twice and
407
+ // throw "already exists".
408
+ const workerEncodeBoundPages = new WeakSet();
409
+ /**
410
+ * Initialize the in-page JPEG encode Worker for a session. Must be called
411
+ * after page navigation (post-`initializeSession`) and before any
412
+ * `produceDrawElementFrame` calls.
413
+ *
414
+ * Safe to call multiple times for the same page (e.g. session reuse after
415
+ * navigation): the exposeFunction binding survives navigation, but the
416
+ * in-page Worker is re-created. Pending promises from a prior navigation are
417
+ * rejected with a "session reused" error.
418
+ */
419
+ export async function initDrawElementWorkerEncode(page) {
420
+ const existing = workerEncodeStates.get(page);
421
+ if (existing) {
422
+ // Session reused after navigation — reject stale pending promises and
423
+ // reset the frame-id counter so ids track the new render's frame indices.
424
+ for (const entry of existing.pending.values()) {
425
+ entry.reject(new Error("drawElement worker encode: session reused, frame dropped"));
426
+ }
427
+ existing.pending.clear();
428
+ existing.nextId = 0;
429
+ }
430
+ else {
431
+ const state = { nextId: 0, pending: new Map() };
432
+ workerEncodeStates.set(page, state);
433
+ }
434
+ // Register the node-side callback ONCE per page. The exposeFunction binding
435
+ // survives navigation and cannot be re-added (throws "already exists"), so
436
+ // guard with workerEncodeBoundPages rather than the per-session state. The
437
+ // callback reads the CURRENT WorkerEncodeState live, so it works across
438
+ // re-inits where the state object is replaced.
439
+ if (!workerEncodeBoundPages.has(page)) {
440
+ workerEncodeBoundPages.add(page);
441
+ await page.exposeFunction("__hfFrameReady", (id, b64, error) => {
442
+ const s = workerEncodeStates.get(page);
443
+ if (!s)
444
+ return;
445
+ // id < 0 is a fatal worker signal (e.g. worker onerror): the worker is
446
+ // dead and no frame will ever come back — reject every in-flight frame
447
+ // so awaiters fail fast instead of hanging forever.
448
+ if (id < 0) {
449
+ for (const entry of s.pending.values()) {
450
+ entry.reject(new Error(`drawElement worker encode failed: ${error ?? "worker error"}`));
451
+ }
452
+ s.pending.clear();
453
+ return;
454
+ }
455
+ const entry = s.pending.get(id);
456
+ if (!entry)
457
+ return;
458
+ s.pending.delete(id);
459
+ if (error) {
460
+ entry.reject(new Error(`drawElement worker encode failed: ${error}`));
461
+ }
462
+ else if (!b64) {
463
+ // A success message with no payload would otherwise resolve a 0-byte
464
+ // Buffer and ffmpeg would write a corrupt/empty frame silently. Fail loud.
465
+ entry.reject(new Error(`drawElement worker encode returned empty frame (frame ${id})`));
466
+ }
467
+ else {
468
+ entry.resolve(Buffer.from(b64, "base64"));
469
+ }
470
+ });
471
+ }
472
+ // Inject (or re-create) the in-page Worker after each navigation.
473
+ await page.evaluate(() => {
474
+ const ew = window;
475
+ if (ew.__hfEncWorker) {
476
+ ew.__hfEncWorker.terminate();
477
+ ew.__hfEncWorker = undefined;
478
+ }
479
+ // Base64 is done INSIDE the worker (off the main thread) so it never
480
+ // competes with the produce phase; the worker posts a string the page
481
+ // relays to node. On any encode failure the worker posts an error for that
482
+ // frame's id so the node-side promise rejects instead of hanging.
483
+ const workerSrc = `
484
+ // Reuse one OffscreenCanvas across frames (dimensions are constant for a
485
+ // render) — a fresh canvas per frame churns ~w*h*4 bytes of backing store
486
+ // every frame and pressures GC on the encode hot path.
487
+ let oc = null, c = null;
488
+ self.onmessage = async (e) => {
489
+ const { bmp, id, w, h, q } = e.data;
490
+ try {
491
+ if (!oc || oc.width !== w || oc.height !== h) {
492
+ oc = new OffscreenCanvas(w, h);
493
+ c = oc.getContext('2d');
494
+ }
495
+ if (!c) throw new Error('OffscreenCanvas 2d context unavailable');
496
+ c.drawImage(bmp, 0, 0);
497
+ bmp.close();
498
+ const blob = await oc.convertToBlob({ type: 'image/jpeg', quality: q });
499
+ const ab = await blob.arrayBuffer();
500
+ const u = new Uint8Array(ab);
501
+ let s = ''; const CH = 0x8000;
502
+ for (let i = 0; i < u.length; i += CH)
503
+ s += String.fromCharCode.apply(null, u.subarray(i, i + CH));
504
+ self.postMessage({ id, b64: btoa(s) });
505
+ } catch (err) {
506
+ try { if (bmp) bmp.close(); } catch (_) {}
507
+ self.postMessage({ id, error: (err && err.message) || String(err) });
508
+ }
509
+ };
510
+ `;
511
+ const url = URL.createObjectURL(new Blob([workerSrc], { type: "text/javascript" }));
512
+ const worker = new Worker(url);
513
+ URL.revokeObjectURL(url); // only needed for Worker construction
514
+ ew.__hfEncWorker = worker;
515
+ worker.onmessage = (ev) => {
516
+ const d = ev.data;
517
+ ew.__hfFrameReady?.(d.id, d.b64 ?? "", d.error);
518
+ };
519
+ worker.onerror = (err) => {
520
+ // Fatal, not tied to a frame id — signal node (id = -1) to reject all
521
+ // in-flight frames so the pipeline fails fast instead of hanging.
522
+ ew.__hfFrameReady?.(-1, "", err.message || "worker fatal error");
523
+ };
524
+ });
525
+ }
526
+ /**
527
+ * Clean up the worker encode state for a session being closed. Rejects any
528
+ * pending frame promises and removes the WeakMap entry. Safe to call even if
529
+ * `initDrawElementWorkerEncode` was never called for this page.
530
+ */
531
+ export function cleanupDrawElementWorkerEncode(page) {
532
+ const state = workerEncodeStates.get(page);
533
+ if (!state)
534
+ return;
535
+ for (const entry of state.pending.values()) {
536
+ entry.reject(new Error("drawElement worker encode: session closed"));
537
+ }
538
+ state.pending.clear();
539
+ workerEncodeStates.delete(page);
540
+ }
541
+ /**
542
+ * Pipelined drawElement frame capture: produce phase only.
543
+ *
544
+ * Performs seek-prep, paint-wait, drawElementImage, compositing, and
545
+ * `createImageBitmap` on the main thread, then transfers the bitmap to the
546
+ * in-page encode worker. Returns as soon as the bitmap is transferred — the
547
+ * worker encodes asynchronously. The returned `encodeResult` resolves when
548
+ * the worker posts the encoded frame back to node.
549
+ *
550
+ * Call `initDrawElementWorkerEncode` once per page before using this function.
551
+ *
552
+ * JPEG only (png falls back to synchronous `captureDrawElementFrame`).
553
+ */
554
+ export async function produceDrawElementFrame(page, width, height, quality = 80, syncToPaintEvent = true) {
555
+ const state = workerEncodeStates.get(page);
556
+ if (!state) {
557
+ throw new Error("drawElement worker encode not initialized; call initDrawElementWorkerEncode first");
558
+ }
559
+ const frameId = ++state.nextId;
560
+ const encodeResult = new Promise((resolve, reject) => {
561
+ // Watchdog: worker.onerror (→ id=-1, reject-all) covers worker CRASHES, but
562
+ // a lost message (page navigation, OOM-killed worker with no ErrorEvent, a
563
+ // dropped postMessage) would never settle this promise — `drainPrev`'s
564
+ // `await encodeResult` would then hang the whole render to the protocol
565
+ // timeout. Bound it so the render fails with a clear error. Generous vs the
566
+ // ~10ms encode to avoid false positives on large frames.
567
+ const timer = setTimeout(() => {
568
+ if (state.pending.delete(frameId)) {
569
+ reject(new Error(`drawElement worker encode timed out (frame ${frameId})`));
570
+ }
571
+ }, 30_000);
572
+ state.pending.set(frameId, {
573
+ resolve: (b) => {
574
+ clearTimeout(timer);
575
+ resolve(b);
576
+ },
577
+ reject: (e) => {
578
+ clearTimeout(timer);
579
+ reject(e);
580
+ },
581
+ });
582
+ });
583
+ // Guard against an unhandled rejection if the caller never awaits this promise
584
+ // (the depth-2 pipeline loop orphans the just-produced frame's encode when an
585
+ // earlier frame's drain throws or the render aborts). The loop's own
586
+ // `await encodeResult` still observes rejections on its separate reaction
587
+ // chain; this only suppresses the no-awaiter case.
588
+ void encodeResult.catch(() => { });
589
+ // Do paint-wait + drawElement composite + createImageBitmap + postMessage.
590
+ // Resolves as soon as the bitmap is transferred (not when encode is done).
591
+ await page.evaluate(({ w, h, q, sync, fid }) => {
592
+ const canvas = document.getElementById("__hf_de_canvas");
593
+ const root = document.querySelector("[data-composition-id]");
594
+ if (!canvas || !root)
595
+ throw new Error("drawElement canvas not initialized");
596
+ const ctx = canvas.getContext("2d");
597
+ if (!ctx)
598
+ throw new Error("drawElement: 2d context unavailable");
599
+ const aw = window;
600
+ aw.__hf3d?.update();
601
+ const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
602
+ if (!sync) {
603
+ for (const c of (aw.__hf_canvas_2d ?? []).filter((c2) => root.contains(c2))) {
604
+ if (!accel.includes(c))
605
+ accel.push(c);
606
+ }
607
+ accel.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
608
+ }
609
+ for (const c of accel) {
610
+ if (c.style.visibility !== "hidden")
611
+ c.style.visibility = "hidden";
612
+ }
613
+ return new Promise((resolveCapture, rejectCapture) => {
614
+ let settled = false;
615
+ const drawAndKick = () => {
616
+ if (settled)
617
+ return;
618
+ settled = true;
619
+ try {
620
+ ctx.clearRect(0, 0, w, h);
621
+ let bg = "";
622
+ for (let el = root.parentElement; el; el = el.parentElement) {
623
+ const c = getComputedStyle(el).backgroundColor;
624
+ if (c && c !== "transparent" && c !== "rgba(0, 0, 0, 0)") {
625
+ bg = c;
626
+ break;
627
+ }
628
+ }
629
+ if (!bg)
630
+ bg = "#fff";
631
+ if (bg) {
632
+ ctx.fillStyle = bg;
633
+ ctx.fillRect(0, 0, w, h);
634
+ }
635
+ // fallow-ignore-next-line code-duplication
636
+ const rootRect = root.getBoundingClientRect();
637
+ for (const c of accel) {
638
+ if (c.hasAttribute("data-hf-3d"))
639
+ continue;
640
+ const r = c.getBoundingClientRect();
641
+ try {
642
+ ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
643
+ }
644
+ catch {
645
+ // skip
646
+ }
647
+ }
648
+ // Re-apply the captured root's compositor-applied opacity/transform —
649
+ // identical to the serial path (see drawAndEncode). drawElementImage does
650
+ // not reflect post-paint changes to these on the root itself; without this
651
+ // the worker path damages comps with an animated root opacity/transform
652
+ // (the serial path corrects it, so worker-on diverged from serial DE).
653
+ const __rw = window;
654
+ let __appliedAlpha = false;
655
+ let __appliedTransform = false;
656
+ if (__rw.__HF_ROOT_PROPS__) {
657
+ try {
658
+ const rcs = getComputedStyle(root);
659
+ const baseOp = __rw.__HF_ROOT_BASE_OPACITY__ ?? 1;
660
+ const curOp = parseFloat(rcs.opacity);
661
+ if (baseOp > 0.001 && Number.isFinite(curOp)) {
662
+ const ratio = curOp / baseOp;
663
+ if (Math.abs(ratio - 1) > 0.002) {
664
+ ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
665
+ __appliedAlpha = true;
666
+ }
667
+ }
668
+ const curTransform = rcs.transform;
669
+ if (curTransform && curTransform !== "none") {
670
+ const m = new DOMMatrix(curTransform);
671
+ const origin = rcs.transformOrigin.split(" ");
672
+ const ox = parseFloat(origin[0] ?? "0") || 0;
673
+ const oy = parseFloat(origin[1] ?? "0") || 0;
674
+ ctx.translate(ox, oy);
675
+ ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f);
676
+ ctx.translate(-ox, -oy);
677
+ __appliedTransform = true;
678
+ }
679
+ }
680
+ catch {
681
+ /* leave context unchanged → uncorrected (no worse than before) */
682
+ }
683
+ }
684
+ ctx.drawElementImage(root, 0, 0);
685
+ if (__appliedAlpha)
686
+ ctx.globalAlpha = 1;
687
+ if (__appliedTransform)
688
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
689
+ // fallow-ignore-next-line code-duplication
690
+ for (const c of accel) {
691
+ if (!c.hasAttribute("data-hf-3d"))
692
+ continue;
693
+ const r = c.getBoundingClientRect();
694
+ try {
695
+ ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
696
+ }
697
+ catch {
698
+ // skip
699
+ }
700
+ }
701
+ }
702
+ catch (e) {
703
+ rejectCapture(e instanceof Error ? e : new Error(String(e)));
704
+ return;
705
+ }
706
+ // Snapshot the canvas and hand off to the encode worker. createImageBitmap
707
+ // is async; resolveCapture only fires after the bitmap is transferred, so
708
+ // the canvas is safe to overwrite for the next frame once this evaluate's
709
+ // promise resolves.
710
+ createImageBitmap(canvas)
711
+ .then((bmp) => {
712
+ const ew = window;
713
+ if (!ew.__hfEncWorker) {
714
+ bmp.close(); // don't leak the GPU-backed ImageBitmap on this reject path
715
+ rejectCapture(new Error("drawElement: encode worker not initialized"));
716
+ return;
717
+ }
718
+ ew.__hfEncWorker.postMessage({ bmp, id: fid, w, h, q: q / 100 }, [bmp]);
719
+ resolveCapture();
720
+ })
721
+ .catch((e) => {
722
+ rejectCapture(e instanceof Error ? e : new Error(String(e)));
723
+ });
724
+ };
725
+ if (!sync) {
726
+ drawAndKick();
727
+ return;
728
+ }
729
+ const onPaint = () => {
730
+ canvas.removeEventListener("paint", onPaint);
731
+ drawAndKick();
732
+ };
733
+ canvas.addEventListener("paint", onPaint);
734
+ const tick = document.getElementById("__hf_de_tick");
735
+ if (tick) {
736
+ tick.style.backgroundColor =
737
+ tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
738
+ }
739
+ setTimeout(() => {
740
+ canvas.removeEventListener("paint", onPaint);
741
+ drawAndKick();
742
+ }, 250);
743
+ });
744
+ }, { w: width, h: height, q: quality, sync: syncToPaintEvent, fid: frameId });
745
+ return { encodeResult };
746
+ }
747
+ /**
748
+ * P6 prototype (HF_DE_BATCH): batch-produce N consecutive frames in ONE CDP
749
+ * round-trip. In-page loop per frame: `__hf.seek(t)` → paint-wait (tick toggle +
750
+ * canvas `paint` event) → drawElementImage composite → createImageBitmap →
751
+ * postMessage to the encode worker. Bitmaps are posted per-frame (encode starts
752
+ * immediately); only the CDP protocol round-trips are amortized N-fold.
753
+ * Micro-pipeline inside the batch: frame i+1's seek/paint-wait overlaps frame
754
+ * i's createImageBitmap (the canvas is only redrawn after i's bitmap resolves).
755
+ *
756
+ * macOS-GPU sync path only (the worker-encode gate guarantees this at the call
757
+ * site). On an in-page failure at frame k, frames < k are already at the worker
758
+ * (their promises resolve normally); pending entries for frames >= k are
759
+ * rejected here and `failedAt` tells the caller to re-capture k.. via the
760
+ * per-frame path (which owns the screenshot-fallback semantics).
761
+ */
762
+ export async function produceDrawElementFrameBatch(page, times, width, height, quality = 80) {
763
+ const state = workerEncodeStates.get(page);
764
+ if (!state) {
765
+ throw new Error("drawElement worker encode not initialized; call initDrawElementWorkerEncode first");
766
+ }
767
+ const fids = [];
768
+ const encodeResults = [];
769
+ for (let i = 0; i < times.length; i++) {
770
+ const frameId = ++state.nextId;
771
+ fids.push(frameId);
772
+ const p = new Promise((resolve, reject) => {
773
+ const timer = setTimeout(() => {
774
+ if (state.pending.delete(frameId)) {
775
+ reject(new Error(`drawElement worker encode timed out (frame ${frameId})`));
776
+ }
777
+ }, 30_000);
778
+ state.pending.set(frameId, {
779
+ resolve: (b) => {
780
+ clearTimeout(timer);
781
+ resolve(b);
782
+ },
783
+ reject: (e) => {
784
+ clearTimeout(timer);
785
+ reject(e);
786
+ },
787
+ });
788
+ });
789
+ void p.catch(() => { }); // same orphan-rejection guard as produceDrawElementFrame
790
+ encodeResults.push(p);
791
+ }
792
+ const outcome = await page.evaluate(async ({ frames, w, h, q, }) => {
793
+ const canvas = document.getElementById("__hf_de_canvas");
794
+ const root = document.querySelector("[data-composition-id]");
795
+ if (!canvas || !root)
796
+ return { failedAt: 0, error: "drawElement canvas not initialized" };
797
+ const ctx = canvas.getContext("2d");
798
+ if (!ctx)
799
+ return { failedAt: 0, error: "drawElement: 2d context unavailable" };
800
+ const aw = window;
801
+ const waitPaint = () => new Promise((res) => {
802
+ let done = false;
803
+ const settle = () => {
804
+ if (done)
805
+ return;
806
+ done = true;
807
+ canvas.removeEventListener("paint", settle);
808
+ res();
809
+ };
810
+ canvas.addEventListener("paint", settle);
811
+ const tick = document.getElementById("__hf_de_tick");
812
+ if (tick) {
813
+ tick.style.backgroundColor =
814
+ tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
815
+ }
816
+ setTimeout(settle, 250);
817
+ });
818
+ let prevBitmap = Promise.resolve();
819
+ let prevBitmapIdx = -1;
820
+ const errMsg = (e) => (e instanceof Error ? e.message : String(e));
821
+ for (let i = 0; i < frames.length; i++) {
822
+ const frame = frames[i];
823
+ if (!frame)
824
+ return { failedAt: i, error: "batch frame missing" };
825
+ const { t, fid } = frame;
826
+ try {
827
+ if (aw.__hf && typeof aw.__hf.seek === "function")
828
+ aw.__hf.seek(t);
829
+ aw.__hf3d?.update();
830
+ const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
831
+ for (const c of accel) {
832
+ if (c.style.visibility !== "hidden")
833
+ c.style.visibility = "hidden";
834
+ }
835
+ await waitPaint();
836
+ // Wait for the previous frame's bitmap before overwriting the canvas.
837
+ try {
838
+ await prevBitmap;
839
+ }
840
+ catch (e) {
841
+ return { failedAt: prevBitmapIdx, error: errMsg(e) };
842
+ }
843
+ ctx.clearRect(0, 0, w, h);
844
+ let bg = "";
845
+ for (let el = root.parentElement; el; el = el.parentElement) {
846
+ const c = getComputedStyle(el).backgroundColor;
847
+ if (c && c !== "transparent" && c !== "rgba(0, 0, 0, 0)") {
848
+ bg = c;
849
+ break;
850
+ }
851
+ }
852
+ ctx.fillStyle = bg || "#fff";
853
+ ctx.fillRect(0, 0, w, h);
854
+ const rootRect = root.getBoundingClientRect();
855
+ for (const c of accel) {
856
+ if (c.hasAttribute("data-hf-3d"))
857
+ continue;
858
+ const r = c.getBoundingClientRect();
859
+ try {
860
+ ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
861
+ }
862
+ catch {
863
+ // skip
864
+ }
865
+ }
866
+ // Root compositor-applied opacity/transform correction — mirrors
867
+ // produceDrawElementFrame (see its comment).
868
+ let appliedAlpha = false;
869
+ let appliedTransform = false;
870
+ if (aw.__HF_ROOT_PROPS__) {
871
+ try {
872
+ const rcs = getComputedStyle(root);
873
+ const baseOp = aw.__HF_ROOT_BASE_OPACITY__ ?? 1;
874
+ const curOp = parseFloat(rcs.opacity);
875
+ if (baseOp > 0.001 && Number.isFinite(curOp)) {
876
+ const ratio = curOp / baseOp;
877
+ if (Math.abs(ratio - 1) > 0.002) {
878
+ ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
879
+ appliedAlpha = true;
880
+ }
881
+ }
882
+ const curTransform = rcs.transform;
883
+ if (curTransform && curTransform !== "none") {
884
+ const m = new DOMMatrix(curTransform);
885
+ const origin = rcs.transformOrigin.split(" ");
886
+ const ox = parseFloat(origin[0] ?? "0") || 0;
887
+ const oy = parseFloat(origin[1] ?? "0") || 0;
888
+ ctx.translate(ox, oy);
889
+ ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f);
890
+ ctx.translate(-ox, -oy);
891
+ appliedTransform = true;
892
+ }
893
+ }
894
+ catch {
895
+ /* leave context unchanged → uncorrected (no worse than before) */
896
+ }
897
+ }
898
+ ctx.drawElementImage(root, 0, 0);
899
+ if (appliedAlpha)
900
+ ctx.globalAlpha = 1;
901
+ if (appliedTransform)
902
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
903
+ for (const c of accel) {
904
+ if (!c.hasAttribute("data-hf-3d"))
905
+ continue;
906
+ const r = c.getBoundingClientRect();
907
+ try {
908
+ ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
909
+ }
910
+ catch {
911
+ // skip
912
+ }
913
+ }
914
+ prevBitmapIdx = i;
915
+ prevBitmap = createImageBitmap(canvas).then((bmp) => {
916
+ if (!aw.__hfEncWorker) {
917
+ bmp.close();
918
+ throw new Error("drawElement: encode worker not initialized");
919
+ }
920
+ aw.__hfEncWorker.postMessage({ bmp, id: fid, w, h, q: q / 100 }, [bmp]);
921
+ });
922
+ }
923
+ catch (e) {
924
+ try {
925
+ await prevBitmap;
926
+ }
927
+ catch {
928
+ /* prior frame's failure surfaces via its own pending timeout path */
929
+ }
930
+ return { failedAt: i, error: errMsg(e) };
931
+ }
932
+ }
933
+ try {
934
+ await prevBitmap;
935
+ }
936
+ catch (e) {
937
+ return { failedAt: prevBitmapIdx, error: errMsg(e) };
938
+ }
939
+ return { failedAt: null };
940
+ }, { frames: times.map((t, i) => ({ t, fid: fids[i] ?? 0 })), w: width, h: height, q: quality });
941
+ if (outcome.failedAt !== null) {
942
+ // Frames >= failedAt never reached the worker — reject their pendings now
943
+ // so nothing waits 30s on the watchdog.
944
+ for (let k = outcome.failedAt; k < fids.length; k++) {
945
+ const fid = fids[k];
946
+ if (fid === undefined)
947
+ continue;
948
+ const entry = state.pending.get(fid);
949
+ if (entry) {
950
+ state.pending.delete(fid);
951
+ entry.reject(new Error(`drawElement batch produce failed at frame ${k}: ${outcome.error ?? "?"}`));
952
+ }
953
+ }
954
+ }
955
+ return { encodeResults, failedAt: outcome.failedAt, error: outcome.error };
956
+ }
957
+ //# sourceMappingURL=drawElementService.js.map