@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,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
|