@hyperframes/engine 0.7.37 → 0.7.39

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 +131 -0
  17. package/dist/services/frameCapture.d.ts.map +1 -1
  18. package/dist/services/frameCapture.js +862 -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 +34 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- // fallow-ignore-file complexity
1
+ // fallow-ignore-file complexity code-duplication
2
2
  /**
3
3
  * Frame Capture Service
4
4
  *
@@ -13,7 +13,36 @@ import { quantizeTimeToFrame, fpsToNumber } from "@hyperframes/core";
13
13
  // ── Extracted modules ───────────────────────────────────────────────────────
14
14
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, } from "./browserManager.js";
15
15
  import { beginFrameCapture, getCdpSession, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
16
+ import { detectSwiftShader, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, } from "./drawElementService.js";
17
+ import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
16
18
  import { DEFAULT_CONFIG } from "../config.js";
19
+ /**
20
+ * drawElement self-verification failure — a captured DE frame diverged from its
21
+ * pre-injection screenshot ground truth (or a blank frame survived a retry).
22
+ * The orchestrator catches this and re-renders the whole job with
23
+ * forceScreenshot. Discriminant-based guard (not instanceof) so it survives
24
+ * duplicated module instances across package boundaries.
25
+ */
26
+ export class DrawElementVerificationError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "DrawElementVerificationError";
30
+ // Discriminant property, assigned dynamically: isDrawElementVerificationError
31
+ // reads it structurally so detection survives duplicated module instances
32
+ // across package boundaries (where instanceof fails).
33
+ this.deVerificationFailure = true;
34
+ }
35
+ }
36
+ export function isDrawElementVerificationError(err) {
37
+ // Walk the cause chain — the producer wraps capture errors (CaptureStageError).
38
+ let e = err;
39
+ for (let depth = 0; depth < 5 && typeof e === "object" && e !== null; depth++) {
40
+ if (e.deVerificationFailure === true)
41
+ return true;
42
+ e = e.cause;
43
+ }
44
+ return false;
45
+ }
17
46
  // Circular buffer for browser console messages dumped on render failure diagnostics.
18
47
  // Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
19
48
  const BROWSER_CONSOLE_BUFFER_SIZE = 200;
@@ -167,6 +196,274 @@ async function waitForCloseWithTimeout(promise) {
167
196
  clearTimeout(timer);
168
197
  return !timedOut;
169
198
  }
