From a0ead41f488ca034ca36a3039a7b2d855746d128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 30 Apr 2026 15:07:46 -0400 Subject: [PATCH 1/3] feat: default streaming encode for sequential renders --- packages/engine/src/config.test.ts | 8 +++ packages/engine/src/config.ts | 2 +- .../src/services/renderOrchestrator.test.ts | 18 ++++++ .../src/services/renderOrchestrator.ts | 58 ++++++++++++------- 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index ef98e87c6..8da438585 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -30,6 +30,7 @@ describe("resolveConfig", () => { expect(config.format).toBe("jpeg"); expect(config.jpegQuality).toBe(80); expect(config.browserGpuMode).toBe("software"); + expect(config.enableStreamingEncode).toBe(true); expect(config.audioGain).toBe(1); expect(config.debug).toBe(false); }); @@ -60,6 +61,13 @@ 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("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 71b51c1a5..ead8067e6 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -126,7 +126,7 @@ export const DEFAULT_CONFIG: EngineConfig = { enableChunkedEncode: false, chunkSizeFrames: 360, - enableStreamingEncode: false, + enableStreamingEncode: true, ffmpegEncodeTimeout: 600_000, ffmpegProcessTimeout: 300_000, diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index d389a23a9..44435ecdd 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,23 @@ describe("extractStandaloneEntryFromIndex", () => { }); }); +describe("shouldUseStreamingEncode", () => { + it("enables streaming for default single-worker video renders", () => { + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1)).toBe(true); + }); + + it("lets config disable streaming encode", () => { + expect(shouldUseStreamingEncode({ enableStreamingEncode: false }, "mp4", 1)).toBe(false); + }); + + it("keeps png-sequence and parallel capture on the non-streaming path", () => { + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "png-sequence", 1)).toBe( + false, + ); + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 2)).toBe(false); + }); +}); + describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => { const tempDirs: string[] = []; afterEach(() => { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index c8a7c3153..39bc1d583 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1710,6 +1710,34 @@ 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, +): boolean { + if (!cfg.enableStreamingEncode) return false; + if (outputFormat === "png-sequence") return false; + return workerCount === 1; +} + /** * Main render pipeline */ @@ -1737,19 +1765,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 +1800,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 +1811,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 +2521,13 @@ 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. + const enableStreamingEncode = shouldUseStreamingEncode(cfg, outputFormat, workerCount); + const captureAttempts: CaptureAttemptSummary[] = []; // png-sequence is "no container" — outputPath is treated as a directory and From eae0b3f7ccce2c5aa0eb1c304f86b34e7bc608ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 30 Apr 2026 15:49:31 -0400 Subject: [PATCH 2/3] fix: gate streaming encode by duration --- .../src/services/renderOrchestrator.test.ts | 15 +++++++++++---- .../producer/src/services/renderOrchestrator.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 44435ecdd..ada1de70c 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -87,18 +87,25 @@ describe("extractStandaloneEntryFromIndex", () => { describe("shouldUseStreamingEncode", () => { it("enables streaming for default single-worker video renders", () => { - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1)).toBe(true); + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1, 240)).toBe(true); }); it("lets config disable streaming encode", () => { - expect(shouldUseStreamingEncode({ enableStreamingEncode: false }, "mp4", 1)).toBe(false); + expect(shouldUseStreamingEncode({ enableStreamingEncode: false }, "mp4", 1, 240)).toBe(false); }); it("keeps png-sequence and parallel capture on the non-streaming path", () => { - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "png-sequence", 1)).toBe( + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "png-sequence", 1, 240)).toBe( + false, + ); + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 2, 240)).toBe(false); + }); + + it("keeps renders over four minutes on normal encoding", () => { + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1, 240)).toBe(true); + expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1, 240.001)).toBe( false, ); - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 2)).toBe(false); }); }); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 39bc1d583..8f942c9f3 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1728,13 +1728,18 @@ function replaceBodyWithRenderClone(body: HTMLElement, renderClone: Element): vo body.appendChild(renderClone); } +const STREAMING_ENCODE_MAX_DURATION_SECONDS = 4 * 60; + export function shouldUseStreamingEncode( cfg: Pick, outputFormat: NonNullable, workerCount: number, + durationSeconds: number, ): boolean { if (!cfg.enableStreamingEncode) return false; if (outputFormat === "png-sequence") return false; + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false; + if (durationSeconds > STREAMING_ENCODE_MAX_DURATION_SECONDS) return false; return workerCount === 1; } @@ -2526,7 +2531,12 @@ export async function executeRenderJob( // 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. - const enableStreamingEncode = shouldUseStreamingEncode(cfg, outputFormat, workerCount); + const enableStreamingEncode = shouldUseStreamingEncode( + cfg, + outputFormat, + workerCount, + job.duration, + ); const captureAttempts: CaptureAttemptSummary[] = []; From 4f52a37b7526c46823dded585b895de5b817460a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 30 Apr 2026 16:45:19 -0400 Subject: [PATCH 3/3] fix: harden streaming encode rollout --- packages/engine/src/config.test.ts | 15 ++++ packages/engine/src/config.ts | 14 ++++ .../src/services/renderOrchestrator.test.ts | 41 +++++++--- .../src/services/renderOrchestrator.ts | 78 ++++++++++++------- 4 files changed, 109 insertions(+), 39 deletions(-) diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 8da438585..02bc3f02f 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -31,6 +31,7 @@ describe("resolveConfig", () => { 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); }); @@ -68,6 +69,20 @@ describe("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 ead8067e6..9ae23ea6c 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 */ @@ -127,6 +133,7 @@ export const DEFAULT_CONFIG: EngineConfig = { enableChunkedEncode: false, chunkSizeFrames: 360, 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 ada1de70c..d14ce4b00 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -86,26 +86,42 @@ describe("extractStandaloneEntryFromIndex", () => { }); describe("shouldUseStreamingEncode", () => { + const streamingEnabledConfig = { + enableStreamingEncode: true, + streamingEncodeMaxDurationSeconds: 240, + }; + it("enables streaming for default single-worker video renders", () => { - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1, 240)).toBe(true); + expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240)).toBe(true); }); it("lets config disable streaming encode", () => { - expect(shouldUseStreamingEncode({ enableStreamingEncode: false }, "mp4", 1, 240)).toBe(false); + 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({ enableStreamingEncode: true }, "png-sequence", 1, 240)).toBe( - false, - ); - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 2, 240)).toBe(false); + expect(shouldUseStreamingEncode(streamingEnabledConfig, "png-sequence", 1, 240)).toBe(false); + expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 2, 240)).toBe(false); }); - it("keeps renders over four minutes on normal encoding", () => { - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1, 240)).toBe(true); - expect(shouldUseStreamingEncode({ enableStreamingEncode: true }, "mp4", 1, 240.001)).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); }); }); @@ -241,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 8f942c9f3..ec836cbe8 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1728,18 +1728,17 @@ function replaceBodyWithRenderClone(body: HTMLElement, renderClone: Element): vo body.appendChild(renderClone); } -const STREAMING_ENCODE_MAX_DURATION_SECONDS = 4 * 60; - export function shouldUseStreamingEncode( - cfg: Pick, + 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 > STREAMING_ENCODE_MAX_DURATION_SECONDS) return false; + if (durationSeconds > cfg.streamingEncodeMaxDurationSeconds) return false; return workerCount === 1; } @@ -2531,12 +2530,15 @@ export async function executeRenderJob( // 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. - const enableStreamingEncode = shouldUseStreamingEncode( - cfg, + let useStreamingEncode = shouldUseStreamingEncode(cfg, outputFormat, workerCount, job.duration); + log.info("streaming-encode gate", { + enabled: useStreamingEncode, + configFlag: cfg.enableStreamingEncode, outputFormat, workerCount, - job.duration, - ); + durationSeconds: job.duration, + maxDurationSeconds: cfg.streamingEncodeMaxDurationSeconds, + }); const captureAttempts: CaptureAttemptSummary[] = []; @@ -3347,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;