mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat: default streaming encode for sequential renders (#579)
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user