mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
Merge pull request #4109 from heygen-com/lfr/02-phase0-duration-cap
feat(producer): stream long single-worker renders by default; duration cap behind a flag (Phase 0)
This commit is contained in:
@@ -927,6 +927,26 @@ does not.
|
||||
becomes a floor and disables that scaling below it. Env:
|
||||
`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`.
|
||||
|
||||
#### Long renders and the capture path
|
||||
|
||||
Captured frames either stream into FFmpeg as they arrive or land on disk first
|
||||
as raw RGBA, which costs `frames × width × height × 4` bytes — about 25 GB per
|
||||
minute at 1080p30. Streaming is the default for single-worker mp4/mov renders
|
||||
**at any duration**: the old four-minute ceiling guarded an FFmpeg timeout that
|
||||
is now per-frame rather than whole-render, so it no longer applies.
|
||||
|
||||
Renders still land on the disk path when the output is `png-sequence` or `gif`,
|
||||
when HDR or shader-transition layering is in play, with more than one worker, or
|
||||
when streaming is switched off. If a render fails preflight asking for tens of
|
||||
GB of temporary frame storage, the message names which of those applied; the
|
||||
usual fixes are `--workers 1`, `--low-memory-mode`, or freeing disk.
|
||||
|
||||
| Environment variable | Default | Effect |
|
||||
| ------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------- |
|
||||
| `PRODUCER_ENABLE_STREAMING_ENCODE` | `true` | `false` sends every render to the disk path |
|
||||
| `PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED` | `false` | `true` restores the duration ceiling below |
|
||||
| `PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS` | `240` | The ceiling's value; inert unless the flag above is `true`, including the `0` ("never") setting |
|
||||
|
||||
#### Render the same composition with different content
|
||||
|
||||
Declare the variables on the composition root, read them inside it, then
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("resolveConfig", () => {
|
||||
expect(config.browserGpuMode).toBe("software");
|
||||
expect(config.enableStreamingEncode).toBe(true);
|
||||
expect(config.streamingEncodeMaxDurationSeconds).toBe(240);
|
||||
expect(config.streamingEncodeDurationCapEnabled).toBe(false);
|
||||
expect((config as Record<string, unknown>).vp9CpuUsed).toBe(4);
|
||||
expect(config.audioGain).toBe(1);
|
||||
expect(config.debug).toBe(false);
|
||||
@@ -105,6 +106,18 @@ describe("resolveConfig", () => {
|
||||
expect(config.streamingEncodeMaxDurationSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it("reads the streaming duration cap enable flag from env", () => {
|
||||
setEnv("PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED", "true");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.streamingEncodeDurationCapEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the streaming duration cap disabled when the flag is unset", () => {
|
||||
const config = resolveConfig();
|
||||
expect(config.streamingEncodeDurationCapEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("reads VP9 cpu-used from env", () => {
|
||||
setEnv("PRODUCER_VP9_CPU_USED", "6");
|
||||
|
||||
|
||||
@@ -160,11 +160,20 @@ export interface EngineConfig {
|
||||
*/
|
||||
streamingEncodeAutoDisabledOnWin32Compound?: 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.
|
||||
* Max composition duration eligible for streaming encode (seconds). Only
|
||||
* applied when `streamingEncodeDurationCapEnabled` is true. Historical: the
|
||||
* 240 s default (#579) guarded a total-render ffmpeg timeout that became an
|
||||
* inactivity timeout in efc16a945, so the cap is off by default. Because of
|
||||
* that, `PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS=0` no longer
|
||||
* disables streaming on its own; `PRODUCER_ENABLE_STREAMING_ENCODE=false`
|
||||
* is the supported off switch.
|
||||
*/
|
||||
streamingEncodeMaxDurationSeconds: number;
|
||||
/**
|
||||
* Apply `streamingEncodeMaxDurationSeconds`. Default false: long single-worker
|
||||
* renders stream. Env: PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED.
|
||||
*/
|
||||
streamingEncodeDurationCapEnabled: boolean;
|
||||
|
||||
// ── FFmpeg timeouts ──────────────────────────────────────────────────
|
||||
/** Timeout for FFmpeg frame encoding (ms). Default: 600_000 */
|
||||
@@ -294,6 +303,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
|
||||
chunkSizeFrames: 360,
|
||||
enableStreamingEncode: true,
|
||||
streamingEncodeMaxDurationSeconds: 240,
|
||||
streamingEncodeDurationCapEnabled: false,
|
||||
|
||||
ffmpegEncodeTimeout: 600_000,
|
||||
ffmpegProcessTimeout: 300_000,
|
||||
@@ -338,6 +348,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [
|
||||
"enablePageSideCompositing",
|
||||
"enableChunkedEncode",
|
||||
"enableStreamingEncode",
|
||||
"streamingEncodeDurationCapEnabled",
|
||||
"hdrAutoDetect",
|
||||
"verifyRuntime",
|
||||
"debug",
|
||||
@@ -866,6 +877,10 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
DEFAULT_CONFIG.streamingEncodeMaxDurationSeconds,
|
||||
),
|
||||
),
|
||||
streamingEncodeDurationCapEnabled: envBool(
|
||||
"PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED",
|
||||
DEFAULT_CONFIG.streamingEncodeDurationCapEnabled,
|
||||
),
|
||||
|
||||
ffmpegEncodeTimeout: envNum("FFMPEG_ENCODE_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegEncodeTimeout),
|
||||
ffmpegProcessTimeout: envNum("FFMPEG_PROCESS_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegProcessTimeout),
|
||||
|
||||
@@ -181,6 +181,7 @@ describe("RenderRequest", () => {
|
||||
{},
|
||||
{ ...value.options.engineConfig, protocolTimeout: "forever" },
|
||||
{ ...value.options.engineConfig, browserGpuMode: "turbo" },
|
||||
{ ...value.options.engineConfig, streamingEncodeDurationCapEnabled: "true" },
|
||||
]) {
|
||||
expect(() =>
|
||||
parseRenderRequest({ ...value, options: { ...value.options, engineConfig } }),
|
||||
@@ -191,6 +192,7 @@ describe("RenderRequest", () => {
|
||||
{},
|
||||
{ ...value.options.engineConfig, protocolTimeout: "forever" },
|
||||
{ ...value.options.engineConfig, browserGpuMode: "turbo" },
|
||||
{ ...value.options.engineConfig, streamingEncodeDurationCapEnabled: "true" },
|
||||
]) {
|
||||
const distributed = distributedConfigFromRequest(value);
|
||||
(distributed as { engineConfig: unknown }).engineConfig = engineConfig;
|
||||
|
||||
@@ -35,6 +35,25 @@ describe("disk capture capacity", () => {
|
||||
).toThrow(/may need ~0\.8 MB.*0\.8 MB is free.*--low-memory-mode/s);
|
||||
});
|
||||
|
||||
it("tells the user which routes still use disk capture, not a dead env var", () => {
|
||||
const landscape = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: { num: 30, den: 1 },
|
||||
format: "jpeg" as const,
|
||||
};
|
||||
let message = "";
|
||||
try {
|
||||
assertDiskCaptureHeadroom("/tmp/frames", 9000, landscape, () => 10 * 1e9);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toContain("Disk capture may need");
|
||||
expect(message).toContain("--workers 1");
|
||||
expect(message).toContain("--low-memory-mode");
|
||||
expect(message).not.toContain("PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS");
|
||||
});
|
||||
|
||||
it("exposes the same 90% headroom decision to fallback planning", () => {
|
||||
const estimatedBytes = estimateDiskCaptureBytes(10, captureOptions);
|
||||
|
||||
|
||||
@@ -194,8 +194,9 @@ export function assertDiskCaptureHeadroom(
|
||||
throw new Error(
|
||||
`Disk capture may need ~${(headroom.estimatedBytes / 1e6).toFixed(1)} MB of temporary frame storage, ` +
|
||||
`but only ${(headroom.freeBytes / 1e6).toFixed(1)} MB is free at ${framesDir}. ` +
|
||||
"Re-run with --low-memory-mode to stream frames, raise " +
|
||||
"PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS if streaming is supported, " +
|
||||
"Disk capture stores every frame as raw RGBA; it is used for multi-worker renders " +
|
||||
"and for png-sequence, gif, HDR and shader-transition outputs. " +
|
||||
"Re-run with --workers 1 to stream frames into the encoder, use --low-memory-mode, " +
|
||||
"or free up disk space.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
closeOrphanedProbeForRetry,
|
||||
describeMemoryExhaustion,
|
||||
executeDiskCaptureWithAdaptiveRetry,
|
||||
explainStreamingEncodeGate,
|
||||
collectVideoMetadataHints,
|
||||
collectVideoReadinessSkipIds,
|
||||
extractStandaloneEntryFromIndex,
|
||||
@@ -586,17 +587,37 @@ describe("shouldUseStreamingEncode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
it("ignores the duration cap unless streamingEncodeDurationCapEnabled is true", () => {
|
||||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240.001)).toBe(true);
|
||||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 3600)).toBe(true);
|
||||
expect(
|
||||
shouldUseStreamingEncode(
|
||||
{ enableStreamingEncode: true, streamingEncodeMaxDurationSeconds: 120 },
|
||||
{ ...streamingEnabledConfig, streamingEncodeDurationCapEnabled: true },
|
||||
"mp4",
|
||||
1,
|
||||
240.001,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseStreamingEncode(
|
||||
{
|
||||
enableStreamingEncode: true,
|
||||
streamingEncodeMaxDurationSeconds: 120,
|
||||
streamingEncodeDurationCapEnabled: true,
|
||||
},
|
||||
"mp4",
|
||||
1,
|
||||
120.001,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseStreamingEncode(
|
||||
{ ...streamingEnabledConfig, streamingEncodeDurationCapEnabled: true },
|
||||
"mp4",
|
||||
1,
|
||||
240,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps long single-worker renders streaming in low-memory mode", () => {
|
||||
@@ -606,6 +627,93 @@ describe("shouldUseStreamingEncode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("explainStreamingEncodeGate", () => {
|
||||
const cfg = {
|
||||
enableStreamingEncode: true,
|
||||
streamingEncodeMaxDurationSeconds: 240,
|
||||
lowMemoryMode: false,
|
||||
};
|
||||
|
||||
it("names the reason for every decision", () => {
|
||||
expect(
|
||||
explainStreamingEncodeGate({ ...cfg, enableStreamingEncode: false }, "mp4", 1, 10),
|
||||
).toEqual({ enabled: false, reason: "disabled_by_config" });
|
||||
expect(explainStreamingEncodeGate(cfg, "png-sequence", 1, 10)).toEqual({
|
||||
enabled: false,
|
||||
reason: "format_excluded",
|
||||
});
|
||||
expect(explainStreamingEncodeGate(cfg, "gif", 1, 10)).toEqual({
|
||||
enabled: false,
|
||||
reason: "format_excluded",
|
||||
});
|
||||
expect(explainStreamingEncodeGate(cfg, "mp4", 1, 0)).toEqual({
|
||||
enabled: false,
|
||||
reason: "invalid_duration",
|
||||
});
|
||||
expect(explainStreamingEncodeGate(cfg, "mp4", 1, Number.NaN)).toEqual({
|
||||
enabled: false,
|
||||
reason: "invalid_duration",
|
||||
});
|
||||
expect(
|
||||
explainStreamingEncodeGate(
|
||||
{ ...cfg, streamingEncodeDurationCapEnabled: true },
|
||||
"mp4",
|
||||
1,
|
||||
300,
|
||||
),
|
||||
).toEqual({ enabled: false, reason: "duration_cap" });
|
||||
expect(
|
||||
explainStreamingEncodeGate(
|
||||
{ ...cfg, streamingEncodeDurationCapEnabled: true, lowMemoryMode: true },
|
||||
"mp4",
|
||||
1,
|
||||
300,
|
||||
),
|
||||
).toEqual({ enabled: true, reason: "low_memory_mode" });
|
||||
expect(explainStreamingEncodeGate(cfg, "mp4", 3, 300, true)).toEqual({
|
||||
enabled: true,
|
||||
reason: "parallel_forced",
|
||||
});
|
||||
expect(explainStreamingEncodeGate(cfg, "mp4", 1, 300)).toEqual({
|
||||
enabled: true,
|
||||
reason: "single_worker",
|
||||
});
|
||||
expect(explainStreamingEncodeGate(cfg, "mp4", 2, 300)).toEqual({
|
||||
enabled: false,
|
||||
reason: "multi_worker",
|
||||
});
|
||||
});
|
||||
|
||||
it("low-memory mode only bypasses the cap; it never streams an unforced multi-worker render", () => {
|
||||
// `--low-memory-mode --workers 4` is a real combination (only the
|
||||
// single-worker pin is bypassed by an explicit worker count). The
|
||||
// contiguous-chunk parallel writer stalls, so the gate must still say no.
|
||||
expect(
|
||||
explainStreamingEncodeGate(
|
||||
{ ...cfg, streamingEncodeDurationCapEnabled: true, lowMemoryMode: true },
|
||||
"mp4",
|
||||
4,
|
||||
300,
|
||||
),
|
||||
).toEqual({ enabled: false, reason: "multi_worker" });
|
||||
});
|
||||
|
||||
it("agrees with shouldUseStreamingEncode on every input", () => {
|
||||
const cases: Array<[typeof cfg, "mp4" | "webm" | "gif", number, number, boolean]> = [
|
||||
[cfg, "mp4", 1, 10, false],
|
||||
[cfg, "mp4", 2, 10, false],
|
||||
[cfg, "mp4", 2, 10, true],
|
||||
[cfg, "gif", 1, 10, false],
|
||||
[{ ...cfg, enableStreamingEncode: false }, "mp4", 1, 10, false],
|
||||
];
|
||||
for (const [c, format, workers, duration, force] of cases) {
|
||||
expect(shouldUseStreamingEncode(c, format, workers, duration, force)).toBe(
|
||||
explainStreamingEncodeGate(c, format, workers, duration, force).enabled,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCompiledFrameSrcResolver", () => {
|
||||
it("maps extracted frame paths under compiledDir to encoded server URLs", () => {
|
||||
const resolver = createCompiledFrameSrcResolver("/tmp/hf job/compiled");
|
||||
|
||||
@@ -1404,35 +1404,96 @@ function replaceBodyWithRenderClone(body: HTMLElement, renderClone: Element): vo
|
||||
body.appendChild(renderClone);
|
||||
}
|
||||
|
||||
export function shouldUseStreamingEncode(
|
||||
cfg: Pick<EngineConfig, "enableStreamingEncode" | "streamingEncodeMaxDurationSeconds"> &
|
||||
Partial<Pick<EngineConfig, "lowMemoryMode">>,
|
||||
export type StreamingEncodeGateReason =
|
||||
| "disabled_by_config"
|
||||
| "format_excluded"
|
||||
| "invalid_duration"
|
||||
| "duration_cap"
|
||||
| "low_memory_mode"
|
||||
| "parallel_forced"
|
||||
| "single_worker"
|
||||
| "multi_worker";
|
||||
|
||||
export interface StreamingEncodeGateDecision {
|
||||
enabled: boolean;
|
||||
/** Why `enabled` is what it is. Logged as `reason` on the streaming-encode gate line. */
|
||||
reason: StreamingEncodeGateReason;
|
||||
}
|
||||
|
||||
type StreamingGateConfig = Pick<
|
||||
EngineConfig,
|
||||
"enableStreamingEncode" | "streamingEncodeMaxDurationSeconds"
|
||||
> &
|
||||
Partial<Pick<EngineConfig, "lowMemoryMode" | "streamingEncodeDurationCapEnabled">>;
|
||||
|
||||
/**
|
||||
* Decide whether captured frames stream into ffmpeg (bounded scratch) or land
|
||||
* on disk as raw RGBA (`frames × w × h × 4` bytes). Every `false` names the
|
||||
* gate that fired so the log line and telemetry can attribute disk-path
|
||||
* renders. Order matters and mirrors the historical predicate:
|
||||
* config → format → duration validity → duration cap → parallel override →
|
||||
* worker count.
|
||||
*/
|
||||
export function explainStreamingEncodeGate(
|
||||
cfg: StreamingGateConfig,
|
||||
outputFormat: NonNullable<RenderConfig["format"]>,
|
||||
workerCount: number,
|
||||
// Composition timeline duration in seconds.
|
||||
durationSeconds: number,
|
||||
// Per-render override (set by the DE parallel router) — see
|
||||
// deParallelStreamForced's declaration in executeRenderJob for why this is
|
||||
// a parameter instead of an env-var read.
|
||||
// Per-render override (set by the DE parallel router or the non-DE
|
||||
// parallel-stream router) — see deParallelStreamForced's declaration in
|
||||
// executeRenderJob for why this is a parameter instead of an env-var read.
|
||||
forceParallelStream = false,
|
||||
): boolean {
|
||||
if (!cfg.enableStreamingEncode) return false;
|
||||
if (outputFormat === "png-sequence") return false;
|
||||
if (outputFormat === "gif") return false;
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
|
||||
): StreamingEncodeGateDecision {
|
||||
if (!cfg.enableStreamingEncode) return { enabled: false, reason: "disabled_by_config" };
|
||||
if (outputFormat === "png-sequence" || outputFormat === "gif") {
|
||||
return { enabled: false, reason: "format_excluded" };
|
||||
}
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
return { enabled: false, reason: "invalid_duration" };
|
||||
}
|
||||
// The duration cap is an operator opt-in (streamingEncodeDurationCapEnabled);
|
||||
// its original reason — a total-render ffmpeg timeout — became an inactivity
|
||||
// timeout in efc16a945, so by default long renders keep streaming.
|
||||
const overCap =
|
||||
cfg.streamingEncodeDurationCapEnabled === true &&
|
||||
durationSeconds > cfg.streamingEncodeMaxDurationSeconds;
|
||||
// Low-memory mode already pins capture to one worker. Keep those renders on
|
||||
// the streaming path regardless of duration so captured frames are drained
|
||||
// directly into FFmpeg instead of accumulating hundreds of gigabytes of
|
||||
// data URIs / disk frames until Chrome OOMs.
|
||||
if (!cfg.lowMemoryMode && durationSeconds > cfg.streamingEncodeMaxDurationSeconds) return false;
|
||||
// data URIs / disk frames until Chrome OOMs. It only bypasses the cap: an
|
||||
// explicit `--workers N` under low-memory mode still falls to the
|
||||
// worker-count gate below.
|
||||
if (overCap && !cfg.lowMemoryMode) return { enabled: false, reason: "duration_cap" };
|
||||
// HF_DE_PARALLEL_STREAM (manual opt-in) / forceParallelStream (router):
|
||||
// allow multi-worker streaming for the interleaved drawElement produce
|
||||
// path. Contiguous-chunk parallel streaming stalls (worker k+1's first
|
||||
// frame waits for ALL of worker k's), so this only makes sense with the
|
||||
// allow multi-worker streaming for the interleaved produce paths.
|
||||
// Contiguous-chunk parallel streaming stalls (worker k+1's first frame
|
||||
// waits for ALL of worker k's), so this only makes sense with the
|
||||
// interleaved distribution the capture stage selects under the same
|
||||
// condition.
|
||||
if (forceParallelStream || process.env.HF_DE_PARALLEL_STREAM === "true") return true;
|
||||
return workerCount === 1;
|
||||
if (forceParallelStream || process.env.HF_DE_PARALLEL_STREAM === "true") {
|
||||
return { enabled: true, reason: "parallel_forced" };
|
||||
}
|
||||
if (workerCount === 1) {
|
||||
return { enabled: true, reason: overCap ? "low_memory_mode" : "single_worker" };
|
||||
}
|
||||
return { enabled: false, reason: "multi_worker" };
|
||||
}
|
||||
|
||||
export function shouldUseStreamingEncode(
|
||||
cfg: StreamingGateConfig,
|
||||
outputFormat: NonNullable<RenderConfig["format"]>,
|
||||
workerCount: number,
|
||||
durationSeconds: number,
|
||||
forceParallelStream = false,
|
||||
): boolean {
|
||||
return explainStreamingEncodeGate(
|
||||
cfg,
|
||||
outputFormat,
|
||||
workerCount,
|
||||
durationSeconds,
|
||||
forceParallelStream,
|
||||
).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2130,9 +2191,12 @@ export function shouldPreferParallelDrawElement(args: {
|
||||
* (`shouldUseStreamingEncode` at the router's worker count with
|
||||
* forceParallelStream). The router's entire value is that path; without it
|
||||
* firing would pin workerCount to 3 and skip calibration while delivering
|
||||
* none of the benefit — e.g. a composition longer than
|
||||
* `streamingEncodeMaxDurationSeconds` (240 s default), where the duration
|
||||
* cap disables streaming before the router's force flag is consulted.
|
||||
* none of the benefit — e.g. png-sequence / gif output, streaming disabled
|
||||
* by config, or — only when the operator opt-in
|
||||
* `streamingEncodeDurationCapEnabled` is on — a composition longer than
|
||||
* `streamingEncodeMaxDurationSeconds` (240 s), where the duration cap
|
||||
* disables streaming before the router's force flag is consulted. With the
|
||||
* cap off (the default) long compositions are eligible here too.
|
||||
*/
|
||||
parallelStreamingAvailable: boolean;
|
||||
/** Machine RAM (os.totalmem, MB). */
|
||||
@@ -3472,8 +3536,9 @@ async function executeRenderPipeline(input: {
|
||||
process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true" ||
|
||||
process.env.HF_DE_PARALLEL_STREAM === "true",
|
||||
routerEnabled: deParallelRouterEnabled,
|
||||
// Router pins 3 workers for the streaming path; don't pin when the
|
||||
// duration cap (or any other streaming gate) would turn that path off.
|
||||
// Router pins 3 workers for the streaming path; don't pin when a
|
||||
// streaming gate (config, format, or the opt-in duration cap) would turn
|
||||
// that path off.
|
||||
parallelStreamingAvailable: shouldUseStreamingEncode(
|
||||
cfg,
|
||||
outputFormat,
|
||||
@@ -3811,16 +3876,19 @@ async function executeRenderPipeline(input: {
|
||||
// on for this multi-worker render (same formula as the early value above,
|
||||
// now including `captureParallelStreamForced`). This is the value the
|
||||
// rest of the pipeline (encode/writer selection, logging) uses.
|
||||
useStreamingEncode = shouldUseStreamingEncode(
|
||||
const streamingGate = explainStreamingEncodeGate(
|
||||
cfg,
|
||||
outputFormat,
|
||||
workerCount,
|
||||
job.duration,
|
||||
deParallelStreamForced || captureParallelStreamForced,
|
||||
);
|
||||
useStreamingEncode = streamingGate.enabled;
|
||||
log.info("streaming-encode gate", {
|
||||
enabled: useStreamingEncode,
|
||||
reason: streamingGate.reason,
|
||||
configFlag: cfg.enableStreamingEncode,
|
||||
durationCapEnabled: cfg.streamingEncodeDurationCapEnabled,
|
||||
outputFormat,
|
||||
workerCount,
|
||||
durationSeconds: job.duration,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Long-form render fixtures
|
||||
|
||||
Manual gates for `plans/long-form-render/`. Not part of the automated lanes (they render for minutes and need ~600 MB of source).
|
||||
|
||||
## Generate the source once
|
||||
|
||||
```sh
|
||||
mkdir -p /tmp/hf-longform/assets && cd /tmp/hf-longform
|
||||
ffmpeg -y -f lavfi -i "testsrc2=size=1920x1080:rate=30" -f lavfi -i "sine=frequency=440:sample_rate=48000" \
|
||||
-t 300 -c:v libx264 -preset ultrafast -crf 23 -g 30 -pix_fmt yuv420p -c:a aac -b:a 96k -movflags +faststart assets/long.mp4
|
||||
node <repo>/packages/producer/tests/long-form/gen.mjs
|
||||
```
|
||||
|
||||
For the 40-minute soak fixture use `-t 2400` and `DUR=2400 node .../gen.mjs`.
|
||||
|
||||
## Run a gate against the worktree build
|
||||
|
||||
```sh
|
||||
cd <repo> && bun run build
|
||||
cd /tmp/hf-longform
|
||||
node <repo>/packages/cli/dist/cli.js render a-single --fps 30 -w 1 --quality draft -o a-single/renders/out.mp4
|
||||
```
|
||||
|
||||
Check duration: `ffprobe -v error -show_entries format=duration -of csv=p=0 a-single/renders/out.mp4` → `300.000000`.
|
||||
|
||||
Never run two of these concurrently on a dev Mac — the fleet limit is why the gates are sequential.
|
||||
|
||||
## Gates by phase
|
||||
|
||||
| Phase | Command | Expect |
|
||||
| ----- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| 0 | `render a-single --fps 30 -w 1` (stock env) | log `streaming-encode gate {"enabled":true,"reason":"single_worker",...}`; output 300.000 s |
|
||||
| 1 | `render a-single --fps 30 -w 4` (stock env) | log `Parallel screenshot capture will stream to the encoder`; work dir < 2 GB; output 300.000 s |
|
||||
| 2a | `HF_SEGMENTED_CAPTURE=true render a-single --fps 30 -w 1` | `segment_*.mp4` in work dir; output 300.000 s; PSNR ≥ 45 dB vs the Phase 0 output |
|
||||
| 2b | kill the 2a render at ~40 %, rerun with `--resume` | log `resuming: N segments complete`; output byte-identical to an uninterrupted run |
|
||||
| 2c | 2a with `HF_SEGMENT_BROWSER_RECYCLE=1` | one `[Render] segment browser recycled` line per segment; output unchanged |
|
||||
| 2d | 2a with `-w 4` | four `segment worker` lines; output 300.000 s |
|
||||
|
||||
The Phase 0 mutation check is the same render with `PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED=true`: the gate line must flip to `"enabled":false,"reason":"duration_cap"` and the render must fail at the disk preflight on a host without ~75 GB free.
|
||||
|
||||
PSNR between two renders:
|
||||
|
||||
```sh
|
||||
ffmpeg -i a.mp4 -i b.mp4 -lavfi "[0:v][1:v]psnr" -f null - 2>&1 | grep -o 'average:[0-9.inf]*'
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
// Long-form render fixtures (spec §8). Run from a scratch directory that
|
||||
// already contains assets/long.mp4 (see README.md for the ffmpeg command).
|
||||
// DUR=300 node gen.mjs # 5-minute fixtures (default)
|
||||
// DUR=2400 node gen.mjs # 40-minute soak fixtures
|
||||
import { mkdirSync, writeFileSync, linkSync, existsSync } from "node:fs";
|
||||
|
||||
const W = 1920;
|
||||
const H = 1080;
|
||||
const DUR = Number(process.env.DUR ?? "300");
|
||||
if (!Number.isFinite(DUR) || DUR <= 0)
|
||||
throw new Error(`DUR must be a positive number, got ${process.env.DUR}`);
|
||||
if (!existsSync("assets/long.mp4")) throw new Error("assets/long.mp4 missing; see README.md");
|
||||
|
||||
const comp = (
|
||||
body,
|
||||
) => `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:#000}#root{position:relative;width:${W}px;height:${H}px;overflow:hidden;background:#000}video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}#badge{position:absolute;right:40px;top:40px;font:700 64px sans-serif;color:#fff;background:rgba(0,0,0,.5);padding:8px 24px}</style></head><body>
|
||||
<div id="root" data-composition-id="root" data-width="${W}" data-height="${H}" data-start="0" data-duration="${DUR}" data-no-timeline>
|
||||
${body}
|
||||
<div id="badge">overlay</div>
|
||||
</div></body></html>`;
|
||||
|
||||
const single = () =>
|
||||
comp(
|
||||
`<video class="clip" id="v0" src="assets/long.mp4" data-start="0" data-duration="${DUR}" data-media-start="0" data-has-audio="true"></video>`,
|
||||
);
|
||||
|
||||
const clips = (n = 60) =>
|
||||
comp(
|
||||
Array.from({ length: n }, (_, i) => {
|
||||
const d = DUR / n;
|
||||
return `<video class="clip" id="v${i}" src="assets/long.mp4" data-start="${(i * d).toFixed(3)}" data-duration="${d.toFixed(3)}" data-media-start="${(i * d).toFixed(3)}" data-has-audio="true"></video>`;
|
||||
}).join("\n"),
|
||||
);
|
||||
|
||||
for (const [name, html] of Object.entries({ "a-single": single(), "c-clips60": clips() })) {
|
||||
mkdirSync(`${name}/assets`, { recursive: true });
|
||||
writeFileSync(`${name}/index.html`, html);
|
||||
if (!existsSync(`${name}/assets/long.mp4`)) linkSync("assets/long.mp4", `${name}/assets/long.mp4`);
|
||||
writeFileSync(
|
||||
`${name}/hyperframes.json`,
|
||||
JSON.stringify({ name, fps: 30, width: W, height: H }, null, 2),
|
||||
);
|
||||
}
|
||||
console.log(`generated a-single c-clips60 (DUR=${DUR}s)`);
|
||||
Reference in New Issue
Block a user