Files
hyperframes/packages/producer/src/services/renderOrchestrator.ts
T

4185 lines
154 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Render Orchestrator Service
*
* Coordinates the entire video rendering pipeline:
* 1. Parse composition metadata
* 2. Pre-extract video frames
* 3. Pre-process audio tracks
* 4. Parallel frame capture
* 5. Video encoding
* 6. Final assembly (audio mux + faststart)
*
* Heavy observability: every stage logs timing, errors include
* full context, and failures produce a diagnostic summary.
*/
import {
existsSync,
mkdirSync,
rmSync,
readFileSync,
openSync,
readSync,
closeSync,
readdirSync,
statSync,
writeFileSync,
copyFileSync,
appendFileSync,
symlinkSync,
} from "fs";
import { parseHTML } from "linkedom";
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
import {
type EngineConfig,
resolveConfig,
extractAllVideoFrames,
resolveProjectRelativeSrc,
type ExtractedFrames,
type ExtractionPhaseBreakdown,
createFrameLookupTable,
type VideoElement,
FrameLookupTable,
type HdrTransfer,
detectTransfer,
createCaptureSession,
initializeSession,
closeCaptureSession,
captureFrame,
captureFrameToBuffer,
getCompositionDuration,
prepareCaptureSessionForReuse,
type CaptureOptions,
type CaptureVideoMetadataHint,
type CaptureSession,
type BeforeCaptureHook,
createVideoFrameInjector,
encodeFramesFromDir,
encodeFramesChunkedConcat,
muxVideoWithAudio,
applyFaststart,
getEncoderPreset,
processCompositionAudio,
type AudioElement,
type ImageElement,
calculateOptimalWorkers,
distributeFrames,
executeParallelCapture,
mergeWorkerFrames,
type ParallelProgress,
type WorkerTask,
spawnStreamingEncoder,
createFrameReorderBuffer,
type StreamingEncoder,
analyzeCompositionHdr,
isHdrColorSpace,
runFfmpeg,
extractMediaMetadata,
type VideoColorSpace,
initTransparentBackground,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
decodePng,
decodePngToRgb48le,
blitRgba8OverRgb48le,
blitRgb48leRegion,
queryElementStacking,
groupIntoLayers,
blitRgb48leAffine,
parseTransformMatrix,
TRANSITIONS,
crossfade,
convertTransfer,
resampleRgb48leObjectFit,
normalizeObjectFit,
type TransitionFn,
type ElementStackingInfo,
type HfTransitionMeta,
} from "@hyperframes/engine";
import { join, dirname, resolve, relative, isAbsolute, basename } from "path";
import { randomUUID } from "crypto";
import { freemem } from "os";
import { fileURLToPath } from "url";
import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "./fileServer.js";
import {
compileForRender,
resolveCompositionDurations,
recompileWithResolutions,
discoverMediaFromBrowser,
type CompiledComposition,
} from "./htmlCompiler.js";
import { defaultLogger, type ProducerLogger } from "../logger.js";
import { isPathInside } from "../utils/paths.js";
import {
type HdrImageTransferCache,
createHdrImageTransferCache,
} from "./hdrImageTransferCache.js";
/**
* Wrap a cleanup operation so it never throws, but logs any failure.
*/
async function safeCleanup(
label: string,
fn: () => Promise<void> | void,
log: ProducerLogger = defaultLogger,
): Promise<void> {
try {
await fn();
} catch (err) {
log.debug(`Cleanup failed (${label})`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
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;
}
// 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.
*/
type HdrTransitionMeta = HfTransitionMeta;
/** Pre-computed frame range for an active transition. */
interface TransitionRange extends HdrTransitionMeta {
startFrame: number;
endFrame: number;
}
export type RenderStatus =
| "queued"
| "preprocessing"
| "rendering"
| "encoding"
| "assembling"
| "complete"
| "failed"
| "cancelled";
export interface RenderConfig {
fps: 24 | 30 | 60;
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.
* - `"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";
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[];
/**
* 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;
}
export interface HdrPerfSummary {
frames: number;
normalFrames: number;
transitionFrames: number;
domLayerCaptures: number;
hdrVideoLayerBlits: number;
hdrImageLayerBlits: number;
timings: Record<string, number>;
avgMs: Record<string, number>;
}
type HdrPerfTimingKey =
| "frameSeekMs"
| "frameInjectMs"
| "stackingQueryMs"
| "canvasClearMs"
| "normalCompositeMs"
| "transitionCompositeMs"
| "encoderWriteMs"
| "hdrVideoReadDecodeMs"
| "hdrVideoTransferMs"
| "hdrVideoBlitMs"
| "hdrImageTransferMs"
| "hdrImageBlitMs"
| "domLayerSeekMs"
| "domLayerInjectMs"
| "domMaskApplyMs"
| "domScreenshotMs"
| "domMaskRemoveMs"
| "domPngDecodeMs"
| "domBlitMs";
interface HdrPerfCollector {
frames: number;
normalFrames: number;
transitionFrames: number;
domLayerCaptures: number;
hdrVideoLayerBlits: number;
hdrImageLayerBlits: number;
timings: Record<HdrPerfTimingKey, number>;
}
function createHdrPerfCollector(): HdrPerfCollector {
return {
frames: 0,
normalFrames: 0,
transitionFrames: 0,
domLayerCaptures: 0,
hdrVideoLayerBlits: 0,
hdrImageLayerBlits: 0,
timings: {
frameSeekMs: 0,
frameInjectMs: 0,
stackingQueryMs: 0,
canvasClearMs: 0,
normalCompositeMs: 0,
transitionCompositeMs: 0,
encoderWriteMs: 0,
hdrVideoReadDecodeMs: 0,
hdrVideoTransferMs: 0,
hdrVideoBlitMs: 0,
hdrImageTransferMs: 0,
hdrImageBlitMs: 0,
domLayerSeekMs: 0,
domLayerInjectMs: 0,
domMaskApplyMs: 0,
domScreenshotMs: 0,
domMaskRemoveMs: 0,
domPngDecodeMs: 0,
domBlitMs: 0,
},
};
}
function addHdrTiming(perf: HdrPerfCollector | undefined, key: HdrPerfTimingKey, startMs: number) {
if (!perf) return;
perf.timings[key] += Date.now() - startMs;
}
function averageTiming(totalMs: number, count: number): number {
return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
}
function finalizeHdrPerf(perf: HdrPerfCollector): HdrPerfSummary {
const avgMs: Record<string, number> = {};
const perFrameKeys: HdrPerfTimingKey[] = [
"frameSeekMs",
"frameInjectMs",
"stackingQueryMs",
"canvasClearMs",
"encoderWriteMs",
];
for (const key of perFrameKeys) avgMs[key] = averageTiming(perf.timings[key], perf.frames);
avgMs.normalCompositeMs = averageTiming(perf.timings.normalCompositeMs, perf.normalFrames);
avgMs.transitionCompositeMs = averageTiming(
perf.timings.transitionCompositeMs,
perf.transitionFrames,
);
const perDomLayerKeys: HdrPerfTimingKey[] = [
"domLayerSeekMs",
"domLayerInjectMs",
"domMaskApplyMs",
"domScreenshotMs",
"domMaskRemoveMs",
"domPngDecodeMs",
"domBlitMs",
];
for (const key of perDomLayerKeys) {
avgMs[key] = averageTiming(perf.timings[key], perf.domLayerCaptures);
}
const perHdrVideoKeys: HdrPerfTimingKey[] = [
"hdrVideoReadDecodeMs",
"hdrVideoTransferMs",
"hdrVideoBlitMs",
];
for (const key of perHdrVideoKeys) {
avgMs[key] = averageTiming(perf.timings[key], perf.hdrVideoLayerBlits);
}
const perHdrImageKeys: HdrPerfTimingKey[] = ["hdrImageTransferMs", "hdrImageBlitMs"];
for (const key of perHdrImageKeys) {
avgMs[key] = averageTiming(perf.timings[key], perf.hdrImageLayerBlits);
}
return {
frames: perf.frames,
normalFrames: perf.normalFrames,
transitionFrames: perf.transitionFrames,
domLayerCaptures: perf.domLayerCaptures,
hdrVideoLayerBlits: perf.hdrVideoLayerBlits,
hdrImageLayerBlits: perf.hdrImageLayerBlits,
timings: { ...perf.timings },
avgMs,
};
}
export interface CaptureCostEstimate {
multiplier: number;
reasons: string[];
p95Ms?: number;
}
export interface CaptureCalibrationSample {
frameIndex: number;
captureTimeMs: number;
}
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;
};
}
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;
}
}
export interface CompositionMetadata {
duration: number;
videos: VideoElement[];
audios: AudioElement[];
images: ImageElement[];
width: number;
height: number;
}
const BROWSER_MEDIA_EPSILON = 0.0001;
/**
* Browser-discovered media inside inlined sub-compositions can still report
* scene-local timing from the merged DOM (e.g. start=0, end=85.52) while the
* compiled metadata is already offset into the parent host timeline
* (e.g. start=4.417, end=89.937). Reproject browser end-time into the
* compiled element's time origin before reconciling it back into the render
* metadata.
*/
export function projectBrowserEndToCompositionTimeline(
existingStart: number,
browserStart: number,
browserEnd: number,
): number {
return browserEnd + (existingStart - browserStart);
}
/**
* Translate the user-facing `--resolution` flag into a Chrome
* `deviceScaleFactor`. The composition's intrinsic dimensions stay the
* page-layout viewport; the screenshot lands at output dims via DPR.
*
* The scale must be a positive integer ≥ 1 — fractional DPRs introduce
* visible aliasing and we'd rather fail loudly than produce a blurry
* 4K render. Downsampling (output < composition) is rejected because
* the user is unlikely to have intended it; if the use case appears
* we can plumb a separate flag.
*
* Throws on:
* - HDR + outputResolution (HDR compositor processes raw pixel buffers
* at composition dimensions and would need parallel scaling).
* - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k).
* - Non-integer scale ratio.
* - Downsampling (output dimensions smaller than composition).
*/
export function resolveDeviceScaleFactor(input: {
compositionWidth: number;
compositionHeight: number;
outputResolution: CanvasResolution | undefined;
hdrRequested: boolean;
alphaRequested: boolean;
}): number {
if (!input.outputResolution) return 1;
if (input.hdrRequested) {
throw new Error(
"outputResolution cannot be combined with hdrMode='force-hdr'. " +
"HDR rendering composites at composition dimensions and does not yet " +
"support supersampling. Pick one or render in two passes.",
);
}
if (input.alphaRequested) {
throw new Error(
"outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). " +
"The alpha screenshot path does not yet apply deviceScaleFactor and would silently " +
"produce composition-resolution frames. Render alpha at composition resolution and " +
"upscale separately, or use --format mp4.",
);
}
const target = CANVAS_DIMENSIONS[input.outputResolution];
// Aspect-ratio compare via cross-multiplication so the equality is integer-
// safe. Float division (`target.width / compositionWidth`) loses precision
// for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a
// future preset could trip a false-mismatch on otherwise valid input.
if (target.width * input.compositionHeight !== target.height * input.compositionWidth) {
throw new Error(
`outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +
`does not match the aspect ratio of the composition ` +
`(${input.compositionWidth}×${input.compositionHeight}). ` +
`Pick a preset whose orientation matches.`,
);
}
// Aspect ratios match → widthRatio === heightRatio. Compute once.
const widthRatio = target.width / input.compositionWidth;
if (widthRatio < 1) {
throw new Error(
`outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +
`is smaller than the composition (${input.compositionWidth}×${input.compositionHeight}). ` +
`Downsampling via --resolution is not supported.`,
);
}
if (!Number.isInteger(widthRatio)) {
throw new Error(
`outputResolution ${input.outputResolution} requires a non-integer ` +
`device scale factor (${widthRatio}×) to upsample from ` +
`${input.compositionWidth}×${input.compositionHeight}. ` +
`Pick a preset that's an integer multiple, or rescale the composition.`,
);
}
return widthRatio;
}
function updateJobStatus(
job: RenderJob,
status: RenderStatus,
stage: string,
progress: number,
onProgress?: ProgressCallback,
): void {
job.status = status;
job.currentStage = stage;
job.progress = progress;
if (status === "failed" || status === "complete") job.completedAt = new Date();
if (onProgress) onProgress(job, stage);
}
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;
};
}
/**
* Write compiled HTML and sub-compositions to the work directory.
*/
// Exported for integration tests. Not part of the stable public API —
// callers outside this package should use `executeRenderJob` instead.
export function writeCompiledArtifacts(
compiled: CompiledComposition,
workDir: string,
includeSummary: boolean,
): void {
const compileDir = join(workDir, "compiled");
mkdirSync(compileDir, { recursive: true });
writeFileSync(join(compileDir, "index.html"), compiled.html, "utf-8");
for (const [srcPath, html] of compiled.subCompositions) {
const outPath = join(compileDir, srcPath);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, html, "utf-8");
}
// Copy external assets (files outside projectDir) into the compiled directory
// so the file server can serve them. The safe-path check uses
// `isPathInside()` rather than a hardcoded separator — on Windows,
// `compileDir + "/"` never matches because paths use `\\`, which caused
// every external asset to be wrongly rejected as "unsafe" (see GH #321).
for (const [relativePath, absolutePath] of compiled.externalAssets) {
const outPath = resolve(join(compileDir, relativePath));
if (!isPathInside(outPath, compileDir)) {
console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`);
continue;
}
mkdirSync(dirname(outPath), { recursive: true });
copyFileSync(absolutePath, outPath);
}
if (includeSummary) {
const summary = {
width: compiled.width,
height: compiled.height,
staticDuration: compiled.staticDuration,
videos: compiled.videos.map((v) => ({
id: v.id,
src: v.src,
start: v.start,
end: v.end,
mediaStart: v.mediaStart,
})),
audios: compiled.audios.map((a) => ({
id: a.id,
src: a.src,
start: a.start,
end: a.end,
mediaStart: a.mediaStart,
})),
subCompositions: Array.from(compiled.subCompositions.keys()),
renderModeHints: compiled.renderModeHints,
hasShaderTransitions: compiled.hasShaderTransitions,
};
writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
}
}
export function createCompiledFrameSrcResolver(
compiledDir: string,
): (framePath: string) => string | null {
const compiledRoot = resolve(compiledDir);
return (framePath: string): string | null => {
const resolvedFramePath = resolve(framePath);
if (!isPathInside(resolvedFramePath, compiledRoot)) return null;
const relativePath = relative(compiledRoot, resolvedFramePath);
if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) {
return null;
}
return `/${relativePath
.split(/[\\/]+/)
.map((segment) => encodeURIComponent(segment))
.join("/")}`;
};
}
type MaterializedExtractedFrames = Pick<ExtractedFrames, "videoId" | "outputDir" | "framePaths">;
type MaterializePathModule = {
resolve: (...segments: string[]) => string;
join: (...segments: string[]) => string;
dirname: (path: string) => string;
basename: (path: string) => string;
relative: (from: string, to: string) => string;
isAbsolute: (path: string) => boolean;
};
type MaterializeFileSystem = {
existsSync: (path: string) => boolean;
mkdirSync: (path: string, options: { recursive: true }) => unknown;
symlinkSync: (target: string, path: string) => unknown;
};
type MaterializeExtractedFramesOptions = {
pathModule?: MaterializePathModule;
fileSystem?: MaterializeFileSystem;
};
const materializePathModule: MaterializePathModule = {
resolve,
join,
dirname,
basename,
relative,
isAbsolute,
};
const materializeFileSystem: MaterializeFileSystem = {
existsSync,
mkdirSync,
symlinkSync,
};
export function materializeExtractedFramesForCompiledDir(
extracted: MaterializedExtractedFrames[],
compiledDir: string,
options: MaterializeExtractedFramesOptions = {},
): void {
const pathModule = options.pathModule ?? materializePathModule;
const fileSystem = options.fileSystem ?? materializeFileSystem;
const resolvedCompiledDir = pathModule.resolve(compiledDir);
const compiledFrameRoot = pathModule.join(resolvedCompiledDir, "__hyperframes_video_frames");
for (const ext of extracted) {
const resolvedOut = pathModule.resolve(ext.outputDir);
if (isPathInside(resolvedOut, resolvedCompiledDir, { pathModule })) continue;
const linkPath = pathModule.join(compiledFrameRoot, ext.videoId);
if (!fileSystem.existsSync(linkPath)) {
fileSystem.mkdirSync(pathModule.dirname(linkPath), { recursive: true });
fileSystem.symlinkSync(resolvedOut, linkPath);
}
const remapped = new Map<number, string>();
for (const [idx, framePath] of ext.framePaths) {
remapped.set(idx, pathModule.join(linkPath, pathModule.basename(framePath)));
}
ext.framePaths = remapped;
ext.outputDir = linkPath;
}
}
export function applyRenderModeHints(
cfg: EngineConfig,
compiled: CompiledComposition,
log: ProducerLogger = defaultLogger,
): void {
if (cfg.forceScreenshot || !compiled.renderModeHints.recommendScreenshot) return;
cfg.forceScreenshot = true;
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),
});
}
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 resolveRenderWorkerCount(
totalFrames: number,
requestedWorkers: number | undefined,
cfg: EngineConfig,
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
log: ProducerLogger = defaultLogger,
measuredCaptureCost?: CaptureCostEstimate,
): number {
const captureCost = combineCaptureCostEstimates(
estimateCaptureCostMultiplier(compiled),
measuredCaptureCost,
);
const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
...cfg,
captureCostMultiplier: captureCost.multiplier,
});
if (requestedWorkers !== undefined || captureCost.multiplier <= 1) {
return workerCount;
}
const baselineWorkers = calculateOptimalWorkers(totalFrames, undefined, cfg);
if (workerCount < baselineWorkers) {
log.warn(
"[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
{
from: baselineWorkers,
to: workerCount,
costMultiplier: captureCost.multiplier,
reasons: captureCost.reasons,
},
);
}
return workerCount;
}
export function estimateCaptureCostMultiplier(
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
): CaptureCostEstimate {
let multiplier = 1;
const reasons: string[] = [];
if (compiled.hasShaderTransitions) {
multiplier += 2;
reasons.push("shader-transitions");
}
const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
if (reasonCodes.has("requestAnimationFrame")) {
multiplier += 1;
reasons.push("requestAnimationFrame");
}
if (reasonCodes.has("iframe")) {
multiplier += 0.5;
reasons.push("iframe");
}
return {
multiplier: Math.round(multiplier * 100) / 100,
reasons,
};
}
function combineCaptureCostEstimates(
staticCost: CaptureCostEstimate,
measuredCost?: CaptureCostEstimate,
): CaptureCostEstimate {
if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
if (staticCost.multiplier >= measuredCost.multiplier) {
return {
multiplier: staticCost.multiplier,
reasons: [...staticCost.reasons, ...measuredCost.reasons],
p95Ms: measuredCost.p95Ms,
};
}
return {
multiplier: measuredCost.multiplier,
reasons: [...measuredCost.reasons, ...staticCost.reasons],
p95Ms: measuredCost.p95Ms,
};
}
const CAPTURE_CALIBRATION_TARGET_MS = 600;
const MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
const CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 30_000;
export function createCaptureCalibrationConfig(cfg: EngineConfig): EngineConfig {
return {
...cfg,
protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS),
};
}
export function estimateMeasuredCaptureCostMultiplier(
samples: CaptureCalibrationSample[],
): CaptureCostEstimate {
if (samples.length === 0) {
return { multiplier: 1, reasons: [] };
}
const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
if (!p95Sample) {
return { multiplier: 1, reasons: [] };
}
const p95Ms = Math.round(p95Sample.captureTimeMs);
const multiplier = Math.min(
MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
Math.max(1, Math.round((p95Ms / CAPTURE_CALIBRATION_TARGET_MS) * 100) / 100),
);
return {
multiplier,
reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
p95Ms,
};
}
export function selectCaptureCalibrationFrames(totalFrames: number): number[] {
if (totalFrames <= 0) return [];
const lastFrame = totalFrames - 1;
const candidates = [
0,
Math.floor(totalFrames * 0.25),
Math.floor(totalFrames * 0.5),
Math.floor(totalFrames * 0.75),
lastFrame,
];
return Array.from(
new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame)))),
).sort((a, b) => a - b);
}
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,
): WorkerTask[][] {
const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
const batches: WorkerTask[][] = [];
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: range.startFrame,
endFrame: range.endFrame,
outputDir: join(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
}));
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 = error instanceof Error ? error.message : String(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,
)
);
}
export function shouldFallbackToScreenshotAfterCalibrationError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame|Runtime\.callFunctionOn timed out|Runtime\.evaluate timed out/i.test(
message,
);
}
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);
}
async function measureCaptureCostFromSession(
session: CaptureSession,
totalFrames: number,
fps: number,
): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> {
const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
const samples: CaptureCalibrationSample[] = [];
for (const frameIndex of sampledFrames) {
const time = frameIndex / fps;
const startedAt = Date.now();
const result = await captureFrameToBuffer(session, frameIndex, time);
samples.push({
frameIndex,
captureTimeMs: result.captureTimeMs || Date.now() - startedAt,
});
}
return {
estimate: estimateMeasuredCaptureCostMultiplier(samples),
samples,
};
}
function logCaptureCalibrationResult(
calibration: { estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] },
log: ProducerLogger,
): void {
if (calibration.estimate.multiplier > 1) {
log.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
multiplier: calibration.estimate.multiplier,
p95Ms: calibration.estimate.p95Ms,
sampledFrames: calibration.samples.map((sample) => sample.frameIndex),
});
} else {
log.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
p95Ms: calibration.estimate.p95Ms,
sampledFrames: calibration.samples.map((sample) => sample.frameIndex),
});
}
}
function createFailedCaptureCalibrationEstimate(reason: string): {
estimate: CaptureCostEstimate;
samples: CaptureCalibrationSample[];
} {
return {
estimate: {
multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
reasons: [reason],
},
samples: [],
};
}
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;
}): Promise<CaptureAttemptSummary[]> {
const attempts: CaptureAttemptSummary[] = [];
let currentWorkers = options.initialWorkerCount;
let missingRanges: FrameRange[] | null = null;
let attempt = 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)
: [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir)];
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.
*/
interface HdrVideoFrameSource {
dir: string;
rawPath: string;
fd: number;
width: number;
height: number;
frameSize: number;
frameCount: number;
scratch: Buffer;
}
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),
});
}
}
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.
*/
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.
*/
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";
}
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).
*/
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);
writeFileSync(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 function createRenderJob(config: RenderConfig): RenderJob {
return {
id: randomUUID(),
config,
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 (!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();
}
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 workDir = job.config.debug
? join(debugDir, job.id)
: join(dirname(outputPath), `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 outputFormat = (job.config.format ?? "mp4") as NonNullable<RenderConfig["format"]>;
const isWebm = outputFormat === "webm";
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;
}
const enableChunkedEncode = cfg.enableChunkedEncode;
const chunkedEncodeSize = cfg.chunkSizeFrames;
// Periodic memory sampler — surfaces peak RSS/heap so the benchmark harness
// can detect memory regressions (e.g. unbounded image-cache growth) that
// wall-clock numbers miss. Sampled every 250ms; the interval is `unref`'d so
// it never keeps the event loop alive on its own, and always cleared in the
// finally block below regardless of how the render exits.
let peakRssBytes = 0;
let peakHeapUsedBytes = 0;
const sampleMemory = (): void => {
try {
const m = process.memoryUsage();
if (m.rss > peakRssBytes) peakRssBytes = m.rss;
if (m.heapUsed > peakHeapUsedBytes) peakHeapUsedBytes = m.heapUsed;
} catch {
// Defensive: process.memoryUsage() shouldn't throw, but if it ever
// does we don't want to take down the render for a benchmark accessory.
}
};
sampleMemory();
const memSamplerInterval: NodeJS.Timeout = setInterval(sampleMemory, 250);
memSamplerInterval.unref?.();
try {
const assertNotAborted = () => {
if (abortSignal?.aborted) {
throw new RenderCancelledError("render_cancelled");
}
};
job.startedAt = new Date();
assertNotAborted();
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 compileStart = Date.now();
let compiled = await compileForRender(projectDir, htmlPath, join(workDir, "downloads"));
assertNotAborted();
perfStages.compileOnlyMs = Date.now() - compileStart;
applyRenderModeHints(cfg, compiled, log);
writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug));
log.info("Compiled composition metadata", {
entryFile,
staticDuration: compiled.staticDuration,
width: compiled.width,
height: compiled.height,
videoCount: compiled.videos.length,
audioCount: compiled.audios.length,
renderModeHints: compiled.renderModeHints,
});
const composition: CompositionMetadata = {
duration: compiled.staticDuration,
videos: compiled.videos,
audios: compiled.audios,
images: compiled.images,
width: compiled.width,
height: compiled.height,
};
const { width, height } = composition;
const deviceScaleFactor = resolveDeviceScaleFactor({
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
hdrRequested: job.config.hdrMode === "force-hdr",
alphaRequested: needsAlpha,
});
const outputWidth = width * deviceScaleFactor;
const outputHeight = height * deviceScaleFactor;
if (deviceScaleFactor > 1) {
log.info("Supersampling composition via deviceScaleFactor", {
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
outputWidth,
outputHeight,
deviceScaleFactor,
});
}
const probeStart = Date.now();
const needsBrowser = composition.duration <= 0 || compiled.unresolvedCompositions.length > 0;
if (needsBrowser) {
const reasons = [];
if (composition.duration <= 0) reasons.push("root duration unknown");
if (compiled.unresolvedCompositions.length > 0)
reasons.push(`${compiled.unresolvedCompositions.length} unresolved composition(s)`);
fileServer = await createFileServer({
projectDir,
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
});
assertNotAborted();
const captureOpts: CaptureOptions = {
width,
height,
fps: job.config.fps,
format: needsAlpha ? "png" : "jpeg",
quality: needsAlpha ? undefined : 80,
deviceScaleFactor,
};
probeSession = await createCaptureSession(
fileServer.url,
join(workDir, "probe"),
captureOpts,
null,
cfg,
);
await initializeSession(probeSession);
assertNotAborted();
lastBrowserConsole = probeSession.browserConsoleBuffer;
// Discover root composition duration
if (composition.duration <= 0) {
const discoveredDuration = await getCompositionDuration(probeSession);
assertNotAborted();
log.info("Probed composition duration from browser", {
discoveredDuration,
staticDuration: compiled.staticDuration,
});
composition.duration = discoveredDuration;
} else {
log.info("Using static duration from data-duration attribute", {
duration: composition.duration,
});
}
// Resolve unresolved composition durations via window.__timelines
if (compiled.unresolvedCompositions.length > 0) {
const resolutions = await resolveCompositionDurations(
probeSession.page,
compiled.unresolvedCompositions,
);
assertNotAborted();
if (resolutions.length > 0) {
compiled = await recompileWithResolutions(
compiled,
resolutions,
projectDir,
join(workDir, "downloads"),
);
assertNotAborted();
// Update composition metadata with re-parsed media
composition.videos = compiled.videos;
composition.audios = compiled.audios;
composition.images = compiled.images;
writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug));
}
}
// Discover media elements from browser DOM (catches dynamically-set src)
const browserMedia = await discoverMediaFromBrowser(probeSession.page);
assertNotAborted();
if (browserMedia.length > 0) {
const existingVideoIds = new Set(composition.videos.map((v) => v.id));
const existingAudioIds = new Set(composition.audios.map((a) => a.id));
for (const el of browserMedia) {
if (!el.src || el.src === "about:blank") continue;
// Convert absolute localhost URLs back to relative paths
let src = el.src;
if (fileServer && src.startsWith(fileServer.url)) {
src = src.slice(fileServer.url.length).replace(/^\//, "");
}
if (el.tagName === "video") {
if (existingVideoIds.has(el.id)) {
// Reconcile to browser/runtime media metadata (runtime src can differ from static HTML).
const existing = composition.videos.find((v) => v.id === el.id);
if (existing) {
if (existing.src !== src) {
existing.src = src;
}
const projectedEnd = projectBrowserEndToCompositionTimeline(
existing.start,
el.start,
el.end,
);
if (
projectedEnd > 0 &&
(existing.end <= 0 ||
Math.abs(existing.end - projectedEnd) > BROWSER_MEDIA_EPSILON)
) {
existing.end = projectedEnd;
}
if (
el.mediaStart > 0 &&
(existing.mediaStart <= 0 ||
Math.abs(existing.mediaStart - el.mediaStart) > BROWSER_MEDIA_EPSILON)
) {
existing.mediaStart = el.mediaStart;
}
if (el.hasAudio && !existing.hasAudio) {
existing.hasAudio = true;
}
if (el.loop && !existing.loop) {
existing.loop = true;
}
}
} else {
// New video discovered from browser
composition.videos.push({
id: el.id,
src,
start: el.start,
end: el.end,
mediaStart: el.mediaStart,
loop: el.loop,
hasAudio: el.hasAudio,
});
existingVideoIds.add(el.id);
}
} else if (el.tagName === "audio") {
if (existingAudioIds.has(el.id)) {
const existing = composition.audios.find((a) => a.id === el.id);
if (existing) {
if (existing.src !== src) {
existing.src = src;
}
const projectedEnd = projectBrowserEndToCompositionTimeline(
existing.start,
el.start,
el.end,
);
if (
projectedEnd > 0 &&
(existing.end <= 0 ||
Math.abs(existing.end - projectedEnd) > BROWSER_MEDIA_EPSILON)
) {
existing.end = projectedEnd;
}
if (
el.mediaStart > 0 &&
(existing.mediaStart <= 0 ||
Math.abs(existing.mediaStart - el.mediaStart) > BROWSER_MEDIA_EPSILON)
) {
existing.mediaStart = el.mediaStart;
}
if (
el.volume > 0 &&
Math.abs((existing.volume ?? 1) - el.volume) > BROWSER_MEDIA_EPSILON
) {
existing.volume = el.volume;
}
}
} else {
composition.audios.push({
id: el.id,
src,
start: el.start,
end: el.end,
mediaStart: el.mediaStart,
layer: 0,
volume: el.volume,
type: "audio",
});
existingAudioIds.add(el.id);
}
}
}
}
}
perfStages.browserProbeMs = Date.now() - probeStart;
job.duration = composition.duration;
job.totalFrames = Math.ceil(composition.duration * job.config.fps);
const totalFrames = job.totalFrames;
if (job.duration <= 0) {
// Gather diagnostics to help users understand why the render would produce a black video.
// Wrapped in try/catch because the browser tab may have crashed (which could be
// WHY duration is 0), and we don't want a Puppeteer error to mask the real message.
const diagnostics: string[] = [];
try {
if (probeSession) {
const timelinesInfo = await probeSession.page.evaluate(() => {
const tl = (window as any).__timelines;
const hf = (window as any).__hf;
return {
timelineKeys: tl ? Object.keys(tl) : [],
hfDuration: hf?.duration ?? null,
gsapLoaded: typeof (window as any).gsap !== "undefined",
};
});
if (!timelinesInfo.gsapLoaded) {
diagnostics.push(
"GSAP is not loaded — CDN script may have failed to download. " +
"Bundle GSAP locally in your project instead of using a CDN <script src>.",
);
} else if (timelinesInfo.timelineKeys.length === 0) {
diagnostics.push(
"GSAP is loaded but no timelines were registered on window.__timelines. " +
"Ensure your script creates a timeline and assigns it: " +
'window.__timelines["main"] = gsap.timeline({ paused: true });',
);
}
for (const line of probeSession.browserConsoleBuffer) {
if (/\[Browser:ERROR\]|\[Browser:PAGEERROR\]|404|net::ERR_/i.test(line)) {
diagnostics.push(`Browser: ${line}`);
}
}
}
} catch (err) {
log.warn("Failed to gather browser diagnostics for zero-duration composition", {
error: err instanceof Error ? err.message : String(err),
});
diagnostics.push("(Could not gather browser diagnostics — page may have crashed)");
}
const hint =
diagnostics.length > 0
? "\n\nDiagnostics:\n - " + diagnostics.join("\n - ")
: "\n\nCheck that GSAP timelines are registered on window.__timelines.";
throw new Error("Composition duration is 0 — this would produce a black video." + hint);
}
// Surface browser-side asset failures (404s, script errors) as warnings.
// These don't block the render but indicate missing images, fonts, or
// scripts that may produce unexpected visual artifacts.
if (probeSession) {
const failedRequests = probeSession.browserConsoleBuffer.filter((line) =>
/404|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|net::ERR_/i.test(line),
);
if (failedRequests.length > 0) {
log.warn("Browser encountered network failures during page load:", {
failures: failedRequests.slice(0, 10),
});
for (const line of failedRequests.slice(0, 5)) {
console.warn(`[Render] Asset load failure: ${line}`);
}
}
}
perfStages.compileMs = Date.now() - stage1Start;
// ── Stage 2: Video frame extraction ─────────────────────────────────
const stage2Start = Date.now();
updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress);
let frameLookup: FrameLookupTable | null = null;
const compiledDir = join(workDir, "compiled");
let extractionResult: Awaited<ReturnType<typeof extractAllVideoFrames>> | null = null;
let videoReadinessSkipIds: string[] = [];
let videoMetadataHints: CaptureVideoMetadataHint[] = [];
// Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR).
// This is needed to identify which videos are natively HDR vs converted-SDR
// for the two-pass compositing path. Skipped only in force-sdr mode to
// avoid ffprobe overhead when the user has explicitly opted out.
const nativeHdrVideoIds = new Set<string>();
const videoTransfers = new Map<string, HdrTransfer>();
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
await Promise.all(
composition.videos.map(async (v) => {
// Use the shared resolver so a `<video src="../assets/foo">` in a
// sub-composition resolves the same way the browser would (see
// resolveProjectRelativeSrc in videoFrameExtractor for the full
// explanation). isAbsolute (not `startsWith("/")`) so Windows
// absolute paths like `C:\...` skip the join correctly.
const videoPath = isAbsolute(v.src)
? v.src
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
if (!existsSync(videoPath)) return;
const meta = await extractMediaMetadata(videoPath);
if (isHdrColorSpace(meta.colorSpace)) {
nativeHdrVideoIds.add(v.id);
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
}
}),
);
}
// Probe images for HDR color spaces (16-bit PNGs tagged BT.2020 PQ/HLG).
// Mirrors the video probe loop above so image-only compositions can
// trigger HDR output without any video sources present. Skipped only in
// force-sdr mode to avoid ffprobe overhead when the user has explicitly
// opted out.
const nativeHdrImageIds = new Set<string>();
const imageTransfers = new Map<string, HdrTransfer>();
const hdrImageSrcPaths = new Map<string, string>();
const imageColorSpaces: (VideoColorSpace | null)[] = [];
if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
const probed = await Promise.all(
composition.images.map(async (img) => {
let imgPath = img.src;
if (!imgPath.startsWith("/")) {
const fromCompiled = existsSync(join(compiledDir, imgPath))
? join(compiledDir, imgPath)
: join(projectDir, imgPath);
imgPath = fromCompiled;
}
if (!existsSync(imgPath)) return null;
const meta = await extractMediaMetadata(imgPath);
if (isHdrColorSpace(meta.colorSpace)) {
nativeHdrImageIds.add(img.id);
imageTransfers.set(img.id, detectTransfer(meta.colorSpace));
hdrImageSrcPaths.set(img.id, imgPath);
}
return meta.colorSpace;
}),
);
imageColorSpaces.push(...probed);
}
if (composition.videos.length > 0) {
extractionResult = await extractAllVideoFrames(
composition.videos,
projectDir,
{ fps: job.config.fps, outputDir: join(compiledDir, "__hyperframes_video_frames") },
abortSignal,
{ extractCacheDir: cfg.extractCacheDir },
compiledDir,
);
assertNotAborted();
materializeExtractedFramesForCompiledDir(extractionResult.extracted, compiledDir);
if (extractionResult.extracted.length > 0) {
frameLookup = createFrameLookupTable(composition.videos, extractionResult.extracted);
}
videoReadinessSkipIds = collectVideoReadinessSkipIds(
nativeHdrVideoIds,
extractionResult.extracted,
);
videoMetadataHints = collectVideoMetadataHints(extractionResult.extracted);
perfStages.videoExtractMs = Date.now() - stage2Start;
// Auto-detect audio from video files via ffprobe metadata
const existingAudioSrcs = new Set(composition.audios.map((a) => a.src));
for (const ext of extractionResult.extracted) {
if (ext.metadata.hasAudio) {
const video = composition.videos.find((v) => v.id === ext.videoId);
if (video && !existingAudioSrcs.has(video.src)) {
composition.audios.push({
id: `${video.id}-audio`,
src: video.src,
start: video.start,
end: video.end,
mediaStart: video.mediaStart,
layer: 0,
volume: 1.0,
type: "video",
});
existingAudioSrcs.add(video.src);
}
}
}
} else {
perfStages.videoExtractMs = Date.now() - stage2Start;
}
// ── HDR auto-detection ──────────────────────────────────────────────
// Analyze probed video AND image color spaces. In auto mode, any HDR
// source enables HDR output. force-hdr always enables HDR, and force-sdr
// always disables it. Image-only compositions can trigger HDR output
// without any video.
let effectiveHdr: { transfer: HdrTransfer } | undefined;
let forcedHdrWithoutSources = false;
{
const hdrMode = job.config.hdrMode ?? "auto";
const videoColorSpaces = (extractionResult?.extracted ?? []).map(
(ext) => ext.metadata.colorSpace,
);
const allColorSpaces = [...videoColorSpaces, ...imageColorSpaces];
const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null;
if (hdrMode === "force-sdr") {
effectiveHdr = undefined;
} else if (hdrMode === "force-hdr") {
if (info?.hasHdr && info.dominantTransfer) {
effectiveHdr = { transfer: info.dominantTransfer };
} else {
effectiveHdr = { transfer: "hlg" };
forcedHdrWithoutSources = true;
}
} else {
if (info?.hasHdr && info.dominantTransfer) {
effectiveHdr = { transfer: info.dominantTransfer };
}
}
}
if (effectiveHdr && outputFormat !== "mp4") {
const hdrSourceReason = forcedHdrWithoutSources
? "HDR was forced without detected HDR sources"
: "HDR source detected";
log.warn(
`[Render] ${hdrSourceReason}, but format is "${outputFormat}" — falling back to SDR. ` +
`HDR + alpha is not supported. Use --format mp4 for HDR10 output.`,
);
effectiveHdr = undefined;
}
{
const hdrMode = job.config.hdrMode ?? "auto";
if (forcedHdrWithoutSources) {
log.warn(
"[Render] HDR forced by --hdr flag, but no HDR sources were detected — defaulting to HLG. SDR-only compositions may look perceptually wrong on HDR displays.",
);
}
if (effectiveHdr) {
const reason =
hdrMode === "force-hdr"
? forcedHdrWithoutSources
? "forced by --hdr flag (no HDR sources detected — defaulting to HLG)"
: "forced by --hdr flag"
: "auto-detected from source(s)";
log.info(
`[Render] HDR ${reason} — output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`,
);
} else if (hdrMode === "force-sdr") {
log.info("[Render] SDR forced by --sdr flag");
} else {
log.info("[Render] No HDR sources detected — rendering SDR");
}
}
// ── Stage 3: Audio processing ───────────────────────────────────────
const stage3Start = Date.now();
updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
const audioOutputPath = join(workDir, "audio.aac");
let hasAudio = false;
if (composition.audios.length > 0) {
const audioResult = await processCompositionAudio(
composition.audios,
projectDir,
join(workDir, "audio-work"),
audioOutputPath,
job.duration,
abortSignal,
undefined,
compiledDir,
);
assertNotAborted();
hasAudio = audioResult.success;
perfStages.audioProcessMs = Date.now() - stage3Start;
} else {
perfStages.audioProcessMs = Date.now() - stage3Start;
}
// ── 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)
if (!fileServer) {
fileServer = await createFileServer({
projectDir,
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
});
assertNotAborted();
}
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;
if (job.config.workers === undefined && totalFrames >= 60) {
const calibrationDir = join(workDir, "capture-calibration");
const calibrationCfg = createCaptureCalibrationConfig(cfg);
const videoInjector = createRenderVideoFrameInjector();
let calibrationSession: CaptureSession | null = null;
try {
calibrationSession = await createCaptureSession(
fileServer.url,
calibrationDir,
buildCaptureOptions(),
videoInjector,
calibrationCfg,
);
if (!calibrationSession.isInitialized) {
await initializeSession(calibrationSession);
}
assertNotAborted();
captureCalibration = await measureCaptureCostFromSession(
calibrationSession,
totalFrames,
job.config.fps,
);
logCaptureCalibrationResult(captureCalibration, log);
} catch (error) {
const shouldFallbackToScreenshot =
!cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
if (shouldFallbackToScreenshot) {
cfg.forceScreenshot = true;
if (probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
await closeCaptureSession(probeSession).catch(() => {});
probeSession = null;
}
if (calibrationSession) {
lastBrowserConsole = calibrationSession.browserConsoleBuffer;
await closeCaptureSession(calibrationSession).catch(() => {});
calibrationSession = null;
}
log.warn(
"[Render] BeginFrame auto-worker calibration timed out; retrying calibration in screenshot capture mode.",
{
protocolTimeout: calibrationCfg.protocolTimeout,
error: error instanceof Error ? error.message : String(error),
},
);
const screenshotCalibrationCfg = createCaptureCalibrationConfig(cfg);
try {
calibrationSession = await createCaptureSession(
fileServer.url,
join(workDir, "capture-calibration-screenshot"),
buildCaptureOptions(),
createRenderVideoFrameInjector(),
screenshotCalibrationCfg,
);
if (!calibrationSession.isInitialized) {
await initializeSession(calibrationSession);
}
assertNotAborted();
captureCalibration = await measureCaptureCostFromSession(
calibrationSession,
totalFrames,
job.config.fps,
);
logCaptureCalibrationResult(captureCalibration, log);
} catch (fallbackError) {
captureCalibration = createFailedCaptureCalibrationEstimate(
"calibration-screenshot-failed",
);
log.warn(
"[Render] Screenshot auto-worker calibration failed after BeginFrame fallback; using conservative worker budget.",
{
protocolTimeout: screenshotCalibrationCfg.protocolTimeout,
error:
fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
},
);
}
} else {
captureCalibration = createFailedCaptureCalibrationEstimate("calibration-failed");
log.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
protocolTimeout: calibrationCfg.protocolTimeout,
error: error instanceof Error ? error.message : String(error),
});
}
} finally {
if (calibrationSession) {
lastBrowserConsole = calibrationSession.browserConsoleBuffer;
await closeCaptureSession(calibrationSession).catch(() => {});
}
}
}
let workerCount = resolveRenderWorkerCount(
totalFrames,
job.config.workers,
cfg,
compiled,
log,
captureCalibration?.estimate,
);
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": "",
};
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);
const useLayeredComposite = shouldUseLayeredComposite({
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" = isPngSequence ? "mp4" : outputFormat;
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) {
log.info(
hasHdrContent
? "[Render] HDR layered composite: z-ordered DOM + native HDR video/image layers"
: "[Render] Shader transition composite: z-ordered SDR DOM layers",
);
hdrPerf = createHdrPerfCollector();
// Layered compositing relies on captureAlphaPng (Page.captureScreenshot
// 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;
// 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
// (injected via the frame injector) and get sRGB→HLG conversion in the blit.
// HDR images don't need an equivalent array — they're keyed off
// `nativeHdrImageIds` directly (decoded once into `hdrImageBuffers` and blitted
// by `blitHdrImageLayer`, with the DOM mask hiding them via `nativeHdrIds`).
const hdrVideoIds = composition.videos
.filter((v) => nativeHdrVideoIds.has(v.id))
.map((v) => v.id);
// Resolve HDR video source paths
const hdrVideoSrcPaths = new Map<string, string>();
for (const v of composition.videos) {
if (!hdrVideoIds.includes(v.id)) continue;
let srcPath = v.src;
if (!srcPath.startsWith("/")) {
const fromCompiled = join(compiledDir, srcPath);
srcPath = existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath);
}
hdrVideoSrcPaths.set(v.id, srcPath);
}
// Launch headless Chrome for DOM capture.
// Pass the video frame injector so SDR videos are rendered correctly in Chrome.
// HDR videos get injected too but are masked out via applyDomLayerMask
// before each DOM screenshot — only the native FFmpeg-extracted HLG
// frames are used for HDR pixels.
if (!fileServer) throw new Error("fileServer must be initialized before HDR compositing");
// Native HDR videos (e.g. HEVC) may be undecodable by Chrome on the
// current platform — Linux headless-shell ships without HEVC support.
// Their pixels come from out-of-band ffmpeg extraction, so the DOM
// `<video>` element is only kept around for layout. Skip the per-page
// readiness wait for these IDs; otherwise the render hangs 45s and
// throws "video metadata not ready" even though we never asked the
// browser to decode the video.
const domSession = await createCaptureSession(
fileServer.url,
framesDir,
buildCaptureOptions(),
createRenderVideoFrameInjector(),
cfg,
);
// Track lifecycle of resources spawned during HDR rendering so the
// outer finally block can defensively reclaim anything that wasn't
// cleaned up via the success path. Both closeCaptureSession and
// StreamingEncoder.close() are idempotent, but the flags let us avoid
// redundant work and make the intent explicit.
let hdrEncoder: StreamingEncoder | null = null;
let hdrEncoderClosed = false;
let domSessionClosed = false;
// Open raw HDR frame files at this scope so cleanup can close descriptors
// on both success and early failure paths.
const hdrVideoFrameSources = new Map<string, HdrVideoFrameSource>();
try {
await initializeSession(domSession);
assertNotAborted();
lastBrowserConsole = domSession.browserConsoleBuffer;
// Set transparent background once for this dedicated DOM session.
// captureAlphaPng() per frame skips the per-frame CDP set/reset overhead.
await initTransparentBackground(domSession.page);
// ── Scene detection for shader transitions ──────────────────────────
// Query the browser for transition metadata written by @hyperframes/shader-transitions
// (window.__hf.transitions) and discover which elements belong to each scene.
const transitionMeta: HdrTransitionMeta[] = await domSession.page.evaluate(() => {
return window.__hf?.transitions ?? [];
});
// Contract: compositions using window.__hf.transitions must wrap each
// scene's elements in a <div class="scene" id="sceneName"> where the id
// matches the fromScene/toScene values declared in the transition metadata.
const sceneElements: Record<string, string[]> = await domSession.page.evaluate(() => {
const scenes = document.querySelectorAll(".scene");
const map: Record<string, string[]> = {};
for (const scene of scenes) {
if (!scene.id) continue;
const ids = new Set<string>([scene.id]);
const els = scene.querySelectorAll("[id]");
for (const el of els) {
if (el.id) ids.add(el.id);
}
map[scene.id] = Array.from(ids);
}
return map;
});
const transitionRanges: TransitionRange[] = transitionMeta.map((t) => ({
...t,
startFrame: Math.floor(t.time * job.config.fps),
endFrame: Math.ceil((t.time + t.duration) * job.config.fps),
}));
if (transitionRanges.length > 0) {
log.info("[Render] Detected shader transitions for layered compositing", {
count: transitionRanges.length,
transitions: transitionRanges.map((t) => ({
shader: t.shader,
from: t.fromScene,
to: t.toScene,
frames: `${t.startFrame}-${t.endFrame}`,
})),
});
}
// Spawn HDR streaming encoder accepting raw rgb48le composited frames.
// Assigned to the let declared above so the outer finally can close it
// if any of the work between here and hdrEncoder.close() throws.
hdrEncoder = await spawnStreamingEncoder(
videoOnlyPath,
{
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
hdr: preset.hdr,
rawInputFormat: "rgb48le",
},
abortSignal,
{ ffmpegStreamingTimeout: 3_600_000 },
);
assertNotAborted();
// ── Query element bounds for HDR extraction dimensions ────────────
// Extract at each HDR video's display dimensions (not composition dimensions)
// so the source stride matches the blit dimensions. Elements that aren't
// visible at t=0 (e.g., data-start > 0) need to be queried at their own
// start time so their layout dimensions are available.
const hdrExtractionDims = new Map<string, { width: number; height: number }>();
// CSS `object-fit` / `object-position` for HDR <img> elements. Captured
// alongside `hdrExtractionDims` so the static-image decoder can resample
// the rgb48le buffer into the element's layout box the same way the
// browser would, instead of blitting the source PNG at native size.
const hdrImageFitInfo = new Map<string, { fit: string; position: string }>();
const hdrVideoStartTimes = new Map<string, number>();
for (const v of composition.videos) {
if (hdrVideoIds.includes(v.id)) {
hdrVideoStartTimes.set(v.id, v.start);
}
}
const hdrImageStartTimes = new Map<string, number>();
for (const img of composition.images) {
if (nativeHdrImageIds.has(img.id)) {
hdrImageStartTimes.set(img.id, img.start);
}
}
// Collect unique start times to minimize seek operations. Merge HDR
// video AND image start times so an HDR image with `data-start > 0`
// also gets a stacking-query pass at its appearance moment.
const uniqueStartTimes = [
...new Set([...hdrVideoStartTimes.values(), ...hdrImageStartTimes.values()]),
].sort((a, b) => a - b);
for (const seekTime of uniqueStartTimes) {
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, seekTime);
if (domSession.onBeforeCapture) {
await domSession.onBeforeCapture(domSession.page, seekTime);
}
const stacking = await queryElementStacking(domSession.page, nativeHdrIds);
for (const el of stacking) {
// Use layout dimensions (offsetWidth/offsetHeight) for extraction — these
// are unaffected by CSS transforms (GSAP scale/rotation). getBoundingClientRect
// returns the transformed bounding box which can be wrong for extraction.
if (
el.isHdr &&
el.layoutWidth > 0 &&
el.layoutHeight > 0 &&
!hdrExtractionDims.has(el.id)
) {
hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight });
}
// Record `object-fit` / `object-position` for HDR images so the
// static-image decode pass can resample to layout dimensions with
// the same semantics the browser would apply.
if (el.isHdr && nativeHdrImageIds.has(el.id) && !hdrImageFitInfo.has(el.id)) {
hdrImageFitInfo.set(el.id, {
fit: el.objectFit,
position: el.objectPosition,
});
}
}
}
// Fallback probe for HDR images that weren't captured above.
// When an image's `data-start` aligns with the exact visibility
// boundary (or precedes a GSAP `from` tween that animates it in
// later), Chrome reports 0 layout dimensions at that instant.
// Re-probe slightly into the element's visible range so the
// resample path gets real layout dims.
for (const [imageId, startTime] of hdrImageStartTimes) {
if (hdrExtractionDims.has(imageId)) continue;
const img = composition.images.find((i) => i.id === imageId);
if (!img) continue;
const duration = img.end - img.start;
const retryTime = startTime + Math.min(0.5, duration * 0.1);
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, retryTime);
if (domSession.onBeforeCapture) {
await domSession.onBeforeCapture(domSession.page, retryTime);
}
const retryStacking = await queryElementStacking(domSession.page, nativeHdrIds);
for (const el of retryStacking) {
if (el.id === imageId && el.isHdr && el.layoutWidth > 0 && el.layoutHeight > 0) {
hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight });
if (!hdrImageFitInfo.has(el.id)) {
hdrImageFitInfo.set(el.id, { fit: el.objectFit, position: el.objectPosition });
}
break;
}
}
}
// ── Pre-extract all HDR video frames in a single FFmpeg pass ──────
// Use raw rgb48le instead of PNG sequences so the hot loop can read a
// fixed byte range per frame and skip PNG decode entirely.
for (const [videoId, srcPath] of hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
const frameDir = join(framesDir, `hdr_${videoId}`);
mkdirSync(frameDir, { recursive: true });
const duration = video.end - video.start;
const dims = hdrExtractionDims.get(videoId) ?? { width, height };
const rawPath = join(frameDir, "frames.rgb48le");
const ffmpegArgs = [
"-ss",
String(video.mediaStart),
"-i",
srcPath,
"-t",
String(duration),
"-r",
String(job.config.fps),
"-vf",
`scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`,
"-pix_fmt",
"rgb48le",
"-f",
"rawvideo",
"-y",
rawPath,
];
const result = await runFfmpeg(ffmpegArgs, { signal: abortSignal });
if (!result.success) {
hdrDiagnostics.videoExtractionFailures += 1;
log.error("HDR frame pre-extraction failed; aborting render", {
videoId,
srcPath,
stderr: result.stderr.slice(-400),
});
throw new Error(
`HDR frame extraction failed for video "${videoId}". ` +
`Aborting render to avoid shipping black HDR layers.`,
);
}
const frameSize = dims.width * dims.height * 6;
const frameCount = Math.floor(statSync(rawPath).size / frameSize);
if (frameCount < 1) {
hdrDiagnostics.videoExtractionFailures += 1;
throw new Error(
`HDR frame extraction produced no frames for video "${videoId}". ` +
`Aborting render to avoid shipping black HDR layers.`,
);
}
hdrVideoFrameSources.set(videoId, {
dir: frameDir,
rawPath,
fd: openSync(rawPath, "r"),
width: dims.width,
height: dims.height,
frameSize,
frameCount,
scratch: Buffer.allocUnsafe(frameSize),
});
}
// ── Pre-decode all HDR image buffers once ────────────────────────
// Static images decode exactly once, then the resulting rgb48le buffer
// is blitted on every visible frame. Caching the decode here keeps the
// per-frame cost to a memcpy + blit. Failures are logged and skipped so
// a single broken file doesn't kill the render.
//
// We resample the decoded buffer to the element's *layout* dimensions
// here (using CSS `object-fit` / `object-position` semantics), so the
// affine blit downstream can treat the buffer as if the source was
// sized to the element's box. Without this step, an `<img>` element
// styled `object-fit: cover` would render its source PNG at native
// pixel size inside the layout box — visually a small image floating
// in the top-left corner of its container instead of filling it.
const hdrImageBuffers = new Map<string, HdrImageBuffer>();
for (const [imageId, srcPath] of hdrImageSrcPaths) {
try {
const decoded = decodePngToRgb48le(readFileSync(srcPath));
const layout = hdrExtractionDims.get(imageId);
const fitInfo = hdrImageFitInfo.get(imageId);
if (layout && (layout.width !== decoded.width || layout.height !== decoded.height)) {
const fit = normalizeObjectFit(fitInfo?.fit);
const resampled = resampleRgb48leObjectFit(
decoded.data,
decoded.width,
decoded.height,
layout.width,
layout.height,
fit,
fitInfo?.position,
);
hdrImageBuffers.set(imageId, {
data: resampled,
width: layout.width,
height: layout.height,
});
} else {
hdrImageBuffers.set(imageId, {
data: Buffer.from(decoded.data),
width: decoded.width,
height: decoded.height,
});
}
} catch (err) {
hdrDiagnostics.imageDecodeFailures += 1;
log.error("HDR image decode failed; aborting render", {
imageId,
srcPath,
error: err instanceof Error ? err.message : String(err),
});
throw new Error(
`HDR image decode failed for image "${imageId}". ` +
`Aborting render to avoid shipping missing HDR image layers.`,
);
}
}
assertNotAborted();
try {
// The beforeCaptureHook injects SDR video frames into the DOM.
// We call it manually since the HDR loop doesn't use captureFrame().
const beforeCaptureHook = domSession.onBeforeCapture;
// Track which HDR video raw frame sources have been cleaned up.
// Once a video's last frame has been used (time > video.end), its
// extraction directory is deleted to free disk space. This prevents
// disk exhaustion on compositions with many HDR videos.
const cleanedUpVideos = new Set<string>();
// Build a map of video end times for quick lookup
const hdrVideoEndTimes = new Map<string, number>();
for (const v of composition.videos) {
if (hdrVideoFrameSources.has(v.id)) {
hdrVideoEndTimes.set(v.id, v.end);
}
}
// ── HDR composite helper context ───────────────────────────────────
// The actual layer-compositing logic lives at module scope in
// `compositeHdrFrame`; we just pre-bind its long-lived dependencies
// here so call sites stay short.
const debugDumpEnabled = process.env.KEEP_TEMP === "1";
const debugDumpDir = debugDumpEnabled ? join(framesDir, "debug-composite") : null;
if (debugDumpDir && !existsSync(debugDumpDir)) {
mkdirSync(debugDumpDir, { recursive: true });
}
const compositeTransfer = resolveCompositeTransfer(hasHdrContent, effectiveHdr);
const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer;
// Per-job LRU cache for transfer-converted HDR image buffers. Static HDR
// images that need PQ↔HLG conversion are converted exactly once per
// (imageId, targetTransfer) and then reused for every subsequent frame
// instead of paying a fresh `Buffer.from` + `convertTransfer` on every
// composite. The cache is local to this render job so concurrent renders
// do not share state.
const hdrCacheMaxBytes = process.env.HDR_TRANSFER_CACHE_MAX_BYTES
? Number(process.env.HDR_TRANSFER_CACHE_MAX_BYTES)
: undefined;
const hdrImageTransferCache = createHdrImageTransferCache(
hdrCacheMaxBytes !== undefined ? { maxBytes: hdrCacheMaxBytes } : {},
);
const hdrCompositeCtx: HdrCompositeContext = {
log,
domSession,
beforeCaptureHook,
width,
height,
fps: job.config.fps,
compositeTransfer,
nativeHdrImageIds,
hdrImageBuffers,
hdrImageTransferCache,
hdrVideoFrameSources,
hdrVideoStartTimes,
imageTransfers,
videoTransfers,
debugDumpEnabled,
debugDumpDir,
hdrPerf,
};
// ── Pre-allocate transition buffers ─────────────────────────────────
// Each buffer is width * height * 6 bytes (~37 MB at 1080p). Reused
// across frames to avoid per-frame allocation in the hot loop.
const bufSize = width * height * 6;
const hasTransitions = transitionRanges.length > 0;
const transBufferA = hasTransitions ? Buffer.alloc(bufSize) : null;
const transBufferB = hasTransitions ? Buffer.alloc(bufSize) : null;
const transOutput = hasTransitions ? Buffer.alloc(bufSize) : null;
// Pre-allocate the normal-frame canvas too — reused via .fill(0) each iteration
// to avoid ~37 MB allocation per frame in the hot loop.
const normalCanvas = Buffer.alloc(bufSize);
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
if (hdrPerf) hdrPerf.frames += 1;
// Seek timeline
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, "frameSeekMs", timingStart);
// Inject SDR video frames into the DOM
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "frameInjectMs", timingStart);
}
// Query ALL timed elements for z-order analysis
timingStart = Date.now();
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds);
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
// Find active transition for this frame (if any)
const activeTransition = transitionRanges.find(
(t) => i >= t.startFrame && i <= t.endFrame,
);
// Per-frame debug snapshot (every 30 frames). The meta object
// requires `Array.find` over `stackingInfo` plus a number-format
// and conditional struct allocation — non-trivial work to do
// every 30 frames in the encode hot loop. Gate the entire block
// on the logger's level check so production runs (level=info)
// pay nothing.
//
// Audit note (PR #383 review): this is the only per-frame log
// site in the streaming HDR encode loop that constructs
// non-trivial metadata. The `[diag]` log.info calls inside
// compositeToBuffer (compositeToBuffer plan, hdr layer blit,
// dom layer blit, compositeToBuffer end) are already gated by
// `shouldLog = debugDumpEnabled && debugFrameIndex >= 0`, where
// debugDumpEnabled is driven by KEEP_TEMP=1 — strictly stricter
// than an isLevelEnabled check. The HDR blit error-path
// log.debugs only fire on caught failures, not on the happy
// path. Any new per-frame log site that builds meta should
// follow the same `if (log.isLevelEnabled?.("level") ?? true)`
// pattern (or stay behind `shouldLog`) so production stays
// allocation-free in the hot loop.
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
const hdrEl = stackingInfo.find((e) => e.isHdr);
log.debug("[Render] HDR layer composite frame", {
frame: i,
time: time.toFixed(2),
hdrElement: hdrEl
? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width }
: null,
stackingCount: stackingInfo.length,
activeTransition: activeTransition?.shader,
});
}
if (activeTransition && transBufferA && transBufferB && transOutput) {
if (hdrPerf) hdrPerf.transitionFrames += 1;
const transitionTimingStart = Date.now();
// ── Transition frame: dual-scene compositing ──────────────────
const progress =
activeTransition.endFrame === activeTransition.startFrame
? 1
: (i - activeTransition.startFrame) /
(activeTransition.endFrame - activeTransition.startFrame);
// Resolve scene element IDs
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
// Zero-fill scene buffers (transition function writes every output pixel)
timingStart = Date.now();
transBufferA.fill(0);
transBufferB.fill(0);
addHdrTiming(hdrPerf, "canvasClearMs", timingStart);
for (const [sceneBuf, sceneIds] of [
[transBufferA, sceneAIds],
[transBufferB, sceneBIds],
] as const) {
// Re-check abort between scene A and scene B. Each scene
// capture below performs a DOM seek, optional hook,
// per-layer HDR blits, and a full-page screenshot — easily
// hundreds of ms. Without this, an abort that arrives
// during scene A's capture won't fire until the next outer
// frame, after scene B has already been fully composited
// and discarded.
assertNotAborted();
// Fresh state: seek + inject
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);
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
}
// Blit all HDR videos/images for this scene
for (const el of stackingInfo) {
if (!el.isHdr || !sceneIds.has(el.id)) continue;
if (nativeHdrImageIds.has(el.id)) {
blitHdrImageLayer(
sceneBuf as Buffer,
el,
hdrImageBuffers,
hdrImageTransferCache,
width,
height,
log,
imageTransfers.get(el.id),
hdrTargetTransfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
sceneBuf as Buffer,
el,
time,
job.config.fps,
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(el.id),
hdrTargetTransfer,
hdrPerf,
);
}
}
// Single DOM screenshot: mask the page so only this scene's DOM
// elements paint. Same masking strategy as the per-layer DOM
// branch — see applyDomLayerMask for details. Native HDR videos
// and images are always inline-hidden so their fallback poster /
// SDR thumbnail doesn't bleed into the DOM overlay (HDR pixels
// are blitted separately by blitHdrVideoLayer / blitHdrImageLayer
// above).
const showIds = Array.from(sceneIds);
const hideIds = stackingInfo
.map((e) => e.id)
.filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id));
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
timingStart = Date.now();
await applyDomLayerMask(domSession.page, showIds, hideIds);
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart);
timingStart = Date.now();
const domPng = await captureAlphaPng(domSession.page, width, height);
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart);
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);
timingStart = Date.now();
blitRgba8OverRgb48le(
domRgba,
sceneBuf as Buffer,
width,
height,
compositeTransfer,
);
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", {
frameIndex: i,
sceneIds: Array.from(sceneIds),
error: err instanceof Error ? err.message : String(err),
});
}
}
// Apply shader transition blend directly in the active rgb48le
// signal space. Linearizing HDR was attempted but destroys dark
// PQ content — values below PQ ~5000 quantize to zero in 16-bit
// linear, wiping out the bottom portion of dark video content.
// SDR compositions use 16-bit-expanded sRGB, which matches the
// shader design space.
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
transitionFn(transBufferA, transBufferB, transOutput, width, height, progress);
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
timingStart = Date.now();
hdrEncoder.writeFrame(transOutput);
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart);
} else {
if (hdrPerf) hdrPerf.normalFrames += 1;
// ── Normal frame: full layer composite (no transition) ─────────
timingStart = Date.now();
normalCanvas.fill(0);
addHdrTiming(hdrPerf, "canvasClearMs", timingStart);
timingStart = Date.now();
await compositeHdrFrame(
hdrCompositeCtx,
normalCanvas,
time,
stackingInfo,
undefined,
i,
);
addHdrTiming(hdrPerf, "normalCompositeMs", timingStart);
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
const previewPath = join(
debugDumpDir,
`frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`,
);
writeFileSync(previewPath, normalCanvas);
}
timingStart = Date.now();
hdrEncoder.writeFrame(normalCanvas);
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart);
}
// Clean up HDR raw frame sources for videos that have ended.
// Frees disk space during long renders with many HDR videos.
// Skip when KEEP_TEMP=1 so we can inspect intermediate state.
if (process.env.KEEP_TEMP !== "1") {
for (const [videoId, endTime] of hdrVideoEndTimes) {
if (time > endTime && !cleanedUpVideos.has(videoId)) {
// Also check no active transition references this video's scene
const stillNeeded =
activeTransition &&
(sceneElements[activeTransition.fromScene]?.includes(videoId) ||
sceneElements[activeTransition.toScene]?.includes(videoId));
if (!stillNeeded) {
const frameSource = hdrVideoFrameSources.get(videoId);
if (frameSource) {
closeHdrVideoFrameSource(frameSource, log);
try {
rmSync(frameSource.dir, { recursive: true, force: true });
} catch (err) {
log.warn("Failed to clean up HDR raw frame directory", {
videoId,
frameDir: frameSource.dir,
rawPath: frameSource.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
hdrVideoFrameSources.delete(videoId);
}
cleanedUpVideos.add(videoId);
}
}
}
}
job.framesRendered = i + 1;
if ((i + 1) % 10 === 0 || i + 1 === totalFrames) {
const frameProgress = (i + 1) / totalFrames;
updateJobStatus(
job,
"rendering",
`Layered composite frame ${i + 1}/${job.totalFrames}`,
Math.round(25 + frameProgress * 55),
onProgress,
);
}
}
} finally {
lastBrowserConsole = domSession.browserConsoleBuffer;
await closeCaptureSession(domSession);
domSessionClosed = true;
}
const hdrEncodeResult = await hdrEncoder.close();
hdrEncoderClosed = true;
assertNotAborted();
if (!hdrEncodeResult.success) {
throw new Error(`HDR encode failed: ${hdrEncodeResult.error}`);
}
perfStages.captureMs = Date.now() - stage4Start;
perfStages.encodeMs = hdrEncodeResult.durationMs;
} finally {
// Defensive cleanup: if anything between domSession creation and the
// success-path closes threw, the encoder ffmpeg subprocess and the
// browser would otherwise be leaked. Both close() methods are
// idempotent so it's safe to call them when the flags are already set,
// but we skip the redundant work to keep logs clean.
if (hdrEncoder && !hdrEncoderClosed) {
try {
await hdrEncoder.close();
} catch (err) {
log.warn("hdrEncoder defensive close failed", {
err: err instanceof Error ? err.message : String(err),
});
}
}
if (!domSessionClosed) {
await closeCaptureSession(domSession).catch((err) => {
log.warn("closeCaptureSession defensive close failed", {
err: err instanceof Error ? err.message : String(err),
});
});
}
// Close any raw frame files that survived in-loop cleanup (early
// failures, KEEP_TEMP=1, videos still active when the render exits).
// The on-disk frames themselves are torn down with workDir.
for (const frameSource of hdrVideoFrameSources.values()) {
closeHdrVideoFrameSource(frameSource, log);
}
hdrVideoFrameSources.clear();
}
} else // ── Standard capture paths (SDR or DOM-only HDR) ──────────────────
// Streaming encode mode: pipe frame buffers directly to FFmpeg stdin,
// skipping disk writes and the separate Stage 5 encode step.
{
let streamingEncoder: StreamingEncoder | null = null;
let streamingEncoderClosed = false;
if (useStreamingEncode) {
try {
streamingEncoder = await spawnStreamingEncoder(
videoOnlyPath,
{
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,
},
abortSignal,
);
assertNotAborted();
} catch (err) {
if (abortSignal?.aborted) {
if (streamingEncoder && !streamingEncoderClosed) {
await streamingEncoder.close().catch(() => {});
streamingEncoderClosed = true;
}
throw err;
}
useStreamingEncode = false;
streamingEncoder = null;
log.warn("[Render] Streaming encoder spawn failed; falling back to disk-frame encode.", {
error: err instanceof Error ? err.message : String(err),
outputFormat,
workerCount,
durationSeconds: job.duration,
});
}
}
try {
if (useStreamingEncode && streamingEncoder) {
// ── Streaming capture + encode (Stage 4 absorbs Stage 5) ──────────
// Streaming encode is locked in here; capture retries may shrink
// workerCount later, but must not grow a streaming render past one worker.
const reorderBuffer = createFrameReorderBuffer(0, totalFrames);
const currentEncoder = streamingEncoder;
if (workerCount > 1) {
// Parallel capture → streaming encode
const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
const onFrameBuffer = async (frameIndex: number, buffer: Buffer): Promise<void> => {
await reorderBuffer.waitForFrame(frameIndex);
currentEncoder.writeFrame(buffer);
reorderBuffer.advanceTo(frameIndex + 1);
};
await executeParallelCapture(
fileServer.url,
workDir,
tasks,
buildCaptureOptions(),
createRenderVideoFrameInjector,
abortSignal,
(progress) => {
job.framesRendered = progress.capturedFrames;
const frameProgress = progress.capturedFrames / progress.totalFrames;
const progressPct = 25 + frameProgress * 55;
if (
progress.capturedFrames % 30 === 0 ||
progress.capturedFrames === progress.totalFrames
) {
updateJobStatus(
job,
"rendering",
`Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
Math.round(progressPct),
onProgress,
);
}
},
onFrameBuffer,
cfg,
);
if (probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
await closeCaptureSession(probeSession);
probeSession = null;
}
} else {
// Sequential capture → streaming encode
const videoInjector = createRenderVideoFrameInjector();
const session =
probeSession ??
(await createCaptureSession(
fileServer.url,
framesDir,
buildCaptureOptions(),
videoInjector,
cfg,
));
if (probeSession) {
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
probeSession = null;
}
try {
if (!session.isInitialized) {
await initializeSession(session);
}
assertNotAborted();
lastBrowserConsole = session.browserConsoleBuffer;
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
const { buffer } = await captureFrameToBuffer(session, i, time);
await reorderBuffer.waitForFrame(i);
currentEncoder.writeFrame(buffer);
reorderBuffer.advanceTo(i + 1);
job.framesRendered = i + 1;
const frameProgress = (i + 1) / totalFrames;
const progress = 25 + frameProgress * 55;
updateJobStatus(
job,
"rendering",
`Streaming frame ${i + 1}/${job.totalFrames}`,
Math.round(progress),
onProgress,
);
}
} finally {
lastBrowserConsole = session.browserConsoleBuffer;
await closeCaptureSession(session);
}
}
// Close encoder and get result
const encodeResult = await currentEncoder.close();
streamingEncoderClosed = true;
assertNotAborted();
if (!encodeResult.success) {
throw new Error(`Streaming encode failed: ${encodeResult.error}`);
}
perfStages.captureMs = Date.now() - stage4Start;
perfStages.encodeMs = encodeResult.durationMs; // Overlapped with capture
} else {
// ── Disk-based capture (original flow) ────────────────────────────
if (workerCount > 1) {
// Parallel capture
const attempts = await executeDiskCaptureWithAdaptiveRetry({
serverUrl: fileServer.url,
workDir,
framesDir,
totalFrames: job.totalFrames,
initialWorkerCount: workerCount,
allowRetry: job.config.workers === undefined,
frameExt: needsAlpha ? "png" : "jpg",
captureOptions: buildCaptureOptions(),
createBeforeCaptureHook: createRenderVideoFrameInjector,
abortSignal,
onProgress: (progress) => {
job.framesRendered = progress.capturedFrames;
const frameProgress = progress.capturedFrames / progress.totalFrames;
const progressPct = 25 + frameProgress * 45;
if (
progress.capturedFrames % 30 === 0 ||
progress.capturedFrames === progress.totalFrames
) {
updateJobStatus(
job,
"rendering",
`Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
Math.round(progressPct),
onProgress,
);
}
},
cfg,
log,
});
captureAttempts.push(...attempts);
const lastAttempt = attempts[attempts.length - 1];
if (lastAttempt) {
workerCount = lastAttempt.workers;
}
if (probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
await closeCaptureSession(probeSession);
probeSession = null;
}
} else {
// Sequential capture
const videoInjector = createRenderVideoFrameInjector();
const session =
probeSession ??
(await createCaptureSession(
fileServer.url,
framesDir,
buildCaptureOptions(),
videoInjector,
cfg,
));
if (probeSession) {
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
probeSession = null;
}
try {
if (!session.isInitialized) {
await initializeSession(session);
}
assertNotAborted();
lastBrowserConsole = session.browserConsoleBuffer;
for (let i = 0; i < job.totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
await captureFrame(session, i, time);
job.framesRendered = i + 1;
const frameProgress = (i + 1) / job.totalFrames;
const progress = 25 + frameProgress * 45;
updateJobStatus(
job,
"rendering",
`Capturing frame ${i + 1}/${job.totalFrames}`,
Math.round(progress),
onProgress,
);
}
} finally {
lastBrowserConsole = session.browserConsoleBuffer;
await closeCaptureSession(session);
}
}
perfStages.captureMs = Date.now() - stage4Start;
if (isPngSequence) {
// ── Stage 5 (png-sequence): copy captured PNGs to outputDir ──────
// No encoder, no mux, no faststart — captured frames already carry
// alpha and are the deliverable. We rename to `frame_NNNNNN.png`
// (zero-padded) so consumers (After Effects, Nuke, Fusion, ffmpeg
// image2 demuxer) can globbed-import without surprises.
const stage5Start = Date.now();
updateJobStatus(job, "encoding", "Writing PNG sequence", 75, onProgress);
if (!existsSync(outputPath)) mkdirSync(outputPath, { recursive: true });
const captured = readdirSync(framesDir)
.filter((name) => name.endsWith(".png"))
.sort();
if (captured.length === 0) {
throw new Error(
`[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`,
);
}
captured.forEach((name, i) => {
const dst = join(outputPath, `frame_${String(i + 1).padStart(6, "0")}.png`);
copyFileSync(join(framesDir, name), dst);
});
if (hasAudio && existsSync(audioOutputPath)) {
// Sidecar audio for callers that need to re-mux later. png-sequence
// has no container of its own, so this is the only place audio
// can land alongside the frames.
copyFileSync(audioOutputPath, join(outputPath, "audio.aac"));
log.info(
`[Render] png-sequence: audio.aac sidecar written to ${outputPath}/audio.aac`,
);
}
perfStages.encodeMs = Date.now() - stage5Start;
} else {
// ── Stage 5: Encode ───────────────────────────────────────────────
const stage5Start = Date.now();
updateJobStatus(job, "encoding", "Encoding video", 75, onProgress);
const frameExt = needsAlpha ? "png" : "jpg";
const framePattern = `frame_%06d.${frameExt}`;
const encoderOpts = {
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
hdr: preset.hdr,
};
const encodeResult = enableChunkedEncode
? await encodeFramesChunkedConcat(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
chunkedEncodeSize,
abortSignal,
)
: await encodeFramesFromDir(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
abortSignal,
);
assertNotAborted();
if (!encodeResult.success) {
throw new Error(`Encoding failed: ${encodeResult.error}`);
}
perfStages.encodeMs = Date.now() - stage5Start;
}
}
} finally {
// Defensive cleanup: if the streaming encoder branch threw before
// currentEncoder.close() (e.g. capture failure, abort, broken pipe),
// the ffmpeg subprocess would otherwise leak. close() is idempotent so
// this is safe to call alongside the success-path close — we just gate
// on the flag to avoid redundant work.
if (streamingEncoder && !streamingEncoderClosed) {
try {
await streamingEncoder.close();
} catch (err) {
log.warn("streamingEncoder defensive close failed", {
err: err instanceof Error ? err.message : String(err),
});
}
}
}
} // 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 png-sequence — there is no encoded video to mux/faststart.
// The frames were copied directly to outputPath in Stage 5.
if (!isPngSequence) {
const stage6Start = Date.now();
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
if (hasAudio) {
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
audioOutputPath,
outputPath,
abortSignal,
);
assertNotAborted();
if (!muxResult.success) {
throw new Error(`Audio muxing failed: ${muxResult.error}`);
}
} else {
const faststartResult = await applyFaststart(videoOnlyPath, outputPath, abortSignal);
assertNotAborted();
if (!faststartResult.success) {
throw new Error(`Faststart failed: ${faststartResult.error}`);
}
}
perfStages.assembleMs = Date.now() - stage6Start;
}
// ── Complete ─────────────────────────────────────────────────────────
job.outputPath = outputPath;
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
const totalElapsed = Date.now() - pipelineStart;
sampleMemory();
const tmpPeakBytes = existsSync(workDir) ? sampleDirectoryBytes(workDir) : 0;
const perfSummary: RenderPerfSummary = {
renderId: job.id,
totalElapsedMs: totalElapsed,
fps: job.config.fps,
quality: job.config.quality,
workers: workerCount,
chunkedEncode: enableChunkedEncode,
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
compositionDurationSeconds: composition.duration,
totalFrames: totalFrames,
resolution: { width: outputWidth, height: outputHeight },
videoCount: composition.videos.length,
audioCount: composition.audios.length,
stages: perfStages,
videoExtractBreakdown: extractionResult?.phaseBreakdown,
tmpPeakBytes,
captureCalibration: captureCalibration
? {
sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
p95Ms: captureCalibration.estimate.p95Ms,
multiplier: captureCalibration.estimate.multiplier,
reasons: captureCalibration.estimate.reasons,
}
: undefined,
captureAttempts: captureAttempts.length > 0 ? captureAttempts : undefined,
hdrDiagnostics:
hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0
? { ...hdrDiagnostics }
: undefined,
hdrPerf: hdrPerf ? finalizeHdrPerf(hdrPerf) : undefined,
captureAvgMs:
totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : undefined,
peakRssMb: Math.round(peakRssBytes / (1024 * 1024)),
peakHeapUsedMb: Math.round(peakHeapUsedBytes / (1024 * 1024)),
};
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);
if (fileServer) {
const fs = fileServer;
await safeCleanup(
"close file server (cancel)",
() => {
fs.close();
},
log,
);
}
if (probeSession) {
const session = probeSession;
await safeCleanup("close probe session (cancel)", () => closeCaptureSession(session), log);
}
if (!job.config.debug) {
await safeCleanup(
"remove workDir (cancel)",
() => {
rmSync(workDir, { recursive: true, force: true });
},
log,
);
}
if (restoreLogger) restoreLogger();
throw error instanceof RenderCancelledError
? error
: new RenderCancelledError("render_cancelled");
}
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
// 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);
// Diagnostic summary
const elapsed = Date.now() - pipelineStart;
const freeMemMB = Math.round(freemem() / (1024 * 1024));
// Populate structured error details for downstream consumers (SSE, sync response)
job.failedStage = job.currentStage;
job.errorDetails = {
message: errorMessage,
stack: errorStack,
elapsedMs: elapsed,
freeMemoryMB: freeMemMB,
browserConsoleTail: lastBrowserConsole.length > 0 ? lastBrowserConsole.slice(-30) : undefined,
perfStages: Object.keys(perfStages).length > 0 ? { ...perfStages } : undefined,
hdrDiagnostics:
hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0
? { ...hdrDiagnostics }
: undefined,
};
// Cleanup
if (fileServer) {
const fs = fileServer;
await safeCleanup(
"close file server (error)",
() => {
fs.close();
},
log,
);
}
if (probeSession) {
const session = probeSession;
await safeCleanup("close probe session (error)", () => closeCaptureSession(session), log);
}
if (!job.config.debug) {
await safeCleanup(
"remove workDir (error)",
() => {
if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
},
log,
);
}
if (restoreLogger) restoreLogger();
throw error;
} finally {
clearInterval(memSamplerInterval);
}
}