Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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> = {
Expand All @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions packages/engine/src/services/frameCapture-staticDedupIndex.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading