mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(cli): add hls output format (#4022)
This commit is contained in:
@@ -46,6 +46,7 @@ npx hyperframes render --output final.mp4
|
||||
| WebM | Web delivery and transparent overlays |
|
||||
| GIF | Short previews in issues, pull requests, and documentation |
|
||||
| PNG sequence | Frame-by-frame handoff to compositing software |
|
||||
| HLS | VOD streaming from a playlist directory |
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -61,6 +62,9 @@ npx hyperframes render --format gif --fps 15 --output preview.gif
|
||||
|
||||
# RGBA frames in a directory
|
||||
npx hyperframes render --format png-sequence --output frames
|
||||
|
||||
# HLS VOD stream in a directory
|
||||
npx hyperframes render --format hls --output stream
|
||||
```
|
||||
|
||||
GIF has no audio and limited transparency. Prefer MP4 or WebM for normal playback.
|
||||
@@ -94,6 +98,25 @@ View the PNG over a checkerboard or contrasting background. If it is still
|
||||
opaque, compare it with the composition's PNG-sequence output to check whether
|
||||
transparency was already missing before video encoding.
|
||||
|
||||
### HLS streaming output
|
||||
|
||||
`--format hls` writes an HLS VOD stream into the output **directory**:
|
||||
`master.m3u8`, `video.m3u8` and `video_NNNNN.ts`, plus `audio.m3u8` and
|
||||
`audio_NNNNN.ts` when the composition has audio. The video and audio encodes
|
||||
are identical to MP4; only the delivery container differs, and packaging is a
|
||||
stream copy rather than a second encode.
|
||||
|
||||
```bash
|
||||
npx hyperframes render --format hls --output stream
|
||||
npx hyperframes render --format hls --hls-segment-seconds 6 --output stream
|
||||
```
|
||||
|
||||
Segments are exactly `--hls-segment-seconds` long (default 4) except the last,
|
||||
and every segment starts on a keyframe. HLS output is SDR only, so `--hdr` is
|
||||
rejected, and `--gpu` is rejected because GPU encoders ignore the forced-keyframe
|
||||
lock that fixed-length segments depend on. Cloud rendering (`lambda`,
|
||||
`cloudrun`) does not offer HLS yet.
|
||||
|
||||
## Input video codecs
|
||||
|
||||
Studio, preview, `check`, and published projects normally create cached browser
|
||||
|
||||
@@ -845,6 +845,9 @@ npx hyperframes render --docker --output output.mp4
|
||||
# Transparent, for overlays and lower thirds
|
||||
npx hyperframes render --format webm --output overlay.webm
|
||||
|
||||
# HLS VOD stream (output is a directory of playlists and .ts segments)
|
||||
npx hyperframes render --format hls --output stream
|
||||
|
||||
npx hyperframes render --output output.mp4 --fps 60 --quality high
|
||||
npx hyperframes render --gpu --output gpu.mp4
|
||||
```
|
||||
@@ -855,7 +858,7 @@ The flags that come up most:
|
||||
| ------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--output, -o` | `renders/<name>.mp4` | Output file path |
|
||||
| `--composition, -c` | `index.html` | Render a different composition file. Sub-compositions using `<template>` wrappers must be referenced from `index.html` via `data-composition-src`. |
|
||||
| `--format` | `mp4` | `mp4`, `webm`, `mov`, `gif`, or `png-sequence`. WebM and MOV carry transparency; `png-sequence` writes RGBA frames to a directory. |
|
||||
| `--format` | `mp4` | `mp4`, `webm`, `mov`, `gif`, `png-sequence`, or `hls`. WebM and MOV carry transparency; `png-sequence` writes RGBA frames to a directory; `hls` writes a VOD playlist directory. |
|
||||
| `--fps, -f` | root `data-fps`, otherwise 30 | 1–240, or an ffmpeg rational like `30000/1001` for 29.97 |
|
||||
| `--quality, -q` | `looks` | `draft`, `looks` (CRF 16, default), `delivery` (= `high`), `standard`, or `high`. MOV always uses the fixed alpha-preserving ProRes 4444 profile. |
|
||||
| `--resolution` | the composition's size | Supersample to a preset via Chrome's `deviceScaleFactor`. Aspect ratio must match and the scale must be a whole multiple. Not with `--hdr`. See [4K rendering](/guides/4k-rendering). |
|
||||
@@ -873,6 +876,7 @@ Quality and file size:
|
||||
| `--video-bitrate` | from `--quality`| Target MP4/WebM bitrate, e.g. `10M` or `5000k`. Not with `--crf`; rejected for MOV. |
|
||||
| `--vp9-cpu-used` | encoder default | libvpx-vp9 speed/quality trade-off for WebM, -8 to 8. Env: `PRODUCER_VP9_CPU_USED`. |
|
||||
| `--gif-loop` | `0` | GIF loop count, `0` for forever. Range 0–65535, `--format gif` only. |
|
||||
| `--hls-segment-seconds`| `4` | HLS target segment length in whole seconds, 1–60. `--format hls` only. Also fixes the encoder GOP so every segment starts on a keyframe. |
|
||||
| `--video-frame-format` | `auto` | How source video frames are extracted: `auto`, `jpg`, `png`. Use `png` for UI recordings, screen captures, and other colour-sensitive footage. |
|
||||
| `--hdr` | off | Force HDR even with no HDR sources. MP4 only. See [HDR rendering](/guides/hdr). |
|
||||
| `--sdr` | off | Force SDR even when HDR sources are detected |
|
||||
|
||||
@@ -63,6 +63,7 @@ const job = createRenderJob({
|
||||
| `mov` | Transparent ProRes 4444 for an editor | AAC |
|
||||
| `gif` | Small silent previews | None |
|
||||
| `png-sequence` | Lossless frames for another pipeline | AAC sidecar when needed |
|
||||
| `hls` | VOD streaming from a playlist directory | AAC rendition |
|
||||
|
||||
HDR output is available for MP4 through `hdrMode`. Transparent formats render
|
||||
in SDR because HDR and alpha are not one supported output path.
|
||||
@@ -78,6 +79,27 @@ const job = createRenderJob({
|
||||
|
||||
See [HDR rendering](/guides/hdr) for the source and delivery constraints.
|
||||
|
||||
### HLS
|
||||
|
||||
`format: "hls"` writes an HLS VOD stream into `outputPath`, which is a
|
||||
directory: `master.m3u8`, `video.m3u8` and `video_NNNNN.ts`, plus `audio.m3u8`
|
||||
and `audio_NNNNN.ts` when the composition has audio. The encode is the same
|
||||
H.264 + AAC as MP4 — the packaging pass only stream-copies it into segments.
|
||||
|
||||
```ts
|
||||
const job = createRenderJob({
|
||||
fps: 30,
|
||||
format: "hls",
|
||||
hlsSegmentSeconds: 4,
|
||||
});
|
||||
```
|
||||
|
||||
Segments are exactly `hlsSegmentSeconds` long except the last, because the
|
||||
encoder GOP is locked to `round(fps × hlsSegmentSeconds)` frames so every
|
||||
segment starts on a keyframe. HLS is SDR only (`hdrMode: "force-hdr"` is
|
||||
rejected), refuses GPU encoding (GPU encoders ignore the keyframe lock), and is
|
||||
not available in distributed rendering, Lambda, or Cloud Run.
|
||||
|
||||
## Cancel a render
|
||||
|
||||
Pass an abort signal to the final argument:
|
||||
|
||||
@@ -822,6 +822,44 @@ describe("renderLocal browser GPU config", () => {
|
||||
expect(producerState.createdJobs[0]?.format).toBe("png-sequence");
|
||||
});
|
||||
|
||||
it("forwards format: hls and hlsSegmentSeconds through to createRenderJob", async () => {
|
||||
await renderLocal("/tmp/project", "/tmp/stream", {
|
||||
fps: { num: 30, den: 1 },
|
||||
quality: "standard",
|
||||
format: "hls",
|
||||
hlsSegmentSeconds: 6,
|
||||
gpu: false,
|
||||
browserGpuMode: "software",
|
||||
hdrMode: "auto",
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
expect(producerState.createdJobs[0]?.format).toBe("hls");
|
||||
expect(producerState.createdJobs[0]?.hlsSegmentSeconds).toBe(6);
|
||||
});
|
||||
|
||||
// HLS refuses the VideoToolbox fallback MP4 takes: fixed-length segments need
|
||||
// the software encoder's forced-keyframe lock.
|
||||
it("fails an HLS render instead of falling back to GPU H.264", async () => {
|
||||
ffmpegEncoderState.encoders = " V....D h264_videotoolbox H.264 (VideoToolbox)\n";
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
await expect(
|
||||
renderLocal("/tmp/project", "/tmp/stream", {
|
||||
fps: { num: 30, den: 1 },
|
||||
quality: "standard",
|
||||
format: "hls",
|
||||
gpu: false,
|
||||
browserGpuMode: "software",
|
||||
hdrMode: "auto",
|
||||
quiet: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "CliRuntimeError" });
|
||||
|
||||
expect(producerState.createdJobs).toHaveLength(0);
|
||||
expect(stderr.mock.calls.flat().join(" ")).toContain("libx264");
|
||||
});
|
||||
|
||||
it("forwards format: gif and gifLoop through to createRenderJob", async () => {
|
||||
await renderLocal("/tmp/project", "/tmp/demo.gif", {
|
||||
fps: { num: 15, den: 1 },
|
||||
|
||||
@@ -27,6 +27,10 @@ export const examples: Example[] = [
|
||||
"Render PNG sequence (RGBA frames for AE/Nuke/Fusion)",
|
||||
"hyperframes render --format png-sequence --output frames/",
|
||||
],
|
||||
[
|
||||
"Render HLS VOD (master playlist + MPEG-TS segments in a directory)",
|
||||
"hyperframes render --format hls --output stream/",
|
||||
],
|
||||
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
|
||||
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
|
||||
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
|
||||
@@ -114,7 +118,7 @@ import {
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "render",
|
||||
description: "Render a composition to MP4, WebM, MOV, GIF, or a PNG sequence",
|
||||
description: "Render a composition to MP4, WebM, MOV, GIF, HLS, or a PNG sequence",
|
||||
},
|
||||
args: {
|
||||
dir: {
|
||||
@@ -164,15 +168,23 @@ export default defineCommand({
|
||||
format: {
|
||||
type: "string",
|
||||
description:
|
||||
"Output format: mp4, webm, mov, gif, png-sequence " +
|
||||
"Output format: mp4, webm, mov, gif, png-sequence, hls " +
|
||||
"(MOV/WebM render with transparency; png-sequence writes RGBA frames " +
|
||||
"to a directory for AE/Nuke/Fusion ingest; gif is best at 15fps for PRs/docs)",
|
||||
"to a directory for AE/Nuke/Fusion ingest; gif is best at 15fps for PRs/docs; " +
|
||||
"hls writes a VOD playlist directory — H.264/AAC, SDR only, no GPU encoding)",
|
||||
default: "mp4",
|
||||
},
|
||||
"gif-loop": {
|
||||
type: "string",
|
||||
description: "GIF loop count, 0 = infinite. Range: 0-65535. Only used with --format gif.",
|
||||
},
|
||||
"hls-segment-seconds": {
|
||||
type: "string",
|
||||
description:
|
||||
"HLS target segment length in whole seconds (default: 4). Range: 1-60. " +
|
||||
"Also fixes the encoder GOP at segment x fps frames so every segment " +
|
||||
"starts on a keyframe. Only used with --format hls.",
|
||||
},
|
||||
"video-frame-format": {
|
||||
type: "string",
|
||||
description:
|
||||
@@ -419,6 +431,8 @@ export interface RenderOptions {
|
||||
catalogUsage?: CatalogUsage;
|
||||
format: RenderFormat;
|
||||
gifLoop?: number;
|
||||
/** HLS target segment length in seconds; ignored unless `format` is `"hls"`. */
|
||||
hlsSegmentSeconds?: number;
|
||||
workers?: number;
|
||||
gpu: boolean;
|
||||
/**
|
||||
@@ -742,6 +756,7 @@ async function renderDocker(
|
||||
quality: options.quality,
|
||||
format: options.format,
|
||||
gifLoop: options.gifLoop,
|
||||
hlsSegmentSeconds: options.hlsSegmentSeconds,
|
||||
workers: options.workers,
|
||||
gpu: options.gpu,
|
||||
browserGpu: options.browserGpuMode === "hardware",
|
||||
@@ -812,7 +827,12 @@ async function renderDocker(
|
||||
// threaded back here; the summary shows render time only (never a wrong video
|
||||
// length). Probe the output with ffprobe if a duration figure is wanted here.
|
||||
runPostRenderStep("printRenderComplete", () =>
|
||||
printRenderComplete(outputPath, elapsed, options.quiet),
|
||||
printRenderComplete({
|
||||
outputPath,
|
||||
elapsedMs: elapsed,
|
||||
quiet: options.quiet,
|
||||
format: options.format,
|
||||
}),
|
||||
);
|
||||
runPostRenderStep("warnIfWebmAlphaDropped", () =>
|
||||
warnIfWebmAlphaDropped(outputPath, options.format, options.quiet),
|
||||
@@ -893,7 +913,10 @@ async function executeLocalRender(
|
||||
process.env.PRODUCER_HEADLESS_SHELL_PATH = preflight.browser.executablePath;
|
||||
}
|
||||
|
||||
if (!options.gpu && options.format === "mp4" && preflight.ffmpegPath) {
|
||||
// HLS shares the H.264 encode path but cannot take the GPU fallback below:
|
||||
// its fixed-length segments need the software encoder's forced-keyframe lock.
|
||||
const isHls = options.format === "hls";
|
||||
if (!options.gpu && (options.format === "mp4" || isHls) && preflight.ffmpegPath) {
|
||||
let encoderMode: H264EncoderMode = "software";
|
||||
try {
|
||||
encoderMode = await detectH264EncoderModeForRender(
|
||||
@@ -904,12 +927,18 @@ async function executeLocalRender(
|
||||
} catch (error) {
|
||||
if (cancellation.signal.aborted) cancellation.signal.throwIfAborted();
|
||||
// HDR MP4 uses HEVC; auto mode cannot resolve the codec until sources
|
||||
// have been inspected. Only forced SDR is definitely H.264 here.
|
||||
if (error instanceof H264EncoderUnavailableError && options.hdrMode === "force-sdr") {
|
||||
// have been inspected. Forced SDR and HLS are definitely H.264 here.
|
||||
if (
|
||||
error instanceof H264EncoderUnavailableError &&
|
||||
(options.hdrMode === "force-sdr" || isHls)
|
||||
) {
|
||||
errorBox(
|
||||
"MP4 H.264 encoder unavailable",
|
||||
`${isHls ? "HLS" : "MP4"} H.264 encoder unavailable`,
|
||||
error.message,
|
||||
`Install an FFmpeg build with libx264 support (${getFFmpegInstallHint()}), or render WebM instead: hyperframes render --format webm --output output.webm`,
|
||||
`Install an FFmpeg build with libx264 support (${getFFmpegInstallHint()})` +
|
||||
(isHls
|
||||
? "."
|
||||
: ", or render WebM instead: hyperframes render --format webm --output output.webm"),
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
@@ -920,6 +949,14 @@ async function executeLocalRender(
|
||||
console.warn(c.warn(` Unable to probe H.264 encoder capabilities: ${detail}`));
|
||||
}
|
||||
}
|
||||
if (encoderMode === "gpu" && isHls) {
|
||||
errorBox(
|
||||
"HLS H.264 encoder unavailable",
|
||||
"FFmpeg does not include libx264, and HLS cannot fall back to VideoToolbox: fixed-length segments require the software encoder's forced-keyframe lock.",
|
||||
`Install an FFmpeg build with libx264 support (${getFFmpegInstallHint()}).`,
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
if (encoderMode === "gpu") {
|
||||
console.warn(
|
||||
c.warn(" FFmpeg does not include libx264; falling back to VideoToolbox H.264 encoding."),
|
||||
@@ -968,6 +1005,7 @@ async function executeLocalRender(
|
||||
quality: options.quality,
|
||||
format: options.format,
|
||||
gifLoop: options.gifLoop,
|
||||
hlsSegmentSeconds: options.hlsSegmentSeconds,
|
||||
workers: options.workers,
|
||||
useGpu: options.gpu,
|
||||
hdrMode: options.hdrMode,
|
||||
@@ -1032,13 +1070,14 @@ async function executeLocalRender(
|
||||
}
|
||||
runPostRenderStep("trackRenderMetrics", () => trackRenderMetrics(job, elapsed, options, false));
|
||||
runPostRenderStep("printRenderComplete", () =>
|
||||
printRenderComplete(
|
||||
printRenderComplete({
|
||||
outputPath,
|
||||
elapsed,
|
||||
options.quiet,
|
||||
job.perfSummary,
|
||||
options.browserGpuMode,
|
||||
),
|
||||
elapsedMs: elapsed,
|
||||
quiet: options.quiet,
|
||||
format: options.format,
|
||||
perf: job.perfSummary,
|
||||
requestedGpuMode: options.browserGpuMode,
|
||||
}),
|
||||
);
|
||||
runPostRenderStep("warnIfWebmAlphaDropped", () =>
|
||||
warnIfWebmAlphaDropped(outputPath, options.format, options.quiet),
|
||||
@@ -1723,7 +1762,7 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
|
||||
try {
|
||||
const stat = statSync(outputPath);
|
||||
if (!stat.isDirectory()) return { fileSize: formatBytes(stat.size), isDirectory: false };
|
||||
// png-sequence output is a directory; sum contained file sizes so the
|
||||
// png-sequence and hls write a directory; sum contained file sizes so the
|
||||
// user sees the deliverable footprint, not the directory inode size.
|
||||
let total = 0;
|
||||
for (const entry of readdirSync(outputPath, { withFileTypes: true })) {
|
||||
@@ -1740,25 +1779,28 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
|
||||
}
|
||||
}
|
||||
|
||||
function printRenderComplete(
|
||||
outputPath: string,
|
||||
elapsedMs: number,
|
||||
quiet: boolean,
|
||||
perf?: RenderPerfSummary,
|
||||
requestedGpuMode?: "auto" | "hardware" | "software",
|
||||
): void {
|
||||
if (quiet) return;
|
||||
function printRenderComplete(input: {
|
||||
outputPath: string;
|
||||
elapsedMs: number;
|
||||
quiet: boolean;
|
||||
format: RenderFormat;
|
||||
perf?: RenderPerfSummary;
|
||||
requestedGpuMode?: "auto" | "hardware" | "software";
|
||||
}): void {
|
||||
if (input.quiet) return;
|
||||
const { outputPath, elapsedMs, perf } = input;
|
||||
const { fileSize, isDirectory } = readOutputFootprint(outputPath);
|
||||
const detail = formatRenderSummaryDetail({
|
||||
elapsedMs,
|
||||
outputDurationSeconds: perf?.compositionDurationSeconds,
|
||||
isDirectory,
|
||||
frameCount: perf?.totalFrames,
|
||||
playlistDirectory: input.format === "hls",
|
||||
});
|
||||
console.log("");
|
||||
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
||||
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
|
||||
if (perf) printRenderPipeline(perf, requestedGpuMode);
|
||||
if (perf) printRenderPipeline(perf, input.requestedGpuMode);
|
||||
}
|
||||
|
||||
function printRenderPipeline(
|
||||
|
||||
@@ -122,6 +122,7 @@ export async function executeRenderPlan(
|
||||
catalogUsage: plan.catalogUsage,
|
||||
format: plan.format,
|
||||
gifLoop: plan.gifLoop,
|
||||
hlsSegmentSeconds: plan.hlsSegmentSeconds,
|
||||
workers: plan.workers,
|
||||
gpu: plan.useGpu,
|
||||
browserGpuMode: plan.browserGpuMode,
|
||||
@@ -303,6 +304,7 @@ async function executeBatchRender(
|
||||
catalogUsage: plan.catalogUsage,
|
||||
format: plan.format,
|
||||
gifLoop: plan.gifLoop,
|
||||
hlsSegmentSeconds: plan.hlsSegmentSeconds,
|
||||
workers: plan.workers,
|
||||
gpu: plan.useGpu,
|
||||
browserGpuMode: plan.browserGpuMode,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { CliUsageError } from "../../utils/commandResult.js";
|
||||
import { createRenderPlan } from "./plan.js";
|
||||
|
||||
@@ -129,6 +129,43 @@ describe("createRenderPlan", () => {
|
||||
expect(plan.videoBitrate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("plans HLS as directory output with the default segment length", () => {
|
||||
const plan = createRenderPlan({ dir: projectDir, format: "hls" });
|
||||
|
||||
expect(plan.format).toBe("hls");
|
||||
expect(plan.hlsSegmentSeconds).toBe(4);
|
||||
// No extension: outputPath is the directory the playlists are written into.
|
||||
expect(plan.outputPath.startsWith(resolve("renders", plan.project.name))).toBe(true);
|
||||
expect(basename(plan.outputPath)).not.toContain(".");
|
||||
});
|
||||
|
||||
it("carries an explicit --hls-segment-seconds and rejects a fractional one", () => {
|
||||
expect(
|
||||
createRenderPlan({ dir: projectDir, format: "hls", "hls-segment-seconds": "6" })
|
||||
.hlsSegmentSeconds,
|
||||
).toBe(6);
|
||||
expect(() =>
|
||||
createRenderPlan({ dir: projectDir, format: "hls", "hls-segment-seconds": "2.5" }),
|
||||
).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("leaves hlsSegmentSeconds unset for other formats", () => {
|
||||
expect(
|
||||
createRenderPlan({ dir: projectDir, format: "mp4", "hls-segment-seconds": "6" })
|
||||
.hlsSegmentSeconds,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["--hdr", { hdr: true }],
|
||||
["--gpu", { gpu: true }],
|
||||
])("rejects %s with HLS output", (flag, conflicting) => {
|
||||
expect(() => createRenderPlan({ dir: projectDir, format: "hls", ...conflicting })).toThrow(
|
||||
CliUsageError,
|
||||
);
|
||||
expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain(flag);
|
||||
});
|
||||
|
||||
it("keeps MP4 and WebM rate controls available", () => {
|
||||
expect(createRenderPlan({ dir: projectDir, format: "mp4", crf: "18" }).crf).toBe(18);
|
||||
expect(
|
||||
|
||||
@@ -23,6 +23,7 @@ import { resolveProject } from "../../utils/project.js";
|
||||
import {
|
||||
hasExplicitCompositionArg,
|
||||
parseGifLoopArg,
|
||||
parseHlsSegmentSecondsArg,
|
||||
resolveBrowserTimeoutMsArg,
|
||||
resolveCompositionEntryArg,
|
||||
resolveDefaultFpsArg,
|
||||
@@ -38,9 +39,12 @@ const QUALITY_ALIASES = {
|
||||
looks: { quality: "standard" as const, crf: 16 },
|
||||
delivery: { quality: "high" as const },
|
||||
} as const;
|
||||
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
|
||||
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif", "hls"] as const;
|
||||
const VALID_FORMAT = new Set<string>(RENDER_FORMATS);
|
||||
const RENDER_FORMAT_LABEL = "mp4, webm, mov, png-sequence, or gif";
|
||||
const RENDER_FORMAT_LABEL = "mp4, webm, mov, png-sequence, gif, or hls";
|
||||
|
||||
/** Mirrors the producer's `DEFAULT_HLS_SEGMENT_SECONDS`; the plan is built before the producer loads. */
|
||||
const DEFAULT_HLS_SEGMENT_SECONDS = 4;
|
||||
|
||||
export type RenderFormat = (typeof RENDER_FORMATS)[number];
|
||||
export type RenderQuality = "draft" | "standard" | "high";
|
||||
@@ -54,6 +58,8 @@ const FORMAT_EXT: Record<RenderFormat, string> = {
|
||||
mov: ".mov",
|
||||
"png-sequence": "",
|
||||
gif: ".gif",
|
||||
// Directory output, like png-sequence: playlists and .ts segments go inside.
|
||||
hls: "",
|
||||
};
|
||||
|
||||
export interface RenderCommandArgs {
|
||||
@@ -65,6 +71,7 @@ export interface RenderCommandArgs {
|
||||
skill?: string;
|
||||
format?: string;
|
||||
"gif-loop"?: string;
|
||||
"hls-segment-seconds"?: string;
|
||||
"video-frame-format"?: string;
|
||||
workers?: string;
|
||||
docker?: boolean;
|
||||
@@ -111,6 +118,8 @@ export interface RenderPlan {
|
||||
format: RenderFormat;
|
||||
gifLoop?: number;
|
||||
gifFpsCapped: boolean;
|
||||
/** HLS target segment length in seconds; only set for `format: "hls"`. */
|
||||
hlsSegmentSeconds?: number;
|
||||
videoFrameFormat: VideoFrameFormat;
|
||||
outputResolution?: CanvasResolution;
|
||||
outputResolutionAspectAgnostic: boolean;
|
||||
@@ -240,6 +249,14 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
|
||||
}
|
||||
const gifLoop = gifLoopParse.value ?? (format === "gif" ? 0 : undefined);
|
||||
|
||||
const hlsSegmentParse = parseHlsSegmentSecondsArg(args["hls-segment-seconds"]);
|
||||
if (!hlsSegmentParse.ok) {
|
||||
errorBox("Invalid hls-segment-seconds", hlsSegmentParse.message);
|
||||
failUsage();
|
||||
}
|
||||
const hlsSegmentSeconds =
|
||||
format === "hls" ? (hlsSegmentParse.value ?? DEFAULT_HLS_SEGMENT_SECONDS) : undefined;
|
||||
|
||||
const videoFrameFormatRaw = args["video-frame-format"] ?? "auto";
|
||||
if (!isVideoFrameFormat(videoFrameFormatRaw)) {
|
||||
errorBox(
|
||||
@@ -275,6 +292,24 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
|
||||
errorBox("Conflicting flags", "--hdr and --sdr are mutually exclusive.");
|
||||
failUsage();
|
||||
}
|
||||
if (format === "hls" && args.hdr) {
|
||||
errorBox(
|
||||
"Unsupported HLS output",
|
||||
"--hdr cannot be combined with --format hls. HLS output is SDR only (H.264 + AAC in MPEG-TS); HDR10 would need fMP4 segments and HEVC.",
|
||||
"Render HDR to MP4, or drop --hdr.",
|
||||
);
|
||||
failUsage();
|
||||
}
|
||||
// Fixed-length segments come from the software encoder's forced-keyframe
|
||||
// lock, which GPU encoders ignore; the producer rejects the pair too.
|
||||
if (format === "hls" && args.gpu) {
|
||||
errorBox(
|
||||
"Unsupported HLS output",
|
||||
"--gpu cannot be combined with --format hls. Fixed-length segments require the software encoder's forced-keyframe lock, which GPU encoders ignore.",
|
||||
"Re-run without --gpu.",
|
||||
);
|
||||
failUsage();
|
||||
}
|
||||
|
||||
const workers =
|
||||
args.workers != null && args.workers !== "auto"
|
||||
@@ -446,6 +481,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
|
||||
format,
|
||||
gifLoop,
|
||||
gifFpsCapped,
|
||||
hlsSegmentSeconds,
|
||||
videoFrameFormat: videoFrameFormatRaw,
|
||||
outputResolution,
|
||||
outputResolutionAspectAgnostic,
|
||||
|
||||
@@ -42,20 +42,23 @@ function presentRenderSummary(plan: RenderPlan): void {
|
||||
if (plan.outputResolution) {
|
||||
console.log(c.dim(" Output resolution: " + plan.outputResolution));
|
||||
}
|
||||
if (plan.useGpu || plan.browserGpuMode !== "software") {
|
||||
const gpuModes = [
|
||||
plan.useGpu ? "encoder GPU" : null,
|
||||
plan.browserGpuMode === "hardware"
|
||||
? "browser GPU (forced)"
|
||||
: plan.browserGpuMode === "auto"
|
||||
? "browser GPU (auto-detect)"
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
console.log(c.dim(" GPU: " + gpuModes.join(" + ")));
|
||||
if (plan.format === "hls") {
|
||||
console.log(c.dim(` HLS: ${plan.hlsSegmentSeconds}s segments in a playlist directory`));
|
||||
}
|
||||
const gpuModes = formatGpuModes(plan);
|
||||
if (gpuModes) console.log(c.dim(" GPU: " + gpuModes));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/** The GPU line's accelerated modes, or undefined when neither is. */
|
||||
function formatGpuModes(plan: RenderPlan): string | undefined {
|
||||
const modes: string[] = [];
|
||||
if (plan.useGpu) modes.push("encoder GPU");
|
||||
if (plan.browserGpuMode === "hardware") modes.push("browser GPU (forced)");
|
||||
else if (plan.browserGpuMode === "auto") modes.push("browser GPU (auto-detect)");
|
||||
return modes.length > 0 ? modes.join(" + ") : undefined;
|
||||
}
|
||||
|
||||
async function warnForSlideshow(plan: RenderPlan): Promise<void> {
|
||||
try {
|
||||
const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow");
|
||||
|
||||
@@ -109,6 +109,17 @@ describe("formatRenderSummaryDetail", () => {
|
||||
expect(detail).not.toContain("video");
|
||||
});
|
||||
|
||||
it("shows the video length for an HLS playlist directory", () => {
|
||||
const detail = formatRenderSummaryDetail({
|
||||
elapsedMs: 12_000,
|
||||
isDirectory: true,
|
||||
playlistDirectory: true,
|
||||
frameCount: 120,
|
||||
outputDurationSeconds: 4,
|
||||
});
|
||||
expect(detail).toBe("4.0s video · rendered in 12.0s");
|
||||
});
|
||||
|
||||
it("does not crash and shows only render time for a directory with no frame count", () => {
|
||||
expect(formatRenderSummaryDetail({ elapsedMs: 1_000, isDirectory: true })).toBe(
|
||||
"rendered in 1.0s",
|
||||
|
||||
@@ -21,21 +21,24 @@ export function formatDuration(ms: number): string {
|
||||
* wall-clock render time explicitly labeled "rendered in" so the two are never
|
||||
* confused (users were comparing the render time to ffprobe's media duration).
|
||||
* Directory (png-sequence) output has no single muxed video, so it shows a frame
|
||||
* count instead, or just the render time when neither is known.
|
||||
* count instead, or just the render time when neither is known. An HLS playlist
|
||||
* directory does play as one continuous video, so it reports a duration.
|
||||
*/
|
||||
export function formatRenderSummaryDetail(input: {
|
||||
elapsedMs: number;
|
||||
outputDurationSeconds?: number;
|
||||
isDirectory: boolean;
|
||||
frameCount?: number;
|
||||
playlistDirectory?: boolean;
|
||||
}): string {
|
||||
const middle = input.isDirectory
|
||||
? input.frameCount != null
|
||||
? `${input.frameCount} frames`
|
||||
: undefined
|
||||
: input.outputDurationSeconds != null && input.outputDurationSeconds > 0
|
||||
? `${formatDuration(input.outputDurationSeconds * 1000)} video`
|
||||
: undefined;
|
||||
const middle =
|
||||
input.isDirectory && !input.playlistDirectory
|
||||
? input.frameCount != null
|
||||
? `${input.frameCount} frames`
|
||||
: undefined
|
||||
: input.outputDurationSeconds != null && input.outputDurationSeconds > 0
|
||||
? `${formatDuration(input.outputDurationSeconds * 1000)} video`
|
||||
: undefined;
|
||||
const renderTime = `rendered in ${formatDuration(input.elapsedMs)}`;
|
||||
return [middle, renderTime].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
@@ -244,6 +244,24 @@ describe("buildDockerRunArgs", () => {
|
||||
expect(args[formatIdx + 1]).toBe("png-sequence");
|
||||
});
|
||||
|
||||
it("forwards --format hls and --hls-segment-seconds to the container", () => {
|
||||
const args = buildDockerRunArgs({
|
||||
...FIXED_INPUT,
|
||||
outputFilename: "stream",
|
||||
options: { ...BASE, format: "hls", hlsSegmentSeconds: 6 },
|
||||
});
|
||||
const formatIdx = args.indexOf("--format");
|
||||
const segmentIdx = args.indexOf("--hls-segment-seconds");
|
||||
expect(args[formatIdx + 1]).toBe("hls");
|
||||
expect(args[segmentIdx + 1]).toBe("6");
|
||||
});
|
||||
|
||||
it("omits --hls-segment-seconds for other formats", () => {
|
||||
expect(buildDockerRunArgs({ ...FIXED_INPUT, options: BASE })).not.toContain(
|
||||
"--hls-segment-seconds",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards --format gif and --gif-loop to the container", () => {
|
||||
const args = buildDockerRunArgs({
|
||||
...FIXED_INPUT,
|
||||
|
||||
@@ -39,8 +39,9 @@ export interface DockerRenderOptions {
|
||||
*/
|
||||
fps: Fps;
|
||||
quality: "draft" | "standard" | "high";
|
||||
format: "mp4" | "webm" | "mov" | "png-sequence" | "gif";
|
||||
format: "mp4" | "webm" | "mov" | "png-sequence" | "gif" | "hls";
|
||||
gifLoop?: number;
|
||||
hlsSegmentSeconds?: number;
|
||||
workers?: number;
|
||||
gpu: boolean;
|
||||
browserGpu: boolean;
|
||||
@@ -132,6 +133,9 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
|
||||
"--format",
|
||||
options.format,
|
||||
...(options.gifLoop != null ? ["--gif-loop", String(options.gifLoop)] : []),
|
||||
...(options.hlsSegmentSeconds != null
|
||||
? ["--hls-segment-seconds", String(options.hlsSegmentSeconds)]
|
||||
: []),
|
||||
...(options.workers != null ? ["--workers", String(options.workers)] : []),
|
||||
...(options.crf != null ? ["--crf", String(options.crf)] : []),
|
||||
...(options.vp9CpuUsed != null ? ["--vp9-cpu-used", String(options.vp9CpuUsed)] : []),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveDiagnosticNavigationTimeoutMs,
|
||||
parseCompositionEntryArg,
|
||||
parseGifLoopArg,
|
||||
parseHlsSegmentSecondsArg,
|
||||
resolveDefaultFpsArg,
|
||||
type BrowserTimeoutParseResult,
|
||||
type CompositionEntryParseResult,
|
||||
@@ -298,3 +299,20 @@ describe("parseGifLoopArg", () => {
|
||||
expect(parseGifLoopArg(" ").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHlsSegmentSecondsArg", () => {
|
||||
it("accepts absent flag and whole seconds in range", () => {
|
||||
expect(parseHlsSegmentSecondsArg(undefined)).toEqual({ ok: true, value: undefined });
|
||||
expect(parseHlsSegmentSecondsArg("1")).toEqual({ ok: true, value: 1 });
|
||||
expect(parseHlsSegmentSecondsArg(" 6 ")).toEqual({ ok: true, value: 6 });
|
||||
expect(parseHlsSegmentSecondsArg("60")).toEqual({ ok: true, value: 60 });
|
||||
});
|
||||
|
||||
// Fractional seconds would make ffmpeg's integer #EXT-X-TARGETDURATION lie.
|
||||
it("rejects zero, fractional, out-of-range, and empty inputs", () => {
|
||||
expect(parseHlsSegmentSecondsArg("0").ok).toBe(false);
|
||||
expect(parseHlsSegmentSecondsArg("2.5").ok).toBe(false);
|
||||
expect(parseHlsSegmentSecondsArg("61").ok).toBe(false);
|
||||
expect(parseHlsSegmentSecondsArg(" ").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,3 +298,31 @@ export function parseGifLoopArg(raw: string | undefined): GifLoopParseResult {
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
export type HlsSegmentSecondsParseResult =
|
||||
| { ok: true; value: number | undefined }
|
||||
| { ok: false; message: string };
|
||||
|
||||
const MAX_HLS_SEGMENT_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Parse and validate `--hls-segment-seconds <n>` (HLS target segment length).
|
||||
* Whole seconds only: the value becomes ffmpeg's `-hls_time`, which writes an
|
||||
* integer `#EXT-X-TARGETDURATION`. Returns `{ ok: true, value: undefined }`
|
||||
* when the flag is absent so the caller can apply the format-dependent default.
|
||||
*/
|
||||
export function parseHlsSegmentSecondsArg(raw: string | undefined): HlsSegmentSecondsParseResult {
|
||||
if (raw === undefined) return { ok: true, value: undefined };
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { ok: false, message: "HLS segment length must not be empty." };
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_HLS_SEGMENT_SECONDS) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Got "${raw}". HLS segment length must be a whole number of seconds between 1 and ${MAX_HLS_SEGMENT_SECONDS}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
+49
-11
@@ -47,16 +47,17 @@ await startServer({ port: 8080 });
|
||||
|
||||
`RenderConfig` controls the render pipeline:
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `inputPath` | — | Path to the HTML composition |
|
||||
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"`) |
|
||||
| `width` | 1920 | Frame width in pixels |
|
||||
| `height` | 1080 | Frame height in pixels |
|
||||
| `fps` | 30 | Frames per second (24, 30, or 60) |
|
||||
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
|
||||
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, or `"png-sequence"`. See [Transparent Video Output](#transparent-video-output) below. |
|
||||
| `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. |
|
||||
| Option | Default | Description |
|
||||
| ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `inputPath` | — | Path to the HTML composition |
|
||||
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"` and `format: "hls"`) |
|
||||
| `width` | 1920 | Frame width in pixels |
|
||||
| `height` | 1080 | Frame height in pixels |
|
||||
| `fps` | 30 | Frames per second (24, 30, or 60) |
|
||||
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
|
||||
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, `"gif"`, `"png-sequence"`, or `"hls"`. See [Transparent Video Output](#transparent-video-output) and [HLS Output](#hls-output) below. |
|
||||
| `hlsSegmentSeconds` | `4` | HLS target segment length in whole seconds. Ignored for every other format. |
|
||||
| `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. |
|
||||
|
||||
## Transparent Video Output
|
||||
|
||||
@@ -68,6 +69,7 @@ The producer can render HTML compositions to formats that carry a **true alpha c
|
||||
| `"webm"` | VP9 + yuva420p | **True alpha** | Opus | Web playback as overlay (`<video>` over background); supported in Chrome, Edge, Firefox |
|
||||
| `"mov"` | ProRes 4444 + yuva444p10le | **True alpha + 10-bit** | AAC | Editor ingest (Premiere, Final Cut Pro, DaVinci Resolve) |
|
||||
| `"png-sequence"` | Numbered RGBA PNGs in a directory | **Lossless alpha** | Sidecar `audio.aac` | After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding |
|
||||
| `"hls"` | H.264 (yuv420p) in MPEG-TS | No | AAC rendition | VOD streaming — see [HLS Output](#hls-output) |
|
||||
|
||||
### Example
|
||||
|
||||
@@ -107,6 +109,42 @@ This is not chroma keying. There is no green/blue background to remove and no "k
|
||||
|
||||
Don't paint a fullscreen background in your HTML. The default body background is overridden to transparent automatically — any `body { background: ... }`, `#root { background: ... }`, or `[data-composition-id] { background: ... }` rule is force-overridden during alpha rendering. Backgrounds on inner elements (cards, scenes, components) are kept.
|
||||
|
||||
## HLS Output
|
||||
|
||||
`format: "hls"` packages the same H.264 + AAC encode as `mp4` as an HLS VOD
|
||||
stream. `outputPath` is a **directory**:
|
||||
|
||||
```
|
||||
master.m3u8 # references both renditions
|
||||
video.m3u8 # video media playlist
|
||||
video_00000.ts # MPEG-TS segments, one IDR frame at the start of each
|
||||
audio.m3u8 # written only when the composition has audio
|
||||
audio_00000.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
const job = createRenderJob({
|
||||
inputPath: "./my-composition.html",
|
||||
outputPath: "./renders/stream",
|
||||
format: "hls",
|
||||
hlsSegmentSeconds: 4,
|
||||
});
|
||||
```
|
||||
|
||||
Segments are exactly `hlsSegmentSeconds` long except the last: the encoder's GOP
|
||||
is locked to `round(fps × hlsSegmentSeconds)` frames so the packaging pass can
|
||||
stream-copy (`-c copy`) and still cut on keyframes. There is no second encode.
|
||||
|
||||
Constraints:
|
||||
|
||||
- **SDR only.** `hdrMode: "force-hdr"` is rejected; HDR10 would need fMP4
|
||||
segments and HEVC. Use `format: "mp4"` for HDR.
|
||||
- **Software encoder only.** `useGpu: true` is rejected — GPU encoders ignore
|
||||
the forced-keyframe lock that fixed-length segments depend on.
|
||||
- **Not available in distributed rendering**, including Lambda and Cloud Run.
|
||||
- MPEG-TS carries no mov-family metadata, so the render provenance tags written
|
||||
into MP4 output are absent.
|
||||
|
||||
## Distributed rendering
|
||||
|
||||
For renders too large for a single machine, the producer ships a public set of distributed-render primitives. They are pure functions over local file paths — networking and orchestration live in adapter packages (Temporal, AWS Lambda + Step Functions, Cloud Run Jobs, K8s Jobs).
|
||||
@@ -144,7 +182,7 @@ The activity functions plus their result types are also re-exported from `@hyper
|
||||
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame` (or `Page.captureScreenshot` for transparent / non-Linux renders), captures screenshots
|
||||
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for `format: "png-sequence"`.
|
||||
4. **Mix** — extracts `<audio>` elements and mixes them into the final video. For `png-sequence`, audio is written as an `audio.aac` sidecar.
|
||||
5. **Finalize** — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and `png-sequence`)
|
||||
5. **Finalize** — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and `png-sequence`). For `hls`, stream-copies the encode into playlists and MPEG-TS segments instead.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"files": 7
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "3fa1be752aac3ae8",
|
||||
"hash": "666010a0b0d30da7",
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
|
||||
@@ -117,31 +117,32 @@ npx hyperframes render --docker # byte-identical
|
||||
|
||||
> Default `--output` is `renders/<project-name>_<YYYY-MM-DD>_<HH-MM-SS>.<ext>` — timestamped per render so successive runs don't clobber each other. Pass `--output` to get a stable name.
|
||||
|
||||
| Flag | Options | Default | Notes |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dir` (positional) | path | cwd | Project directory. Omit to use current working directory. |
|
||||
| `--composition`, `-c` | path to composition file | `index.html` | Render a specific composition file (e.g. `compositions/intro.html`) instead of the project's `index.html`. |
|
||||
| `--output`, `-o` | path | `renders/<project>_<ts>.<ext>` | Output path. Default is timestamped (`<project-name>_YYYY-MM-DD_HH-MM-SS.<ext>`). |
|
||||
| `--fps` | 24, 30, 60 | 30 | 60fps doubles render time |
|
||||
| `--quality` | draft, looks, delivery, standard, high | looks | `looks` is CRF 16 on the standard preset. `delivery` is `high`. draft for iterating |
|
||||
| `--format` | mp4, webm, mov, gif, png-sequence | mp4 | WebM/MOV render with transparency; gif for inline autoplay in GitHub PRs/READMEs/docs (two-pass palette encode, fps capped at 30 — prefer `--fps 15` — no audio, 1-bit transparency only, HDR falls back to SDR); png-sequence writes RGBA frames to a directory (AE/Nuke/Fusion ingest) |
|
||||
| `--gif-loop` | 0-65535 | 0 | GIF loop count; `0` loops forever. Only with `--format gif`. |
|
||||
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (+ aliases `1080p`, `4k`, `uhd`) | — | Supersample via Chrome `deviceScaleFactor`. Aspect ratio must match composition; scale must be an integer. Not with `--hdr`. |
|
||||
| `--crf` | 0-51 | — | Encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate`. |
|
||||
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate. Mutually exclusive with `--crf`. |
|
||||
| `--hdr` | flag | off | Force HDR output even with SDR sources. MP4 only. |
|
||||
| `--sdr` | flag | off | Force SDR even with HDR sources. |
|
||||
| `--workers` | number or `auto` | auto | Each worker spawns Chrome (~256 MB) |
|
||||
| `--docker` | flag | off | Reproducible output across hosts |
|
||||
| `--gpu` | flag | off | GPU-accelerated FFmpeg encoding (NVENC / VideoToolbox / VAAPI / QSV) |
|
||||
| `--browser-gpu` / `--no-browser-gpu` | flag | auto (local), off (docker) | Host GPU for Chrome/WebGL capture |
|
||||
| `--browser-timeout` | seconds (0.001–86400) | 60 | Puppeteer page-navigation timeout for the entry HTML. Raise when heavy compositions (many videos / fonts / remote assets) can't reach `domcontentloaded` within the 60s default. |
|
||||
| `--quiet` | flag | off | Suppress verbose output |
|
||||
| `--strict` | flag | off | Fail on lint errors |
|
||||
| `--strict-all` | flag | off | Fail on lint errors AND warnings |
|
||||
| `--variables` | JSON object | — | Override values declared in `data-composition-variables` |
|
||||
| `--variables-file` | path | — | JSON file with variable values (alternative to `--variables`) |
|
||||
| `--strict-variables` | flag | off | Fail render on undeclared keys or type mismatches in `--variables` |
|
||||
| Flag | Options | Default | Notes |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dir` (positional) | path | cwd | Project directory. Omit to use current working directory. |
|
||||
| `--composition`, `-c` | path to composition file | `index.html` | Render a specific composition file (e.g. `compositions/intro.html`) instead of the project's `index.html`. |
|
||||
| `--output`, `-o` | path | `renders/<project>_<ts>.<ext>` | Output path. Default is timestamped (`<project-name>_YYYY-MM-DD_HH-MM-SS.<ext>`). |
|
||||
| `--fps` | 24, 30, 60 | 30 | 60fps doubles render time |
|
||||
| `--quality` | draft, looks, delivery, standard, high | looks | `looks` is CRF 16 on the standard preset. `delivery` is `high`. draft for iterating |
|
||||
| `--format` | mp4, webm, mov, gif, png-sequence, hls | mp4 | WebM/MOV render with transparency; gif for inline autoplay in GitHub PRs/READMEs/docs (two-pass palette encode, fps capped at 30 — prefer `--fps 15` — no audio, 1-bit transparency only, HDR falls back to SDR); png-sequence writes RGBA frames to a directory (AE/Nuke/Fusion ingest); hls writes an HLS VOD directory (master.m3u8 + video/audio playlists + MPEG-TS segments), SDR only, rejects `--gpu`, unavailable on lambda/cloudrun |
|
||||
| `--gif-loop` | 0-65535 | 0 | GIF loop count; `0` loops forever. Only with `--format gif`. |
|
||||
| `--hls-segment-seconds` | 1-60 | 4 | HLS target segment length in whole seconds; also locks the encoder GOP so every segment starts on a keyframe. Only with `--format hls`. |
|
||||
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (+ aliases `1080p`, `4k`, `uhd`) | — | Supersample via Chrome `deviceScaleFactor`. Aspect ratio must match composition; scale must be an integer. Not with `--hdr`. |
|
||||
| `--crf` | 0-51 | — | Encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate`. |
|
||||
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate. Mutually exclusive with `--crf`. |
|
||||
| `--hdr` | flag | off | Force HDR output even with SDR sources. MP4 only. |
|
||||
| `--sdr` | flag | off | Force SDR even with HDR sources. |
|
||||
| `--workers` | number or `auto` | auto | Each worker spawns Chrome (~256 MB) |
|
||||
| `--docker` | flag | off | Reproducible output across hosts |
|
||||
| `--gpu` | flag | off | GPU-accelerated FFmpeg encoding (NVENC / VideoToolbox / VAAPI / QSV) |
|
||||
| `--browser-gpu` / `--no-browser-gpu` | flag | auto (local), off (docker) | Host GPU for Chrome/WebGL capture |
|
||||
| `--browser-timeout` | seconds (0.001–86400) | 60 | Puppeteer page-navigation timeout for the entry HTML. Raise when heavy compositions (many videos / fonts / remote assets) can't reach `domcontentloaded` within the 60s default. |
|
||||
| `--quiet` | flag | off | Suppress verbose output |
|
||||
| `--strict` | flag | off | Fail on lint errors |
|
||||
| `--strict-all` | flag | off | Fail on lint errors AND warnings |
|
||||
| `--variables` | JSON object | — | Override values declared in `data-composition-variables` |
|
||||
| `--variables-file` | path | — | JSON file with variable values (alternative to `--variables`) |
|
||||
| `--strict-variables` | flag | off | Fail render on undeclared keys or type mismatches in `--variables` |
|
||||
|
||||
**Quality guidance:** `draft` while iterating, `looks` (the default) for the first real encode, `delivery` for final delivery. `standard` and `high` still work.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user