diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 7c640a1265..02eae285db 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -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 @@ -114,6 +114,7 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4 with the [ | Flag | Values | Default | Description | |------|--------|---------|-------------| | `--output` | path | `renders/.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) | @@ -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 + + + 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. + + +### 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 | + + + **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. + + +### 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 + +``` + +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 diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index c607e7ba23..1e89bd2cc1 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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"], @@ -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 = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; const CPU_CORE_COUNT = cpus().length; @@ -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: { @@ -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: { @@ -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; @@ -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, "-"); @@ -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; diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index eb89fb4acc..e060745bbe 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 }), @@ -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 */ diff --git a/packages/cli/src/utils/mime.ts b/packages/cli/src/utils/mime.ts index e6234e06c0..d359df0f4e 100644 --- a/packages/cli/src/utils/mime.ts +++ b/packages/cli/src/utils/mime.ts @@ -10,6 +10,7 @@ export const MIME_TYPES: Record = { ".gif": "image/gif", ".webp": "image/webp", ".mp4": "video/mp4", + ".mov": "video/quicktime", ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav", diff --git a/packages/core/src/studio-api/helpers/mime.ts b/packages/core/src/studio-api/helpers/mime.ts index efdb4c02af..050b3b00ff 100644 --- a/packages/core/src/studio-api/helpers/mime.ts +++ b/packages/core/src/studio-api/helpers/mime.ts @@ -12,6 +12,7 @@ export const MIME_TYPES: Record = { ".webp": "image/webp", ".ico": "image/x-icon", ".mp4": "video/mp4", + ".mov": "video/quicktime", ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav", diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index 67731b5e20..f21fc433b3 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -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 = { 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) @@ -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, @@ -126,6 +128,18 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void }); }); + const RENDER_MIME: Record = { + ".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(); @@ -133,8 +147,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, { @@ -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, { @@ -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); } @@ -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: { @@ -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; diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 1c0cfd74e6..d186358c0f 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -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; diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 67e3fcec1e..9245306dca 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -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"); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 9285c38b3f..9cd9c43ccc 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -23,12 +23,13 @@ export const ENCODER_PRESETS = { /** * Get encoder preset for a given quality and output format. - * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264. + * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264; + * MOV uses ProRes 4444 with alpha for editor-compatible transparency. */ export function getEncoderPreset( quality: "draft" | "standard" | "high", - format: "mp4" | "webm" = "mp4", -): { preset: string; quality: number; codec: "h264" | "vp9"; pixelFormat: string } { + format: "mp4" | "webm" | "mov" = "mp4", +): { preset: string; quality: number; codec: "h264" | "vp9" | "prores"; pixelFormat: string } { const base = ENCODER_PRESETS[quality]; if (format === "webm") { return { @@ -38,6 +39,14 @@ export function getEncoderPreset( pixelFormat: "yuva420p", }; } + if (format === "mov") { + return { + preset: "4444", + quality: base.quality, + codec: "prores", + pixelFormat: "yuva444p10le", + }; + } return { ...base, pixelFormat: "yuv420p" }; } @@ -121,6 +130,7 @@ export function buildEncoderArgs( } } else if (codec === "prores") { args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0"); + args.push("-pix_fmt", pixelFormat); return [...args, "-y", outputPath]; } @@ -309,7 +319,11 @@ export async function encodeFramesChunkedConcat( } const startNumber = i * chunkSize; const framesInChunk = Math.min(chunkSize, files.length - startNumber); - const ext = outputPath.endsWith(".webm") ? ".webm" : ".mp4"; + const ext = outputPath.endsWith(".webm") + ? ".webm" + : outputPath.endsWith(".mov") + ? ".mov" + : ".mp4"; const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`); const inputPath = join(framesDir, framePattern); const inputArgs = [ @@ -415,10 +429,13 @@ export async function muxVideoWithAudio( if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); const isWebm = outputPath.endsWith(".webm"); + const isMov = outputPath.endsWith(".mov"); const args = ["-i", videoPath, "-i", audioPath, "-c:v", "copy"]; if (isWebm) { args.push("-c:a", "libopus", "-b:a", "128k"); + } else if (isMov) { + args.push("-c:a", "aac", "-b:a", "192k"); } else { args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"); } @@ -453,8 +470,9 @@ export async function applyFaststart( signal?: AbortSignal, config?: Partial>, ): Promise { - // faststart is MP4-only (moves moov atom to file start for streaming) - if (outputPath.endsWith(".webm")) { + // faststart is MP4-only (moves moov atom to file start for streaming). + // WebM and MOV don't need it — skip the re-mux. + if (outputPath.endsWith(".webm") || outputPath.endsWith(".mov")) { if (inputPath !== outputPath) copyFileSync(inputPath, outputPath); return { success: true, outputPath, durationMs: 0 }; } diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 387ba2f3be..c6fb738f8a 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -190,6 +190,7 @@ function buildStreamingArgs( } } else if (codec === "prores") { args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0"); + args.push("-pix_fmt", pixelFormat); return [...args, "-y", outputPath]; } diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 48e1ee2996..03f8e82618 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -66,7 +66,7 @@ interface RenderInput { outputPath?: string | null; fps: 24 | 30 | 60; quality: "draft" | "standard" | "high"; - format?: "mp4" | "webm"; + format?: "mp4" | "webm" | "mov"; workers?: number; useGpu: boolean; debug: boolean; @@ -98,10 +98,9 @@ function parseRenderOptions(body: Record): Omit = {}; const perfOutputPath = join(workDir, "perf-summary.json"); const cfg = { ...(job.config.producerConfig ?? resolveConfig()) }; - const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm"; + const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov"; const isWebm = outputFormat === "webm"; - // WebM/transparency requires screenshot mode — beginFrame doesn't support alpha channel - if (isWebm) { + const isMov = outputFormat === "mov"; + const needsAlpha = isWebm || isMov; + // Transparency requires screenshot mode — beginFrame doesn't support alpha channel + if (needsAlpha) { cfg.forceScreenshot = true; } const enableChunkedEncode = cfg.enableChunkedEncode; @@ -478,8 +480,8 @@ export async function executeRenderJob( width, height, fps: job.config.fps, - format: isWebm ? "png" : "jpeg", - quality: isWebm ? undefined : 80, + format: needsAlpha ? "png" : "jpeg", + quality: needsAlpha ? undefined : 80, }; probeSession = await createCaptureSession( fileServer.url, @@ -775,13 +777,14 @@ export async function executeRenderJob( width, height, fps: job.config.fps, - format: isWebm ? "png" : "jpeg", - quality: isWebm ? undefined : job.config.quality === "draft" ? 80 : 95, + format: needsAlpha ? "png" : "jpeg", + quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95, }; const workerCount = calculateOptimalWorkers(job.totalFrames!, job.config.workers, cfg); - const videoExt = isWebm ? ".webm" : ".mp4"; + const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; + const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4"; const videoOnlyPath = join(workDir, `video-only${videoExt}`); const preset = getEncoderPreset(job.config.quality, outputFormat); @@ -1015,7 +1018,7 @@ export async function executeRenderJob( const stage5Start = Date.now(); updateJobStatus(job, "encoding", "Encoding video", 75, onProgress); - const frameExt = isWebm ? "png" : "jpg"; + const frameExt = needsAlpha ? "png" : "jpg"; const framePattern = `frame_%06d.${frameExt}`; const encoderOpts = { fps: job.config.fps, @@ -1131,7 +1134,7 @@ export async function executeRenderJob( if (job.config.debug) { // Copy output MP4 into debug dir for easy access if (existsSync(outputPath)) { - const debugOutput = join(workDir, isWebm ? "output.webm" : "output.mp4"); + const debugOutput = join(workDir, `output${videoExt}`); copyFileSync(outputPath, debugOutput); } } else { diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 715edcfc9e..c269e112eb 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -7,7 +7,7 @@ interface RenderQueueProps { projectId: string; onDelete: (jobId: string) => void; onClearCompleted: () => void; - onStartRender: (format: "mp4" | "webm") => void; + onStartRender: (format: "mp4" | "webm" | "mov") => void; isRendering: boolean; } @@ -15,20 +15,21 @@ function FormatExportButton({ onStartRender, isRendering, }: { - onStartRender: (format: "mp4" | "webm") => void; + onStartRender: (format: "mp4" | "webm" | "mov") => void; isRendering: boolean; }) { - const [format, setFormat] = useState<"mp4" | "webm">("mp4"); + const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4"); return (