@hyperframes/engine 0.6.111 → 0.6.112

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.
@@ -51,6 +51,30 @@ export interface CaptureSession {
51
51
  outputDir: string;
52
52
  onBeforeCapture: BeforeCaptureHook | null;
53
53
  isInitialized: boolean;
54
+ /**
55
+ * Static-frame dedup (default-on; opt out with `HF_STATIC_DEDUP=false`): indices of frames byte-identical
56
+ * to their predecessor (no GSAP tween / clip cut active in either), predicted from
57
+ * window.__timelines and empirically anchor-verified. These reuse `lastFrameBuffer`
58
+ * instead of re-seeking + re-screenshotting. Undefined when disabled or ineligible.
59
+ */
60
+ staticFrames?: Set<number>;
61
+ /** Last non-deduped frame buffer, reused for every `staticFrames` index in its run. */
62
+ lastFrameBuffer?: Buffer;
63
+ /** Count of frames served from a reused buffer (dedup telemetry). */
64
+ staticDedupCount?: number;
65
+ // ── Static-dedup observability (set by armStaticDedup; surfaced via
66
+ // getCapturePerfSummary → RenderPerfSummary → the render_complete event) ──
67
+ // NOTE: `armed` and `predicted` are NOT stored — they derive from
68
+ // `staticFrames` (armed ⟺ non-empty set; predicted === size) in
69
+ // getCapturePerfSummary, so they can't desync from the actual reuse set.
70
+ /** Dedup was enabled for this render (default-on; opt out with `HF_STATIC_DEDUP=false`). */
71
+ staticDedupEnabled?: boolean;
72
+ /**
73
+ * Short machine code for WHY dedup did not arm, for a low-cardinality breakdown.
74
+ * One of: `capture_mode` | `video_injection` | `page_composite` |
75
+ * `ineligible` | `verification_failed` | `verification_budget`. Undefined when armed or disabled.
76
+ */
77
+ staticDedupSkipReason?: string;
54
78
  // Tracks whether the page/browser handles have already been released by
55
79
  // closeCaptureSession. Used to make closeCaptureSession idempotent under
56
80
  // browser-pool semantics (see the function body for the full invariant).
@@ -1021,6 +1045,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
1021
1045
  await initTransparentBackground(session.page);
1022
1046
  }
1023
1047
 
1048
+ await armStaticDedup(session, session.page, logInitPhase);
1024
1049
  session.isInitialized = true;
1025
1050
  return;
1026
1051
  }
@@ -1170,6 +1195,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
1170
1195
  await initTransparentBackground(session.page);
1171
1196
  }
1172
1197
 
1198
+ await armStaticDedup(session, session.page, logInitPhase);
1173
1199
  session.isInitialized = true;
1174
1200
  }
1175
1201
 
@@ -1280,6 +1306,335 @@ async function prepareFrameForCapture(
1280
1306
  return { quantizedTime, seekMs, beforeCaptureMs };
1281
1307
  }
1282
1308
 
