mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(engine): static-frame dedup default-on + render telemetry (#1549)
* feat(engine): static-frame dedup for screenshot capture (opt-in) Skip re-seeking + re-screenshotting frames byte-identical to their predecessor. A frame is dedupable iff no GSAP tween or clip cut is active in it or its predecessor (predicted from window.__timelines + clip schedule) AND an empirical anchor-compare confirms it. Opt-in HF_STATIC_DEDUP=true, default off. Correctness (designed for the multi-worker / distributed render paths): - Reuse is keyed by the ABSOLUTE composition frame (derived from the frame's time), NOT the captureFrameCore frameIndex arg — chunked/parallel callers pass a chunk- relative index. Validated lossless (PSNR=inf) on both single- and multi-worker renders of a static-hold comp. - verifyStaticFramesSafe checks EVERY run (no longest-first budget truncation that left runs armed-but-unverified), and samples each run's FIRST reused frame, its END, and interior points at a stride; a hard cap disables dedup rather than trust an unverified set. - Conservative arming: skipped when capture mode != screenshot (BeginFrame tick semantics + the verifier's screenshot path wouldn't transfer), when a before-capture hook is set (per-frame video injection), when page-side compositing is active (shader / drawElement composite the plain verification screenshot can't reproduce), and when any data-start is a non-numeric reference expression the clip-boundary parser can't protect, or duration is unknown/zero. - Session reuse (prepareCaptureSessionForReuse) resets lastFrameBuffer + dedup counter so a probe/prior-render buffer can't bleed into the first static frame; the armed set is kept (same-composition reuse). Cost calibration bypasses dedup for its sparse, non-contiguous sample sweep, then restores the armed set. - HF_STATIC_DEDUP_SAMPLES is NaN-guarded. Disqualifies on signals the GSAP predictor can't see: video, canvas/webgl, zero tweens, running CSS/WAAPI animation. Pays on static-hold content (title cards, slideshow/kiosk loops, data-viz pauses); no-op on continuously-animated comps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(engine): static-frame dedup default-on + render telemetry Flip dedup from opt-in (HF_STATIC_DEDUP=true) to default-on (opt-out HF_STATIC_DEDUP=false). Verification (verifyStaticFramesSafe) is the safety net that keeps reuse sound at scale. Add end-to-end dedup observability. The capture session records enabled / armed / skipReason / predicted; these surface via CapturePerfSummary -> a dedupPerfs accumulator (disk sequential + parallel AND streaming sequential + parallel) -> aggregated into RenderPerfSummary.staticDedup (OR armed, SUM frames across workers) -> render_complete props static_dedup_{enabled,armed,skip_reason, predicted_frames,reused_frames}. skip_reason is a low-cardinality code: capture_mode | video_injection | page_composite | ineligible | verification_failed. Distributed chunks run on Linux/beginframe where dedup never arms, so they pass a throwaway dedupPerfs sink (no per-chunk reporting). Tests: aggregation logic (OR/SUM/skip-reason) + opt-out passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(engine): address review on dedup default-on + telemetry Review feedback (miga-heygen) + self-review fixes: - Retry double-count: executeDiskCaptureWithAdaptiveRetry pushed worker dedup perf inside the retry loop, so an adaptive retry counted frames twice (reused/predicted could exceed totalFrames). Reset dedupPerfs at the start of each attempt — retry now REPLACES rather than accumulates; common no-retry path is unchanged. - Opt-out parsing: HF_STATIC_DEDUP now disables on {false,0,off} case/space-insensitive (was strict !== "false", so `False`/`0` silently kept dedup on — the kill-switch could no-op). - Verification budget vs drift: verifyStaticFramesSafe returns {badFrame, budgetExhausted}; armStaticDedup reports a distinct `verification_budget` skip reason so a telemetry spike means "raise HF_STATIC_DEDUP_SAMPLES", not "compositions are non-static". - Index idiom: captureFrameCore now uses Math.floor(time*fps + 1e-9) (matches quantizeTimeToFrame) so the dedup lookup agrees with the frame the seek lands on even for non-exact times. - Stale "opt-in HF_STATIC_DEDUP=true" comments -> "opt-out HF_STATIC_DEDUP=false" across frameCapture.ts + types.ts. - Extract pushWorkerDedupPerfs helper (perfSummary.ts), used by the disk and streaming parallel paths — removes the duplicated push loop and drops captureStreamingStage back under the complexity threshold. - dedupPerfs is now required (not optional) on executeDiskCaptureWithAdaptiveRetry — a missing arg silently dropped telemetry. - Test: captureStreamingStage createInput() now provides the required dedupPerfs field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(engine): address deferred dedup-review items - Derivable state: drop session.staticDedupArmed/staticDedupPredicted; derive both from session.staticFrames in getCapturePerfSummary (armed ⟺ non-empty set, predicted === size) so they can't desync. - Config altitude: HF_STATIC_DEDUP now resolves into EngineConfig.staticFrameDedup (resolveConfig, opt-out on {false,0,off}), alongside forceScreenshot/browserGpuMode — armStaticDedup reads config instead of process.env. Default-on preserved (missing config → enabled). - Lossy aggregation: aggregateDedup now reports DISTINCT skip reasons (sorted, `|`-joined) across diverging unarmed workers instead of just the first. - discardWarmupCapture: also snapshot/restore staticDedupCount and lastFrameBuffer so a warmup capture can't leak a phantom reuse or a stale buffer anchor into the real summary. - Convention: perfSummary-dedup.test builds its job via createRenderJob instead of `as unknown as RenderJob`. - Docs: verification_budget added to skip-reason lists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5fb5153e4e
commit
7310223b66
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user