@hyperframes/engine 0.8.198 → 0.8.200

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.
@@ -19,7 +19,10 @@ export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSum
19
19
  /** Called after seeking, before screenshot. Use for video frame injection or other pre-capture work.
20
20
  * `heldVideoTime` (motion-blur samples only) is the output frame's time: a video on screen at
21
21
  * both times shows its frame for `heldVideoTime`, so footage does not smear. */
22
- export type BeforeCaptureHook = (page: Page, time: number, heldVideoTime?: number) => Promise<void>;
22
+ export type BeforeCaptureHook = ((page: Page, time: number, heldVideoTime?: number) => Promise<void>) & {
23
+ /** A hook with state of its own, for a second page; absent when the hook cannot be copied. */
24
+ fresh?: () => BeforeCaptureHook | null;
25
+ };
23
26
  /** The per-frame wait a capture is in, worded for the stall error a user reads. */
24
27
  type CaptureStep = "seeking the timeline" | "decoding CSS background images" | "injecting video frames" | "waiting for page seek handlers" | "loading color-grading LUTs" | "compositing the page" | "capturing the frame";
25
28
  export interface CaptureSession {
@@ -161,6 +164,13 @@ export interface CaptureSession {
161
164
  * Empty/undefined when the predictor produces no frames.
162
165
  */
163
166
  clipBoundaryFrames?: Set<number>;
167
+ /**
168
+ * Frames drawElement cannot reproduce, for the caller to capture with a screenshot session.
169
+ * Set only when the caller can (worker-encode pipeline); drawElement capture throws on these.
170
+ */
171
+ deRiskFrames?: Set<number>;
172
+ /** The effects behind {@link deRiskFrames}, for the capture log. */
173
+ deRiskReason?: string;
164
174
  /** Rolling drawElement frame byte-sizes (last ~60), for silent-blank-drop detection:
165
175
  * drawElement intermittently returns an anomalously small (blank) frame with no
166
176
  * throw; a frame far below the running median is re-captured via screenshot. */
@@ -761,6 +771,8 @@ export declare function getCompositionDuration(session: CaptureSession): Promise
761
771
  * timeline passed verification while the drained output bottomed at 30.9 dB.
762
772
  */
763
773
  export declare function computeDeVerifySampleFractions(k: number): number[];
774
+ /** Seeks the page the truth is read from; callbacks run unless suppressed (suppressed, a count-up's text is stale). */
775
+ export declare function seekDeVerifyTruth(page: Page, t: number, suppressEvents: boolean): Promise<void>;
764
776
  /**
765
777
  * Percentile of a positive-real sample set (nearest-rank; matches how the
766
778
  * existing {@link medianOf} p50 helper picks the middle index). `p` is a
@@ -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";
@@ -19,7 +19,9 @@ import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionB
19
19
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
20
20
  import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, 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";
@@ -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.log(`[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).
@@ -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
@@ -673,6 +719,49 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
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.log(`[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;
@@ -737,6 +824,16 @@ export async function completeDeferredDrawElementInit(session) {
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))
@@ -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);
@@ -1753,8 +1855,9 @@ export async function initializeSession(session) {
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)
1858
+ if (!diagnostic.suppressHostLog && !loggedEarlierInRender(session, text)) {
1757
1859
  console.log(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);
@@ -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) => {
@@ -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) {
@@ -3403,6 +3517,7 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
3403
3517
  export async function captureFrameToBufferPipelined(session, frameIndex, time) {
3404
3518
  const { page, options } = session;
3405
3519
  const startTime = Date.now();
3520
+ assertNotRiskFrame(session, frameIndex);
3406
3521
  // Task B: static-frame dedup (worker path). Reuse the prior frame's encode result
3407
3522
  // and skip the seek + drawElement + encode entirely. Same predicate as the serial
3408
3523
  // path; clip-cut frames are excluded from staticFrames so they always capture.
@@ -3527,6 +3642,8 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3527
3642
  throw new Error("[FrameCapture] Session not initialized");
3528
3643
  }
3529
3644
  assertVfxIntact(session);
3645
+ for (const frameIndex of frameIndices)
3646
+ assertNotRiskFrame(session, frameIndex);
3530
3647
  const startTime = Date.now();
3531
3648
  const fps = fpsToNumber(options.fps);
3532
3649
  const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
@@ -3774,6 +3891,14 @@ export function computeDeVerifySampleFractions(k) {
3774
3891
  return [0.95];
3775
3892
  return [...Array.from({ length: k - 1 }, (_, i) => (i + 1) / k), 0.95];
3776
3893
  }
3894
+ /** Seeks the page the truth is read from; callbacks run unless suppressed (suppressed, a count-up's text is stale). */
3895
+ export async function seekDeVerifyTruth(page, t, suppressEvents) {
3896
+ await page.evaluate((tt, suppress) => {
3897
+ const hf = window.__hf;
3898
+ if (hf && typeof hf.seek === "function")
3899
+ hf.seek(tt, { suppressEvents: suppress });
3900
+ }, t, suppressEvents);
3901
+ }
3777
3902
  async function captureDeVerificationFrames(session, page, logInitPhase) {
3778
3903
  // Explicit HF_DE_VERIFY wins; otherwise the session's own sample count
3779
3904
  // (raised by the parallel coordinator for multi-worker DE), then default 4.
@@ -3819,48 +3944,58 @@ async function captureDeVerificationFrames(session, page, logInitPhase) {
3819
3944
  // would share the corruption, PSNR would pass on the damaged output.
3820
3945
  // Seeking 0 → ascending reproduces the render's own seek order.
3821
3946
  const fractions = computeDeVerifySampleFractions(k);
3822
- const seekTo = async (t) => {
3823
- await page.evaluate((tt) => {
3824
- const hf = window.__hf;
3825
- if (hf && typeof hf.seek === "function")
3826
- hf.seek(tt, { suppressEvents: true });
3827
- }, t);
3828
- };
3829
- await seekTo(quantizeTimeToFrame(0, fps));
3830
- // Force one frame so lazy tween initialization paints at t=0 state.
3831
- await pageScreenshotCapture(page, session.options);
3947
+ // The truth is read on a disposable page, so callbacks that run there cannot change the render's
3948
+ // own page. A video comp needs an injector of its own for it (the render's tracks its page).
3949
+ const truthInjector = session.onBeforeCapture?.fresh?.() ?? null;
3950
+ const disposable = session.onBeforeCapture && truthInjector === null
3951
+ ? null
3952
+ : await createStaticVerificationPage(session).catch(() => {
3953
+ logInitPhase("self-verify truth page unavailable; reading it on the render page");
3954
+ return null;
3955
+ });
3956
+ const isolated = disposable !== null;
3957
+ const truthPage = disposable ?? page;
3958
+ const injectFrames = isolated
3959
+ ? (truthInjector ?? session.onBeforeCapture)
3960
+ : session.onBeforeCapture;
3961
+ const seekTo = (t) => seekDeVerifyTruth(truthPage, t, !isolated);
3832
3962
  const frames = new Map();
3833
- for (const f of fractions) {
3834
- let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
3835
- // Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
3836
- // if the nudge saturates on a boundary index, skip the sample entirely.
3837
- let guard = 0;
3838
- while (boundary.has(idx) && guard++ < 6)
3839
- idx = Math.min(totalFrames - 1, idx + 2);
3840
- if (boundary.has(idx))
3841
- continue;
3842
- if (frames.has(idx))
3843
- continue;
3844
- const t = quantizeTimeToFrame(idx / fps, fps);
3845
- await seekTo(t);
3846
- // Video frame injection (same hook the real capture paths run) — without
3847
- // it, <video> elements screenshot black and every video comp would
3848
- // false-positive into the screenshot fallback.
3849
- if (session.onBeforeCapture)
3850
- await session.onBeforeCapture(page, t);
3851
- // Double-capture: the first screenshot forces a frame, which is what runs
3852
- // rAF-driven callbacks (count-up text counters land a tick after seek()
3853
- // returns — a single immediate screenshot captures stale text and
3854
- // false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
3855
- // values while the DE frame was correct). NOTE: waiting on rAF via
3856
- // evaluate instead deadlocks — headless only fires rAF when a frame is
3857
- // produced, and nothing produces one until a screenshot asks.
3858
- await pageScreenshotCapture(page, session.options);
3859
- frames.set(idx, await pageScreenshotCapture(page, session.options));
3860
- }
3861
- // Leave the page at frame 0 so the render's first seek starts from the
3862
- // same state as an unverified render.
3863
- await seekTo(quantizeTimeToFrame(0, fps));
3963
+ try {
3964
+ await seekTo(quantizeTimeToFrame(0, fps));
3965
+ // Force one frame so lazy tween initialization paints at t=0 state.
3966
+ await pageScreenshotCapture(truthPage, session.options);
3967
+ for (const f of fractions) {
3968
+ let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
3969
+ // Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
3970
+ // if the nudge saturates on a boundary index, skip the sample entirely.
3971
+ let guard = 0;
3972
+ const skip = (i) => boundary.has(i) || session.deRiskFrames?.has(i) === true;
3973
+ while (skip(idx) && guard++ < 6)
3974
+ idx = Math.min(totalFrames - 1, idx + 2);
3975
+ if (skip(idx))
3976
+ continue;
3977
+ if (frames.has(idx))
3978
+ continue;
3979
+ const t = quantizeTimeToFrame(idx / fps, fps);
3980
+ await seekTo(t);
3981
+ // Video frame injection (same hook the real capture paths run) — without
3982
+ // it, <video> elements screenshot black and every video comp would
3983
+ // false-positive into the screenshot fallback.
3984
+ await injectFrames?.(truthPage, t);
3985
+ // The first screenshot forces a frame, which runs rAF-driven callbacks (a count-up lands
3986
+ // a tick after seek()); waiting on rAF via evaluate deadlocks, headless fires it only
3987
+ // when a frame is produced.
3988
+ await pageScreenshotCapture(truthPage, session.options);
3989
+ frames.set(idx, await pageScreenshotCapture(truthPage, session.options));
3990
+ }
3991
+ // The render's own page goes back to frame 0 so its first seek starts like an unverified render.
3992
+ if (!isolated)
3993
+ await seekTo(quantizeTimeToFrame(0, fps));
3994
+ }
3995
+ finally {
3996
+ if (isolated)
3997
+ await truthPage.close().catch(() => { });
3998
+ }
3864
3999
  session.deVerifyFrames = frames;
3865
4000
  logInitPhase(`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`);
3866
4001
  }
@@ -3946,6 +4081,7 @@ export function getCapturePerfSummary(session) {
3946
4081
  deVerifyArmed: session.deVerifyFrames?.size ?? 0,
3947
4082
  deVerifyInitMs: session.deVerifyInitMs ?? 0,
3948
4083
  deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
4084
+ deRiskFrames: session.deRiskFrames?.size ?? 0,
3949
4085
  deNcprFallbacks: ncprFallbacks,
3950
4086
  deFrameTimeouts: session.deFrameTimeouts ?? 0,
3951
4087
  };
@@ -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;
@@ -0,0 +1,101 @@
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 { quantizeTimeToFrame } from "@hyperframes/core";
6
+ /** Frames per page round trip while scanning; a scan is one in-page loop per chunk. */
7
+ const SCAN_CHUNK_FRAMES = 240;
8
+ /**
9
+ * Above this share of risk frames the second browser and the switching cost outweigh the fast
10
+ * frames, so the whole render captures by screenshot as it always has.
11
+ */
12
+ export const MAX_RISK_FRAME_FRACTION = 0.6;
13
+ /** Peak memory per-frame capture adds over a screenshot render at 1080p: 1002 to 2001 MB measured. */
14
+ export const RISK_RENDER_ADDED_BYTES = 1_000_000_000;
15
+ /** Memory that must still be available once the screenshot browser is open. */
16
+ export const RISK_RENDER_HEADROOM_BYTES = 1_500_000_000;
17
+ const PIXELS_1080P = 1920 * 1080;
18
+ /** Risk ranges closer than this many frames are joined, so capture never alternates modes. */
19
+ export const RISK_RANGE_HYSTERESIS_FRAMES = 12;
20
+ /**
21
+ * Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
22
+ * Null once more than `maxFrames` frames qualify.
23
+ */
24
+ export async function scanCssEffectLiveFrames(page, fps, totalFrames, maxFrames) {
25
+ const live = new Set();
26
+ for (let from = 0; from < totalFrames; from += SCAN_CHUNK_FRAMES) {
27
+ const times = [];
28
+ for (let f = from; f < Math.min(totalFrames, from + SCAN_CHUNK_FRAMES); f++) {
29
+ times.push(quantizeTimeToFrame(f / fps, fps));
30
+ }
31
+ const hits = await page.evaluate(async (ts) => {
32
+ const w = window;
33
+ const targets = w.__hfCssFxTargets ?? [];
34
+ // A hidden element still shows a descendant that sets `visibility: visible` itself.
35
+ const paints = (el) => getComputedStyle(el).visibility !== "hidden" ||
36
+ Array.from(el.querySelectorAll("*")).some((d) => getComputedStyle(d).visibility === "visible");
37
+ const shows = (el) => {
38
+ for (let n = el; n; n = n.parentElement) {
39
+ const cs = getComputedStyle(n);
40
+ if (cs.display === "none" || Number(cs.opacity) === 0)
41
+ return false;
42
+ }
43
+ return paints(el);
44
+ };
45
+ const out = [];
46
+ for (let i = 0; i < ts.length; i++) {
47
+ w.__hf?.seek?.(ts[i]);
48
+ await w.__hfWaitForSeekCompletion?.();
49
+ if (targets.some(shows))
50
+ out.push(i);
51
+ }
52
+ return out;
53
+ }, times);
54
+ for (const i of hits)
55
+ live.add(from + i);
56
+ if (live.size > maxFrames)
57
+ break;
58
+ }
59
+ return live.size > maxFrames ? null : live;
60
+ }
61
+ /**
62
+ * The given frames with a one-frame margin, ranges closer than {@link RISK_RANGE_HYSTERESIS_FRAMES}
63
+ * joined. Null when they cover more than {@link MAX_RISK_FRAME_FRACTION} of the render.
64
+ */
65
+ export function planRiskFrames(frames, totalFrames) {
66
+ const sorted = [...new Set(frames)].sort((a, b) => a - b);
67
+ const plan = new Set();
68
+ let rangeStart = -1;
69
+ let rangeEnd = -1;
70
+ const flush = () => {
71
+ for (let f = Math.max(0, rangeStart - 1); f <= Math.min(totalFrames - 1, rangeEnd + 1); f++) {
72
+ plan.add(f);
73
+ }
74
+ };
75
+ for (const f of sorted) {
76
+ if (rangeStart >= 0 && f - rangeEnd <= RISK_RANGE_HYSTERESIS_FRAMES) {
77
+ rangeEnd = f;
78
+ continue;
79
+ }
80
+ if (rangeStart >= 0)
81
+ flush();
82
+ rangeStart = f;
83
+ rangeEnd = f;
84
+ }
85
+ if (rangeStart >= 0)
86
+ flush();
87
+ return plan.size > totalFrames * MAX_RISK_FRAME_FRACTION ? null : plan;
88
+ }
89
+ /** `live` plus every frame from just before the first callback to the end of the render. */
90
+ export function withFramesAfterCallback(live, callbackFrom, fps, totalFrames) {
91
+ if (callbackFrom === null)
92
+ return live;
93
+ for (let f = Math.max(0, Math.floor(callbackFrom * fps) - 1); f < totalFrames; f++)
94
+ live.add(f);
95
+ return live;
96
+ }
97
+ /** Whether the render fits: what it adds, scaled by output pixels, leaves the headroom. */
98
+ export function riskSessionFits(availableMb, outputPixels) {
99
+ const rss = RISK_RENDER_ADDED_BYTES * Math.max(1, outputPixels / PIXELS_1080P);
100
+ return availableMb * 1024 * 1024 - rss >= RISK_RENDER_HEADROOM_BYTES;
101
+ }
@@ -49,3 +49,5 @@ export declare const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
49
49
  * small VMs, small Lambda tiers, small containers) are detected correctly.
50
50
  */
51
51
  export declare function isLowMemorySystem(totalMb?: number): boolean;
52
+ /** Memory available in MiB: on macOS and Linux, counting pages the OS reclaims on demand. */
53
+ export declare function getAvailableMemoryMb(): number;
@@ -7,8 +7,9 @@
7
7
  * They all need the same "how much memory does this box have" reading, so
8
8
  * it lives here once instead of being re-derived inline.
9
9
  */
10
+ import { execSync } from "child_process";
10
11
  import { readFileSync } from "fs";
11
- import { totalmem } from "os";
12
+ import { freemem, platform, totalmem } from "os";
12
13
  const BYTES_PER_MIB = 1024 * 1024;
13
14
  const BYTES_PER_MIB_BIGINT = BigInt(BYTES_PER_MIB);
14
15
  // These are the paths as seen from INSIDE a container, where the runtime
@@ -154,3 +155,39 @@ export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
154
155
  export function isLowMemorySystem(totalMb = getSystemTotalMb()) {
155
156
  return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
156
157
  }
158
+ /** Memory available in MiB: on macOS and Linux, counting pages the OS reclaims on demand. */
159
+ export function getAvailableMemoryMb() {
160
+ const fallback = Math.trunc(freemem() / BYTES_PER_MIB);
161
+ if (platform() === "darwin") {
162
+ try {
163
+ const raw = execSync("vm_stat", { encoding: "utf-8", timeout: 5000 });
164
+ const pageSize = parseInt(raw.match(/page size of (\d+)/)?.[1] ?? "0", 10);
165
+ if (!pageSize)
166
+ return fallback;
167
+ const pages = (key) => parseInt(raw.match(new RegExp(`${key}:\\s+(\\d+)`))?.[1] ?? "0", 10);
168
+ const available = (pages("Pages free") +
169
+ pages("Pages inactive") +
170
+ pages("Pages purgeable") +
171
+ pages("Pages speculative")) *
172
+ pageSize;
173
+ return available > 0 ? Math.trunc(available / BYTES_PER_MIB) : fallback;
174
+ }
175
+ catch {
176
+ return fallback;
177
+ }
178
+ }
179
+ if (platform() === "linux") {
180
+ try {
181
+ const meminfo = readFileSync("/proc/meminfo", "utf-8");
182
+ const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
183
+ if (match) {
184
+ return Math.trunc(parseInt(match[1], 10) / 1024);
185
+ }
186
+ return fallback;
187
+ }
188
+ catch {
189
+ return fallback;
190
+ }
191
+ }
192
+ return fallback;
193
+ }
@@ -77,3 +77,17 @@ export declare function initThreeDProjection(page: Page): Promise<ThreeDProjecti
77
77
  * Returns the first matched effect name (for logging) or null.
78
78
  */
79
79
  export declare function detectCssEffectRisk(page: Page): Promise<string | null>;
80
+ /** What {@link scanCssEffects} found. */
81
+ export interface CssEffectScan {
82
+ /** First effect matched, in scan order (computed, stylesheet, tween vars, WebGL), or null. */
83
+ reason: string | null;
84
+ /** False when a match is not tied to an element (unmatched rule, CSS animation, WebGL). */
85
+ bounded: boolean;
86
+ /** Seconds at which the first timeline callback can fire; the effect can show anywhere after. */
87
+ callbackFrom: number | null;
88
+ }
89
+ /**
90
+ * Which CSS effects drawElementImage cannot reproduce. `collect: true` scans past the first match
91
+ * and leaves the elements carrying an effect in `window.__hfCssFxTargets`.
92
+ */
93
+ export declare function scanCssEffects(page: Page, collect: boolean): Promise<CssEffectScan>;
@@ -864,6 +864,13 @@ export async function initThreeDProjection(page) {
864
864
  * Returns the first matched effect name (for logging) or null.
865
865
  */
866
866
  export async function detectCssEffectRisk(page) {
867
+ return (await scanCssEffects(page, false)).reason;
868
+ }
869
+ /**
870
+ * Which CSS effects drawElementImage cannot reproduce. `collect: true` scans past the first match
871
+ * and leaves the elements carrying an effect in `window.__hfCssFxTargets`.
872
+ */
873
+ export async function scanCssEffects(page, collect) {
867
874
  try {
868
875
  // MUST NOT seek the timeline here. Driving `window.__hf.seek` to sample
869
876
  // frames renders GSAP `.from()` / overlapping tweens out of forward order and
@@ -874,10 +881,23 @@ export async function detectCssEffectRisk(page) {
874
881
  // 1e5a1165: the Pong scene's scale/rotation tween lands wrong, 19.8 dB / 27
875
882
  // damaged frames; the fix restored it to baseline parity, 43 dB / 0). So we
876
883
  // detect the same effects seek-free, four ways:
877
- return await page.evaluate(() => {
884
+ return await page.evaluate((collectAll) => {
878
885
  const root = document.querySelector("[data-composition-id]");
879
886
  if (!root)
880
- return null;
887
+ return { reason: null, bounded: true, callbackFrom: null };
888
+ let reason = null;
889
+ let bounded = true;
890
+ const targets = new Set();
891
+ // A match on `els` (null: not tied to any element). Returns true to stop the scan.
892
+ const hit = (name, els) => {
893
+ reason = reason ?? name;
894
+ if (els && els.length > 0)
895
+ for (const el of Array.from(els))
896
+ targets.add(el);
897
+ else
898
+ bounded = false;
899
+ return !collectAll;
900
+ };
881
901
  // (1) Computed styles at the current (init/t0) state — effects present in
882
902
  // the base render.
883
903
  const scanComputed = () => {
@@ -885,28 +905,51 @@ export async function detectCssEffectRisk(page) {
885
905
  for (const el of els) {
886
906
  const cs = getComputedStyle(el);
887
907
  const bf = cs.backdropFilter || cs.webkitBackdropFilter || "";
888
- if (bf && bf !== "none")
889
- return "backdrop-filter";
908
+ if (bf && bf !== "none" && hit("backdrop-filter", [el]))
909
+ return true;
890
910
  const f = cs.filter || "";
891
- if (f && f !== "none" && f.indexOf("blur(") !== -1)
892
- return "filter:blur";
893
- if (f && f !== "none" && f.indexOf("drop-shadow(") !== -1)
894
- return "filter:drop-shadow";
911
+ if (f && f !== "none" && f.indexOf("blur(") !== -1 && hit("filter:blur", [el])) {
912
+ return true;
913
+ }
914
+ if (f &&
915
+ f !== "none" &&
916
+ f.indexOf("drop-shadow(") !== -1 &&
917
+ hit("filter:drop-shadow", [el])) {
918
+ return true;
919
+ }
895
920
  const mbm = cs.mixBlendMode || "";
896
- if (mbm && mbm !== "normal")
897
- return "mix-blend-mode";
921
+ if (mbm && mbm !== "normal" && hit("mix-blend-mode", [el]))
922
+ return true;
898
923
  const an = cs.animationName || "";
899
924
  const ad = cs.animationDuration || "0s";
900
- if (an && an !== "none" && ad !== "0s" && parseFloat(ad) > 0)
901
- return "css-animation";
925
+ if (an &&
926
+ an !== "none" &&
927
+ ad !== "0s" &&
928
+ parseFloat(ad) > 0 &&
929
+ hit("css-animation", null)) {
930
+ return true;
931
+ }
902
932
  }
903
- return null;
933
+ return false;
904
934
  };
905
935
  // (2) Stylesheet rules — an effect applied later via a class swap won't be
906
936
  // computed at t0, but the rule that declares it exists up front. This
907
937
  // replaces the old per-frame scrub scan seek-free (conservative: a declared
908
938
  // rule ⇒ gate, even if applied only mid-timeline).
909
939
  const scanStyleSheets = () => {
940
+ // The elements a style rule can apply to now; a pseudo-element's effect shows only while
941
+ // its owner does. Empty when the rule is not a plain style rule or matches nothing yet.
942
+ const ruleTargets = (rule) => {
943
+ const selector = rule.selectorText;
944
+ if (!selector)
945
+ return [];
946
+ try {
947
+ return Array.from(root.querySelectorAll(selector.replace(/::?(before|after|first-line|first-letter|marker|placeholder|selection|backdrop)/g, "")));
948
+ }
949
+ catch {
950
+ return [];
951
+ }
952
+ };
910
953
  for (const sheet of Array.from(document.styleSheets)) {
911
954
  let rules = null;
912
955
  try {
@@ -917,63 +960,96 @@ export async function detectCssEffectRisk(page) {
917
960
  }
918
961
  for (const rule of Array.from(rules ?? [])) {
919
962
  const txt = rule.cssText || "";
963
+ let name = null;
920
964
  if (/backdrop-filter\s*:\s*(?!none)/i.test(txt))
921
- return "backdrop-filter";
922
- if (/(?:^|[^-])filter\s*:[^;{}]*blur\(/i.test(txt))
923
- return "filter:blur";
924
- if (/(?:^|[^-])filter\s*:[^;{}]*drop-shadow\(/i.test(txt)) {
925
- return "filter:drop-shadow";
965
+ name = "backdrop-filter";
966
+ else if (/(?:^|[^-])filter\s*:[^;{}]*blur\(/i.test(txt))
967
+ name = "filter:blur";
968
+ else if (/(?:^|[^-])filter\s*:[^;{}]*drop-shadow\(/i.test(txt)) {
969
+ name = "filter:drop-shadow";
926
970
  }
927
- if (/mix-blend-mode\s*:\s*(?!normal)/i.test(txt))
928
- return "mix-blend-mode";
971
+ else if (/mix-blend-mode\s*:\s*(?!normal)/i.test(txt))
972
+ name = "mix-blend-mode";
973
+ if (name && hit(name, ruleTargets(rule)))
974
+ return true;
929
975
  }
930
976
  }
931
- return null;
977
+ return false;
932
978
  };
933
979
  // (3) GSAP tween vars — effects animated by GSAP writing inline
934
980
  // filter / backdropFilter / mixBlendMode (never present in a stylesheet).
935
981
  const scanTweenVars = () => {
936
982
  const walk = (tl) => {
937
983
  if (typeof tl.getChildren !== "function")
938
- return null;
984
+ return false;
939
985
  for (const c of tl.getChildren(false, true, true)) {
940
- const sub = walk(c);
941
- if (sub)
942
- return sub;
986
+ if (walk(c))
987
+ return true;
943
988
  const vars = c.vars || {};
944
989
  for (const k of Object.keys(vars)) {
945
990
  const kl = k.toLowerCase();
991
+ let name = null;
946
992
  if (kl.includes("blend"))
947
- return "mix-blend-mode";
948
- if (kl.includes("backdrop"))
949
- return "backdrop-filter";
950
- if (kl === "filter" && /blur\(/i.test(String(vars[k] ?? "")))
951
- return "filter:blur";
952
- if (kl === "filter" && /drop-shadow\(/i.test(String(vars[k] ?? ""))) {
953
- return "filter:drop-shadow";
993
+ name = "mix-blend-mode";
994
+ else if (kl.includes("backdrop"))
995
+ name = "backdrop-filter";
996
+ else if (kl === "filter" && /blur\(/i.test(String(vars[k] ?? ""))) {
997
+ name = "filter:blur";
954
998
  }
999
+ else if (kl === "filter" && /drop-shadow\(/i.test(String(vars[k] ?? ""))) {
1000
+ name = "filter:drop-shadow";
1001
+ }
1002
+ if (!name)
1003
+ continue;
1004
+ const els = (typeof c.targets === "function" ? c.targets() : []).filter((t) => t instanceof Element);
1005
+ if (hit(name, els))
1006
+ return true;
955
1007
  }
956
1008
  }
957
- return null;
1009
+ return false;
958
1010
  };
959
1011
  const tls = window.__timelines || {};
960
1012
  for (const tl of Object.values(tls)) {
961
- const r = walk(tl);
962
- if (r)
963
- return r;
1013
+ if (walk(tl))
1014
+ return true;
964
1015
  }
965
- return null;
1016
+ return false;
966
1017
  };
967
1018
  // (4) WebGL context — seek-invariant, recorded at context creation.
968
1019
  const scanWebgl = () => {
969
1020
  const aw = window;
970
1021
  const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
971
- return accel.length > 0 ? "webgl-context" : null;
1022
+ return accel.length > 0 && hit("webgl-context", null);
972
1023
  };
973
- return scanComputed() || scanStyleSheets() || scanTweenVars() || scanWebgl();
974
- });
1024
+ for (const scan of [scanComputed, scanStyleSheets, scanTweenVars, scanWebgl]) {
1025
+ if (scan())
1026
+ break;
1027
+ }
1028
+ const calls = ["onStart", "onUpdate", "onComplete", "onRepeat", "onReverseComplete"];
1029
+ const firstCallback = (node, offset) => {
1030
+ const start = offset + (typeof node.startTime === "function" ? node.startTime() : 0);
1031
+ let first = calls.some((k) => typeof node.vars?.[k] === "function") ? start : Infinity;
1032
+ if (typeof node.getChildren === "function") {
1033
+ for (const child of node.getChildren(false, true, true)) {
1034
+ first = Math.min(first, firstCallback(child, start));
1035
+ }
1036
+ }
1037
+ return first;
1038
+ };
1039
+ const timelines = window
1040
+ .__timelines;
1041
+ const callbackStart = reason
1042
+ ? Math.min(Infinity, ...Object.values(timelines ?? {}).map((tl) => firstCallback(tl, 0)))
1043
+ : Infinity;
1044
+ window.__hfCssFxTargets = [...targets];
1045
+ return {
1046
+ reason,
1047
+ bounded,
1048
+ callbackFrom: Number.isFinite(callbackStart) ? callbackStart : null,
1049
+ };
1050
+ }, collect);
975
1051
  }
976
1052
  catch {
977
- return null;
1053
+ return { reason: null, bounded: true, callbackFrom: null };
978
1054
  }
979
1055
  }
@@ -148,7 +148,7 @@ export function createVideoFrameInjector(frameLookup, config) {
148
148
  lastCacheTouchByDir.set(cacheDir, now);
149
149
  }
150
150
  // fallow-ignore-next-line complexity
151
- return async (page, time, heldVideoTime) => {
151
+ const inject = async (page, time, heldVideoTime) => {
152
152
  for (const cacheDir of frameLookup.frameDirs())
153
153
  renewCacheLease(cacheDir);
154
154
  const activePayloads = frameLookup.getActiveFramePayloads(time);
@@ -205,6 +205,7 @@ export function createVideoFrameInjector(frameLookup, config) {
205
205
  }
206
206
  }
207
207
  };
208
+ return Object.assign(inject, { fresh: () => createVideoFrameInjector(frameLookup, config) });
208
209
  }
209
210
  /**
210
211
  * Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so
package/dist/types.d.ts CHANGED
@@ -16,11 +16,12 @@ export type PageMotionBlurOff = "off" | "hdr-render" | "layered-composite" | "di
16
16
  */
17
17
  export type SubTimelineWaitOutcome = "ready" | "timeout" | "script_failure";
18
18
  /**
19
- * Shared by every capture session of one render: composition ids whose timeline
20
- * wait already timed out, so later sessions report the timeout without waiting again.
19
+ * Shared by every capture session of one render: timeline waits that already timed out
20
+ * (so later sessions skip the wait), and scene callback errors the render log already shows.
21
21
  */
22
22
  export interface SubTimelineWaitMemo {
23
23
  unregisteredIds?: readonly string[];
24
+ loggedSceneCallbackErrors?: string[];
24
25
  }
25
26
  export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
26
27
  /** Structured correctness warning produced while preparing a capture session. */
@@ -221,6 +222,7 @@ export interface CaptureOptions {
221
222
  * Clamped to 0..8 like the env knob; HF_DE_VERIFY, when set, still wins.
222
223
  */
223
224
  deVerifySamples?: number;
225
+ deFrameFallback?: boolean;
224
226
  }
225
227
  export interface CaptureVideoMetadataHint {
226
228
  id: string;
@@ -382,6 +384,7 @@ export interface CapturePerfSummary {
382
384
  deVerifyInitMs: number;
383
385
  /** Clip-cut boundary frames routed to per-frame screenshot (Lim 6). */
384
386
  deBoundaryFrames: number;
387
+ deRiskFrames: number;
385
388
  /** Per-frame "No cached paint record" screenshot fallbacks during capture. */
386
389
  deNcprFallbacks: number;
387
390
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.198",
3
+ "version": "0.8.200",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "linkedom": "^0.18.12",
51
51
  "puppeteer": "^25.8.0",
52
52
  "puppeteer-core": "^25.8.0",
53
- "@hyperframes/parsers": "^0.8.198",
54
- "@hyperframes/core": "^0.8.198"
53
+ "@hyperframes/core": "^0.8.200",
54
+ "@hyperframes/parsers": "^0.8.200"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",