feat(cli): add hls output format (#4022)

This commit is contained in:
Aditya Poudyal
2026-09-16 21:09:14 -04:00
committed by GitHub
parent 9aa60a844f
commit e4b1832fad
18 changed files with 413 additions and 85 deletions
+23
View File
@@ -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
+5 -1
View File
@@ -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 |
+22
View File
@@ -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:
+38
View File
@@ -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 },
+67 -25
View File
@@ -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,
+38 -1
View File
@@ -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(
+38 -2
View File
@@ -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,
+13 -10
View File
@@ -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");
+11
View File
@@ -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",
+11 -8
View File
@@ -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,
+5 -1
View File
@@ -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)] : []),
+18
View File
@@ -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);
});
});
+28
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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.