@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.
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/services/frameCapture.d.ts +19 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +350 -1
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +13 -0
- package/src/services/frameCapture-staticDedupIndex.test.ts +76 -0
- package/src/services/frameCapture.ts +417 -1
- package/src/types.ts +20 -0
|
@@ -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
|
|
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 ────────────────────────────────────────────────────────
|