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:
Vance Ingalls
2026-09-18 20:50:36 -07:00
committed by GitHub
10 changed files with 368 additions and 33 deletions
+20
View File
@@ -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
+13
View File
@@ -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");
+18 -3
View File
@@ -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]*'
```
+44
View File
@@ -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)`);