diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b8d357843 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# No environment variables required for basic usage. +# Run `pnpm dev` to start the studio, `npx hyperframes render` to render video. + +# Optional integrations: +# ANTHROPIC_API_KEY= # For AI-assisted composition via MCP diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6e7f67f1f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Golden baseline videos for regression tests +packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2b43df242..b1edaa9fa 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -30,7 +30,7 @@ Examples of unacceptable behavior: ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the project maintainers at **oss@heygen.com**. All complaints will +reported to the project maintainers via [GitHub Issues](https://github.com/heygen-com/hyperframes/issues). All complaints will be reviewed and investigated promptly and fairly. ## Attribution diff --git a/SECURITY.md b/SECURITY.md index 15ad63de6..5a3d44678 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ If you discover a security vulnerability in Hyperframes, please report it respon **Do not open a public GitHub issue for security vulnerabilities.** -Instead, email **security@heygen.com** with: +Instead, open a [GitHub Security Advisory](https://github.com/heygen-com/hyperframes/security/advisories/new) with: - A description of the vulnerability - Steps to reproduce diff --git a/package.json b/package.json new file mode 100644 index 000000000..c9d95fc0d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "hyperframes", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm studio", + "build": "pnpm -r build", + "build:producer": "pnpm --filter @hyperframes/producer build", + "studio": "pnpm --filter @hyperframes/studio dev", + "build:hyperframes-runtime": "pnpm --filter @hyperframes/core build:hyperframes-runtime", + "build:hyperframes-runtime:modular": "pnpm --filter @hyperframes/core build:hyperframes-runtime:modular" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "concurrently": "^8.2.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..373d2e38e --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,54 @@ +{ + "name": "hyperframes", + "version": "0.1.0", + "description": "HyperFrames CLI — create, preview, and render HTML video compositions", + "type": "module", + "bin": { + "hyperframes": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsx src/cli.ts", + "build": "pnpm build:studio && tsup && pnpm build:runtime && pnpm build:copy", + "build:studio": "cd ../studio && pnpm build", + "build:runtime": "tsx scripts/build-runtime.ts", + "build:copy": "mkdir -p dist/studio dist/docs dist/templates && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/warm-grain src/templates/play-mode src/templates/swiss-grid src/templates/vignelli dist/templates/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.8.0", + "@puppeteer/browsers": "^2.13.0", + "adm-zip": "^0.5.16", + "cheerio": "^1.2.0", + "citty": "^0.2.1", + "esbuild": "^0.25.0", + "hono": "^4.0.0", + "mime-types": "^3.0.2", + "open": "^10.0.0", + "puppeteer-core": "^24.39.1" + }, + "devDependencies": { + "@hyperframes/core": "workspace:*", + "@clack/prompts": "^1.1.0", + "@hono/node-server": "^1.0.0", + "@hyperframes/engine": "workspace:*", + "@hyperframes/producer": "workspace:*", + "@types/adm-zip": "^0.5.7", + "@types/mime-types": "^3.0.1", + "@types/node": "^22.0.0", + "adm-zip": "^0.5.16", + "cheerio": "^1.2.0", + "hono": "^4.0.0", + "linkedom": "^0.18.12", + "mime-types": "^3.0.2", + "picocolors": "^1.1.1", + "tsup": "^8.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/cli/scripts/build-runtime.ts b/packages/cli/scripts/build-runtime.ts new file mode 100644 index 000000000..465b96cd3 --- /dev/null +++ b/packages/cli/scripts/build-runtime.ts @@ -0,0 +1,4 @@ +import { loadHyperframeRuntimeSource } from "@hyperframes/core"; +import { writeFileSync } from "node:fs"; + +writeFileSync("dist/hyperframe-runtime.js", loadHyperframeRuntimeSource()); diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts new file mode 100644 index 000000000..b9e1d2894 --- /dev/null +++ b/packages/cli/src/browser/ffmpeg.ts @@ -0,0 +1,25 @@ +import { execSync } from "node:child_process"; + +export function findFFmpeg(): string | undefined { + try { + const result = execSync("which ffmpeg", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }).trim(); + return result || undefined; + } catch { + return undefined; + } +} + +export function getFFmpegInstallHint(): string { + switch (process.platform) { + case "darwin": + return "brew install ffmpeg"; + case "linux": + return "sudo apt install ffmpeg"; + default: + return "https://ffmpeg.org/download.html"; + } +} diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts new file mode 100644 index 000000000..9a0c7bee4 --- /dev/null +++ b/packages/cli/src/browser/manager.ts @@ -0,0 +1,161 @@ +import { execSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + Browser, + detectBrowserPlatform, + getInstalledBrowsers, + install, +} from "@puppeteer/browsers"; + +const CHROME_VERSION = "131.0.6778.85"; +const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome"); + +/** Override browser path via --browser-path flag. Takes priority over env var. */ +let _browserPathOverride: string | undefined; +export function setBrowserPath(path: string): void { + _browserPathOverride = path; +} + +export type BrowserSource = + | "env" + | "cache" + | "system" + | "download"; + +export interface BrowserResult { + executablePath: string; + source: BrowserSource; +} + +export interface EnsureBrowserOptions { + onProgress?: (downloadedBytes: number, totalBytes: number) => void; +} + +// --- Internal helpers ------------------------------------------------------- + +const SYSTEM_CHROME_PATHS: ReadonlyArray = + process.platform === "darwin" + ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] + : [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ]; + +function whichBinary(name: string): string | undefined { + try { + const result = execSync(`which ${name}`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }).trim(); + return result || undefined; + } catch { + return undefined; + } +} + +function findFromEnv(): BrowserResult | undefined { + // --browser-path flag takes priority + if (_browserPathOverride && existsSync(_browserPathOverride)) { + return { executablePath: _browserPathOverride, source: "env" }; + } + const envPath = process.env["HYPERFRAMES_BROWSER_PATH"]; + if (envPath && existsSync(envPath)) { + return { executablePath: envPath, source: "env" }; + } + return undefined; +} + +async function findFromCache(): Promise { + if (!existsSync(CACHE_DIR)) { + return undefined; + } + + const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); + const match = installed.find( + (b) => b.browser === Browser.CHROMEHEADLESSSHELL, + ); + if (match) { + return { executablePath: match.executablePath, source: "cache" }; + } + + return undefined; +} + +function findFromSystem(): BrowserResult | undefined { + for (const p of SYSTEM_CHROME_PATHS) { + if (existsSync(p)) { + return { executablePath: p, source: "system" }; + } + } + + const fromWhich = + whichBinary("google-chrome") ?? whichBinary("chromium"); + if (fromWhich) { + return { executablePath: fromWhich, source: "system" }; + } + + return undefined; +} + +// --- Public API ------------------------------------------------------------- + +/** + * Find an existing browser without downloading. + * Resolution: env var -> cached download -> system Chrome. + */ +export async function findBrowser(): Promise { + const fromEnv = findFromEnv(); + if (fromEnv) return fromEnv; + + const fromCache = await findFromCache(); + if (fromCache) return fromCache; + + return findFromSystem(); +} + +/** + * Find or download a browser. + * Resolution: env var -> cached download -> system Chrome -> auto-download. + */ +export async function ensureBrowser( + options?: EnsureBrowserOptions, +): Promise { + const existing = await findBrowser(); + if (existing) return existing; + + const platform = detectBrowserPlatform(); + if (!platform) { + throw new Error( + `Unsupported platform: ${process.platform} ${process.arch}`, + ); + } + + const installed = await install({ + cacheDir: CACHE_DIR, + browser: Browser.CHROMEHEADLESSSHELL, + buildId: CHROME_VERSION, + platform, + downloadProgressCallback: options?.onProgress, + }); + + return { executablePath: installed.executablePath, source: "download" }; +} + +/** + * Remove the cached Chrome download directory. + * Returns true if anything was removed. + */ +export function clearBrowser(): boolean { + if (!existsSync(CACHE_DIR)) { + return false; + } + rmSync(CACHE_DIR, { recursive: true, force: true }); + return true; +} + +export { CHROME_VERSION, CACHE_DIR }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 000000000..2c66c6f1a --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { defineCommand, runMain } from "citty"; +import { VERSION } from "./version.js"; + +const main = defineCommand({ + meta: { + name: "hyperframes", + version: VERSION, + description: "Create and render HTML video compositions", + }, + subCommands: { + init: () => import("./commands/init.js").then((m) => m.default), + dev: () => import("./commands/dev.js").then((m) => m.default), + render: () => import("./commands/render.js").then((m) => m.default), + lint: () => import("./commands/lint.js").then((m) => m.default), + info: () => import("./commands/info.js").then((m) => m.default), + compositions: () => import("./commands/compositions.js").then((m) => m.default), + benchmark: () => import("./commands/benchmark.js").then((m) => m.default), + browser: () => import("./commands/browser.js").then((m) => m.default), + docs: () => import("./commands/docs.js").then((m) => m.default), + doctor: () => import("./commands/doctor.js").then((m) => m.default), + upgrade: () => import("./commands/upgrade.js").then((m) => m.default), + }, +}); + +runMain(main); diff --git a/packages/cli/src/commands/benchmark.ts b/packages/cli/src/commands/benchmark.ts new file mode 100644 index 000000000..cb3c30c25 --- /dev/null +++ b/packages/cli/src/commands/benchmark.ts @@ -0,0 +1,226 @@ +import { defineCommand } from "citty"; +import { existsSync, statSync } from "node:fs"; +import { resolve, 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 * as clack from "@clack/prompts"; + +interface BenchmarkConfig { + label: string; + fps: 24 | 30 | 60; + quality: "draft" | "standard" | "high"; + workers: number; +} + +interface RunResult { + elapsedMs: number; + fileSize: number | null; +} + +interface ConfigResult { + config: BenchmarkConfig; + runs: RunResult[]; + failures: number; + avgTime: number | null; + avgSize: number | null; +} + +const DEFAULT_CONFIGS: BenchmarkConfig[] = [ + { label: "30fps \u00B7 draft \u00B7 2w", fps: 30, quality: "draft", workers: 2 }, + { label: "30fps \u00B7 standard \u00B7 2w", fps: 30, quality: "standard", workers: 2 }, + { label: "30fps \u00B7 high \u00B7 2w", fps: 30, quality: "high", workers: 2 }, + { label: "30fps \u00B7 standard \u00B7 4w", fps: 30, quality: "standard", workers: 4 }, + { label: "60fps \u00B7 standard \u00B7 4w", fps: 60, quality: "standard", workers: 4 }, +]; + +export default defineCommand({ + meta: { name: "benchmark", description: "Run multiple render configurations and compare results" }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + runs: { type: "string", description: "Number of runs per config", default: "3" }, + json: { type: "boolean", description: "Output results as JSON", default: false }, + }, + async run({ args }) { + // ── Resolve project ────────────────────────────────────────────────── + const project = resolveProject(args.dir); + + // ── Parse runs ─────────────────────────────────────────────────────── + const runsPerConfig = parseInt(args.runs ?? "3", 10); + if (isNaN(runsPerConfig) || runsPerConfig < 1 || runsPerConfig > 20) { + errorBox("Invalid runs", `Got "${args.runs ?? "3"}". Must be between 1 and 20.`); + process.exit(1); + } + + const jsonOutput = args.json ?? false; + + // ── Temp output for benchmark renders ──────────────────────────────── + const benchDir = resolve("renders", ".benchmark"); + + // ── Load producer ──────────────────────────────────────────────────── + let producer: Awaited> | null = null; + try { + producer = await loadProducer(); + } catch { + if (jsonOutput) { + console.log(JSON.stringify({ error: "Producer module not available. Is the project built?" })); + } else { + errorBox( + "Producer module not available", + "The rendering pipeline could not be loaded.", + "Ensure @hyperframes/producer is built and linked.", + ); + } + process.exit(1); + } + + // ── Print header ───────────────────────────────────────────────────── + if (!jsonOutput) { + console.log(""); + console.log( + c.accent("\u25C6") + + " Benchmarking " + + c.accent(project.name) + + c.dim(` (${runsPerConfig} runs each)`), + ); + console.log(""); + } + + // ── Run benchmarks ─────────────────────────────────────────────────── + const results: ConfigResult[] = []; + + for (const config of DEFAULT_CONFIGS) { + const runs: RunResult[] = []; + let failures = 0; + + const s = !jsonOutput ? clack.spinner() : undefined; + s?.start(`Benchmarking ${config.label}...`); + + for (let i = 0; i < runsPerConfig; i++) { + s?.message(`${config.label} — run ${i + 1}/${runsPerConfig}`); + const outputPath = join(benchDir, `${config.label.replace(/[^a-zA-Z0-9]/g, "_")}_run${i}.mp4`); + + try { + const startTime = Date.now(); + await producer.renderComposition(project.dir, { + output: outputPath, + fps: config.fps, + quality: config.quality, + workers: config.workers, + quiet: true, + }); + const elapsedMs = Date.now() - startTime; + + let fileSize: number | null = null; + if (existsSync(outputPath)) { + const stat = statSync(outputPath); + fileSize = stat.size; + } + + runs.push({ elapsedMs, fileSize }); + } catch { + failures++; + } + } + + s?.stop(`${config.label} — ${runs.length} runs${failures > 0 ? `, ${failures} failed` : ""}`); + + const successfulRuns = runs.filter((r) => r.elapsedMs > 0); + const avgTime = + successfulRuns.length > 0 + ? successfulRuns.reduce((sum, r) => sum + r.elapsedMs, 0) / successfulRuns.length + : null; + const sizesWithValues = runs.map((r) => r.fileSize).filter((s): s is number => s != null); + const avgSize = + sizesWithValues.length > 0 + ? sizesWithValues.reduce((sum, s) => sum + s, 0) / sizesWithValues.length + : null; + + results.push({ config, runs, failures, avgTime, avgSize }); + } + + // ── Output results ─────────────────────────────────────────────────── + if (jsonOutput) { + console.log( + JSON.stringify( + results.map((r) => ({ + config: r.config.label, + fps: r.config.fps, + quality: r.config.quality, + workers: r.config.workers, + avgTimeMs: r.avgTime, + avgSizeBytes: r.avgSize, + failures: r.failures, + runs: r.runs, + })), + null, + 2, + ), + ); + return; + } + + // ── Table output ───────────────────────────────────────────────────── + const configColWidth = 26; + const timeColWidth = 10; + const sizeColWidth = 10; + + const header = + " " + + c.bold("Config".padEnd(configColWidth)) + + c.bold("Time".padEnd(timeColWidth)) + + c.bold("Size".padEnd(sizeColWidth)); + const separator = " " + c.dim("\u2500".repeat(configColWidth + timeColWidth + sizeColWidth)); + + console.log(header); + console.log(separator); + + for (const result of results) { + const timeStr = + result.avgTime != null ? formatDuration(result.avgTime) : c.dim("failed"); + const sizeStr = + result.avgSize != null ? formatBytes(result.avgSize) : c.dim("n/a"); + const failStr = + result.failures > 0 ? c.warn(` (${result.failures} failed)`) : ""; + + console.log( + " " + + result.config.label.padEnd(configColWidth) + + timeStr.padEnd(timeColWidth) + + sizeStr.padEnd(sizeColWidth) + + failStr, + ); + } + + // ── Summary ────────────────────────────────────────────────────────── + const successfulResults = results.filter((r) => r.avgTime != null); + if (successfulResults.length > 0) { + let fastest = successfulResults[0]; + for (const r of successfulResults) { + if (fastest == null || r.avgTime == null) continue; + if (fastest.avgTime == null || r.avgTime < fastest.avgTime) { + fastest = r; + } + } + + if (fastest?.avgTime != null) { + console.log(""); + console.log( + c.success("\u25C7") + + " Fastest: " + + c.accent(fastest.config.label) + + c.dim(` (${formatDuration(fastest.avgTime)})`), + ); + } + } else { + console.log(""); + console.log( + c.error("\u2717") + + " All configurations failed. Ensure the rendering pipeline is set up.", + ); + } + + console.log(""); + }, +}); diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts new file mode 100644 index 000000000..24d075793 --- /dev/null +++ b/packages/cli/src/commands/browser.ts @@ -0,0 +1,133 @@ +import { defineCommand } from "citty"; +import * as clack from "@clack/prompts"; +import { c } from "../ui/colors.js"; +import { formatBytes } from "../ui/format.js"; +import { + ensureBrowser, + findBrowser, + clearBrowser, + CHROME_VERSION, + CACHE_DIR, +} from "../browser/manager.js"; + +async function runEnsure(): Promise { + clack.intro(c.bold("hyperframes browser ensure")); + + const s = clack.spinner(); + s.start("Looking for an existing browser..."); + + const existing = await findBrowser(); + if (existing) { + s.stop(c.success("Browser found")); + console.log(); + console.log(` ${c.dim("Source:")} ${c.bold(existing.source)}`); + console.log(` ${c.dim("Path:")} ${c.bold(existing.executablePath)}`); + console.log(); + clack.outro(c.success("Ready to render.")); + return; + } + + s.stop("No browser found — downloading"); + + const downloadSpinner = clack.spinner(); + downloadSpinner.start( + `Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`, + ); + + let lastPct = -1; + const result = await ensureBrowser({ + onProgress: (downloaded, total) => { + if (total <= 0) return; + const pct = Math.floor((downloaded / total) * 100); + if (pct > lastPct) { + lastPct = pct; + downloadSpinner.message( + `Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)} — ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`, + ); + } + }, + }); + + downloadSpinner.stop(c.success("Download complete")); + + console.log(); + console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`); + console.log(` ${c.dim("Path:")} ${c.bold(result.executablePath)}`); + console.log(); + + clack.outro(c.success("Ready to render.")); +} + +async function runPath(): Promise { + const result = await findBrowser(); + if (!result) { + // Try a full ensure (which includes download) but write only the path + try { + const ensured = await ensureBrowser(); + process.stdout.write(ensured.executablePath + "\n"); + } catch (err: unknown) { + console.error( + err instanceof Error ? err.message : "Failed to find browser", + ); + process.exit(1); + } + return; + } + process.stdout.write(result.executablePath + "\n"); +} + +function runClear(): void { + clack.intro(c.bold("hyperframes browser clear")); + + const removed = clearBrowser(); + if (removed) { + clack.outro( + c.success("Removed cached browser from ") + c.dim(CACHE_DIR), + ); + } else { + clack.outro(c.dim("No cached browser to remove.")); + } +} + +export default defineCommand({ + meta: { name: "browser", description: "Manage the Chrome browser used for rendering" }, + args: { + subcommand: { type: "positional", description: "Subcommand: ensure, path, clear", required: false }, + }, + async run({ args }) { + const subcommand = args.subcommand; + + if (!subcommand || subcommand === "") { + console.log(` +${c.bold("hyperframes browser")} ${c.dim("")} + +Manage the Chrome browser used for rendering. + +${c.bold("SUBCOMMANDS:")} + ${c.accent("ensure")} ${c.dim("Find or download Chrome for rendering")} + ${c.accent("path")} ${c.dim("Print browser executable path (for scripting)")} + ${c.accent("clear")} ${c.dim("Remove cached Chrome download")} + +${c.bold("EXAMPLES:")} + ${c.accent("npx hyperframes browser ensure")} ${c.dim("Download Chrome if needed")} + ${c.accent("npx hyperframes browser path")} ${c.dim("Print path for scripts")} + ${c.accent("npx hyperframes browser clear")} ${c.dim("Remove cached browser")} +`); + return; + } + + switch (subcommand) { + case "ensure": + return runEnsure(); + case "path": + return runPath(); + case "clear": + return runClear(); + default: + console.error( + `${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes browser --help")} for usage.`, + ); + process.exit(1); + } + }, +}); diff --git a/packages/cli/src/commands/compositions.ts b/packages/cli/src/commands/compositions.ts new file mode 100644 index 000000000..35b7e1b15 --- /dev/null +++ b/packages/cli/src/commands/compositions.ts @@ -0,0 +1,107 @@ +import { defineCommand } from "citty"; +import { readFileSync } from "node:fs"; +import { c } from "../ui/colors.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; + +interface CompositionInfo { + id: string; + duration: number; + width: number; + height: number; + elementCount: number; +} + +function parseCompositions(html: string): CompositionInfo[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + const compositionDivs = doc.querySelectorAll("[data-composition-id]"); + const compositions: CompositionInfo[] = []; + + compositionDivs.forEach((div) => { + const id = div.getAttribute("data-composition-id") ?? "unknown"; + const width = parseInt(div.getAttribute("data-width") ?? "1920", 10); + const height = parseInt(div.getAttribute("data-height") ?? "1080", 10); + + const timedChildren = div.querySelectorAll("[data-start]"); + let maxEnd = 0; + let elementCount = 0; + + timedChildren.forEach((el) => { + elementCount++; + const start = parseFloat(el.getAttribute("data-start") ?? "0"); + const endAttr = el.getAttribute("data-end"); + const durationAttr = el.getAttribute("data-duration"); + + let end: number; + if (endAttr) { + end = parseFloat(endAttr); + } else if (durationAttr) { + end = start + parseFloat(durationAttr); + } else { + end = start + 5; + } + + if (end > maxEnd) { + maxEnd = end; + } + }); + + compositions.push({ + id, + duration: maxEnd, + width, + height, + elementCount, + }); + }); + + return compositions; +} + +export default defineCommand({ + meta: { name: "compositions", description: "List all compositions in a project" }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output as JSON", default: false }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + const html = readFileSync(project.indexPath, "utf-8"); + + ensureDOMParser(); + const compositions = parseCompositions(html); + + if (compositions.length === 0) { + console.log(`${c.success("◇")} ${c.accent(project.name)} — no compositions found`); + return; + } + + if (args.json) { + console.log(JSON.stringify(compositions, null, 2)); + return; + } + + const compositionLabel = + compositions.length === 1 ? "1 composition" : `${compositions.length} compositions`; + console.log( + `${c.success("◇")} ${c.accent(project.name)} ${c.dim("—")} ${c.dim(compositionLabel)}`, + ); + console.log(); + + // Calculate padding for alignment + const maxIdLen = compositions.reduce((max, comp) => Math.max(max, comp.id.length), 0); + + for (const comp of compositions) { + const id = c.accent(comp.id.padEnd(maxIdLen)); + const duration = c.bold(`${comp.duration.toFixed(1)}s`); + const resolution = c.dim(`${comp.width}×${comp.height}`); + const elements = c.dim( + `${comp.elementCount} ${comp.elementCount === 1 ? "element" : "elements"}`, + ); + + console.log(` ${id} ${duration} ${resolution} ${elements}`); + } + }, +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts new file mode 100644 index 000000000..499cc7133 --- /dev/null +++ b/packages/cli/src/commands/dev.ts @@ -0,0 +1,186 @@ +import { defineCommand } from "citty"; +import { spawn } from "node:child_process"; +import { + existsSync, + lstatSync, + symlinkSync, + unlinkSync, + readlinkSync, + mkdirSync, +} from "node:fs"; +import { resolve, dirname, basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as clack from "@clack/prompts"; +import { c } from "../ui/colors.js"; + +/** + * Check if a port is available by trying to listen on it briefly. + */ +function isPortAvailable(port: number): Promise { + return new Promise((resolvePromise) => { + const { createServer } = require("node:net") as typeof import("node:net"); + const server = createServer(); + server.once("error", () => resolvePromise(false)); + server.once("listening", () => { + server.close(() => resolvePromise(true)); + }); + server.listen(port); + }); +} + +/** + * Find an available port starting from the given port. + */ +async function findAvailablePort(startPort: number): Promise { + for (let port = startPort; port < startPort + 10; port++) { + if (await isPortAvailable(port)) return port; + } + return startPort; // fallback — let the server fail with a clear error +} + +/** + * Detect whether we're running from source (monorepo dev) or from the built bundle. + * When running via tsx from source, the file is at cli/src/commands/dev.ts. + * When running from the built bundle, the file is at cli/dist/cli.js. + * We check the filename portion of the URL to avoid false positives from + * directory names (e.g., /Users/someone/src/...). + */ +function isDevMode(): boolean { + const url = new URL(import.meta.url); + // In dev mode the file is a .ts source file; in production it's a bundled .js + return url.pathname.endsWith(".ts"); +} + +export default defineCommand({ + meta: { name: "dev", description: "Start the studio for local development" }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + }, + async run({ args }) { + const dir = resolve(args.dir ?? "."); + + if (isDevMode()) { + return runDevMode(dir); + } + const port = await findAvailablePort(3002); + return runEmbeddedMode(dir, port); + }, +}); + +/** + * Dev mode: spawn pnpm studio from the monorepo (existing behavior). + */ +async function runDevMode(dir: string): Promise { + // Find monorepo root by navigating from packages/cli/src/commands/ + const thisFile = fileURLToPath(import.meta.url); + const repoRoot = resolve(dirname(thisFile), "..", "..", "..", ".."); + + // Symlink project into the studio's data directory + const projectsDir = join(repoRoot, "packages", "studio", "data", "projects"); + const projectName = basename(dir); + const symlinkPath = join(projectsDir, projectName); + + mkdirSync(projectsDir, { recursive: true }); + + let createdSymlink = false; + if (dir !== symlinkPath) { + if (existsSync(symlinkPath)) { + try { + const stat = lstatSync(symlinkPath); + if (stat.isSymbolicLink()) { + const target = readlinkSync(symlinkPath); + if (resolve(target) !== resolve(dir)) { + unlinkSync(symlinkPath); + } + } + // If it's a real directory, leave it alone + } catch { + // Not a symlink — don't touch it + } + } + + if (!existsSync(symlinkPath)) { + symlinkSync(dir, symlinkPath, "dir"); + createdSymlink = true; + } + } + + clack.intro(c.bold("hyperframes dev")); + + const s = clack.spinner(); + s.start("Starting studio..."); + + // Run the new consolidated studio (single Vite dev server with API plugin) + const studioPkgDir = join(repoRoot, "packages", "studio"); + const child = spawn("pnpm", ["exec", "vite"], { + cwd: studioPkgDir, + stdio: ["ignore", "pipe", "pipe"], + }); + + let frontendUrl = ""; + + function handleOutput(data: Buffer): void { + const text = data.toString(); + + // Detect Vite URL + const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/); + if (localMatch && !frontendUrl) { + frontendUrl = localMatch[1] ?? ""; + s.stop(c.success("Studio running")); + console.log(); + console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); + console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`); + console.log(); + console.log(` ${c.dim("Press Ctrl+C to stop")}`); + console.log(); + + const urlToOpen = `${frontendUrl}#/project/${projectName}`; + import("open").then((mod) => mod.default(urlToOpen)).catch(() => {}); + + child.stdout?.removeListener("data", handleOutput); + child.stderr?.removeListener("data", handleOutput); + } + } + + child.stdout?.on("data", handleOutput); + child.stderr?.on("data", handleOutput); + + // If child exits before we detect readiness, show what we have + child.on("error", (err) => { + s.stop(c.error("Failed to start studio")); + console.error(c.dim(err.message)); + }); + + function cleanup(): void { + if (createdSymlink && existsSync(symlinkPath)) { + try { + unlinkSync(symlinkPath); + } catch { + /* ignore */ + } + } + } + + return new Promise((resolvePromise) => { + // Temporarily ignore SIGINT on the parent so Ctrl+C only kills the child. + // The child gets SIGINT from the terminal's process group signal. + // When the child exits, we clean up and resolve back to the caller. + const noop = (): void => {}; + process.on("SIGINT", noop); + + child.on("close", () => { + process.removeListener("SIGINT", noop); + cleanup(); + resolvePromise(); + }); + }); +} + +/** + * Embedded mode — not yet available. + * TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI. + */ +async function runEmbeddedMode(_dir: string, _port: number): Promise { + console.error(c.error("Embedded mode not yet available. Run from the monorepo root with: hyperframes dev ")); + process.exit(1); +} diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts new file mode 100644 index 000000000..3c11fe9bc --- /dev/null +++ b/packages/cli/src/commands/docs.ts @@ -0,0 +1,129 @@ +import { defineCommand } from "citty"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { c } from "../ui/colors.js"; + +interface TopicEntry { + file: string; + description: string; +} + +const TOPICS: Record = { + "data-attributes": { + file: "data-attributes.md", + description: "Timing, media, and composition attributes", + }, + templates: { + file: "templates.md", + description: "Built-in project templates for init", + }, + rendering: { + file: "rendering.md", + description: "Render compositions to MP4 (local & Docker)", + }, + gsap: { + file: "gsap.md", + description: "GSAP animation setup and usage", + }, + troubleshooting: { + file: "troubleshooting.md", + description: "Common issues and fixes", + }, + compositions: { + file: "compositions.md", + description: "Composition structure, nesting, and variables", + }, +}; + +function docsDir(): string { + const thisFile = fileURLToPath(import.meta.url); + const dir = dirname(thisFile); + // In dev: cli/src/commands/ → ../docs = cli/src/docs/ + // In built: cli/dist/ → docs = cli/dist/docs/ + const devPath = resolve(dir, "..", "docs"); + const builtPath = resolve(dir, "docs"); + return existsSync(devPath) ? devPath : builtPath; +} + +function formatInlineCode(line: string): string { + // Replace inline backtick spans with accented text + return line.replace(/`([^`]+)`/g, (_match, code: string) => c.accent(code)); +} + +function renderMarkdown(content: string): void { + const lines = content.split("\n"); + + for (const line of lines) { + // Skip code fences + if (line.trim().startsWith("```")) { + continue; + } + + // H1 heading + if (line.startsWith("# ")) { + console.log(c.bold(line.slice(2))); + continue; + } + + // H2 subheading + if (line.startsWith("## ")) { + console.log(c.bold(c.dim(line.slice(3)))); + continue; + } + + // List items + if (line.startsWith("- ")) { + const rest = formatInlineCode(line.slice(2)); + console.log(`${c.dim(" \u2022")} ${rest}`); + continue; + } + + // Everything else + console.log(formatInlineCode(line)); + } +} + +export default defineCommand({ + meta: { name: "docs", description: "View inline documentation in the terminal" }, + args: { + topic: { type: "positional", description: "Topic to view", required: false }, + }, + async run({ args }) { + const topic = args.topic; + + // No topic: list available topics + if (topic === undefined || topic === "") { + console.log(c.bold("Available topics:")); + console.log(); + for (const [name, entry] of Object.entries(TOPICS)) { + console.log(` ${c.accent(name.padEnd(20))} ${c.dim(entry.description)}`); + } + console.log(); + console.log(c.dim(`Run ${c.accent("hyperframes docs ")} to view a topic.`)); + return; + } + + // Look up the topic + const entry = TOPICS[topic]; + if (entry === undefined) { + console.error(c.error(`Unknown topic: ${topic}`)); + console.error(); + console.error("Available topics:"); + for (const name of Object.keys(TOPICS)) { + console.error(` ${c.accent(name)}`); + } + process.exit(1); + } + + const filePath = join(docsDir(), entry.file); + if (!existsSync(filePath)) { + console.error(c.error(`Doc file not found: ${filePath}`)); + process.exit(1); + } + + const content = readFileSync(filePath, "utf-8"); + console.log(); + renderMarkdown(content); + }, +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 000000000..45ec046a3 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,131 @@ +import { defineCommand } from "citty"; +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { c } from "../ui/colors.js"; +import { findBrowser } from "../browser/manager.js"; +import { findFFmpeg } from "../browser/ffmpeg.js"; + +interface Check { + name: string; + run: () => CheckResult | Promise; +} + +interface CheckResult { + ok: boolean; + detail: string; + hint?: string; +} + +function checkFFmpeg(): CheckResult { + const path = findFFmpeg(); + if (path) { + try { + const version = execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }) + .split("\n")[0] ?? ""; + return { ok: true, detail: version.trim() }; + } catch { + return { ok: true, detail: path }; + } + } + return { + ok: false, + detail: "Not found", + hint: process.platform === "darwin" ? "brew install ffmpeg" : "sudo apt install ffmpeg", + }; +} + +function checkFFprobe(): CheckResult { + try { + const result = execSync("which ffprobe", { encoding: "utf-8", timeout: 5000 }).trim(); + return { ok: true, detail: result }; + } catch { + return { + ok: false, + detail: "Not found", + hint: "Installed with ffmpeg", + }; + } +} + +async function checkChrome(): Promise { + const info = await findBrowser(); + if (info) { + return { ok: true, detail: `${info.source}: ${info.executablePath}` }; + } + return { + ok: false, + detail: "Not found", + hint: "Run: npx hyperframes browser ensure", + }; +} + +function checkDocker(): CheckResult { + try { + const version = execSync("docker --version", { encoding: "utf-8", timeout: 5000 }).trim(); + return { ok: true, detail: version }; + } catch { + return { + ok: false, + detail: "Not found", + hint: "https://docs.docker.com/get-docker/", + }; + } +} + +function checkDockerRunning(): CheckResult { + try { + execSync("docker info", { stdio: "pipe", timeout: 5000 }); + return { ok: true, detail: "Running" }; + } catch { + return { + ok: false, + detail: "Not running", + hint: "Start Docker Desktop or run: sudo systemctl start docker", + }; + } +} + +function checkNode(): CheckResult { + return { ok: true, detail: `${process.version} (${process.platform} ${process.arch})` }; +} + + +export default defineCommand({ + meta: { name: "doctor", description: "Check system dependencies and environment" }, + args: {}, + async run() { + console.log(); + console.log(c.bold("hyperframes doctor")); + console.log(); + + const checks: Check[] = [ + { name: "Node.js", run: checkNode }, + { name: "FFmpeg", run: checkFFmpeg }, + { name: "FFprobe", run: checkFFprobe }, + { name: "Chrome", run: checkChrome }, + { name: "Docker", run: checkDocker }, + { name: "Docker running", run: checkDockerRunning }, + ]; + + let allOk = true; + + for (const check of checks) { + const result = await check.run(); + const icon = result.ok ? c.success("\u2713") : c.error("\u2717"); + const name = check.name.padEnd(16); + console.log(` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`); + if (!result.ok && result.hint) { + console.log(` ${" ".repeat(19)}${c.accent(result.hint)}`); + } + if (!result.ok) allOk = false; + } + + console.log(); + if (allOk) { + console.log(` ${c.success("\u25C7")} ${c.success("All checks passed")}`); + } else { + console.log(` ${c.warn("\u25C7")} ${c.warn("Some checks failed — see hints above")}`); + } + console.log(); + }, +}); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 000000000..61d561b53 --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -0,0 +1,75 @@ +import { defineCommand } from "citty"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { parseHtml } from "@hyperframes/core"; +import { c } from "../ui/colors.js"; +import { formatBytes, label } from "../ui/format.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; + +function totalSize(dir: string): number { + let total = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + total += totalSize(path); + } else { + total += statSync(path).size; + } + } + return total; +} + +export default defineCommand({ + meta: { name: "info", description: "Print project metadata" }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output as JSON", default: false }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + const html = readFileSync(project.indexPath, "utf-8"); + + ensureDOMParser(); + const parsed = parseHtml(html); + + const tracks = new Set(parsed.elements.map((el) => el.zIndex)); + const maxEnd = parsed.elements.reduce( + (max, el) => Math.max(max, el.startTime + el.duration), + 0, + ); + const resolution = + parsed.resolution === "portrait" ? "1080x1920" : "1920x1080"; + const size = totalSize(project.dir); + + const typeCounts: Record = {}; + for (const el of parsed.elements) { + typeCounts[el.type] = (typeCounts[el.type] ?? 0) + 1; + } + const typeStr = Object.entries(typeCounts) + .map(([t, count]) => `${count} ${t}`) + .join(", "); + + if (args.json) { + console.log(JSON.stringify({ + name: project.name, + resolution: parsed.resolution, + width: parsed.resolution === "portrait" ? 1080 : 1920, + height: parsed.resolution === "portrait" ? 1920 : 1080, + duration: maxEnd, + elements: parsed.elements.length, + tracks: tracks.size, + types: typeCounts, + size, + }, null, 2)); + return; + } + + console.log(`${c.success("◇")} ${c.accent(project.name)}`); + console.log(label("Resolution", resolution)); + console.log(label("Duration", `${maxEnd.toFixed(1)}s`)); + console.log(label("Elements", `${parsed.elements.length}${typeStr ? ` (${typeStr})` : ""}`)); + console.log(label("Tracks", `${tracks.size}`)); + console.log(label("Size", formatBytes(size))); + }, +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 000000000..1537f4b2d --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,492 @@ +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 { execSync, execFileSync, spawn } from "node:child_process"; +import * as clack from "@clack/prompts"; +import { c } from "../ui/colors.js"; +import { + TEMPLATES, + type TemplateId, +} from "../templates/generators.js"; + +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()); +} + +function hasFFmpeg(): boolean { + try { + execSync("ffmpeg -version", { stdio: "ignore", timeout: 5000 }); + return true; + } catch { + return false; + } +} + +function transcodeToMp4(inputPath: string, outputPath: string): Promise { + 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 +// --------------------------------------------------------------------------- + +function getStaticTemplateDir(templateId: string): string { + const dir = dirname(fileURLToPath(import.meta.url)); + // In dev: cli/src/commands/ → ../templates = cli/src/templates/ + // In built: cli/dist/ → templates = cli/dist/templates/ + const devPath = resolve(dir, "..", "templates", templateId); + const builtPath = resolve(dir, "templates", templateId); + return existsSync(devPath) ? devPath : builtPath; +} + +function patchVideoSrc(dir: string, videoFilename: string | undefined): 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(/]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, ""); + content = content.replace(/]*src="__VIDEO_SRC__"[^>]*>/g, ""); + } + writeFileSync(file, content, "utf-8"); + } +} + +// --------------------------------------------------------------------------- +// 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, +): void { + mkdirSync(destDir, { recursive: true }); + + const templateDir = getStaticTemplateDir(templateId); + cpSync(templateDir, destDir, { recursive: true }); + patchVideoSrc(destDir, localVideoName); + + writeFileSync( + resolve(destDir, "meta.json"), + JSON.stringify( + { + id: name, + name, + createdAt: new Date().toISOString(), + }, + null, + 2, + ), + "utf-8", + ); +} + +// --------------------------------------------------------------------------- +// nextStepLoop — "What do you want to do?" loop after scaffolding +// --------------------------------------------------------------------------- + +async function nextStepLoop(destDir: string): Promise { + while (true) { + const next = await clack.select({ + message: "What do you want to do?", + options: [ + { value: "dev", label: "Open in studio", hint: "full editor with timeline" }, + { value: "render", label: "Render to MP4", hint: "export video now" }, + { value: "done", label: "Done for now" }, + ], + }); + + if (clack.isCancel(next) || next === "done") { + clack.outro(c.success("Happy editing!")); + return; + } + + // Hand off to the selected command — use explicit imports so the + // bundler can resolve them (dynamic import with a variable fails in bundles) + try { + if (next === "dev") { + const devCmd = await import("./dev.js").then((m) => m.default); + await runCommand(devCmd, { rawArgs: [destDir] }); + } else if (next === "render") { + const renderCmd = await import("./render.js").then((m) => m.default); + await runCommand(renderCmd, { rawArgs: [destDir] }); + } + } catch { + // Command may throw on Ctrl+C — that's fine, loop back + } + + // Wait a tick so any lingering SIGINT state clears before Clack prompts again + await new Promise((r) => setTimeout(r, 100)); + console.log(); + } +} + +// --------------------------------------------------------------------------- +// Exported command +// --------------------------------------------------------------------------- + +export default defineCommand({ + meta: { name: "init", description: "Scaffold a new composition project" }, + 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 source video file", alias: "V" }, + }, + async run({ args }) { + const templateFlag = args.template; + const videoFlag = args.video; + + // ----------------------------------------------------------------------- + // Non-interactive mode: flags provided + // ----------------------------------------------------------------------- + if (templateFlag) { + if (!ALL_TEMPLATE_IDS.includes(templateFlag as TemplateId)) { + console.error(c.error(`Unknown template: ${templateFlag}`)); + console.error(`Available: ${ALL_TEMPLATE_IDS.join(", ")}`); + process.exit(1); + } + const templateId = templateFlag 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; + + if (videoFlag) { + const videoPath = resolve(videoFlag); + if (!existsSync(videoPath)) { + console.error(c.error(`Video file not found: ${videoFlag}`)); + process.exit(1); + } + const result = await handleVideoFile(videoPath, destDir, false); + localVideoName = result.localVideoName; + } + + scaffoldProject(destDir, basename(destDir), templateId, localVideoName); + + console.log(c.success(`\nCreated ${c.accent(name + "/")}`)); + for (const f of readdirSync(destDir)) { + console.log(` ${c.accent(f)}`); + } + return; + } + + // ----------------------------------------------------------------------- + // Interactive mode + // ----------------------------------------------------------------------- + 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. Got a video? + let localVideoName: string | undefined; + + if (videoFlag) { + // Video supplied via --video flag even in interactive mode + const videoPath = resolve(videoFlag); + if (!existsSync(videoPath)) { + clack.log.error(`Video file not found: ${videoFlag}`); + clack.cancel("Setup cancelled."); + process.exit(1); + } + mkdirSync(destDir, { recursive: true }); + const result = await handleVideoFile(videoPath, destDir, true); + localVideoName = result.localVideoName; + } else { + const videoChoice = await clack.select({ + message: "Got a video file?", + options: [ + { value: "yes", label: "Yes", hint: "MP4 or WebM recommended" }, + { + value: "no", + label: "No", + hint: "Start with motion graphics or text", + }, + ], + initialValue: "no" as "yes" | "no", + }); + if (clack.isCancel(videoChoice)) { + clack.cancel("Setup cancelled."); + process.exit(0); + } + + if (videoChoice === "yes") { + const pathResult = await clack.text({ + message: "Path to your video file (drag and drop or paste)", + placeholder: "/path/to/video.mp4", + validate(val) { + const trimmed = val?.trim(); + if (!trimmed) return "Please enter a file path"; + if (!existsSync(resolve(trimmed))) return "File not found"; + return undefined; + }, + }); + if (clack.isCancel(pathResult)) { + clack.cancel("Setup cancelled."); + process.exit(0); + } + + const videoPath = resolve(String(pathResult).trim()); + + mkdirSync(destDir, { recursive: true }); + const result = await handleVideoFile(videoPath, destDir, true); + localVideoName = result.localVideoName; + } + } + + // 3. Pick template — single list for all templates + const templateResult = await clack.select({ + message: "Pick a template", + options: TEMPLATES.map((t) => ({ + value: t.id, + label: t.label, + hint: t.hint, + })), + initialValue: TEMPLATES[0]?.id, + }); + if (clack.isCancel(templateResult)) { + clack.cancel("Setup cancelled."); + process.exit(0); + } + + const templateId: TemplateId = templateResult; + + // 4. Copy template and patch + scaffoldProject(destDir, name, templateId, localVideoName); + + const files = readdirSync(destDir); + clack.note( + files.map((f) => c.accent(f)).join("\n"), + c.success(`Created ${name}/`), + ); + + await nextStepLoop(destDir); + }, +}); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts new file mode 100644 index 000000000..daaa3e222 --- /dev/null +++ b/packages/cli/src/commands/lint.ts @@ -0,0 +1,44 @@ +import { defineCommand } from "citty"; +import { readFileSync } from "node:fs"; +import { lintHyperframeHtml } from "@hyperframes/core/lint"; +import { c } from "../ui/colors.js"; +import { resolveProject } from "../utils/project.js"; + +export default defineCommand({ + meta: { name: "lint", description: "Validate a composition for common mistakes" }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output findings as JSON", default: false }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + const html = readFileSync(project.indexPath, "utf-8"); + const result = lintHyperframeHtml(html, { filePath: project.indexPath }); + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(result.ok ? 0 : 1); + } + + console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/index.html")}`); + console.log(); + + if (result.ok) { + console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); + return; + } + + for (const finding of result.findings) { + const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); + const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; + console.log(`${prefix} ${c.bold(finding.code)}${loc}: ${finding.message}`); + if (finding.fixHint) { + console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); + } + } + + const summaryIcon = result.errorCount > 0 ? c.error("◇") : c.success("◇"); + console.log(`\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`); + process.exit(result.errorCount > 0 ? 1 : 0); + }, +}); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts new file mode 100644 index 000000000..ee7ab4144 --- /dev/null +++ b/packages/cli/src/commands/render.ts @@ -0,0 +1,210 @@ +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"; + +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/.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 ──────────────────────────────── + 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) + ")")}`); + }, + }); + 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 }); + } + }, +}); + +interface RenderOptions { + fps: 24 | 30 | 60; + quality: "draft" | "standard" | "high"; + workers?: number; + gpu: boolean; + quiet: boolean; +} + +async function renderDocker( + projectDir: string, + outputPath: string, + options: RenderOptions, +): Promise { + const producer = await loadProducer(); + const startTime = Date.now(); + + try { + await producer.renderComposition(projectDir, { + output: outputPath, + fps: options.fps, + quality: options.quality, + workers: options.workers ?? null, + gpu: options.gpu, + quiet: options.quiet, + }); + } catch (error: unknown) { + 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; + printRenderComplete(outputPath, elapsed, options.quiet); +} + +async function renderLocal( + projectDir: string, + outputPath: string, + options: RenderOptions, +): Promise { + const producer = await loadProducer(); + const startTime = Date.now(); + + 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) { + 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; + 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")); +} diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts new file mode 100644 index 000000000..50989001a --- /dev/null +++ b/packages/cli/src/commands/upgrade.ts @@ -0,0 +1,61 @@ +import { defineCommand } from "citty"; +import * as clack from "@clack/prompts"; +import { c } from "../ui/colors.js"; +import { VERSION } from "../version.js"; + +export default defineCommand({ + meta: { name: "upgrade", description: "Check for updates and show upgrade instructions" }, + args: {}, + async run() { + clack.intro(c.bold("hyperframes upgrade")); + + const s = clack.spinner(); + s.start("Checking for updates..."); + + let latest: string; + try { + const res = await fetch("https://registry.npmjs.org/hyperframes/latest"); + if (!res.ok) { + s.stop("Could not check for updates"); + clack.outro(c.dim("Package not yet published to npm.")); + return; + } + const data = (await res.json()) as { version?: string }; + latest = data.version ?? VERSION; + } catch { + s.stop("Could not check for updates"); + clack.outro(c.dim("Network error. Check your connection.")); + return; + } + + if (latest === VERSION) { + s.stop(c.success("Already up to date")); + clack.outro(`${c.success("◇")} ${c.bold("v" + VERSION)}`); + return; + } + + s.stop("Update available"); + + console.log(); + console.log(` ${c.dim("Current:")} ${c.bold("v" + VERSION)}`); + console.log(` ${c.dim("Latest:")} ${c.bold(c.accent("v" + latest))}`); + console.log(); + + const shouldUpgrade = await clack.confirm({ + message: "Upgrade now?", + }); + + if (clack.isCancel(shouldUpgrade) || !shouldUpgrade) { + clack.outro(c.dim("Skipped.")); + return; + } + + console.log(); + console.log(` ${c.accent("npm install -g hyperframes@" + latest)}`); + console.log(` ${c.dim("or")}`); + console.log(` ${c.accent("npx hyperframes@" + latest + " --version")}`); + console.log(); + + clack.outro(c.success("Run one of the commands above to upgrade.")); + }, +}); diff --git a/packages/cli/src/docs/compositions.md b/packages/cli/src/docs/compositions.md new file mode 100644 index 000000000..98a103f53 --- /dev/null +++ b/packages/cli/src/docs/compositions.md @@ -0,0 +1,26 @@ +# Compositions + +A composition is an HTML document that defines a video timeline. + +## Structure +Every composition needs a root element with `data-composition-id`: +```html +
+ +
+``` + +## Nested Compositions +Embed one composition inside another: +```html +
+``` + +## Listing Compositions +Use `npx hyperframes compositions` to see all compositions in a project. + +## Variables +Compositions can expose variables for dynamic content: +```html +
+``` diff --git a/packages/cli/src/docs/data-attributes.md b/packages/cli/src/docs/data-attributes.md new file mode 100644 index 000000000..9eaf5d8ce --- /dev/null +++ b/packages/cli/src/docs/data-attributes.md @@ -0,0 +1,22 @@ +# Data Attributes + +Core attributes for controlling element timing and behavior. + +## Timing +- `data-start="0"` — Start time in seconds +- `data-duration="5"` — Duration in seconds +- `data-track-index="0"` — Timeline track number (controls z-ordering) + +## Media +- `data-media-start="2"` — Media playback offset / trim point (seconds) +- `data-volume="0.8"` — Audio/video volume, 0 to 1 +- `data-has-audio="true"` — Indicates video has an audio track + +## Composition +- `data-composition-id="root"` — Unique ID for composition wrapper (required) +- `data-width="1920"` — Composition width in pixels +- `data-height="1080"` — Composition height in pixels +- `data-composition-src="./intro.html"` — Nested composition source + +## Element Visibility +Add `class="clip"` to timed elements so the runtime can manage their visibility lifecycle. diff --git a/packages/cli/src/docs/gsap.md b/packages/cli/src/docs/gsap.md new file mode 100644 index 000000000..4058cb4b5 --- /dev/null +++ b/packages/cli/src/docs/gsap.md @@ -0,0 +1,23 @@ +# GSAP Animation + +HyperFrames uses GSAP for animation. Timelines are paused and controlled by the runtime. + +## Setup +```html + + +``` + +## Key Rules +- Always create timelines with `{ paused: true }` +- Register timelines on `window.__timelines` with the composition ID as key +- Position parameter (3rd arg) sets absolute time: `tl.to(el, vars, 1.5)` +- Supported methods: `set`, `to`, `from`, `fromTo` + +## Supported Properties +opacity, x, y, scale, scaleX, scaleY, rotation, width, height, visibility diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md new file mode 100644 index 000000000..1ae283aa4 --- /dev/null +++ b/packages/cli/src/docs/rendering.md @@ -0,0 +1,23 @@ +# Rendering + +Render compositions to MP4 with `npx hyperframes render`. + +## Local Mode (default) +Uses Puppeteer (bundled Chromium) + system FFmpeg. Fast for iteration. +Requires: FFmpeg installed (`brew install ffmpeg` or `apt install ffmpeg`). + +## Docker Mode (--docker) +Deterministic output with exact Chrome version and fonts. For production. +Requires: Docker installed and running. + +## Options +- `-f, --fps` — 24, 30, or 60 (default: 30) +- `-q, --quality` — draft, standard, high (default: standard) +- `-w, --workers` — Parallel workers 1-8 (default: auto) +- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI) +- `-o, --output` — Custom output path + +## Tips +- Use `draft` quality for fast previews during development +- Use `npx hyperframes benchmark` to find optimal settings +- 4 workers is usually the sweet spot for most compositions diff --git a/packages/cli/src/docs/templates.md b/packages/cli/src/docs/templates.md new file mode 100644 index 000000000..9a562b6db --- /dev/null +++ b/packages/cli/src/docs/templates.md @@ -0,0 +1,15 @@ +# Templates + +Built-in templates available via `npx hyperframes init --template `. + +## blank +Empty 1920x1080 composition with GSAP timeline wired up. Start from scratch. + +## title-card +Animated title and subtitle with GSAP fade-in/out. Good for intro cards. + +## video-edit +Video element with trimming, audio, and track controls. Starting point for video editing. + +## Custom Templates +Any directory with an `index.html` can serve as a template. Copy it manually or build your own init workflow. diff --git a/packages/cli/src/docs/troubleshooting.md b/packages/cli/src/docs/troubleshooting.md new file mode 100644 index 000000000..8fac30862 --- /dev/null +++ b/packages/cli/src/docs/troubleshooting.md @@ -0,0 +1,22 @@ +# Troubleshooting + +## "No composition found" +Your directory needs an `index.html`. Run `npx hyperframes init` to create one. + +## "FFmpeg not found" +Local rendering requires FFmpeg. Install it: +- macOS: `brew install ffmpeg` +- Ubuntu: `sudo apt install ffmpeg` +- Windows: Download from https://ffmpeg.org/download.html + +## Lint errors +Run `npx hyperframes lint` to check for common issues: +- Missing `data-composition-id` on root element +- Missing `class="clip"` on timed elements +- Overlapping timelines or invalid data attributes + +## Preview not updating +Make sure you're editing the `index.html` in the project directory. The preview server watches for file changes and auto-reloads. + +## Render looks different from preview +Use `--docker` mode for deterministic output. Local renders may differ due to font availability and Chrome version. diff --git a/packages/cli/src/templates/generators.ts b/packages/cli/src/templates/generators.ts new file mode 100644 index 000000000..8d2657477 --- /dev/null +++ b/packages/cli/src/templates/generators.ts @@ -0,0 +1,14 @@ +export type TemplateId = "warm-grain" | "play-mode" | "swiss-grid" | "vignelli"; + +export interface TemplateOption { + id: TemplateId; + label: string; + hint: string; +} + +export const TEMPLATES: TemplateOption[] = [ + { id: "warm-grain", label: "Warm Grain", hint: "Cream aesthetic with grain texture" }, + { id: "play-mode", label: "Play Mode", hint: "Playful elastic animations" }, + { id: "swiss-grid", label: "Swiss Grid", hint: "Structured grid layout" }, + { id: "vignelli", label: "Vignelli", hint: "Bold typography with red accents" }, +]; diff --git a/packages/cli/src/templates/play-mode/compositions/captions.html b/packages/cli/src/templates/play-mode/compositions/captions.html new file mode 100644 index 000000000..14f2a8fdd --- /dev/null +++ b/packages/cli/src/templates/play-mode/compositions/captions.html @@ -0,0 +1,97 @@ + diff --git a/packages/cli/src/templates/play-mode/compositions/intro.html b/packages/cli/src/templates/play-mode/compositions/intro.html new file mode 100644 index 000000000..5ea591cab --- /dev/null +++ b/packages/cli/src/templates/play-mode/compositions/intro.html @@ -0,0 +1,88 @@ + diff --git a/packages/cli/src/templates/play-mode/compositions/stats.html b/packages/cli/src/templates/play-mode/compositions/stats.html new file mode 100644 index 000000000..d96b6acd3 --- /dev/null +++ b/packages/cli/src/templates/play-mode/compositions/stats.html @@ -0,0 +1,252 @@ + diff --git a/packages/cli/src/templates/play-mode/index.html b/packages/cli/src/templates/play-mode/index.html new file mode 100644 index 000000000..1e50e10d0 --- /dev/null +++ b/packages/cli/src/templates/play-mode/index.html @@ -0,0 +1,173 @@ + + + + + + Hyperframes - Play Mode + + + + + +
+ +
+
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+
+ + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/packages/cli/src/templates/swiss-grid/assets/swiss-grid.svg b/packages/cli/src/templates/swiss-grid/assets/swiss-grid.svg new file mode 100644 index 000000000..a3fb54f75 --- /dev/null +++ b/packages/cli/src/templates/swiss-grid/assets/swiss-grid.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/cli/src/templates/swiss-grid/compositions/captions.html b/packages/cli/src/templates/swiss-grid/compositions/captions.html new file mode 100644 index 000000000..f2594daf2 --- /dev/null +++ b/packages/cli/src/templates/swiss-grid/compositions/captions.html @@ -0,0 +1,95 @@ + diff --git a/packages/cli/src/templates/swiss-grid/compositions/graphics.html b/packages/cli/src/templates/swiss-grid/compositions/graphics.html new file mode 100644 index 000000000..64134a57a --- /dev/null +++ b/packages/cli/src/templates/swiss-grid/compositions/graphics.html @@ -0,0 +1,198 @@ + \ No newline at end of file diff --git a/packages/cli/src/templates/swiss-grid/compositions/intro.html b/packages/cli/src/templates/swiss-grid/compositions/intro.html new file mode 100644 index 000000000..dd89ed690 --- /dev/null +++ b/packages/cli/src/templates/swiss-grid/compositions/intro.html @@ -0,0 +1,114 @@ + diff --git a/packages/cli/src/templates/swiss-grid/index.html b/packages/cli/src/templates/swiss-grid/index.html new file mode 100644 index 000000000..10a0e3bd8 --- /dev/null +++ b/packages/cli/src/templates/swiss-grid/index.html @@ -0,0 +1,172 @@ + + + + + + Swiss Grid - Hyperframes + + + + +
+ + + Grid + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + +
+ + diff --git a/packages/cli/src/templates/vignelli/compositions/captions.html b/packages/cli/src/templates/vignelli/compositions/captions.html new file mode 100644 index 000000000..6b3f62f31 --- /dev/null +++ b/packages/cli/src/templates/vignelli/compositions/captions.html @@ -0,0 +1,122 @@ + diff --git a/packages/cli/src/templates/vignelli/compositions/overlays.html b/packages/cli/src/templates/vignelli/compositions/overlays.html new file mode 100644 index 000000000..e2a13c7b1 --- /dev/null +++ b/packages/cli/src/templates/vignelli/compositions/overlays.html @@ -0,0 +1,271 @@ + diff --git a/packages/cli/src/templates/vignelli/index.html b/packages/cli/src/templates/vignelli/index.html new file mode 100644 index 000000000..f37079ff2 --- /dev/null +++ b/packages/cli/src/templates/vignelli/index.html @@ -0,0 +1,171 @@ + + + + + Massimo Vignelli Video + + + + +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ + +
+ + diff --git a/packages/cli/src/templates/warm-grain/compositions/captions.html b/packages/cli/src/templates/warm-grain/compositions/captions.html new file mode 100644 index 000000000..522e8d053 --- /dev/null +++ b/packages/cli/src/templates/warm-grain/compositions/captions.html @@ -0,0 +1,133 @@ + diff --git a/packages/cli/src/templates/warm-grain/compositions/graphics.html b/packages/cli/src/templates/warm-grain/compositions/graphics.html new file mode 100644 index 000000000..e684da13d --- /dev/null +++ b/packages/cli/src/templates/warm-grain/compositions/graphics.html @@ -0,0 +1,157 @@ + \ No newline at end of file diff --git a/packages/cli/src/templates/warm-grain/compositions/intro.html b/packages/cli/src/templates/warm-grain/compositions/intro.html new file mode 100644 index 000000000..dd1f549e8 --- /dev/null +++ b/packages/cli/src/templates/warm-grain/compositions/intro.html @@ -0,0 +1,77 @@ + diff --git a/packages/cli/src/templates/warm-grain/index.html b/packages/cli/src/templates/warm-grain/index.html new file mode 100644 index 000000000..93e29020d --- /dev/null +++ b/packages/cli/src/templates/warm-grain/index.html @@ -0,0 +1,195 @@ + + + + + + Warm Grain - Hyperframes + + + + +
+ + +
+
+ +
+ + + + + +
+
+ +
+
+ +
+
+ + + + + + + +
+ + diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts new file mode 100644 index 000000000..5af3996c9 --- /dev/null +++ b/packages/cli/src/ui/colors.ts @@ -0,0 +1,23 @@ +import pc from "picocolors"; + +const isColorSupported = + process.stdout.isTTY === true && + !process.env["NO_COLOR"] && + process.env["FORCE_COLOR"] !== "0"; + +function wrap(fn: (s: string) => string): (s: string) => string { + return isColorSupported ? fn : (s: string) => s; +} + +export const c = { + success: wrap(pc.green), + error: wrap(pc.red), + warn: wrap(pc.yellow), + dim: wrap(pc.dim), + bold: wrap(pc.bold), + accent: wrap(pc.cyan), + progress: wrap(pc.magenta), + reset: isColorSupported ? pc.reset : (s: string) => s, +}; + +export { isColorSupported }; diff --git a/packages/cli/src/ui/format.ts b/packages/cli/src/ui/format.ts new file mode 100644 index 000000000..7fe028439 --- /dev/null +++ b/packages/cli/src/ui/format.ts @@ -0,0 +1,27 @@ +import { c } from "./colors.js"; + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatDuration(ms: number): string { + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds - minutes * 60; + return `${minutes}m ${remaining.toFixed(1)}s`; +} + +export function label(name: string, value: string): string { + const pad = 14 - name.length; + return ` ${c.dim(name)}${" ".repeat(Math.max(1, pad))}${c.bold(value)}`; +} + +export function errorBox(title: string, hint?: string, suggestion?: string): void { + console.error(`\n${c.error("\u2717")} ${c.bold(title)}`); + if (hint) console.error(`\n ${c.dim(hint)}`); + if (suggestion) console.error(` ${c.accent(suggestion)}`); + console.error(); +} diff --git a/packages/cli/src/ui/progress.ts b/packages/cli/src/ui/progress.ts new file mode 100644 index 000000000..d50fec345 --- /dev/null +++ b/packages/cli/src/ui/progress.ts @@ -0,0 +1,20 @@ +import { c } from "./colors.js"; + +const { stdout } = process; + +export function renderProgress(percent: number, stage: string, row?: number): void { + const width = 25; + const filled = Math.floor(percent / (100 / width)); + const empty = width - filled; + const bar = + c.progress("\u2588".repeat(filled)) + + c.dim("\u2591".repeat(empty)); + + const line = ` ${bar} ${c.bold(String(Math.round(percent)) + "%")} ${c.dim(stage)}`; + + if (row !== undefined && stdout.isTTY) { + stdout.write(`\x1b[${row};1H\x1b[2K${line}`); + } else { + stdout.write(`\r\x1b[2K${line}`); + } +} diff --git a/packages/cli/src/utils/dom.ts b/packages/cli/src/utils/dom.ts new file mode 100644 index 000000000..e4d178f06 --- /dev/null +++ b/packages/cli/src/utils/dom.ts @@ -0,0 +1,11 @@ +import { DOMParser } from "linkedom"; + +/** + * Polyfill DOMParser on globalThis so @hyperframes/core's parseHtml works in Node.js. + * Safe to call multiple times — only sets the global once. + */ +export function ensureDOMParser(): void { + if (typeof globalThis.DOMParser === "undefined") { + (globalThis as Record).DOMParser = DOMParser; + } +} diff --git a/packages/cli/src/utils/mime.ts b/packages/cli/src/utils/mime.ts new file mode 100644 index 000000000..e6234e06c --- /dev/null +++ b/packages/cli/src/utils/mime.ts @@ -0,0 +1,21 @@ +export const MIME_TYPES: Record = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + ".ico": "image/x-icon", +}; diff --git a/packages/cli/src/utils/producer.ts b/packages/cli/src/utils/producer.ts new file mode 100644 index 000000000..cf5fa15ea --- /dev/null +++ b/packages/cli/src/utils/producer.ts @@ -0,0 +1,7 @@ +/** + * Dynamically load the producer module. tsup inlines @hyperframes/producer + * via noExternal so this resolves in the published bundle. + */ +export async function loadProducer() { + return await import("@hyperframes/producer"); +} diff --git a/packages/cli/src/utils/project.ts b/packages/cli/src/utils/project.ts new file mode 100644 index 000000000..cf701a8e1 --- /dev/null +++ b/packages/cli/src/utils/project.ts @@ -0,0 +1,30 @@ +import { existsSync, statSync } from "node:fs"; +import { resolve, basename } from "node:path"; +import { errorBox } from "../ui/format.js"; + +export interface ProjectDir { + dir: string; + name: string; + indexPath: string; +} + +export function resolveProject(dirArg: string | undefined): ProjectDir { + const dir = resolve(dirArg ?? "."); + const name = basename(dir); + const indexPath = resolve(dir, "index.html"); + + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + errorBox("Not a directory: " + dir); + process.exit(1); + } + if (!existsSync(indexPath)) { + errorBox( + "No composition found in " + dir, + "No index.html file found.", + "Run npx hyperframes init to create a new composition.", + ); + process.exit(1); + } + + return { dir, name, indexPath }; +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 000000000..52c905c11 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "0.1.0"; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..441b14aee --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 000000000..1f6d69589 --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,53 @@ +import { defineConfig } from "tsup"; +import { resolve } from "node:path"; + +export default defineConfig({ + entry: ["src/cli.ts"], + format: ["esm"], + outDir: "dist", + target: "node22", + platform: "node", + bundle: true, + splitting: false, + sourcemap: false, + clean: true, + banner: { + js: `import { createRequire as __hf_createRequire } from "node:module"; +import { fileURLToPath as __hf_fileURLToPath } from "node:url"; +import { dirname as __hf_dirname } from "node:path"; +const require = __hf_createRequire(import.meta.url); +const __filename = __hf_fileURLToPath(import.meta.url); +const __dirname = __hf_dirname(__filename);`, + }, + external: [ + "puppeteer-core", + "puppeteer", + "@puppeteer/browsers", + "open", + "hono", + "hono/*", + "@hono/node-server", + "cheerio", + "mime-types", + "adm-zip", + "esbuild", + ], + noExternal: [ + "@hyperframes/core", + "@hyperframes/producer", + "@hyperframes/studio-backend", + "@hyperframes/engine", + "@clack/prompts", + "@clack/core", + "picocolors", + "linkedom", + "sisteransi", + "is-unicode-supported", + "citty", + ], + esbuildOptions(options) { + options.alias = { + "@hyperframes/producer": resolve(__dirname, "../producer/src/index.ts"), + }; + }, +}); diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 000000000..0cd4fb4a9 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,125 @@ +# HyperFrames Core + +> Create videos by writing HTML. The HTML is the source of truth - timing, layout, content. Scripts define how clips animate. + +HyperFrames Core is the foundational library for video composition. It defines the data attributes and HTML structure that represent video timelines, with GSAP animations to bring clips to life. + +Frame adapter direction: + +- [`../FRAME.md`](../FRAME.md) - repository-level frame model and deterministic renderer direction +- [`adapters/README.md`](adapters/README.md) - adapter contract and contribution guide + +## Philosophy + +``` +HTML = Scene description (content, timing, layout) +CSS = Styling +JS = GSAP animations (motion) +``` + +Every element in the video timeline is represented as an HTML element with data attributes that define its position in time. + +--- + +## Documentation + +### Schema + +The specification for valid HyperFrames HTML: + +- **[schema.md](docs/schema.md)** - Element types, data attributes, HTML patterns + +### Guides + +For AI agents and developers working with HyperFrames: + +| Document | Description | +| --------------------------------------------------------- | -------------------------------------- | +| [Cheat Sheet](docs/guides/cheat-sheet.md) | Quick reference for common actions | +| [Position Guide](docs/guides/position.md) | Natural language → position mappings | +| [Text Style Guide](docs/guides/text-style.md) | Natural language → text style mappings | +| [Motion Design Guide](docs/guides/motion-design/guide.md) | Creating and editing motion designs | +| [Caption Guide](docs/guides/motion-design/captions.md) | Adding captions to videos | + +--- + +## Quick Start + +### Using the Editor (Recommended) + +```bash +pnpm install +pnpm dev +``` + +This starts the studio dev server. + +### Writing Compositions Manually + +```html + + + + + + + +
+ +
+
Hello World
+
+
+ + + +``` + +--- + +## Element Types + +| Type | HTML Tag | Purpose | +| ----------- | ------------------------------- | -------------------- | +| video | `