mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
+16
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user