Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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");

Expand Down
16 changes: 15 additions & 1 deletion packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
44 changes: 44 additions & 0 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolveRenderWorkerCount,
selectCaptureCalibrationFrames,
shouldFallbackToScreenshotAfterCalibrationError,
shouldUseStreamingEncode,
writeCompiledArtifacts,
} from "./renderOrchestrator.js";
import { toExternalAssetKey } from "../utils/paths.js";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 90 additions & 40 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
Loading