mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
feat: add MOV (ProRes 4444) as transparent video output format (#224)
## Summary - Adds `--format mov` to the render CLI for ProRes 4444 transparent video output - ProRes 4444 with alpha is the industry standard for transparent video overlays, supported by CapCut, Final Cut, Premiere, DaVinci, and After Effects - WebM VP9 alpha technically works but is ignored by all major video editors — only browsers decode it - Adds MOV to the studio export dropdown alongside MP4 and WebM ## Transparency format comparison | Format | Codec | Alpha | Video editors | Browsers | File size | | --- | --- | --- | --- | --- | --- | | **MOV** | ProRes 4444 | Yes | CapCut, Final Cut, Premiere, DaVinci, After Effects | No (won't play in browser) | Large (~5-40 MB) | | **WebM** | VP9 | Yes | None (shows black) | Chrome, Firefox | Small (~200 KB) | | **MP4** | H.264 | No | All | All | Small | > **Note:** ProRes MOV files do not play in Chromium browsers — they are an intermediate/editing format, not a delivery format. Use [rotato.app/tools/transparent-video](https://rotato.app/tools/transparent-video) to verify transparency works correctly. ## Changes - **CLI**: Add `mov` to `--format` validation, examples, and output path logic - **Engine**: `getEncoderPreset()` returns ProRes 4444 (`yuva444p10le`) for `mov` format; handle `.mov` in `applyFaststart` and `muxVideoWithAudio`; add `pix_fmt` to streaming encoder ProRes path - **Producer**: Treat `mov` like `webm` for alpha capture (PNG frames, screenshot mode, `forceScreenshot`) - **Studio**: Add MOV option to export format dropdown and render queue hook - **Core**: Add `mov` to studio API types, render route, and mime helpers - **Tests**: Add encoder preset tests for mov format (42 total, all passing) ## Usage ```bash hyperframes render --format mov --output overlay.mov ``` ## Test plan - [x] `pnpm build` passes - [x] `pnpm --filter @hyperframes/engine test` — 42 tests pass (2 new for MOV) - [x] `oxlint` and `oxfmt` clean on all 12 changed files - [x] End-to-end local render produces ProRes 4444 (`yuva444p12le`) with working alpha - [x] Docker render with `--format mov` — ProRes 4444 confirmed via ffprobe - [x] Studio dropdown shows MOV option in built JS - [x] Transparency verified with [rotato.app/tools/transparent-video](https://rotato.app/tools/transparent-video)
This commit is contained in:
@@ -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<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
||||
): Promise<MuxResult> {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user