@hyperframes/engine 0.8.44 → 0.8.46

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,8 +9,10 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
12
+ import { quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
13
13
  import { DrawElementCaptureError } from "./drawElementCaptureError.js";
14
+ import { encodePng } from "../utils/alphaBlit.js";
15
+ import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
14
16
  // ── Extracted modules ───────────────────────────────────────────────────────
15
17
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, } from "./browserManager.js";
16
18
  import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
@@ -1653,7 +1655,7 @@ export async function initializeSession(session) {
1653
1655
  await initDrawElementOrTransparentBackground(session, page, logInitPhase);
1654
1656
  await armStaticDedup(session, session.page, logInitPhase);
1655
1657
  await ensureRenderFrameSiblings(session.page);
1656
- session.isInitialized = true;
1658
+ finalizeSessionInit(session);
1657
1659
  return;
1658
1660
  }
1659
1661
  // In BeginFrame mode, Chrome's event loop is paused until we issue frames.
@@ -1821,7 +1823,7 @@ export async function initializeSession(session) {
1821
1823
  await ensureRenderFrameSiblings(page);
1822
1824
  const commitCdp = await getCdpSession(page);
1823
1825
  await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams);
1824
- session.isInitialized = true;
1826
+ finalizeSessionInit(session);
1825
1827
  }
1826
1828
  async function captureFrameErrorDiagnostics(session, frameIndex, time, error) {
1827
1829
  try {
@@ -1856,23 +1858,30 @@ export async function waitForPendingSeekCompletion(page) {
1856
1858
  await waitForCompletion?.();
1857
1859
  });
1858
1860
  }
