Files
hyperframes/packages/cli/src/commands/render.ts
T
Miguel ÁngelandClaude Sonnet 4.6 9d2362990b fix(cli): inject version from package.json, fix docker hint, add --port to dev
- version.ts: replace hardcoded "0.1.0" with __CLI_VERSION__ injected by
  tsup at build time from package.json — fixes version mismatch where
  `hyperframes --version` reported 0.1.0 while package was 0.1.4
- tsup.config.ts: add define.__CLI_VERSION__ using package.json version
- render.ts: renderDocker error handler showed "Try --docker" even when
  already using --docker — changed to "Check Docker is running: docker info"
- dev.ts: add missing --port arg to embedded mode; findAvailablePort now
  starts from the user-supplied port instead of hardcoded 3002

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:45:36 +00:00

254 lines
9.5 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"]);
export default defineCommand({
meta: { name: "render", description: "Render a composition to MP4" },
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" },
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 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 outputPath = args.output ? resolve(args.output) : join(rendersDir, `${project.name}.mp4`);
// 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, workers, gpu: useGpu, quiet });
} else {
await renderLocal(project.dir, outputPath, {
fps,
quality,
workers,
gpu: useGpu,
quiet,
browserPath,
});
}
},
});
interface RenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
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,
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,
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"));
}