feat(engine): static-frame dedup default-on + render telemetry (#1549)

* feat(engine): static-frame dedup for screenshot capture (opt-in)

Skip re-seeking + re-screenshotting frames byte-identical to their predecessor. A
frame is dedupable iff no GSAP tween or clip cut is active in it or its predecessor
(predicted from window.__timelines + clip schedule) AND an empirical anchor-compare
confirms it. Opt-in HF_STATIC_DEDUP=true, default off.

Correctness (designed for the multi-worker / distributed render paths):
- Reuse is keyed by the ABSOLUTE composition frame (derived from the frame's time),
  NOT the captureFrameCore frameIndex arg — chunked/parallel callers pass a chunk-
  relative index. Validated lossless (PSNR=inf) on both single- and multi-worker
  renders of a static-hold comp.
- verifyStaticFramesSafe checks EVERY run (no longest-first budget truncation that
  left runs armed-but-unverified), and samples each run's FIRST reused frame, its END,
  and interior points at a stride; a hard cap disables dedup rather than trust an
  unverified set.
- Conservative arming: skipped when capture mode != screenshot (BeginFrame tick
  semantics + the verifier's screenshot path wouldn't transfer), when a before-capture
  hook is set (per-frame video injection), when page-side compositing is active (shader
  / drawElement composite the plain verification screenshot can't reproduce), and when
  any data-start is a non-numeric reference expression the clip-boundary parser can't
  protect, or duration is unknown/zero.
- Session reuse (prepareCaptureSessionForReuse) resets lastFrameBuffer + dedup counter
  so a probe/prior-render buffer can't bleed into the first static frame; the armed set
  is kept (same-composition reuse). Cost calibration bypasses dedup for its sparse,
  non-contiguous sample sweep, then restores the armed set.
- HF_STATIC_DEDUP_SAMPLES is NaN-guarded.

Disqualifies on signals the GSAP predictor can't see: video, canvas/webgl, zero
tweens, running CSS/WAAPI animation. Pays on static-hold content (title cards,
slideshow/kiosk loops, data-viz pauses); no-op on continuously-animated comps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(engine): static-frame dedup default-on + render telemetry

Flip dedup from opt-in (HF_STATIC_DEDUP=true) to default-on (opt-out
HF_STATIC_DEDUP=false). Verification (verifyStaticFramesSafe) is the
safety net that keeps reuse sound at scale.

Add end-to-end dedup observability. The capture session records
enabled / armed / skipReason / predicted; these surface via
CapturePerfSummary -> a dedupPerfs accumulator (disk sequential +
parallel AND streaming sequential + parallel) -> aggregated into
RenderPerfSummary.staticDedup (OR armed, SUM frames across workers) ->
render_complete props static_dedup_{enabled,armed,skip_reason,
predicted_frames,reused_frames}. skip_reason is a low-cardinality code:
capture_mode | video_injection | page_composite | ineligible |
verification_failed.

Distributed chunks run on Linux/beginframe where dedup never arms, so
they pass a throwaway dedupPerfs sink (no per-chunk reporting).

Tests: aggregation logic (OR/SUM/skip-reason) + opt-out passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): address review on dedup default-on + telemetry

Review feedback (miga-heygen) + self-review fixes:

- Retry double-count: executeDiskCaptureWithAdaptiveRetry pushed worker
  dedup perf inside the retry loop, so an adaptive retry counted frames
  twice (reused/predicted could exceed totalFrames). Reset dedupPerfs at
  the start of each attempt — retry now REPLACES rather than accumulates;
  common no-retry path is unchanged.
- Opt-out parsing: HF_STATIC_DEDUP now disables on {false,0,off}
  case/space-insensitive (was strict !== "false", so `False`/`0` silently
  kept dedup on — the kill-switch could no-op).
- Verification budget vs drift: verifyStaticFramesSafe returns
  {badFrame, budgetExhausted}; armStaticDedup reports a distinct
  `verification_budget` skip reason so a telemetry spike means "raise
  HF_STATIC_DEDUP_SAMPLES", not "compositions are non-static".
- Index idiom: captureFrameCore now uses Math.floor(time*fps + 1e-9)
  (matches quantizeTimeToFrame) so the dedup lookup agrees with the frame
  the seek lands on even for non-exact times.
- Stale "opt-in HF_STATIC_DEDUP=true" comments -> "opt-out
  HF_STATIC_DEDUP=false" across frameCapture.ts + types.ts.
- Extract pushWorkerDedupPerfs helper (perfSummary.ts), used by the disk
  and streaming parallel paths — removes the duplicated push loop and
  drops captureStreamingStage back under the complexity threshold.
- dedupPerfs is now required (not optional) on
  executeDiskCaptureWithAdaptiveRetry — a missing arg silently dropped
  telemetry.
- Test: captureStreamingStage createInput() now provides the required
  dedupPerfs field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(engine): address deferred dedup-review items

- Derivable state: drop session.staticDedupArmed/staticDedupPredicted;
  derive both from session.staticFrames in getCapturePerfSummary
  (armed ⟺ non-empty set, predicted === size) so they can't desync.
- Config altitude: HF_STATIC_DEDUP now resolves into
  EngineConfig.staticFrameDedup (resolveConfig, opt-out on {false,0,off}),
  alongside forceScreenshot/browserGpuMode — armStaticDedup reads config
  instead of process.env. Default-on preserved (missing config → enabled).
- Lossy aggregation: aggregateDedup now reports DISTINCT skip reasons
  (sorted, `|`-joined) across diverging unarmed workers instead of just
  the first.
- discardWarmupCapture: also snapshot/restore staticDedupCount and
  lastFrameBuffer so a warmup capture can't leak a phantom reuse or a
  stale buffer anchor into the real summary.
- Convention: perfSummary-dedup.test builds its job via createRenderJob
  instead of `as unknown as RenderJob`.
- Docs: verification_budget added to skip-reason lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-18 01:12:05 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5fb5153e4e
commit 7310223b66
14 changed files with 805 additions and 14 deletions
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { captureFrameToBuffer, type CaptureSession } from "./frameCapture.js";
/**
* Regression lock for the static-dedup reuse index.
*
* `captureFrameCore` must key the static-frame reuse set on the ABSOLUTE
* composition frame — derived from `time` (`round(time * fps)`) — NOT the
* `frameIndex` argument. Distributed / per-worker-range / parallel callers pass
* a chunk-RELATIVE `frameIndex` (captureStage passes the loop `i`,
* parallelCoordinator passes `i - outputFrameOffset`) while `staticFrames` is
* keyed in absolute frames. A prior bug used `frameIndex`, so a chunk with
* `startFrame > 0` reused the wrong frames (and the right frames missed).
*
* The reuse branch returns BEFORE any page interaction, so we can exercise the
* decision with a stub session whose `page` throws if touched: a dedup HIT
* returns the cached buffer (page untouched); a MISS proceeds to the page and
* rejects. Both assertions below FAIL on the pre-fix (relative-index) code.
*/
const SENTINEL = Buffer.from("cached-anchor-frame");
// ponytail: minimal stub of the 40-field CaptureSession — only the fields the
// reuse decision reads are real; `page` is a trap that throws on any access so
// a dedup MISS (which falls through to prepareFrameForCapture) rejects loudly.
function makeSession(staticFrames: Set<number>, fps: { num: number; den: number }): CaptureSession {
const pageTrap = new Proxy(
{},
{
get() {
throw new Error("PAGE_TOUCHED");
},
},
);
return {
page: pageTrap,
options: { fps, format: "jpg" },
captureMode: "screenshot",
isInitialized: false,
staticFrames,
lastFrameBuffer: SENTINEL,
staticDedupCount: 0,
} as unknown as CaptureSession;
}
describe("static-dedup reuse keys on absolute frame index (time), not relative frameIndex", () => {
const fps30 = { num: 30, den: 1 };
it("HIT: relative frameIndex=0 but absolute time=90/30 reuses the anchor", async () => {
const session = makeSession(new Set([90]), fps30);
// Pre-fix used frameIndex (0) ∉ {90} → would miss → page trap throws.
const result = await captureFrameToBuffer(session, 0, 90 / 30);
expect(result.buffer).toBe(SENTINEL);
expect(session.staticDedupCount).toBe(1);
});
it("MISS: relative frameIndex=90 but absolute time=0 does NOT reuse", async () => {
const session = makeSession(new Set([90]), fps30);
// Pre-fix used frameIndex (90) ∈ {90} → would wrongly reuse the anchor.
await expect(captureFrameToBuffer(session, 90, 0)).rejects.toThrow();
expect(session.staticDedupCount).toBe(0);
});
it("non-integer fps (29.97) recovers the absolute index exactly", async () => {
const fps2997 = { num: 30000, den: 1001 };
const session = makeSession(new Set([100]), fps2997);
const time = (100 * fps2997.den) / fps2997.num; // absolute frame 100 → time
const result = await captureFrameToBuffer(session, 7, time);
expect(result.buffer).toBe(SENTINEL);
});
it("no reuse when the absolute frame is not in the static set", async () => {
const session = makeSession(new Set([10, 11, 12]), fps30);
await expect(captureFrameToBuffer(session, 0, 50 / 30)).rejects.toThrow();
});
});
+417 -1
View File
@@ -51,6 +51,30 @@ export interface CaptureSession {
outputDir: string;
onBeforeCapture: BeforeCaptureHook | null;
isInitialized: boolean;
/**
* Static-frame dedup (default-on; opt out with `HF_STATIC_DEDUP=false`): indices of frames byte-identical
* to their predecessor (no GSAP tween / clip cut active in either), predicted from
* window.__timelines and empirically anchor-verified. These reuse `lastFrameBuffer`
* instead of re-seeking + re-screenshotting. Undefined when disabled or ineligible.
*/
staticFrames?: Set<number>;
/** Last non-deduped frame buffer, reused for every `staticFrames` index in its run. */
lastFrameBuffer?: Buffer;
/** Count of frames served from a reused buffer (dedup telemetry). */
staticDedupCount?: number;
// ── Static-dedup observability (set by armStaticDedup; surfaced via
// getCapturePerfSummary → RenderPerfSummary → the render_complete event) ──
// NOTE: `armed` and `predicted` are NOT stored — they derive from
// `staticFrames` (armed ⟺ non-empty set; predicted === size) in
// getCapturePerfSummary, so they can't desync from the actual reuse set.
/** Dedup was enabled for this render (default-on; opt out with `HF_STATIC_DEDUP=false`). */
staticDedupEnabled?: boolean;
/**
* Short machine code for WHY dedup did not arm, for a low-cardinality breakdown.
* One of: `capture_mode` | `video_injection` | `page_composite` |
* `ineligible` | `verification_failed` | `verification_budget`. Undefined when armed or disabled.
*/
staticDedupSkipReason?: string;
// Tracks whether the page/browser handles have already been released by
// closeCaptureSession. Used to make closeCaptureSession idempotent under
// browser-pool semantics (see the function body for the full invariant).
@@ -1021,6 +1045,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await initTransparentBackground(session.page);
}
await armStaticDedup(session, session.page, logInitPhase);
session.isInitialized = true;
return;
}
@@ -1170,6 +1195,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await initTransparentBackground(session.page);
}
await armStaticDedup(session, session.page, logInitPhase);
session.isInitialized = true;
}
@@ -1280,6 +1306,335 @@ async function prepareFrameForCapture(
return { quantizedTime, seekMs, beforeCaptureMs };
}
// ── Static-frame dedup (default-on, opt-out HF_STATIC_DEDUP=false) ─────────────
// Skip re-seeking + re-screenshotting frames that are byte-identical to their
// predecessor. A frame is dedupable iff no GSAP tween or clip cut is active in it or
// its predecessor (predicted from window.__timelines), AND an empirical anchor-compare
// confirms it. Capture-mode-independent (works on screenshot + beginframe), lossless
// (verification disables the whole comp on any drift), default off. Pays on
// static-hold content (title cards, slideshows, data-viz pauses); a no-op on
// continuously-animated comps and disqualified by video/canvas/non-GSAP animation.
/**
* Clip-cut boundary frames (±1) from the [data-start] schedule. A hard scene swap at a
* cut changes content with no tween; treat those frames as animated so the post-cut
* frame is captured fresh and later static frames reuse the correct scene.
*/
async function computeClipBoundaryFrames(page: Page, fps: number): Promise<Set<number>> {
const schedule = await page.evaluate(() =>
Array.from(document.querySelectorAll("[data-start]")).map((el) => ({
start: parseFloat((el as HTMLElement).dataset.start || ""),
dur: parseFloat((el as HTMLElement).dataset.duration || ""),
})),
);
const frames = new Set<number>();
for (const { start, dur } of schedule) {
if (Number.isNaN(start)) continue;
const edges = [Math.round(start * fps)];
if (!Number.isNaN(dur)) edges.push(Math.round((start + dur) * fps));
for (const e of edges) {
for (const f of [e - 1, e, e + 1]) {
if (f >= 0) frames.add(f);
}
}
}
return frames;
}
/**
* Predict the dedupable (static) frame set from window.__timelines. A frame f (f>0) is
* static iff NEITHER f NOR f-1 falls inside any GSAP tween interval — content didn't
* change f-1→f, so f can reuse f-1's buffer. Requiring BOTH neighbours static under-
* claims by one frame at each tween edge (the SAFE direction). Disqualifies the whole
* comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
* a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
*/
async function computeStaticFrameSet(
page: Page,
fps: number,
): Promise<{
totalFrames: number;
staticFrameSet: Set<number>;
hasVideo: boolean;
hasCanvas: boolean;
hasNonGsapAnim: boolean;
tweenCount: number;
eligible: boolean;
reason: string;
}> {
const result = await page.evaluate(() => {
type AnyTween = {
startTime(): number;
duration(): number;
totalDuration?(): number;
getChildren?(nested: boolean, tweens: boolean, timelines: boolean): AnyTween[];
};
const intervals: Array<{ start: number; end: number }> = [];
let tweenCount = 0;
// totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
// a repeating timeline is marked opaque over its whole span (conservative).
function walk(tl: AnyTween, offset: number): void {
if (typeof tl.getChildren !== "function") return;
for (const child of tl.getChildren(false, true, true)) {
const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
const single = typeof child.duration === "function" ? child.duration() : 0;
const total = typeof child.totalDuration === "function" ? child.totalDuration() : single;
if (typeof child.getChildren === "function") {
if (total > single + 1e-6) intervals.push({ start, end: start + total });
else walk(child, start);
} else {
tweenCount++;
intervals.push({ start, end: start + total });
}
}
}
const w = window as unknown as {
__timelines?: Record<string, AnyTween>;
__hf?: { duration?: number };
};
for (const tl of Object.values(w.__timelines || {})) {
if (tl && typeof tl.getChildren === "function") walk(tl, 0);
}
const hasVideo = !!document.querySelector("video");
const hasCanvas = !!document.querySelector("canvas");
// A non-numeric data-start (reference expression like "intro+0.5") can't be turned
// into a clip-cut boundary by computeClipBoundaryFrames' parseFloat, so the cut goes
// unprotected and could be deduped into the previous scene. Disqualify the comp.
const hasUnresolvableClipStart = Array.from(document.querySelectorAll("[data-start]")).some(
(el) => {
const v = (el as HTMLElement).dataset.start;
return v != null && v.trim() !== "" && !Number.isFinite(parseFloat(v));
},
);
// Non-GSAP animation (CSS @keyframes / transitions / WAAPI) surfaces via
// getAnimations(); any running/paused one can change content without a tween.
let hasNonGsapAnim = false;
try {
const docAnims = (document as unknown as { getAnimations?: () => Animation[] }).getAnimations;
if (typeof docAnims === "function") {
hasNonGsapAnim = docAnims.call(document).some((a) => {
const t = a as Animation & { playState?: string };
return t.playState === "running" || t.playState === "paused";
});
}
} catch {
hasNonGsapAnim = true;
}
return {
intervals,
tweenCount,
duration: w.__hf?.duration ?? 0,
hasVideo,
hasCanvas,
hasNonGsapAnim,
hasUnresolvableClipStart,
};
});
const {
intervals,
tweenCount,
duration,
hasVideo,
hasCanvas,
hasNonGsapAnim,
hasUnresolvableClipStart,
} = result as {
intervals: Array<{ start: number; end: number }>;
tweenCount: number;
duration: number;
hasVideo: boolean;
hasCanvas: boolean;
hasNonGsapAnim: boolean;
hasUnresolvableClipStart: boolean;
};
const totalFrames = Math.max(1, Math.ceil(duration * fps));
const animated = new Set<number>();
for (const { start, end } of intervals) {
const lo = Math.max(0, Math.floor(start * fps));
const hi = Math.min(totalFrames - 1, Math.ceil(end * fps));
for (let f = lo; f <= hi; f++) animated.add(f);
}
for (const f of await computeClipBoundaryFrames(page, fps)) animated.add(f);
const reasons: string[] = [];
if (!(duration > 0)) reasons.push("unknown/zero duration");
if (hasVideo) reasons.push("video");
if (hasCanvas) reasons.push("canvas/webgl");
if (tweenCount === 0) reasons.push("no GSAP tweens (non-GSAP animation)");
if (hasNonGsapAnim) reasons.push("running CSS/WAAPI animation");
if (hasUnresolvableClipStart) reasons.push("unresolvable clip start (reference expression)");
const eligible = reasons.length === 0;
const staticFrameSet = new Set<number>();
if (eligible) {
for (let f = 1; f < totalFrames; f++) {
if (!animated.has(f) && !animated.has(f - 1)) staticFrameSet.add(f);
}
}
return {
totalFrames,
staticFrameSet,
hasVideo,
hasCanvas,
hasNonGsapAnim,
tweenCount,
eligible,
reason: eligible ? "eligible" : reasons.join("+"),
};
}
/**
* Empirically verify the predicted-static set before trusting it. Group static frames
* into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR,
* not the predecessor — a slow drift with sub-quantization per-frame deltas is byte-
* identical frame-to-frame yet drifts far from the anchor by the run's end (the real
* frozen error). Capture each run's anchor once, compare END + a midpoint to it; any
* mismatch ⇒ the run isn't truly static ⇒ disable dedup whole-comp. Capture-mode-
* independent (seeks + screenshots in normal DOM). Returns the first bad frame, or null.
*/
async function verifyStaticFramesSafe(
session: CaptureSession,
page: Page,
staticFrames: Set<number>,
fps: number,
sampleCount: number,
): Promise<{ badFrame: number; budgetExhausted: boolean } | null> {
const frames = [...staticFrames].sort((a, b) => a - b);
if (frames.length === 0) return null;
// Runs are maximal-contiguous (adjacent frames merge), so a run's anchor a-1 is
// guaranteed NOT static — always a freshly-captured frame.
const runs: Array<{ a: number; b: number }> = [];
for (const f of frames) {
const last = runs[runs.length - 1];
if (last && f === last.b + 1) last.b = f;
else runs.push({ a: f, b: f });
}
const seekCapture = async (frameIdx: number): Promise<Buffer> => {
const t = quantizeTimeToFrame(frameIdx / fps, fps);
await page.evaluate((tt: number) => {
const hf = (window as unknown as { __hf?: { seek?: (t: number) => void } }).__hf;
if (hf && typeof hf.seek === "function") hf.seek(tt);
}, t);
return pageScreenshotCapture(page, session.options);
};
// Verify EVERY run in order (no longest-first truncation that would leave runs armed
// but unverified). Per run, compare the FIRST reused frame `a`, the END `b` (max
// accumulated drift), and interior points at a stride — against the anchor the run
// actually reuses. `sampleCount` sets the interior density (points per run ~ that many
// for a long run); a hard cap bounds pathological run counts, and hitting it DISABLES
// dedup (conservative: never trust an unverified set).
const perRun = Math.max(3, Math.min(sampleCount, 8));
const hardCap = Math.max(sampleCount * 8, 400);
let spent = 0;
for (const { a, b } of runs) {
const anchor = a - 1;
if (anchor < 0) continue;
const anchorBuf = await seekCapture(anchor);
spent++;
const span = b - a;
const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1;
const pts = new Set<number>();
for (let f = a; f <= b; f += stride) pts.add(f);
pts.add(b); // always include the end (max drift)
for (const f of [...pts].sort((x, y) => x - y)) {
const cur = await seekCapture(f);
spent++;
if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false };
}
// Budget exhausted → can't fully verify → disarm. Reported distinctly from real
// drift so a `verification_budget` spike in telemetry signals "tune HF_STATIC_DEDUP_SAMPLES",
// not "compositions are non-static".
if (spent > hardCap) return { badFrame: a, budgetExhausted: true };
}
return null;
}
/**
* Arm static-frame dedup for this render (default-on; opt out with HF_STATIC_DEDUP=false).
* Runs at init in normal DOM state so the verification screenshots are valid. Predicts
* the static set, anchor-verifies it (skip with HF_STATIC_DEDUP_VERIFY=false — unsafe),
* and on success stores it on the session for captureFrameCore to reuse. Sample budget
* via HF_STATIC_DEDUP_SAMPLES (default 24).
*/
async function armStaticDedup(
session: CaptureSession,
page: Page,
logInitPhase: (phase: string) => void,
): Promise<void> {
// Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
// EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
// Default-on: only an explicit `staticFrameDedup === false` (resolved from
// HF_STATIC_DEDUP) disables; a missing config leaves dedup enabled.
session.staticDedupEnabled = session.config?.staticFrameDedup !== false;
if (!session.staticDedupEnabled) return;
// Conservative gates: dedup is verified against the plain screenshot path, so only arm
// where the production capture matches what verification measures, and where reuse is
// sound. Skip when:
// - capture mode is not screenshot (BeginFrame advances the compositor clock per
// frame; skipping beginFrame for static frames gaps the tick sequence, and the
// verifier uses pageScreenshotCapture not beginFrameCapture — its proof wouldn't
// transfer);
// - a before-capture hook is set (per-frame video-frame injection — those frames are
// NOT static even if the GSAP timeline is idle, and the injector is skipped on reuse);
// - page-side compositing is active (shader transitions / drawElement composite paint
// a frame the plain verification screenshot doesn't reproduce).
if (session.captureMode !== "screenshot") {
session.staticDedupSkipReason = "capture_mode";
logInitPhase(
`static-frame dedup: disabled (capture mode ${session.captureMode}, not screenshot)`,
);
return;
}
if (session.onBeforeCapture) {
session.staticDedupSkipReason = "video_injection";
logInitPhase("static-frame dedup: disabled (before-capture hook / video injection active)");
return;
}
const pageComposite = await page
.evaluate(
() =>
typeof (window as unknown as { __hf_page_composite_prepare?: unknown })
.__hf_page_composite_prepare === "function",
)
.catch(() => true); // fail CLOSED: if we can't determine, assume compositing → skip dedup
if (pageComposite) {
session.staticDedupSkipReason = "page_composite";
logInitPhase("static-frame dedup: disabled (page-side compositing active)");
return;
}
const fps = fpsToNumber(session.options.fps);
const stats = await computeStaticFrameSet(page, fps);
if (!stats.eligible || stats.staticFrameSet.size === 0) {
session.staticDedupSkipReason = "ineligible";
logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
return;
}
const rawSamples = Number(process.env.HF_STATIC_DEDUP_SAMPLES ?? "24");
const samples = Number.isFinite(rawSamples) && rawSamples >= 1 ? rawSamples : 24;
const verdict =
process.env.HF_STATIC_DEDUP_VERIFY === "false"
? null
: await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
if (verdict !== null) {
session.staticDedupSkipReason = verdict.budgetExhausted
? "verification_budget"
: "verification_failed";
logInitPhase(
verdict.budgetExhausted
? `static-frame dedup: disabled (verification budget exhausted before frame ${verdict.badFrame}; ` +
`raise HF_STATIC_DEDUP_SAMPLES to verify more)`
: `static-frame dedup: disabled (verification failed — content drifts from anchor at ` +
`predicted-static frame ${verdict.badFrame})`,
);
return;
}
// armed + predicted are derived from staticFrames in getCapturePerfSummary.
session.staticFrames = stats.staticFrameSet;
logInitPhase(
`static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` +
`(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`,
);
}
/**
* Internal core: prepare, screenshot, and track perf.
* Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
@@ -1293,6 +1648,30 @@ async function captureFrameCore(
const { page, options } = session;
const startTime = Date.now();
// Static-frame dedup: this frame is byte-identical to its predecessor (predicted +
// anchor-verified at init) → reuse the prior buffer, skip the seek + screenshot.
// KEY: index by the ABSOLUTE composition frame (derived from `time`), NOT the
// `frameIndex` arg — chunked/parallel/distributed callers pass a chunk-RELATIVE
// frameIndex (captureStage passes the loop `i`, parallelCoordinator passes
// `i-outputFrameOffset`) while staticFrames is keyed in absolute frames. Using `time`
// is correct on every path (sequential, per-worker range, distributed chunk) because
// `time` is always the absolute composition time for the frame. Each session captures
// its range in ascending order, so lastFrameBuffer is the correct in-range anchor (and
// since a static run is verified identical, reusing the run's first in-range capture
// equals reusing the global anchor). Telemetry: count reuses separately; do NOT bump
// capturePerf.frames (that would dilute the per-frame timing averages).
// Use the SAME floor+epsilon idiom as quantizeTimeToFrame so the dedup lookup agrees
// with the frame the seek actually lands on, even if `time` ever isn't exactly i/fps.
const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
if (session.staticFrames?.has(absFrameIndex) && session.lastFrameBuffer) {
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
return {
buffer: session.lastFrameBuffer,
quantizedTime: quantizeTimeToFrame(time, fpsToNumber(options.fps)),
captureTimeMs: Date.now() - startTime,
};
}
try {
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
session,
@@ -1328,6 +1707,9 @@ async function captureFrameCore(
session.capturePerf.screenshotMs += screenshotMs;
session.capturePerf.totalMs += captureTimeMs;
// Retain this freshly-captured buffer so the following static frames can reuse it.
if (session.staticFrames) session.lastFrameBuffer = screenshotBuffer;
return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
} catch (captureError) {
if (session.isInitialized) {
@@ -1428,18 +1810,40 @@ export async function discardWarmupCapture(
const perfBefore = { ...session.capturePerf };
const hasDamageBefore = session.beginFrameHasDamageCount;
const noDamageBefore = session.beginFrameNoDamageCount;
const dedupCountBefore = session.staticDedupCount;
const lastFrameBufferBefore = session.lastFrameBuffer;
try {
await innerCapture(session, frameIndex, time);
} finally {
// Always restore — even on error. A failed warmup capture should not
// leak inflated perf counters into the real capture summary.
// leak inflated perf counters, a phantom dedup reuse, or a warmup-era
// lastFrameBuffer anchor into the real capture summary/state.
session.capturePerf = perfBefore;
session.beginFrameHasDamageCount = hasDamageBefore;
session.beginFrameNoDamageCount = noDamageBefore;
session.staticDedupCount = dedupCountBefore;
session.lastFrameBuffer = lastFrameBufferBefore;
}
}
export async function closeCaptureSession(session: CaptureSession): Promise<void> {
// Realized static-dedup telemetry: how much the cache actually helped this
// render (vs the prediction logged at arm time). Both capture paths
// (sequential orchestrator + parallel workers) close their session here, so
// this is the one uniform emit point. Zero the count afterward so the
// idempotent re-close (HDR cleanup) doesn't double-log.
const reused = session.staticDedupCount ?? 0;
if (session.staticFrames && reused > 0) {
const captured = session.capturePerf.frames; // excludes reuses by design
const total = captured + reused;
const pct = total > 0 ? Math.round((reused / total) * 100) : 0;
const avgTotalMs = captured > 0 ? Math.round(session.capturePerf.totalMs / captured) : 0;
console.log(
`[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
`est. ~${reused * avgTotalMs}ms saved (avg ${avgTotalMs}ms/frame)`,
);
session.staticDedupCount = 0;
}
// INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
// cleanup path tracks a `domSessionClosed` flag and may still re-call this
// in the outer finally if the inner cleanup raised before the flag flipped.
@@ -1496,6 +1900,12 @@ export function prepareCaptureSessionForReuse(
};
session.beginFrameHasDamageCount = 0;
session.beginFrameNoDamageCount = 0;
// Reset per-render dedup state so a buffer captured by the prior render/probe can't
// bleed into this render's first static frame. staticFrames (the armed set) is left
// intact: it's keyed in absolute frames and stays valid for a same-composition reuse;
// lastFrameBuffer must be re-seeded by this render's first fresh capture.
session.lastFrameBuffer = undefined;
session.staticDedupCount = 0;
}
export async function getCompositionDuration(session: CaptureSession): Promise<number> {
@@ -1514,5 +1924,11 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
staticDedupReused: session.staticDedupCount ?? 0,
staticDedupEnabled: session.staticDedupEnabled ?? false,
// armed ⟺ a non-empty static set survived verification; predicted === its size.
staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
staticDedupPredicted: session.staticFrames?.size ?? 0,
staticDedupSkipReason: session.staticDedupSkipReason,
};
}