feat(render): add WebM output with VP9 alpha transparency

Support rendering compositions with transparent backgrounds via
`--format webm`. VP9+alpha is the standard format for overlayable
video (captions, lower thirds, overlays).

Changes by layer:
- CLI: `--format mp4|webm` flag on render command
- Producer: threads format through RenderConfig, switches to PNG
  capture and VP9 encoding when webm
- Engine: getEncoderPreset() returns VP9 config with yuva420p;
  transparent page background via CDP when capturing PNG;
  mux uses Opus audio for WebM; VP9 flags from production:
  -row-mt 1, -auto-alt-ref 0, alpha_mode=1 metadata
- Frame capture: Emulation.setDefaultBackgroundColorOverride a=0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-03-27 02:12:39 +00:00
co-authored by Claude Opus 4.6
parent ffd0fb678f
commit 15f6b86ac0
6 changed files with 119 additions and 48 deletions
+43 -18
View File
@@ -21,6 +21,26 @@ export const ENCODER_PRESETS = {
high: { preset: "slow", quality: 18, codec: "h264" as const },
};
/**
* Get encoder preset for a given quality and output format.
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264.
*/
export function getEncoderPreset(
quality: "draft" | "standard" | "high",
format: "mp4" | "webm" = "mp4",
): { preset: string; quality: number; codec: "h264" | "vp9"; pixelFormat: string } {
const base = ENCODER_PRESETS[quality];
if (format === "webm") {
return {
preset: base.preset === "ultrafast" ? "realtime" : "good",
quality: base.quality,
codec: "vp9",
pixelFormat: "yuva420p",
};
}
return { ...base, pixelFormat: "yuv420p" };
}
// Re-export GPU utilities so existing consumers that import from chunkEncoder still work.
export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
@@ -83,6 +103,11 @@ function buildEncoderArgs(
} else if (codec === "vp9") {
args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
args.push("-row-mt", "1");
if (pixelFormat === "yuva420p") {
args.push("-auto-alt-ref", "0");
args.push("-metadata:s:v:0", "alpha_mode=1");
}
} else if (codec === "prores") {
args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0");
return [...args, "-y", outputPath];
@@ -243,7 +268,8 @@ export async function encodeFramesChunkedConcat(
}
const startNumber = i * chunkSize;
const framesInChunk = Math.min(chunkSize, files.length - startNumber);
const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}.mp4`);
const ext = outputPath.endsWith(".webm") ? ".webm" : ".mp4";
const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`);
const inputPath = join(framesDir, framePattern);
const inputArgs = [
"-framerate",
@@ -347,23 +373,15 @@ export async function muxVideoWithAudio(
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const args = [
"-i",
videoPath,
"-i",
audioPath,
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"+faststart",
"-y",
outputPath,
];
const isWebm = outputPath.endsWith(".webm");
const args = ["-i", videoPath, "-i", audioPath, "-c:v", "copy"];
if (isWebm) {
args.push("-c:a", "libopus", "-b:a", "128k");
} else {
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
}
args.push("-shortest", "-y", outputPath);
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const result = await runFfmpeg(args, { signal, timeout: processTimeout });
@@ -394,6 +412,13 @@ 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")) {
// For WebM, just copy the file as-is
const { copyFileSync } = await import("fs");
if (inputPath !== outputPath) copyFileSync(inputPath, outputPath);
return { success: true, outputPath, durationMs: 0 };
}
const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart", "-y", outputPath];
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;