@hyperframes/engine 0.6.111 → 0.6.113
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/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +21 -5
- package/dist/services/videoFrameExtractor.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/services/videoFrameExtractor.test.ts +65 -0
- package/src/services/videoFrameExtractor.ts +21 -5
- 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
|
}
|
|
@@ -328,6 +328,71 @@ describe("FrameLookupTable", () => {
|
|
|
328
328
|
expect(table.getActiveFramePayloads(0.5).has("hero")).toBe(true);
|
|
329
329
|
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
|
|
330
330
|
});
|
|
331
|
+
|
|
332
|
+
it("holds the last frame at the inclusive clip end (t === end)", () => {
|
|
333
|
+
// clip [1,3] with exactly 2s of source frames (60 @ 30fps). The frame
|
|
334
|
+
// landing on t === end used to deactivate one frame early and render blank,
|
|
335
|
+
// while the runtime keeps the element visible on its last frame.
|
|
336
|
+
const table = createFrameLookupTable(
|
|
337
|
+
[
|
|
338
|
+
{
|
|
339
|
+
id: "hero",
|
|
340
|
+
src: "clip.webm",
|
|
341
|
+
start: 1,
|
|
342
|
+
end: 3,
|
|
343
|
+
mediaStart: 0,
|
|
344
|
+
loop: false,
|
|
345
|
+
hasAudio: false,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
[fakeExtracted(60, 30)],
|
|
349
|
+
);
|
|
350
|
+
const atEnd = table.getActiveFramePayloads(3.0).get("hero");
|
|
351
|
+
expect(atEnd?.frameIndex).toBe(59);
|
|
352
|
+
// mid-clip is unaffected
|
|
353
|
+
expect(table.getActiveFramePayloads(2.5).get("hero")?.frameIndex).toBe(45);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("holds the last frame at the clip end even when the source is shorter than the window", () => {
|
|
357
|
+
// clip [0,5] with only 1s of source (30 @ 30fps). The mid-clip tail stays
|
|
358
|
+
// blank (source exhausted), but t === end still holds the last frame to
|
|
359
|
+
// match the runtime's inclusive visibility.
|
|
360
|
+
const table = createFrameLookupTable(
|
|
361
|
+
[
|
|
362
|
+
{
|
|
363
|
+
id: "hero",
|
|
364
|
+
src: "clip.webm",
|
|
365
|
+
start: 0,
|
|
366
|
+
end: 5,
|
|
367
|
+
mediaStart: 0,
|
|
368
|
+
loop: false,
|
|
369
|
+
hasAudio: false,
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
[fakeExtracted(30, 30)],
|
|
373
|
+
);
|
|
374
|
+
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
|
|
375
|
+
expect(table.getActiveFramePayloads(5.0).get("hero")?.frameIndex).toBe(29);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("keeps both clips active at a shared adjacent boundary, matching the runtime", () => {
|
|
379
|
+
// clip A ends at 3.0, clip B starts at 3.0. The runtime shows both at the
|
|
380
|
+
// shared instant; the active set must too.
|
|
381
|
+
const table = createFrameLookupTable(
|
|
382
|
+
[
|
|
383
|
+
{ id: "a", src: "a.webm", start: 0, end: 3, mediaStart: 0, loop: false, hasAudio: false },
|
|
384
|
+
{ id: "b", src: "b.webm", start: 3, end: 6, mediaStart: 0, loop: false, hasAudio: false },
|
|
385
|
+
],
|
|
386
|
+
// createFrameLookupTable maps each clip to extracted frames by id.
|
|
387
|
+
[
|
|
388
|
+
{ ...fakeExtracted(90, 30), videoId: "a" },
|
|
389
|
+
{ ...fakeExtracted(90, 30), videoId: "b" },
|
|
390
|
+
],
|
|
391
|
+
);
|
|
392
|
+
const payloads = table.getActiveFramePayloads(3.0);
|
|
393
|
+
expect(payloads.has("a")).toBe(true);
|
|
394
|
+
expect(payloads.has("b")).toBe(true);
|
|
395
|
+
});
|
|
331
396
|
});
|
|
332
397
|
|
|
333
398
|
describe("parseImageElements", () => {
|
|
@@ -1003,7 +1003,7 @@ export class FrameLookupTable {
|
|
|
1003
1003
|
getFrame(videoId: string, globalTime: number): string | null {
|
|
1004
1004
|
const video = this.videos.get(videoId);
|
|
1005
1005
|
if (!video) return null;
|
|
1006
|
-
if (globalTime < video.start || globalTime
|
|
1006
|
+
if (globalTime < video.start || globalTime > video.end) return null;
|
|
1007
1007
|
return getFrameAtTime(video.extracted, globalTime, video.start, video.loop, video.mediaStart);
|
|
1008
1008
|
}
|
|
1009
1009
|
|
|
@@ -1014,11 +1014,16 @@ export class FrameLookupTable {
|
|
|
1014
1014
|
}
|
|
1015
1015
|
|
|
1016
1016
|
private refreshActiveSet(globalTime: number): void {
|
|
1017
|
+
// The active window is [start, end] INCLUSIVE of the end, mirroring the
|
|
1018
|
+
// runtime's element-visibility contract (core/runtime init.ts keeps an
|
|
1019
|
+
// element visible through `currentTime <= end`). An exclusive end-bound
|
|
1020
|
+
// here deactivated the video one frame early, so the frame landing exactly
|
|
1021
|
+
// on a clip's end rendered blank while the runtime still showed it.
|
|
1017
1022
|
if (this.lastTime == null || globalTime < this.lastTime) {
|
|
1018
1023
|
this.activeVideoIds.clear();
|
|
1019
1024
|
this.startCursor = 0;
|
|
1020
1025
|
for (const entry of this.orderedVideos) {
|
|
1021
|
-
if (entry.start <= globalTime && globalTime
|
|
1026
|
+
if (entry.start <= globalTime && globalTime <= entry.end) {
|
|
1022
1027
|
this.activeVideoIds.add(entry.videoId);
|
|
1023
1028
|
}
|
|
1024
1029
|
if (entry.start <= globalTime) {
|
|
@@ -1037,7 +1042,7 @@ export class FrameLookupTable {
|
|
|
1037
1042
|
if (candidate.start > globalTime) {
|
|
1038
1043
|
break;
|
|
1039
1044
|
}
|
|
1040
|
-
if (globalTime
|
|
1045
|
+
if (globalTime <= candidate.end) {
|
|
1041
1046
|
this.activeVideoIds.add(candidate.videoId);
|
|
1042
1047
|
}
|
|
1043
1048
|
this.startCursor += 1;
|
|
@@ -1045,7 +1050,7 @@ export class FrameLookupTable {
|
|
|
1045
1050
|
|
|
1046
1051
|
for (const videoId of Array.from(this.activeVideoIds)) {
|
|
1047
1052
|
const video = this.videos.get(videoId);
|
|
1048
|
-
if (!video || globalTime < video.start || globalTime
|
|
1053
|
+
if (!video || globalTime < video.start || globalTime > video.end) {
|
|
1049
1054
|
this.activeVideoIds.delete(videoId);
|
|
1050
1055
|
}
|
|
1051
1056
|
}
|
|
@@ -1073,7 +1078,18 @@ export class FrameLookupTable {
|
|
|
1073
1078
|
}
|
|
1074
1079
|
continue;
|
|
1075
1080
|
}
|
|
1076
|
-
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames)
|
|
1081
|
+
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) {
|
|
1082
|
+
// At the inclusive clip end (globalTime === end), hold the last
|
|
1083
|
+
// extracted frame so the render matches the runtime, which keeps the
|
|
1084
|
+
// element visible on its final frame at `t === end`. Mid-clip source
|
|
1085
|
+
// exhaustion (globalTime < end) stays blank — unchanged.
|
|
1086
|
+
if (globalTime >= video.end && video.extracted.totalFrames > 0) {
|
|
1087
|
+
const lastIndex = video.extracted.totalFrames - 1;
|
|
1088
|
+
const lastPath = video.extracted.framePaths.get(lastIndex);
|
|
1089
|
+
if (lastPath) frames.set(videoId, { framePath: lastPath, frameIndex: lastIndex });
|
|
1090
|
+
}
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1077
1093
|
const framePath = video.extracted.framePaths.get(frameIndex);
|
|
1078
1094
|
if (!framePath) continue;
|
|
1079
1095
|
frames.set(videoId, { framePath, frameIndex });
|
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 ────────────────────────────────────────────────────────
|