Files
hyperframes/packages/producer/src/services/renderOrchestrator.ts
T
Miguel Ángel 5917c0382d fix(producer): pass resolved engine config through every encode path (#1371)
encodeFramesFromDir was called with 5 of its 6 args, dropping the config
param — the encode timeout always fell back to the hardcoded 600s default
and FFMPEG_ENCODE_TIMEOUT_MS was silently ignored, so any encode over
600s wall time was deterministically SIGTERM-killed. Resolve the engine
config once in the encode stage and pass it to the non-chunked, chunked,
and GIF paths. The chunked per-chunk encodes previously had no timeout at
all; they now honor the same config value.

Review follow-ups: the chunked path's final concat spawn gains the same
config-driven timeout (it previously had none); every encode-timeout
kill now appends 'FFmpeg killed after exceeding ffmpegEncodeTimeout
(N ms)' to the failure instead of surfacing a bare exit-255; and the
orchestrator threads its already-resolved config into the encode stage
via an optional EncodeStageInput.engineConfig field (direct callers and
distributed chunks keep the producerConfig ?? resolveConfig() fallback).
The encode-timeout tests run against a mocked child_process spawn with
fake timers, removing the real-ffmpeg dependency that made the previous
default-timeout test environment-fragile in CI.

Fixes #1348
2026-06-12 12:21:35 -04:00

2475 lines
88 KiB
TypeScript

// fallow-ignore-file unused-export unused-type circular-dependency code-duplication complexity
/**
* Render Orchestrator Service
*
* `executeRenderJob` is the in-process entry point that composes the
* pipeline's six stages. Each stage lives in its own module under
* `./render/stages/` so the pure-function primitives can be reused by
* the distributed render path without dragging the orchestrator's
* cleanup and observability scaffolding with them.
*
* Stage 1 compile → services/render/stages/compileStage.ts
* Stage 1b probe → services/render/stages/probeStage.ts
* (browser-driven duration discovery + media reconciliation;
* grouped with Stage 1 in the perf summary)
* Stage 2 extract videos → services/render/stages/extractVideosStage.ts
* Stage 3 audio → services/render/stages/audioStage.ts
* Stage 4 capture → services/render/stages/captureStage.ts
* services/render/stages/captureStreamingStage.ts
* services/render/stages/captureHdrStage.ts
* Stage 5 encode → services/render/stages/encodeStage.ts
* Stage 6 assemble → services/render/stages/assembleStage.ts
*
* Resources spawned by stages (file server, capture sessions, streaming
* encoders, raw HDR frame files) are tracked in the orchestrator's
* `try/finally` so a stage throwing mid-pipeline doesn't leak Chrome
* processes or ffmpeg subprocesses.
*
* Heavy observability: every stage records timing into `perfStages`,
* errors carry full context, and failures produce a diagnostic summary
* (browser console tail, memory peaks, capture attempts, HDR
* diagnostics).
*/
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readSync,
closeSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
copyFileSync,
appendFileSync,
} from "fs";
import { parseHTML } from "linkedom";
import { type CanvasResolution, type Fps, type FpsInput, toFps } from "@hyperframes/core";
import {
type EngineConfig,
resolveConfig,
type ExtractionResult,
type ExtractionPhaseBreakdown,
type HdrTransfer,
closeCaptureSession,
type CaptureOptions,
type CaptureVideoMetadataHint,
type CaptureSession,
type BeforeCaptureHook,
createVideoFrameInjector,
getEncoderPreset,
distributeFrames,
executeParallelCapture,
mergeWorkerFrames,
type ParallelProgress,
type WorkerTask,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
decodePng,
blitRgba8OverRgb48le,
blitRgb48leRegion,
groupIntoLayers,
blitRgb48leAffine,
parseTransformMatrix,
convertTransfer,
type ElementStackingInfo,
type HfTransitionMeta,
getSystemTotalMb,
LOW_MEMORY_TOTAL_MB_THRESHOLD,
assertConfiguredFfmpegBinariesExist,
} from "@hyperframes/engine";
import { join, dirname, resolve } from "path";
import { randomUUID } from "crypto";
import { fileURLToPath } from "url";
import {
createFileServer,
type FileServerHandle,
HF_PAGE_SIDE_COMPOSITING_STUB,
VIRTUAL_TIME_SHIM,
} from "./fileServer.js";
import { defaultLogger, type ProducerLogger } from "../logger.js";
import { type HdrImageTransferCache } from "./hdrImageTransferCache.js";
import {
createCompiledFrameSrcResolver,
createMemorySampler,
type MemorySampler,
updateJobStatus,
writeFileExclusiveSync,
} from "./render/shared.js";
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
import { buildRenderPerfSummary } from "./render/perfSummary.js";
import { getCaptureStageBrowserConsole } from "./render/captureStageError.js";
import {
type CaptureCalibrationSample,
type CaptureCostEstimate,
resolveRenderWorkerCount,
runCaptureCalibration,
} from "./render/captureCost.js";
import {
computeCompositionObservabilityHash,
RenderObservabilityRecorder,
observeRenderStage,
type RenderCaptureObservability,
type RenderExtractionObservability,
type RenderObservabilitySummary,
} from "./render/observability.js";
import { type HdrPerfCollector, type HdrPerfSummary, addHdrTiming } from "./render/hdrPerf.js";
import { runCompileStage } from "./render/stages/compileStage.js";
import { runProbeStage } from "./render/stages/probeStage.js";
import { runExtractVideosStage } from "./render/stages/extractVideosStage.js";
import { runAudioStage } from "./render/stages/audioStage.js";
import { runCaptureStage } from "./render/stages/captureStage.js";
import { runCaptureStreamingStage } from "./render/stages/captureStreamingStage.js";
import { runCaptureHdrStage } from "./render/stages/captureHdrStage.js";
import { runEncodeStage } from "./render/stages/encodeStage.js";
import { runAssembleStage } from "./render/stages/assembleStage.js";
function sampleDirectoryBytes(dir: string): number {
let total = 0;
const stack: string[] = [dir];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
let entries: string[] = [];
try {
entries = readdirSync(current);
} catch {
continue;
}
for (const name of entries) {
const full = join(current, name);
try {
const st = statSync(full);
if (st.isDirectory()) {
stack.push(full);
} else if (st.isFile()) {
total += st.size;
}
} catch {
// ignore
}
}
}
return total;
}
// fallow-ignore-next-line complexity
function summarizeExtractionObservability(
extractionResult: ExtractionResult | null,
videoCount: number,
): RenderExtractionObservability {
const extracted = extractionResult?.extracted ?? [];
const totalFramesExtracted = extractionResult?.totalFramesExtracted ?? 0;
const maxFramesPerVideo = extracted.reduce((max, item) => Math.max(max, item.totalFrames), 0);
const phaseBreakdown = extractionResult?.phaseBreakdown;
return {
videoCount,
extractedVideoCount: extracted.length,
totalFramesExtracted,
maxFramesPerVideo,
avgFramesPerExtractedVideo:
extracted.length > 0 ? Math.round(totalFramesExtracted / extracted.length) : undefined,
vfrProbeMs: phaseBreakdown?.vfrProbeMs,
vfrPreflightMs: phaseBreakdown?.vfrPreflightMs,
vfrPreflightCount: phaseBreakdown?.vfrPreflightCount,
cacheHits: phaseBreakdown?.cacheHits,
cacheMisses: phaseBreakdown?.cacheMisses,
};
}
// Diagnostic helpers used by the HDR layered compositor when KEEP_TEMP=1
// is set. They are pure (capture no state), so we keep them at module scope
// to avoid re-creating closures per frame and to make them callable from
// any future composite path that needs to log non-zero pixel counts.
function countNonZeroAlpha(rgba: Uint8Array): number {
let n = 0;
for (let p = 3; p < rgba.length; p += 4) {
if (rgba[p] !== 0) n++;
}
return n;
}
function countNonZeroRgb48(buf: Uint8Array): number {
let n = 0;
for (let p = 0; p < buf.length; p += 6) {
if (
buf[p] !== 0 ||
buf[p + 1] !== 0 ||
buf[p + 2] !== 0 ||
buf[p + 3] !== 0 ||
buf[p + 4] !== 0 ||
buf[p + 5] !== 0
)
n++;
}
return n;
}
/**
* Metadata for a shader transition between two scenes, extracted from
* `window.__hf.transitions`. Re-exported from the engine so the producer
* shares the contract with composition runtime code.
*/
export type HdrTransitionMeta = HfTransitionMeta;
/** Pre-computed frame range for an active transition. */
export interface TransitionRange extends HdrTransitionMeta {
startFrame: number;
endFrame: number;
}
export type RenderStatus =
| "queued"
| "preprocessing"
| "rendering"
| "encoding"
| "assembling"
| "complete"
| "failed"
| "cancelled";
export interface RenderConfig {
/**
* Frame rate as an exact rational. Integer fps is `{ num: 30, den: 1 }`;
* NTSC is `{ num: 30000, den: 1001 }`. This shape lets the orchestrator
* pass the exact rational through to FFmpeg's `-r` / `-framerate` flags
* without a decimal round-trip — see `fpsToFfmpegArg` in @hyperframes/core.
*
* Use `fpsToNumber(config.fps)` at any site that needs a `number` for
* arithmetic (frame-index → time, telemetry, frame-interval ms). Decimal
* precision at our scales is more than sufficient.
*/
fps: Fps;
quality: "draft" | "standard" | "high";
/**
* Output container format. Defaults to `"mp4"`; existing renders are
* unaffected unless this field is set explicitly.
*
* - `"mp4"`: H.264 by default, or H.265 + HDR10 when HDR auto-detect
* engages or `hdrMode: "force-hdr"` is set. Opaque. The
* default streaming/social deliverable. Faststart is applied so the
* `moov` atom sits at the file start and the file plays from a
* partial download.
* - `"webm"`: VP9 + `yuva420p` pixel format → **true alpha channel**, no
* chroma key. Plays in Chrome, Edge, and Firefox; Safari support for
* alpha-WebM is incomplete. Use this when the output should drop
* straight into a `<video>` over a colored background on the web.
* Audio is muxed as Opus.
* - `"mov"`: ProRes 4444 + `yuva444p10le` → **true alpha channel +
* 10-bit color**. Sized for editor ingest (Premiere, Final Cut Pro,
* DaVinci Resolve), not direct web playback. Audio is muxed as AAC.
* - `"gif"`: animated GIF encoded from captured frames with a two-pass
* FFmpeg palette (`palettegen` + `paletteuse`). Use for PRs, READMEs,
* and docs where inline autoplay matters more than file size. No audio
* stream and no alpha channel.
* - `"png-sequence"`: a directory of zero-padded RGBA PNGs
* (`frame_000001.png` …). Lossless alpha, largest on disk, no muxed
* audio (an `audio.aac` sidecar is written alongside the PNGs when
* the composition has audio elements). Use for After Effects / Nuke
* / Fusion ingest, or when frames need post-processing before
* encoding. `outputPath` is treated as a directory; it is created if
* it doesn't exist.
*
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`) automatically
* forces screenshot capture (Chrome's BeginFrame compositor does not
* preserve alpha on Linux headless-shell) and disables HDR — HDR +
* alpha is not a supported combination, a warning is logged and HDR
* falls back to SDR. The transparent-background CSS is injected by
* the engine's `initTransparentBackground` helper, so authors should
* not paint a fullscreen `body` / `#root` background in their
* compositions when targeting alpha output.
*/
format?: "mp4" | "webm" | "mov" | "png-sequence" | "gif";
/** GIF Netscape loop count. 0 means infinite looping. Only used with `format: "gif"`. */
gifLoop?: number;
workers?: number;
useGpu?: boolean;
debug?: boolean;
/** Entry HTML file relative to projectDir. Defaults to "index.html". */
entryFile?: string;
/** Full producer config. When provided, env vars are not read. */
producerConfig?: EngineConfig;
/** Custom logger. Defaults to console-based defaultLogger. */
logger?: ProducerLogger;
/** Override CRF for the video encoder. Mutually exclusive with `videoBitrate`. */
crf?: number;
/** Target video bitrate (e.g. "10M"). Mutually exclusive with `crf`. */
videoBitrate?: string;
/** HDR rendering mode.
* - `auto` (default): probe sources; enable HDR if any HDR content is found.
* - `force-hdr`: enable HDR even on SDR-only compositions (falls back to HLG transfer).
* - `force-sdr`: skip probing entirely; always render SDR.
*/
hdrMode?: "auto" | "force-hdr" | "force-sdr";
/**
* Render-time variable overrides for the composition. Injected as
* `window.__hfVariables` before any page script runs and consumed by the
* runtime helper `getVariables()`, which merges them over the declared
* defaults from `<html data-composition-variables="...">`.
*
* Populated by the CLI from `--variables '<json>'` /
* `--variables-file <path>`. Must be a JSON-serializable plain object.
*/
variables?: Record<string, unknown>;
/**
* Override the output resolution via Chrome `deviceScaleFactor` (DPR).
* The composition's authored dimensions are unchanged. See
* {@link resolveDeviceScaleFactor} for the integer-scale, aspect, and
* HDR constraints.
*/
outputResolution?: CanvasResolution;
}
export interface RenderPerfSummary {
renderId: string;
totalElapsedMs: number;
fps: number;
quality: string;
workers: number;
chunkedEncode: boolean;
chunkSizeFrames: number | null;
compositionDurationSeconds: number;
totalFrames: number;
resolution: { width: number; height: number };
videoCount: number;
audioCount: number;
stages: Record<string, number>;
/** Per-phase breakdown of the Phase 2 video extraction (resolve, HDR probe, HDR preflight, VFR probe/preflight, per-video extract). Undefined when the composition has no videos. */
videoExtractBreakdown?: ExtractionPhaseBreakdown;
/** Bytes on disk in the render's workDir at assembly time (sampled before cleanup). Lets callers correlate peak temp usage with render duration. */
tmpPeakBytes?: number;
captureAvgMs?: number;
capturePeakMs?: number;
captureCalibration?: {
sampledFrames: number[];
p95Ms?: number;
multiplier: number;
reasons: string[];
};
captureAttempts?: CaptureAttemptSummary[];
observability?: RenderObservabilitySummary;
/**
* Peak resident set size (RSS) observed during the render, in MiB.
*
* Sampled every 250ms by a process-wide poller; surfaces gross memory
* regressions (e.g. unbounded image-cache growth) that wall-clock numbers
* miss. Optional because callers can serialize older `RenderPerfSummary`
* shapes back into this type.
*/
peakRssMb?: number;
/**
* Peak V8 heap used observed during the render, in MiB.
*
* Useful as a finer-grained complement to {@link peakRssMb} — RSS includes
* native ffmpeg/Chrome allocations, while heapUsed isolates JS-object growth
* inside the orchestrator. Optional for the same back-compat reason.
*/
peakHeapUsedMb?: number;
hdrDiagnostics?: HdrDiagnostics;
hdrPerf?: HdrPerfSummary;
}
export interface HdrDiagnostics {
videoExtractionFailures: number;
imageDecodeFailures: number;
}
// HDR-pipeline perf collector lives in `./render/hdrPerf.ts`. Re-exported
// from this module so the existing `import { ... } from "./renderOrchestrator"`
// callers keep working unchanged.
export {
type HdrPerfCollector,
type HdrPerfSummary,
type HdrPerfTimingKey,
addHdrTiming,
createHdrPerfCollector,
finalizeHdrPerf,
} from "./render/hdrPerf.js";
// Capture-cost calibration helpers and constants live in `./render/captureCost.ts`.
// Re-exported here for backwards compatibility with existing
// `import { ... } from "./renderOrchestrator"` callers.
export {
type CaptureCalibrationOutcome,
type CaptureCalibrationSample,
type CaptureCostEstimate,
createCaptureCalibrationConfig,
createFailedCaptureCalibrationEstimate,
estimateCaptureCostMultiplier,
estimateMeasuredCaptureCostMultiplier,
logCaptureCalibrationResult,
measureCaptureCostFromSession,
resolveRenderWorkerCount,
runCaptureCalibration,
selectCaptureCalibrationFrames,
} from "./render/captureCost.js";
export interface FrameRange {
startFrame: number;
endFrame: number;
}
export interface CaptureAttemptSummary {
attempt: number;
workers: number;
frameCount: number;
reason: "initial" | "retry";
}
export interface RenderJob {
id: string;
config: RenderConfig;
status: RenderStatus;
progress: number;
currentStage: string;
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
error?: string;
outputPath?: string;
duration?: number;
totalFrames?: number;
framesRendered?: number;
perfSummary?: RenderPerfSummary;
failedStage?: string;
errorDetails?: {
message: string;
stack?: string;
elapsedMs: number;
freeMemoryMB: number;
browserConsoleTail?: string[];
perfStages?: Record<string, number>;
hdrDiagnostics?: HdrDiagnostics;
observability?: RenderObservabilitySummary;
};
}
export type ProgressCallback = (job: RenderJob, message: string) => void;
export class RenderCancelledError extends Error {
reason: "user_cancelled" | "timeout" | "aborted";
constructor(
message: string = "render_cancelled",
reason: "user_cancelled" | "timeout" | "aborted" = "aborted",
) {
super(message);
this.name = "RenderCancelledError";
this.reason = reason;
}
}
function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger): () => void {
const origLog = console.log;
const origError = console.error;
const origWarn = console.warn;
const write = (prefix: string, args: unknown[]) => {
const ts = new Date().toISOString();
const line = `[${ts}] ${prefix} ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`;
try {
appendFileSync(logPath, line);
} catch (err) {
log.debug("Debug log write failed", {
logPath,
error: err instanceof Error ? err.message : String(err),
});
}
};
console.log = (...args: unknown[]) => {
write("LOG", args);
origLog(...args);
};
console.error = (...args: unknown[]) => {
write("ERR", args);
origError(...args);
};
console.warn = (...args: unknown[]) => {
write("WRN", args);
origWarn(...args);
};
return () => {
console.log = origLog;
console.error = origError;
console.warn = origWarn;
};
}
// Compile-output helpers (`createCompiledFrameSrcResolver`,
// `materializeExtractedFramesForCompiledDir`) live in `./render/shared.ts`.
// Re-exported from this module for backwards compatibility with existing
// `import { ... } from "./renderOrchestrator"` call sites.
export {
createCompiledFrameSrcResolver,
materializeExtractedFramesForCompiledDir,
} from "./render/shared.js";
export function collectVideoReadinessSkipIds(
nativeHdrVideoIds: ReadonlySet<string>,
extractedVideos: readonly ExtractedVideoReadinessInput[],
): string[] {
return Array.from(
new Set([
...nativeHdrVideoIds,
...extractedVideos
.filter((video) => hasUsableVideoDimensions(video.metadata))
.map((video) => video.videoId),
]),
).sort();
}
interface ExtractedVideoReadinessInput {
videoId: string;
metadata: {
width: number;
height: number;
};
}
function hasUsableVideoDimensions(metadata: ExtractedVideoReadinessInput["metadata"]) {
return (
Number.isFinite(metadata.width) &&
Number.isFinite(metadata.height) &&
metadata.width > 0 &&
metadata.height > 0
);
}
export function collectVideoMetadataHints(
extractedVideos: readonly ExtractedVideoReadinessInput[],
): CaptureVideoMetadataHint[] {
return extractedVideos
.filter((video) => hasUsableVideoDimensions(video.metadata))
.map((video) => ({
id: video.videoId,
width: video.metadata.width,
height: video.metadata.height,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
export function findMissingFrameRanges(
totalFrames: number,
framesDir: string,
frameExt: "jpg" | "png",
): FrameRange[] {
const ranges: FrameRange[] = [];
let rangeStart: number | null = null;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
const missing = !existsSync(framePath);
if (missing && rangeStart === null) {
rangeStart = frameIndex;
} else if (!missing && rangeStart !== null) {
ranges.push({ startFrame: rangeStart, endFrame: frameIndex });
rangeStart = null;
}
}
if (rangeStart !== null) {
ranges.push({ startFrame: rangeStart, endFrame: totalFrames });
}
return ranges;
}
export function buildMissingFrameRetryBatches(
ranges: FrameRange[],
maxWorkers: number,
workDir: string,
attempt: number,
rangeStart: number = 0,
): WorkerTask[][] {
const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
const batches: WorkerTask[][] = [];
// `ranges` are 0-indexed within the chunk's frame range (or full timeline
// when `rangeStart === 0`); translate to absolute composition indices so
// `WorkerTask`'s per-frame time math lands on the page's actual virtual
// clock, and propagate `outputFrameOffset` so the retry captures back at
// the same local file name `findMissingFrameRanges` was looking for.
for (let i = 0; i < ranges.length; i += workersPerBatch) {
const batchIndex = batches.length;
const batch = ranges.slice(i, i + workersPerBatch).map((range, workerId) => ({
workerId,
startFrame: rangeStart + range.startFrame,
endFrame: rangeStart + range.endFrame,
outputDir: join(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
outputFrameOffset: rangeStart,
}));
batches.push(batch);
}
return batches;
}
export function getNextRetryWorkerCount(currentWorkers: number): number {
return Math.max(1, Math.floor(currentWorkers / 2));
}
export function isRecoverableParallelCaptureError(error: unknown): boolean {
const message = normalizeErrorMessage(error);
return (
message.includes("[Parallel] Capture failed") &&
/Runtime\.callFunctionOn timed out|HeadlessExperimental\.beginFrame timed out|Waiting failed|timeout exceeded|timed out|Navigation timeout|Protocol error|Target closed/i.test(
message,
)
);
}
// `shouldFallbackToScreenshotAfterCalibrationError` lives in
// `./render/captureCost.ts` alongside the calibration runner. Re-exported
// here for backwards compatibility with existing
// `import { ... } from "./renderOrchestrator"` callers.
export { shouldFallbackToScreenshotAfterCalibrationError } from "./render/captureCost.js";
function countCapturedFrames(
totalFrames: number,
framesDir: string,
frameExt: "jpg" | "png",
): number {
let captured = 0;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
if (existsSync(framePath)) captured++;
}
return captured;
}
function countFrameRanges(ranges: FrameRange[]): number {
return ranges.reduce((sum, range) => sum + (range.endFrame - range.startFrame), 0);
}
export async function executeDiskCaptureWithAdaptiveRetry(options: {
serverUrl: string;
workDir: string;
framesDir: string;
totalFrames: number;
initialWorkerCount: number;
allowRetry: boolean;
frameExt: "jpg" | "png";
captureOptions: CaptureOptions;
createBeforeCaptureHook: () => BeforeCaptureHook | null;
abortSignal?: AbortSignal;
onProgress?: (progress: ParallelProgress) => void;
cfg: EngineConfig;
log: ProducerLogger;
/**
* Forwarded to each `WorkerTask`'s `outputFrameOffset` and to the
* `buildMissingFrameRetryBatches` translation. Default 0 (in-process
* contract: `[0, totalFrames)`). See `WorkerTask.outputFrameOffset`.
*/
frameRangeStart?: number;
}): Promise<CaptureAttemptSummary[]> {
const attempts: CaptureAttemptSummary[] = [];
let currentWorkers = options.initialWorkerCount;
let missingRanges: FrameRange[] | null = null;
let attempt = 0;
const rangeStart = options.frameRangeStart ?? 0;
while (true) {
const frameCount = missingRanges ? countFrameRanges(missingRanges) : options.totalFrames;
attempts.push({
attempt,
workers: currentWorkers,
frameCount,
reason: attempt === 0 ? "initial" : "retry",
});
const attemptWorkDir = join(options.workDir, `capture-attempt-${attempt}`);
const batches = missingRanges
? buildMissingFrameRetryBatches(
missingRanges,
currentWorkers,
attemptWorkDir,
attempt,
rangeStart,
)
: [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir, rangeStart)];
try {
for (const tasks of batches) {
const capturedBeforeBatch = countCapturedFrames(
options.totalFrames,
options.framesDir,
options.frameExt,
);
try {
await executeParallelCapture(
options.serverUrl,
attemptWorkDir,
tasks,
options.captureOptions,
options.createBeforeCaptureHook,
options.abortSignal,
options.onProgress
? (progress) => {
options.onProgress?.({
...progress,
totalFrames: options.totalFrames,
capturedFrames: Math.min(
options.totalFrames,
capturedBeforeBatch + progress.capturedFrames,
),
});
}
: undefined,
undefined,
options.cfg,
);
} finally {
await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
}
}
const remaining = findMissingFrameRanges(
options.totalFrames,
options.framesDir,
options.frameExt,
);
if (remaining.length === 0) {
return attempts;
}
if (!options.allowRetry || currentWorkers <= 1) {
throw new Error(
`[Render] Capture completed but ${countFrameRanges(remaining)} frame(s) are missing`,
);
}
const nextWorkers = getNextRetryWorkerCount(currentWorkers);
options.log.warn("[Render] Retrying missing captured frames with fewer workers.", {
fromWorkers: currentWorkers,
toWorkers: nextWorkers,
missingFrames: countFrameRanges(remaining),
});
currentWorkers = nextWorkers;
missingRanges = remaining;
attempt++;
} catch (error) {
const remaining = findMissingFrameRanges(
options.totalFrames,
options.framesDir,
options.frameExt,
);
if (remaining.length === 0) {
return attempts;
}
if (!options.allowRetry || currentWorkers <= 1 || !isRecoverableParallelCaptureError(error)) {
throw error;
}
const nextWorkers = getNextRetryWorkerCount(currentWorkers);
options.log.warn("[Render] Parallel capture timed out; retrying missing frames.", {
fromWorkers: currentWorkers,
toWorkers: nextWorkers,
missingFrames: countFrameRanges(remaining),
error: error instanceof Error ? error.message : String(error),
});
currentWorkers = nextWorkers;
missingRanges = remaining;
attempt++;
}
}
}
/**
* Crop an rgb48le buffer to a sub-region. Returns a new Buffer containing
* only the cropped pixels.
*/
function cropRgb48le(
src: Buffer,
srcW: number,
srcH: number,
cropX: number,
cropY: number,
cropW: number,
cropH: number,
): Buffer {
const BPP = 6;
const dst = Buffer.alloc(cropW * cropH * BPP);
for (let row = 0; row < cropH; row++) {
const srcRow = cropY + row;
if (srcRow < 0 || srcRow >= srcH) continue;
const srcOff = (srcRow * srcW + cropX) * BPP;
const dstOff = row * cropW * BPP;
const copyLen = Math.min(cropW, srcW - cropX) * BPP;
if (copyLen > 0) src.copy(dst, dstOff, srcOff, srcOff + copyLen);
}
return dst;
}
/**
* Blit a single HDR video layer onto an rgb48le canvas.
*
* Shared between the normal-frame compositing path (compositeToBuffer)
* and the transition dual-scene compositing loop to avoid duplicating
* the frame lookup, raw read, transfer, transform, and blit logic.
*/
export interface HdrVideoFrameSource {
dir: string;
rawPath: string;
fd: number;
width: number;
height: number;
frameSize: number;
frameCount: number;
scratch: Buffer;
}
export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void {
try {
closeSync(source.fd);
} catch (err) {
log?.warn("Failed to close HDR raw frame file", {
rawPath: source.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
export function blitHdrVideoLayer(
canvas: Buffer,
el: ElementStackingInfo,
time: number,
fps: number,
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>,
hdrStartTimes: Map<string, number>,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const frameSource = hdrVideoFrameSources.get(el.id);
const startTime = hdrStartTimes.get(el.id);
if (!frameSource || startTime === undefined || el.opacity <= 0) {
return;
}
// Frame index within the video. Clamp to the extracted raw frame count so
// a composition that outlives the source clip freezes on the last frame,
// matching Chrome's <video> behavior.
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
if (videoFrameIndex < 1) return;
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
if (effectiveIndex < 1) return;
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
try {
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;
let timingStart = Date.now();
const bytesRead = readSync(
frameSource.fd,
frameSource.scratch,
0,
frameSource.frameSize,
frameOffset,
);
if (bytesRead !== frameSource.frameSize) return;
const hdrRgb = frameSource.scratch;
const srcW = frameSource.width;
const srcH = frameSource.height;
addHdrTiming(hdrPerf, "hdrVideoReadDecodeMs", timingStart);
// Convert between HDR transfer functions if source doesn't match output
if (sourceTransfer && targetTransfer && sourceTransfer !== targetTransfer) {
timingStart = Date.now();
convertTransfer(hdrRgb, sourceTransfer, targetTransfer);
addHdrTiming(hdrPerf, "hdrVideoTransferMs", timingStart);
}
const viewportMatrix = parseTransformMatrix(el.transform);
// Pass border-radius for rounded-corner masking (only when non-zero)
const br = el.borderRadius;
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
// Apply ancestor overflow:hidden clip rect by constraining the blit
// bounds. For the no-transform (region) path, we crop the source
// image and adjust the destination position. For the affine path,
// clip rect support is not yet implemented (would require per-pixel
// scissor in the affine blit); log a warning and skip clipping.
let blitX = el.x;
let blitY = el.y;
let blitSrcX = 0;
let blitSrcY = 0;
let blitW = srcW;
let blitH = srcH;
let clipped = false;
if (el.clipRect) {
const cr = el.clipRect;
const cx1 = Math.max(blitX, cr.x);
const cy1 = Math.max(blitY, cr.y);
const cx2 = Math.min(blitX + blitW, cr.x + cr.width);
const cy2 = Math.min(blitY + blitH, cr.y + cr.height);
if (cx2 <= cx1 || cy2 <= cy1) return; // fully clipped
blitSrcX = cx1 - blitX;
blitSrcY = cy1 - blitY;
blitW = cx2 - cx1;
blitH = cy2 - cy1;
blitX = cx1;
blitY = cy1;
clipped = true;
}
// Detect translation-only matrix (no scale/rotation) — route through the
// region path which supports clip rects. Chrome reports a viewport matrix
// for all HDR elements, even untransformed ones or those with only layout
// translation (e.g. `left: 960px` → `matrix(1,0,0,1,960,0)`). The region
// blit handles translation via el.x/el.y, so we only need the affine path
// for actual scale/rotation transforms.
// parseTransformMatrix returns a 6-element array or null — length check unnecessary.
const isTranslationOnly = !!(
viewportMatrix &&
Math.abs(viewportMatrix[0]! - 1) < 0.001 &&
Math.abs(viewportMatrix[1]!) < 0.001 &&
Math.abs(viewportMatrix[2]!) < 0.001 &&
Math.abs(viewportMatrix[3]! - 1) < 0.001
);
timingStart = Date.now();
if (viewportMatrix && !isTranslationOnly) {
if (clipped && log) {
log.debug(
`HDR clip rect on affine-transformed element ${el.id} — clip not applied (affine scissor not yet supported)`,
);
}
blitRgb48leAffine(
canvas,
hdrRgb,
viewportMatrix,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else if (clipped) {
// Crop the source buffer to the clipped region before blitting
const croppedBuf = cropRgb48le(hdrRgb, srcW, srcH, blitSrcX, blitSrcY, blitW, blitH);
blitRgb48leRegion(
canvas,
croppedBuf,
blitX,
blitY,
blitW,
blitH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else {
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
}
addHdrTiming(hdrPerf, "hdrVideoBlitMs", timingStart);
} catch (err) {
if (log) {
log.debug(`HDR blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Pre-decoded HDR image buffer with its native pixel dimensions.
*
* Static images decode exactly once at setup time and are blitted on every
* visible frame, unlike video frames which are read fresh per timestamp.
*/
export interface HdrImageBuffer {
data: Buffer;
width: number;
height: number;
}
/**
* Blit a single HDR image layer onto an rgb48le canvas.
*
* Image-equivalent of `blitHdrVideoLayer` — the buffer is pre-decoded and
* static, so there's no time-based frame lookup or per-frame PNG read.
*/
export function blitHdrImageLayer(
canvas: Buffer,
el: ElementStackingInfo,
hdrImageBuffers: Map<string, HdrImageBuffer>,
hdrImageTransferCache: HdrImageTransferCache,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const buf = hdrImageBuffers.get(el.id);
if (!buf || el.opacity <= 0) {
return;
}
if (el.clipRect && log) {
log.debug(`HDR clip rect on image element ${el.id} — clip not yet supported for images`);
}
try {
if (hdrPerf) hdrPerf.hdrImageLayerBlits += 1;
// The cache returns `buf.data` unchanged when no conversion is needed,
// and otherwise returns a per-(imageId, targetTransfer) buffer that was
// converted exactly once and reused across every subsequent frame.
let timingStart = Date.now();
const hdrRgb =
sourceTransfer && targetTransfer
? hdrImageTransferCache.getConverted(el.id, sourceTransfer, targetTransfer, buf.data)
: buf.data;
addHdrTiming(hdrPerf, "hdrImageTransferMs", timingStart);
const viewportMatrix = parseTransformMatrix(el.transform);
const br = el.borderRadius;
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
timingStart = Date.now();
if (viewportMatrix) {
blitRgb48leAffine(
canvas,
hdrRgb,
viewportMatrix,
buf.width,
buf.height,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else {
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
buf.width,
buf.height,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
}
addHdrTiming(hdrPerf, "hdrImageBlitMs", timingStart);
} catch (err) {
if (log) {
log.debug(`HDR image blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Dependencies passed to `compositeHdrFrame`.
*
* Every field except the per-frame arguments is captured once when the HDR
* render path opens its `try { ... }` block and reused across every frame —
* extracting them into an explicit struct lets the helper live at module
* scope (no closure-over-renderJob) and keeps the per-call signature small.
*/
type CompositeTransfer = HdrTransfer | "srgb";
export function shouldUseLayeredComposite(options: {
hasHdrContent: boolean;
hasShaderTransitions: boolean;
isPngSequence: boolean;
}): boolean {
return options.hasHdrContent || (options.hasShaderTransitions && !options.isPngSequence);
}
export function resolveCompositeTransfer(
hasHdrContent: boolean,
effectiveHdr: { transfer: HdrTransfer } | undefined,
): CompositeTransfer {
return hasHdrContent && effectiveHdr ? effectiveHdr.transfer : "srgb";
}
export interface HdrCompositeContext {
log: ProducerLogger;
domSession: CaptureSession;
beforeCaptureHook: BeforeCaptureHook | null;
width: number;
height: number;
fps: number;
compositeTransfer: CompositeTransfer;
nativeHdrImageIds: Set<string>;
hdrImageBuffers: Map<string, HdrImageBuffer>;
hdrImageTransferCache: HdrImageTransferCache;
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>;
hdrVideoStartTimes: Map<string, number>;
imageTransfers: Map<string, HdrTransfer>;
videoTransfers: Map<string, HdrTransfer>;
debugDumpEnabled: boolean;
debugDumpDir: string | null;
hdrPerf?: HdrPerfCollector;
}
/**
* Composite a single HDR frame into a pre-allocated `rgb48le` canvas.
*
* Bottom-to-top z-order: HDR layers are blitted directly from cached image
* buffers / extracted video frames; DOM layers are screenshotted with a
* mass-hide mask (so each layer paints only its own elements) and then
* blended into the canvas via `blitRgba8OverRgb48le` in the active HDR
* transfer space.
*
* The `elementFilter` parameter exists so the transition path can composite
* each scene independently; pass `undefined` for whole-stack rendering.
*
* @param ctx - Long-lived dependencies (logger, browser session, dimensions,
* HDR layer maps). Captured once per render — see
* {@link HdrCompositeContext}.
* @param canvas - Pre-allocated `width * height * 6` byte buffer. Caller must
* zero-fill before every frame (this helper does not).
* @param time - Seek time in seconds.
* @param fullStacking - Stacking info for ALL elements at this time. Even when
* filtering, every other element id is needed to build
* the DOM-layer hide-list.
* @param elementFilter - When set, only elements whose id is in the set are
* composited.
* @param debugFrameIndex - Frame index used to label per-layer diagnostic
* dumps. Pass `-1` to disable per-layer dumps even
* when `KEEP_TEMP=1` (e.g. for warmup frames).
*/
export async function compositeHdrFrame(
ctx: HdrCompositeContext,
canvas: Buffer,
time: number,
fullStacking: ElementStackingInfo[],
elementFilter?: Set<string>,
debugFrameIndex: number = -1,
): Promise<void> {
const {
log,
domSession,
beforeCaptureHook,
width,
height,
fps,
compositeTransfer,
nativeHdrImageIds,
hdrImageBuffers,
hdrImageTransferCache,
hdrVideoFrameSources,
hdrVideoStartTimes,
imageTransfers,
videoTransfers,
debugDumpEnabled,
debugDumpDir,
hdrPerf,
} = ctx;
const filteredStacking = elementFilter
? fullStacking.filter((e) => elementFilter.has(e.id))
: fullStacking;
// Zero-opacity elements stay in the stacking for correct hide-list
// generation (their <img> replacements must be hidden from sibling
// screenshots). The actual blit is skipped in the compositing loop below.
const layers = groupIntoLayers(filteredStacking);
const shouldLog = debugDumpEnabled && debugFrameIndex >= 0;
if (shouldLog) {
log.info("[diag] compositeToBuffer plan", {
frame: debugFrameIndex,
time: time.toFixed(3),
filterSize: elementFilter?.size,
fullStackingCount: fullStacking.length,
filteredCount: filteredStacking.length,
layerCount: layers.length,
layers: layers.map((l) =>
l.type === "hdr"
? {
type: "hdr",
id: l.element.id,
z: l.element.zIndex,
visible: l.element.visible,
opacity: l.element.opacity,
bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`,
}
: { type: "dom", ids: l.elementIds },
),
});
}
for (const [layerIdx, layer] of layers.entries()) {
if (layer.type === "hdr") {
// Skip zero-opacity HDR elements — their parent scene may have faded out.
if (layer.element.opacity <= 0) continue;
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const isHdrImage = nativeHdrImageIds.has(layer.element.id);
const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer;
if (isHdrImage) {
blitHdrImageLayer(
canvas,
layer.element,
hdrImageBuffers,
hdrImageTransferCache,
width,
height,
log,
imageTransfers.get(layer.element.id),
hdrTargetTransfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
canvas,
layer.element,
time,
fps,
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(layer.element.id),
hdrTargetTransfer,
hdrPerf,
);
}
if (shouldLog) {
const after = countNonZeroRgb48(canvas);
if (isHdrImage) {
const buf = hdrImageBuffers.get(layer.element.id);
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
kind: "image",
pixelsAdded: after - before,
totalNonZero: after,
bufferDecoded: !!buf,
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
});
} else {
const frameSource = hdrVideoFrameSources.get(layer.element.id);
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
const localTime = time - startTime;
const frameNum = Math.floor(localTime * fps) + 1;
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
kind: "video",
pixelsAdded: after - before,
totalNonZero: after,
startTime,
localTime: localTime.toFixed(3),
hdrFrameNum: frameNum,
rawPath: frameSource?.rawPath ?? null,
frameCount: frameSource?.frameCount ?? null,
});
}
}
} else {
// DOM layer: capture only elements in this layer.
//
// Each layer gets a fresh seek + inject cycle to guarantee correct
// visibility state — avoids fragile interactions between the frame
// injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek.
//
// The mask:
// - mass-hides every body descendant via stylesheet
// - re-shows the layer's elements (and their descendants and
// their injected `__render_frame_*` siblings) so deep-nested
// content stays visible even though intermediate ancestors
// are hidden
// - inline-hides every other data-start element so they don't
// paint when they happen to be descendants of a layer element
// (most importantly: HDR videos and other-layer SDR videos
// that live inside `#root` when capturing the root DOM layer)
//
// Without the mask, every DOM screenshot captures the full page
// (root background, sibling scenes' static content, the painted
// border/box-shadow of cards, etc.) and the resulting opaque
// pixels overwrite previously composited HDR content beneath.
const allElementIds = fullStacking.map((e) => e.id);
const layerIds = new Set(layer.elementIds);
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
// 1. Seek GSAP to restore all animated properties from clean state
let timingStart = Date.now();
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart);
// 2. Run frame injector to set correct SDR video visibility
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
}
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
timingStart = Date.now();
await applyDomLayerMask(domSession.page, layer.elementIds, hideIds);
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart);
// 4. Screenshot
timingStart = Date.now();
const domPng = await captureAlphaPng(domSession.page, width, height);
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart);
// 5. Tear down the mask
timingStart = Date.now();
await removeDomLayerMask(domSession.page, hideIds);
addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart);
try {
timingStart = Date.now();
const { data: domRgba } = decodePng(domPng);
addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart);
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
timingStart = Date.now();
blitRgba8OverRgb48le(domRgba, canvas, width, height, compositeTransfer);
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
if (shouldLog && debugDumpDir) {
const after = countNonZeroRgb48(canvas);
const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`;
const dumpPath = join(debugDumpDir, dumpName);
writeFileExclusiveSync(dumpPath, domPng);
log.info("[diag] dom layer blit", {
frame: debugFrameIndex,
layerIdx,
layerIds: layer.elementIds,
hideCount: hideIds.length,
pngBytes: domPng.length,
alphaPixels,
pixelsAdded: after - before,
totalNonZero: after,
dumpPath,
});
}
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay", {
layerIds: layer.elementIds,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
if (shouldLog && debugDumpDir) {
const finalNonZero = countNonZeroRgb48(canvas);
log.info("[diag] compositeToBuffer end", {
frame: debugFrameIndex,
finalNonZeroPixels: finalNonZero,
totalPixels: width * height,
coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%",
});
}
}
export type RenderConfigInput = Omit<RenderConfig, "fps"> & { fps: FpsInput };
export function createRenderJob(config: RenderConfigInput): RenderJob {
return {
id: randomUUID(),
config: { ...config, fps: toFps(config.fps) },
status: "queued",
progress: 0,
currentStage: "Queued",
createdAt: new Date(),
};
}
function normalizeCompositionSrcPath(srcPath: string): string {
return srcPath.replace(/\\/g, "/").replace(/^\.\//, "");
}
function createStandaloneEntryRenderClone(root: Element, host: Element): Element {
const hostClone = host.cloneNode(true) as Element;
hostClone.setAttribute("data-start", "0");
if (root === host) return hostClone;
const rootClone = root.cloneNode(false) as Element;
rootClone.appendChild(hostClone);
return rootClone;
}
function replaceBodyWithRenderClone(body: HTMLElement, renderClone: Element): void {
while (body.firstChild) {
body.removeChild(body.firstChild);
}
body.appendChild(renderClone);
}
export function shouldUseStreamingEncode(
cfg: Pick<EngineConfig, "enableStreamingEncode" | "streamingEncodeMaxDurationSeconds">,
outputFormat: NonNullable<RenderConfig["format"]>,
workerCount: number,
// Composition timeline duration in seconds.
durationSeconds: number,
): boolean {
if (!cfg.enableStreamingEncode) return false;
if (outputFormat === "png-sequence") return false;
if (outputFormat === "gif") return false;
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
if (durationSeconds > cfg.streamingEncodeMaxDurationSeconds) return false;
return workerCount === 1;
}
/**
* Main render pipeline
*/
export function extractStandaloneEntryFromIndex(
indexHtml: string,
entryFile: string,
): string | null {
const normalizedEntryFile = normalizeCompositionSrcPath(entryFile);
const { document } = parseHTML(indexHtml);
const body = document.querySelector("body");
if (!body) return null;
const hosts = Array.from(document.querySelectorAll("[data-composition-src]")) as Element[];
const host = hosts.find(
(candidate) =>
normalizeCompositionSrcPath(candidate.getAttribute("data-composition-src") || "") ===
normalizedEntryFile,
);
if (!host) return null;
const root =
(Array.from(body.children) as Element[]).find((candidate) =>
candidate.hasAttribute("data-composition-id"),
) ?? null;
if (!root) return null;
const renderClone = createStandaloneEntryRenderClone(root, host);
replaceBodyWithRenderClone(body, renderClone);
return document.toString();
}
/**
* Render a `RenderJob` end-to-end: compile → probe → extract videos →
* audio → capture → encode → assemble. The function body is a thin
* sequencer over the eight stage modules in `./render/stages/`; the
* orchestrator owns shared resources (work dir, file server, probe
* session, browser console buffer, perf counters, peak-memory sampler)
* and the `try/finally` cleanup. Returns once the final output exists at
* `outputPath`; throws on cancellation, encoder failure, or a stage
* error (with a diagnostic summary written to `perf-summary.json`).
*/
export async function executeRenderJob(
job: RenderJob,
projectDir: string,
outputPath: string,
onProgress?: ProgressCallback,
abortSignal?: AbortSignal,
): Promise<void> {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const producerRoot = process.env.PRODUCER_RENDERS_DIR
? resolve(process.env.PRODUCER_RENDERS_DIR, "..")
: resolve(moduleDir, "../..");
const debugDir = join(producerRoot, ".debug");
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const workDir = job.config.debug
? join(debugDir, job.id)
: mkdtempSync(join(outputDir, `work-${job.id}-`));
const pipelineStart = Date.now();
const log = job.config.logger ?? defaultLogger;
let fileServer: FileServerHandle | null = null;
let probeSession: CaptureSession | null = null;
let lastBrowserConsole: string[] = [];
let restoreLogger: (() => void) | null = null;
const perfStages: Record<string, number> = {};
const hdrDiagnostics: HdrDiagnostics = {
videoExtractionFailures: 0,
imageDecodeFailures: 0,
};
let hdrPerf: HdrPerfCollector | undefined;
const perfOutputPath = join(workDir, "perf-summary.json");
const cfg = { ...(job.config.producerConfig ?? resolveConfig()) };
const observability = new RenderObservabilityRecorder({
pipelineStartMs: pipelineStart,
log,
renderJobId: job.id,
});
const outputFormat = (job.config.format ?? "mp4") as NonNullable<RenderConfig["format"]>;
const isWebm = outputFormat === "webm";
const isMov = outputFormat === "mov";
const isPngSequence = outputFormat === "png-sequence";
const isGif = outputFormat === "gif";
const needsAlpha = isWebm || isMov || isPngSequence;
// `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. This keeps `cfg` immutable for
// the rest of the pipeline.
const enableChunkedEncode = cfg.enableChunkedEncode;
const chunkedEncodeSize = cfg.chunkSizeFrames;
const captureObservability: RenderCaptureObservability = {
forceScreenshot: Boolean(cfg.forceScreenshot),
captureMode: cfg.forceScreenshot ? "screenshot" : "beginframe",
browserGpuMode: cfg.browserGpuMode,
protocolTimeoutMs: cfg.protocolTimeout,
pageNavigationTimeoutMs: cfg.pageNavigationTimeout,
playerReadyTimeoutMs: cfg.playerReadyTimeout,
};
let extractionObservability: RenderExtractionObservability | undefined;
let compositionHash: string | undefined;
const updateCaptureObservability = (patch: Partial<RenderCaptureObservability>): void => {
Object.assign(captureObservability, patch);
captureObservability.captureMode = captureObservability.forceScreenshot
? "screenshot"
: "beginframe";
};
// Declared outside the try so `finally` can stop the interval, but
// the sampler is created INSIDE the try so a synchronous throw
// between declaration and the try-block (currently impossible, but
// defensible if more setup ever lands here) can't leak the interval.
let memSampler: MemorySampler | null = null;
try {
memSampler = createMemorySampler();
const assertNotAborted = () => {
if (abortSignal?.aborted) {
throw new RenderCancelledError("render_cancelled");
}
};
job.startedAt = new Date();
assertNotAborted();
assertConfiguredFfmpegBinariesExist();
log.info("[Render] Pipeline started", {
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
fps: job.config.fps,
format: outputFormat,
quality: job.config.quality,
browserGpuMode: cfg.browserGpuMode,
forceScreenshot: cfg.forceScreenshot,
protocolTimeout: cfg.protocolTimeout,
browserTimeout: cfg.browserTimeout,
pageNavigationTimeout: cfg.pageNavigationTimeout,
playerReadyTimeout: cfg.playerReadyTimeout,
});
observability.checkpoint("pipeline", "started", {
format: outputFormat,
quality: job.config.quality,
browserGpuMode: cfg.browserGpuMode,
forceScreenshot: Boolean(cfg.forceScreenshot),
protocolTimeoutMs: cfg.protocolTimeout,
pageNavigationTimeoutMs: cfg.pageNavigationTimeout,
playerReadyTimeoutMs: cfg.playerReadyTimeout,
requestedWorkers: job.config.workers ?? "auto",
});
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
if (job.config.debug) {
const logPath = join(workDir, "render.log");
restoreLogger = installDebugLogger(logPath, log);
}
const entryFile = job.config.entryFile || "index.html";
let htmlPath = join(projectDir, entryFile);
if (!existsSync(htmlPath)) {
throw new Error(`Entry file not found: ${htmlPath}`);
}
assertNotAborted();
// If entryFile is a sub-composition (<template> wrapper), reuse the real
// index.html shell and isolate the matching host instead of fabricating
// a new standalone document.
const rawEntry = readFileSync(htmlPath, "utf-8");
if (entryFile !== "index.html" && rawEntry.trimStart().startsWith("<template")) {
const wrapperPath = join(workDir, "standalone-entry.html");
const projectIndexPath = join(projectDir, "index.html");
if (!existsSync(projectIndexPath)) {
throw new Error(
`Template entry file "${entryFile}" requires a project index.html to extract its render shell.`,
);
}
const standaloneHtml = extractStandaloneEntryFromIndex(
readFileSync(projectIndexPath, "utf-8"),
entryFile,
);
if (!standaloneHtml) {
throw new Error(
`Entry file "${entryFile}" is not mounted from index.html via data-composition-src, so it cannot be rendered independently.`,
);
}
writeFileSync(wrapperPath, standaloneHtml, "utf-8");
htmlPath = wrapperPath;
log.info("Extracted standalone entry from index.html host context", {
entryFile,
});
}
// ── Stage 1: Compile ─────────────────────────────────────────────────
const stage1Start = Date.now();
updateJobStatus(job, "preprocessing", "Compiling composition", 5, onProgress);
const compileResult = await observeRenderStage(observability, "compile", { needsAlpha }, () =>
runCompileStage({
projectDir,
workDir,
htmlPath,
entryFile,
job,
cfg,
needsAlpha,
log,
assertNotAborted,
}),
);
let compiled = compileResult.compiled;
compositionHash = computeCompositionObservabilityHash(compiled.html);
const composition = compileResult.composition;
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;
updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
observability.checkpoint("compile", "composition metadata resolved", {
width,
height,
videoCount: composition.videos.length,
audioCount: composition.audios.length,
imageCount: composition.images.length,
deviceScaleFactor,
forceScreenshot: captureForceScreenshot,
compositionHash,
});
// Low-memory safe profile: on memory-constrained hosts the default render
// shape (probe Chrome + a throwaway calibration Chrome + N capture
// workers) thrashes — concurrent Chrome instances drive memory pressure
// that slows every CDP call and spikes V8 GC, surfacing as the slow/stuck
// renders in heygen-com/hyperframes#1218 / #1219. Collapse to the cheapest
// shape: skip auto-worker calibration (the gate below), pin to a single
// worker (resolved below), and prefer screenshot capture over BeginFrame
// (which avoids the BeginFrame protocol-timeout → relaunch churn on slow
// hardware). Auto-detected from total RAM; opt out with
// `--no-low-memory-mode` / PRODUCER_LOW_MEMORY_MODE=false. An explicit
// `--workers N` still gets screenshot capture + skipped calibration; only
// the single-worker pin is bypassed.
if (cfg.lowMemoryMode) {
captureForceScreenshot = true;
updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
log.info(
"[Render] Low-memory render profile active — " +
"screenshot capture, auto-worker calibration skipped" +
(job.config.workers === undefined ? ", pinned to 1 worker" : "") +
". Override with --no-low-memory-mode or PRODUCER_LOW_MEMORY_MODE=false.",
{ totalMemMb: getSystemTotalMb(), thresholdMb: LOW_MEMORY_TOTAL_MB_THRESHOLD },
);
}
const probeResult = await observeRenderStage(
observability,
"browser_probe",
{ forceScreenshot: captureForceScreenshot },
() =>
runProbeStage({
projectDir,
workDir,
job,
cfg,
forceScreenshot: captureForceScreenshot,
log,
assertNotAborted,
compiled,
composition,
width,
height,
needsAlpha,
deviceScaleFactor,
}),
);
compiled = probeResult.compiled;
compositionHash = computeCompositionObservabilityHash(compiled.html);
fileServer = probeResult.fileServer;
probeSession = probeResult.probeSession;
lastBrowserConsole = probeResult.lastBrowserConsole;
// The probe stage produces `duration` / `totalFrames` values; the
// sequencer owns the `RenderJob` and writes them onto it.
job.duration = probeResult.duration;
job.totalFrames = probeResult.totalFrames;
const totalFrames = probeResult.totalFrames;
perfStages.browserProbeMs = probeResult.browserProbeMs;
perfStages.compileMs = Date.now() - stage1Start;
observability.checkpoint("browser_probe", "duration resolved", {
durationSeconds: probeResult.duration,
totalFrames,
compositionHash,
});
// ── Stage 2: Video frame extraction ─────────────────────────────────
updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress);
const compiledDir = join(workDir, "compiled");
const extractResult = await observeRenderStage(
observability,
"video_extract",
{ videoCount: composition.videos.length },
() =>
runExtractVideosStage({
projectDir,
compiledDir,
job,
cfg,
log,
composition,
abortSignal,
assertNotAborted,
}),
);
const {
extractionResult,
frameLookup,
videoReadinessSkipIds,
videoMetadataHints,
nativeHdrVideoIds,
videoTransfers,
nativeHdrImageIds,
imageTransfers,
hdrImageSrcPaths,
imageColorSpaces,
} = extractResult;
perfStages.videoExtractMs = extractResult.videoExtractMs;
extractionObservability = summarizeExtractionObservability(
extractionResult,
composition.videos.length,
);
observability.checkpoint("video_extract", "frames resolved", {
videoCount: extractionObservability.videoCount,
extractedVideoCount: extractionObservability.extractedVideoCount,
totalFramesExtracted: extractionObservability.totalFramesExtracted,
maxFramesPerVideo: extractionObservability.maxFramesPerVideo,
avgFramesPerExtractedVideo: extractionObservability.avgFramesPerExtractedVideo ?? null,
vfrPreflightCount: extractionObservability.vfrPreflightCount ?? null,
vfrPreflightMs: extractionObservability.vfrPreflightMs ?? null,
cacheHits: extractionObservability.cacheHits ?? null,
cacheMisses: extractionObservability.cacheMisses ?? null,
});
// ── HDR auto-detection ──────────────────────────────────────────────
const effectiveHdr = resolveEffectiveHdrMode({
hdrMode: job.config.hdrMode,
outputFormat,
extractionResult,
imageColorSpaces,
log,
});
observability.checkpoint("hdr_detection", "resolved", {
requestedHdrMode: job.config.hdrMode ?? "auto",
effectiveHdr: effectiveHdr ? effectiveHdr.transfer : "sdr",
nativeHdrVideoCount: nativeHdrVideoIds.size,
nativeHdrImageCount: nativeHdrImageIds.size,
});
// ── Stage 3: Audio processing ───────────────────────────────────────
updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
const audioResult = await observeRenderStage(
observability,
"audio_process",
{ audioCount: composition.audios.length },
() =>
runAudioStage({
projectDir,
workDir,
compiledDir,
duration: probeResult.duration,
audios: composition.audios,
abortSignal,
assertNotAborted,
}),
);
const { audioOutputPath, hasAudio } = audioResult;
perfStages.audioProcessMs = audioResult.audioProcessMs;
// ── Stage 4: Frame capture ──────────────────────────────────────────
const stage4Start = Date.now();
updateJobStatus(job, "rendering", "Starting frame capture", 25, onProgress);
// Start file server (may already be running from duration discovery).
// The page-side compositing stub is injected later (after hasHdrContent
// is known) via addPreHeadScript — see usePageSideCompositingForTransitions.
if (!fileServer) {
const fileServerStart = observability.stageStart("file_server", { reused: false });
try {
fileServer = await createFileServer({
projectDir,
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
});
assertNotAborted();
observability.stageEnd("file_server", fileServerStart);
} catch (error) {
observability.stageError("file_server", fileServerStart, error);
throw error;
}
} else {
observability.checkpoint("file_server", "reused probe file server");
}
const activeFileServer = fileServer;
if (!activeFileServer) {
throw new Error("File server failed to initialize before frame capture");
}
const framesDir = join(workDir, "captured-frames");
if (!existsSync(framesDir)) mkdirSync(framesDir, { recursive: true });
const captureOptions: CaptureOptions = {
width,
height,
fps: job.config.fps,
format: needsAlpha ? "png" : "jpeg",
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
variables: job.config.variables,
deviceScaleFactor,
};
// Capture sessions do not need native browser metadata for videos whose
// pixels come from out-of-band FFmpeg frame extraction. Waiting on those
// `<video>` elements lets browser decode/cache quirks block renders even
// though the browser never supplies their pixels. We still pass FFmpeg
// dimensions as metadata hints so CSS layouts that depend on intrinsic
// aspect ratio stay stable before the first injected frame. Native HDR
// videos are included for the same reason: Chrome may not decode them at
// all, while the renderer composites their extracted frames separately.
const buildCaptureOptions = (): CaptureOptions => ({
...captureOptions,
videoMetadataHints,
skipReadinessVideoIds: videoReadinessSkipIds,
});
const frameSrcResolver = createCompiledFrameSrcResolver(compiledDir);
const createRenderVideoFrameInjector = (): BeforeCaptureHook | null =>
createVideoFrameInjector(frameLookup, {
frameDataUriCacheLimit: cfg.frameDataUriCacheLimit,
frameDataUriCacheBytesLimitMb: cfg.frameDataUriCacheBytesLimitMb,
frameSrcResolver,
});
let captureCalibration:
| {
estimate: CaptureCostEstimate;
samples: CaptureCalibrationSample[];
}
| undefined;
const htmlInCanvasDetected = compiled.renderModeHints.reasons.some(
(r) => r.code === "htmlInCanvas",
);
if (
job.config.workers === undefined &&
totalFrames >= 60 &&
!htmlInCanvasDetected &&
!cfg.lowMemoryMode
) {
const outcome = await observeRenderStage(
observability,
"capture_calibration",
{ forceScreenshot: captureForceScreenshot },
() =>
runCaptureCalibration({
cfg,
fileServer: activeFileServer,
workDir,
log,
job,
totalFrames,
forceScreenshot: captureForceScreenshot,
probeSession,
buildCaptureOptions,
createRenderVideoFrameInjector,
assertNotAborted,
}),
);
captureCalibration = outcome.calibration;
captureForceScreenshot = outcome.forceScreenshot;
updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
probeSession = outcome.probeSession;
if (outcome.lastBrowserConsole.length > 0) {
lastBrowserConsole = outcome.lastBrowserConsole;
}
observability.checkpoint("capture_calibration", "resolved", {
forceScreenshot: captureForceScreenshot,
multiplier: outcome.calibration?.estimate.multiplier ?? null,
p95Ms: outcome.calibration?.estimate.p95Ms ?? null,
});
} else {
observability.checkpoint("capture_calibration", "skipped", {
requestedWorkers: job.config.workers ?? "auto",
totalFrames,
htmlInCanvasDetected,
lowMemoryMode: Boolean(cfg.lowMemoryMode),
});
}
// Low-memory safe-mode's single-worker pin lives inside
// resolveRenderWorkerCount so its "why workers=N" logging stays coherent.
let workerCount = resolveRenderWorkerCount(
totalFrames,
job.config.workers,
cfg,
compiled,
log,
captureCalibration?.estimate,
);
updateCaptureObservability({ workerCount });
observability.checkpoint("worker_resolution", "resolved", { workerCount });
if (workerCount > 1 && probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
await closeCaptureSession(probeSession);
probeSession = null;
}
// Streaming encode pipes captured frames through ffmpeg's stdin to produce
// a single video file. Keep the default enabled for sequential capture, but
// let auto-parallel renders use disk frames: the current ordered streaming
// writer would otherwise stall later workers behind earlier frame ranges.
// png-sequence has no encoded video output, so streaming is always bypassed.
let useStreamingEncode = shouldUseStreamingEncode(cfg, outputFormat, workerCount, job.duration);
log.info("streaming-encode gate", {
enabled: useStreamingEncode,
configFlag: cfg.enableStreamingEncode,
outputFormat,
workerCount,
durationSeconds: job.duration,
maxDurationSeconds: cfg.streamingEncodeMaxDurationSeconds,
});
const captureAttempts: CaptureAttemptSummary[] = [];
// png-sequence is "no container" — outputPath is treated as a directory and
// the encode/mux/faststart stages are skipped entirely. The empty extension
// keeps `videoOnlyPath` (which is constructed below) sensible even though
// it will not be written.
const FORMAT_EXT: Record<string, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
gif: ".gif",
};
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
// Only use the HDR encoder preset when there's HDR content to pass through —
// either native HDR videos OR native HDR images. For SDR-only compositions,
// auto mode stays SDR since H.265 10-bit causes browser color management
// issues (orange shift) with no quality benefit.
const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0);
// Page-side compositing opt-in: when the engine is configured to run the
// shader blend inside Chrome via a page-side WebGL canvas, the layered
// Node-side composite path is unnecessary for SDR shader transitions.
// The streaming path takes ONE opaque RGB screenshot per output frame —
// exactly the single capture the page-side compositor produces. HDR
// content still forces the layered path (HDR layers need per-layer
// alpha + native HDR raw frame compositing in Node; that's out of scope
// for this opt-in). GIF also uses this path for shader transitions
// because its two-pass palette encoder needs disk frames, not the
// layered path's streaming raw-video encoder.
const usePageSideCompositingForTransitions =
(cfg.enablePageSideCompositing || isGif) &&
compiled.hasShaderTransitions &&
!hasHdrContent &&
!isPngSequence &&
!needsAlpha;
if (usePageSideCompositingForTransitions) {
activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
log.info(
"[Render] Page-side compositing enabled — bypassing Node-side layered " +
"shader-blend path. Engine will capture one opaque RGB frame per output frame.",
);
}
const useLayeredComposite =
!usePageSideCompositingForTransitions &&
shouldUseLayeredComposite({
hasHdrContent,
hasShaderTransitions: compiled.hasShaderTransitions && !isGif,
isPngSequence,
});
updateCaptureObservability({
workerCount,
useStreamingEncode,
useLayeredComposite,
usePageSideCompositing: usePageSideCompositingForTransitions,
hasHdrContent,
forceScreenshot: captureForceScreenshot,
});
observability.checkpoint("capture_strategy", "resolved", {
workerCount,
forceScreenshot: captureForceScreenshot,
useStreamingEncode,
useLayeredComposite,
usePageSideCompositing: usePageSideCompositingForTransitions,
hasHdrContent,
hasShaderTransitions: compiled.hasShaderTransitions,
isPngSequence,
});
const encoderHdr = hasHdrContent ? effectiveHdr : undefined;
// png-sequence has no encoder, but the rest of the orchestrator still
// reads `preset.quality` for `effectiveQuality` and `preset.codec` for
// unrelated bookkeeping. Fall back to the mp4 preset shape — its values
// are never written to ffmpeg in the png-sequence path.
const presetFormat: "mp4" | "webm" | "mov" =
outputFormat === "webm" || outputFormat === "mov" ? outputFormat : "mp4";
const preset = getEncoderPreset(job.config.quality, presetFormat, encoderHdr);
// CLI overrides (--crf, --video-bitrate) flow through job.config and must
// win over the preset-derived defaults. The CLI enforces mutual exclusivity
// upstream, but we still resolve them defensively. Without this, the flags
// are silently ignored at the encoder spawn sites below — see PR #268 which
// dropped the prior baseEncoderOpts wiring.
//
// Programmatic callers can construct RenderConfig directly and bypass the
// CLI's mutual-exclusivity guard. If both are set we honor crf (matches the
// CLI semantics where --crf is the explicit override) and warn loudly so
// the caller doesn't get a quietly-different bitrate than they passed in.
if (job.config.crf != null && job.config.videoBitrate) {
log.warn(
`[Render] Both crf=${job.config.crf} and videoBitrate=${job.config.videoBitrate} were set. ` +
`These are mutually exclusive; honoring crf and ignoring videoBitrate. ` +
`Set only one to silence this warning.`,
);
}
const effectiveQuality = job.config.crf ?? preset.quality;
const effectiveBitrate = job.config.crf != null ? undefined : job.config.videoBitrate;
job.framesRendered = 0;
// ── Z-ordered multi-layer compositing ─────────────────────────────────
// Per frame: query all elements' z-order, group into layers (DOM or HDR),
// composite bottom-to-top in Node.js memory. HDR layers use native
// pre-extracted pixels; DOM layers use Chrome alpha screenshots converted
// into the active rgb48le signal space. Shader transitions use this same
// 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;
updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
const hdrRes = await observeRenderStage(
observability,
"capture_hdr_layered",
{ workerCount, forceScreenshot: captureForceScreenshot, hasHdrContent },
() =>
runCaptureHdrStage({
job,
cfg,
forceScreenshot: captureForceScreenshot,
log,
projectDir,
compiledDir,
framesDir,
videoOnlyPath,
width,
height,
totalFrames,
composition,
hasHdrContent,
effectiveHdr,
nativeHdrVideoIds,
nativeHdrImageIds,
videoTransfers,
imageTransfers,
hdrImageSrcPaths,
preset,
effectiveQuality,
effectiveBitrate,
fileServer: activeFileServer,
buildCaptureOptions,
createRenderVideoFrameInjector,
hdrDiagnostics,
abortSignal,
assertNotAborted,
onProgress,
}),
);
lastBrowserConsole = hdrRes.lastBrowserConsole;
hdrPerf = hdrRes.hdrPerf;
perfStages.captureMs = hdrRes.captureDurationMs;
perfStages.encodeMs = hdrRes.encodeMs;
} else {
// ── Standard capture paths (SDR or DOM-only HDR) ──────────────────
// Streaming encode mode pipes frame buffers directly to FFmpeg stdin,
// skipping disk writes and the separate Stage 5 encode step. If the
// streaming spawn fails (non-abort) the stage returns { success: false }
// and we fall back to the disk path below.
let streamingHandled = false;
if (useStreamingEncode) {
const streamingRes = await observeRenderStage(
observability,
"capture_streaming",
{ workerCount, forceScreenshot: captureForceScreenshot },
() =>
runCaptureStreamingStage({
fileServer: activeFileServer,
workDir,
framesDir,
videoOnlyPath,
job,
totalFrames,
cfg,
forceScreenshot: captureForceScreenshot,
log,
workerCount,
probeSession,
outputFormat,
streamingEncoderOptions: {
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
imageFormat: captureOptions.format || "jpeg",
hdr: preset.hdr,
},
buildCaptureOptions,
createRenderVideoFrameInjector,
abortSignal,
assertNotAborted,
onProgress,
}),
);
if (streamingRes.success) {
streamingHandled = true;
workerCount = streamingRes.workerCount;
updateCaptureObservability({ workerCount });
probeSession = streamingRes.probeSession;
lastBrowserConsole = streamingRes.lastBrowserConsole;
perfStages.captureMs = Date.now() - stage4Start;
perfStages.encodeMs = streamingRes.encodeMs; // Overlapped with capture
} else {
useStreamingEncode = false;
updateCaptureObservability({ useStreamingEncode });
observability.checkpoint("capture_streaming", "spawn failed; falling back to disk");
}
}
if (!streamingHandled) {
// ── Disk-based capture (original flow) ────────────────────────────
const captureRes = await observeRenderStage(
observability,
"capture_disk",
{ workerCount, forceScreenshot: captureForceScreenshot, needsAlpha },
() =>
runCaptureStage({
fileServer: activeFileServer,
workDir,
framesDir,
job,
totalFrames,
cfg,
forceScreenshot: captureForceScreenshot,
log,
workerCount,
probeSession,
needsAlpha,
captureAttempts,
buildCaptureOptions,
createRenderVideoFrameInjector,
abortSignal,
assertNotAborted,
onProgress,
}),
);
workerCount = captureRes.workerCount;
updateCaptureObservability({ workerCount });
probeSession = captureRes.probeSession;
lastBrowserConsole = captureRes.lastBrowserConsole;
perfStages.captureMs = Date.now() - stage4Start;
const encodeRes = await observeRenderStage(
observability,
"encode",
{ hasAudio, isPngSequence, isGif, chunkedEncode: enableChunkedEncode },
() =>
runEncodeStage({
job,
log,
outputPath,
framesDir,
videoOnlyPath,
width,
height,
needsAlpha,
hasAudio,
audioOutputPath,
isPngSequence,
isGif,
preset,
effectiveQuality,
effectiveBitrate,
enableChunkedEncode,
chunkedEncodeSize,
engineConfig: cfg,
abortSignal,
assertNotAborted,
onProgress,
}),
);
perfStages.encodeMs = encodeRes.encodeMs;
}
} // end SDR capture paths block
if (probeSession !== null) {
const remainingProbeSession: CaptureSession = probeSession;
lastBrowserConsole = remainingProbeSession.browserConsoleBuffer;
await closeCaptureSession(remainingProbeSession);
probeSession = null;
}
if (frameLookup) frameLookup.cleanup();
// Stop file server
fileServer.close();
fileServer = null;
// ── Stage 6: Assemble ───────────────────────────────────────────────
// Skipped for formats with no mux/faststart step. png-sequence is a
// directory deliverable, and gif is written directly to outputPath by the
// two-pass palette encoder.
if (!isPngSequence && !isGif) {
const assembleRes = await observeRenderStage(observability, "assemble", { hasAudio }, () =>
runAssembleStage({
job,
videoOnlyPath,
audioOutputPath,
outputPath,
hasAudio,
abortSignal,
assertNotAborted,
onProgress,
}),
);
perfStages.assembleMs = assembleRes.assembleMs;
} else {
observability.checkpoint("assemble", `skipped for ${outputFormat}`);
}
// ── Complete ─────────────────────────────────────────────────────────
job.outputPath = outputPath;
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
const totalElapsed = Date.now() - pipelineStart;
const tmpPeakBytes = existsSync(workDir) ? sampleDirectoryBytes(workDir) : 0;
observability.checkpoint("pipeline", "completed", { totalElapsedMs: totalElapsed });
const observabilitySummary = observability.summary({
lastBrowserConsole,
capture: captureObservability,
extraction: extractionObservability,
compositionHash,
});
const perfSummary = buildRenderPerfSummary({
job,
workerCount,
enableChunkedEncode,
chunkedEncodeSize,
compositionDurationSeconds: composition.duration,
totalFrames,
outputWidth,
outputHeight,
videoCount: composition.videos.length,
audioCount: composition.audios.length,
totalElapsedMs: totalElapsed,
perfStages,
videoExtractBreakdown: extractionResult?.phaseBreakdown,
tmpPeakBytes,
captureCalibration,
captureAttempts,
hdrDiagnostics,
hdrPerf,
observability: observabilitySummary,
peakRssBytes: memSampler.peakRssBytes(),
peakHeapUsedBytes: memSampler.peakHeapUsedBytes(),
});
job.perfSummary = perfSummary;
if (job.config.debug) {
try {
writeFileSync(perfOutputPath, JSON.stringify(perfSummary, null, 2), "utf-8");
} catch (err) {
log.debug("Failed to write perf summary", {
perfOutputPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
// ── Cleanup ─────────────────────────────────────────────────────────
if (job.config.debug) {
// Copy output MP4 (or single-file alpha output) into the debug dir for
// easy access. Skipped for png-sequence: outputPath is a directory, not
// a single file — the captured frames already live in `framesDir` under
// workDir during a debug run anyway.
if (!isPngSequence && existsSync(outputPath)) {
const debugOutput = join(workDir, `output${videoExt}`);
copyFileSync(outputPath, debugOutput);
}
} else if (process.env.KEEP_TEMP === "1") {
log.info("KEEP_TEMP=1 — leaving workDir on disk for inspection", { workDir });
} else {
await safeCleanup(
"remove workDir",
() => {
rmSync(workDir, { recursive: true, force: true });
},
log,
);
}
if (restoreLogger) restoreLogger();
} catch (error) {
if (error instanceof RenderCancelledError || abortSignal?.aborted) {
job.error = error instanceof Error ? error.message : "render_cancelled";
updateJobStatus(job, "cancelled", "Render cancelled", job.progress, onProgress);
await cleanupRenderResources({
fileServer,
probeSession,
workDir,
debug: Boolean(job.config.debug),
log,
label: "cancel",
});
if (restoreLogger) restoreLogger();
throw error instanceof RenderCancelledError
? error
: new RenderCancelledError("render_cancelled");
}
const errorMessage = normalizeErrorMessage(error);
const carriedBrowserConsole = getCaptureStageBrowserConsole(error);
if (carriedBrowserConsole.length > 0) {
lastBrowserConsole = [...lastBrowserConsole, ...carriedBrowserConsole].slice(-200);
}
if (!observability.hasFailure()) {
const failureStart = Date.now();
observability.stageError(job.currentStage || "pipeline", failureStart, error);
}
// Suggest single-worker retry on parallel capture timeout.
// Video-heavy compositions often cause multi-worker timeouts because
// Chrome can't seek multiple video elements simultaneously.
const isTimeoutError =
errorMessage.includes("Waiting failed") ||
errorMessage.includes("timeout exceeded") ||
errorMessage.includes("Navigation timeout");
const wasParallel = job.config.workers !== 1;
if (isTimeoutError && wasParallel) {
log.warn(
`Parallel capture timed out with ${job.config.workers ?? "auto"} workers. ` +
`Video-heavy compositions often need sequential capture. Retry with --workers 1`,
);
}
job.error = errorMessage;
updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
job.failedStage = job.currentStage;
const observabilitySummary = observability.summary({
lastBrowserConsole,
capture: captureObservability,
extraction: extractionObservability,
compositionHash,
});
job.errorDetails = buildRenderErrorDetails({
error,
pipelineStartMs: pipelineStart,
lastBrowserConsole,
perfStages,
hdrDiagnostics,
observability: observabilitySummary,
});
log.info("[Render] Failure summary", {
failedStage: job.currentStage,
error: errorMessage,
elapsedMs: Date.now() - pipelineStart,
stageTimings: perfStages,
isTimeout: isTimeoutError,
workers: job.config.workers ?? "auto",
protocolTimeout: cfg.protocolTimeout,
observedFailedPhase: observabilitySummary.failedPhase,
observedLastPhase: observabilitySummary.lastEvent?.phase,
observedLastStatus: observabilitySummary.lastEvent?.status,
browserDiagnostics: observabilitySummary.browserDiagnostics,
extraction: observabilitySummary.extraction,
browserConsoleErrors: lastBrowserConsole
.filter(
(l) =>
l.includes("ERROR") ||
l.includes("PAGEERROR") ||
l.includes("REQUESTFAILED") ||
l.includes("[FrameCapture:NAV]") ||
/\[Browser:HTTP\d{3}\]/.test(l),
)
.slice(-5),
});
await cleanupRenderResources({
fileServer,
probeSession,
workDir,
debug: Boolean(job.config.debug),
log,
label: "error",
});
if (restoreLogger) restoreLogger();
throw error;
} finally {
memSampler?.stop();
}
}