Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Golden baseline videos for regression tests
packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
54 changes: 54 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
4 changes: 4 additions & 0 deletions packages/cli/scripts/build-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { writeFileSync } from "node:fs";

writeFileSync("dist/hyperframe-runtime.js", loadHyperframeRuntimeSource());
25 changes: 25 additions & 0 deletions packages/cli/src/browser/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
161 changes: 161 additions & 0 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
@@ -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<string> =
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<BrowserResult | undefined> {
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<BrowserResult | undefined> {
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<BrowserResult> {
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 };
27 changes: 27 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading