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
+5
View File
@@ -1412,6 +1412,11 @@ function trackRenderMetrics(
workers: options.workers ?? perf?.workers,
docker,
gpu: options.gpu,
staticDedupEnabled: perf?.staticDedup?.enabled,
staticDedupArmed: perf?.staticDedup?.armed,
staticDedupSkipReason: perf?.staticDedup?.skipReason,
staticDedupPredictedFrames: perf?.staticDedup?.predictedFrames,
staticDedupReusedFrames: perf?.staticDedup?.reusedFrames,
compositionDurationMs,
compositionWidth: perf?.resolution.width,
compositionHeight: perf?.resolution.height,
+12
View File
@@ -101,6 +101,13 @@ export function trackRenderComplete(
workers?: number;
docker: boolean;
gpu: boolean;
// Static-frame dedup outcome (opt-out HF_STATIC_DEDUP=false). Undefined on
// render paths with no capture session.
staticDedupEnabled?: boolean;
staticDedupArmed?: boolean;
staticDedupSkipReason?: string;
staticDedupPredictedFrames?: number;
staticDedupReusedFrames?: number;
// "cli" when triggered by `hyperframes render` (default), "studio" when
// triggered by a studio preview-server render (POST /api/projects/:id/render).
source?: "cli" | "studio";
@@ -149,6 +156,11 @@ export function trackRenderComplete(
workers: props.workers,
docker: props.docker,
gpu: props.gpu,
static_dedup_enabled: props.staticDedupEnabled,
static_dedup_armed: props.staticDedupArmed,
static_dedup_skip_reason: props.staticDedupSkipReason,
static_dedup_predicted_frames: props.staticDedupPredictedFrames,
static_dedup_reused_frames: props.staticDedupReusedFrames,
source: props.source ?? "cli",
composition_duration_ms: props.compositionDurationMs,
composition_width: props.compositionWidth,
+13
View File
@@ -54,6 +54,12 @@ export interface EngineConfig {
expectedChromiumMajor?: number;
/** Force screenshot capture mode (skip BeginFrame even on Linux). */
forceScreenshot: boolean;
/**
* Static-frame dedup: reuse byte-identical frames instead of re-seeking +
* re-screenshotting (anchor-verified at init). Default ON; disable via
* `HF_STATIC_DEDUP` in {false,0,off}. Only arms in screenshot capture mode.
*/
staticFrameDedup: boolean;
/**
* Low-memory render profile. When `true`, the orchestrator collapses the
* pipeline to its cheapest shape on memory-constrained hosts: it skips the
@@ -208,6 +214,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
browserTimeout: 120_000,
protocolTimeout: 300_000,
forceScreenshot: false,
staticFrameDedup: true,
// Auto-detected per host in `resolveConfig`; defaults off for the raw
// DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks).
lowMemoryMode: false,
@@ -282,6 +289,11 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
if (raw === "false" || raw === "off" || raw === "0") return false;
return isLowMemorySystem();
};
// Opt-OUT: default ON, disabled only by an explicit falsey value.
const resolveStaticFrameDedup = (): boolean => {
const raw = env("HF_STATIC_DEDUP")?.trim().toLowerCase();
return !(raw === "false" || raw === "off" || raw === "0");
};
// Env-var layer (backward compat)
const fromEnv: Partial<EngineConfig> = {
@@ -307,6 +319,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
: undefined,
forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
staticFrameDedup: resolveStaticFrameDedup(),
lowMemoryMode: resolveLowMemoryMode(),
enablePageSideCompositing: envBool(
"HF_PAGE_SIDE_COMPOSITING",
@@ -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,
};
}
+20
View File
@@ -160,6 +160,26 @@ export interface CapturePerfSummary {
avgSeekMs: number;
avgBeforeCaptureMs: number;
avgScreenshotMs: number;
/**
* Frames served from the static-dedup cache instead of a real seek+screenshot
* (opt-out HF_STATIC_DEDUP=false). 0 when dedup was off or never armed. NOT counted
* in `frames` (reuses are excluded so they don't dilute the per-frame
* averages) the captured total this session is `frames + staticDedupReused`.
*/
staticDedupReused: number;
/** `HF_STATIC_DEDUP=true` was set for this render (adoption signal). */
staticDedupEnabled: boolean;
/** Dedup passed every gate + verification and was active. */
staticDedupArmed: boolean;
/** Predicted reusable frame count when armed; 0 otherwise. */
staticDedupPredicted: number;
/**
* Low-cardinality reason dedup did not arm: `capture_mode` | `video_injection`
* | `page_composite` | `ineligible` | `verification_failed` | `verification_budget`.
* Undefined when armed or when dedup was disabled. (Render-level aggregation may
* `|`-join distinct reasons when parallel workers diverge.)
*/
staticDedupSkipReason?: string;
}
// ── Global Augmentation ────────────────────────────────────────────────────────
@@ -573,6 +573,9 @@ export async function renderChunk(
probeSession: session,
needsAlpha: plan.dimensions.format !== "mp4",
captureAttempts: [],
// Distributed chunks run on Linux (beginframe) where dedup never arms;
// a throwaway sink satisfies the type without per-chunk dedup reporting.
dedupPerfs: [],
buildCaptureOptions: () => captureOptions,
createRenderVideoFrameInjector: () => videoInjector,
abortSignal: undefined,
@@ -227,16 +227,28 @@ export async function measureCaptureCostFromSession(
const samples: CaptureCalibrationSample[] = [];
const totalSamples = sampledFrames.length;
for (let i = 0; i < sampledFrames.length; i++) {
const frameIndex = sampledFrames[i]!;
log?.info(`Calibration: capturing test frame ${i + 1}/${totalSamples}...`);
const time = frameIndex / fps;
const startedAt = Date.now();
const result = await captureFrameToBuffer(session, frameIndex, time);
samples.push({
frameIndex,
captureTimeMs: result.captureTimeMs || Date.now() - startedAt,
});
// Calibration samples are SPARSE and non-contiguous, so static-frame dedup must not
// fire here: a sampled frame in the static set would reuse a far-away sample's buffer
// in ~0ms, both corrupting the per-frame cost estimate and returning the wrong pixels.
// Bypass dedup for the calibration sweep; restore the armed set (and clear the
// calibration-era buffer) so the real render that reuses this session still dedups.
const savedStaticFrames = session.staticFrames;
session.staticFrames = undefined;
try {
for (let i = 0; i < sampledFrames.length; i++) {
const frameIndex = sampledFrames[i]!;
log?.info(`Calibration: capturing test frame ${i + 1}/${totalSamples}...`);
const time = frameIndex / fps;
const startedAt = Date.now();
const result = await captureFrameToBuffer(session, frameIndex, time);
samples.push({
frameIndex,
captureTimeMs: result.captureTimeMs || Date.now() - startedAt,
});
}
} finally {
session.staticFrames = savedStaticFrames;
session.lastFrameBuffer = undefined;
}
const estimate = estimateMeasuredCaptureCostMultiplier(samples);
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import type { CapturePerfSummary } from "@hyperframes/engine";
import { buildRenderPerfSummary } from "./perfSummary.js";
import { createRenderJob } from "../renderOrchestrator.js";
function baseInput(dedupPerfs: CapturePerfSummary[]) {
return {
job: createRenderJob({ fps: { num: 30, den: 1 }, quality: "high" }),
workerCount: dedupPerfs.length || 1,
enableChunkedEncode: false,
chunkedEncodeSize: 0,
compositionDurationSeconds: 5,
totalFrames: 150,
outputWidth: 1920,
outputHeight: 1080,
videoCount: 0,
audioCount: 0,
totalElapsedMs: 1000,
perfStages: {},
videoExtractBreakdown: undefined,
tmpPeakBytes: 0,
captureAttempts: [],
hdrDiagnostics: { videoExtractionFailures: 0, imageDecodeFailures: 0 },
peakRssBytes: 0,
peakHeapUsedBytes: 0,
dedupPerfs,
};
}
function perf(p: Partial<CapturePerfSummary>): CapturePerfSummary {
return {
frames: 0,
avgTotalMs: 0,
avgSeekMs: 0,
avgBeforeCaptureMs: 0,
avgScreenshotMs: 0,
staticDedupReused: 0,
staticDedupEnabled: false,
staticDedupArmed: false,
staticDedupPredicted: 0,
...p,
};
}
describe("buildRenderPerfSummary static-dedup aggregation", () => {
it("is undefined when no capture session ran", () => {
expect(buildRenderPerfSummary(baseInput([])).staticDedup).toBeUndefined();
});
it("SUMs reused/predicted and ORs armed across workers", () => {
const s = buildRenderPerfSummary(
baseInput([
perf({
staticDedupEnabled: true,
staticDedupArmed: true,
staticDedupPredicted: 40,
staticDedupReused: 30,
}),
perf({
staticDedupEnabled: true,
staticDedupArmed: false,
staticDedupSkipReason: "ineligible",
}),
perf({
staticDedupEnabled: true,
staticDedupArmed: true,
staticDedupPredicted: 20,
staticDedupReused: 18,
}),
]),
).staticDedup;
expect(s).toEqual({
enabled: true,
armed: true,
predictedFrames: 60,
reusedFrames: 48,
skipReason: undefined, // armed → no skip reason
});
});
it("reports skipReason when no worker armed", () => {
const s = buildRenderPerfSummary(
baseInput([
perf({
staticDedupEnabled: true,
staticDedupArmed: false,
staticDedupSkipReason: "capture_mode",
}),
]),
).staticDedup;
expect(s?.armed).toBe(false);
expect(s?.skipReason).toBe("capture_mode");
expect(s?.reusedFrames).toBe(0);
});
it("joins DISTINCT skip reasons across diverging unarmed workers (sorted, deduped)", () => {
const s = buildRenderPerfSummary(
baseInput([
perf({
staticDedupEnabled: true,
staticDedupArmed: false,
staticDedupSkipReason: "ineligible",
}),
perf({
staticDedupEnabled: true,
staticDedupArmed: false,
staticDedupSkipReason: "capture_mode",
}),
perf({
staticDedupEnabled: true,
staticDedupArmed: false,
staticDedupSkipReason: "capture_mode",
}),
]),
).staticDedup;
expect(s?.armed).toBe(false);
expect(s?.skipReason).toBe("capture_mode|ineligible"); // sorted, deduped
});
it("carries enabled=false through (opt-out renders)", () => {
const s = buildRenderPerfSummary(baseInput([perf({ staticDedupEnabled: false })])).staticDedup;
expect(s).toEqual({
enabled: false,
armed: false,
predictedFrames: 0,
reusedFrames: 0,
skipReason: undefined,
});
});
});
@@ -4,6 +4,7 @@
*/
import { fpsToNumber } from "@hyperframes/core";
import type { CapturePerfSummary } from "@hyperframes/engine";
import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js";
import type {
CaptureAttemptSummary,
@@ -14,6 +15,47 @@ import type {
import { type HdrPerfCollector, finalizeHdrPerf } from "./hdrPerf.js";
import type { RenderObservabilitySummary } from "./observability.js";
/**
* Append each parallel worker's static-dedup perf into the render-level sink
* (skipping workers that reported none). Shared by the disk + streaming parallel
* paths so the collection contract lives in one place.
*/
export function pushWorkerDedupPerfs(
results: ReadonlyArray<{ perf?: CapturePerfSummary }>,
sink: CapturePerfSummary[],
): void {
for (const r of results) {
if (r.perf) sink.push(r.perf);
}
}
/**
* Collapse per-session/per-worker static-dedup perf into one render-level
* outcome. enabled/armed = OR across workers (they run the same gates on the
* same composition); predicted/reused = SUM (each worker dedups its own frame
* range); skipReason = the distinct reasons (sorted, `|`-joined) when not armed.
*/
function aggregateDedup(perfs: CapturePerfSummary[]): RenderPerfSummary["staticDedup"] {
if (perfs.length === 0) return undefined;
const armed = perfs.some((p) => p.staticDedupArmed);
// When unarmed, report every DISTINCT skip reason across workers (sorted, joined)
// rather than just the first — workers can diverge (e.g. one `ineligible`, one
// `capture_mode`), and dropping the rest hides why dedup didn't engage. Cardinality
// stays bounded (a handful of codes, small combinations).
const skipReasons = armed
? []
: [
...new Set(perfs.map((p) => p.staticDedupSkipReason).filter((r): r is string => !!r)),
].sort();
return {
enabled: perfs.some((p) => p.staticDedupEnabled),
armed,
predictedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupPredicted ?? 0), 0),
reusedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupReused ?? 0), 0),
skipReason: skipReasons.length > 0 ? skipReasons.join("|") : undefined,
};
}
export function buildRenderPerfSummary(input: {
job: RenderJob;
workerCount: number;
@@ -39,6 +81,8 @@ export function buildRenderPerfSummary(input: {
observability?: RenderObservabilitySummary;
peakRssBytes: number;
peakHeapUsedBytes: number;
/** Per-session/per-worker static-dedup perf; aggregated into `staticDedup`. */
dedupPerfs: CapturePerfSummary[];
}): RenderPerfSummary {
return {
renderId: input.job.id,
@@ -82,5 +126,6 @@ export function buildRenderPerfSummary(input: {
: undefined,
peakRssMb: Math.round(input.peakRssBytes / (1024 * 1024)),
peakHeapUsedMb: Math.round(input.peakHeapUsedBytes / (1024 * 1024)),
staticDedup: aggregateDedup(input.dedupPerfs),
};
}
@@ -39,11 +39,13 @@
import {
type BeforeCaptureHook,
type CaptureOptions,
type CapturePerfSummary,
type CaptureSession,
type EngineConfig,
captureFrame,
closeCaptureSession,
createCaptureSession,
getCapturePerfSummary,
initializeSession,
prepareCaptureSessionForReuse,
} from "@hyperframes/engine";
@@ -90,6 +92,13 @@ export interface CaptureStageInput {
needsAlpha: boolean;
/** Mutated in place — each parallel retry attempt is appended. */
captureAttempts: CaptureAttemptSummary[];
/**
* Mutated in place per-session static-dedup perf is appended (one entry
* for the sequential session, one per worker on the parallel path). The
* sequencer aggregates these into the `RenderPerfSummary` dedup block. Same
* append-in-place contract as `captureAttempts`.
*/
dedupPerfs: CapturePerfSummary[];
buildCaptureOptions: () => CaptureOptions;
createRenderVideoFrameInjector: () => BeforeCaptureHook | null;
abortSignal: AbortSignal | undefined;
@@ -137,6 +146,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
onProgress,
needsAlpha,
frameRange,
dedupPerfs,
} = input;
let { workerCount, probeSession } = input;
let lastBrowserConsole: string[] = [];
@@ -190,6 +200,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
createBeforeCaptureHook: createRenderVideoFrameInjector,
abortSignal,
frameRangeStart: frameRange?.startFrame,
dedupPerfs,
onProgress: (progress) => {
job.framesRendered = progress.capturedFrames;
const frameProgress = progress.capturedFrames / progress.totalFrames;
@@ -277,6 +288,9 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
onProgress,
);
}
// Capture the sequential session's static-dedup perf before close (the
// counters are valid only while the session is live).
dedupPerfs.push(getCapturePerfSummary(session));
// This must mirror streaming capture: catch wraps the original failure with
// browser diagnostics, finally only handles cleanup.
// fallow-ignore-next-line code-duplication
@@ -127,6 +127,7 @@ function createInput(cfg: MinimalEngineConfig) {
createRenderVideoFrameInjector: () => null,
abortSignal: undefined,
assertNotAborted: () => {},
dedupPerfs: [],
};
}
@@ -43,6 +43,7 @@
import {
type BeforeCaptureHook,
type CaptureOptions,
type CapturePerfSummary,
type CaptureSession,
type EngineConfig,
type StreamingEncoder,
@@ -52,6 +53,7 @@ import {
createFrameReorderBuffer,
distributeFrames,
executeParallelCapture,
getCapturePerfSummary,
initializeSession,
prepareCaptureSessionForReuse,
spawnStreamingEncoder,
@@ -60,6 +62,7 @@ import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { wrapCaptureStageError } from "../captureStageError.js";
import { pushWorkerDedupPerfs } from "../perfSummary.js";
import { ensureFrameWritten } from "./captureHdrFrameShared.js";
import { updateJobStatus } from "../shared.js";
@@ -106,6 +109,12 @@ export interface CaptureStreamingStageInput {
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
/**
* Mutated in place static-dedup perf appended for the sequential session or
* each parallel worker, aggregated into the `RenderPerfSummary` dedup block.
* Same append-in-place contract as the disk-capture stage.
*/
dedupPerfs: CapturePerfSummary[];
}
export type CaptureStreamingStageResult =
@@ -143,6 +152,7 @@ export async function runCaptureStreamingStage(
abortSignal,
assertNotAborted,
onProgress,
dedupPerfs,
} = input;
let { workerCount, probeSession } = input;
let lastBrowserConsole: string[] = [];
@@ -200,7 +210,7 @@ export async function runCaptureStreamingStage(
reorderBuffer.advanceTo(frameIndex + 1);
};
await executeParallelCapture(
const workerResults = await executeParallelCapture(
fileServer.url,
workDir,
tasks,
@@ -228,6 +238,7 @@ export async function runCaptureStreamingStage(
onFrameBuffer,
captureCfg,
);
pushWorkerDedupPerfs(workerResults, dedupPerfs);
if (probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
@@ -283,6 +294,9 @@ export async function runCaptureStreamingStage(
onProgress,
);
}
// Capture the session's static-dedup perf before close (counters valid
// only while the session is live).
dedupPerfs.push(getCapturePerfSummary(session));
// This must mirror disk capture: catch wraps the original failure with
// browser diagnostics, finally only handles cleanup.
// fallow-ignore-next-line code-duplication
@@ -66,6 +66,7 @@ import {
getSystemTotalMb,
LOW_MEMORY_TOTAL_MB_THRESHOLD,
assertConfiguredFfmpegBinariesExist,
type CapturePerfSummary,
} from "@hyperframes/engine";
import { join, dirname, resolve } from "path";
import { randomUUID } from "crypto";
@@ -88,7 +89,7 @@ import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { formatCaptureFrameName } from "../utils/paths.js";
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
import { buildRenderPerfSummary } from "./render/perfSummary.js";
import { buildRenderPerfSummary, pushWorkerDedupPerfs } from "./render/perfSummary.js";
import { getCaptureStageBrowserConsole } from "./render/captureStageError.js";
import {
type CaptureCalibrationSample,
@@ -326,6 +327,21 @@ export interface RenderPerfSummary {
peakHeapUsedMb?: number;
hdrDiagnostics?: HdrDiagnostics;
hdrPerf?: HdrPerfSummary;
/**
* Static-frame dedup outcome for this render (opt-out HF_STATIC_DEDUP=false),
* aggregated across the sequential session or all parallel workers. `enabled`
* is the adoption signal; `armed` means it passed every gate + verification;
* `skipReason` says why it didn't arm; `reusedFrames`/`predictedFrames` measure
* effectiveness (reuse % = reusedFrames / totalFrames). Undefined when no
* capture session ran (e.g. layered-HDR-only paths).
*/
staticDedup?: {
enabled: boolean;
armed: boolean;
predictedFrames: number;
reusedFrames: number;
skipReason?: string;
};
}
export interface HdrDiagnostics {
@@ -576,6 +592,8 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
* contract: `[0, totalFrames)`). See `WorkerTask.outputFrameOffset`.
*/
frameRangeStart?: number;
/** Mutated in place — replaced each attempt so only the final attempt's worker perf survives (see retry reset below). */
dedupPerfs: CapturePerfSummary[];
}): Promise<CaptureAttemptSummary[]> {
const attempts: CaptureAttemptSummary[] = [];
let currentWorkers = options.initialWorkerCount;
@@ -603,6 +621,11 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
)
: [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir, rangeStart)];
// Reset before each attempt so a retry REPLACES (not accumulates) worker perf —
// otherwise a frame captured in attempt 0 AND re-captured on retry would be counted
// twice, inflating reused/predicted past totalFrames. The common no-retry path keeps
// exactly one attempt's perf; a retry reports only the final attempt's set.
options.dedupPerfs.length = 0;
try {
for (const tasks of batches) {
const capturedBeforeBatch = countCapturedFrames(
@@ -611,7 +634,7 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
options.frameExt,
);
try {
await executeParallelCapture(
const workerResults = await executeParallelCapture(
options.serverUrl,
attemptWorkDir,
tasks,
@@ -633,6 +656,7 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
undefined,
options.cfg,
);
pushWorkerDedupPerfs(workerResults, options.dedupPerfs);
} finally {
await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
}
@@ -1285,6 +1309,9 @@ export async function executeRenderJob(
});
const captureAttempts: CaptureAttemptSummary[] = [];
// Static-dedup perf, appended per sequential session / per parallel worker
// by the capture stage, aggregated into the perf summary below.
const dedupPerfs: CapturePerfSummary[] = [];
// png-sequence is "no container" — outputPath is treated as a directory and
// the encode/mux/faststart stages are skipped entirely. The empty extension
@@ -1483,6 +1510,7 @@ export async function executeRenderJob(
abortSignal,
assertNotAborted,
onProgress,
dedupPerfs,
}),
);
if (streamingRes.success) {
@@ -1520,6 +1548,7 @@ export async function executeRenderJob(
probeSession,
needsAlpha,
captureAttempts,
dedupPerfs,
buildCaptureOptions,
createRenderVideoFrameInjector,
abortSignal,
@@ -1634,6 +1663,7 @@ export async function executeRenderJob(
tmpPeakBytes,
captureCalibration,
captureAttempts,
dedupPerfs,
hdrDiagnostics,
hdrPerf,
observability: observabilitySummary,