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
74 changes: 72 additions & 2 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
title: Rendering
description: "Render compositions to MP4 locally or in Docker."
description: "Render compositions to MP4, MOV, or WebM locally or in Docker."
---

Render your Hyperframes [compositions](/concepts/compositions) to MP4 with the [CLI](/packages/cli). The rendering pipeline is frame-by-frame and seek-driven — see [Deterministic Rendering](/concepts/determinism) for how this works under the hood.
Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or WebM with the [CLI](/packages/cli). The rendering pipeline is frame-by-frame and seek-driven — see [Deterministic Rendering](/concepts/determinism) for how this works under the hood.

## Getting Started

Expand Down Expand Up @@ -114,6 +114,7 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4 with the [
| 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) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset |
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
Expand Down Expand Up @@ -167,6 +168,75 @@ npx hyperframes render --workers 8 --output output.mp4
- Dedicated render machines or CI runners
- Docker mode on a well-provisioned host

## Transparent Video

Hyperframes supports rendering with a transparent background — useful for overlays, lower thirds, subscribe cards, and any element you want to composite over other footage in a video editor.

### Recommended format: MOV (ProRes 4444)

```bash Terminal
npx hyperframes render --format mov --output overlay.mov
```

**MOV with ProRes 4444** is the industry standard for transparent video. It works in all major video editors:

- CapCut
- Final Cut Pro
- Adobe Premiere Pro
- DaVinci Resolve
- After Effects

<Warning>
ProRes MOV files are large (typically 5-40 MB for short clips) because ProRes is a high-quality intermediate codec optimized for editing, not delivery. This is expected — the same tradeoff Remotion and professional pipelines make.
</Warning>

### Format comparison

| Format | Codec | Transparency | Video editors | Browsers | File size |
|--------|-------|-------------|---------------|----------|-----------|
| **MOV** | ProRes 4444 | Yes | CapCut, Final Cut, Premiere, DaVinci, After Effects | No | Large |
| **WebM** | VP9 | Yes | None (shows black background) | Chrome, Firefox | Small |
| **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>

### How it works

When you render with `--format mov` or `--format webm`, 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)

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

### Authoring transparent compositions

```html
<style>
/* Do NOT set background on html/body — leave them transparent */
* { margin: 0; padding: 0; box-sizing: border-box; }

[data-composition-id="my-overlay"] {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
/* No background here either */
}
</style>
```

Only the visible elements (cards, text, images) will appear in the final video. Everything else will be transparent.

### Verifying transparency

- **In a browser:** Open the MOV file — it won't play (ProRes is not a browser codec). Instead, render a WebM copy and open it in Chrome on a checkerboard background page.
- **In a video editor:** Import the MOV file and place it on a track above other footage. Transparent areas should show the footage below.
- **Online tool:** Use [rotato.app/tools/transparent-video](https://rotato.app/tools/transparent-video) to verify your MOV or WebM has working transparency.

## Tips

<Tip>
Expand Down
16 changes: 9 additions & 7 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, statSync } from "node:fs";

export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
Expand All @@ -24,7 +25,8 @@ import type { RenderJob } from "@hyperframes/producer";

const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm"]);
const VALID_FORMAT = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };

const CPU_CORE_COUNT = cpus().length;

