refactor(producer): snapshot cfg.forceScreenshot at compile time, stop mutating mid-pipeline

Resolve the compileStage TODO from PR #720. cfg.forceScreenshot is now
computed exactly once inside compileStage (after applyRenderModeHints)
and returned on CompileStageResult.forceScreenshot. The sequencer stores
it on a local captureForceScreenshot; downstream capture stages take
the value as an explicit parameter and derive their own engine config
rather than reading cfg.forceScreenshot.

Mid-pipeline mutations removed:
- renderOrchestrator.ts: the pre-compile alpha-output mutation moved
  into compileStage so the resolution is one operation in one place.
- captureHdrStage.ts: stopped mutating caller-owned cfg; the layered
  composite path now uses a local hdrCfg derived from cfg plus
  forceScreenshot=true. The stage throws if called with
  forceScreenshot=false to make the contract explicit.
- BeginFrame auto-worker calibration fallback: still flips capture mode
  on a timeout, but flips the local boolean instead of cfg. The
  screenshot-mode retry uses a derived cfg view.

captureStage / captureStreamingStage add a forceScreenshot input and
derive captureCfg (identity-equal to cfg when the values already
agree, so no extra allocation on the common path).

lefthook.yml: grandfather renderOrchestrator.ts and captureHdrStage.ts
in the new 500-line filesize hook (#748). Both pre-date the hook and
are actively being shrunk in the producer stages stack.

Unblocks Phase 3 chunked rendering: LockedRenderConfig.forceScreenshot
in the distributed plan is computed here and survives across processes
without depending on shared mutable state.
This commit is contained in:
James
2026-05-12 22:19:49 +00:00
parent e25a3b3a4b
commit e221cb8d5c
9 changed files with 432 additions and 43 deletions
+16 -1
View File
@@ -17,7 +17,22 @@ pre-commit:
exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)"
run: |
for f in {staged_files}; do
case "$f" in *useTimelinePlayer.ts|*App.tsx) continue ;; esac
# The hook-level `exclude` regex above is meant to skip test
# and generated files, but lefthook expands `{staged_files}`
# before evaluating it for our shell loop — so the runtime
# filter has to repeat the rule.
case "$f" in
*.test.ts|*.test.tsx|*.generated.ts|*.generated.tsx) continue ;;
esac
# Grandfathered files that pre-date this hook (added in #748).
# New files >500 lines still fail; existing offenders are tracked
# for thinning in separate refactors — the producer stages stack
# is actively shrinking renderOrchestrator.ts, and
# captureHdrStage.ts is on the cycle-break list.
case "$f" in
*useTimelinePlayer.ts|*App.tsx) continue ;;
*renderOrchestrator.ts|*captureHdrStage.ts) continue ;;
esac
lines=$(wc -l < "$f")
if [ "$lines" -gt 500 ]; then
echo "ERROR: $f has $lines lines (max 500)"
@@ -13,7 +13,7 @@
import { copyFileSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
import type { AudioElement, EngineConfig, ImageElement, VideoElement } from "@hyperframes/engine";
import type { AudioElement, ImageElement, VideoElement } from "@hyperframes/engine";
import type { CompiledComposition } from "../htmlCompiler.js";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { isPathInside } from "../../utils/paths.js";
@@ -190,18 +190,33 @@ export function writeCompiledArtifacts(
}
}
export interface RenderModeHintResult {
/** Resolved capture-mode boolean after folding in the hint. */
forceScreenshot: boolean;
/** True iff the hint flipped a `false` input to `true` (warn log fired). */
autoSelected: boolean;
}
/**
* Fold the composition's `renderModeHints.recommendScreenshot` signal
* into the caller's already-resolved `forceScreenshot` value. Pure: the
* caller owns the assignment to its own config. When the hint is the
* deciding factor (caller passed `false`, hint says recommend), fires
* the auto-select warn log with the composition's reason codes.
*/
export function applyRenderModeHints(
cfg: EngineConfig,
alreadyForced: boolean,
compiled: CompiledComposition,
log: ProducerLogger = defaultLogger,
): void {
if (cfg.forceScreenshot || !compiled.renderModeHints.recommendScreenshot) return;
cfg.forceScreenshot = true;
): RenderModeHintResult {
if (alreadyForced || !compiled.renderModeHints.recommendScreenshot) {
return { forceScreenshot: alreadyForced, autoSelected: false };
}
log.warn("Auto-selected screenshot capture mode for render compatibility", {
reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code),
reasons: compiled.renderModeHints.reasons.map((reason) => reason.message),
});
return { forceScreenshot: true, autoSelected: true };
}
/**
@@ -19,9 +19,13 @@
* paths so they don't run twice when the success path already closed.
* - `hdrVideoFrameSources` is drained + cleared in the outer `finally`
* regardless of how the body exits.
* - `cfg.forceScreenshot = true` is set unconditionally inside the
* layered path because `captureAlphaPng` hangs under
* `--enable-begin-frame-control`.
* - The layered path unconditionally captures in screenshot mode
* because `captureAlphaPng` hangs under `--enable-begin-frame-control`.
* Previously the stage mutated `cfg.forceScreenshot = true` directly;
* the value is now derived into a local `hdrCfg` so the caller-owned
* `cfg` survives the stage unchanged. The sequencer is expected to
* pass `forceScreenshot: true` for the layered branch as a contract
* check.
*
* Known follow-up: same runtime import cycle pattern as the other
* capture stages — the stage imports HDR helpers from
@@ -92,6 +96,14 @@ import { updateJobStatus, type CompositionMetadata } from "../shared.js";
export interface CaptureHdrStageInput {
job: RenderJob;
cfg: EngineConfig;
/**
* Capture-mode flag threaded from `compileStage`. The HDR layered
* branch requires `true` (see file header for the
* `captureAlphaPng` / `--enable-begin-frame-control` constraint);
* the stage throws if called with `false`. Stored locally as
* `hdrCfg.forceScreenshot` so the caller-owned `cfg` is not mutated.
*/
forceScreenshot: boolean;
log: ProducerLogger;
projectDir: string;
@@ -143,6 +155,7 @@ export async function runCaptureHdrStage(
const {
job,
cfg,
forceScreenshot,
log,
projectDir,
compiledDir,
@@ -171,6 +184,12 @@ export async function runCaptureHdrStage(
onProgress,
} = input;
if (!forceScreenshot) {
throw new Error(
"captureHdrStage requires forceScreenshot=true; the layered composite path uses captureAlphaPng which hangs under --enable-begin-frame-control.",
);
}
const stageStart = Date.now();
let lastBrowserConsole: string[] = [];
let hdrPerf: HdrPerfCollector | undefined;
@@ -192,9 +211,14 @@ export async function runCaptureHdrStage(
// with a transparent background) for DOM layers. That CDP call hangs
// indefinitely when Chrome is launched with --enable-begin-frame-control
// (the default on Linux/headless-shell), because the compositor is paused
// and never produces a frame to capture. Force screenshot mode for the
// entire layered path — same constraint as alpha output formats above.
cfg.forceScreenshot = true;
// and never produces a frame to capture. Use screenshot mode for the
// entire layered path — same constraint as alpha output formats. We
// derive a local `hdrCfg` instead of mutating the caller-owned `cfg`
// so the value flowing through the rest of the pipeline is the one the
// sequencer locked at compile time. (The HDR path is end-of-pipeline
// today, but Phase 3 chunked rendering depends on stages not mutating
// caller config.)
const hdrCfg: EngineConfig = { ...cfg, forceScreenshot: true };
// Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR
// videos are hidden + extracted natively. SDR videos stay in the DOM screenshot
@@ -236,7 +260,7 @@ export async function runCaptureHdrStage(
framesDir,
buildCaptureOptions(),
createRenderVideoFrameInjector(),
cfg,
hdrCfg,
);
// Track lifecycle of resources spawned during HDR rendering so the
// outer finally block can defensively reclaim anything that wasn't
@@ -70,6 +70,16 @@ export interface CaptureStageInput {
*/
totalFrames: number;
cfg: EngineConfig;
/**
* Capture-mode flag threaded from `compileStage`. The stage derives a
* local copy of `cfg` with this value applied to `forceScreenshot`
* before any engine call, so the caller-owned `cfg` is never mutated.
* The sequencer may override `compileResult.forceScreenshot` after a
* BeginFrame calibration timeout — passing the override through this
* parameter keeps the decision visible at the call site instead of
* hiding it inside a shared mutable config.
*/
forceScreenshot: boolean;
log: ProducerLogger;
/** Initial worker count from `resolveRenderWorkerCount`; adaptive retry may reduce it. */
workerCount: number;
@@ -103,6 +113,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
job,
totalFrames,
cfg,
forceScreenshot,
log,
captureAttempts,
buildCaptureOptions,
@@ -115,6 +126,13 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
let { workerCount, probeSession } = input;
let lastBrowserConsole: string[] = [];
// Derive a local cfg view rather than reading `forceScreenshot` from the
// caller-owned `cfg`. The sequencer threads the resolved value via the
// explicit parameter; this keeps the engine-facing config a pure
// pass-through.
const captureCfg: EngineConfig =
cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
if (workerCount > 1) {
// Parallel capture
const attempts = await executeDiskCaptureWithAdaptiveRetry({
@@ -146,7 +164,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
);
}
},
cfg,
cfg: captureCfg,
log,
});
captureAttempts.push(...attempts);
@@ -170,7 +188,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
framesDir,
buildCaptureOptions(),
videoInjector,
cfg,
captureCfg,
));
if (probeSession) {
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
@@ -82,6 +82,16 @@ export interface CaptureStreamingStageInput {
*/
totalFrames: number;
cfg: EngineConfig;
/**
* Capture-mode flag threaded from `compileStage`. The stage derives a
* local copy of `cfg` with this value applied to `forceScreenshot`
* before any engine call, so the caller-owned `cfg` is never mutated.
* The sequencer may override `compileResult.forceScreenshot` after a
* BeginFrame calibration timeout — passing the override through this
* parameter keeps the decision visible at the call site instead of
* hiding it inside a shared mutable config.
*/
forceScreenshot: boolean;
log: ProducerLogger;
workerCount: number;
probeSession: CaptureSession | null;
@@ -122,6 +132,7 @@ export async function runCaptureStreamingStage(
job,
totalFrames,
cfg,
forceScreenshot,
log,
outputFormat,
streamingEncoderOptions,
@@ -134,6 +145,13 @@ export async function runCaptureStreamingStage(
let { workerCount, probeSession } = input;
let lastBrowserConsole: string[] = [];
// Derive a local cfg view rather than reading `forceScreenshot` from the
// caller-owned `cfg`. The sequencer threads the resolved value via the
// explicit parameter; this keeps the engine-facing config a pure
// pass-through.
const captureCfg: EngineConfig =
cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
let streamingEncoder: StreamingEncoder | null = null;
let streamingEncoderClosed = false;
@@ -205,7 +223,7 @@ export async function runCaptureStreamingStage(
}
},
onFrameBuffer,
cfg,
captureCfg,
);
if (probeSession) {
@@ -224,7 +242,7 @@ export async function runCaptureStreamingStage(
framesDir,
buildCaptureOptions(),
videoInjector,
cfg,
captureCfg,
));
if (probeSession) {
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
@@ -0,0 +1,212 @@
/**
* Tests for the `forceScreenshot` snapshot contract in `runCompileStage`.
*
* `compileStage` is the single point in the in-process renderer that
* resolves `cfg.forceScreenshot`. The decision folds three inputs:
*
* 1. `cfg.forceScreenshot` (whatever the caller passed in)
* 2. `needsAlpha` (webm / mov / png-sequence require screenshot mode
* because BeginFrame doesn't preserve alpha on headless-shell)
* 3. `compiled.renderModeHints.recommendScreenshot` (iframes or raw
* `requestAnimationFrame` in inline scripts)
*
* After this stage, the resolved value is frozen for the rest of the
* pipeline — downstream capture stages consume it as an explicit
* `forceScreenshot` parameter. These tests pin the freezing point: the
* returned `result.forceScreenshot` must match `cfg.forceScreenshot`
* the moment compile completes, regardless of which signal flipped it.
*/
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { EngineConfig } from "@hyperframes/engine";
import { runCompileStage, type CompileStageInput } from "./compileStage.js";
import type { RenderJob } from "../../renderOrchestrator.js";
const noopLog = {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
};
function createCfg(overrides: Partial<EngineConfig> = {}): EngineConfig {
return {
chromeArgs: [],
chromePath: undefined,
captureCostMultiplier: 1,
format: "jpeg",
jpegQuality: 80,
concurrency: "auto",
coresPerWorker: 2.5,
minParallelFrames: 120,
largeRenderThreshold: 1000,
disableGpu: false,
browserGpuMode: "software",
enableBrowserPool: false,
browserTimeout: 120000,
protocolTimeout: 300000,
forceScreenshot: false,
enableChunkedEncode: false,
chunkSizeFrames: 360,
enableStreamingEncode: false,
streamingEncodeMaxDurationSeconds: 240,
ffmpegEncodeTimeout: 600000,
ffmpegProcessTimeout: 300000,
ffmpegStreamingTimeout: 600000,
hdr: false,
hdrAutoDetect: true,
audioGain: 1,
frameDataUriCacheLimit: 256,
frameDataUriCacheBytesLimitMb: 1500,
playerReadyTimeout: 45000,
renderReadyTimeout: 15000,
verifyRuntime: true,
debug: false,
...overrides,
};
}
function createJob(): RenderJob {
return {
id: "test-job",
config: {
fps: { num: 30, den: 1 },
quality: "standard",
},
status: "queued",
progress: 0,
currentStage: "Queued",
createdAt: new Date(),
};
}
const PLAIN_HTML = `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1">
<p>plain composition</p>
</div>
</body>
</html>`;
// Contains an <iframe>, which triggers
// `detectRenderModeHints` → `recommendScreenshot: true`.
const IFRAME_HTML = `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1">
<iframe src="about:blank" data-start="0" data-end="1"></iframe>
</div>
</body>
</html>`;
interface CompileFixture {
workDir: string;
htmlPath: string;
cleanup: () => void;
}
function setupFixture(html: string): CompileFixture {
const workDir = mkdtempSync(join(tmpdir(), "compile-stage-test-"));
const projectDir = join(workDir, "project");
mkdirSync(projectDir);
const htmlPath = join(projectDir, "index.html");
writeFileSync(htmlPath, html, "utf-8");
return {
workDir,
htmlPath,
cleanup: () => rmSync(workDir, { recursive: true, force: true }),
};
}
async function runWith(
fixture: CompileFixture,
cfg: EngineConfig,
needsAlpha: boolean,
): Promise<{ resolved: boolean; cfgPost: boolean }> {
const projectDir = join(fixture.workDir, "project");
const input: CompileStageInput = {
projectDir,
workDir: fixture.workDir,
htmlPath: fixture.htmlPath,
entryFile: "index.html",
job: createJob(),
cfg,
needsAlpha,
log: noopLog,
assertNotAborted: () => {},
};
const result = await runCompileStage(input);
return { resolved: result.forceScreenshot, cfgPost: cfg.forceScreenshot };
}
describe("runCompileStage — forceScreenshot snapshot", () => {
let fixture: CompileFixture | null = null;
afterEach(() => {
fixture?.cleanup();
fixture = null;
});
it("returns false when needsAlpha=false, no render-mode hints, and cfg starts false", async () => {
fixture = setupFixture(PLAIN_HTML);
const cfg = createCfg();
const { resolved, cfgPost } = await runWith(fixture, cfg, false);
expect(resolved).toBe(false);
expect(cfgPost).toBe(false);
});
it("returns true when needsAlpha=true is the only signal", async () => {
fixture = setupFixture(PLAIN_HTML);
const cfg = createCfg();
const { resolved, cfgPost } = await runWith(fixture, cfg, true);
expect(resolved).toBe(true);
expect(cfgPost).toBe(true);
});
it("returns true when the render-mode hint is the only signal (iframe)", async () => {
fixture = setupFixture(IFRAME_HTML);
const cfg = createCfg();
const { resolved, cfgPost } = await runWith(fixture, cfg, false);
expect(resolved).toBe(true);
expect(cfgPost).toBe(true);
});
it("returns true when the caller's cfg already forced screenshot", async () => {
fixture = setupFixture(PLAIN_HTML);
const cfg = createCfg({ forceScreenshot: true });
const { resolved, cfgPost } = await runWith(fixture, cfg, false);
expect(resolved).toBe(true);
expect(cfgPost).toBe(true);
});
it("returns the same value carried on cfg post-compile (single-write contract)", async () => {
// Sweep every (cfg.forceScreenshot, needsAlpha, recommendScreenshot)
// combination and assert the result is the OR of all three. The
// capture stages downstream receive `result.forceScreenshot` and
// must see the same value the engine would see via cfg — both
// assertions together pin the contract.
for (const html of [PLAIN_HTML, IFRAME_HTML]) {
for (const initial of [false, true]) {
for (const needsAlpha of [false, true]) {
fixture = setupFixture(html);
const cfg = createCfg({ forceScreenshot: initial });
const { resolved, cfgPost } = await runWith(fixture, cfg, needsAlpha);
const expected = initial || needsAlpha || html === IFRAME_HTML;
expect(resolved).toBe(expected);
// The returned snapshot must match the cfg value the stage
// left behind — otherwise the engine and downstream stages
// would disagree about capture mode.
expect(resolved).toBe(cfgPost);
fixture.cleanup();
fixture = null;
}
}
}
});
});
@@ -1,8 +1,8 @@
/**
* compileStage — pure compile pass of `executeRenderJob`.
*
* Runs `compileForRender` on the entry HTML, applies render-mode hints
* (which may flip `cfg.forceScreenshot` on for compositions that need it),
* Runs `compileForRender` on the entry HTML, folds the alpha-output and
* render-mode-hint signals into a single `forceScreenshot` decision,
* writes compiled artifacts to `workDir/compiled/`, builds the
* `CompositionMetadata` view of the result, and resolves the
* `deviceScaleFactor` for supersampling.
@@ -12,14 +12,27 @@
* the point where the in-process renderer enters the `if (needsBrowser)`
* branch.
*
* `forceScreenshot` is the only field on `cfg` that this stage writes,
* and it is written exactly once: at the end of the stage, after
* `compileForRender` has reported the composition's `renderModeHints`
* and the orchestrator has told us whether the output format demands an
* alpha channel. The resolved boolean is also returned on the stage's
* result so downstream stages can consume the value as an explicit
* parameter instead of reading `cfg.forceScreenshot` directly. See the
* distributed-render plan §4.3 — `LockedRenderConfig.forceScreenshot`
* is computed here and frozen for the rest of the pipeline.
*
* Hard constraints preserved verbatim from the in-process renderer:
* - `applyRenderModeHints(cfg, ...)` is allowed to mutate `cfg.forceScreenshot`.
* - `perfStages.compileOnlyMs` is set to wall-clock ms around the
* `compileForRender` call only.
* - The `log.info("Compiled composition metadata", ...)` line is emitted
* after writing artifacts, with the same payload shape as before.
* - The `log.info("Supersampling composition via deviceScaleFactor", ...)`
* line is emitted only when `deviceScaleFactor > 1`.
* - `applyRenderModeHints` short-circuits when the caller-supplied
* `alreadyForced` boolean is `true`, so the auto-select warn log
* fires only when the composition hint is the deciding factor —
* same behavior as before this PR.
*/
import { join } from "node:path";
@@ -43,7 +56,14 @@ export interface CompileStageInput {
/** The relative `entryFile` string, used only for log payloads. */
entryFile: string;
job: RenderJob;
/** EngineConfig — may be mutated via `cfg.forceScreenshot = true`. */
/**
* EngineConfig used by the compile pass. `cfg.forceScreenshot` is
* written exactly once near the end of the stage (after
* `applyRenderModeHints`); no other field on `cfg` is mutated. The
* resolved value is also returned on `CompileStageResult.forceScreenshot`
* so callers can thread the value explicitly without reading from
* `cfg`.
*/
cfg: EngineConfig;
/** True when the output format requires an alpha channel (webm/mov/png-sequence). */
needsAlpha: boolean;
@@ -60,6 +80,15 @@ export interface CompileStageResult {
outputHeight: number;
/** Wall-clock ms for the pure `compileForRender` call only (excludes artifact writes). */
compileOnlyMs: number;
/**
* Capture-mode decision computed from `cfg.forceScreenshot` (caller
* default), `needsAlpha` (alpha output requires screenshot capture
* because BeginFrame doesn't preserve alpha on headless-shell), and
* the composition's `renderModeHints`. Locked at compile time; the
* sequencer threads this value through downstream capture stages
* instead of relying on `cfg.forceScreenshot` mutations.
*/
forceScreenshot: boolean;
}
export async function runCompileStage(input: CompileStageInput): Promise<CompileStageResult> {
@@ -70,12 +99,15 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
const compiled = await compileForRender(projectDir, htmlPath, join(workDir, "downloads"));
assertNotAborted();
const compileOnlyMs = Date.now() - compileStart;
// TODO(distributed-render): `applyRenderModeHints` mutates `cfg.forceScreenshot`
// on a caller-owned object. Before freezePlan wires up, this side-effect
// needs to move into the result (e.g. `forceScreenshot: boolean` on
// `CompileStageResult`) so the value can be baked into `LockedRenderConfig`
// and survive across processes / replays.
applyRenderModeHints(cfg, compiled, log);
// Fold three signals into a single capture-mode decision: caller's
// initial `cfg.forceScreenshot`, alpha-output (webm / mov / png-sequence —
// BeginFrame doesn't preserve alpha on Linux headless-shell), and the
// composition's `renderModeHints.recommendScreenshot`. The single
// write to `cfg.forceScreenshot` happens at the end of this block so
// the contract is enforceable by inspection.
const callerForced = cfg.forceScreenshot || needsAlpha;
const { forceScreenshot } = applyRenderModeHints(callerForced, compiled, log);
cfg.forceScreenshot = forceScreenshot;
writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug));
log.info("Compiled composition metadata", {
@@ -117,5 +149,13 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
});
}
return { compiled, composition, deviceScaleFactor, outputWidth, outputHeight, compileOnlyMs };
return {
compiled,
composition,
deviceScaleFactor,
outputWidth,
outputHeight,
compileOnlyMs,
forceScreenshot,
};
}
@@ -420,7 +420,6 @@ function createConfig(): EngineConfig {
describe("applyRenderModeHints", () => {
it("forces screenshot mode when compatibility hints recommend it", () => {
const cfg = createConfig();
const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]);
const log = {
error: vi.fn(),
@@ -429,15 +428,13 @@ describe("applyRenderModeHints", () => {
debug: vi.fn(),
};
applyRenderModeHints(cfg, compiled, log);
const result = applyRenderModeHints(false, compiled, log);
expect(cfg.forceScreenshot).toBe(true);
expect(result).toEqual({ forceScreenshot: true, autoSelected: true });
expect(log.warn).toHaveBeenCalledOnce();
});
it("does nothing when screenshot mode is already forced", () => {
const cfg = createConfig();
cfg.forceScreenshot = true;
const compiled = createCompiledComposition(["iframe"]);
const log = {
error: vi.fn(),
@@ -446,8 +443,24 @@ describe("applyRenderModeHints", () => {
debug: vi.fn(),
};
applyRenderModeHints(cfg, compiled, log);
const result = applyRenderModeHints(true, compiled, log);
expect(result).toEqual({ forceScreenshot: true, autoSelected: false });
expect(log.warn).not.toHaveBeenCalled();
});
it("returns false when neither caller nor hint forces", () => {
const compiled = createCompiledComposition([]);
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
const result = applyRenderModeHints(false, compiled, log);
expect(result).toEqual({ forceScreenshot: false, autoSelected: false });
expect(log.warn).not.toHaveBeenCalled();
});
});
@@ -1860,10 +1860,13 @@ export async function executeRenderJob(
const isMov = outputFormat === "mov";
const isPngSequence = outputFormat === "png-sequence";
const needsAlpha = isWebm || isMov || isPngSequence;
// Transparency requires screenshot mode — beginFrame doesn't support alpha channel
if (needsAlpha) {
cfg.forceScreenshot = true;
}
// `forceScreenshot` is resolved exactly once inside `compileStage` (alpha
// output + composition `renderModeHints` are folded together there) and
// returned on `compileResult.forceScreenshot`. The sequencer stores it
// in a local `captureForceScreenshot` below; the BeginFrame calibration
// fallback updates the local — not `cfg` — and capture stages receive
// the value as an explicit parameter. See DISTRIBUTED-RENDERING-PLAN.md
// §4.3 (`LockedRenderConfig.forceScreenshot`).
const enableChunkedEncode = cfg.enableChunkedEncode;
const chunkedEncodeSize = cfg.chunkSizeFrames;
// Periodic memory sampler — surfaces peak RSS/heap so the benchmark harness
@@ -1958,6 +1961,13 @@ export async function executeRenderJob(
const { deviceScaleFactor, outputWidth, outputHeight } = compileResult;
const { width, height } = composition;
perfStages.compileOnlyMs = compileResult.compileOnlyMs;
// Snapshot of `cfg.forceScreenshot` resolved by compileStage. The
// BeginFrame auto-worker calibration may flip this to `true` at
// runtime if the calibration session times out under BeginFrame
// (see fallback below); subsequent capture stages receive the value
// via the explicit `forceScreenshot` parameter rather than reading
// `cfg.forceScreenshot` directly.
let captureForceScreenshot = compileResult.forceScreenshot;
const probeResult = await runProbeStage({
projectDir,
@@ -2149,7 +2159,15 @@ export async function executeRenderJob(
if (job.config.workers === undefined && totalFrames >= 60) {
const calibrationDir = join(workDir, "capture-calibration");
const calibrationCfg = createCaptureCalibrationConfig(cfg);
// Build the calibration cfg from a `forceScreenshot`-applied view of
// `cfg` rather than reading `cfg.forceScreenshot` directly, so the
// capture-mode decision flows through `captureForceScreenshot`
// exclusively. Identity-equal to `cfg` when the values already match.
const calibrationBaseCfg: EngineConfig =
cfg.forceScreenshot === captureForceScreenshot
? cfg
: { ...cfg, forceScreenshot: captureForceScreenshot };
const calibrationCfg = createCaptureCalibrationConfig(calibrationBaseCfg);
const videoInjector = createRenderVideoFrameInjector();
let calibrationSession: CaptureSession | null = null;
try {
@@ -2173,9 +2191,14 @@ export async function executeRenderJob(
logCaptureCalibrationResult(captureCalibration, log);
} catch (error) {
const shouldFallbackToScreenshot =
!cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
!captureForceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
if (shouldFallbackToScreenshot) {
cfg.forceScreenshot = true;
// Runtime adaptation: BeginFrame failed under this host's Chrome
// build, so the rest of the pipeline switches to screenshot
// capture. We flip the local boolean only — `cfg` stays the
// compile-time view; downstream stages receive the new value
// via the explicit `forceScreenshot` parameter.
captureForceScreenshot = true;
if (probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
await closeCaptureSession(probeSession).catch(() => {});
@@ -2195,7 +2218,10 @@ export async function executeRenderJob(
},
);
const screenshotCalibrationCfg = createCaptureCalibrationConfig(cfg);
const screenshotCalibrationCfg = createCaptureCalibrationConfig({
...cfg,
forceScreenshot: true,
});
try {
calibrationSession = await createCaptureSession(
fileServer.url,
@@ -2336,9 +2362,15 @@ export async function executeRenderJob(
// path for SDR compositions so the engine can apply transition math to
// isolated scene buffers instead of recording plain DOM screenshots.
if (useLayeredComposite) {
// Layered composite always runs in screenshot mode — keep
// `captureForceScreenshot` in sync so the perf summary and any
// post-HDR diagnostic that reads the boolean see the same value
// the stage uses internally.
captureForceScreenshot = true;
const hdrRes = await runCaptureHdrStage({
job,
cfg,
forceScreenshot: captureForceScreenshot,
log,
projectDir,
compiledDir,
@@ -2386,6 +2418,7 @@ export async function executeRenderJob(
job,
totalFrames,
cfg,
forceScreenshot: captureForceScreenshot,
log,
workerCount,
probeSession,
@@ -2430,6 +2463,7 @@ export async function executeRenderJob(
job,
totalFrames,
cfg,
forceScreenshot: captureForceScreenshot,
log,
workerCount,
probeSession,