mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
## 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
767 lines
25 KiB
TypeScript
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
|
|
}
|
|
},
|
|
});
|