Files
hyperframes/packages/cli/src/commands/render.ts
T
JamesandClaude Opus 4.6 15f6b86ac0 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>
2026-03-27 02:12:39 +00:00

282 lines
10 KiB
TypeScript

import { defineCommand } from "citty";
import { existsSync, mkdirSync, statSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import { resolveProject } from "../utils/project.js";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
import { renderProgress } from "../ui/progress.js";
import { trackRenderComplete, trackRenderError } from "../telemetry/events.js";
const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm"]);
export default defineCommand({
meta: { name: "render", description: "Render a composition to MP4 or WebM" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
output: { type: "string", description: "Output path (default: renders/<name>.mp4)" },
fps: { type: "string", description: "Frame rate: 24, 30, 60", default: "30" },
quality: { type: "string", description: "Quality: draft, standard, high", default: "standard" },
format: {
type: "string",
description: "Output format: mp4, webm (WebM renders with transparency)",
default: "mp4",
},
workers: { type: "string", description: "Parallel workers 1-8" },
docker: { type: "boolean", description: "Use Docker for deterministic render", default: false },
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
quiet: { type: "boolean", description: "Suppress verbose output", default: false },
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
const project = resolveProject(args.dir);
// ── Validate fps ───────────────────────────────────────────────────────
const fpsRaw = parseInt(args.fps ?? "30", 10);
if (!VALID_FPS.has(fpsRaw)) {
errorBox("Invalid fps", `Got "${args.fps ?? "30"}". Must be 24, 30, or 60.`);
process.exit(1);
}
const fps = fpsRaw as 24 | 30 | 60;
// ── Validate quality ───────────────────────────────────────────────────
const qualityRaw = args.quality ?? "standard";
if (!VALID_QUALITY.has(qualityRaw)) {
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
process.exit(1);
}
const quality = qualityRaw as "draft" | "standard" | "high";
// ── Validate format ─────────────────────────────────────────────────
const formatRaw = args.format ?? "mp4";
if (!VALID_FORMAT.has(formatRaw)) {
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4 or webm.`);
process.exit(1);
}
const format = formatRaw as "mp4" | "webm";
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null) {
const parsed = parseInt(args.workers, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 8) {
errorBox("Invalid workers", `Got "${args.workers}". Must be between 1 and 8.`);
process.exit(1);
}
workers = parsed;
}
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = format === "webm" ? ".webm" : ".mp4";
const outputPath = args.output
? resolve(args.output)
: join(rendersDir, `${project.name}${ext}`);
// Ensure output directory exists
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const quiet = args.quiet ?? false;
// ── Print render plan ─────────────────────────────────────────────────
const workerCount = workers ?? 4;
if (!quiet) {
console.log("");
console.log(
c.accent("\u25C6") +
" Rendering " +
c.accent(project.name) +
c.dim(" \u2192 " + outputPath),
);
console.log(
c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"),
);
console.log("");
}
// ── Check FFmpeg for local renders ───────────────────────────────────
if (!useDocker) {
const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js");
if (!findFFmpeg()) {
errorBox(
"FFmpeg not found",
"Rendering requires FFmpeg for video encoding.",
`Install: ${getFFmpegInstallHint()}`,
);
process.exit(1);
}
}
// ── Ensure browser for local renders ────────────────────────────────
let browserPath: string | undefined;
if (!useDocker) {
const { ensureBrowser } = await import("../browser/manager.js");
const clack = await import("@clack/prompts");
const s = clack.spinner();
s.start("Checking browser...");
try {
const info = await ensureBrowser({
onProgress: (downloaded, total) => {
if (total <= 0) return;
const pct = Math.floor((downloaded / total) * 100);
s.message(
`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
);
},
});
browserPath = info.executablePath;
s.stop(c.dim(`Browser: ${info.source}`));
} catch (err: unknown) {
s.stop(c.error("Browser not available"));
errorBox(
"Chrome not found",
err instanceof Error ? err.message : String(err),
"Run: npx hyperframes browser ensure",
);
process.exit(1);
}
}
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
fps,
quality,
format,
workers,
gpu: useGpu,
quiet,
});
} else {
await renderLocal(project.dir, outputPath, {
fps,
quality,
format,
workers,
gpu: useGpu,
quiet,
browserPath,
});
}
},
});
interface RenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm";
workers?: number;
gpu: boolean;
quiet: boolean;
browserPath?: string;
}
async function renderDocker(
projectDir: string,
outputPath: string,
options: RenderOptions,
): Promise<void> {
const producer = await loadProducer();
const startTime = Date.now();
try {
const job = producer.createRenderJob({
fps: options.fps,
quality: options.quality,
format: options.format,
workers: options.workers,
useGpu: options.gpu,
});
await producer.executeRenderJob(job, projectDir, outputPath);
} catch (error: unknown) {
trackRenderError({ fps: options.fps, quality: options.quality, docker: true });
const message = error instanceof Error ? error.message : String(error);
errorBox("Render failed", message, "Check Docker is running: docker info");
process.exit(1);
}
const elapsed = Date.now() - startTime;
trackRenderComplete({
durationMs: elapsed,
fps: options.fps,
quality: options.quality,
workers: options.workers ?? 4,
docker: true,
gpu: options.gpu,
});
printRenderComplete(outputPath, elapsed, options.quiet);
}
async function renderLocal(
projectDir: string,
outputPath: string,
options: RenderOptions,
): Promise<void> {
const producer = await loadProducer();
const startTime = Date.now();
// Pass the resolved browser path to the producer via env var so
// resolveConfig() picks it up. This bridges the CLI's ensureBrowser()
// (which knows about system Chrome on macOS) with the engine's
// acquireBrowser() (which only checks the puppeteer cache).
if (options.browserPath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = options.browserPath;
}
const job = producer.createRenderJob({
fps: options.fps,
quality: options.quality,
format: options.format,
workers: options.workers,
useGpu: options.gpu,
});
const onProgress = options.quiet
? undefined
: (progressJob: { progress: number }, message: string) => {
renderProgress(progressJob.progress, message);
};
try {
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
} catch (error: unknown) {
trackRenderError({ fps: options.fps, quality: options.quality, docker: false });
const message = error instanceof Error ? error.message : String(error);
errorBox("Render failed", message, "Try --docker for containerized rendering");
process.exit(1);
}
const elapsed = Date.now() - startTime;
trackRenderComplete({
durationMs: elapsed,
fps: options.fps,
quality: options.quality,
workers: options.workers ?? 4,
docker: false,
gpu: options.gpu,
});
printRenderComplete(outputPath, elapsed, options.quiet);
}
function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boolean): void {
if (quiet) return;
let fileSize = "unknown";
if (existsSync(outputPath)) {
const stat = statSync(outputPath);
fileSize = formatBytes(stat.size);
}
const duration = formatDuration(elapsedMs);
console.log("");
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + duration + " \u00B7 completed"));
}