Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
| Flag | Values | Default | Description |
|------|--------|---------|-------------|
| `--output` | path | `renders/<name>.mp4` | Output file path |
| `--format` | mp4, mov, webm | mp4 | Output format (see [Transparent Video](#transparent-video) below) |
| `--format` | mp4, mov, webm, png-sequence | mp4 | Output format (see [Transparent Video](#transparent-video) below) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset |
| `--crf` | 0–51 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
Expand Down Expand Up @@ -301,19 +301,28 @@ npx hyperframes render --format mov --output overlay.mov
|--------|-------|-------------|---------------|----------|-----------|
| **MOV** | ProRes 4444 | Yes | CapCut, Final Cut, Premiere, DaVinci, After Effects | No | Large |
| **WebM** | VP9 | Yes | None (shows black background) | Chrome, Firefox | Small |
| **PNG sequence** | RGBA PNGs (no encoding) | Yes (lossless) | After Effects, Nuke, Fusion (image-sequence import) | No | Largest |
| **MP4** | H.264 | No | All | All | Small |

<Note>
**WebM VP9 alpha** is technically supported but all major video editors ignore the alpha channel and render transparent areas as black. Only Chromium-based browsers (Chrome, Arc, Brave, Edge) decode VP9 alpha correctly. Safari does not support it. Use MOV for editor workflows and WebM only for browser-based playback.
</Note>

### PNG sequence (no encoding)

```bash Terminal
npx hyperframes render --format png-sequence --output frames/
```

`--format png-sequence` skips the encoder entirely. The captured RGBA frames are copied to `<output>/frame_NNNNNN.png` (zero-padded) and, if the composition has audio, an `audio.aac` sidecar is written alongside. Use this when you want lossless frames — for compositing in After Effects / Nuke / Fusion, or as the input to a custom encode pipeline. `--output` is treated as a directory and is created if it doesn't exist.

### How it works

When you render with `--format mov` or `--format webm`, Hyperframes:
When you render with `--format mov`, `--format webm`, or `--format png-sequence`, Hyperframes:

1. Captures each frame as a **PNG with alpha channel** (instead of JPEG for MP4)
2. Sets Chrome's page background to transparent via `Emulation.setDefaultBackgroundColorOverride`
3. Encodes with an alpha-capable codec (ProRes 4444 for MOV, VP9 for WebM)
3. Encodes with an alpha-capable codec (ProRes 4444 for MOV, VP9 for WebM); `png-sequence` skips encoding and writes the captured frames directly

Your composition's HTML should **not** set a `background` on `html` or `body` — leave it unset so the transparent background comes through.

Expand Down
2 changes: 1 addition & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| Flag | Values | Default | Description |
|------|--------|---------|-------------|
| `--output` | path | `renders/<name>.mp4` | Output file path |
| `--format` | mp4, webm, mov | mp4 | Output format (WebM/MOV render with transparency) |
| `--format` | mp4, webm, mov, png-sequence | mp4 | Output format (WebM/MOV render with transparency; png-sequence writes a directory of RGBA PNGs) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.variables).toEqual({ title: "Hello", count: 3 });
});

it("forwards format: png-sequence through to createRenderJob", async () => {
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/frames", {
fps: 30,
quality: "standard",
format: "png-sequence",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});

expect(producerState.createdJobs[0]?.format).toBe("png-sequence");
});

it("omits variables from createRenderJob when not provided", async () => {
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
Expand All @@ -165,7 +180,7 @@ describe("renderLocal browser GPU config", () => {
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
entryFile: "compositions/intro.html",
Expand All @@ -181,7 +196,7 @@ describe("renderLocal browser GPU config", () => {
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
Expand Down
49 changes: 40 additions & 9 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";

export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
["Render a specific composition", "hyperframes render -c compositions/intro.html -o intro.mp4"],
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
[
"Render PNG sequence (RGBA frames for AE/Nuke/Fusion)",
"hyperframes render --format png-sequence --output frames/",
],
["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"],
Expand Down Expand Up @@ -47,15 +51,22 @@ import {

const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
// `png-sequence` writes a directory of frames rather than a single muxed file,
// so its "extension" is empty — the auto-output path becomes a directory name.
const FORMAT_EXT: Record<string, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
};

const CPU_CORE_COUNT = cpus().length;

export default defineCommand({
meta: {
name: "render",
description: "Render a composition to MP4, WebM, or MOV",
description: "Render a composition to MP4, WebM, MOV, or a PNG sequence",
},
args: {
dir: {
Expand Down Expand Up @@ -89,7 +100,10 @@ export default defineCommand({
},
format: {
type: "string",
description: "Output format: mp4, webm, mov (MOV/WebM render with transparency)",
description:
"Output format: mp4, webm, mov, png-sequence " +
"(MOV/WebM render with transparency; png-sequence writes RGBA frames " +
"to a directory for AE/Nuke/Fusion ingest)",
default: "mp4",
},
workers: {
Expand Down Expand Up @@ -187,10 +201,10 @@ export default defineCommand({
// ── Validate format ─────────────────────────────────────────────────
const formatRaw = args.format ?? "mp4";
if (!VALID_FORMAT.has(formatRaw)) {
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4, webm, or mov.`);
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4, webm, mov, or png-sequence.`);
process.exit(1);
}
const format = formatRaw as "mp4" | "webm" | "mov";
const format = formatRaw as "mp4" | "webm" | "mov" | "png-sequence";

// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
Expand Down Expand Up @@ -464,7 +478,7 @@ export default defineCommand({
interface RenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm" | "mov";
format: "mp4" | "webm" | "mov" | "png-sequence";
workers?: number;
gpu: boolean;
/**
Expand Down Expand Up @@ -976,7 +990,24 @@ function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boole

let fileSize = "unknown";
try {
fileSize = formatBytes(statSync(outputPath).size);
const stat = statSync(outputPath);
if (stat.isDirectory()) {
// png-sequence output is a directory; sum the contained file sizes so
// the user sees the on-disk footprint of the deliverable rather than
// the platform-specific size of the directory inode itself.
let total = 0;
for (const entry of readdirSync(outputPath, { withFileTypes: true })) {
if (!entry.isFile()) continue;
try {
total += statSync(join(outputPath, entry.name)).size;
} catch {
// skip unreadable entries
}
}
fileSize = formatBytes(total);
} else {
fileSize = formatBytes(stat.size);
}
} catch {
// file doesn't exist or is inaccessible
}
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("compositions/intro.html");
});

it("forwards --format png-sequence to the container", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
outputFilename: "frames",
options: { ...BASE, format: "png-sequence" },
});
const formatIdx = args.indexOf("--format");
expect(formatIdx).toBeGreaterThanOrEqual(0);
expect(args[formatIdx + 1]).toBe("png-sequence");
});

it("forwards --video-bitrate to the container when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface DockerRunArgsInput {
export interface DockerRenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm" | "mov";
format: "mp4" | "webm" | "mov" | "png-sequence";
workers?: number;
gpu: boolean;
browserGpu: boolean;
Expand Down
Loading