@hyperframes/engine 0.8.10 → 0.8.12

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 { quantizeTimeToFrame, fpsToNumber } from "@hyperframes/core";
12
+ import { quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
13
13
  // ── Extracted modules ───────────────────────────────────────────────────────
14
14
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, } from "./browserManager.js";
15
15
  import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
@@ -1904,18 +1904,15 @@ async function prepareFrameForCapture(session, frameIndex, time) {
1904
1904
  * cut changes content with no tween; treat those frames as animated so the post-cut
1905
1905
  * frame is captured fresh and later static frames reuse the correct scene.
1906
1906
  */
1907
- async function computeClipBoundaryFrames(page, fps) {
1908
- const schedule = await page.evaluate(() => Array.from(document.querySelectorAll("[data-start]")).map((el) => ({
1909
- start: parseFloat(el.dataset.start || ""),
1910
- dur: parseFloat(el.dataset.duration || ""),
1911
- })));
1907
+ export function computeAuthoredClipBoundaryFrames(schedule, fps) {
1912
1908
  const frames = new Set();
1913
- for (const { start, dur } of schedule) {
1914
- if (Number.isNaN(start))
1909
+ for (const rawTiming of schedule) {
1910
+ const timing = resolveAuthoredTimingWindow(rawTiming);
1911
+ if (!timing)
1915
1912
  continue;
1916
- const edges = [Math.round(start * fps)];
1917
- if (!Number.isNaN(dur))
1918
- edges.push(Math.round((start + dur) * fps));
1913
+ const edges = [Math.round(timing.start * fps)];
1914
+ if (timing.end != null)
1915
+ edges.push(Math.round(timing.end * fps));
1919
1916
  for (const e of edges) {
1920
1917
  for (const f of [e - 1, e, e + 1]) {
1921
1918
  if (f >= 0)
@@ -1925,6 +1922,16 @@ async function computeClipBoundaryFrames(page, fps) {
1925
1922
  }
1926
1923
  return frames;
1927
1924
  }
1925
+ async function computeClipBoundaryFrames(page, fps) {
1926
+ const schedule = await page.evaluate(() => Array.from(document.querySelectorAll("[data-start]")).map((el) => ({
1927
+ start: el.getAttribute("data-start"),
1928
+ duration: el.getAttribute("data-duration"),
1929
+ authoredDuration: el.getAttribute("data-hf-authored-duration"),
1930
+ end: el.getAttribute("data-end"),
1931
+ authoredEnd: el.getAttribute("data-hf-authored-end"),
1932
+ })));
1933
+ return computeAuthoredClipBoundaryFrames(schedule, fps);
1934
+ }
1928
1935
  // Static dedup is an optional optimization. Building frame-index Sets scales with the
1929
1936
  // composition's declared duration, so malformed/sentinel durations must fail closed before
1930
1937
  // allocating them. Normal capture and the producer's typed duration validation still proceed.
@@ -2108,64 +2115,135 @@ const STATIC_VERIFY_REFERENCE_STRIDE = 24;
2108
2115
  // optimization before the real render starts. Exhaustion fails closed: dedup is
2109
2116
  // disabled and normal capture proceeds.
2110
2117
  const STATIC_VERIFY_MAX_MS = 15_000;
2111
- /**
2112
- * Interior verification points for a run [a..b], plus the always-included end `b`.
2113
- * Density used to be a flat point-count cap (min(sampleCount, 8)), so a run's
2114
- * stride grew with its span on a long run (many merged static frames), two
2115
- * checks could land hundreds of frames apart. A genuine content change in
2116
- * between (e.g. text swapped by a mechanism computeStaticFrameSet's GSAP-only
2117
- * tween walk can't see) then hides between samples and the whole run gets
2118
- * wrongly trusted as static.
2119
- *
2120
- * `sampleCount` (HF_STATIC_DEDUP_SAMPLES) is a per-run point-count FLOOR, not a
2121
- * stride cap — raising it always increases density, never decreases it. (An
2122
- * earlier revision of this fix bounded the stride BY sampleCount directly, which
2123
- * inverted that: raising sampleCount widened the allowed gap instead of shrinking
2124
- * it, and the "raise HF_STATIC_DEDUP_SAMPLES to verify more" log guidance became
2125
- * backwards for exactly the long runs it's meant to help.) The length-scaling
2126
- * fix itself comes from STATIC_VERIFY_REFERENCE_STRIDE, which is independent of
2127
- * sampleCount, so density scales with run length regardless of how that knob is
2128
- * set; sampleCount only ever raises density further above that floor.
2129
- *
2130
- * Pure and exported so its scaling behavior is unit-testable without a real
2131
- * page/browser.
2132
- */
2133
- export function computeStaticVerificationPoints(a, b, sampleCount) {
2134
- const span = b - a;
2135
- const lengthScaledPoints = span > 0 ? Math.ceil(span / STATIC_VERIFY_REFERENCE_STRIDE) + 1 : 1;
2136
- const perRun = Math.max(3, sampleCount, lengthScaledPoints);
2137
- const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1;
2138
- const pts = new Set();
2139
- for (let f = a; f <= b; f += stride)
2140
- pts.add(f);
2141
- pts.add(b);
2142
- return [...pts].sort((x, y) => x - y);
2118
+ // Two captures (anchor + comparison) are the minimum proof for a run. Four hundred
2119
+ // therefore permits 200 fully verified runs while bounding pathological schedules.
2120
+ const STATIC_VERIFY_MIN_SCREENSHOT_CAP = 400;
2121
+ // Preserve the legacy tuning headroom: raising the composition-wide sample floor may
2122
+ // raise the cap proportionally, but never changes the fixed wall deadline.
2123
+ const STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER = 8;
2124
+ // Keep sample-driven work within the 400-screenshot base cap: 50 comparisons × 8
2125
+ // legacy headroom. Mandatory <=24-gap points may still raise the independent cap.
2126
+ const STATIC_VERIFY_MAX_GLOBAL_SAMPLE_FLOOR = STATIC_VERIFY_MIN_SCREENSHOT_CAP / STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER;
2127
+ function contiguousStaticRuns(frames) {
2128
+ const runs = [];
2129
+ for (const frame of frames) {
2130
+ const last = runs.at(-1);
2131
+ if (last && frame === last.b + 1)
2132
+ last.b = frame;
2133
+ else
2134
+ runs.push({ a: frame, b: frame });
2135
+ }
2136
+ return runs;
2137
+ }
2138
+ function mandatoryRunComparisons(anchor, end) {
2139
+ const points = new Set();
2140
+ for (let frame = anchor + STATIC_VERIFY_REFERENCE_STRIDE; frame < end; frame += STATIC_VERIFY_REFERENCE_STRIDE) {
2141
+ points.add(frame);
2142
+ }
2143
+ points.add(end);
2144
+ return [...points].sort((left, right) => left - right);
2145
+ }
2146
+ /** Pure composition-wide planner. Every retained run is profitable and has gaps <=24 frames. */
2147
+ export function planStaticVerification(staticFrames, sampleFloor) {
2148
+ const frames = [...staticFrames].sort((left, right) => left - right);
2149
+ const allRuns = contiguousStaticRuns(frames).map(({ a, b }) => {
2150
+ const comparisons = mandatoryRunComparisons(a - 1, b);
2151
+ const frameCount = b - a + 1;
2152
+ return {
2153
+ a,
2154
+ b,
2155
+ anchor: a - 1,
2156
+ comparisons,
2157
+ frameCount,
2158
+ netSavings: frameCount - comparisons.length - 1,
2159
+ };
2160
+ });
2161
+ const runs = allRuns.filter((run) => run.anchor >= 0 && run.netSavings > 0);
2162
+ const skippedRuns = allRuns
2163
+ .filter((run) => run.anchor < 0 || run.netSavings <= 0)
2164
+ .map((run) => ({ a: run.a, b: run.b, reason: "unprofitable" }));
2165
+ const normalizedSampleFloor = Number.isFinite(sampleFloor)
2166
+ ? Math.max(1, Math.floor(sampleFloor))
2167
+ : 1;
2168
+ const effectiveSampleFloor = Math.min(normalizedSampleFloor, STATIC_VERIFY_MAX_GLOBAL_SAMPLE_FLOOR);
2169
+ const comparisonCount = () => runs.reduce((sum, run) => sum + run.comparisons.length, 0);
2170
+ while (comparisonCount() < effectiveSampleFloor) {
2171
+ const candidates = runs
2172
+ .filter((run) => run.frameCount > run.comparisons.length + 2)
2173
+ .flatMap((run) => {
2174
+ const points = [run.anchor, ...run.comparisons];
2175
+ return points.slice(1).map((right, index) => ({
2176
+ run,
2177
+ left: points[index],
2178
+ right,
2179
+ width: right - points[index],
2180
+ }));
2181
+ })
2182
+ .filter((gap) => gap.width > 1)
2183
+ .sort((left, right) => right.width - left.width || left.run.a - right.run.a || left.left - right.left);
2184
+ const selected = candidates[0];
2185
+ if (!selected)
2186
+ break;
2187
+ selected.run.comparisons.push(Math.floor((selected.left + selected.right) / 2));
2188
+ selected.run.comparisons.sort((left, right) => left - right);
2189
+ selected.run.netSavings = selected.run.frameCount - selected.run.comparisons.length - 1;
2190
+ }
2191
+ runs.sort((left, right) => right.netSavings - left.netSavings || left.a - right.a);
2192
+ const plannedComparisons = comparisonCount();
2193
+ return {
2194
+ runs,
2195
+ skippedRuns,
2196
+ effectiveSampleFloor,
2197
+ predictedFrames: frames.length,
2198
+ verifiedCandidateFrames: runs.reduce((sum, run) => sum + run.frameCount, 0),
2199
+ plannedAnchors: runs.length,
2200
+ plannedComparisons,
2201
+ plannedScreenshots: runs.length + plannedComparisons,
2202
+ };
2143
2203
  }
2144
2204
  /**
2145
2205
  * Empirically verify the predicted-static set before trusting it. Group static frames
2146
2206
  * into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR,
2147
2207
  * not the predecessor — a slow drift with sub-quantization per-frame deltas is byte-
2148
2208
  * identical frame-to-frame yet drifts far from the anchor by the run's end (the real
2149
- * frozen error). Capture each run's anchor once, compare END + a midpoint to it; any
2150
- * mismatch the run isn't truly static disable dedup whole-comp. Capture-mode-
2151
- * independent (seeks + screenshots in normal DOM). Returns the first bad frame, or null.
2209
+ * frozen error). Capture each run's anchor once, compare its end plus deterministic
2210
+ * composition-wide points that preserve a 24-frame maximum gap; any mismatch the
2211
+ * run isn't truly static disable dedup whole-comp. Capture-mode-
2212
+ * independent (seeks + screenshots in normal DOM). Budget exhaustion may retain only
2213
+ * runs whose anchor and every planned comparison completed successfully.
2152
2214
  */
2153
- export async function verifyStaticFramesSafe(session, page, staticFrames, fps, sampleCount) {
2154
- const frames = [...staticFrames].sort((a, b) => a - b);
2155
- if (frames.length === 0)
2156
- return null;
2157
- const deadline = Date.now() + STATIC_VERIFY_MAX_MS;
2158
- // Runs are maximal-contiguous (adjacent frames merge), so a run's anchor a-1 is
2159
- // guaranteed NOT static — always a freshly-captured frame.
2160
- const runs = [];
2161
- for (const f of frames) {
2162
- const last = runs[runs.length - 1];
2163
- if (last && f === last.b + 1)
2164
- last.b = f;
2165
- else
2166
- runs.push({ a: f, b: f });
2167
- }
2215
+ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, sampleCount, dependencies = {}) {
2216
+ const plan = planStaticVerification(staticFrames, sampleCount);
2217
+ const now = dependencies.now ?? Date.now;
2218
+ const capture = dependencies.capture ?? pageScreenshotCapture;
2219
+ const startedAt = now();
2220
+ const deadline = startedAt + STATIC_VERIFY_MAX_MS;
2221
+ const verifiedFrames = new Set();
2222
+ const stats = {
2223
+ plannedRuns: plan.runs.length,
2224
+ completedRuns: 0,
2225
+ plannedAnchors: plan.plannedAnchors,
2226
+ completedAnchors: 0,
2227
+ plannedComparisons: plan.plannedComparisons,
2228
+ completedComparisons: 0,
2229
+ seeks: 0,
2230
+ screenshots: 0,
2231
+ byteComparisons: 0,
2232
+ elapsedMs: 0,
2233
+ predictedFrames: plan.predictedFrames,
2234
+ verifiedFrames: 0,
2235
+ unverifiedFrames: plan.predictedFrames,
2236
+ };
2237
+ const finish = (outcome, badFrame) => ({
2238
+ outcome,
2239
+ verifiedFrames,
2240
+ ...(badFrame == null ? {} : { badFrame }),
2241
+ stats,
2242
+ });
2243
+ if (plan.runs.length === 0)
2244
+ return finish("unprofitable");
2168
2245
  const seekToFrame = async (frameIdx) => {
2246
+ stats.seeks++;
2169
2247
  const t = quantizeTimeToFrame(frameIdx / fps, fps);
2170
2248
  await page.evaluate((tt) => {
2171
2249
  const hf = window.__hf;
@@ -2173,52 +2251,55 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
2173
2251
  hf.seek(tt, { suppressEvents: true });
2174
2252
  }, t);
2175
2253
  };
2254
+ const hardCap = Math.max(STATIC_VERIFY_MIN_SCREENSHOT_CAP, plan.effectiveSampleFloor * STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER, Math.ceil(plan.predictedFrames / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + plan.runs.length);
2176
2255
  const seekCapture = async (frameIdx) => {
2256
+ if (now() >= deadline)
2257
+ return "time_budget";
2258
+ if (stats.screenshots >= hardCap)
2259
+ return "count_budget";
2177
2260
  await seekToFrame(frameIdx);
2178
- return pageScreenshotCapture(page, session.options);
2261
+ stats.screenshots++;
2262
+ return capture(page, session.options);
2179
2263
  };
2180
- // Verify EVERY run in order (no longest-first truncation that would leave runs armed
2181
- // but unverified). Per run, compare the FIRST reused frame `a`, the END `b` (max
2182
- // accumulated drift), and interior points at a stride (see computeStaticVerificationPoints)
2183
- // — against the anchor the run actually reuses.
2184
- //
2185
- // hardCap bounds pathological cases and hitting it DISABLES dedup (conservative:
2186
- // never trust an unverified set). It must scale with the new density model:
2187
- // each run now costs roughly span/STATIC_VERIFY_REFERENCE_STRIDE + 1 checks (plus
2188
- // one anchor), not the ~8 the old flat point cap cost — sizing the budget only off
2189
- // sampleCount (which no longer drives density for long runs) would make a
2190
- // genuinely-static long composition spuriously disarm under the new, more
2191
- // thorough checking. `frames.length` approximates total interior checks; a 3x
2192
- // margin absorbs per-run anchor overhead and the 3-point floor on short runs.
2193
- const hardCap = Math.max(sampleCount * 8, 400, Math.ceil(frames.length / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + runs.length);
2264
+ // Verify profitable runs in deterministic savings order. A run is added to the armed
2265
+ // set only after its anchor and every planner-selected comparison match. Budget exits
2266
+ // retain completed runs; mismatch or infrastructure failure clears all verified frames.
2194
2267
  try {
2195
- let spent = 0;
2196
- for (const { a, b } of runs) {
2197
- const anchor = a - 1;
2198
- if (anchor < 0)
2199
- continue;
2200
- if (Date.now() >= deadline)
2201
- return { badFrame: a, budgetExhausted: true };
2202
- const anchorBuf = await seekCapture(anchor);
2203
- spent++;
2204
- for (const f of computeStaticVerificationPoints(a, b, sampleCount)) {
2205
- if (Date.now() >= deadline)
2206
- return { badFrame: f, budgetExhausted: true };
2268
+ for (const run of plan.runs) {
2269
+ const anchorBuf = await seekCapture(run.anchor);
2270
+ if (typeof anchorBuf === "string")
2271
+ return finish(anchorBuf, run.a);
2272
+ stats.completedAnchors++;
2273
+ for (const f of run.comparisons) {
2207
2274
  const cur = await seekCapture(f);
2208
- spent++;
2209
- if (!anchorBuf.equals(cur))
2210
- return { badFrame: f, budgetExhausted: false };
2275
+ if (typeof cur === "string")
2276
+ return finish(cur, f);
2277
+ stats.completedComparisons++;
2278
+ stats.byteComparisons++;
2279
+ if (!anchorBuf.equals(cur)) {
2280
+ verifiedFrames.clear();
2281
+ stats.verifiedFrames = 0;
2282
+ stats.unverifiedFrames = plan.predictedFrames;
2283
+ return finish("mismatch", f);
2284
+ }
2211
2285
  }
2212
- // Budget exhausted → can't fully verify → disarm, distinct from real drift so a
2213
- // `verification_budget` spike in telemetry reads as "this composition has a lot
2214
- // of static material to verify," not "compositions are non-static."
2215
- if (spent > hardCap)
2216
- return { badFrame: a, budgetExhausted: true };
2286
+ stats.completedRuns++;
2287
+ for (let frame = run.a; frame <= run.b; frame++)
2288
+ verifiedFrames.add(frame);
2289
+ stats.verifiedFrames = verifiedFrames.size;
2290
+ stats.unverifiedFrames = plan.predictedFrames - verifiedFrames.size;
2217
2291
  }
2218
- return null;
2292
+ return finish("verified");
2293
+ }
2294
+ catch {
2295
+ verifiedFrames.clear();
2296
+ stats.verifiedFrames = 0;
2297
+ stats.unverifiedFrames = plan.predictedFrames;
2298
+ return finish("infrastructure");
2219
2299
  }
2220
2300
  finally {
2221
2301
  await seekToFrame(0).catch(() => { });
2302
+ stats.elapsedMs = Math.max(0, now() - startedAt);
2222
2303
  }
2223
2304
  }
2224
2305
  /**
@@ -2283,24 +2364,36 @@ async function armStaticDedup(session, page, logInitPhase) {
2283
2364
  }
2284
2365
  const rawSamples = Number(process.env.HF_STATIC_DEDUP_SAMPLES ?? "24");
2285
2366
  const samples = Number.isFinite(rawSamples) && rawSamples >= 1 ? rawSamples : 24;
2286
- const verdict = process.env.HF_STATIC_DEDUP_VERIFY === "false"
2287
- ? null
2288
- : await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
2289
- if (verdict !== null) {
2290
- session.staticDedupSkipReason = verdict.budgetExhausted
2291
- ? "verification_budget"
2292
- : "verification_failed";
2293
- logInitPhase(verdict.budgetExhausted
2294
- ? `static-frame dedup: disabled (verification budget exhausted before frame ${verdict.badFrame}; ` +
2295
- `too much predicted-static material to fully verify — this is the safe fallback, not an error)`
2296
- : `static-frame dedup: disabled (verification failed — content drifts from anchor at ` +
2297
- `predicted-static frame ${verdict.badFrame})`);
2367
+ session.staticDedupPredictedCount = stats.staticFrameSet.size;
2368
+ if (process.env.HF_STATIC_DEDUP_VERIFY === "false") {
2369
+ session.staticFrames = stats.staticFrameSet;
2370
+ logInitPhase(`static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` +
2371
+ `(verification explicitly disabled)`);
2298
2372
  return;
2299
2373
  }
2300
- // armed + predicted are derived from staticFrames in getCapturePerfSummary.
2301
- session.staticFrames = stats.staticFrameSet;
2302
- logInitPhase(`static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` +
2303
- `(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`);
2374
+ const verdict = await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
2375
+ session.staticDedupVerification = verdict;
2376
+ if (verdict.outcome === "mismatch" || verdict.outcome === "infrastructure") {
2377
+ session.staticDedupSkipReason = "verification_failed";
2378
+ logInitPhase(verdict.outcome === "mismatch"
2379
+ ? `static-frame dedup: disabled (verification mismatch at predicted-static frame ${verdict.badFrame})`
2380
+ : "static-frame dedup: disabled (verification infrastructure failure)");
2381
+ return;
2382
+ }
2383
+ if (verdict.outcome === "unprofitable") {
2384
+ session.staticDedupSkipReason = "unprofitable";
2385
+ logInitPhase("static-frame dedup: disabled (verification cost cannot save captures)");
2386
+ return;
2387
+ }
2388
+ if (verdict.verifiedFrames.size === 0) {
2389
+ session.staticDedupSkipReason = "verification_budget";
2390
+ logInitPhase(`static-frame dedup: disabled (${verdict.outcome} before frame ${verdict.badFrame}; no run fully verified)`);
2391
+ return;
2392
+ }
2393
+ session.staticFrames = verdict.verifiedFrames;
2394
+ logInitPhase(`static-frame dedup: ${verdict.verifiedFrames.size}/${stats.staticFrameSet.size} predicted frame(s) reusable ` +
2395
+ `(outcome=${verdict.outcome}, runs=${verdict.stats.completedRuns}/${verdict.stats.plannedRuns}, ` +
2396
+ `screenshots=${verdict.stats.screenshots}, seeks=${verdict.stats.seeks}, elapsedMs=${verdict.stats.elapsedMs})`);
2304
2397
  }
2305
2398
  /**
2306
2399
  * Walk window.__timelines and collect frame intervals where GSAP tweens animate
@@ -2405,8 +2498,11 @@ async function captureFrameCore(session, frameIndex, time) {
2405
2498
  // Use the SAME floor+epsilon idiom as quantizeTimeToFrame so the dedup lookup agrees
2406
2499
  // with the frame the seek actually lands on, even if `time` ever isn't exactly i/fps.
2407
2500
  const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
2408
- if (session.staticFrames?.has(absFrameIndex) && session.lastFrameBuffer) {
2501
+ if (session.staticFrames?.has(absFrameIndex) &&
2502
+ session.lastFrameBuffer &&
2503
+ session.lastFrameAbsoluteIndex === absFrameIndex - 1) {
2409
2504
  session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
2505
+ session.lastFrameAbsoluteIndex = absFrameIndex;
2410
2506
  return {
2411
2507
  buffer: session.lastFrameBuffer,
2412
2508
  quantizedTime: quantizeTimeToFrame(time, fpsToNumber(options.fps)),
@@ -2516,8 +2612,10 @@ async function captureFrameCore(session, frameIndex, time) {
2516
2612
  session.capturePerf.totalMs += captureTimeMs;
2517
2613
  session.capturePerf.frameMs.push(captureTimeMs);
2518
2614
  // Retain this freshly-captured buffer so the following static frames can reuse it.
2519
- if (session.staticFrames)
2615
+ if (session.staticFrames) {
2520
2616
  session.lastFrameBuffer = screenshotBuffer;
2617
+ session.lastFrameAbsoluteIndex = absFrameIndex;
2618
+ }
2521
2619
  return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
2522
2620
  }
2523
2621
  catch (captureError) {
@@ -2785,6 +2883,7 @@ export async function discardWarmupCapture(session, frameIndex = 0, time = 0, in
2785
2883
  const noDamageBefore = session.beginFrameNoDamageCount;
2786
2884
  const dedupCountBefore = session.staticDedupCount;
2787
2885
  const lastFrameBufferBefore = session.lastFrameBuffer;
2886
+ const lastFrameAbsoluteIndexBefore = session.lastFrameAbsoluteIndex;
2788
2887
  try {
2789
2888
  await innerCapture(session, frameIndex, time);
2790
2889
  }
@@ -2797,6 +2896,7 @@ export async function discardWarmupCapture(session, frameIndex = 0, time = 0, in
2797
2896
  session.beginFrameNoDamageCount = noDamageBefore;
2798
2897
  session.staticDedupCount = dedupCountBefore;
2799
2898
  session.lastFrameBuffer = lastFrameBufferBefore;
2899
+ session.lastFrameAbsoluteIndex = lastFrameAbsoluteIndexBefore;
2800
2900
  }
2801
2901
  }
2802
2902
  export async function closeCaptureSession(session) {
@@ -2879,6 +2979,7 @@ export function prepareCaptureSessionForReuse(session, outputDir, onBeforeCaptur
2879
2979
  // intact: it's keyed in absolute frames and stays valid for a same-composition reuse;
2880
2980
  // lastFrameBuffer must be re-seeded by this render's first fresh capture.
2881
2981
  session.lastFrameBuffer = undefined;
2982
+ session.lastFrameAbsoluteIndex = undefined;
2882
2983
  session.staticDedupCount = 0;
2883
2984
  }
2884
2985
  export async function getCompositionDuration(session) {
@@ -3054,9 +3155,17 @@ export function getCapturePerfSummary(session) {
3054
3155
  warnings: cloneCaptureWarnings(session.warnings),
3055
3156
  staticDedupReused: session.staticDedupCount ?? 0,
3056
3157
  staticDedupEnabled: session.staticDedupEnabled ?? false,
3057
- // armed ⟺ a non-empty static set survived verification; predicted === its size.
3158
+ // armed ⟺ a non-empty static set survived verification.
3058
3159
  staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
3059
- staticDedupPredicted: session.staticFrames?.size ?? 0,
3160
+ staticDedupPredicted: session.staticDedupPredictedCount ?? 0,
3161
+ staticDedupVerified: session.staticFrames?.size ?? 0,
3162
+ staticDedupVerificationOutcome: session.staticDedupVerification?.outcome,
3163
+ staticDedupVerificationPlannedRuns: session.staticDedupVerification?.stats.plannedRuns,
3164
+ staticDedupVerificationCompletedRuns: session.staticDedupVerification?.stats.completedRuns,
3165
+ staticDedupVerificationScreenshots: session.staticDedupVerification?.stats.screenshots,
3166
+ staticDedupVerificationSeeks: session.staticDedupVerification?.stats.seeks,
3167
+ staticDedupVerificationComparisons: session.staticDedupVerification?.stats.byteComparisons,
3168
+ staticDedupVerificationElapsedMs: session.staticDedupVerification?.stats.elapsedMs,
3060
3169
  staticDedupSkipReason: session.staticDedupSkipReason,
3061
3170
  beginFrameNoDamage: session.beginFrameNoDamageCount,
3062
3171
  beginFrameHasDamage: session.beginFrameHasDamageCount,