diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 35dcdb1c2..b4fcbf34b 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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, diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index fc95c8a40..17262dba5 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -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, diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index a235e284d..df05ebd96 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -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 { 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 = { @@ -307,6 +319,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { : undefined, forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot), + staticFrameDedup: resolveStaticFrameDedup(), lowMemoryMode: resolveLowMemoryMode(), enablePageSideCompositing: envBool( "HF_PAGE_SIDE_COMPOSITING", diff --git a/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts b/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts new file mode 100644 index 000000000..66e53c99c --- /dev/null +++ b/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts @@ -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, 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(); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 460510ba6..5d9ceb75b 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -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; + /** 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 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 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> { + 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(); + 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; + 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; + __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(); + 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(); + 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, + 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 => { + 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(); + 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 { + // 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 { + // 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 { @@ -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, }; } diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index a3c23cfd8..726b14a36 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -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 ──────────────────────────────────────────────────────── diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 3d4b56ded..00c2887d2 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -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, diff --git a/packages/producer/src/services/render/captureCost.ts b/packages/producer/src/services/render/captureCost.ts index c43b3efe0..3a12d790b 100644 --- a/packages/producer/src/services/render/captureCost.ts +++ b/packages/producer/src/services/render/captureCost.ts @@ -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); diff --git a/packages/producer/src/services/render/perfSummary-dedup.test.ts b/packages/producer/src/services/render/perfSummary-dedup.test.ts new file mode 100644 index 000000000..7c3898f2c --- /dev/null +++ b/packages/producer/src/services/render/perfSummary-dedup.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/packages/producer/src/services/render/perfSummary.ts b/packages/producer/src/services/render/perfSummary.ts index cce8e6971..bb847cc1f 100644 --- a/packages/producer/src/services/render/perfSummary.ts +++ b/packages/producer/src/services/render/perfSummary.ts @@ -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), }; } diff --git a/packages/producer/src/services/render/stages/captureStage.ts b/packages/producer/src/services/render/stages/captureStage.ts index 1979a2122..12efa349b 100644 --- a/packages/producer/src/services/render/stages/captureStage.ts +++ b/packages/producer/src/services/render/stages/captureStage.ts @@ -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 { job.framesRendered = progress.capturedFrames; const frameProgress = progress.capturedFrames / progress.totalFrames; @@ -277,6 +288,9 @@ export async function runCaptureStage(input: CaptureStageInput): Promise null, abortSignal: undefined, assertNotAborted: () => {}, + dedupPerfs: [], }; } diff --git a/packages/producer/src/services/render/stages/captureStreamingStage.ts b/packages/producer/src/services/render/stages/captureStreamingStage.ts index fca554558..afdbfaeb3 100644 --- a/packages/producer/src/services/render/stages/captureStreamingStage.ts +++ b/packages/producer/src/services/render/stages/captureStreamingStage.ts @@ -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 diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 99137f5d4..4519152e7 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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 { 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,