@hyperframes/engine 0.8.199 → 0.8.201

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.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
12
+ import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, SCENE_CALLBACK_ERROR_PREFIX, } from "@hyperframes/core";
13
13
  import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
14
14
  import { DrawElementCaptureError } from "./drawElementCaptureError.js";
15
15
  import { encodePng } from "../utils/alphaBlit.js";
@@ -17,9 +17,11 @@ import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERT
17
17
  import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
18
18
  // ── Extracted modules ───────────────────────────────────────────────────────
19
19
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
20
- import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
20
+ import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
21
21
  import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
22
- import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
22
+ import { initThreeDProjection, scanCssEffects } from "./threeDProjection.js";
23
+ import { planRiskFrames, scanCssEffectLiveFrames, riskSessionFits, withFramesAfterCallback, MAX_RISK_FRAME_FRACTION, } from "./riskFrames.js";
24
+ import { getAvailableMemoryMb } from "./systemMemory.js";
23
25
  import { isPsnrFilterAvailable } from "../utils/psnrFilterAvailability.js";
24
26
  import { DEFAULT_CONFIG, applyConcreteGpuScreenshotClamp } from "../config.js";
25
27
  import { cloneCaptureWarnings } from "./captureWarning.js";
@@ -472,20 +474,20 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
472
474
  if ((session.config?.useDrawElement ?? false) && supersampling) {
473
475
  session.deGateReason = "supersampling";
474
476
  session.deFallbackTrigger = "supersampling";
475
- console.log("[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
477
+ console.error("[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
476
478
  "ignores deviceScaleFactor, so scaled (DPR other than 1) output uses screenshot capture.");
477
479
  }
478
480
  if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
479
481
  session.deGateReason = "render_mode_hint";
480
482
  session.deFallbackTrigger = "render_mode_hint";
481
- console.log("[engine] fast capture falls back to screenshot: render-mode compatibility " +
483
+ console.error("[engine] fast capture falls back to screenshot: render-mode compatibility " +
482
484
  "hint forced screenshot capture (e.g. raw requestAnimationFrame composition).");
483
485
  }
484
486
  if (useDrawElement) {
485
487
  const gpuBackend = await detectGpuBackend(page);
486
488
  session.isSwiftShader = gpuBackend.isSwiftShader;
487
489
  session.gpuRenderer = classifyGpuRenderer(gpuBackend.renderer);
488
- const transparent = session.options.format === "png";
490
+ const transparent = capturesAlpha(session.options);
489
491
  async function routeToFallback() {
490
492
  session.captureMode = session.launchCaptureMode;
491
493
  if (transparent) {
@@ -526,7 +528,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
526
528
  if (!supportsDrawElement) {
527
529
  session.deGateReason = "unsupported_chrome";
528
530
  session.deFallbackTrigger = "unsupported_chrome";
529
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
531
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
530
532
  "this Chrome build does not implement canvas.drawElementImage (Dev/Canary-only " +
531
533
  "feature); run `hyperframes browser ensure --force` to fetch a supported " +
532
534
  "build, or set HYPERFRAMES_BROWSER_PATH to one.");
@@ -574,7 +576,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
574
576
  // "screenshot": on a BeginFrame-launched browser (Linux fast capture)
575
577
  // Page.captureScreenshot hangs for the full protocol timeout, while
576
578
  // beginFrameCapture is the platform's normal baseline path.
577
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
579
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
578
580
  "SwiftShader (software rasterizer, no GPU egress to skip, drawElement is " +
579
581
  "parity-or-slower; see fast-capture-limitations.md)");
580
582
  await routeToFallback();
@@ -585,18 +587,37 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
585
587
  // path — drawElementImage can't reproduce either, producing 18–49 dB
586
588
  // damaged frames (community eval). Fall back to the platform baseline.
587
589
  // HF_FAST_CAPTURE_CSSFX=true bypasses for R&D.
590
+ // Frames this render captures by screenshot instead (see CaptureSession.deRiskFrames);
591
+ // only a session whose caller can hand them over (worker-encode pipeline) may keep drawElement.
592
+ const perFrame = session.options.deFrameFallback === true &&
593
+ drawElementWorkerEncodeEligible(session, transparent) &&
594
+ !forceDE;
595
+ const riskFrames = new Set();
596
+ const riskReasons = [];
597
+ let cssFrames = 0;
598
+ const routeWholeRender = async (reason, trigger, message) => {
599
+ session.deGateReason = reason;
600
+ session.deFallbackTrigger = trigger;
601
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ${message}`);
602
+ await routeToFallback();
603
+ };
588
604
  if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
589
- const cssFx = await detectCssEffectRisk(page);
605
+ const cssFx = (await scanCssEffects(page, false)).reason;
590
606
  if (cssFx) {
591
- session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
592
- // Full specific effect from `detectCssEffectRisk` (e.g. "filter:blur",
607
+ // Full specific effect from `scanCssEffects` (e.g. "filter:blur",
593
608
  // "mix-blend-mode", "webgl-context") — `deGateReason` keeps its
594
609
  // low-cardinality prefix, `deFallbackTrigger` the full value.
595
- session.deFallbackTrigger = cssFx;
596
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
597
- `${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`);
598
- await routeToFallback();
599
- return;
610
+ const gateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
611
+ const gateMessage = `${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`;
612
+ cssFrames = perFrame ? await renderFrameCount(session, page) : 0;
613
+ const live = cssFrames > 0 ? await cssEffectRiskFrames(session, cssFrames) : null;
614
+ if (!live) {
615
+ await routeWholeRender(gateReason, cssFx, gateMessage);
616
+ return;
617
+ }
618
+ for (const f of live)
619
+ riskFrames.add(f);
620
+ riskReasons.push(cssFx);
600
621
  }
601
622
  }
602
623
  // Lim 7: timeline-interval at-risk predictor. Walk window.__timelines and
@@ -622,15 +643,36 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
622
643
  const fractionFloor = Number(process.env.HF_FAST_CAPTURE_INTERVAL_FRACTION ?? "0");
623
644
  logInitPhase(`timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`);
624
645
  if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
625
- session.deGateReason = "at_risk_timeline";
626
- session.deFallbackTrigger = "at_risk_timeline";
627
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
628
- `${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
629
- `(blend/3D/clip/mask); drawElementImage drops these mid-animation ` +
630
- `(deterministic timeline gate; see fast-capture-limitations.md Lim 7)`);
631
- await routeToFallback();
646
+ if (perFrame) {
647
+ for (const f of atRisk)
648
+ riskFrames.add(f);
649
+ riskReasons.push("at_risk_timeline");
650
+ }
651
+ else {
652
+ await routeWholeRender("at_risk_timeline", "at_risk_timeline", `${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
653
+ `(blend/3D/clip/mask); drawElementImage drops these mid-animation ` +
654
+ `(deterministic timeline gate; see fast-capture-limitations.md Lim 7)`);
655
+ return;
656
+ }
657
+ }
658
+ }
659
+ if (riskFrames.size > 0) {
660
+ const total = cssFrames > 0 ? cssFrames : await renderFrameCount(session, page);
661
+ const plan = total > 0 ? planRiskFrames(riskFrames, total) : null;
662
+ if (!plan) {
663
+ await routeWholeRender("risk_frames", riskReasons.join("+"), `${riskFrames.size}/${total} frames need screenshot capture (${riskReasons.join(", ")}), ` +
664
+ `too many to keep fast capture for the rest`);
665
+ return;
666
+ }
667
+ if (!riskSessionFits(getAvailableMemoryMb(), session.options.width * session.options.height)) {
668
+ await routeWholeRender("risk_session_memory", riskReasons.join("+"), `${plan.size} frames need screenshot capture (${riskReasons.join(", ")}) but too little ` +
669
+ `memory is available for per-frame capture`);
632
670
  return;
633
671
  }
672
+ session.deRiskFrames = plan;
673
+ session.deRiskReason = riskReasons.join("+");
674
+ logInitPhase(`fast capture keeps ${total - plan.size}/${total} frames; ${plan.size} frame(s) ` +
675
+ `capture by screenshot (${session.deRiskReason})`);
634
676
  }
635
677
  // Rewrite CSS 3D contexts into WebGL-projected canvases BEFORE the
636
678
  // layoutsubtree canvas goes in (rects are measured in normal layout).
@@ -640,7 +682,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
640
682
  if (!forceDE && !threeD.ok) {
641
683
  session.deGateReason = "3d_init_failed";
642
684
  session.deFallbackTrigger = "3d_init_failed";
643
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
685
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
644
686
  `3D projection init failed (${threeD.reason ?? "unknown"})`);
645
687
  await routeToFallback();
646
688
  return;
@@ -653,6 +695,10 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
653
695
  // passed) and the DOM is still normal (canvas not yet injected, so the
654
696
  // verification screenshots are valid). drawElement-path only; see armStaticDedup.
655
697
  await armStaticDedup(session, page, logInitPhase);
698
+ // A static frame reuses the previous drawElement capture; a risk frame is captured by
699
+ // another session, so its neighbours must not reuse across it.
700
+ for (const f of session.deRiskFrames ?? [])
701
+ session.staticFrames?.delete(f);
656
702
  // Video comps on injector-less sessions (probe sessions initialize before
657
703
  // video extraction) DEFER the rest of the drawElement init: ground-truth
658
704
  // screenshots would capture black <video> boxes, and once the canvas is
@@ -669,10 +715,53 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
669
715
  await finalizeDrawElementInit(session, page, logInitPhase, { transparent, forceDE });
670
716
  }
671
717
  }
672
- else if (session.options.format === "png") {
718
+ else if (capturesAlpha(session.options)) {
673
719
  await initTransparentBackground(session.page);
674
720
  }
675
721
  }
722
+ /** Worker-encode pipeline: macOS hardware GPU path only, JPEG output only. */
723
+ function drawElementWorkerEncodeEligible(session, transparent) {
724
+ return ((session.config?.enableDrawElementWorkerEncode ?? false) &&
725
+ !transparent &&
726
+ session.beginFrameTimeTicks === 0);
727
+ }
728
+ /**
729
+ * The frames a CSS effect shows on, or null when it cannot be bounded. Scans in a throwaway
730
+ * session: the scan seeks the timeline end to end, which this page's render must not inherit.
731
+ */
732
+ async function cssEffectRiskFrames(session, totalFrames) {
733
+ let scratch = null;
734
+ try {
735
+ scratch = await createCaptureSession(session.serverUrl, session.outputDir, { ...session.options, onMemorySample: undefined }, null, {
736
+ ...session.config,
737
+ useDrawElement: false,
738
+ staticFrameDedup: false,
739
+ enableBrowserPool: false,
740
+ });
741
+ await initializeSession(scratch);
742
+ const scan = await scanCssEffects(scratch.page, true);
743
+ if (!scan.reason || !scan.bounded)
744
+ return null;
745
+ const fps = fpsToNumber(session.options.fps);
746
+ const live = await scanCssEffectLiveFrames(scratch.page, fps, totalFrames, totalFrames * MAX_RISK_FRAME_FRACTION);
747
+ return live && withFramesAfterCallback(live, scan.callbackFrom, fps, totalFrames);
748
+ }
749
+ catch (error) {
750
+ console.error(`[engine] fast capture: effect scan failed (${error instanceof Error ? error.message : String(error)})`);
751
+ return null;
752
+ }
753
+ finally {
754
+ if (scratch)
755
+ await closeCaptureSession(scratch).catch(() => { });
756
+ }
757
+ }
758
+ /** Frames the render will capture, from the producer's duration when it set one. */
759
+ async function renderFrameCount(session, page) {
760
+ const duration = session.options.compositionDurationSeconds ??
761
+ (await page.evaluate(() => window.__hf?.duration ?? 0));
762
+ const total = Math.ceil(duration * fpsToNumber(session.options.fps));
763
+ return isStaticDedupFrameAnalysisSafe(total) ? total : 0;
764
+ }
676
765
  /**
677
766
  * The tail of drawElement init: self-verification ground truth (pre-injection),
678
767
  * canvas injection, capture-mode flip, clip-boundary predictor, worker-encode.
@@ -712,9 +801,7 @@ async function finalizeDrawElementInit(session, page, logInitPhase, opts) {
712
801
  // Worker-encode pipeline: macOS hardware GPU path only (syncToPaintEvent=true,
713
802
  // beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker) and transparent
714
803
  // (PNG) output — those use the existing synchronous path unchanged.
715
- const workerEncodeEnabled = (session.config?.enableDrawElementWorkerEncode ?? false) &&
716
- !transparent &&
717
- session.beginFrameTimeTicks === 0;
804
+ const workerEncodeEnabled = drawElementWorkerEncodeEligible(session, transparent);
718
805
  if (workerEncodeEnabled) {
719
806
  await initDrawElementWorkerEncode(page);
720
807
  session.workerEncodeEnabled = true;
@@ -730,13 +817,23 @@ export async function completeDeferredDrawElementInit(session) {
730
817
  if (!session.deInitDeferred || !session.onBeforeCapture)
731
818
  return;
732
819
  const page = session.page;
733
- const logInitPhase = (phase) => console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
820
+ const logInitPhase = (phase) => console.error(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
734
821
  await finalizeDrawElementInit(session, page, logInitPhase, {
735
- transparent: session.options.format === "png",
822
+ transparent: capturesAlpha(session.options),
736
823
  forceDE: process.env.HF_FORCE_DRAWELEMENT === "1",
737
824
  });
738
825
  session.deInitDeferred = false;
739
826
  }
827
+ /**
828
+ * Media elements are addressed by a document-unique render id, not by the per-file element id.
829
+ * Installed before any page script runs, on the render page and the self-check's truth page alike.
830
+ */
831
+ async function installMediaPageHooks(page, options) {
832
+ await installMediaRenderIdBridge(page);
833
+ if (options.skipReadinessVideoIds?.length) {
834
+ await page.evaluateOnNewDocument(holdRenderFrameVideos, options.skipReadinessVideoIds, MEDIA_RENDER_ID_ATTR);
835
+ }
836
+ }
740
837
  // fallow-ignore-next-line unit-size
741
838
  export async function createCaptureSession(serverUrl, outputDir, options, onBeforeCapture = null, config) {
742
839
  if (!existsSync(outputDir))
@@ -755,7 +852,7 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
755
852
  const isLinux = process.platform === "linux";
756
853
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
757
854
  const useDrawElement = config?.useDrawElement ?? false;
758
- const drawElementTransparent = useDrawElement && options.format === "png";
855
+ const drawElementTransparent = useDrawElement && capturesAlpha(options);
759
856
  // drawElement and page-side shader compositing are mutually incompatible
760
857
  // capture strategies: drawElement reads the composition root's paint records
761
858
  // directly and skips the prepare→micro-screenshot→resolve protocol (the
@@ -903,12 +1000,7 @@ async function constructCaptureSession(input) {
903
1000
  w.__name = (fn, _name) => fn;
904
1001
  }
905
1002
  });
906
- // Media elements are addressed by a document-unique render id, not by the
907
- // per-file element id. Install the resolvers before any page script runs.
908
- await installMediaRenderIdBridge(page);
909
- if (options.skipReadinessVideoIds?.length) {
910
- await page.evaluateOnNewDocument(holdRenderFrameVideos, options.skipReadinessVideoIds, MEDIA_RENDER_ID_ATTR);
911
- }
1003
+ await installMediaPageHooks(page, options);
912
1004
  // Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
913
1005
  // preserveDrawingBuffer before any page script can create a context — their
914
1006
  // paint records freeze at the first frame, so captureDrawElementFrame
@@ -1693,6 +1785,16 @@ function recordConsoleScriptError(session, error) {
1693
1785
  const message = new VfxFailureError(error).message;
1694
1786
  recordCaptureWarnings(session, [{ code: "vfx_failure", message, details: { sources: [error] } }]);
1695
1787
  }
1788
+ // Every worker page hits the same scene callback error; the render log names it once.
1789
+ function loggedEarlierInRender(session, text) {
1790
+ if (!text.startsWith(SCENE_CALLBACK_ERROR_PREFIX))
1791
+ return false;
1792
+ const logged = (session.subTimelineWaitMemo.loggedSceneCallbackErrors ??= []);
1793
+ if (logged.includes(text))
1794
+ return true;
1795
+ logged.push(text);
1796
+ return false;
1797
+ }
1696
1798
  function recordPageError(session, error) {
1697
1799
  if (error && !session.pageErrors.includes(error))
1698
1800
  session.pageErrors.push(error);
@@ -1747,14 +1849,15 @@ export function classifyPageError(details, projectScripts) {
1747
1849
  export async function initializeSession(session) {
1748
1850
  const { page, serverUrl } = session;
1749
1851
  // Forward browser console to host. HyperFrames runtime logs get a dedicated
1750
- // prefix so page-context observability is visible in producer stdout.
1852
+ // prefix so page-context observability is visible in producer stderr.
1751
1853
  page.on("console", (msg) => {
1752
1854
  const type = msg.type();
1753
1855
  const text = msg.text();
1754
1856
  const locationUrl = msg.location()?.url ?? "";
1755
1857
  const diagnostic = formatConsoleDiagnostic(type, text, locationUrl);
1756
- if (!diagnostic.suppressHostLog)
1757
- console.log(diagnostic.text);
1858
+ if (!diagnostic.suppressHostLog && !loggedEarlierInRender(session, text)) {
1859
+ console.error(diagnostic.text);
1860
+ }
1758
1861
  appendBrowserDiagnostic(session, diagnostic.text);
1759
1862
  // A blocked script can never register its timeline (#3352); a thrown one may still, so it only counts on timeout.
1760
1863
  const scriptFailure = classifyConsoleScriptFailure(type, text);
@@ -1818,7 +1921,7 @@ export async function initializeSession(session) {
1818
1921
  const pageNavigationTimeout = session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
1819
1922
  const initStart = Date.now();
1820
1923
  const logInitPhase = (phase) => {
1821
- console.log(`[initSession:${session.captureMode}] ${phase} (${Date.now() - initStart}ms)`);
1924
+ console.error(`[initSession:${session.captureMode}] ${phase} (${Date.now() - initStart}ms)`);
1822
1925
  };
1823
1926
  const gotoEntryPage = async () => {
1824
1927
  appendBrowserDiagnostic(session, formatNavigationStartDiagnostic({
@@ -2538,6 +2641,7 @@ export async function createStaticVerificationPage(session) {
2538
2641
  w.__name = (fn, _name) => fn;
2539
2642
  }
2540
2643
  });
2644
+ await installMediaPageHooks(page, session.options);
2541
2645
  if (session.options.variables && Object.keys(session.options.variables).length > 0) {
2542
2646
  const variablesJson = JSON.stringify(session.options.variables);
2543
2647
  await page.evaluateOnNewDocument((json) => {
@@ -2569,7 +2673,7 @@ export async function createStaticVerificationPage(session) {
2569
2673
  page.evaluate(`document.fonts?.ready`),
2570
2674
  waitForOptionalTailwindReady(page, pageReadyTimeout),
2571
2675
  ]);
2572
- if (session.options.format === "png")
2676
+ if (capturesAlpha(session.options))
2573
2677
  await initTransparentBackground(page);
2574
2678
  return page;
2575
2679
  }
@@ -3046,6 +3150,15 @@ async function redrawDrawElementFrame(session, frameIndex, time, seekOptions, he
3046
3150
  await prepareFrameForCapture(session, frameIndex, time, seekOptions, heldVideoTime);
3047
3151
  return captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80, true);
3048
3152
  }
3153
+ /**
3154
+ * Fails the attempt on a frame in {@link CaptureSession.deRiskFrames}. Callers that hand those
3155
+ * frames to a screenshot session never reach it; any other caller restarts by screenshot.
3156
+ */
3157
+ function assertNotRiskFrame(session, frameIndex) {
3158
+ if (session.deRiskFrames?.has(frameIndex)) {
3159
+ throw new DrawElementCaptureError(frameIndex, "frame needs screenshot capture, which this capture path cannot hand over");
3160
+ }
3161
+ }
3049
3162
  /** Seek to `time` and capture one surface with the session's capture mode. */
3050
3163
  async function captureFrameSurface(session, frameIndex, time, seekOptions, heldVideoTime) {
3051
3164
  const { page, options } = session;
@@ -3078,6 +3191,7 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
3078
3191
  throw new DrawElementCaptureError(frameIndex, "boundary screenshot requested on an injected canvas page");
3079
3192
  }
3080
3193
  else if (session.captureMode === "drawelement") {
3194
+ assertNotRiskFrame(session, frameIndex);
3081
3195
  // Advance compositor state via BeginFrame when available (Linux headless-shell);
3082
3196
  // on macOS the compositor advances naturally without BeginFrame.
3083
3197
  if (session.beginFrameTimeTicks > 0) {
@@ -3263,6 +3377,13 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
3263
3377
  ...totals,
3264
3378
  };
3265
3379
  }
3380
+ function framesAreStatic(staticFrames, from, to) {
3381
+ for (let frame = from; frame <= to; frame++) {
3382
+ if (!staticFrames.has(frame))
3383
+ return false;
3384
+ }
3385
+ return true;
3386
+ }
3266
3387
  async function captureFrameCore(session, frameIndex, time) {
3267
3388
  const { options } = session;
3268
3389
  const startTime = Date.now();
@@ -3283,7 +3404,8 @@ async function captureFrameCore(session, frameIndex, time) {
3283
3404
  const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
3284
3405
  if (session.staticFrames?.has(absFrameIndex) &&
3285
3406
  session.lastFrameBuffer &&
3286
- session.lastFrameAbsoluteIndex === absFrameIndex - 1 &&
3407
+ session.lastFrameAbsoluteIndex !== undefined &&
3408
+ framesAreStatic(session.staticFrames, session.lastFrameAbsoluteIndex + 1, absFrameIndex) &&
3287
3409
  (!session.motionBlur ||
3288
3410
  motionBlurWindowIsStatic(session.motionBlur, absFrameIndex, session.staticFrames))) {
3289
3411
  session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
@@ -3311,7 +3433,7 @@ async function captureFrameCore(session, frameIndex, time) {
3311
3433
  if (session.capturePerf.frames === 1) {
3312
3434
  const blur = await describePageMotionBlur(session.page).catch(() => null);
3313
3435
  if (blur)
3314
- console.log(blur);
3436
+ console.error(blur);
3315
3437
  }
3316
3438
  // Retain this freshly-captured buffer so the following static frames can reuse it.
3317
3439
  if (session.staticFrames) {
@@ -3370,7 +3492,7 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
3370
3492
  // works. Counted here rather than in captureFrameCore's catch: the
3371
3493
  // deadline rejects from outside it, so that catch never runs.
3372
3494
  session.deFrameTimeouts = (session.deFrameTimeouts ?? 0) + 1;
3373
- console.log(`[engine] fast capture frame ${frameIndex}: capture exceeded ` +
3495
+ console.error(`[engine] fast capture frame ${frameIndex}: capture exceeded ` +
3374
3496
  `${DE_FRAME_TIMEOUT_MS}ms; renderer stalled after drawElementImage ` +
3375
3497
  `(PRINFRA-488). Failing the drawElement attempt so the whole render ` +
3376
3498
  `retries via screenshot.`);
@@ -3403,6 +3525,7 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
3403
3525
  export async function captureFrameToBufferPipelined(session, frameIndex, time) {
3404
3526
  const { page, options } = session;
3405
3527
  const startTime = Date.now();
3528
+ assertNotRiskFrame(session, frameIndex);
3406
3529
  // Task B: static-frame dedup (worker path). Reuse the prior frame's encode result
3407
3530
  // and skip the seek + drawElement + encode entirely. Same predicate as the serial
3408
3531
  // path; clip-cut frames are excluded from staticFrames so they always capture.
@@ -3412,13 +3535,7 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
3412
3535
  // same pixels. Sequential capture reduces to has(i) (gap = {i}); the
3413
3536
  // interleaved parallel stride makes the gap N frames wide.
3414
3537
  const lastIdx = session.lastEncodeResultFrame ?? frameIndex - 1;
3415
- let gapStatic = true;
3416
- for (let j = lastIdx + 1; j <= frameIndex; j++) {
3417
- if (!session.staticFrames.has(j)) {
3418
- gapStatic = false;
3419
- break;
3420
- }
3421
- }
3538
+ const gapStatic = framesAreStatic(session.staticFrames, lastIdx + 1, frameIndex);
3422
3539
  if (gapStatic) {
3423
3540
  session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
3424
3541
  session.capturePerf.frames += 1;
@@ -3527,6 +3644,8 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3527
3644
  throw new Error("[FrameCapture] Session not initialized");
3528
3645
  }
3529
3646
  assertVfxIntact(session);
3647
+ for (const frameIndex of frameIndices)
3648
+ assertNotRiskFrame(session, frameIndex);
3530
3649
  const startTime = Date.now();
3531
3650
  const fps = fpsToNumber(options.fps);
3532
3651
  const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
@@ -3572,7 +3691,7 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3572
3691
  throw new DrawElementCaptureError(frameIndices[failedAt] ?? failedAt, reason, error);
3573
3692
  }
3574
3693
  else {
3575
- console.log(`[engine] fast capture: batch produce failed at frame ` +
3694
+ console.error(`[engine] fast capture: batch produce failed at frame ` +
3576
3695
  `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
3577
3696
  `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
3578
3697
  for (let i = failedAt; i < frameIndices.length; i++) {
@@ -3651,7 +3770,7 @@ export async function closeCaptureSession(session) {
3651
3770
  if (session.page && !session.pageReleased) {
3652
3771
  const blur = await describePageMotionBlurAtEnd(session.page);
3653
3772
  if (blur)
3654
- console.log(blur);
3773
+ console.error(blur);
3655
3774
  }
3656
3775
  // Realized static-dedup telemetry: how much the cache actually helped this
3657
3776
  // render (vs the prediction logged at arm time). Both capture paths
@@ -3664,7 +3783,7 @@ export async function closeCaptureSession(session) {
3664
3783
  const total = captured + reused;
3665
3784
  const pct = total > 0 ? Math.round((reused / total) * 100) : 0;
3666
3785
  const avgTotalMs = captured > 0 ? Math.round(session.capturePerf.totalMs / captured) : 0;
3667
- console.log(`[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
3786
+ console.error(`[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
3668
3787
  `est. ~${reused * avgTotalMs}ms saved (avg ${avgTotalMs}ms/frame)`);
3669
3788
  session.staticDedupCount = 0;
3670
3789
  }
@@ -3827,9 +3946,10 @@ async function captureDeVerificationFrames(session, page, logInitPhase) {
3827
3946
  // would share the corruption, PSNR would pass on the damaged output.
3828
3947
  // Seeking 0 → ascending reproduces the render's own seek order.
3829
3948
  const fractions = computeDeVerifySampleFractions(k);
3830
- // A video-free comp reads its truth on a disposable page: callbacks that run there
3831
- // cannot leave the render's own page changed. The video injector is tied to the render page.
3832
- const disposable = session.onBeforeCapture
3949
+ // The truth is read on a disposable page, so callbacks that run there cannot change the render's
3950
+ // own page. A video comp needs an injector of its own for it (the render's tracks its page).
3951
+ const truthInjector = session.onBeforeCapture?.fresh?.() ?? null;
3952
+ const disposable = session.onBeforeCapture && truthInjector === null
3833
3953
  ? null
3834
3954
  : await createStaticVerificationPage(session).catch(() => {
3835
3955
  logInitPhase("self-verify truth page unavailable; reading it on the render page");
@@ -3837,6 +3957,9 @@ async function captureDeVerificationFrames(session, page, logInitPhase) {
3837
3957
  });
3838
3958
  const isolated = disposable !== null;
3839
3959
  const truthPage = disposable ?? page;
3960
+ const injectFrames = isolated
3961
+ ? (truthInjector ?? session.onBeforeCapture)
3962
+ : session.onBeforeCapture;
3840
3963
  const seekTo = (t) => seekDeVerifyTruth(truthPage, t, !isolated);
3841
3964
  const frames = new Map();
3842
3965
  try {
@@ -3848,9 +3971,10 @@ async function captureDeVerificationFrames(session, page, logInitPhase) {
3848
3971
  // Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
3849
3972
  // if the nudge saturates on a boundary index, skip the sample entirely.
3850
3973
  let guard = 0;
3851
- while (boundary.has(idx) && guard++ < 6)
3974
+ const skip = (i) => boundary.has(i) || session.deRiskFrames?.has(i) === true;
3975
+ while (skip(idx) && guard++ < 6)
3852
3976
  idx = Math.min(totalFrames - 1, idx + 2);
3853
- if (boundary.has(idx))
3977
+ if (skip(idx))
3854
3978
  continue;
3855
3979
  if (frames.has(idx))
3856
3980
  continue;
@@ -3859,8 +3983,7 @@ async function captureDeVerificationFrames(session, page, logInitPhase) {
3859
3983
  // Video frame injection (same hook the real capture paths run) — without
3860
3984
  // it, <video> elements screenshot black and every video comp would
3861
3985
  // false-positive into the screenshot fallback.
3862
- if (session.onBeforeCapture)
3863
- await session.onBeforeCapture(truthPage, t);
3986
+ await injectFrames?.(truthPage, t);
3864
3987
  // The first screenshot forces a frame, which runs rAF-driven callbacks (a count-up lands
3865
3988
  // a tick after seek()); waiting on rAF via evaluate deadlocks, headless fires it only
3866
3989
  // when a frame is produced.
@@ -3960,6 +4083,7 @@ export function getCapturePerfSummary(session) {
3960
4083
  deVerifyArmed: session.deVerifyFrames?.size ?? 0,
3961
4084
  deVerifyInitMs: session.deVerifyInitMs ?? 0,
3962
4085
  deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
4086
+ deRiskFrames: session.deRiskFrames?.size ?? 0,
3963
4087
  deNcprFallbacks: ncprFallbacks,
3964
4088
  deFrameTimeouts: session.deFrameTimeouts ?? 0,
3965
4089
  };
@@ -358,7 +358,7 @@ async function captureFrameRange(session, task, captureOptions, signal, onFrameC
358
358
  throw new Error("Parallel worker cancelled");
359
359
  const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
360
360
  if (dbg && i < task.startFrame + dbgWin) {
361
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} start`);
361
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} start`);
362
362
  }
363
363
  const { encodeResult } = await runCaptureOperation("frame_capture", i, () => captureFrameToBufferPipelined(session, i - outputOffset, time));
364
364
  // Marks the promise "handled" for Node's unhandled-rejection detector
@@ -368,19 +368,19 @@ async function captureFrameRange(session, task, captureOptions, signal, onFrameC
368
368
  // and would otherwise surface as an unhandled rejection during teardown.
369
369
  encodeResult.catch(() => { });
370
370
  if (dbg && i < task.startFrame + dbgWin) {
371
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} kicked`);
371
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} kicked`);
372
372
  }
373
373
  if (prev) {
374
374
  if (dbg && prev.idx < task.startFrame + dbgWin) {
375
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} await-encode`);
375
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} await-encode`);
376
376
  }
377
377
  const buf = await awaitEncode(prev.idx, prev.encodeResult);
378
378
  if (dbg && prev.idx < task.startFrame + dbgWin) {
379
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} encoded ${buf.length}B`);
379
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} encoded ${buf.length}B`);
380
380
  }
381
381
  await onFrameBuffer(prev.idx, buf, session);
382
382
  if (dbg && prev.idx < task.startFrame + dbgWin) {
383
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} written`);
383
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} written`);
384
384
  }
385
385
  framesCaptured++;
386
386
  if (onFrameCaptured)
@@ -453,7 +453,7 @@ export function selectVerifySampleIndicesForTask(sampleIndices, task) {
453
453
  /** HF_DE_PAR_DEBUG=1 gated per-worker trace line (message built lazily). */
454
454
  function logParDebug(message) {
455
455
  if (process.env.HF_DE_PAR_DEBUG === "1")
456
- console.log(message());
456
+ console.error(message());
457
457
  }
458
458
  /**
459
459
  * Throw the verification error for a sample below the PSNR floor; log the
@@ -466,7 +466,7 @@ function assertDiskSampleAboveFloor(db, verifyMinDb, idx, workerId) {
466
466
  throw new DrawElementVerificationError(`drawElement self-verify failed at frame ${idx} (disk path, worker ${workerId}): ` +
467
467
  `${db.toFixed(1)}dB < ${verifyMinDb}dB vs pre-injection screenshot`, { kind: "psnr", frameIndex: idx, failedDb: db, verifyThresholdDb: verifyMinDb });
468
468
  }
469
- console.log(`[Parallel] drawElement disk self-verify passed (worker ${workerId}, frame ${idx}, ` +
469
+ console.error(`[Parallel] drawElement disk self-verify passed (worker ${workerId}, frame ${idx}, ` +
470
470
  `${db === Infinity ? "inf" : db.toFixed(1)}dB)`);
471
471
  }
472
472
  /**
@@ -760,9 +760,11 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
760
760
  };
761
761
  const parallel = tasks.length > 1;
762
762
  const deVerifySamples = resolveParallelDeVerifySamples(captureOptions.deVerifySamples, tasks.length);
763
- const workerCaptureOptions = deVerifySamples === captureOptions.deVerifySamples
764
- ? captureOptions
765
- : { ...captureOptions, deVerifySamples };
763
+ const workerCaptureOptions = {
764
+ ...captureOptions,
765
+ deVerifySamples,
766
+ deFrameFallback: false,
767
+ };
766
768
  const peerController = new AbortController();
767
769
  const workerSignal = signal
768
770
  ? AbortSignal.any([signal, peerController.signal])
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Which frames drawElement capture cannot reproduce, so those frames alone are captured by
3
+ * screenshot and the rest keep the fast path.
4
+ */
5
+ import type { Page } from "puppeteer-core";
6
+ /**
7
+ * Above this share of risk frames the second browser and the switching cost outweigh the fast
8
+ * frames, so the whole render captures by screenshot as it always has.
9
+ */
10
+ export declare const MAX_RISK_FRAME_FRACTION = 0.6;
11
+ /** Peak memory per-frame capture adds over a screenshot render at 1080p: 1002 to 2001 MB measured. */
12
+ export declare const RISK_RENDER_ADDED_BYTES = 1000000000;
13
+ /** Memory that must still be available once the screenshot browser is open. */
14
+ export declare const RISK_RENDER_HEADROOM_BYTES = 1500000000;
15
+ /** Risk ranges closer than this many frames are joined, so capture never alternates modes. */
16
+ export declare const RISK_RANGE_HYSTERESIS_FRAMES = 12;
17
+ /**
18
+ * Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
19
+ * Null once more than `maxFrames` frames qualify.
20
+ */
21
+ export declare function scanCssEffectLiveFrames(page: Page, fps: number, totalFrames: number, maxFrames: number): Promise<Set<number> | null>;
22
+ /**
23
+ * The given frames with a one-frame margin, ranges closer than {@link RISK_RANGE_HYSTERESIS_FRAMES}
24
+ * joined. Null when they cover more than {@link MAX_RISK_FRAME_FRACTION} of the render.
25
+ */
26
+ export declare function planRiskFrames(frames: Iterable<number>, totalFrames: number): Set<number> | null;
27
+ /** `live` plus every frame from just before the first callback to the end of the render. */
28
+ export declare function withFramesAfterCallback(live: Set<number>, callbackFrom: number | null, fps: number, totalFrames: number): Set<number>;
29
+ /** Whether the render fits: what it adds, scaled by output pixels, leaves the headroom. */
30
+ export declare function riskSessionFits(availableMb: number, outputPixels: number): boolean;