Expand All @@ -36,7 +38,7 @@ function defaultWorkerCount(): number {
export default defineCommand({
meta: {
name: "render",
description: "Render a composition to MP4 or WebM",
description: "Render a composition to MP4, WebM, or MOV",
},
args: {
dir: {
Expand All @@ -60,7 +62,7 @@ export default defineCommand({
},
format: {
type: "string",
description: "Output format: mp4, webm (WebM renders with transparency)",
description: "Output format: mp4, webm, mov (MOV/WebM render with transparency)",
default: "mp4",
},
workers: {
Expand Down Expand Up @@ -114,10 +116,10 @@ export default defineCommand({
// ── Validate format ─────────────────────────────────────────────────
const formatRaw = args.format ?? "mp4";
if (!VALID_FORMAT.has(formatRaw)) {
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4 or webm.`);
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4, webm, or mov.`);
process.exit(1);
}
const format = formatRaw as "mp4" | "webm";
const format = formatRaw as "mp4" | "webm" | "mov";

// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
Expand All @@ -132,7 +134,7 @@ export default defineCommand({

// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = format === "webm" ? ".webm" : ".mp4";
const ext = FORMAT_EXT[format] ?? ".mp4";
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
Expand Down Expand Up @@ -262,7 +264,7 @@ export default defineCommand({
interface RenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm";
format: "mp4" | "webm" | "mov";
workers: number;
gpu: boolean;
quiet: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
await executeRenderJob(job, opts.project.dir, opts.outputPath, onProgress);
state.status = "complete";
state.progress = 100;
const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
writeFileSync(
metaPath,
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
Expand All @@ -186,7 +186,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
state.status = "failed";
state.error = err instanceof Error ? err.message : String(err);
try {
const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
} catch {
/* ignore */
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const MIME_TYPES: Record<string, string> = {
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/studio-api/helpers/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const MIME_TYPES: Record<string, string> = {
".webp": "image/webp",
".ico": "image/x-icon",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
Comment thread
miguel-heygen marked this conversation as resolved.
Expand Down
35 changes: 23 additions & 12 deletions packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
quality?: string;
format?: string;
};
const format = body.format === "webm" ? "webm" : "mp4";
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
const fps: 24 | 30 | 60 = body.fps === 24 || body.fps === 60 ? body.fps : 30;
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string)
Expand All @@ -62,13 +64,13 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const jobId = `${project.id}_${datePart}_${timePart}`;
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });
const ext = format === "webm" ? ".webm" : ".mp4";
const ext = FORMAT_EXT[format] ?? ".mp4";
const outputPath = join(rendersDir, `${jobId}${ext}`);

const jobState = adapter.startRender({
project,
outputPath,
format: format as "mp4" | "webm",
format: format as "mp4" | "webm" | "mov",
fps,
quality,
jobId,
Expand Down Expand Up @@ -126,15 +128,26 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
});
});

const RENDER_MIME: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
};
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);

function renderContentType(filePath: string): string {
const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e));
return (ext && RENDER_MIME[ext]) ?? "video/mp4";
}

// Serve render inline (for in-browser playback — opens in a new tab)
api.get("/render/:jobId/view", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const isWebm = job.outputPath.endsWith(".webm");
const contentType = isWebm ? "video/webm" : "video/mp4";
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
Expand All @@ -154,8 +167,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const isWebm = job.outputPath.endsWith(".webm");
const contentType = isWebm ? "video/webm" : "video/mp4";
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
Expand All @@ -172,7 +184,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
for (const [, state] of renderJobs) {
if (state.id === jobId && state.outputPath) {
const dir = state.outputPath.replace(/\/[^/]+$/, "");
for (const ext of [".mp4", ".webm", ".meta.json"]) {
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
const fp = join(dir, `${jobId}${ext}`);
if (existsSync(fp)) unlinkSync(fp);
}
Expand All @@ -192,8 +204,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const rendersDir = adapter.rendersDir(project);
const fp = join(rendersDir, filename);
if (!existsSync(fp)) return c.json({ error: "not found" }, 404);
const isWebm = fp.endsWith(".webm");
const contentType = isWebm ? "video/webm" : "video/mp4";
const contentType = renderContentType(fp);
const content = readFileSync(fp);
return new Response(content, {
headers: {
Expand All @@ -212,11 +223,11 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) return c.json({ renders: [] });
const files = readdirSync(rendersDir)
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm"))
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov"))
.map((f) => {
const fp = join(rendersDir, f);
const stat = statSync(fp);
const rid = f.replace(/\.(mp4|webm)$/, "");
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
const metaPath = join(rendersDir, `${rid}.meta.json`);
let status: "complete" | "failed" = "complete";
let durationMs: number | undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface StudioApiAdapter {
startRender(opts: {
project: ResolvedProject;
outputPath: string;
format: "mp4" | "webm";
format: "mp4" | "webm" | "mov";
fps: number;
quality: string;
jobId: string;
Expand Down
15 changes: 15 additions & 0 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ describe("getEncoderPreset", () => {
}
});

it("returns prores 4444 with yuva444p10le for mov format", () => {
const preset = getEncoderPreset("standard", "mov");
expect(preset.codec).toBe("prores");
expect(preset.preset).toBe("4444");
expect(preset.pixelFormat).toBe("yuva444p10le");
});

it("uses prores 4444 for all mov quality levels", () => {
for (const q of ["draft", "standard", "high"] as const) {
const preset = getEncoderPreset(q, "mov");
expect(preset.codec).toBe("prores");
expect(preset.preset).toBe("4444");
}
});

it("defaults to mp4 when format is omitted", () => {
const preset = getEncoderPreset("standard");
expect(preset.codec).toBe("h264");
Expand Down
Loading
Loading