diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 8de19627c..9d429d7dd 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -116,7 +116,7 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W | Flag | Values | Default | Description | |------|--------|---------|-------------| | `--output` | path | `renders/.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` | @@ -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 | **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. +### 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 `/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. diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 0e24c119b..c2a5adf20 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -604,7 +604,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | Flag | Values | Default | Description | |------|--------|---------|-------------| | `--output` | path | `renders/.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` | diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index b74860fb3..358629d7a 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -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", { @@ -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", @@ -181,7 +196,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, }); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 02b495ba3..d0fbfb34d 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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"], @@ -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 = { 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 = { + 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: { @@ -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: { @@ -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; @@ -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; /** @@ -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 } diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index ac3029d3e..b4f853ea3 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -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, diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index dd913d17f..a3d275b6b 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -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;