@hyperframes/engine 0.7.37 → 0.7.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/config.d.ts +25 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +43 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/browserManager.d.ts +1 -1
  10. package/dist/services/browserManager.d.ts.map +1 -1
  11. package/dist/services/browserManager.js.map +1 -1
  12. package/dist/services/drawElementService.d.ts +149 -0
  13. package/dist/services/drawElementService.d.ts.map +1 -0
  14. package/dist/services/drawElementService.js +957 -0
  15. package/dist/services/drawElementService.js.map +1 -0
  16. package/dist/services/frameCapture.d.ts +112 -0
  17. package/dist/services/frameCapture.d.ts.map +1 -1
  18. package/dist/services/frameCapture.js +792 -31
  19. package/dist/services/frameCapture.js.map +1 -1
  20. package/dist/services/screenshotService.d.ts +21 -4
  21. package/dist/services/screenshotService.d.ts.map +1 -1
  22. package/dist/services/screenshotService.js +81 -11
  23. package/dist/services/screenshotService.js.map +1 -1
  24. package/dist/services/threeDProjection.d.ts +80 -0
  25. package/dist/services/threeDProjection.d.ts.map +1 -0
  26. package/dist/services/threeDProjection.js +980 -0
  27. package/dist/services/threeDProjection.js.map +1 -0
  28. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  29. package/dist/services/videoFrameInjector.js +29 -22
  30. package/dist/services/videoFrameInjector.js.map +1 -1
  31. package/dist/types.d.ts +28 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
@@ -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,225 @@ 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
+ // Self-verification ground truth: must ALSO run pre-injection — after the
378
+ // canvas wraps the root, a page screenshot shows the canvas's last-drawn
379
+ // bitmap, not the live DOM (see the Lim 6 boundary-screenshot note).
380
+ {
381
+ const verifyStart = Date.now();
382
+ await captureDeVerificationFrames(session, page, logInitPhase);
383
+ session.deVerifyInitMs = Date.now() - verifyStart;
384
+ }
385
+ await injectDrawElementCanvas(page, session.options.width, session.options.height);
386
+ if (transparent) {
387
+ await initTransparentBackground(session.page);
388
+ }
389
+ session.captureMode = "drawelement";
390
+ session.drawElementReady = true;
391
+ logInitPhase("drawElement canvas injected");
392
+ // Lim 6: clip-cut boundary frames — screenshot these instead of drawElement.
393
+ if (process.env.HF_FAST_CAPTURE_BOUNDARY_SS !== "false" && !forceDE) {
394
+ const fps = fpsToNumber(session.options.fps);
395
+ const boundaryFrames = await computeClipBoundaryFrames(page, fps);
396
+ if (boundaryFrames.size > 0) {
397
+ session.clipBoundaryFrames = boundaryFrames;
398
+ logInitPhase(`screenshot fallback: ${boundaryFrames.size} clip-boundary frame(s)`);
399
+ }
400
+ }
401
+ // Worker-encode pipeline: macOS hardware GPU path only (syncToPaintEvent=true,
402
+ // beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker) and transparent
403
+ // (PNG) output — those use the existing synchronous path unchanged.
404
+ const workerEncodeEnabled = (session.config?.enableDrawElementWorkerEncode ?? false) &&
405
+ !transparent &&
406
+ session.beginFrameTimeTicks === 0;
407
+ if (workerEncodeEnabled) {
408
+ await initDrawElementWorkerEncode(page);
409
+ session.workerEncodeEnabled = true;
410
+ logInitPhase("drawElement worker encode initialized");
411
+ }
412
+ }
413
+ }
414
+ else if (session.options.format === "png") {
415
+ await initTransparentBackground(session.page);
416
+ }
417
+ }
170
418
  // fallow-ignore-next-line unit-size
171
419
  export async function createCaptureSession(serverUrl, outputDir, options, onBeforeCapture = null, config) {
172
420
  if (!existsSync(outputDir))
@@ -176,16 +424,38 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
176
424
  // `options.format === "png"` for transparent capture should also set
177
425
  // `config.forceScreenshot = true` (the producer's renderOrchestrator does this
178
426
  // automatically when `RenderConfig.format` is an alpha-capable value).
427
+ // Exception: `useDrawElement=true` with png self-manages the screenshot-browser
428
+ // requirement (both the SwiftShader fallback and the GPU transparent path need
429
+ // a screenshot-launched browser — the SwiftShader path calls Page.captureScreenshot
430
+ // which hangs on a BeginFrame browser, and the GPU path doesn't need BeginFrame
431
+ // because the compositor runs freely on a screenshot-launched browser).
179
432
  const headlessShell = resolveHeadlessShellPath(config);
180
433
  const isLinux = process.platform === "linux";
181
434
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
435
+ const useDrawElement = config?.useDrawElement ?? false;
436
+ const drawElementTransparent = useDrawElement && options.format === "png";
437
+ // drawElement and page-side shader compositing are mutually incompatible
438
+ // capture strategies: drawElement reads the composition root's paint records
439
+ // directly and skips the prepare→micro-screenshot→resolve protocol (the
440
+ // micro-screenshot would also hang on an opaque/beginframe-launched browser).
441
+ // `resolveConfig` forces page-side compositing off whenever useDrawElement is
442
+ // set, so this only trips for a direct caller that bypassed resolveConfig and
443
+ // passed both flags — warn once and treat page-side as disabled.
444
+ if (useDrawElement &&
445
+ (config?.enablePageSideCompositing ?? DEFAULT_CONFIG.enablePageSideCompositing)) {
446
+ console.warn("[engine] useDrawElement is incompatible with page-side shader compositing — " +
447
+ "ignoring enablePageSideCompositing for this render. Prefer resolveConfig, " +
448
+ "which disables page-side compositing automatically for fast-capture renders.");
449
+ }
182
450
  // BeginFrame's screenshot does not honor a viewport `deviceScaleFactor`
183
451
  // (the captured surface is sized by the OS window in CSS pixels regardless
184
452
  // of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
185
453
  // need explicit clip+scale on `Page.captureScreenshot`, so fall back to
186
454
  // the screenshot path for any DPR > 1.
187
455
  const supersampling = (options.deviceScaleFactor ?? 1) > 1;
188
- const preMode = headlessShell && isLinux && !forceScreenshot && !supersampling ? "beginframe" : "screenshot";
456
+ const preMode = headlessShell && isLinux && !forceScreenshot && !supersampling && !drawElementTransparent
457
+ ? "beginframe"
458
+ : "screenshot";
189
459
  const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
190
460
  const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
191
461
  chromePath: headlessShell ?? undefined,
@@ -226,6 +496,42 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
226
496
  w.__name = (fn, _name) => fn;
227
497
  }
