Files
frames/packages/cli/src/commands/init.ts
T
Miguel ÁngelandGitHub 70387d860e feat(cli): simplify init flow (#163)
## Summary

- **Streamline init from 7 steps to 3**: name → template → auto-opens studio preview
- **Remove** interactive "Got a video or audio file?" prompt (`--video`/`--audio` flags still work)
- **Remove** "What do you want to do?" post-scaffold menu (auto-launches preview)
- **Fast startup**: `--version` exits in ~10ms (lazy-load telemetry + update checker)
- **Brand teal**: CLI accent color is now `#3CE6AC` instead of generic cyan

### Before

```
name → overwrite? → media file? → [drag file] → transcribe? → pick template (9) → what next?
```

### After

```
name → pick template → auto-opens studio
```

## Test plan

- [x] `hyperframes init my-project` — should prompt name + template, then auto-open preview
- [x] `hyperframes --version` — should print version instantly (~10ms)
- [x] `hyperframes init --video path.mp4 my-project` — video flag still works
- [x] `hyperframes init --template blank my-project` — template flag still works
- [x] Verify teal accent color in terminal output
2026-03-31 21:16:18 +02:00

767 lines
25 KiB
TypeScript

import { defineCommand, runCommand } from "citty";
import {
existsSync,
mkdirSync,
copyFileSync,
cpSync,
writeFileSync,
readFileSync,
readdirSync,
} from "node:fs";
import { resolve, basename, join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync, spawn } from "node:child_process";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { printBanner } from "../ui/banner.js";
import { TEMPLATES, type TemplateId } from "../templates/generators.js";
import { trackInitTemplate } from "../telemetry/events.js";
import { hasFFmpeg } from "../whisper/manager.js";
// ---------------------------------------------------------------------------
// Install skills silently after scaffolding
// ---------------------------------------------------------------------------
async function installSkills(interactive: boolean): Promise<void> {
try {
const { installAllSkills, TARGETS } = await import("./install-skills.js");
let selectedTargets: string[] | undefined;
if (interactive) {
const choices = await clack.multiselect({
message: "Install skills for:",
options: TARGETS.map((t) => ({
value: t.flag,
label: t.name,
hint: t.dir,
})),
initialValues: TARGETS.filter((t) => t.defaultEnabled).map((t) => t.flag),
required: false,
});
if (clack.isCancel(choices)) {
return;
}
selectedTargets = choices as string[];
if (selectedTargets.length === 0) {
clack.log.info(c.dim("Skipping skills installation"));
return;
}
}
const spin = interactive ? clack.spinner() : null;
spin?.start("Installing AI coding skills...");
const result = await installAllSkills(selectedTargets);
if (result.count > 0) {
const msg = `${result.count} skills installed (${result.targets.join(", ")})`;
if (spin) {
spin.stop(c.success(msg));
} else {
console.log(c.success(msg));
}
if (result.skipped.length > 0) {
const skipMsg = `Skipped: ${result.skipped.join(", ")} (repo not accessible)`;
if (interactive) {
clack.log.warn(c.dim(skipMsg));
} else {
console.log(c.dim(` ${skipMsg}`));
}
}
} else {
spin?.stop(c.dim("No skills installed"));
}
} catch {
if (interactive) {
clack.log.warn(c.dim("Skills install skipped (no git or network)"));
}
}
}
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
interface VideoMeta {
durationSeconds: number;
width: number;
height: number;
fps: number;
hasAudio: boolean;
videoCodec: string;
}
const WEB_CODECS = new Set(["h264", "vp8", "vp9", "av1", "theora"]);
const DEFAULT_META: VideoMeta = {
durationSeconds: 5,
width: 1920,
height: 1080,
fps: 30,
hasAudio: false,
videoCodec: "h264",
};
// ---------------------------------------------------------------------------
// ffprobe helper — shells out to ffprobe to avoid engine dependency
// ---------------------------------------------------------------------------
function probeVideo(filePath: string): VideoMeta | undefined {
try {
const raw = execFileSync(
"ffprobe",
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
{ encoding: "utf-8", timeout: 15_000 },
);
const parsed: {
streams?: {
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
r_frame_rate?: string;
avg_frame_rate?: string;
}[];
format?: { duration?: string };
} = JSON.parse(raw);
const streams = parsed.streams ?? [];
const videoStream = streams.find((s) => s.codec_type === "video");
if (!videoStream) return undefined;
const hasAudio = streams.some((s) => s.codec_type === "audio");
let fps = 30;
const fpsStr = videoStream.avg_frame_rate ?? videoStream.r_frame_rate;
if (fpsStr) {
const parts = fpsStr.split("/");
const num = parseFloat(parts[0] ?? "");
const den = parseFloat(parts[1] ?? "1");
if (den !== 0 && !Number.isNaN(num) && !Number.isNaN(den)) {
fps = Math.round((num / den) * 100) / 100;
}
}
const durationStr = parsed.format?.duration;
const durationSeconds = durationStr !== undefined ? parseFloat(durationStr) : 5;
return {
durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds,
width: videoStream.width ?? 1920,
height: videoStream.height ?? 1080,
fps,
hasAudio,
videoCodec: videoStream.codec_name ?? "unknown",
};
} catch {
return undefined;
}
}
function isWebCompatible(codec: string): boolean {
return WEB_CODECS.has(codec.toLowerCase());
}
// hasFFmpeg is imported from whisper/manager.ts to avoid duplication
function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
return new Promise((resolvePromise) => {
const child = spawn(
"ffmpeg",
[
"-i",
inputPath,
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-c:a",
"aac",
"-b:a",
"192k",
"-y",
outputPath,
],
{ stdio: "pipe" },
);
child.on("close", (code) => resolvePromise(code === 0));
child.on("error", () => resolvePromise(false));
});
}
// ---------------------------------------------------------------------------
// Static template helpers
// ---------------------------------------------------------------------------
/** Resolve an asset directory that differs between dev (src/) and built (dist/). */
function resolveAssetDir(devSegments: string[], builtSegments: string[]): string {
const base = dirname(fileURLToPath(import.meta.url));
const devPath = resolve(base, ...devSegments);
const builtPath = resolve(base, ...builtSegments);
return existsSync(devPath) ? devPath : builtPath;
}
function getStaticTemplateDir(templateId: string): string {
return resolveAssetDir(["..", "templates", templateId], ["templates", templateId]);
}
function getSharedTemplateDir(): string {
return resolveAssetDir(["..", "templates", "_shared"], ["templates", "_shared"]);
}
function getBundledSkillsDir(): string {
// In dev: cli/src/commands/ → repo root skills/
// In built: cli/dist/ → cli/dist/skills/
return resolveAssetDir(["..", "..", "..", "..", "skills"], ["skills"]);
}
function patchVideoSrc(
dir: string,
videoFilename: string | undefined,
durationSeconds?: number,
): void {
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile() && e.name.endsWith(".html"))
.map((e) => join(e.parentPath ?? e.path, e.name));
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
if (videoFilename) {
content = content.replaceAll("__VIDEO_SRC__", videoFilename);
} else {
// Remove video elements with placeholder src
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, "");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
// Remove audio elements with placeholder src
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/audio>/g, "");
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
}
// Patch duration — use probed duration or default
const dur = durationSeconds ? String(Math.round(durationSeconds * 100) / 100) : "10";
content = content.replaceAll("__VIDEO_DURATION__", dur);
writeFileSync(file, content, "utf-8");
}
}
async function patchTranscript(dir: string, transcriptPath: string): Promise<void> {
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const { words } = loadTranscript(transcriptPath);
if (words.length === 0) return;
patchCaptionHtml(dir, words);
}
// ---------------------------------------------------------------------------
// handleVideoFile — probe, check codec, optionally transcode, copy to destDir
// ---------------------------------------------------------------------------
async function handleVideoFile(
videoPath: string,
destDir: string,
interactive: boolean,
): Promise<{ meta: VideoMeta; localVideoName: string }> {
const probed = probeVideo(videoPath);
let meta: VideoMeta = { ...DEFAULT_META };
let localVideoName = basename(videoPath);
if (probed) {
meta = probed;
if (interactive) {
clack.log.info(
`Video: ${meta.width}x${meta.height}, ${meta.durationSeconds.toFixed(1)}s, ${meta.fps}fps${meta.hasAudio ? ", has audio" : ""}`,
);
}
} else {
const msg =
"ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: brew install ffmpeg";
if (interactive) {
clack.log.warn(msg);
} else {
console.log(c.warn(msg));
}
}
// Check codec compatibility
if (probed && !isWebCompatible(probed.videoCodec)) {
if (interactive) {
clack.log.warn(
c.warn(`Video codec "${probed.videoCodec}" is not supported by web browsers.`),
);
} else {
console.log(c.warn(`Video codec "${probed.videoCodec}" is not supported by browsers.`));
}
if (hasFFmpeg()) {
let shouldTranscode = !interactive; // non-interactive auto-transcodes
if (interactive) {
const transcode = await clack.select({
message: "Transcode to H.264 MP4 for browser playback?",
options: [
{
value: "yes",
label: "Yes, transcode",
hint: "converts to H.264 MP4",
},
{
value: "no",
label: "No, keep original",
hint: "video won't play in browser",
},
],
});
if (clack.isCancel(transcode)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
shouldTranscode = transcode === "yes";
}
if (shouldTranscode) {
const mp4Name = localVideoName.replace(/\.[^.]+$/, ".mp4");
const mp4Path = resolve(destDir, mp4Name);
const spin = clack.spinner();
spin.start("Transcoding to H.264 MP4...");
const ok = await transcodeToMp4(videoPath, mp4Path);
if (ok) {
spin.stop(c.success(`Transcoded to ${mp4Name}`));
localVideoName = mp4Name;
} else {
spin.stop(c.warn("Transcode failed — copying original file"));
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
} else {
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
} else {
if (interactive) {
clack.log.warn(c.dim("ffmpeg not installed — cannot transcode."));
clack.log.info(c.accent("Install: brew install ffmpeg"));
} else {
console.log(c.warn("ffmpeg not installed — cannot transcode. Copying original."));
console.log(c.dim("Install: ") + c.accent("brew install ffmpeg"));
}
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
} else {
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
return { meta, localVideoName };
}
// ---------------------------------------------------------------------------
// scaffoldProject — copy template, patch video refs, write meta.json
// ---------------------------------------------------------------------------
function scaffoldProject(
destDir: string,
name: string,
templateId: TemplateId,
localVideoName: string | undefined,
durationSeconds?: number,
): void {
mkdirSync(destDir, { recursive: true });
const templateDir = getStaticTemplateDir(templateId);
cpSync(templateDir, destDir, { recursive: true });
patchVideoSrc(destDir, localVideoName, durationSeconds);
writeFileSync(
resolve(destDir, "meta.json"),
JSON.stringify(
{
id: name,
name,
createdAt: new Date().toISOString(),
},
null,
2,
),
"utf-8",
);
// Copy shared files (CLAUDE.md, AGENTS.md) for AI agent context
const sharedDir = getSharedTemplateDir();
if (existsSync(sharedDir)) {
for (const entry of readdirSync(sharedDir, { withFileTypes: true })) {
const src = join(sharedDir, entry.name);
const dest = resolve(destDir, entry.name);
if (entry.isFile() || entry.isSymbolicLink()) {
copyFileSync(src, dest);
}
}
}
// Copy project-level skills (.claude/skills/) for immediate availability
const skillsSrcDir = getBundledSkillsDir();
if (existsSync(skillsSrcDir)) {
const projectSkills = ["hyperframes-compose", "hyperframes-captions", "hyperframes-cli"];
for (const skill of projectSkills) {
const src = join(skillsSrcDir, skill);
if (existsSync(src)) {
const dest = resolve(destDir, ".claude", "skills", skill);
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
}
}
}
}
// ---------------------------------------------------------------------------
// Exported command
// ---------------------------------------------------------------------------
export default defineCommand({
meta: {
name: "init",
description: `Scaffold a new composition project
Examples:
hyperframes init my-video # interactive wizard
hyperframes init my-video --template warm-grain # pick a template
hyperframes init my-video --video video.mp4 # with video file
hyperframes init my-video --non-interactive # skip prompts (CI/agents)`,
},
args: {
name: { type: "positional", description: "Project name", required: false },
template: {
type: "string",
description: `Template (${ALL_TEMPLATE_IDS.join(", ")})`,
alias: "t",
},
video: {
type: "string",
description: "Path to a video file (MP4, WebM, MOV)",
alias: "V",
},
audio: {
type: "string",
description: "Path to an audio file (MP3, WAV, M4A)",
alias: "a",
},
"skip-skills": {
type: "boolean",
description: "Skip AI coding skills installation",
},
"skip-transcribe": {
type: "boolean",
description: "Skip whisper transcription",
},
model: {
type: "string",
description:
"Whisper model for transcription (e.g. tiny.en, base.en, small.en, medium.en, large)",
},
language: {
type: "string",
description:
"Language code for transcription (e.g. en, es, ja). Filters out non-target speech.",
},
"non-interactive": {
type: "boolean",
description: "Disable interactive prompts (for CI/agents)",
},
},
async run({ args }) {
const templateFlag = args.template;
const videoFlag = args.video;
const audioFlag = args.audio;
const skipSkills = args["skip-skills"] === true;
const skipTranscribe = args["skip-transcribe"] === true;
const nonInteractive = args["non-interactive"] === true;
const modelFlag = args.model;
const languageFlag = args.language;
const interactive = !nonInteractive && process.stdout.isTTY === true;
// -----------------------------------------------------------------------
// Non-interactive mode — all inputs from flags, defaults where missing
// -----------------------------------------------------------------------
if (!interactive) {
const resolvedTemplate = templateFlag ?? "blank";
if (!ALL_TEMPLATE_IDS.includes(resolvedTemplate as TemplateId)) {
console.error(c.error(`Unknown template: ${resolvedTemplate}`));
console.error(`Available: ${ALL_TEMPLATE_IDS.join(", ")}`);
process.exit(1);
}
const templateId = resolvedTemplate as TemplateId;
const name = args.name ?? "my-video";
const destDir = resolve(name);
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
console.error(c.error(`Directory already exists and is not empty: ${name}`));
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
let localVideoName: string | undefined;
let videoDuration: number | undefined;
let sourceFilePath: string | undefined;
if (videoFlag && audioFlag) {
console.error(c.error("Cannot use --video and --audio together"));
process.exit(1);
}
// Handle video
if (videoFlag) {
const videoPath = resolve(videoFlag);
if (!existsSync(videoPath)) {
console.error(c.error(`Video file not found: ${videoFlag}`));
process.exit(1);
}
sourceFilePath = videoPath;
const result = await handleVideoFile(videoPath, destDir, false);
localVideoName = result.localVideoName;
videoDuration = result.meta.durationSeconds;
console.log(
`Video: ${result.meta.width}x${result.meta.height}, ${result.meta.durationSeconds.toFixed(1)}s`,
);
}
// Handle audio
if (audioFlag) {
const audioPath = resolve(audioFlag);
if (!existsSync(audioPath)) {
console.error(c.error(`Audio file not found: ${audioFlag}`));
process.exit(1);
}
sourceFilePath = audioPath;
copyFileSync(audioPath, resolve(destDir, basename(audioPath)));
console.log(`Audio: ${basename(audioPath)}`);
}
// Transcribe
if (sourceFilePath && !skipTranscribe) {
try {
const { ensureWhisper, ensureModel } = await import("../whisper/manager.js");
await ensureWhisper();
await ensureModel(modelFlag);
console.log("Transcribing...");
const { transcribe: runTranscribe } = await import("../whisper/transcribe.js");
const result = await runTranscribe(sourceFilePath, destDir, {
model: modelFlag,
language: languageFlag,
});
console.log(
`Transcribed: ${result.wordCount} words (${result.durationSeconds.toFixed(1)}s)`,
);
if (!videoDuration) videoDuration = result.durationSeconds;
} catch (err) {
console.log(`Transcription skipped: ${err instanceof Error ? err.message : err}`);
}
}
// Scaffold
scaffoldProject(destDir, basename(destDir), templateId, localVideoName, videoDuration);
trackInitTemplate(templateId);
const transcriptFile = resolve(destDir, "transcript.json");
if (existsSync(transcriptFile)) {
await patchTranscript(destDir, transcriptFile);
}
// Skills
if (!skipSkills) {
await installSkills(false);
}
console.log(c.success(`Created ${c.accent(name + "/")}`));
for (const f of readdirSync(destDir).filter((f) => !f.startsWith("."))) {
console.log(` ${c.accent(f)}`);
}
console.log();
console.log("Get started:");
console.log();
console.log(` ${c.accent("1.")} Open this project with your AI coding agent:`);
console.log(
` ${c.accent(`cd ${name}`)} then start ${c.accent("Claude Code")}, ${c.accent("Cursor")}, or your preferred agent`,
);
console.log(
` ${c.dim("AI skills are installed — your agent knows how to create and edit compositions.")}`,
);
console.log();
console.log(` ${c.accent("2.")} Preview in the browser:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npx hyperframes preview")}`);
console.log();
console.log(` ${c.accent("3.")} Render to MP4 when ready:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npx hyperframes render")}`);
console.log();
console.log(` ${c.dim("Full docs: hyperframes.heygen.com")}`);
return;
}
// -----------------------------------------------------------------------
// Interactive mode
// -----------------------------------------------------------------------
printBanner();
clack.intro("Create a new HyperFrames project");
// 1. Project name
let name: string;
const hasPositionalName = args.name !== undefined && args.name !== "";
if (hasPositionalName) {
name = args.name ?? "my-video";
} else {
const nameResult = await clack.text({
message: "Project name",
placeholder: "my-video",
defaultValue: "my-video",
});
if (clack.isCancel(nameResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
name = nameResult;
}
const destDir = resolve(name);
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
const overwrite = await clack.confirm({
message: `Directory ${c.accent(name)} already exists and is not empty. Overwrite?`,
initialValue: false,
});
if (clack.isCancel(overwrite) || !overwrite) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
}
// 2. Video/audio file handling (only via --video/--audio flags, no interactive prompt)
let localVideoName: string | undefined;
let sourceFilePath: string | undefined;
let videoDuration: number | undefined;
if (videoFlag) {
const videoPath = resolve(videoFlag);
if (!existsSync(videoPath)) {
clack.log.error(`File not found: ${videoFlag}`);
clack.cancel("Setup cancelled.");
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
sourceFilePath = videoPath;
const result = await handleVideoFile(videoPath, destDir, true);
localVideoName = result.localVideoName;
videoDuration = result.meta.durationSeconds;
} else if (audioFlag) {
const audioPath = resolve(audioFlag);
if (!existsSync(audioPath)) {
clack.log.error(`File not found: ${audioFlag}`);
clack.cancel("Setup cancelled.");
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
sourceFilePath = audioPath;
copyFileSync(audioPath, resolve(destDir, basename(audioPath)));
clack.log.info(`Audio copied to ${c.accent(basename(audioPath))}`);
}
// 2b. Transcribe if we have a source file with audio (via flags)
if (sourceFilePath) {
const transcribeChoice = await clack.confirm({
message: "Generate captions from audio?",
initialValue: true,
});
if (!clack.isCancel(transcribeChoice) && transcribeChoice) {
const { findWhisper } = await import("../whisper/manager.js");
const needsInstall = findWhisper() === undefined;
if (needsInstall) {
clack.log.info(c.dim("whisper-cpp not found — installing automatically..."));
}
const spin = clack.spinner();
spin.start(
needsInstall
? "Installing whisper-cpp (this may take a moment)..."
: "Preparing transcription...",
);
try {
const { ensureWhisper, ensureModel } = await import("../whisper/manager.js");
await ensureWhisper({
onProgress: (msg) => spin.message(msg),
});
await ensureModel(modelFlag, {
onProgress: (msg) => spin.message(msg),
});
spin.message("Transcribing audio...");
const { transcribe: runTranscribe } = await import("../whisper/transcribe.js");
const transcribeResult = await runTranscribe(sourceFilePath, destDir, {
model: modelFlag,
language: languageFlag,
onProgress: (msg) => spin.message(msg),
});
spin.stop(
c.success(
`Transcribed ${transcribeResult.wordCount} words (${transcribeResult.durationSeconds.toFixed(1)}s)`,
),
);
} catch (err) {
spin.stop(c.dim(`Transcription skipped: ${err instanceof Error ? err.message : err}`));
}
}
}
// 3. Pick template — skip prompt if --template was provided
let templateId: TemplateId;
if (templateFlag && ALL_TEMPLATE_IDS.includes(templateFlag as TemplateId)) {
templateId = templateFlag as TemplateId;
} else {
if (templateFlag) {
clack.log.warn(`Unknown template "${templateFlag}" — pick from the list below`);
}
const templateResult = await clack.select({
message: "Pick a template",
options: TEMPLATES.map((t) => ({
value: t.id,
label: t.label,
hint: t.hint,
})),
initialValue: "blank" as TemplateId,
});
if (clack.isCancel(templateResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
templateId = templateResult;
}
// 4. Copy template and patch
scaffoldProject(destDir, name, templateId, localVideoName, videoDuration);
trackInitTemplate(templateId);
// 4b. Patch captions with transcript if available
const transcriptFile = resolve(destDir, "transcript.json");
if (existsSync(transcriptFile)) {
patchTranscript(destDir, transcriptFile);
}
// 5. Install AI coding skills
if (!skipSkills) {
await installSkills(true);
}
const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
clack.log.message(
`${c.dim("Tip:")} Open this project with ${c.accent("Claude Code")}, ${c.accent("Cursor")}, or your preferred AI agent.\n` +
`${c.dim(" AI skills are installed — your agent knows how to create and edit compositions.")}`,
);
// Auto-launch studio preview
clack.log.info("Opening studio preview...");
try {
const previewCmd = await import("./preview.js").then((m) => m.default);
await runCommand(previewCmd, { rawArgs: [destDir] });
} catch {
// Ctrl+C or error — that's fine
}
},
});