mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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:
co-authored by
Claude Opus 4.8
parent
5fb5153e4e
commit
7310223b66
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user