diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index ef98e87c..02bc3f02 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -30,6 +30,8 @@ describe("resolveConfig", () => { expect(config.format).toBe("jpeg"); expect(config.jpegQuality).toBe(80); expect(config.browserGpuMode).toBe("software"); + expect(config.enableStreamingEncode).toBe(true); + expect(config.streamingEncodeMaxDurationSeconds).toBe(240); expect(config.audioGain).toBe(1); expect(config.debug).toBe(false); }); @@ -60,6 +62,27 @@ describe("resolveConfig", () => { expect(config.enableBrowserPool).toBe(true); }); + it("lets env vars opt out of default streaming encode", () => { + setEnv("PRODUCER_ENABLE_STREAMING_ENCODE", "false"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(false); + }); + + it("reads the streaming encode duration cutoff from env", () => { + setEnv("PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS", "120"); + + const config = resolveConfig(); + expect(config.streamingEncodeMaxDurationSeconds).toBe(120); + }); + + it("clamps negative streaming encode duration cutoff env values to zero", () => { + setEnv("PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS", "-1"); + + const config = resolveConfig(); + expect(config.streamingEncodeMaxDurationSeconds).toBe(0); + }); + it("treats non-'true' boolean env vars as false", () => { setEnv("PRODUCER_DISABLE_GPU", "yes"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index 71b51c1a..9ae23ea6 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -48,6 +48,12 @@ export interface EngineConfig { enableChunkedEncode: boolean; chunkSizeFrames: number; enableStreamingEncode: boolean; + /** + * Max composition duration eligible for streaming encode (seconds). + * Mirrors GSAP rendering's 4-minute streaming guard: production has seen + * ffmpeg's streaming pipe hit FFMPEG_STREAMING_TIMEOUT_MS on longer videos. + */ + streamingEncodeMaxDurationSeconds: number; // ── FFmpeg timeouts ────────────────────────────────────────────────── /** Timeout for FFmpeg frame encoding (ms). Default: 600_000 */ @@ -126,7 +132,8 @@ export const DEFAULT_CONFIG: EngineConfig = { enableChunkedEncode: false, chunkSizeFrames: 360, - enableStreamingEncode: false, + enableStreamingEncode: true, + streamingEncodeMaxDurationSeconds: 240, ffmpegEncodeTimeout: 600_000, ffmpegProcessTimeout: 300_000, @@ -207,6 +214,13 @@ export function resolveConfig(overrides?: Partial): EngineConfig { "PRODUCER_ENABLE_STREAMING_ENCODE", DEFAULT_CONFIG.enableStreamingEncode, ), + streamingEncodeMaxDurationSeconds: Math.max( + 0, + envNum( + "PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS", + DEFAULT_CONFIG.streamingEncodeMaxDurationSeconds, + ), + ), ffmpegEncodeTimeout: envNum("FFMPEG_ENCODE_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegEncodeTimeout), ffmpegProcessTimeout: envNum("FFMPEG_PROCESS_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegProcessTimeout), diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index d389a23a..d14ce4b0 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -21,6 +21,7 @@ import { resolveRenderWorkerCount, selectCaptureCalibrationFrames, shouldFallbackToScreenshotAfterCalibrationError, + shouldUseStreamingEncode, writeCompiledArtifacts, } from "./renderOrchestrator.js"; import { toExternalAssetKey } from "../utils/paths.js"; @@ -84,6 +85,46 @@ describe("extractStandaloneEntryFromIndex", () => { }); }); +describe("shouldUseStreamingEncode", () => { + const streamingEnabledConfig = { + enableStreamingEncode: true, + streamingEncodeMaxDurationSeconds: 240, + }; + + it("enables streaming for default single-worker video renders", () => { + expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240)).toBe(true); + }); + + it("lets config disable streaming encode", () => { + expect( + shouldUseStreamingEncode( + { enableStreamingEncode: false, streamingEncodeMaxDurationSeconds: 240 }, + "mp4", + 1, + 240, + ), + ).toBe(false); + }); + + it("keeps png-sequence and parallel capture on the non-streaming path", () => { + expect(shouldUseStreamingEncode(streamingEnabledConfig, "png-sequence", 1, 240)).toBe(false); + expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 2, 240)).toBe(false); + }); + + it("keeps renders over the configured max duration on normal encoding", () => { + expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240)).toBe(true); + expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240.001)).toBe(false); + expect( + shouldUseStreamingEncode( + { enableStreamingEncode: true, streamingEncodeMaxDurationSeconds: 120 }, + "mp4", + 1, + 120.001, + ), + ).toBe(false); + }); +}); + describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => { const tempDirs: string[] = []; afterEach(() => { @@ -216,9 +257,12 @@ function createConfig(): EngineConfig { enableChunkedEncode: false, chunkSizeFrames: 360, enableStreamingEncode: false, + streamingEncodeMaxDurationSeconds: 240, ffmpegEncodeTimeout: 600000, ffmpegProcessTimeout: 300000, ffmpegStreamingTimeout: 600000, + hdr: false, + hdrAutoDetect: true, audioGain: 1, frameDataUriCacheLimit: 256, playerReadyTimeout: 45000, diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index c8a7c315..ec836cbe 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1710,6 +1710,38 @@ 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, + outputFormat: NonNullable, + 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 */ @@ -1737,19 +1769,8 @@ export function extractStandaloneEntryFromIndex( ) ?? null; if (!root) return null; - const hostClone = host.cloneNode(true) as Element; - hostClone.setAttribute("data-start", "0"); - - body.innerHTML = ""; - - if (root === host) { - body.appendChild(hostClone); - return document.toString(); - } - - const rootClone = root.cloneNode(false) as Element; - rootClone.appendChild(hostClone); - body.appendChild(rootClone); + const renderClone = createStandaloneEntryRenderClone(root, host); + replaceBodyWithRenderClone(body, renderClone); return document.toString(); } @@ -1783,7 +1804,7 @@ export async function executeRenderJob( let hdrPerf: HdrPerfCollector | undefined; const perfOutputPath = join(workDir, "perf-summary.json"); const cfg = { ...(job.config.producerConfig ?? resolveConfig()) }; - const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov" | "png-sequence"; + const outputFormat = (job.config.format ?? "mp4") as NonNullable; const isWebm = outputFormat === "webm"; const isMov = outputFormat === "mov"; const isPngSequence = outputFormat === "png-sequence"; @@ -1794,12 +1815,6 @@ export async function executeRenderJob( } const enableChunkedEncode = cfg.enableChunkedEncode; const chunkedEncodeSize = cfg.chunkSizeFrames; - // Streaming encode pipes captured frames through ffmpeg's stdin to produce - // a single video file. png-sequence has no encoded video output — frames go - // straight to disk — so the streaming branch is bypassed regardless of the - // engine config flag. - const enableStreamingEncode = cfg.enableStreamingEncode && !isPngSequence; - // 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 @@ -2510,6 +2525,21 @@ export async function executeRenderJob( 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 @@ -3319,30 +3349,50 @@ export async function executeRenderJob( let streamingEncoder: StreamingEncoder | null = null; let streamingEncoderClosed = false; - if (enableStreamingEncode) { - 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(); + 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 (enableStreamingEncode && streamingEncoder) { + 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;