feat: default streaming encode for sequential renders (#579)

This commit is contained in:
Miguel Ángel
2026-05-01 04:22:26 +02:00
committed by GitHub
parent a68e840dda
commit dde26cf62d
4 changed files with 172 additions and 41 deletions
+23
View File
@@ -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");
+15 -1
View File
@@ -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>): 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),
@@ -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,
@@ -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<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
*/
@@ -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<RenderConfig["format"]>;
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;