1309
+ // ── Static-frame dedup (default-on, opt-out HF_STATIC_DEDUP=false) ─────────────
1310
+ // Skip re-seeking + re-screenshotting frames that are byte-identical to their
1311
+ // predecessor. A frame is dedupable iff no GSAP tween or clip cut is active in it or
1312
+ // its predecessor (predicted from window.__timelines), AND an empirical anchor-compare
1313
+ // confirms it. Capture-mode-independent (works on screenshot + beginframe), lossless
1314
+ // (verification disables the whole comp on any drift), default off. Pays on
1315
+ // static-hold content (title cards, slideshows, data-viz pauses); a no-op on
1316
+ // continuously-animated comps and disqualified by video/canvas/non-GSAP animation.
1317
+
1318
+ /**
1319
+ * Clip-cut boundary frames (±1) from the [data-start] schedule. A hard scene swap at a
1320
+ * cut changes content with no tween; treat those frames as animated so the post-cut
1321
+ * frame is captured fresh and later static frames reuse the correct scene.
1322
+ */
1323
+ async function computeClipBoundaryFrames(page: Page, fps: number): Promise<Set<number>> {
1324
+ const schedule = await page.evaluate(() =>
1325
+ Array.from(document.querySelectorAll("[data-start]")).map((el) => ({
1326
+ start: parseFloat((el as HTMLElement).dataset.start || ""),
1327
+ dur: parseFloat((el as HTMLElement).dataset.duration || ""),
1328
+ })),
1329
+ );
1330
+ const frames = new Set<number>();
1331
+ for (const { start, dur } of schedule) {
1332
+ if (Number.isNaN(start)) continue;
1333
+ const edges = [Math.round(start * fps)];
1334
+ if (!Number.isNaN(dur)) edges.push(Math.round((start + dur) * fps));
1335
+ for (const e of edges) {
1336
+ for (const f of [e - 1, e, e + 1]) {
1337
+ if (f >= 0) frames.add(f);
1338
+ }
1339
+ }
1340
+ }
1341
+ return frames;
1342
+ }
1343
+
1344
+ /**
1345
+ * Predict the dedupable (static) frame set from window.__timelines. A frame f (f>0) is
1346
+ * static iff NEITHER f NOR f-1 falls inside any GSAP tween interval — content didn't
1347
+ * change f-1→f, so f can reuse f-1's buffer. Requiring BOTH neighbours static under-
1348
+ * claims by one frame at each tween edge (the SAFE direction). Disqualifies the whole
1349
+ * comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
1350
+ * a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
1351
+ */
1352
+ async function computeStaticFrameSet(
1353
+ page: Page,
1354
+ fps: number,
1355
+ ): Promise<{
1356
+ totalFrames: number;
1357
+ staticFrameSet: Set<number>;
1358
+ hasVideo: boolean;
1359
+ hasCanvas: boolean;
1360
+ hasNonGsapAnim: boolean;
1361
+ tweenCount: number;
1362
+ eligible: boolean;
1363
+ reason: string;
1364
+ }> {
1365
+ const result = await page.evaluate(() => {
1366
+ type AnyTween = {
1367
+ startTime(): number;
1368
+ duration(): number;
1369
+ totalDuration?(): number;
1370
+ getChildren?(nested: boolean, tweens: boolean, timelines: boolean): AnyTween[];
1371
+ };
1372
+ const intervals: Array<{ start: number; end: number }> = [];
1373
+ let tweenCount = 0;
1374
+ // totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
1375
+ // a repeating timeline is marked opaque over its whole span (conservative).
1376
+ function walk(tl: AnyTween, offset: number): void {
1377
+ if (typeof tl.getChildren !== "function") return;
1378
+ for (const child of tl.getChildren(false, true, true)) {
1379
+ const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
1380
+ const single = typeof child.duration === "function" ? child.duration() : 0;
1381
+ const total = typeof child.totalDuration === "function" ? child.totalDuration() : single;
1382
+ if (typeof child.getChildren === "function") {
1383
+ if (total > single + 1e-6) intervals.push({ start, end: start + total });
1384
+ else walk(child, start);
1385
+ } else {
1386
+ tweenCount++;
1387
+ intervals.push({ start, end: start + total });
1388
+ }
1389
+ }
1390
+ }
1391
+ const w = window as unknown as {
1392
+ __timelines?: Record<string, AnyTween>;
1393
+ __hf?: { duration?: number };
1394
+ };
1395
+ for (const tl of Object.values(w.__timelines || {})) {
1396
+ if (tl && typeof tl.getChildren === "function") walk(tl, 0);
1397
+ }
1398
+ const hasVideo = !!document.querySelector("video");
1399
+ const hasCanvas = !!document.querySelector("canvas");
1400
+ // A non-numeric data-start (reference expression like "intro+0.5") can't be turned
1401
+ // into a clip-cut boundary by computeClipBoundaryFrames' parseFloat, so the cut goes
1402
+ // unprotected and could be deduped into the previous scene. Disqualify the comp.
1403
+ const hasUnresolvableClipStart = Array.from(document.querySelectorAll("[data-start]")).some(
1404
+ (el) => {
1405
+ const v = (el as HTMLElement).dataset.start;
1406
+ return v != null && v.trim() !== "" && !Number.isFinite(parseFloat(v));
1407
+ },
1408
+ );
1409
+ // Non-GSAP animation (CSS @keyframes / transitions / WAAPI) surfaces via
1410
+ // getAnimations(); any running/paused one can change content without a tween.
1411
+ let hasNonGsapAnim = false;
1412
+ try {
1413
+ const docAnims = (document as unknown as { getAnimations?: () => Animation[] }).getAnimations;
1414
+ if (typeof docAnims === "function") {
1415
+ hasNonGsapAnim = docAnims.call(document).some((a) => {
1416
+ const t = a as Animation & { playState?: string };
1417
+ return t.playState === "running" || t.playState === "paused";
1418
+ });
1419
+ }
1420
+ } catch {
1421
+ hasNonGsapAnim = true;
1422
+ }
1423
+ return {
1424
+ intervals,
1425
+ tweenCount,
1426
+ duration: w.__hf?.duration ?? 0,
1427
+ hasVideo,
1428
+ hasCanvas,
1429
+ hasNonGsapAnim,
1430
+ hasUnresolvableClipStart,
1431
+ };
1432
+ });
1433
+
1434
+ const {
1435
+ intervals,
1436
+ tweenCount,
1437
+ duration,
1438
+ hasVideo,
1439
+ hasCanvas,
1440
+ hasNonGsapAnim,
1441
+ hasUnresolvableClipStart,
1442
+ } = result as {
1443
+ intervals: Array<{ start: number; end: number }>;
1444
+ tweenCount: number;
1445
+ duration: number;
1446
+ hasVideo: boolean;
1447
+ hasCanvas: boolean;
1448
+ hasNonGsapAnim: boolean;
1449
+ hasUnresolvableClipStart: boolean;
1450
+ };
1451
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
1452
+ const animated = new Set<number>();
1453
+ for (const { start, end } of intervals) {
1454
+ const lo = Math.max(0, Math.floor(start * fps));
1455
+ const hi = Math.min(totalFrames - 1, Math.ceil(end * fps));
1456
+ for (let f = lo; f <= hi; f++) animated.add(f);
1457
+ }
1458
+ for (const f of await computeClipBoundaryFrames(page, fps)) animated.add(f);
1459
+ const reasons: string[] = [];
1460
+ if (!(duration > 0)) reasons.push("unknown/zero duration");
1461
+ if (hasVideo) reasons.push("video");
1462
+ if (hasCanvas) reasons.push("canvas/webgl");
1463
+ if (tweenCount === 0) reasons.push("no GSAP tweens (non-GSAP animation)");
1464
+ if (hasNonGsapAnim) reasons.push("running CSS/WAAPI animation");
1465
+ if (hasUnresolvableClipStart) reasons.push("unresolvable clip start (reference expression)");
1466
+ const eligible = reasons.length === 0;
1467
+ const staticFrameSet = new Set<number>();
1468
+ if (eligible) {
1469
+ for (let f = 1; f < totalFrames; f++) {
1470
+ if (!animated.has(f) && !animated.has(f - 1)) staticFrameSet.add(f);
1471
+ }
1472
+ }
1473
+ return {
1474
+ totalFrames,
1475
+ staticFrameSet,
1476
+ hasVideo,
1477
+ hasCanvas,
1478
+ hasNonGsapAnim,
1479
+ tweenCount,
1480
+ eligible,
1481
+ reason: eligible ? "eligible" : reasons.join("+"),
1482
+ };
1483
+ }
1484
+
1485
+ /**
1486
+ * Empirically verify the predicted-static set before trusting it. Group static frames
1487
+ * into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR,
1488
+ * not the predecessor — a slow drift with sub-quantization per-frame deltas is byte-
1489
+ * identical frame-to-frame yet drifts far from the anchor by the run's end (the real
1490
+ * frozen error). Capture each run's anchor once, compare END + a midpoint to it; any
1491
+ * mismatch ⇒ the run isn't truly static ⇒ disable dedup whole-comp. Capture-mode-
1492
+ * independent (seeks + screenshots in normal DOM). Returns the first bad frame, or null.
1493
+ */
1494
+ async function verifyStaticFramesSafe(
1495
+ session: CaptureSession,
1496
+ page: Page,
1497
+ staticFrames: Set<number>,
1498
+ fps: number,
1499
+ sampleCount: number,
1500
+ ): Promise<{ badFrame: number; budgetExhausted: boolean } | null> {
1501
+ const frames = [...staticFrames].sort((a, b) => a - b);
1502
+ if (frames.length === 0) return null;
1503
+ // Runs are maximal-contiguous (adjacent frames merge), so a run's anchor a-1 is
1504
+ // guaranteed NOT static — always a freshly-captured frame.
1505
+ const runs: Array<{ a: number; b: number }> = [];
1506
+ for (const f of frames) {
1507
+ const last = runs[runs.length - 1];
1508
+ if (last && f === last.b + 1) last.b = f;
1509
+ else runs.push({ a: f, b: f });
1510
+ }
1511
+ const seekCapture = async (frameIdx: number): Promise<Buffer> => {
1512
+ const t = quantizeTimeToFrame(frameIdx / fps, fps);
1513
+ await page.evaluate((tt: number) => {
1514
+ const hf = (window as unknown as { __hf?: { seek?: (t: number) => void } }).__hf;
1515
+ if (hf && typeof hf.seek === "function") hf.seek(tt);
1516
+ }, t);
1517
+ return pageScreenshotCapture(page, session.options);
1518
+ };
1519
+ // Verify EVERY run in order (no longest-first truncation that would leave runs armed
1520
+ // but unverified). Per run, compare the FIRST reused frame `a`, the END `b` (max
1521
+ // accumulated drift), and interior points at a stride — against the anchor the run
1522
+ // actually reuses. `sampleCount` sets the interior density (points per run ~ that many
1523
+ // for a long run); a hard cap bounds pathological run counts, and hitting it DISABLES
1524
+ // dedup (conservative: never trust an unverified set).
1525
+ const perRun = Math.max(3, Math.min(sampleCount, 8));
1526
+ const hardCap = Math.max(sampleCount * 8, 400);
1527
+ let spent = 0;
1528
+ for (const { a, b } of runs) {
1529
+ const anchor = a - 1;
1530
+ if (anchor < 0) continue;
1531
+ const anchorBuf = await seekCapture(anchor);
1532
+ spent++;
1533
+ const span = b - a;
1534
+ const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1;
1535
+ const pts = new Set<number>();
1536
+ for (let f = a; f <= b; f += stride) pts.add(f);
1537
+ pts.add(b); // always include the end (max drift)
1538
+ for (const f of [...pts].sort((x, y) => x - y)) {
1539
+ const cur = await seekCapture(f);
1540
+ spent++;
1541
+ if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false };
1542
+ }
1543
+ // Budget exhausted → can't fully verify → disarm. Reported distinctly from real
1544
+ // drift so a `verification_budget` spike in telemetry signals "tune HF_STATIC_DEDUP_SAMPLES",
1545
+ // not "compositions are non-static".
1546
+ if (spent > hardCap) return { badFrame: a, budgetExhausted: true };
1547
+ }
1548
+ return null;
1549
+ }
1550
+
1551
+ /**
1552
+ * Arm static-frame dedup for this render (default-on; opt out with HF_STATIC_DEDUP=false).
1553
+ * Runs at init in normal DOM state so the verification screenshots are valid. Predicts
1554
+ * the static set, anchor-verifies it (skip with HF_STATIC_DEDUP_VERIFY=false — unsafe),
1555
+ * and on success stores it on the session for captureFrameCore to reuse. Sample budget
1556
+ * via HF_STATIC_DEDUP_SAMPLES (default 24).
1557
+ */
1558
+ async function armStaticDedup(
1559
+ session: CaptureSession,
1560
+ page: Page,
1561
+ logInitPhase: (phase: string) => void,
1562
+ ): Promise<void> {
1563
+ // Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
1564
+ // EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
1565
+ // Default-on: only an explicit `staticFrameDedup === false` (resolved from
1566
+ // HF_STATIC_DEDUP) disables; a missing config leaves dedup enabled.
1567
+ session.staticDedupEnabled = session.config?.staticFrameDedup !== false;
1568
+ if (!session.staticDedupEnabled) return;
1569
+ // Conservative gates: dedup is verified against the plain screenshot path, so only arm
1570
+ // where the production capture matches what verification measures, and where reuse is
1571
+ // sound. Skip when:
1572
+ // - capture mode is not screenshot (BeginFrame advances the compositor clock per
1573
+ // frame; skipping beginFrame for static frames gaps the tick sequence, and the
1574
+ // verifier uses pageScreenshotCapture not beginFrameCapture — its proof wouldn't
1575
+ // transfer);
1576
+ // - a before-capture hook is set (per-frame video-frame injection — those frames are
1577
+ // NOT static even if the GSAP timeline is idle, and the injector is skipped on reuse);
1578
+ // - page-side compositing is active (shader transitions / drawElement composite paint
1579
+ // a frame the plain verification screenshot doesn't reproduce).
1580
+ if (session.captureMode !== "screenshot") {
1581
+ session.staticDedupSkipReason = "capture_mode";
1582
+ logInitPhase(
1583
+ `static-frame dedup: disabled (capture mode ${session.captureMode}, not screenshot)`,
1584
+ );
1585
+ return;
1586
+ }
1587
+ if (session.onBeforeCapture) {
1588
+ session.staticDedupSkipReason = "video_injection";
1589
+ logInitPhase("static-frame dedup: disabled (before-capture hook / video injection active)");
1590
+ return;
1591
+ }
1592
+ const pageComposite = await page
1593
+ .evaluate(
1594
+ () =>
1595
+ typeof (window as unknown as { __hf_page_composite_prepare?: unknown })
1596
+ .__hf_page_composite_prepare === "function",
1597
+ )
1598
+ .catch(() => true); // fail CLOSED: if we can't determine, assume compositing → skip dedup
1599
+ if (pageComposite) {
1600
+ session.staticDedupSkipReason = "page_composite";
1601
+ logInitPhase("static-frame dedup: disabled (page-side compositing active)");
1602
+ return;
1603
+ }
1604
+ const fps = fpsToNumber(session.options.fps);
1605
+ const stats = await computeStaticFrameSet(page, fps);
1606
+ if (!stats.eligible || stats.staticFrameSet.size === 0) {
1607
+ session.staticDedupSkipReason = "ineligible";
1608
+ logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
1609
+ return;
1610
+ }
1611
+ const rawSamples = Number(process.env.HF_STATIC_DEDUP_SAMPLES ?? "24");
1612
+ const samples = Number.isFinite(rawSamples) && rawSamples >= 1 ? rawSamples : 24;
1613
+ const verdict =
1614
+ process.env.HF_STATIC_DEDUP_VERIFY === "false"
1615
+ ? null
1616
+ : await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
1617
+ if (verdict !== null) {
1618
+ session.staticDedupSkipReason = verdict.budgetExhausted
1619
+ ? "verification_budget"
1620
+ : "verification_failed";
1621
+ logInitPhase(
1622
+ verdict.budgetExhausted
1623
+ ? `static-frame dedup: disabled (verification budget exhausted before frame ${verdict.badFrame}; ` +
1624
+ `raise HF_STATIC_DEDUP_SAMPLES to verify more)`
1625
+ : `static-frame dedup: disabled (verification failed — content drifts from anchor at ` +
1626
+ `predicted-static frame ${verdict.badFrame})`,
1627
+ );
1628
+ return;
1629
+ }
1630
+ // armed + predicted are derived from staticFrames in getCapturePerfSummary.
1631
+ session.staticFrames = stats.staticFrameSet;
1632
+ logInitPhase(
1633
+ `static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` +
1634
+ `(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`,
1635
+ );
1636
+ }
1637
+
1283
1638
  /**
1284
1639
  * Internal core: prepare, screenshot, and track perf.
1285
1640
  * Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
@@ -1293,6 +1648,30 @@ async function captureFrameCore(
1293
1648
  const { page, options } = session;
1294
1649
  const startTime = Date.now();
1295
1650
 
1651
+ // Static-frame dedup: this frame is byte-identical to its predecessor (predicted +
1652
+ // anchor-verified at init) → reuse the prior buffer, skip the seek + screenshot.
1653
+ // KEY: index by the ABSOLUTE composition frame (derived from `time`), NOT the
1654
+ // `frameIndex` arg — chunked/parallel/distributed callers pass a chunk-RELATIVE
1655
+ // frameIndex (captureStage passes the loop `i`, parallelCoordinator passes
1656
+ // `i-outputFrameOffset`) while staticFrames is keyed in absolute frames. Using `time`
1657
+ // is correct on every path (sequential, per-worker range, distributed chunk) because
1658
+ // `time` is always the absolute composition time for the frame. Each session captures
1659
+ // its range in ascending order, so lastFrameBuffer is the correct in-range anchor (and
1660
+ // since a static run is verified identical, reusing the run's first in-range capture
1661
+ // equals reusing the global anchor). Telemetry: count reuses separately; do NOT bump
1662
+ // capturePerf.frames (that would dilute the per-frame timing averages).
1663
+ // Use the SAME floor+epsilon idiom as quantizeTimeToFrame so the dedup lookup agrees
1664
+ // with the frame the seek actually lands on, even if `time` ever isn't exactly i/fps.
1665
+ const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
1666
+ if (session.staticFrames?.has(absFrameIndex) && session.lastFrameBuffer) {
1667
+ session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
1668
+ return {
1669
+ buffer: session.lastFrameBuffer,
1670
+ quantizedTime: quantizeTimeToFrame(time, fpsToNumber(options.fps)),
1671
+ captureTimeMs: Date.now() - startTime,
1672
+ };
1673
+ }
1674
+
1296
1675
  try {
1297
1676
  const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
1298
1677
  session,
@@ -1328,6 +1707,9 @@ async function captureFrameCore(
1328
1707
  session.capturePerf.screenshotMs += screenshotMs;
1329
1708
  session.capturePerf.totalMs += captureTimeMs;
1330
1709
 
1710
+ // Retain this freshly-captured buffer so the following static frames can reuse it.
1711
+ if (session.staticFrames) session.lastFrameBuffer = screenshotBuffer;
1712
+
1331
1713
  return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
1332
1714
  } catch (captureError) {
1333
1715
  if (session.isInitialized) {
@@ -1428,18 +1810,40 @@ export async function discardWarmupCapture(
1428
1810
  const perfBefore = { ...session.capturePerf };
1429
1811
  const hasDamageBefore = session.beginFrameHasDamageCount;
1430
1812
  const noDamageBefore = session.beginFrameNoDamageCount;
1813
+ const dedupCountBefore = session.staticDedupCount;
1814
+ const lastFrameBufferBefore = session.lastFrameBuffer;
1431
1815
  try {
1432
1816
  await innerCapture(session, frameIndex, time);
1433
1817
  } finally {
1434
1818
  // Always restore — even on error. A failed warmup capture should not
1435
- // leak inflated perf counters into the real capture summary.
1819
+ // leak inflated perf counters, a phantom dedup reuse, or a warmup-era
1820
+ // lastFrameBuffer anchor into the real capture summary/state.
1436
1821
  session.capturePerf = perfBefore;
1437
1822
  session.beginFrameHasDamageCount = hasDamageBefore;
1438
1823
  session.beginFrameNoDamageCount = noDamageBefore;
1824
+ session.staticDedupCount = dedupCountBefore;
1825
+ session.lastFrameBuffer = lastFrameBufferBefore;
1439
1826
  }
1440
1827
  }
1441
1828
 
1442
1829
  export async function closeCaptureSession(session: CaptureSession): Promise<void> {
1830
+ // Realized static-dedup telemetry: how much the cache actually helped this
1831
+ // render (vs the prediction logged at arm time). Both capture paths
1832
+ // (sequential orchestrator + parallel workers) close their session here, so
1833
+ // this is the one uniform emit point. Zero the count afterward so the
1834
+ // idempotent re-close (HDR cleanup) doesn't double-log.
1835
+ const reused = session.staticDedupCount ?? 0;
1836
+ if (session.staticFrames && reused > 0) {
1837
+ const captured = session.capturePerf.frames; // excludes reuses by design
1838
+ const total = captured + reused;
1839
+ const pct = total > 0 ? Math.round((reused / total) * 100) : 0;
1840
+ const avgTotalMs = captured > 0 ? Math.round(session.capturePerf.totalMs / captured) : 0;
1841
+ console.log(
1842
+ `[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
1843
+ `est. ~${reused * avgTotalMs}ms saved (avg ${avgTotalMs}ms/frame)`,
1844
+ );
1845
+ session.staticDedupCount = 0;
1846
+ }
1443
1847
  // INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
1444
1848
  // cleanup path tracks a `domSessionClosed` flag and may still re-call this
1445
1849
  // in the outer finally if the inner cleanup raised before the flag flipped.
@@ -1496,6 +1900,12 @@ export function prepareCaptureSessionForReuse(
1496
1900
  };
1497
1901
  session.beginFrameHasDamageCount = 0;
1498
1902
  session.beginFrameNoDamageCount = 0;
1903
+ // Reset per-render dedup state so a buffer captured by the prior render/probe can't
1904
+ // bleed into this render's first static frame. staticFrames (the armed set) is left
1905
+ // intact: it's keyed in absolute frames and stays valid for a same-composition reuse;
1906
+ // lastFrameBuffer must be re-seeded by this render's first fresh capture.
1907
+ session.lastFrameBuffer = undefined;
1908
+ session.staticDedupCount = 0;
1499
1909
  }
1500
1910
 
1501
1911
  export async function getCompositionDuration(session: CaptureSession): Promise<number> {
@@ -1514,5 +1924,11 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
1514
1924
  avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
1515
1925
  avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
1516
1926
  avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
1927
+ staticDedupReused: session.staticDedupCount ?? 0,
1928
+ staticDedupEnabled: session.staticDedupEnabled ?? false,
1929
+ // armed ⟺ a non-empty static set survived verification; predicted === its size.
1930
+ staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
1931
+ staticDedupPredicted: session.staticFrames?.size ?? 0,
1932
+ staticDedupSkipReason: session.staticDedupSkipReason,
1517
1933
  };
1518
1934
  }
package/src/types.ts CHANGED
@@ -160,6 +160,26 @@ export interface CapturePerfSummary {
160
160
  avgSeekMs: number;
161
161
  avgBeforeCaptureMs: number;
162
162
  avgScreenshotMs: number;
163
+ /**
164
+ * Frames served from the static-dedup cache instead of a real seek+screenshot
165
+ * (opt-out HF_STATIC_DEDUP=false). 0 when dedup was off or never armed. NOT counted
166
+ * in `frames` (reuses are excluded so they don't dilute the per-frame
167
+ * averages) — the captured total this session is `frames + staticDedupReused`.
168
+ */
169
+ staticDedupReused: number;
170
+ /** `HF_STATIC_DEDUP=true` was set for this render (adoption signal). */
171
+ staticDedupEnabled: boolean;
172
+ /** Dedup passed every gate + verification and was active. */
173
+ staticDedupArmed: boolean;
174
+ /** Predicted reusable frame count when armed; 0 otherwise. */
175
+ staticDedupPredicted: number;
176
+ /**
177
+ * Low-cardinality reason dedup did not arm: `capture_mode` | `video_injection`
178
+ * | `page_composite` | `ineligible` | `verification_failed` | `verification_budget`.
179
+ * Undefined when armed or when dedup was disabled. (Render-level aggregation may
180
+ * `|`-join distinct reasons when parallel workers diverge.)
181
+ */
182
+ staticDedupSkipReason?: string;
163
183
  }
164
184
 
165
185
  // ── Global Augmentation ────────────────────────────────────────────────────────