228
498
  });
499
+ // Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
500
+ // preserveDrawingBuffer before any page script can create a context — their
501
+ // paint records freeze at the first frame, so captureDrawElementFrame
502
+ // composites their live content via drawImage instead (see
503
+ // instrumentAcceleratedCanvases). Must be registered before navigation.
504
+ if (useDrawElement) {
505
+ await page.evaluateOnNewDocument(instrumentAcceleratedCanvases);
506
+ }
507
+ // Signal the producer's GSAP stub to rewrite `opacity` → `autoAlpha` in tween
508
+ // vars (stacked opacity-0 caption layers break drawElementImage capture).
509
+ //
510
+ // DEFAULT OFF (opt in with HF_FAST_CAPTURE_AUTOALPHA=true). The rewrite is baked
511
+ // at tween-creation (page load) and `retractAutoAlphaFlag` (flag-only) can't
512
+ // un-bake it: GSAP autoAlpha sets visibility:hidden under seek capture and renders
513
+ // ~28 dB below a clean opacity baseline (corpus eval 2026-06-16, e.g.
514
+ // 05f22830/06167790: autoAlpha-off = ∞, autoAlpha-on = 28 dB). The rewrite was a
515
+ // workaround for the stacked-fade opacity-layer drop (crbug 521861819), now fixed
516
+ // in Chrome 151 — so it damages more than it fixes. Re-enable per render only if a
517
+ // drawElement comp shows transparent-layer drop on the pinned 151 floor.
518
+ if (useDrawElement && process.env.HF_FAST_CAPTURE_AUTOALPHA === "true") {
519
+ await page.evaluateOnNewDocument(() => {
520
+ window.__HF_FAST_CAPTURE_AUTOALPHA__ = true;
521
+ });
522
+ }
523
+ // Re-apply the captured root's own computed opacity to the 2D context:
524
+ // drawElementImage does not reflect post-paint changes to compositor-applied
525
+ // properties on the captured element itself (the root's opacity is applied by
526
+ // its parent at composite time, never baked into its content snapshot), so an
527
+ // animated root fade renders at full opacity. captureDrawElementFrame corrects
528
+ // this by the ratio current/base opacity (no-op for a static root). On by
529
+ // default; disable with HF_FAST_CAPTURE_ROOT_PROPS=false.
530
+ if (useDrawElement && process.env.HF_FAST_CAPTURE_ROOT_PROPS !== "false") {
531
+ await page.evaluateOnNewDocument(() => {
532
+ window.__HF_ROOT_PROPS__ = true;
533
+ });
534
+ }
229
535
  // Inject render-time variable overrides before any page script runs, so the
230
536
  // runtime helper `getVariables()` returns the merged result on its first
231
537
  // call. Pass the JSON string and parse inside the page so we don't require
@@ -280,6 +586,7 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
280
586
  totalMs: 0,
281
587
  },
282
588
  captureMode,
589
+ launchCaptureMode: captureMode,
283
590
  beginFrameTimeTicks: 0,
284
591
  // Frame interval in ms: 1000 * den / num. For 30/1 → 33.333…, for
285
592
  // 30000/1001 (NTSC) → 33.366…. JavaScript number precision is fine at
@@ -444,11 +751,16 @@ async function pollHfReady(page, timeoutMs, intervalMs = 100) {
444
751
  `renderReady=${diag.renderReady}, duration=${diag.duration}`);
445
752
  }
446
753
  async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
754
+ // Hosts may opt out of the timeline wait with `data-no-timeline` —
755
+ // compositions driven purely by CSS animations / rAF (the render-compat
756
+ // contract) never register window.__timelines[id], and without the opt-out
757
+ // they stall here for the full playerReadyTimeout (45 s) on every render.
447
758
  const expression = `(function() {
448
759
  var hosts = document.querySelectorAll("[data-composition-id]");
449
760
  if (hosts.length === 0) return true;
450
761
  var timelines = window.__timelines || {};
451
762
  for (var i = 0; i < hosts.length; i++) {
763
+ if (hosts[i].hasAttribute("data-no-timeline")) continue;
452
764
  var id = hosts[i].getAttribute("data-composition-id");
453
765
  if (!id) continue;
454
766
  if (!timelines[id]) return false;
@@ -476,13 +788,15 @@ async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
476
788
  var timelines = window.__timelines || {};
477
789
  var m = [];
478
790
  for (var i = 0; i < hosts.length; i++) {
791
+ if (hosts[i].hasAttribute("data-no-timeline")) continue;
479
792
  var id = hosts[i].getAttribute("data-composition-id");
480
793
  if (id && !timelines[id]) m.push(id);
481
794
  }
482
795
  return m.join(", ");
483
796
  })()`);