199
+ /**
200
+ * Post-readiness capture-surface init, shared by the screenshot and BeginFrame
201
+ * init paths (called after the page is fully ready). When `useDrawElement` is
202
+ * set, detect SwiftShader and route: transparent+SwiftShader falls back to
203
+ * screenshot capture (the drawElement transparent path is broken on SwiftShader),
204
+ * everything else injects the drawElement canvas and switches to "drawelement"
205
+ * mode. Otherwise, for PNG output, force a transparent page background so the
206
+ * screenshots carry a real alpha channel (Chrome resets the override on every
207
+ * navigation, so this must run after page load).
208
+ *
209
+ * drawElement is also skipped when supersampling (deviceScaleFactor > 1):
210
+ * `drawElementImage` reads the canvas at CSS pixels and has no equivalent of
211
+ * `Page.captureScreenshot`'s clip+scale, so it would silently capture at 1x and
212
+ * drop the requested supersample. Such renders fall through to the screenshot
213
+ * path (preMode already forces "screenshot" for DPR > 1).
214
+ */
215
+ async function initDrawElementOrTransparentBackground(session, page, logInitPhase) {
216
+ const supersampling = (session.options.deviceScaleFactor ?? 1) > 1;
217
+ // forceScreenshot is an explicit routing decision made upstream (render-mode
218
+ // compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
219
+ // drawElement must not override it. Concretely: an rAF-compat comp on
220
+ // SwiftShader gets a screenshot-launched (free-running) browser, where
221
+ // drawElement runs in paint-event-sync mode; SwiftShader never refreshes a
222
+ // 2d canvas bitmap inside a cached paint record there, so every canvas
223
+ // captures frozen-blank (raf-ball rendered fully black). On a GPU the same
224
+ // path happens to work, but the hint asked for screenshot — honor it.
225
+ const forceScreenshot = session.config?.forceScreenshot ?? false;
226
+ // DIAGNOSTIC ONLY — HF_FORCE_DRAWELEMENT=1 forces the drawElement path,
227
+ // bypassing every compile/init gate AND the compatibility hints (it overrides
228
+ // forceScreenshot). Exists for upstream-Chromium repro work (isolating gate
229
+ // behavior from drawElementImage behavior, e.g. the crbug 521861819 149-vs-151
230
+ // comparison) and for R&D on gated effect classes. Renders under this flag may
231
+ // be DAMAGED by design — the gates it skips exist because measured damage
232
+ // (blur/backdrop ~18-49dB, 3D backface, SwiftShader sub-layer drops) is real.
233
+ // Never set it in production; it is intentionally not documented in user-facing
234
+ // help, and the safety-net blank guard also stands down under it so diagnostic
235
+ // frames arrive unmodified.
236
+ const forceDE = process.env.HF_FORCE_DRAWELEMENT === "1";
237
+ const useDrawElement = ((session.config?.useDrawElement ?? false) || forceDE) &&
238
+ !supersampling &&
239
+ (!forceScreenshot || forceDE);
240
+ if ((session.config?.useDrawElement ?? false) && supersampling) {
241
+ session.deGateReason = "supersampling";
242
+ console.log("[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
243
+ "ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.");
244
+ }
245
+ if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
246
+ session.deGateReason = "render_mode_hint";
247
+ console.log("[engine] fast capture: falling back to screenshot — render-mode compatibility " +
248
+ "hint forced screenshot capture (e.g. raw requestAnimationFrame composition).");
249
+ }
250
+ // Retract the per-page autoAlpha rewrite flag when a runtime gate routes the
251
+ // session to screenshot mode. evaluateOnNewDocument already fired; a follow-up
252
+ // evaluate overrides it in the live page context so hideTransparentAutoAlpha-
253
+ // Targets does not hide elements on the fallback screenshot render
254
+ // (up to 21 dB damage if not retracted, A/B proven 2026-06-12).
255
+ async function retractAutoAlphaFlag() {
256
+ await page.evaluate(() => {
257
+ window.__HF_FAST_CAPTURE_AUTOALPHA__ = false;
258
+ });
259
+ }
260
+ if (useDrawElement) {
261
+ session.isSwiftShader = await detectSwiftShader(page);
262
+ const transparent = session.options.format === "png";
263
+ async function routeToFallback() {
264
+ session.captureMode = session.launchCaptureMode;
265
+ if (transparent) {
266
+ await initTransparentBackground(session.page);
267
+ }
268
+ await retractAutoAlphaFlag();
269
+ // Static-frame dedup is capture-mode-independent (the serial path reuses
270
+ // lastFrameBuffer regardless of how the frame was captured) and lossless
271
+ // (anchor-verified). A comp only reaches THIS fallback with useDrawElement=true
272
+ // AND forceScreenshot=false — i.e. it is deterministic: raw-rAF / iframe /
273
+ // htmlInCanvas comps are forced to screenshot upstream (forceScreenshot=true) and
274
+ // never enter this block, so they never arm dedup. The comps that DO fall back here
275
+ // (blur / backdrop / 3D / at-risk) carry only a compositor
276
+ // EFFECT drawElement can't paint, not nondeterminism, so their predicted-static set
277
+ // is sound. Verification seeks via Page.captureScreenshot, which hangs on a
278
+ // BeginFrame-launched browser — gate on the launch mode (macOS fast-capture launches
279
+ // screenshot-mode; Linux/Docker launches beginframe and is skipped).
280
+ if (session.launchCaptureMode === "screenshot") {
281
+ await armStaticDedup(session, page, logInitPhase);
282
+ }
283
+ }
284
+ // SwiftShader gate: drawElement's only advantage is skipping the GPU→CPU
285
+ // screenshot-readback IPC. On a software rasterizer (Docker/CI, no GPU) both
286
+ // paths block on identical software raster, so drawElement is parity-or-slower
287
+ // — route to the platform baseline.
288
+ //
289
+ // Two gates were REMOVED here once Chrome 151 fixed crbug 521861819
290
+ // (drawElementImage dropped compositor-promoted opacity layers mid-fade):
291
+ // - the <video> gate (a proxy for the word-by-word caption opacity pattern,
292
+ // Lim 2), and
293
+ // - the stacked-fade gate (>=2 overlapping viewport-scale opacity-fade targets).
294
+ // Both reproduced on Chrome <=150 (video+caption-fade ~12 dB; stacked fade
295
+ // 24.5 dB) and both render correctly on 151 (verified: video+nested-fade repro
296
+ // PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor.
297
+ const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
298
+ if (mode === "screenshot") {
299
+ session.deGateReason = "swiftshader";
300
+ // Fall back to the browser's LAUNCH mode, not unconditionally to
301
+ // "screenshot": on a BeginFrame-launched browser (Linux fast capture)
302
+ // Page.captureScreenshot hangs for the full protocol timeout, while
303
+ // beginFrameCapture is the platform's normal baseline path.
304
+ console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
305
+ "SwiftShader (software rasterizer — no GPU egress to skip, drawElement is " +
306
+ "parity-or-slower; see fast-capture-limitations.md)");
307
+ await routeToFallback();
308
+ }
309
+ else {
310
+ // CSS-effect gate: backdrop-filter samples the compositor backdrop and
311
+ // filter:blur/drop-shadow render differently through the paint-record
312
+ // path — drawElementImage can't reproduce either, producing 18–49 dB
313
+ // damaged frames (community eval). Fall back to the platform baseline.
314
+ // HF_FAST_CAPTURE_CSSFX=true bypasses for R&D.
315
+ if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
316
+ const cssFx = await detectCssEffectRisk(page);
317
+ if (cssFx) {
318
+ session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
319
+ console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
320
+ `${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`);
321
+ await routeToFallback();
322
+ return;
323
+ }
324
+ }
325
+ // Lim 7: timeline-interval at-risk predictor. Walk window.__timelines and
326
+ // find tweens animating compositor-incompatible props (opacity, filter,
327
+ // blend-mode, 3D transform, clip-path, mask). drawElementImage drops these
328
+ // mid-animation (crbug 521861819 et al), and whether it drops a given one
329
+ // cannot be told reliably without rendering (jump-seek != sequential render,
330
+ // proven 2026-06-16) nor from geometry (size is a proxy, not the mechanism).
331
+ // The only deterministic + reliable route to 0 damage is to gate on the
332
+ // PRESENCE of any such tween — a pure fact of the declared timeline, the same
333
+ // every run. Conservative by design: comps whose risky tweens drawElement
334
+ // would have handled also fall back, but correctness is never at risk and the
335
+ // fast path stays open for static + plain-2D-transform comps (x/y/scale are
336
+ // NOT in the at-risk set). Tune the gate's frame-fraction floor with
337
+ // HF_FAST_CAPTURE_INTERVAL_FRACTION (default 0 = any at-risk frame gates).
338
+ // Must run BEFORE canvas injection so a whole-comp fallback doesn't leave the
339
+ // drawElement canvas wrapping the composition root. Disable with
340
+ // HF_FAST_CAPTURE_INTERVAL_SS=false.
341
+ if (!forceDE && process.env.HF_FAST_CAPTURE_INTERVAL_SS !== "false") {
342
+ const fps = fpsToNumber(session.options.fps);
343
+ const { frames: atRisk, totalFrames } = await computeTimelineAtRiskFrames(page, fps);
344
+ const atRiskFraction = atRisk.size / totalFrames;
345
+ const fractionFloor = Number(process.env.HF_FAST_CAPTURE_INTERVAL_FRACTION ?? "0");
346
+ logInitPhase(`timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`);
347
+ if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
348
+ session.deGateReason = "at_risk_timeline";
349
+ console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
350
+ `${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
351
+ `(blend/3D/clip/mask); drawElementImage drops these mid-animation ` +
352
+ `(deterministic timeline gate; see fast-capture-limitations.md Lim 7)`);
353
+ await routeToFallback();
354
+ return;
355
+ }
356
+ }
357
+ // Rewrite CSS 3D contexts into WebGL-projected canvases BEFORE the
358
+ // layoutsubtree canvas goes in (rects are measured in normal layout).
359
+ // drawElementImage cannot paint 3D rendering contexts — see
360
+ // threeDProjection.ts. No-op for compositions without 3D content.
361
+ const threeD = await initThreeDProjection(page);
362
+ if (!forceDE && !threeD.ok) {
363
+ session.deGateReason = "3d_init_failed";
364
+ console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
365
+ `3D projection init failed (${threeD.reason ?? "unknown"})`);
366
+ await routeToFallback();
367
+ return;
368
+ }
369
+ if (threeD.groups > 0) {
370
+ logInitPhase(`3D projection active: ${threeD.groups} context(s), ${threeD.quads} quad(s), ` +
371
+ `${threeD.selfQuads ?? 0} self-quad el(s), ${threeD.stubTargets ?? 0} stub target(s)`);
372
+ }
373
+ // Task B: arm static-frame dedup here — drawElement is confirmed (all gates
374
+ // passed) and the DOM is still normal (canvas not yet injected, so the
375
+ // verification screenshots are valid). drawElement-path only; see armStaticDedup.
376
+ await armStaticDedup(session, page, logInitPhase);
377
+ // Video comps on injector-less sessions (probe sessions initialize before
378
+ // video extraction) DEFER the rest of the drawElement init: ground-truth
379
+ // screenshots would capture black <video> boxes, and once the canvas is
380
+ // injected they can never be retaken. The capture stage completes the
381
+ // init after prepareCaptureSessionForReuse attaches the injector.
382
+ // Retract the autoAlpha rewrite flag while deferred — if no path ever
383
+ // completes the init (e.g. the disk path takes over), the session
384
+ // captures via screenshot, where the armed flag causes measured damage.
385
+ if (!session.onBeforeCapture && !forceDE) {
386
+ const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
387
+ if (hasVideos) {
388
+ session.deInitDeferred = true;
389
+ await retractAutoAlphaFlag();
390
+ logInitPhase("drawElement init deferred: video comp awaiting frame injector");
391
+ return;
392
+ }
393
+ }
394
+ await finalizeDrawElementInit(session, page, logInitPhase, { transparent, forceDE });
395
+ }
396
+ }
397
+ else if (session.options.format === "png") {
398
+ await initTransparentBackground(session.page);
399
+ }
400
+ }
401
+ /**
402
+ * The tail of drawElement init: self-verification ground truth (pre-injection),
403
+ * canvas injection, capture-mode flip, clip-boundary predictor, worker-encode.
404
+ * Runs inline when the session already has its video-frame injector (or the
405
+ * comp has no videos); runs deferred via completeDeferredDrawElementInit for
406
+ * probe-initialized video comps.
407
+ */
408
+ async function finalizeDrawElementInit(session, page, logInitPhase, opts) {
409
+ const { transparent, forceDE } = opts;
410
+ // Self-verification ground truth: must run pre-injection — after the canvas
411
+ // wraps the root, a page screenshot shows the canvas's last-drawn bitmap,
412
+ // not the live DOM (see the Lim 6 boundary-screenshot note).
413
+ {
414
+ const verifyStart = Date.now();
415
+ await captureDeVerificationFrames(session, page, logInitPhase);
416
+ session.deVerifyInitMs = Date.now() - verifyStart;
417
+ }
418
+ await injectDrawElementCanvas(page, session.options.width, session.options.height);
419
+ if (transparent) {
420
+ await initTransparentBackground(session.page);
421
+ }
422
+ session.captureMode = "drawelement";
423
+ session.drawElementReady = true;
424
+ logInitPhase("drawElement canvas injected");
425
+ // Lim 6: clip-cut boundary frames — screenshot these instead of drawElement.
426
+ if (process.env.HF_FAST_CAPTURE_BOUNDARY_SS !== "false" && !forceDE) {
427
+ const fps = fpsToNumber(session.options.fps);
428
+ const boundaryFrames = await computeClipBoundaryFrames(page, fps);
429
+ if (boundaryFrames.size > 0) {
430
+ session.clipBoundaryFrames = boundaryFrames;
431
+ logInitPhase(`screenshot fallback: ${boundaryFrames.size} clip-boundary frame(s)`);
432
+ }
433
+ }
434
+ // Worker-encode pipeline: macOS hardware GPU path only (syncToPaintEvent=true,
435
+ // beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker) and transparent
436
+ // (PNG) output — those use the existing synchronous path unchanged.
437
+ const workerEncodeEnabled = (session.config?.enableDrawElementWorkerEncode ?? false) &&
438
+ !transparent &&
439
+ session.beginFrameTimeTicks === 0;
440
+ if (workerEncodeEnabled) {
441
+ await initDrawElementWorkerEncode(page);
442
+ session.workerEncodeEnabled = true;
443
+ logInitPhase("drawElement worker encode initialized");
444
+ }
445
+ }
446
+ /**
447
+ * Complete a deferred drawElement init (see CaptureSession.deInitDeferred).
448
+ * Call after prepareCaptureSessionForReuse has attached the video-frame
449
+ * injector; no-op when the session is not deferred or still has no injector.
450
+ * Re-asserts the autoAlpha rewrite flag retracted at deferral time.
451
+ */
452
+ export async function completeDeferredDrawElementInit(session) {
453
+ if (!session.deInitDeferred || !session.onBeforeCapture)
454
+ return;
455
+ const page = session.page;
456
+ await page.evaluate(() => {
457
+ window.__HF_FAST_CAPTURE_AUTOALPHA__ =
458
+ true;
459
+ });
460
+ const logInitPhase = (phase) => console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
461
+ await finalizeDrawElementInit(session, page, logInitPhase, {
462
+ transparent: session.options.format === "png",
463
+ forceDE: process.env.HF_FORCE_DRAWELEMENT === "1",
464
+ });
465
+ session.deInitDeferred = false;
466
+ }
170
467
  // fallow-ignore-next-line unit-size
171
468
  export async function createCaptureSession(serverUrl, outputDir, options, onBeforeCapture = null, config) {
172
469
  if (!existsSync(outputDir))
@@ -176,16 +473,38 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
176
473
  // `options.format === "png"` for transparent capture should also set
177
474
  // `config.forceScreenshot = true` (the producer's renderOrchestrator does this
178
475
  // automatically when `RenderConfig.format` is an alpha-capable value).
476
+ // Exception: `useDrawElement=true` with png self-manages the screenshot-browser
477
+ // requirement (both the SwiftShader fallback and the GPU transparent path need
478
+ // a screenshot-launched browser — the SwiftShader path calls Page.captureScreenshot
479
+ // which hangs on a BeginFrame browser, and the GPU path doesn't need BeginFrame
480
+ // because the compositor runs freely on a screenshot-launched browser).
179
481
  const headlessShell = resolveHeadlessShellPath(config);
180
482
  const isLinux = process.platform === "linux";
181
483
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
484
+ const useDrawElement = config?.useDrawElement ?? false;
485
+ const drawElementTransparent = useDrawElement && options.format === "png";
486
+ // drawElement and page-side shader compositing are mutually incompatible
487
+ // capture strategies: drawElement reads the composition root's paint records
488
+ // directly and skips the prepare→micro-screenshot→resolve protocol (the
489
+ // micro-screenshot would also hang on an opaque/beginframe-launched browser).
490
+ // `resolveConfig` forces page-side compositing off whenever useDrawElement is
491
+ // set, so this only trips for a direct caller that bypassed resolveConfig and
492
+ // passed both flags — warn once and treat page-side as disabled.
493
+ if (useDrawElement &&
494
+ (config?.enablePageSideCompositing ?? DEFAULT_CONFIG.enablePageSideCompositing)) {
495
+ console.warn("[engine] useDrawElement is incompatible with page-side shader compositing — " +
496
+ "ignoring enablePageSideCompositing for this render. Prefer resolveConfig, " +
497
+ "which disables page-side compositing automatically for fast-capture renders.");
498
+ }
182
499
  // BeginFrame's screenshot does not honor a viewport `deviceScaleFactor`
183
500
  // (the captured surface is sized by the OS window in CSS pixels regardless
184
501
  // of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
185
502
  // need explicit clip+scale on `Page.captureScreenshot`, so fall back to
186
503
  // the screenshot path for any DPR > 1.
187
504
  const supersampling = (options.deviceScaleFactor ?? 1) > 1;
188
- const preMode = headlessShell && isLinux && !forceScreenshot && !supersampling ? "beginframe" : "screenshot";
505
+ const preMode = headlessShell && isLinux && !forceScreenshot && !supersampling && !drawElementTransparent
506
+ ? "beginframe"
507
+ : "screenshot";
189
508
  const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
190
509
  const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
191
510
  chromePath: headlessShell ?? undefined,
@@ -226,6 +545,42 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
226
545
  w.__name = (fn, _name) => fn;
227
546
  }
228
547
  });
548
+ // Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
549
+ // preserveDrawingBuffer before any page script can create a context — their
550
+ // paint records freeze at the first frame, so captureDrawElementFrame
551
+ // composites their live content via drawImage instead (see
552
+ // instrumentAcceleratedCanvases). Must be registered before navigation.
553
+ if (useDrawElement) {
554
+ await page.evaluateOnNewDocument(instrumentAcceleratedCanvases);
555
+ }
556
+ // Signal the producer's GSAP stub to rewrite `opacity` → `autoAlpha` in tween
557
+ // vars (stacked opacity-0 caption layers break drawElementImage capture).
558
+ //
559
+ // DEFAULT OFF (opt in with HF_FAST_CAPTURE_AUTOALPHA=true). The rewrite is baked
560
+ // at tween-creation (page load) and `retractAutoAlphaFlag` (flag-only) can't
561
+ // un-bake it: GSAP autoAlpha sets visibility:hidden under seek capture and renders
562
+ // ~28 dB below a clean opacity baseline (corpus eval 2026-06-16, e.g.
563
+ // 05f22830/06167790: autoAlpha-off = ∞, autoAlpha-on = 28 dB). The rewrite was a
564
+ // workaround for the stacked-fade opacity-layer drop (crbug 521861819), now fixed
565
+ // in Chrome 151 — so it damages more than it fixes. Re-enable per render only if a
566
+ // drawElement comp shows transparent-layer drop on the pinned 151 floor.
567
+ if (useDrawElement && process.env.HF_FAST_CAPTURE_AUTOALPHA === "true") {
568
+ await page.evaluateOnNewDocument(() => {
569
+ window.__HF_FAST_CAPTURE_AUTOALPHA__ = true;
570
+ });
571
+ }
572
+ // Re-apply the captured root's own computed opacity to the 2D context:
573
+ // drawElementImage does not reflect post-paint changes to compositor-applied
574
+ // properties on the captured element itself (the root's opacity is applied by
575
+ // its parent at composite time, never baked into its content snapshot), so an
576
+ // animated root fade renders at full opacity. captureDrawElementFrame corrects
577
+ // this by the ratio current/base opacity (no-op for a static root). On by
578
+ // default; disable with HF_FAST_CAPTURE_ROOT_PROPS=false.
579
+ if (useDrawElement && process.env.HF_FAST_CAPTURE_ROOT_PROPS !== "false") {
580
+ await page.evaluateOnNewDocument(() => {
581
+ window.__HF_ROOT_PROPS__ = true;
582
+ });
583
+ }
229
584
  // Inject render-time variable overrides before any page script runs, so the
230
585
  // runtime helper `getVariables()` returns the merged result on its first
231
586
  // call. Pass the JSON string and parse inside the page so we don't require
@@ -278,8 +633,10 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
278
633
  beforeCaptureMs: 0,
279
634
  screenshotMs: 0,
280
635
  totalMs: 0,
636
+ frameMs: [],
281
637
  },
282
638
  captureMode,
639
+ launchCaptureMode: captureMode,
283
640
  beginFrameTimeTicks: 0,
284
641
  // Frame interval in ms: 1000 * den / num. For 30/1 → 33.333…, for
285
642
  // 30000/1001 (NTSC) → 33.366…. JavaScript number precision is fine at
@@ -444,11 +801,16 @@ async function pollHfReady(page, timeoutMs, intervalMs = 100) {
444
801
  `renderReady=${diag.renderReady}, duration=${diag.duration}`);
445
802
  }
446
803
  async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
804
+ // Hosts may opt out of the timeline wait with `data-no-timeline` —
805
+ // compositions driven purely by CSS animations / rAF (the render-compat
806
+ // contract) never register window.__timelines[id], and without the opt-out
807
+ // they stall here for the full playerReadyTimeout (45 s) on every render.
447
808
  const expression = `(function() {
448
809
  var hosts = document.querySelectorAll("[data-composition-id]");
449
810
  if (hosts.length === 0) return true;
450
811
  var timelines = window.__timelines || {};
451
812
  for (var i = 0; i < hosts.length; i++) {
813
+ if (hosts[i].hasAttribute("data-no-timeline")) continue;
452
814
  var id = hosts[i].getAttribute("data-composition-id");
453
815
  if (!id) continue;
454
816
  if (!timelines[id]) return false;
@@ -476,13 +838,15 @@ async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
476
838
  var timelines = window.__timelines || {};
477
839
  var m = [];
478
840
  for (var i = 0; i < hosts.length; i++) {
841
+ if (hosts[i].hasAttribute("data-no-timeline")) continue;
479
842
  var id = hosts[i].getAttribute("data-composition-id");
480
843
  if (id && !timelines[id]) m.push(id);
481
844
  }
482
845
  return m.join(", ");
483
846
  })()`);
484
847
  console.warn(`[FrameCapture] Sub-composition timelines not registered after ${timeoutMs}ms: ${missing}. ` +
485
- `Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes.`);
848
+ `Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes. ` +
849
+ `Compositions intentionally driven without GSAP timelines (CSS animations / rAF) can mark the host with data-no-timeline to skip this wait.`);
486
850
  }
487
851
  }
488
852
  async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
@@ -760,16 +1124,8 @@ export async function initializeSession(session) {
760
1124
  `Continuing render — affected videos will appear as blank/black frames.`);
761
1125
  }
762
1126
  await recordSessionInitTelemetry(session, initStart);
763
- // For PNG captures, force the page background fully transparent so the
764
- // captured screenshots carry a real alpha channel. Must run AFTER
765
- // navigation (Chrome resets the override on every goto) and AFTER the
766
- // page is loaded (the injected stylesheet needs a real document.head).
767
- // The override is overridden by `body { background: ... }` and
768
- // `#root { background: ... }` rules — the helper handles that with a
769
- // `[data-composition-id]{background:transparent !important}` injection.
770
- if (session.options.format === "png") {
771
- await initTransparentBackground(session.page);
772
- }
1127
+ // drawElement or transparent-background init runs after page is fully ready.
1128
+ await initDrawElementOrTransparentBackground(session, page, logInitPhase);
773
1129
  await armStaticDedup(session, session.page, logInitPhase);
774
1130
  session.isInitialized = true;
775
1131
  return;
@@ -896,15 +1252,15 @@ export async function initializeSession(session) {
896
1252
  // constant so chunk workers on different hosts compute the same baseline.
897
1253
  const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
898
1254
  session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
899
- // For PNG captures, inject the transparent-background override + stylesheet
900
- // (see the screenshot-mode branch above for the rationale). BeginFrame mode
901
- // does not actually preserve alpha through its compositor callers that
902
- // need transparent output should set `forceScreenshot: true` so this branch
903
- // is bypassed entirely. The call is left here as defense-in-depth for any
904
- // future BeginFrame alpha support.
905
- if (session.options.format === "png") {
906
- await initTransparentBackground(session.page);
907
- }
1255
+ // drawElement or transparent-background init runs after page is fully ready.
1256
+ // IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
1257
+ // drawelement branch gates its BeginFrame call on `beginFrameTimeTicks > 0`;
1258
+ // if this ran first, ticks would be 0 and the paused compositor would never
1259
+ // advance for opaque drawElement on Linux. (In beginframe-launched mode,
1260
+ // transparent is always false — useDrawElement+png forces preMode="screenshot"
1261
+ // upstream so the SwiftShader fallback inside the helper is dead-but-harmless
1262
+ // defense-in-depth here.)
1263
+ await initDrawElementOrTransparentBackground(session, page, logInitPhase);
908
1264
  await armStaticDedup(session, session.page, logInitPhase);
909
1265
  session.isInitialized = true;
910
1266
  }
@@ -965,7 +1321,9 @@ async function prepareFrameForCapture(session, frameIndex, time) {
965
1321
  // 1. prepare — clone scenes (now containing injected video <img>s)
966
1322
  // 2. micro-screenshot — force browser to paint cloned elements
967
1323
  // 3. resolve — drawElementImage reads paint records, shader composites
968
- if (hasPendingComposite && session.captureMode !== "beginframe") {
1324
+ if (hasPendingComposite &&
1325
+ session.captureMode !== "beginframe" &&
1326
+ session.captureMode !== "drawelement") {
969
1327
  await page.evaluate(async () => {
970
1328
  const w = window;
971
1329
  if (typeof w.__hf_page_composite_prepare === "function") {
@@ -1249,6 +1607,14 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
1249
1607
  * via HF_STATIC_DEDUP_SAMPLES (default 24).
1250
1608
  */
1251
1609
  async function armStaticDedup(session, page, logInitPhase) {
1610
+ // Idempotent: the drawElement init path arms dedup BEFORE canvas injection
1611
+ // (verification screenshots need the un-injected DOM), and initializeSession
1612
+ // calls this again unconditionally afterwards. Once staticFrames is
1613
+ // populated, re-running would overwrite the armed state with
1614
+ // skipReason="capture_mode" (captureMode is "drawelement" by then) —
1615
+ // contradictory telemetry — and re-run the verification seeks. No-op instead.
1616
+ if (session.staticFrames || session.staticDedupSkipReason)
1617
+ return;
1252
1618
  // Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
1253
1619
  // EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
1254
1620
  // Default-on: only an explicit `staticFrameDedup === false` (resolved from
@@ -1315,10 +1681,90 @@ async function armStaticDedup(session, page, logInitPhase) {
1315
1681
  `(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`);
1316
1682
  }
1317
1683
  /**
1318
- * Internal core: prepare, screenshot, and track perf.
1319
- * Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
1320
- * Returns the screenshot buffer, quantized time, and total capture time.
1684
+ * Walk window.__timelines and collect frame intervals where GSAP tweens animate
1685
+ * compositor-incompatible properties (blend-mode, 3D transforms, clip-path, mask).
1686
+ * drawElement cannot reproduce these effects mid-tween capture those frames via
1687
+ * screenshot instead. opacity/filter fades were dropped from the set once Chrome 151
1688
+ * fixed crbug 521861819. See docs/fast-capture-limitations.md Lim 7.
1689
+ *
1690
+ * Returns the union of at-risk frame indices (±1 margin around each tween interval)
1691
+ * and totalFrames (for fraction computation by the caller).
1321
1692
  */
1693
+ async function computeTimelineAtRiskFrames(page, fps) {
1694
+ const result = await page.evaluate(() => {
1695
+ // opacity/autoAlpha/filter fades were removed from this set once Chrome 151
1696
+ // fixed crbug 521861819 (drawElementImage dropped promoted opacity layers
1697
+ // mid-fade) — those now render correctly on the drawElement path. backdrop-filter
1698
+ // and filter:blur stay gated by detectCssEffectRisk (architectural single-element
1699
+ // capture limit, not 521861819). What remains here is the per-tween backstop for
1700
+ // effects drawElementImage still cannot reproduce mid-animation: mix-blend-mode,
1701
+ // CSS 3D transforms (crbug 522872457), clip-path, and mask.
1702
+ const AT_RISK_PROPS = new Set([
1703
+ "backdropFilter",
1704
+ "backdrop-filter",
1705
+ "mixBlendMode",
1706
+ "mix-blend-mode",
1707
+ "rotationX",
1708
+ "rotationY",
1709
+ "rotateX",
1710
+ "rotateY",
1711
+ "z",
1712
+ "translateZ",
1713
+ "clipPath",
1714
+ "clip-path",
1715
+ "maskImage",
1716
+ "mask",
1717
+ ]);
1718
+ function walkTimeline(tl, offset, out) {
1719
+ if (typeof tl.getChildren !== "function")
1720
+ return;
1721
+ for (const child of tl.getChildren(false, true, true)) {
1722
+ const childStart = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
1723
+ const childDur = typeof child.duration === "function" ? child.duration() : 0;
1724
+ if (typeof child.getChildren === "function") {
1725
+ walkTimeline(child, childStart, out);
1726
+ }
1727
+ else {
1728
+ const vars = child.vars || {};
1729
+ if (Object.keys(vars).some((k) => AT_RISK_PROPS.has(k))) {
1730
+ out.push({ start: childStart, end: childStart + childDur });
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+ const w = window;
1736
+ const timelines = w.__timelines || {};
1737
+ const intervals = [];
1738
+ for (const tl of Object.values(timelines)) {
1739
+ if (tl && typeof tl.getChildren === "function") {
1740
+ walkTimeline(tl, 0, intervals);
1741
+ }
1742
+ }
1743
+ const duration = w.__hf?.duration ?? 0;
1744
+ return { intervals, duration };
1745
+ });
1746
+ const { intervals, duration } = result;
1747
+ const frames = new Set();
1748
+ for (const { start, end } of intervals) {
1749
+ const lo = Math.floor(start * fps) - 1;
1750
+ const hi = Math.ceil(end * fps) + 1;
1751
+ for (let f = Math.max(0, lo); f <= hi; f++) {
1752
+ frames.add(f);
1753
+ }
1754
+ }
1755
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
1756
+ return { frames, totalFrames };
1757
+ }
1758
+ /**
1759
+ * True for the drawElement `InvalidStateError: No cached paint record for element`
1760
+ * thrown when a subtree element has no paint record for the current frame (display
1761
+ * toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
1762
+ * whole-comp — callers fall back to screenshot for the single frame.
1763
+ */
1764
+ function isNoCachedPaintRecordError(err) {
1765
+ const msg = err instanceof Error ? err.message : String(err);
1766
+ return msg.includes("No cached paint record");
1767
+ }
1322
1768
  async function captureFrameCore(session, frameIndex, time) {
1323
1769
  const { page, options } = session;
1324
1770
  const startTime = Date.now();
@@ -1358,6 +1804,84 @@ async function captureFrameCore(session, frameIndex, time) {
1358
1804
  session.beginFrameNoDamageCount++;
1359
1805
  screenshotBuffer = result.buffer;
1360
1806
  }
1807
+ else if (session.captureMode === "drawelement" &&
1808
+ session.clipBoundaryFrames?.has(frameIndex) &&
1809
+ process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
1810
+ // Lim 6 (serial path): proactively screenshotting clip-boundary frames is now
1811
+ // OPT-IN (was default-on). It is net-harmful: drawElement renders most boundary
1812
+ // frames correctly, but Page.captureScreenshot in drawElement mode captures the
1813
+ // injected canvas (unpainted at render start → white; mid-render → ~1-frame
1814
+ // stale), so the "fallback" REPLACES good frames with damaged ones (validated:
1815
+ // 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
1816
+ // boundary failure modes are now caught reactively below — the throw case by
1817
+ // isNoCachedPaintRecordError, the silent-solid-black case by the small-frame
1818
+ // blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
1819
+ // handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
1820
+ // path keeps proactive boundary-SS (it has no blank-guard); see
1821
+ // captureFrameToBufferPipelined and docs/fast-capture-limitations.md.
1822
+ screenshotBuffer = await pageScreenshotCapture(page, options);
1823
+ }
1824
+ else if (session.captureMode === "drawelement") {
1825
+ // Advance compositor state via BeginFrame when available (Linux headless-shell);
1826
+ // on macOS the compositor advances naturally without BeginFrame.
1827
+ if (session.beginFrameTimeTicks > 0) {
1828
+ const client = await getCdpSession(page);
1829
+ await client.send("HeadlessExperimental.beginFrame", {
1830
+ frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
1831
+ interval: session.beginFrameIntervalMs,
1832
+ noDisplayUpdates: false,
1833
+ // no screenshot param — we capture via canvas
1834
+ });
1835
+ }
1836
+ try {
1837
+ screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
1838
+ // Paint-event sync only without BeginFrame (macOS / screenshot-launched):
1839
+ // under BeginFrame control the per-frame beginFrame above already painted
1840
+ // a fresh snapshot, and no further paint would arrive during a wait.
1841
+ session.beginFrameTimeTicks === 0);
1842
+ // Silent-blank-drop guard: drawElement occasionally returns a blank/dropped
1843
+ // frame WITHOUT throwing (paint-record miss; the throw case is handled below).
1844
+ // Such a frame's JPEG is anomalously tiny vs the comp's running median (a blank
1845
+ // 1080p frame ~5-9 KB; content frames 50 KB-1 MB). Re-capture via screenshot
1846
+ // (ground truth) — harmless for legitimately simple frames (screenshot matches).
1847
+ // Catches scattered intermittent drops (e.g. 4001da8e: 11 blanks in 9300 frames,
1848
+ // 9.7 dB) that no static gate can see. PNG/transparent excluded (alpha sizing
1849
+ // differs and that path is its own).
1850
+ if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
1851
+ const sizes = (session.deFrameSizes ??= []);
1852
+ const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
1853
+ const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
1854
+ const floor = Math.max(20000, median * 0.12);
1855
+ if (screenshotBuffer.length < floor) {
1856
+ console.log(`[engine] fast capture: frame ${frameIndex} — drawElement frame anomalously ` +
1857
+ `small (${screenshotBuffer.length}B < ${Math.round(floor)}B, likely a silent ` +
1858
+ `paint-record drop); screenshot fallback (see fast-capture-limitations.md)`);
1859
+ screenshotBuffer = await pageScreenshotCapture(page, options);
1860
+ }
1861
+ else {
1862
+ if (sizes.length >= 60)
1863
+ sizes.shift();
1864
+ sizes.push(screenshotBuffer.length);
1865
+ }
1866
+ }
1867
+ }
1868
+ catch (err) {
1869
+ // drawElementImage throws `InvalidStateError: No cached paint record for
1870
+ // element` when an element in the subtree has no paint record this frame
1871
+ // (display toggled / detached / freshly-shown at a clip-cut boundary). This
1872
+ // is a per-frame condition, not a whole-comp one — fall back to screenshot
1873
+ // for THIS frame instead of aborting the render. See fast-capture-limitations.md.
1874
+ if (isNoCachedPaintRecordError(err)) {
1875
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
1876
+ console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
1877
+ `screenshot fallback for this frame (see fast-capture-limitations.md)`);
1878
+ screenshotBuffer = await pageScreenshotCapture(page, options);
1879
+ }
1880
+ else {
1881
+ throw err;
1882
+ }
1883
+ }
1884
+ }
1361
1885
  else {
1362
1886
  screenshotBuffer = await pageScreenshotCapture(page, options);
1363
1887
  }
@@ -1368,6 +1892,7 @@ async function captureFrameCore(session, frameIndex, time) {
1368
1892
  session.capturePerf.beforeCaptureMs += beforeCaptureMs;
1369
1893
  session.capturePerf.screenshotMs += screenshotMs;
1370
1894
  session.capturePerf.totalMs += captureTimeMs;
1895
+ session.capturePerf.frameMs.push(captureTimeMs);
1371
1896
  // Retain this freshly-captured buffer so the following static frames can reuse it.
1372
1897
  if (session.staticFrames)
1373
1898
  session.lastFrameBuffer = screenshotBuffer;
@@ -1381,14 +1906,24 @@ async function captureFrameCore(session, frameIndex, time) {
1381
1906
  }
1382
1907
  }
1383
1908
  export async function captureFrame(session, frameIndex, time) {
1384
- const { options, outputDir } = session;
1385
1909
  const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
1386
- const ext = options.format === "png" ? "png" : "jpg";
1387
- const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
1388
- const framePath = join(outputDir, frameName);
1389
- writeFileSync(framePath, buffer);
1910
+ const framePath = writeCapturedFrame(session, frameIndex, buffer);
1390
1911
  return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
1391
1912
  }
1913
+ /**
1914
+ * Write an already-captured frame buffer to the session's output dir using the
1915
+ * canonical `frame_NNNNNN.{jpg,png}` naming. `fileIndex` is the ENCODER-facing
1916
+ * index (0-based within the captured range), which may differ from the absolute
1917
+ * composition frame index used for seeking/boundary lookups. Extracted so the
1918
+ * disk worker-encode pipeline can write a buffer produced by
1919
+ * `captureFrameToBufferPipelined` without duplicating the naming convention.
1920
+ */
1921
+ export function writeCapturedFrame(session, fileIndex, buffer) {
1922
+ const ext = session.options.format === "png" ? "png" : "jpg";
1923
+ const framePath = join(session.outputDir, `frame_${String(fileIndex).padStart(6, "0")}.${ext}`);
1924
+ writeFileSync(framePath, buffer);
1925
+ return framePath;
1926
+ }
1392
1927
  /**
1393
1928
  * Capture a frame and return the screenshot as a Buffer instead of writing to disk.
1394
1929
  * Used by the streaming encode pipeline to pipe frames directly to FFmpeg stdin.
@@ -1397,6 +1932,178 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
1397
1932
  const { buffer, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
1398
1933
  return { buffer, captureTimeMs };
1399
1934
  }
1935
+ /**
1936
+ * Pipelined drawElement frame capture for the worker-encode path.
1937
+ *
1938
+ * Performs seek prep + paint-wait + drawElementImage + composite +
1939
+ * `createImageBitmap` + transfers the bitmap to the in-page encode worker.
1940
+ * Returns `encodeResult` immediately (before the worker finishes encoding).
1941
+ * The caller overlaps frame N's encode with frame N+1's produce phase.
1942
+ *
1943
+ * Requirements:
1944
+ * - `session.workerEncodeEnabled` must be true (set by initializeSession when
1945
+ * `config.enableDrawElementWorkerEncode` is true and mode resolved to drawelement).
1946
+ * - JPEG format only. PNG falls back to `captureFrameToBuffer`.
1947
+ * - macOS hardware GPU path (syncToPaintEvent=true, beginFrameTimeTicks=0).
1948
+ * BeginFrame (Linux) uses the standard synchronous path unchanged.
1949
+ */
1950
+ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
1951
+ const { page, options } = session;
1952
+ const startTime = Date.now();
1953
+ // Task B: static-frame dedup (worker path). Reuse the prior frame's encode result
1954
+ // and skip the seek + drawElement + encode entirely. Same predicate as the serial
1955
+ // path; clip-cut frames are excluded from staticFrames so they always capture.
1956
+ if (session.staticFrames?.has(frameIndex) && session.lastEncodeResult) {
1957
+ session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
1958
+ session.capturePerf.frames += 1;
1959
+ return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
1960
+ }
1961
+ try {
1962
+ const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
1963
+ void quantizedTime;
1964
+ // Lim 6: clip-cut boundary frame — screenshot ONLY when opt-in, matching the serial
1965
+ // path (captureFrameCore). Proactive boundary screenshots in drawElement mode are
1966
+ // net-HARMFUL: with `<canvas layoutsubtree>` the child composition root is laid out
1967
+ // but not painted to screen — only the canvas 2D bitmap is visible — so
1968
+ // Page.captureScreenshot captures the injected canvas holding the LAST drawElement
1969
+ // frame (stale by ≥1 scene at a hard cut), REPLACING a good frame with a stale one.
1970
+ // Measured on 0531c45f: worker boundary frames showed the previous scene's video.
1971
+ // Default OFF → boundary frames fall through to produceDrawElementFrame, which draws
1972
+ // the CURRENT frame into the canvas. (Force old behavior with
1973
+ // HF_FAST_CAPTURE_BOUNDARY_SS=true.) See captureFrameCore for the serial rationale.
1974
+ if (session.clipBoundaryFrames?.has(frameIndex) &&
1975
+ process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
1976
+ const buffer = await pageScreenshotCapture(page, options);
1977
+ session.capturePerf.frames += 1;
1978
+ session.capturePerf.seekMs += seekMs;
1979
+ session.capturePerf.beforeCaptureMs += beforeCaptureMs;
1980
+ {
1981
+ const boundaryMs = Date.now() - startTime;
1982
+ session.capturePerf.totalMs += boundaryMs;
1983
+ session.capturePerf.frameMs.push(boundaryMs);
1984
+ }
1985
+ const boundaryResult = Promise.resolve(buffer);
1986
+ if (session.staticFrames)
1987
+ session.lastEncodeResult = boundaryResult;
1988
+ return { encodeResult: boundaryResult, captureTimeMs: Date.now() - startTime };
1989
+ }
1990
+ // Worker-encode is gated to the macOS GPU path (beginFrameTimeTicks === 0,
1991
+ // syncToPaintEvent = true); see initDrawElementOrTransparentBackground. The
1992
+ // BeginFrame branch present in the synchronous captureFrameCore is therefore
1993
+ // unreachable here and intentionally omitted.
1994
+ const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
1995
+ const captureTimeMs = Date.now() - startTime;
1996
+ session.capturePerf.frames += 1;
1997
+ session.capturePerf.seekMs += seekMs;
1998
+ session.capturePerf.beforeCaptureMs += beforeCaptureMs;
1999
+ // screenshotMs reflects produce time only (encode is async, not tracked here)
2000
+ session.capturePerf.screenshotMs += captureTimeMs - seekMs - beforeCaptureMs;
2001
+ session.capturePerf.totalMs += captureTimeMs;
2002
+ session.capturePerf.frameMs.push(captureTimeMs);
2003
+ // Task B: retain this encode result so a following static frame can reuse it.
2004
+ if (session.staticFrames)
2005
+ session.lastEncodeResult = encodeResult;
2006
+ return { encodeResult, captureTimeMs };
2007
+ }
2008
+ catch (captureError) {
2009
+ // Per-frame `No cached paint record`: fall back to screenshot for THIS frame
2010
+ // instead of aborting the render (clip-cut boundary / freshly-shown element).
2011
+ // The worker isn't involved for this frame; return a resolved encodeResult so
2012
+ // the pipeline loop writes it like any other. See fast-capture-limitations.md.
2013
+ if (isNoCachedPaintRecordError(captureError)) {
2014
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2015
+ console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
2016
+ `screenshot fallback for this frame (see fast-capture-limitations.md)`);
2017
+ const buffer = await pageScreenshotCapture(page, options);
2018
+ return { encodeResult: Promise.resolve(buffer), captureTimeMs: Date.now() - startTime };
2019
+ }
2020
+ // Mirror captureFrameCore: capture per-frame diagnostics (frame-error
2021
+ // PNG/HTML/JSON + console tail) before rethrowing so pipelined-path
2022
+ // failures are debuggable like the serial path.
2023
+ if (session.isInitialized) {
2024
+ await captureFrameErrorDiagnostics(session, frameIndex, time, captureError instanceof Error ? captureError : new Error(String(captureError)));
2025
+ }
2026
+ throw captureError;
2027
+ }
2028
+ }
2029
+ /**
2030
+ * Verification-grade single-frame recapture for the producer's blank-frame
2031
+ * guard. Unlike {@link captureFrameToBufferPipelined} it takes NO shortcuts
2032
+ * and has NO fallbacks, both of which can return the WRONG FRAME's pixels at
2033
+ * drain time:
2034
+ * - the static-dedup fast path returns session.lastEncodeResult, which by
2035
+ * drain time can hold a frame several indices AHEAD of the suspect frame;
2036
+ * - the per-frame "No cached paint record" screenshot fallback captures the
2037
+ * injected canvas — i.e. the LAST drawn drawElement frame, not this one.
2038
+ * Any failure here throws; the caller treats that as verification failure and
2039
+ * falls back the whole render (correct, never wrong-frame).
2040
+ */
2041
+ export async function recaptureDrawElementFrameForVerify(session, frameIndex, time) {
2042
+ const { page, options } = session;
2043
+ if (!session.isInitialized) {
2044
+ throw new Error("[FrameCapture] Session not initialized");
2045
+ }
2046
+ await prepareFrameForCapture(session, frameIndex, time);
2047
+ const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
2048
+ return encodeResult;
2049
+ }
2050
+ /**
2051
+ * P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
2052
+ * round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
2053
+ * batch (consecutive frame indices, none static-dedup'd, none opt-in
2054
+ * boundary-screenshot). On a mid-batch in-page failure the remaining frames are
2055
+ * re-captured through {@link captureFrameToBufferPipelined}, which owns the
2056
+ * per-frame screenshot-fallback semantics — so failure behavior is identical to
2057
+ * the unbatched path, just discovered at batch granularity.
2058
+ */
2059
+ export async function captureFramesBatchPipelined(session, frameIndices, times) {
2060
+ const { page, options } = session;
2061
+ if (!session.isInitialized) {
2062
+ throw new Error("[FrameCapture] Session not initialized");
2063
+ }
2064
+ const startTime = Date.now();
2065
+ const fps = fpsToNumber(options.fps);
2066
+ const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
2067
+ const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(page, quantized, options.width, options.height, options.quality ?? 80);
2068
+ const okCount = failedAt === null ? frameIndices.length : failedAt;
2069
+ const elapsed = Date.now() - startTime;
2070
+ session.capturePerf.frames += okCount;
2071
+ // Round-trips are fused — attribute the whole batch to produce time; each
2072
+ // frame gets the batch mean for the per-frame sample series.
2073
+ session.capturePerf.screenshotMs += elapsed;
2074
+ session.capturePerf.totalMs += elapsed;
2075
+ if (okCount > 0) {
2076
+ const perFrame = elapsed / okCount;
2077
+ for (let s2 = 0; s2 < okCount; s2++)
2078
+ session.capturePerf.frameMs.push(perFrame);
2079
+ }
2080
+ const results = [];
2081
+ for (let i = 0; i < okCount; i++) {
2082
+ const frameIndex = frameIndices[i];
2083
+ const encodeResult = encodeResults[i];
2084
+ if (frameIndex === undefined || !encodeResult)
2085
+ break;
2086
+ results.push({ frameIndex, encodeResult });
2087
+ }
2088
+ if (failedAt !== null) {
2089
+ console.log(`[engine] fast capture: batch produce failed at frame ` +
2090
+ `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
2091
+ `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
2092
+ for (let i = failedAt; i < frameIndices.length; i++) {
2093
+ const frameIndex = frameIndices[i];
2094
+ const time = times[i];
2095
+ if (frameIndex === undefined || time === undefined)
2096
+ break;
2097
+ const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
2098
+ results.push({ frameIndex, encodeResult });
2099
+ }
2100
+ }
2101
+ // Task B: retain the last encode result so a following static frame can reuse it.
2102
+ const last = results[results.length - 1];
2103
+ if (session.staticFrames && last)
2104
+ session.lastEncodeResult = last.encodeResult;
2105
+ return results;
2106
+ }
1400
2107
  /**
1401
2108
  * Perform one capture, throw away the buffer, and restore any session
1402
2109
  * side-effects (perf counters, BeginFrame damage tallies) so downstream
@@ -1478,6 +2185,9 @@ export async function closeCaptureSession(session) {
1478
2185
  // Example: page release succeeds, browser release throws → pageReleased=true
1479
2186
  // but browserReleased=false → second call no-ops on page and retries browser.
1480
2187
  // This matches the orchestrator's intent for HDR cleanup.
2188
+ if (session.workerEncodeEnabled && session.page && !session.pageReleased) {
2189
+ cleanupDrawElementWorkerEncode(session.page);
2190
+ }
1481
2191
  if (!session.pageReleased && session.page) {
1482
2192
  const pageClosed = await waitForCloseWithTimeout(session.page.close());
1483
2193
  if (!pageClosed) {
@@ -1509,6 +2219,7 @@ export function prepareCaptureSessionForReuse(session, outputDir, onBeforeCaptur
1509
2219
  beforeCaptureMs: 0,
1510
2220
  screenshotMs: 0,
1511
2221
  totalMs: 0,
2222
+ frameMs: [],
1512
2223
  };
1513
2224
  session.beginFrameHasDamageCount = 0;
1514
2225
  session.beginFrameNoDamageCount = 0;
@@ -1526,6 +2237,118 @@ export async function getCompositionDuration(session) {
1526
2237
  return window.__hf?.duration ?? 0;
1527
2238
  });
1528
2239
  }
2240
+ /**
2241
+ * Ungated-release safety net, part 1: capture K screenshot ground-truth frames
2242
+ * BEFORE the drawElement canvas is injected (the only window where a page
2243
+ * screenshot shows the live DOM). The producer's drain compares each DE frame
2244
+ * at these indices against its screenshot; a breach (or a blank frame that
2245
+ * survives one retry) throws DrawElementVerificationError, and the orchestrator
2246
+ * re-renders the whole job via the screenshot path.
2247
+ *
2248
+ * Env: HF_DE_VERIFY = sample count (default 4, clamp 0..8; 0 disables).
2249
+ * Deterministic index selection: fixed fractions of the timeline, nudged off
2250
+ * clip-cut boundaries (screenshot-vs-DE is legitimately ±1-frame desynced
2251
+ * there — Lim 6). Skipped for tiny comps (<10 frames) and under
2252
+ * HF_FORCE_DRAWELEMENT (debug escape hatch).
2253
+ *
2254
+ * False-positive bias is intentional: a nondeterministic comp may mismatch its
2255
+ * init-time screenshot → the render falls back to the screenshot path (slower,
2256
+ * never wrong). Cost when passing: ~K×(seek+screenshot) ≈ 150–300ms at init.
2257
+ */
2258
+ async function captureDeVerificationFrames(session, page, logInitPhase) {
2259
+ const kRaw = Number(process.env.HF_DE_VERIFY ?? "4");
2260
+ const k = Number.isFinite(kRaw) ? Math.max(0, Math.min(8, Math.floor(kRaw))) : 4;
2261
+ if (k === 0 || process.env.HF_FORCE_DRAWELEMENT === "1")
2262
+ return;
2263
+ if (session.options.format === "png")
2264
+ return; // worker-encode drain (the consumer) is jpeg-only
2265
+ const fps = fpsToNumber(session.options.fps);
2266
+ // Prefer the producer-resolved duration (the range that will actually be
2267
+ // drained). The page's raw __hf.duration can exceed it — timelines outrun
2268
+ // their data-duration, and infinite-repeat GSAP reports a huge sentinel —
2269
+ // and indices derived from it would never be drained, silently disarming
2270
+ // verification for exactly the comps that need it.
2271
+ const duration = session.options.compositionDurationSeconds ??
2272
+ (await page.evaluate(() => window.__hf?.duration ?? 0));
2273
+ const totalFrames = Math.floor(duration * fps);
2274
+ if (totalFrames < 10)
2275
+ return;
2276
+ if (duration > 3600) {
2277
+ // No producer duration and the page reports an implausible one.
2278
+ logInitPhase(`drawElement self-verify skipped: implausible duration ${duration}s`);
2279
+ return;
2280
+ }
2281
+ // Ground truth must show what the real capture paths would show: <video>
2282
+ // pixels come from the onBeforeCapture injector. When this session has no
2283
+ // injector (e.g. a probe session initialized before the render wires one
2284
+ // up) a video comp's truth would screenshot black boxes and every sample
2285
+ // would false-positive into the screenshot fallback — skip instead.
2286
+ if (!session.onBeforeCapture) {
2287
+ const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
2288
+ if (hasVideos) {
2289
+ logInitPhase("drawElement self-verify skipped: video comp without frame injector");
2290
+ return;
2291
+ }
2292
+ }
2293
+ const boundary = await computeClipBoundaryFrames(page, fps);
2294
+ // Ascending order, and seek frame 0 first: GSAP .from()/overlapping tweens
2295
+ // lazily record their start values on FIRST seek — scrubbing mid-timeline
2296
+ // before the render's frame-0 seek corrupts those caches for the whole
2297
+ // render (the detectCssEffectRisk lesson), and because DE frames and truth
2298
+ // would share the corruption, PSNR would pass on the damaged output.
2299
+ // Seeking 0 → ascending reproduces the render's own seek order.
2300
+ const fractions = Array.from({ length: k }, (_, i) => (i + 1) / (k + 1));
2301
+ const seekTo = async (t) => {
2302
+ await page.evaluate((tt) => {
2303
+ const hf = window.__hf;
2304
+ if (hf && typeof hf.seek === "function")
2305
+ hf.seek(tt);
2306
+ }, t);
2307
+ };
2308
+ await seekTo(quantizeTimeToFrame(0, fps));
2309
+ // Force one frame so lazy tween initialization paints at t=0 state.
2310
+ await pageScreenshotCapture(page, session.options);
2311
+ const frames = new Map();
2312
+ for (const f of fractions) {
2313
+ let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
2314
+ // Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
2315
+ // if the nudge saturates on a boundary index, skip the sample entirely.
2316
+ let guard = 0;
2317
+ while (boundary.has(idx) && guard++ < 6)
2318
+ idx = Math.min(totalFrames - 1, idx + 2);
2319
+ if (boundary.has(idx))
2320
+ continue;
2321
+ if (frames.has(idx))
2322
+ continue;
2323
+ const t = quantizeTimeToFrame(idx / fps, fps);
2324
+ await seekTo(t);
2325
+ // Video frame injection (same hook the real capture paths run) — without
2326
+ // it, <video> elements screenshot black and every video comp would
2327
+ // false-positive into the screenshot fallback.
2328
+ if (session.onBeforeCapture)
2329
+ await session.onBeforeCapture(page, t);
2330
+ // Double-capture: the first screenshot forces a frame, which is what runs
2331
+ // rAF-driven callbacks (count-up text counters land a tick after seek()
2332
+ // returns — a single immediate screenshot captures stale text and
2333
+ // false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
2334
+ // values while the DE frame was correct). NOTE: waiting on rAF via
2335
+ // evaluate instead deadlocks — headless only fires rAF when a frame is
2336
+ // produced, and nothing produces one until a screenshot asks.
2337
+ await pageScreenshotCapture(page, session.options);
2338
+ frames.set(idx, await pageScreenshotCapture(page, session.options));
2339
+ }
2340
+ // Leave the page at frame 0 so the render's first seek starts from the
2341
+ // same state as an unverified render.
2342
+ await seekTo(quantizeTimeToFrame(0, fps));
2343
+ session.deVerifyFrames = frames;
2344
+ logInitPhase(`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`);
2345
+ }
2346
+ function medianOf(samples) {
2347
+ if (samples.length === 0)
2348
+ return 0;
2349
+ const sorted = [...samples].sort((a, b) => a - b);
2350
+ return Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0);
2351
+ }
1529
2352
  export function getCapturePerfSummary(session) {
1530
2353
  const frames = Math.max(1, session.capturePerf.frames);
1531
2354
  return {
@@ -1534,12 +2357,20 @@ export function getCapturePerfSummary(session) {
1534
2357
  avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
1535
2358
  avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
1536
2359
  avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
2360
+ p50TotalMs: medianOf(session.capturePerf.frameMs),
1537
2361
  staticDedupReused: session.staticDedupCount ?? 0,
1538
2362
  staticDedupEnabled: session.staticDedupEnabled ?? false,
1539
2363
  // armed ⟺ a non-empty static set survived verification; predicted === its size.
1540
2364
  staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
1541
2365
  staticDedupPredicted: session.staticFrames?.size ?? 0,
1542
2366
  staticDedupSkipReason: session.staticDedupSkipReason,
2367
+ captureMode: session.captureMode,
2368
+ deGateReason: session.deGateReason,
2369
+ deWorkerEncode: session.workerEncodeEnabled ?? false,
2370
+ deVerifyArmed: session.deVerifyFrames?.size ?? 0,
2371
+ deVerifyInitMs: session.deVerifyInitMs ?? 0,
2372
+ deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
2373
+ deNcprFallbacks: session.deNcprFallbacks ?? 0,
1543
2374
  };
1544
2375
  }
1545
2376
  // ── Transient browser error classification ─────────────────────────────────