1859
- async function prepareFrameForCapture(session, frameIndex, time) {
1861
+ /**
1862
+ * Seek the page timeline and report whether a page-side composite is pending.
1863
+ *
1864
+ * The page's `seek()` owns all framework-specific stepping (GSAP, CSS animations, WAAPI);
1865
+ * the options object reaches it untouched through the producer's `__hf.seek` bridge.
1866
+ * Seek and pending-flag read share one round trip.
1867
+ */
1868
+ async function seekPageTimeline(page, time, seekOptions) {
1869
+ return page.evaluate((t, opts) => {
1870
+ if (window.__hf && typeof window.__hf.seek === "function") {
1871
+ window.__hf.seek(t, opts);
1872
+ }
1873
+ return !!window
1874
+ .__hf_page_composite_pending;
1875
+ }, time, seekOptions);
1876
+ }
1877
+ async function prepareFrameForCapture(session, frameIndex, time, seekOptions) {
1860
1878
  const { page, options } = session;
1861
1879
  if (!session.isInitialized) {
1862
1880
  throw new Error("[FrameCapture] Session not initialized");
1863
1881
  }
1864
- const quantizedTime = quantizeTimeToFrame(time, fpsToNumber(options.fps));
1882
+ const quantizedTime = quantizeSeekTime(time, fpsToNumber(options.fps), seekOptions?.subFrameDivisions);
1865
1883
  const seekStart = Date.now();
1866
- // Seek via the __hf protocol. The page's seek() implementation handles
1867
- // all framework-specific logic (GSAP stepping, CSS animation sync, etc.)
1868
- // Seek + check page-side composite pending flag in one round-trip.
1869
- const hasPendingComposite = await page.evaluate((t) => {
1870
- if (window.__hf && typeof window.__hf.seek === "function") {
1871
- window.__hf.seek(t);
1872
- }
1873
- return !!window
1874
- .__hf_page_composite_pending;
1875
- }, quantizedTime);
1884
+ const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
1876
1885
  await decodeDynamicCssBackgroundImages(page);
1877
1886
  const seekMs = Date.now() - seekStart;
1878
1887
  // Before-capture hook (e.g. video frame injection) — runs before
@@ -1976,7 +1985,19 @@ export function isStaticDedupFrameAnalysisSafe(totalFrames) {
1976
1985
  * a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
1977
1986
  */
1978
1987
  export async function computeStaticFrameSet(page, fps) {
1979
- const result = await page.evaluate(() => {
1988
+ const result = await page.evaluate((spatialProps) => {
1989
+ // Passed in from SPATIAL_TWEEN_PROPERTIES rather than declared here, so the list
1990
+ // stays the one this module's own tests exercise (the matching code itself must
1991
+ // stay inline — this closure is serialized and runs in the page realm).
1992
+ const SPATIAL_PROPS = new Set(spatialProps);
1993
+ function isSpatial(vars) {
1994
+ if (!vars)
1995
+ return false;
1996
+ for (const key of Object.keys(vars))
1997
+ if (SPATIAL_PROPS.has(key))
1998
+ return true;
1999
+ return false;
2000
+ }
1980
2001
  const intervals = [];
1981
2002
  let tweenCount = 0;
1982
2003
  // totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
@@ -2002,8 +2023,9 @@ export async function computeStaticFrameSet(page, fps) {
2002
2023
  // Mark its entire span as animated so those frames are never deduped.
2003
2024
  if (typeof tl.vars?.onUpdate === "function") {
2004
2025
  const total = typeof tl.totalDuration === "function" ? tl.totalDuration() : 0;
2026
+ // onUpdate can move anything (e.g. x/y from Math.random) — conservatively spatial.
2005
2027
  if (total > 0)
2006
- intervals.push({ start: offset, end: offset + total });
2028
+ intervals.push({ start: offset, end: offset + total, spatial: true });
2007
2029
  }
2008
2030
  for (const child of tl.getChildren(false, true, true)) {
2009
2031
  const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
@@ -2011,7 +2033,10 @@ export async function computeStaticFrameSet(page, fps) {
2011
2033
  const total = typeof child.totalDuration === "function" ? child.totalDuration() : single;
2012
2034
  if (typeof child.getChildren === "function") {
2013
2035
  if (total > single + 1e-6) {
2014
- intervals.push({ start, end: start + total });
2036
+ // A repeating nested timeline's own children are walked for their real
2037
+ // vars below; this span is a conservative (spatial) placeholder for the
2038
+ // repeat/yoyo overrun, which a per-child walk doesn't otherwise cover.
2039
+ intervals.push({ start, end: start + total, spatial: true });
2015
2040
  // Still descend for hasTimelineCall even though the repeating
2016
2041
  // span is already opaque (its frames are excluded from dedup
2017
2042
  // regardless): a call() inside it is a review-flagged detection
@@ -2026,7 +2051,7 @@ export async function computeStaticFrameSet(page, fps) {
2026
2051
  }
2027
2052
  else {
2028
2053
  tweenCount++;
2029
- intervals.push({ start, end: start + total });
2054
+ intervals.push({ start, end: start + total, spatial: isSpatial(child.vars) });
2030
2055
  if (total <= 1e-6 &&
2031
2056
  (typeof child.vars?.onComplete === "function" ||
2032
2057
  typeof child.vars?.onReverseComplete === "function")) {
@@ -2074,13 +2099,14 @@ export async function computeStaticFrameSet(page, fps) {
2074
2099
  hasUnresolvableClipStart,
2075
2100
  hasTimelineCall,
2076
2101
  };
2077
- });
2102
+ }, SPATIAL_TWEEN_PROPERTIES);
2078
2103
  const { intervals, tweenCount, duration, hasVideo, hasCanvas, hasNonGsapAnim, hasUnresolvableClipStart, hasTimelineCall, } = result;
2079
2104
  const totalFrames = Math.max(1, Math.ceil(duration * fps));
2080
2105
  if (!isStaticDedupFrameAnalysisSafe(totalFrames)) {
2081
2106
  return {
2082
2107
  totalFrames,
2083
2108
  staticFrameSet: new Set(),
2109
+ nonSpatialOnlyFrameSet: new Set(),
2084
2110
  hasVideo,
2085
2111
  hasCanvas,
2086
2112
  hasNonGsapAnim,
@@ -2090,11 +2116,15 @@ export async function computeStaticFrameSet(page, fps) {
2090
2116
  };
2091
2117
  }
2092
2118
  const animated = new Set();
2093
- for (const { start, end } of intervals) {
2119
+ const spatialFrameSet = new Set();
2120
+ for (const { start, end, spatial } of intervals) {
2094
2121
  const lo = Math.max(0, Math.floor(start * fps));
2095
2122
  const hi = Math.min(totalFrames - 1, Math.ceil(end * fps));
2096
- for (let f = lo; f <= hi; f++)
2123
+ for (let f = lo; f <= hi; f++) {
2097
2124
  animated.add(f);
2125
+ if (spatial)
2126
+ spatialFrameSet.add(f);
2127
+ }
2098
2128
  }
2099
2129
  for (const f of await computeClipBoundaryFrames(page, fps))
2100
2130
  animated.add(f);
@@ -2126,9 +2156,16 @@ export async function computeStaticFrameSet(page, fps) {
2126
2156
  staticFrameSet.add(f);
2127
2157
  }
2128
2158
  }
2159
+ const nonSpatialOnlyFrameSet = new Set();
2160
+ if (eligible) {
2161
+ for (const f of animated)
2162
+ if (!spatialFrameSet.has(f))
2163
+ nonSpatialOnlyFrameSet.add(f);
2164
+ }
2129
2165
  return {
2130
2166
  totalFrames,
2131
2167
  staticFrameSet,
2168
+ nonSpatialOnlyFrameSet,
2132
2169
  hasVideo,
2133
2170
  hasCanvas,
2134
2171
  hasNonGsapAnim,
@@ -2396,6 +2433,21 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
2396
2433
  * via HF_STATIC_DEDUP_SAMPLES (default 24).
2397
2434
  */
2398
2435
  async function armStaticDedup(session, page, logInitPhase) {
2436
+ // Adaptive motion-blur sample-count classification shares the GSAP-timeline walk
2437
+ // below but is gated independently of dedup (capture mode / before-capture hooks are
2438
+ // about buffer-reuse safety, irrelevant to which properties a tween touches), so it
2439
+ // is computed here, once, ahead of dedup's own idempotency check. Cached in
2440
+ // `sharedStaticFrameStats` so dedup's own call further down does not repeat the walk.
2441
+ // Reads `session.options.motionBlur` (the raw caller options), not `session.motionBlur`
2442
+ // (the resolved plan) — every armStaticDedup call site runs before finalizeSessionInit
2443
+ // resolves the plan, so the resolved field is never set yet at this point.
2444
+ let sharedStaticFrameStats;
2445
+ if (session.options.motionBlur &&
2446
+ session.options.motionBlur.samplesPerFrame === undefined &&
2447
+ !session.motionBlurNonSpatialFrames) {
2448
+ sharedStaticFrameStats = await computeStaticFrameSet(page, fpsToNumber(session.options.fps));
2449
+ session.motionBlurNonSpatialFrames = sharedStaticFrameStats.nonSpatialOnlyFrameSet;
2450
+ }
2399
2451
  // Idempotent: the drawElement init path arms dedup BEFORE canvas injection
2400
2452
  // (verification screenshots need the un-injected DOM), and initializeSession
2401
2453
  // calls this again unconditionally afterwards. Once staticFrames is
@@ -2442,7 +2494,7 @@ async function armStaticDedup(session, page, logInitPhase) {
2442
2494
  return;
2443
2495
  }
2444
2496
  const fps = fpsToNumber(session.options.fps);
2445
- const stats = await computeStaticFrameSet(page, fps);
2497
+ const stats = sharedStaticFrameStats ?? (await computeStaticFrameSet(page, fps));
2446
2498
  if (!stats.eligible || stats.staticFrameSet.size === 0) {
2447
2499
  session.staticDedupSkipReason = "ineligible";
2448
2500
  logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
@@ -2676,8 +2728,192 @@ export async function withFrameDeadline(work, label, ms, onTimeout) {
2676
2728
  });
2677
2729
  }
2678
2730
  }
2679
- async function captureFrameCore(session, frameIndex, time) {
2731
+ /**
2732
+ * Resolve the session's motion-blur plan, rejecting combinations the accumulation pass
2733
+ * cannot render correctly instead of silently producing an unblurred frame.
2734
+ *
2735
+ * Called once initialization has settled the capture mode. `format: "png"` is required
2736
+ * because samples are averaged pixel by pixel: JPEG samples would be averaged after
2737
+ * lossy quantization and the blended frame is re-encoded as PNG. `<video>` content is
2738
+ * out of scope because it is supplied by the before-capture frame-injection hook rather
2739
+ * than by the timeline seek, so it cannot follow a sub-frame time.
2740
+ */
2741
+ export function resolveSessionMotionBlur(session) {
2742
+ const plan = resolveMotionBlurPlan(session.options.motionBlur);
2743
+ if (!plan)
2744
+ return undefined;
2745
+ if (session.captureMode !== "screenshot") {
2746
+ throw new Error(`[MotionBlur] sub-frame motion blur requires screenshot capture mode, got "${session.captureMode}"`);
2747
+ }
2748
+ if (session.options.format !== "png") {
2749
+ throw new Error(`[MotionBlur] sub-frame motion blur requires format "png", got "${session.options.format ?? "jpeg"}"`);
2750
+ }
2751
+ if (session.onBeforeCapture) {
2752
+ throw new Error("[MotionBlur] sub-frame motion blur cannot run with injected video frames: video content is extracted per output frame and does not follow a sub-frame seek");
2753
+ }
2754
+ return plan;
2755
+ }
2756
+ /** Seek to `time` and capture one surface with the session's capture mode. */
2757
+ async function captureFrameSurface(session, frameIndex, time, seekOptions) {
2680
2758
  const { page, options } = session;
2759
+ const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time, seekOptions);
2760
+ const screenshotStart = Date.now();
2761
+ let screenshotBuffer;
2762
+ if (session.captureMode === "beginframe") {
2763
+ const frameTimeTicks = session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
2764
+ const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
2765
+ if (result.hasDamage)
2766
+ session.beginFrameHasDamageCount++;
2767
+ else
2768
+ session.beginFrameNoDamageCount++;
2769
+ screenshotBuffer = result.buffer;
2770
+ }
2771
+ else if (session.captureMode === "drawelement" &&
2772
+ session.clipBoundaryFrames?.has(frameIndex) &&
2773
+ process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
2774
+ throw new DrawElementCaptureError(frameIndex, "boundary screenshot requested on an injected canvas page");
2775
+ }
2776
+ else if (session.captureMode === "drawelement") {
2777
+ // Advance compositor state via BeginFrame when available (Linux headless-shell);
2778
+ // on macOS the compositor advances naturally without BeginFrame.
2779
+ if (session.beginFrameTimeTicks > 0) {
2780
+ const client = await getCdpSession(page);
2781
+ await client.send("HeadlessExperimental.beginFrame", {
2782
+ frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
2783
+ interval: session.beginFrameIntervalMs,
2784
+ noDisplayUpdates: false,
2785
+ // no screenshot param — we capture via canvas
2786
+ });
2787
+ }
2788
+ try {
2789
+ screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
2790
+ // Paint-event sync only without BeginFrame (macOS / screenshot-launched):
2791
+ // under BeginFrame control the per-frame beginFrame above already painted
2792
+ // a fresh snapshot, and no further paint would arrive during a wait.
2793
+ session.beginFrameTimeTicks === 0);
2794
+ // A tiny JPEG may be a dropped paint record. Never screenshot this page:
2795
+ // its injected canvas can still hold the preceding frame's bitmap.
2796
+ // Restart on a fresh screenshot page even if this was a simple valid frame.
2797
+ if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
2798
+ const sizes = (session.deFrameSizes ??= []);
2799
+ const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
2800
+ const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
2801
+ const floor = Math.max(20000, median * 0.12);
2802
+ if (screenshotBuffer.length < floor) {
2803
+ throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
2804
+ }
2805
+ else {
2806
+ if (sizes.length >= 60)
2807
+ sizes.shift();
2808
+ sizes.push(screenshotBuffer.length);
2809
+ }
2810
+ }
2811
+ }
2812
+ catch (err) {
2813
+ // Missing paint records/canvas state require a new screenshot page.
2814
+ if (isRecoverableDrawElementError(err)) {
2815
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2816
+ const reason = isCanvasNotInitializedError(err)
2817
+ ? "drawElement canvas not initialized"
2818
+ : "No cached paint record";
2819
+ throw new DrawElementCaptureError(frameIndex, reason, err);
2820
+ }
2821
+ else {
2822
+ throw err;
2823
+ }
2824
+ }
2825
+ }
2826
+ else {
2827
+ screenshotBuffer = await pageScreenshotCapture(page, options);
2828
+ }
2829
+ const screenshotMs = Date.now() - screenshotStart;
2830
+ return { buffer: screenshotBuffer, quantizedTime, seekMs, beforeCaptureMs, screenshotMs };
2831
+ }
2832
+ /**
2833
+ * Mark the session ready to capture.
2834
+ *
2835
+ * The single owner of what finishing initialization means, because `initializeSession`
2836
+ * has two exits: screenshot mode returns early, and every other mode falls through the
2837
+ * end. Resolving motion blur at only one of them marks the session ready with no plan,
2838
+ * which silently renders unblurred rather than failing. Both fields are set here so a
2839
+ * third exit cannot forget one.
2840
+ */
2841
+ function finalizeSessionInit(session) {
2842
+ session.motionBlur = resolveSessionMotionBlur(session);
2843
+ session.isInitialized = true;
2844
+ }
2845
+ /** Choose how many samples this frame gets when the plan is adaptive: the floor with
2846
+ * no probe for a frame confirmed non-spatial, otherwise two edge probes (see
2847
+ * motionBlurProbeTimes) mapped through adaptiveSampleCount. Probe cost is returned
2848
+ * alongside the count so the caller folds it into the frame's timing totals. */
2849
+ async function resolveAdaptiveSampleCount(session, frameIndex, absFrameIndex, plan, fps, sampleSeek) {
2850
+ if (session.motionBlurNonSpatialFrames?.has(absFrameIndex)) {
2851
+ return {
2852
+ samplesPerFrame: DEFAULT_SAMPLES_PER_FRAME,
2853
+ seekMs: 0,
2854
+ beforeCaptureMs: 0,
2855
+ screenshotMs: 0,
2856
+ };
2857
+ }
2858
+ const { windowStart, windowEnd } = motionBlurProbeTimes(plan, absFrameIndex, fps);
2859
+ const probeA = await captureFrameSurface(session, frameIndex, windowStart, sampleSeek);
2860
+ const probeB = await captureFrameSurface(session, frameIndex, windowEnd, sampleSeek);
2861
+ return {
2862
+ samplesPerFrame: adaptiveSampleCount(probeDiffMagnitude(probeA.buffer, probeB.buffer)),
2863
+ seekMs: probeA.seekMs + probeB.seekMs,
2864
+ beforeCaptureMs: probeA.beforeCaptureMs + probeB.beforeCaptureMs,
2865
+ screenshotMs: probeA.screenshotMs + probeB.screenshotMs,
2866
+ };
2867
+ }
2868
+ /**
2869
+ * Capture one output frame as the average of some number of sub-frame captures — fixed
2870
+ * by the caller, or chosen per frame from measured motion (see
2871
+ * `resolveAdaptiveSampleCount`).
2872
+ *
2873
+ * Callback invariant: exactly one eventful seek per output frame, at the frame time,
2874
+ * arriving from the previous frame's time. Every sample seek (including a probe)
2875
+ * suppresses events and the playhead is restored to the frame time afterwards, so a
2876
+ * composition's own onUpdate/onComplete fire on the same interval boundaries as a
2877
+ * render with blur off.
2878
+ */
2879
+ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan) {
2880
+ const fps = fpsToNumber(session.options.fps);
2881
+ const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
2882
+ const eventfulSeekStart = Date.now();
2883
+ await seekPageTimeline(session.page, frameTime, undefined);
2884
+ const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
2885
+ const sampleSeek = {
2886
+ suppressEvents: true,
2887
+ subFrameDivisions: plan.subFrameDivisions,
2888
+ };
2889
+ let samplesPerFrame = plan.fixedSamplesPerFrame;
2890
+ if (samplesPerFrame === null) {
2891
+ const chosen = await resolveAdaptiveSampleCount(session, frameIndex, absFrameIndex, plan, fps, sampleSeek);
2892
+ samplesPerFrame = chosen.samplesPerFrame;
2893
+ totals.seekMs += chosen.seekMs;
2894
+ totals.beforeCaptureMs += chosen.beforeCaptureMs;
2895
+ totals.screenshotMs += chosen.screenshotMs;
2896
+ }
2897
+ const accumulator = new MotionBlurAccumulator(plan.blend);
2898
+ for (const sampleTime of motionBlurSampleTimes(plan, absFrameIndex, fps, samplesPerFrame)) {
2899
+ const sample = await captureFrameSurface(session, frameIndex, sampleTime, sampleSeek);
2900
+ totals.seekMs += sample.seekMs;
2901
+ totals.beforeCaptureMs += sample.beforeCaptureMs;
2902
+ totals.screenshotMs += sample.screenshotMs;
2903
+ accumulator.add(sample.buffer);
2904
+ }
2905
+ const restoreSeekStart = Date.now();
2906
+ await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
2907
+ totals.seekMs += Date.now() - restoreSeekStart;
2908
+ const blended = accumulator.finish();
2909
+ return {
2910
+ buffer: encodePng(blended.width, blended.height, blended.data),
2911
+ quantizedTime: frameTime,
2912
+ ...totals,
2913
+ };
2914
+ }
2915
+ async function captureFrameCore(session, frameIndex, time) {
2916
+ const { options } = session;
2681
2917
  const startTime = Date.now();
2682
2918
  // Static-frame dedup: this frame is byte-identical to its predecessor (predicted +
2683
2919
  // anchor-verified at init) → reuse the prior buffer, skip the seek + screenshot.
@@ -2696,7 +2932,9 @@ async function captureFrameCore(session, frameIndex, time) {
2696
2932
  const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
2697
2933
  if (session.staticFrames?.has(absFrameIndex) &&
2698
2934
  session.lastFrameBuffer &&
2699
- session.lastFrameAbsoluteIndex === absFrameIndex - 1) {
2935
+ session.lastFrameAbsoluteIndex === absFrameIndex - 1 &&
2936
+ (!session.motionBlur ||
2937
+ motionBlurWindowIsStatic(session.motionBlur, absFrameIndex, session.staticFrames))) {
2700
2938
  session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
2701
2939
  session.lastFrameAbsoluteIndex = absFrameIndex;
2702
2940
  return {
@@ -2706,77 +2944,12 @@ async function captureFrameCore(session, frameIndex, time) {
2706
2944
  };
2707
2945
  }
2708
2946
  try {
2709
- const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
2710
- const screenshotStart = Date.now();
2711
- let screenshotBuffer;
2712
- if (session.captureMode === "beginframe") {
2713
- const frameTimeTicks = session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
2714
- const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
2715
- if (result.hasDamage)
2716
- session.beginFrameHasDamageCount++;
2717
- else
2718
- session.beginFrameNoDamageCount++;
2719
- screenshotBuffer = result.buffer;
2720
- }
2721
- else if (session.captureMode === "drawelement" &&
2722
- session.clipBoundaryFrames?.has(frameIndex) &&
2723
- process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
2724
- throw new DrawElementCaptureError(frameIndex, "boundary screenshot requested on an injected canvas page");
2725
- }
2726
- else if (session.captureMode === "drawelement") {
2727
- // Advance compositor state via BeginFrame when available (Linux headless-shell);
2728
- // on macOS the compositor advances naturally without BeginFrame.
2729
- if (session.beginFrameTimeTicks > 0) {
2730
- const client = await getCdpSession(page);
2731
- await client.send("HeadlessExperimental.beginFrame", {
2732
- frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
2733
- interval: session.beginFrameIntervalMs,
2734
- noDisplayUpdates: false,
2735
- // no screenshot param — we capture via canvas
2736
- });
2737
- }
2738
- try {
2739
- screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
2740
- // Paint-event sync only without BeginFrame (macOS / screenshot-launched):
2741
- // under BeginFrame control the per-frame beginFrame above already painted
2742
- // a fresh snapshot, and no further paint would arrive during a wait.
2743
- session.beginFrameTimeTicks === 0);
2744
- // A tiny JPEG may be a dropped paint record. Never screenshot this page:
2745
- // its injected canvas can still hold the preceding frame's bitmap.
2746
- // Restart on a fresh screenshot page even if this was a simple valid frame.
2747
- if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
2748
- const sizes = (session.deFrameSizes ??= []);
2749
- const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
2750
- const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
2751
- const floor = Math.max(20000, median * 0.12);
2752
- if (screenshotBuffer.length < floor) {
2753
- throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
2754
- }
2755
- else {
2756
- if (sizes.length >= 60)
2757
- sizes.shift();
2758
- sizes.push(screenshotBuffer.length);
2759
- }
2760
- }
2761
- }
2762
- catch (err) {
2763
- // Missing paint records/canvas state require a new screenshot page.
2764
- if (isRecoverableDrawElementError(err)) {
2765
- session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2766
- const reason = isCanvasNotInitializedError(err)
2767
- ? "drawElement canvas not initialized"
2768
- : "No cached paint record";
2769
- throw new DrawElementCaptureError(frameIndex, reason, err);
2770
- }
2771
- else {
2772
- throw err;
2773
- }
2774
- }
2775
- }
2776
- else {
2777
- screenshotBuffer = await pageScreenshotCapture(page, options);
2778
- }
2779
- const screenshotMs = Date.now() - screenshotStart;
2947
+ const plan = session.motionBlur;
2948
+ // One capture per frame, or `plan.samplesPerFrame` captures averaged into one. Both
2949
+ // report the same timings, so perf accounting and the dedup anchor stay shared.
2950
+ const { buffer, quantizedTime, seekMs, beforeCaptureMs, screenshotMs } = plan
2951
+ ? await captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
2952
+ : await captureFrameSurface(session, frameIndex, time);
2780
2953
  const captureTimeMs = Date.now() - startTime;
2781
2954
  session.capturePerf.frames += 1;
2782
2955
  session.capturePerf.seekMs += seekMs;
@@ -2786,10 +2959,10 @@ async function captureFrameCore(session, frameIndex, time) {
2786
2959
  session.capturePerf.frameMs.push(captureTimeMs);
2787
2960
  // Retain this freshly-captured buffer so the following static frames can reuse it.
2788
2961
  if (session.staticFrames) {
2789
- session.lastFrameBuffer = screenshotBuffer;
2962
+ session.lastFrameBuffer = buffer;
2790
2963
  session.lastFrameAbsoluteIndex = absFrameIndex;
2791
2964
  }
2792
- return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
2965
+ return { buffer, quantizedTime, captureTimeMs };
2793
2966
  }
2794
2967
  catch (captureError) {
2795
2968
  if (session.isInitialized) {
@@ -2803,6 +2976,15 @@ export async function captureFrame(session, frameIndex, time) {
2803
2976
  const framePath = writeCapturedFrame(session, frameIndex, buffer);
2804
2977
  return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
2805
2978
  }
2979
+ /**
2980
+ * File extension for a captured frame, keyed on the format the frames were actually
2981
+ * captured in. The encoder's input pattern and the writer must agree, so both read this
2982
+ * rather than re-deriving the answer from whether the OUTPUT needs alpha, which is a
2983
+ * different question and stops being equivalent as soon as anything else forces PNG.
2984
+ */
2985
+ export function frameFileExtension(format) {
2986
+ return format === "png" ? "png" : "jpg";
2987
+ }
2806
2988
  /**
2807
2989
  * Write an already-captured frame buffer to the session's output dir using the
2808
2990
  * canonical `frame_NNNNNN.{jpg,png}` naming. `fileIndex` is the ENCODER-facing
@@ -2812,7 +2994,7 @@ export async function captureFrame(session, frameIndex, time) {
2812
2994
  * `captureFrameToBufferPipelined` without duplicating the naming convention.
2813
2995
  */
2814
2996
  export function writeCapturedFrame(session, fileIndex, buffer) {
2815
- const ext = session.options.format === "png" ? "png" : "jpg";
2997
+ const ext = frameFileExtension(session.options.format);
2816
2998
  const framePath = join(session.outputDir, `frame_${String(fileIndex).padStart(6, "0")}.${ext}`);
2817
2999
  writeFileSync(framePath, buffer);
2818
3000
  return framePath;
@@ -2855,6 +3037,13 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
2855
3037
  * - macOS hardware GPU path (syncToPaintEvent=true, beginFrameTimeTicks=0).
2856
3038
  * BeginFrame (Linux) uses the standard synchronous path unchanged.
2857
3039
  */
3040
+ /**
3041
+ * Worker-encode path, gated to drawElement capture. It does not route through
3042
+ * `captureFrameCore` and so has no accumulation branch; that is safe only because
3043
+ * `resolveSessionMotionBlur` rejects every capture mode except screenshot, which makes
3044
+ * this function unreachable with motion blur on. Widening the supported capture modes
3045
+ * means handling accumulation here too.
3046
+ */
2858
3047
  export async function captureFrameToBufferPipelined(session, frameIndex, time) {
2859
3048
  const { page, options } = session;
2860
3049
  const startTime = Date.now();