484
797
  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.`);
798
+ `Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes. ` +
799
+ `Compositions intentionally driven without GSAP timelines (CSS animations / rAF) can mark the host with data-no-timeline to skip this wait.`);
486
800
  }
487
801
  }
488
802
  async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
@@ -760,16 +1074,8 @@ export async function initializeSession(session) {
760
1074
  `Continuing render — affected videos will appear as blank/black frames.`);
761
1075
  }
762
1076
  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
- }
1077
+ // drawElement or transparent-background init runs after page is fully ready.
1078
+ await initDrawElementOrTransparentBackground(session, page, logInitPhase);
773
1079
  await armStaticDedup(session, session.page, logInitPhase);
774
1080
  session.isInitialized = true;
775
1081
  return;
@@ -896,15 +1202,15 @@ export async function initializeSession(session) {
896
1202
  // constant so chunk workers on different hosts compute the same baseline.
897
1203
  const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
898
1204
  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
- }
1205
+ // drawElement or transparent-background init runs after page is fully ready.
1206
+ // IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
1207
+ // drawelement branch gates its BeginFrame call on `beginFrameTimeTicks > 0`;
1208
+ // if this ran first, ticks would be 0 and the paused compositor would never
1209
+ // advance for opaque drawElement on Linux. (In beginframe-launched mode,
1210
+ // transparent is always false — useDrawElement+png forces preMode="screenshot"
1211
+ // upstream so the SwiftShader fallback inside the helper is dead-but-harmless
1212
+ // defense-in-depth here.)
1213
+ await initDrawElementOrTransparentBackground(session, page, logInitPhase);
908
1214
  await armStaticDedup(session, session.page, logInitPhase);
909
1215
  session.isInitialized = true;
910
1216
  }
@@ -965,7 +1271,9 @@ async function prepareFrameForCapture(session, frameIndex, time) {
965
1271
  // 1. prepare — clone scenes (now containing injected video <img>s)
966
1272
  // 2. micro-screenshot — force browser to paint cloned elements
967
1273
  // 3. resolve — drawElementImage reads paint records, shader composites
968
- if (hasPendingComposite && session.captureMode !== "beginframe") {
1274
+ if (hasPendingComposite &&
1275
+ session.captureMode !== "beginframe" &&
1276
+ session.captureMode !== "drawelement") {
969
1277
  await page.evaluate(async () => {
970
1278
  const w = window;
971
1279
  if (typeof w.__hf_page_composite_prepare === "function") {
@@ -1249,6 +1557,14 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
1249
1557
  * via HF_STATIC_DEDUP_SAMPLES (default 24).
1250
1558
  */
1251
1559
  async function armStaticDedup(session, page, logInitPhase) {
1560
+ // Idempotent: the drawElement init path arms dedup BEFORE canvas injection
1561
+ // (verification screenshots need the un-injected DOM), and initializeSession
1562
+ // calls this again unconditionally afterwards. Once staticFrames is
1563
+ // populated, re-running would overwrite the armed state with
1564
+ // skipReason="capture_mode" (captureMode is "drawelement" by then) —
1565
+ // contradictory telemetry — and re-run the verification seeks. No-op instead.
1566
+ if (session.staticFrames || session.staticDedupSkipReason)
1567
+ return;
1252
1568
  // Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
1253
1569
  // EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
1254
1570
  // Default-on: only an explicit `staticFrameDedup === false` (resolved from
@@ -1315,10 +1631,90 @@ async function armStaticDedup(session, page, logInitPhase) {
1315
1631
  `(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`);
1316
1632
  }
1317
1633
  /**
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.
1634
+ * Walk window.__timelines and collect frame intervals where GSAP tweens animate
1635
+ * compositor-incompatible properties (blend-mode, 3D transforms, clip-path, mask).
1636
+ * drawElement cannot reproduce these effects mid-tween capture those frames via
1637
+ * screenshot instead. opacity/filter fades were dropped from the set once Chrome 151
1638
+ * fixed crbug 521861819. See docs/fast-capture-limitations.md Lim 7.
1639
+ *
1640
+ * Returns the union of at-risk frame indices (±1 margin around each tween interval)
1641
+ * and totalFrames (for fraction computation by the caller).
1321
1642
  */
1643
+ async function computeTimelineAtRiskFrames(page, fps) {
1644
+ const result = await page.evaluate(() => {
1645
+ // opacity/autoAlpha/filter fades were removed from this set once Chrome 151
1646
+ // fixed crbug 521861819 (drawElementImage dropped promoted opacity layers
1647
+ // mid-fade) — those now render correctly on the drawElement path. backdrop-filter
1648
+ // and filter:blur stay gated by detectCssEffectRisk (architectural single-element
1649
+ // capture limit, not 521861819). What remains here is the per-tween backstop for
1650
+ // effects drawElementImage still cannot reproduce mid-animation: mix-blend-mode,
1651
+ // CSS 3D transforms (crbug 522872457), clip-path, and mask.
1652
+ const AT_RISK_PROPS = new Set([
1653
+ "backdropFilter",
1654
+ "backdrop-filter",
1655
+ "mixBlendMode",
1656
+ "mix-blend-mode",
1657
+ "rotationX",
1658
+ "rotationY",
1659
+ "rotateX",
1660
+ "rotateY",
1661
+ "z",
1662
+ "translateZ",
1663
+ "clipPath",
1664
+ "clip-path",
1665
+ "maskImage",
1666
+ "mask",
1667
+ ]);
1668
+ function walkTimeline(tl, offset, out) {
1669
+ if (typeof tl.getChildren !== "function")
1670
+ return;
1671
+ for (const child of tl.getChildren(false, true, true)) {
1672
+ const childStart = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
1673
+ const childDur = typeof child.duration === "function" ? child.duration() : 0;
1674
+ if (typeof child.getChildren === "function") {
1675
+ walkTimeline(child, childStart, out);
1676
+ }
1677
+ else {
1678
+ const vars = child.vars || {};
1679
+ if (Object.keys(vars).some((k) => AT_RISK_PROPS.has(k))) {
1680
+ out.push({ start: childStart, end: childStart + childDur });
1681
+ }
1682
+ }
1683
+ }
1684
+ }
1685
+ const w = window;
1686
+ const timelines = w.__timelines || {};
1687
+ const intervals = [];
1688
+ for (const tl of Object.values(timelines)) {
1689
+ if (tl && typeof tl.getChildren === "function") {
1690
+ walkTimeline(tl, 0, intervals);
1691
+ }
1692
+ }
1693
+ const duration = w.__hf?.duration ?? 0;
1694
+ return { intervals, duration };
1695
+ });
1696
+ const { intervals, duration } = result;
1697
+ const frames = new Set();
1698
+ for (const { start, end } of intervals) {
1699
+ const lo = Math.floor(start * fps) - 1;
1700
+ const hi = Math.ceil(end * fps) + 1;
1701
+ for (let f = Math.max(0, lo); f <= hi; f++) {
1702
+ frames.add(f);
1703
+ }
1704
+ }
1705
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
1706
+ return { frames, totalFrames };
1707
+ }
1708
+ /**
1709
+ * True for the drawElement `InvalidStateError: No cached paint record for element`
1710
+ * thrown when a subtree element has no paint record for the current frame (display
1711
+ * toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
1712
+ * whole-comp — callers fall back to screenshot for the single frame.
1713
+ */
1714
+ function isNoCachedPaintRecordError(err) {
1715
+ const msg = err instanceof Error ? err.message : String(err);
1716
+ return msg.includes("No cached paint record");
1717
+ }
1322
1718
  async function captureFrameCore(session, frameIndex, time) {
1323
1719
  const { page, options } = session;
1324
1720
  const startTime = Date.now();
@@ -1358,6 +1754,84 @@ async function captureFrameCore(session, frameIndex, time) {
1358
1754
  session.beginFrameNoDamageCount++;
1359
1755
  screenshotBuffer = result.buffer;
1360
1756
  }
1757
+ else if (session.captureMode === "drawelement" &&
1758
+ session.clipBoundaryFrames?.has(frameIndex) &&
1759
+ process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
1760
+ // Lim 6 (serial path): proactively screenshotting clip-boundary frames is now
1761
+ // OPT-IN (was default-on). It is net-harmful: drawElement renders most boundary
1762
+ // frames correctly, but Page.captureScreenshot in drawElement mode captures the
1763
+ // injected canvas (unpainted at render start → white; mid-render → ~1-frame
1764
+ // stale), so the "fallback" REPLACES good frames with damaged ones (validated:
1765
+ // 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
1766
+ // boundary failure modes are now caught reactively below — the throw case by
1767
+ // isNoCachedPaintRecordError, the silent-solid-black case by the small-frame
1768
+ // blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
1769
+ // handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
1770
+ // path keeps proactive boundary-SS (it has no blank-guard); see
1771
+ // captureFrameToBufferPipelined and docs/fast-capture-limitations.md.
1772
+ screenshotBuffer = await pageScreenshotCapture(page, options);
1773
+ }
1774
+ else if (session.captureMode === "drawelement") {
1775
+ // Advance compositor state via BeginFrame when available (Linux headless-shell);
1776
+ // on macOS the compositor advances naturally without BeginFrame.
1777
+ if (session.beginFrameTimeTicks > 0) {
1778
+ const client = await getCdpSession(page);
1779
+ await client.send("HeadlessExperimental.beginFrame", {
1780
+ frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
1781
+ interval: session.beginFrameIntervalMs,
1782
+ noDisplayUpdates: false,
1783
+ // no screenshot param — we capture via canvas
1784
+ });
1785
+ }
1786
+ try {
1787
+ screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
1788
+ // Paint-event sync only without BeginFrame (macOS / screenshot-launched):
1789
+ // under BeginFrame control the per-frame beginFrame above already painted
1790
+ // a fresh snapshot, and no further paint would arrive during a wait.
1791
+ session.beginFrameTimeTicks === 0);
1792
+ // Silent-blank-drop guard: drawElement occasionally returns a blank/dropped
1793
+ // frame WITHOUT throwing (paint-record miss; the throw case is handled below).
1794
+ // Such a frame's JPEG is anomalously tiny vs the comp's running median (a blank
1795
+ // 1080p frame ~5-9 KB; content frames 50 KB-1 MB). Re-capture via screenshot
1796
+ // (ground truth) — harmless for legitimately simple frames (screenshot matches).
1797
+ // Catches scattered intermittent drops (e.g. 4001da8e: 11 blanks in 9300 frames,
1798
+ // 9.7 dB) that no static gate can see. PNG/transparent excluded (alpha sizing
1799
+ // differs and that path is its own).
1800
+ if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
1801
+ const sizes = (session.deFrameSizes ??= []);
1802
+ const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
1803
+ const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
1804
+ const floor = Math.max(20000, median * 0.12);
1805
+ if (screenshotBuffer.length < floor) {
1806
+ console.log(`[engine] fast capture: frame ${frameIndex} — drawElement frame anomalously ` +
1807
+ `small (${screenshotBuffer.length}B < ${Math.round(floor)}B, likely a silent ` +
1808
+ `paint-record drop); screenshot fallback (see fast-capture-limitations.md)`);
1809
+ screenshotBuffer = await pageScreenshotCapture(page, options);
1810
+ }
1811
+ else {
1812
+ if (sizes.length >= 60)
1813
+ sizes.shift();
1814
+ sizes.push(screenshotBuffer.length);
1815
+ }
1816
+ }
1817
+ }
1818
+ catch (err) {
1819
+ // drawElementImage throws `InvalidStateError: No cached paint record for
1820
+ // element` when an element in the subtree has no paint record this frame
1821
+ // (display toggled / detached / freshly-shown at a clip-cut boundary). This
1822
+ // is a per-frame condition, not a whole-comp one — fall back to screenshot
1823
+ // for THIS frame instead of aborting the render. See fast-capture-limitations.md.
1824
+ if (isNoCachedPaintRecordError(err)) {
1825
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
1826
+ console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
1827
+ `screenshot fallback for this frame (see fast-capture-limitations.md)`);
1828
+ screenshotBuffer = await pageScreenshotCapture(page, options);
1829
+ }
1830
+ else {
1831
+ throw err;
1832
+ }
1833
+ }
1834
+ }
1361
1835
  else {
1362
1836
  screenshotBuffer = await pageScreenshotCapture(page, options);
1363
1837
  }
@@ -1381,14 +1855,24 @@ async function captureFrameCore(session, frameIndex, time) {
1381
1855
  }
1382
1856
  }
1383
1857
  export async function captureFrame(session, frameIndex, time) {
1384
- const { options, outputDir } = session;
1385
1858
  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);
1859
+ const framePath = writeCapturedFrame(session, frameIndex, buffer);
1390
1860
  return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
1391
1861
  }
1862
+ /**
1863
+ * Write an already-captured frame buffer to the session's output dir using the
1864
+ * canonical `frame_NNNNNN.{jpg,png}` naming. `fileIndex` is the ENCODER-facing
1865
+ * index (0-based within the captured range), which may differ from the absolute
1866
+ * composition frame index used for seeking/boundary lookups. Extracted so the
1867
+ * disk worker-encode pipeline can write a buffer produced by
1868
+ * `captureFrameToBufferPipelined` without duplicating the naming convention.
1869
+ */
1870
+ export function writeCapturedFrame(session, fileIndex, buffer) {
1871
+ const ext = session.options.format === "png" ? "png" : "jpg";
1872
+ const framePath = join(session.outputDir, `frame_${String(fileIndex).padStart(6, "0")}.${ext}`);
1873
+ writeFileSync(framePath, buffer);
1874
+ return framePath;
1875
+ }
1392
1876
  /**
1393
1877
  * Capture a frame and return the screenshot as a Buffer instead of writing to disk.
1394
1878
  * Used by the streaming encode pipeline to pipe frames directly to FFmpeg stdin.
@@ -1397,6 +1881,167 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
1397
1881
  const { buffer, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
1398
1882
  return { buffer, captureTimeMs };
1399
1883
  }
1884
+ /**
1885
+ * Pipelined drawElement frame capture for the worker-encode path.
1886
+ *
1887
+ * Performs seek prep + paint-wait + drawElementImage + composite +
1888
+ * `createImageBitmap` + transfers the bitmap to the in-page encode worker.
1889
+ * Returns `encodeResult` immediately (before the worker finishes encoding).
1890
+ * The caller overlaps frame N's encode with frame N+1's produce phase.
1891
+ *
1892
+ * Requirements:
1893
+ * - `session.workerEncodeEnabled` must be true (set by initializeSession when
1894
+ * `config.enableDrawElementWorkerEncode` is true and mode resolved to drawelement).
1895
+ * - JPEG format only. PNG falls back to `captureFrameToBuffer`.
1896
+ * - macOS hardware GPU path (syncToPaintEvent=true, beginFrameTimeTicks=0).
1897
+ * BeginFrame (Linux) uses the standard synchronous path unchanged.
1898
+ */
1899
+ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
1900
+ const { page, options } = session;
1901
+ const startTime = Date.now();
1902
+ // Task B: static-frame dedup (worker path). Reuse the prior frame's encode result
1903
+ // and skip the seek + drawElement + encode entirely. Same predicate as the serial
1904
+ // path; clip-cut frames are excluded from staticFrames so they always capture.
1905
+ if (session.staticFrames?.has(frameIndex) && session.lastEncodeResult) {
1906
+ session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
1907
+ session.capturePerf.frames += 1;
1908
+ return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
1909
+ }
1910
+ try {
1911
+ const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
1912
+ void quantizedTime;
1913
+ // Lim 6: clip-cut boundary frame — screenshot ONLY when opt-in, matching the serial
1914
+ // path (captureFrameCore). Proactive boundary screenshots in drawElement mode are
1915
+ // net-HARMFUL: with `<canvas layoutsubtree>` the child composition root is laid out
1916
+ // but not painted to screen — only the canvas 2D bitmap is visible — so
1917
+ // Page.captureScreenshot captures the injected canvas holding the LAST drawElement
1918
+ // frame (stale by ≥1 scene at a hard cut), REPLACING a good frame with a stale one.
1919
+ // Measured on 0531c45f: worker boundary frames showed the previous scene's video.
1920
+ // Default OFF → boundary frames fall through to produceDrawElementFrame, which draws
1921
+ // the CURRENT frame into the canvas. (Force old behavior with
1922
+ // HF_FAST_CAPTURE_BOUNDARY_SS=true.) See captureFrameCore for the serial rationale.
1923
+ if (session.clipBoundaryFrames?.has(frameIndex) &&
1924
+ process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
1925
+ const buffer = await pageScreenshotCapture(page, options);
1926
+ session.capturePerf.frames += 1;
1927
+ session.capturePerf.seekMs += seekMs;
1928
+ session.capturePerf.beforeCaptureMs += beforeCaptureMs;
1929
+ session.capturePerf.totalMs += Date.now() - startTime;
1930
+ const boundaryResult = Promise.resolve(buffer);
1931
+ if (session.staticFrames)
1932
+ session.lastEncodeResult = boundaryResult;
1933
+ return { encodeResult: boundaryResult, captureTimeMs: Date.now() - startTime };
1934
+ }
1935
+ // Worker-encode is gated to the macOS GPU path (beginFrameTimeTicks === 0,
1936
+ // syncToPaintEvent = true); see initDrawElementOrTransparentBackground. The
1937
+ // BeginFrame branch present in the synchronous captureFrameCore is therefore
1938
+ // unreachable here and intentionally omitted.
1939
+ const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
1940
+ const captureTimeMs = Date.now() - startTime;
1941
+ session.capturePerf.frames += 1;
1942
+ session.capturePerf.seekMs += seekMs;
1943
+ session.capturePerf.beforeCaptureMs += beforeCaptureMs;
1944
+ // screenshotMs reflects produce time only (encode is async, not tracked here)
1945
+ session.capturePerf.screenshotMs += captureTimeMs - seekMs - beforeCaptureMs;
1946
+ session.capturePerf.totalMs += captureTimeMs;
1947
+ // Task B: retain this encode result so a following static frame can reuse it.
1948
+ if (session.staticFrames)
1949
+ session.lastEncodeResult = encodeResult;
1950
+ return { encodeResult, captureTimeMs };
1951
+ }
1952
+ catch (captureError) {
1953
+ // Per-frame `No cached paint record`: fall back to screenshot for THIS frame
1954
+ // instead of aborting the render (clip-cut boundary / freshly-shown element).
1955
+ // The worker isn't involved for this frame; return a resolved encodeResult so
1956
+ // the pipeline loop writes it like any other. See fast-capture-limitations.md.
1957
+ if (isNoCachedPaintRecordError(captureError)) {
1958
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
1959
+ console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
1960
+ `screenshot fallback for this frame (see fast-capture-limitations.md)`);
1961
+ const buffer = await pageScreenshotCapture(page, options);
1962
+ return { encodeResult: Promise.resolve(buffer), captureTimeMs: Date.now() - startTime };
1963
+ }
1964
+ // Mirror captureFrameCore: capture per-frame diagnostics (frame-error
1965
+ // PNG/HTML/JSON + console tail) before rethrowing so pipelined-path
1966
+ // failures are debuggable like the serial path.
1967
+ if (session.isInitialized) {
1968
+ await captureFrameErrorDiagnostics(session, frameIndex, time, captureError instanceof Error ? captureError : new Error(String(captureError)));
1969
+ }
1970
+ throw captureError;
1971
+ }
1972
+ }
1973
+ /**
1974
+ * Verification-grade single-frame recapture for the producer's blank-frame
1975
+ * guard. Unlike {@link captureFrameToBufferPipelined} it takes NO shortcuts
1976
+ * and has NO fallbacks, both of which can return the WRONG FRAME's pixels at
1977
+ * drain time:
1978
+ * - the static-dedup fast path returns session.lastEncodeResult, which by
1979
+ * drain time can hold a frame several indices AHEAD of the suspect frame;
1980
+ * - the per-frame "No cached paint record" screenshot fallback captures the
1981
+ * injected canvas — i.e. the LAST drawn drawElement frame, not this one.
1982
+ * Any failure here throws; the caller treats that as verification failure and
1983
+ * falls back the whole render (correct, never wrong-frame).
1984
+ */
1985
+ export async function recaptureDrawElementFrameForVerify(session, frameIndex, time) {
1986
+ const { page, options } = session;
1987
+ if (!session.isInitialized) {
1988
+ throw new Error("[FrameCapture] Session not initialized");
1989
+ }
1990
+ await prepareFrameForCapture(session, frameIndex, time);
1991
+ const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
1992
+ return encodeResult;
1993
+ }
1994
+ /**
1995
+ * P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
1996
+ * round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
1997
+ * batch (consecutive frame indices, none static-dedup'd, none opt-in
1998
+ * boundary-screenshot). On a mid-batch in-page failure the remaining frames are
1999
+ * re-captured through {@link captureFrameToBufferPipelined}, which owns the
2000
+ * per-frame screenshot-fallback semantics — so failure behavior is identical to
2001
+ * the unbatched path, just discovered at batch granularity.
2002
+ */
2003
+ export async function captureFramesBatchPipelined(session, frameIndices, times) {
2004
+ const { page, options } = session;
2005
+ if (!session.isInitialized) {
2006
+ throw new Error("[FrameCapture] Session not initialized");
2007
+ }
2008
+ const startTime = Date.now();
2009
+ const fps = fpsToNumber(options.fps);
2010
+ const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
2011
+ const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(page, quantized, options.width, options.height, options.quality ?? 80);
2012
+ const okCount = failedAt === null ? frameIndices.length : failedAt;
2013
+ const elapsed = Date.now() - startTime;
2014
+ session.capturePerf.frames += okCount;
2015
+ // Round-trips are fused — attribute the whole batch to produce time.
2016
+ session.capturePerf.screenshotMs += elapsed;
2017
+ session.capturePerf.totalMs += elapsed;
2018
+ const results = [];
2019
+ for (let i = 0; i < okCount; i++) {
2020
+ const frameIndex = frameIndices[i];
2021
+ const encodeResult = encodeResults[i];
2022
+ if (frameIndex === undefined || !encodeResult)
2023
+ break;
2024
+ results.push({ frameIndex, encodeResult });
2025
+ }
2026
+ if (failedAt !== null) {
2027
+ console.log(`[engine] fast capture: batch produce failed at frame ` +
2028
+ `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
2029
+ `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
2030
+ for (let i = failedAt; i < frameIndices.length; i++) {
2031
+ const frameIndex = frameIndices[i];
2032
+ const time = times[i];
2033
+ if (frameIndex === undefined || time === undefined)
2034
+ break;
2035
+ const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
2036
+ results.push({ frameIndex, encodeResult });
2037
+ }
2038
+ }
2039
+ // Task B: retain the last encode result so a following static frame can reuse it.
2040
+ const last = results[results.length - 1];
2041
+ if (session.staticFrames && last)
2042
+ session.lastEncodeResult = last.encodeResult;
2043
+ return results;
2044
+ }
1400
2045
  /**
1401
2046
  * Perform one capture, throw away the buffer, and restore any session
1402
2047
  * side-effects (perf counters, BeginFrame damage tallies) so downstream
@@ -1478,6 +2123,9 @@ export async function closeCaptureSession(session) {
1478
2123
  // Example: page release succeeds, browser release throws → pageReleased=true
1479
2124
  // but browserReleased=false → second call no-ops on page and retries browser.
1480
2125
  // This matches the orchestrator's intent for HDR cleanup.
2126
+ if (session.workerEncodeEnabled && session.page && !session.pageReleased) {
2127
+ cleanupDrawElementWorkerEncode(session.page);
2128
+ }
1481
2129
  if (!session.pageReleased && session.page) {
1482
2130
  const pageClosed = await waitForCloseWithTimeout(session.page.close());
1483
2131
  if (!pageClosed) {
@@ -1526,6 +2174,112 @@ export async function getCompositionDuration(session) {
1526
2174
  return window.__hf?.duration ?? 0;
1527
2175
  });
1528
2176
  }
2177
+ /**
2178
+ * Ungated-release safety net, part 1: capture K screenshot ground-truth frames
2179
+ * BEFORE the drawElement canvas is injected (the only window where a page
2180
+ * screenshot shows the live DOM). The producer's drain compares each DE frame
2181
+ * at these indices against its screenshot; a breach (or a blank frame that
2182
+ * survives one retry) throws DrawElementVerificationError, and the orchestrator
2183
+ * re-renders the whole job via the screenshot path.
2184
+ *
2185
+ * Env: HF_DE_VERIFY = sample count (default 4, clamp 0..8; 0 disables).
2186
+ * Deterministic index selection: fixed fractions of the timeline, nudged off
2187
+ * clip-cut boundaries (screenshot-vs-DE is legitimately ±1-frame desynced
2188
+ * there — Lim 6). Skipped for tiny comps (<10 frames) and under
2189
+ * HF_FORCE_DRAWELEMENT (debug escape hatch).
2190
+ *
2191
+ * False-positive bias is intentional: a nondeterministic comp may mismatch its
2192
+ * init-time screenshot → the render falls back to the screenshot path (slower,
2193
+ * never wrong). Cost when passing: ~K×(seek+screenshot) ≈ 150–300ms at init.
2194
+ */
2195
+ async function captureDeVerificationFrames(session, page, logInitPhase) {
2196
+ const kRaw = Number(process.env.HF_DE_VERIFY ?? "4");
2197
+ const k = Number.isFinite(kRaw) ? Math.max(0, Math.min(8, Math.floor(kRaw))) : 4;
2198
+ if (k === 0 || process.env.HF_FORCE_DRAWELEMENT === "1")
2199
+ return;
2200
+ if (session.options.format === "png")
2201
+ return; // worker-encode drain (the consumer) is jpeg-only
2202
+ const fps = fpsToNumber(session.options.fps);
2203
+ // Prefer the producer-resolved duration (the range that will actually be
2204
+ // drained). The page's raw __hf.duration can exceed it — timelines outrun
2205
+ // their data-duration, and infinite-repeat GSAP reports a huge sentinel —
2206
+ // and indices derived from it would never be drained, silently disarming
2207
+ // verification for exactly the comps that need it.
2208
+ const duration = session.options.compositionDurationSeconds ??
2209
+ (await page.evaluate(() => window.__hf?.duration ?? 0));
2210
+ const totalFrames = Math.floor(duration * fps);
2211
+ if (totalFrames < 10)
2212
+ return;
2213
+ if (duration > 3600) {
2214
+ // No producer duration and the page reports an implausible one.
2215
+ logInitPhase(`drawElement self-verify skipped: implausible duration ${duration}s`);
2216
+ return;
2217
+ }
2218
+ // Ground truth must show what the real capture paths would show: <video>
2219
+ // pixels come from the onBeforeCapture injector. When this session has no
2220
+ // injector (e.g. a probe session initialized before the render wires one
2221
+ // up) a video comp's truth would screenshot black boxes and every sample
2222
+ // would false-positive into the screenshot fallback — skip instead.
2223
+ if (!session.onBeforeCapture) {
2224
+ const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
2225
+ if (hasVideos) {
2226
+ logInitPhase("drawElement self-verify skipped: video comp without frame injector");
2227
+ return;
2228
+ }
2229
+ }
2230
+ const boundary = await computeClipBoundaryFrames(page, fps);
2231
+ // Ascending order, and seek frame 0 first: GSAP .from()/overlapping tweens
2232
+ // lazily record their start values on FIRST seek — scrubbing mid-timeline
2233
+ // before the render's frame-0 seek corrupts those caches for the whole
2234
+ // render (the detectCssEffectRisk lesson), and because DE frames and truth
2235
+ // would share the corruption, PSNR would pass on the damaged output.
2236
+ // Seeking 0 → ascending reproduces the render's own seek order.
2237
+ const fractions = Array.from({ length: k }, (_, i) => (i + 1) / (k + 1));
2238
+ const seekTo = async (t) => {
2239
+ await page.evaluate((tt) => {
2240
+ const hf = window.__hf;
2241
+ if (hf && typeof hf.seek === "function")
2242
+ hf.seek(tt);
2243
+ }, t);
2244
+ };
2245
+ await seekTo(quantizeTimeToFrame(0, fps));
2246
+ // Force one frame so lazy tween initialization paints at t=0 state.
2247
+ await pageScreenshotCapture(page, session.options);
2248
+ const frames = new Map();
2249
+ for (const f of fractions) {
2250
+ let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
2251
+ // Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
2252
+ // if the nudge saturates on a boundary index, skip the sample entirely.
2253
+ let guard = 0;
2254
+ while (boundary.has(idx) && guard++ < 6)
2255
+ idx = Math.min(totalFrames - 1, idx + 2);
2256
+ if (boundary.has(idx))
2257
+ continue;
2258
+ if (frames.has(idx))
2259
+ continue;
2260
+ const t = quantizeTimeToFrame(idx / fps, fps);
2261
+ await seekTo(t);
2262
+ // Video frame injection (same hook the real capture paths run) — without
2263
+ // it, <video> elements screenshot black and every video comp would
2264
+ // false-positive into the screenshot fallback.
2265
+ if (session.onBeforeCapture)
2266
+ await session.onBeforeCapture(page, t);
2267
+ // Double-capture: the first screenshot forces a frame, which is what runs
2268
+ // rAF-driven callbacks (count-up text counters land a tick after seek()
2269
+ // returns — a single immediate screenshot captures stale text and
2270
+ // false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
2271
+ // values while the DE frame was correct). NOTE: waiting on rAF via
2272
+ // evaluate instead deadlocks — headless only fires rAF when a frame is
2273
+ // produced, and nothing produces one until a screenshot asks.
2274
+ await pageScreenshotCapture(page, session.options);
2275
+ frames.set(idx, await pageScreenshotCapture(page, session.options));
2276
+ }
2277
+ // Leave the page at frame 0 so the render's first seek starts from the
2278
+ // same state as an unverified render.
2279
+ await seekTo(quantizeTimeToFrame(0, fps));
2280
+ session.deVerifyFrames = frames;
2281
+ logInitPhase(`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`);
2282
+ }
1529
2283
  export function getCapturePerfSummary(session) {
1530
2284
  const frames = Math.max(1, session.capturePerf.frames);
1531
2285
  return {
@@ -1540,6 +2294,13 @@ export function getCapturePerfSummary(session) {
1540
2294
  staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
1541
2295
  staticDedupPredicted: session.staticFrames?.size ?? 0,
1542
2296
  staticDedupSkipReason: session.staticDedupSkipReason,
2297
+ captureMode: session.captureMode,
2298
+ deGateReason: session.deGateReason,
2299
+ deWorkerEncode: session.workerEncodeEnabled ?? false,
2300
+ deVerifyArmed: session.deVerifyFrames?.size ?? 0,
2301
+ deVerifyInitMs: session.deVerifyInitMs ?? 0,
2302
+ deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
2303
+ deNcprFallbacks: session.deNcprFallbacks ?? 0,
1543
2304
  };
1544
2305
  }
1545
2306
  // ── Transient browser error classification ─────────────────────────────────