chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
# hyperframes
CLI for creating, previewing, and rendering HTML video compositions.
## Install
```bash
npm install -g hyperframes
```
Or use directly with npx:
```bash
npx hyperframes <command>
```
**Requirements:** Node.js >= 22, FFmpeg
## Commands
### `init`
Scaffold a new Hyperframes project from a template:
```bash
npx hyperframes init my-video
cd my-video
```
### `preview`
Start the live preview studio in your browser:
```bash
npx hyperframes preview
# Studio running at http://localhost:3002
npx hyperframes preview --port 4567
```
### `render`
Render a composition to MP4. Run from the project directory; the positional
argument is the project directory (not a file), so render the project's
`index.html` directly, or point at a specific composition file with `-c`:
```bash
npx hyperframes render -o output.mp4
npx hyperframes render -c ./my-composition.html -o output.mp4
```
### `lint`
Validate your Hyperframes HTML:
```bash
npx hyperframes lint ./my-composition
npx hyperframes lint ./my-composition --json # JSON output for CI/tooling
npx hyperframes lint ./my-composition --verbose # Include info-level findings
```
By default only errors and warnings are shown. Use `--verbose` to also display informational findings (e.g., external script dependency notices). Use `--json` for machine-readable output with `errorCount`, `warningCount`, `infoCount`, and a `findings` array.
### `compositions`
List compositions found in the current project:
```bash
npx hyperframes compositions
```
### `benchmark`
Run rendering benchmarks:
```bash
npx hyperframes benchmark ./my-composition.html
```
### `doctor`
Check your environment for required dependencies (Chrome, FFmpeg, Node.js):
```bash
npx hyperframes doctor
```
### `browser`
Manage the bundled Chrome/Chromium installation:
```bash
npx hyperframes browser
```
### `info`
Print version and environment info:
```bash
npx hyperframes info
```
### `docs`
Open the documentation in your browser:
```bash
npx hyperframes docs
```
### `upgrade`
Check for updates and show upgrade instructions:
```bash
npx hyperframes upgrade
npx hyperframes upgrade --check --json # machine-readable for agents
```
## Documentation
Full documentation: [hyperframes.heygen.com/packages/cli](https://hyperframes.heygen.com/packages/cli)
## Related packages
- [`@hyperframes/core`](../core) — types, parsers, frame adapters
- [`@hyperframes/engine`](../engine) — rendering engine
- [`@hyperframes/producer`](../producer) — render pipeline
- [`@hyperframes/studio`](../studio) — composition editor UI
+74
View File
@@ -0,0 +1,74 @@
{
"name": "@hyperframes/cli",
"version": "0.7.55",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/cli"
},
"bin": {
"hyperframes": "./dist/cli.js"
},
"files": [
"dist"
],
"type": "module",
"scripts": {
"test": "vitest run",
"dev": "tsx src/cli.ts",
"build": "bun run build:fonts && tsup && bun run build:runtime && bun run build:beat-analyzer && bun run build:copy",
"build:fonts": "node scripts/build-fonts.mjs",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:beat-analyzer": "node scripts/build-beat-analyzer.mjs",
"build:copy": "node scripts/build-copy.mjs",
"typecheck": "tsc --noEmit",
"gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts",
"gen:agent-dirs": "tsx scripts/sync-agent-dirs.ts"
},
"dependencies": {
"@hono/node-server": "^1.8.0",
"@puppeteer/browsers": "^3.0.6",
"adm-zip": "^0.5.16",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
"debug": "^4.4.0",
"esbuild": "^0.25.12",
"fontkit": "^2.0.4",
"giget": "^3.2.0",
"hono": "^4.0.0",
"onnxruntime-node": "^1.20.0",
"open": "^10.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"puppeteer-core": "^25.2.1",
"sharp": "^0.34.5"
},
"devDependencies": {
"@clack/prompts": "^1.1.0",
"@hyperframes/aws-lambda": "workspace:*",
"@hyperframes/core": "workspace:*",
"@hyperframes/engine": "workspace:*",
"@hyperframes/gcp-cloud-run": "workspace:*",
"@hyperframes/lint": "workspace:*",
"@hyperframes/producer": "workspace:*",
"@hyperframes/studio": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@types/adm-zip": "^0.5.7",
"@types/fontkit": "^2.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.10",
"linkedom": "^0.18.12",
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"optionalDependencies": {
"@google/genai": "^1.50.1"
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,28 @@
// Prebuild the beat-detection browser bundle into dist so `hyperframes beats`
// works in the published CLI (which ships only dist, not source). Mirrors how
// the runtime IIFE is shipped. headlessAnalyzer.ts loads this at runtime and
// injects it into a headless page.
import { build } from "esbuild";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const coreRoot = dirname(require.resolve("@hyperframes/core/package.json"));
const entry = join(coreRoot, "src/beats/beatDetection.ts");
await build({
stdin: {
contents:
`import { analyzeMusicFromBuffer } from ${JSON.stringify(entry)};\n` +
`globalThis.__hfAnalyze = analyzeMusicFromBuffer;`,
resolveDir: coreRoot,
loader: "ts",
},
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
outfile: "dist/beat-analyzer.global.js",
});
console.log("built dist/beat-analyzer.global.js");
+138
View File
@@ -0,0 +1,138 @@
// Cross-platform replacement for the previous `mkdir -p … && cp -r …` shell
// chain, which failed on Windows because `cp` doesn't accept `-r` there.
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as sleep } from "node:timers/promises";
const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, "..");
const REPO_ROOT = resolve(CLI_ROOT, "..", "..");
const DIST = join(CLI_ROOT, "dist");
// Studio's vite build clears its dist before rewriting it; don't start the
// copy until both sentinels are present so we never observe a partial tree.
const STUDIO_WAIT_TIMEOUT_MS = 30_000;
const STUDIO_POLL_INTERVAL_MS = 250;
// fallow-ignore-next-line complexity
async function waitForStudioDist(dir) {
const deadline = Date.now() + STUDIO_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const entries = new Set(readdirSync(dir));
// vite emits `assets/` before rewriting `index.html` at the end of the
// build — so once both are present, the tree is complete.
if (entries.has("index.html") && entries.has("assets")) return;
} catch {
// dir doesn't exist yet — vite will create it
}
await sleep(STUDIO_POLL_INTERVAL_MS);
}
throw new Error(`[build-copy] timed out waiting for studio dist at ${dir}`);
}
function copyDir(src, dest) {
cpSync(src, dest, { recursive: true, force: true });
}
function copyDirContents(src, dest) {
for (const entry of readdirSync(src)) {
cpSync(join(src, entry), join(dest, entry), {
recursive: true,
force: true,
});
}
}
function copyMdFiles(srcDir, destDir) {
if (!existsSync(srcDir)) return;
for (const name of readdirSync(srcDir)) {
if (name.endsWith(".md")) {
cpSync(join(srcDir, name), join(destDir, name));
}
}
}
// fallow-ignore-next-line complexity
async function main() {
for (const sub of ["studio", "docs", "templates", "skills", "docker"]) {
mkdirSync(join(DIST, sub), { recursive: true });
}
mkdirSync(join(DIST, "commands"), { recursive: true });
const studioDist = resolve(CLI_ROOT, "..", "studio", "dist");
await waitForStudioDist(studioDist);
copyDirContents(studioDist, join(DIST, "studio"));
for (const tmpl of ["blank", "_shared"]) {
copyDir(join(CLI_ROOT, "src", "templates", tmpl), join(DIST, "templates", tmpl));
}
// Bundle warm-grain from the repo registry so the built CLI can scaffold it
// offline and CI smoke tests pick up PR-branch changes before merge to main.
const warmGrainSrc = join(REPO_ROOT, "registry", "examples", "warm-grain");
if (existsSync(warmGrainSrc)) {
copyDir(warmGrainSrc, join(DIST, "templates", "warm-grain"));
}
// Skills bundled into the published CLI. Branches don't all carry the same
// skills/ tree (it gets restructured), so each entry is existsSync-guarded:
// a missing skill dir warns + skips instead of crashing the build.
for (const skill of ["hyperframes", "hyperframes-cli", "gsap"]) {
const src = join(REPO_ROOT, "skills", skill);
if (!existsSync(src)) {
console.warn(`[build-copy] skill not found, skipping: skills/${skill}`);
continue;
}
copyDir(src, join(DIST, "skills", skill));
}
const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render");
if (existsSync(dockerfile)) {
cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render"));
}
const layoutAuditScript = join(CLI_ROOT, "src", "commands", "layout-audit.browser.js");
if (existsSync(layoutAuditScript)) {
cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js"));
}
const contrastAuditScript = join(CLI_ROOT, "src", "commands", "contrast-audit.browser.js");
if (existsSync(contrastAuditScript)) {
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
}
const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js");
if (existsSync(motionSampleScript)) {
cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js"));
}
// Player bundles for the standalone browser player used by `present` and
// `play`. resolvePlayerPath/resolveSlideshowPath look for these alongside the
// built CLI (dist/<name>.global.js), so they must ship in the package — the
// monorepo-dev fallback paths don't exist once installed from npm. Without
// this, `npx hyperframes present` fails with "@hyperframes/player not found".
const playerDist = join(REPO_ROOT, "packages", "player", "dist");
const playerGlobals = [
[join(playerDist, "hyperframes-player.global.js"), join(DIST, "hyperframes-player.global.js")],
[
join(playerDist, "slideshow", "hyperframes-slideshow.global.js"),
join(DIST, "hyperframes-slideshow.global.js"),
],
];
for (const [src, dest] of playerGlobals) {
if (existsSync(src)) {
cpSync(src, dest);
} else {
console.warn(`[build-copy] player bundle not found, skipping: ${src}`);
}
}
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
console.log("[build-copy] done");
}
await main();
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { execSync } from "node:child_process";
const target = "../producer/src/services/fontData.generated.ts";
if (existsSync(target)) {
console.log("[build:fonts] skipped — fontData.generated.ts already exists");
process.exit(0);
}
execSync("cd ../producer && tsx scripts/generate-font-data.ts", {
stdio: "inherit",
});
+19
View File
@@ -0,0 +1,19 @@
import { copyFileSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const coreDistDir = resolve(__dirname, "../../core/dist");
// Read the pre-built manifest to find the IIFE artifact name
const manifest = JSON.parse(readFileSync(resolve(coreDistDir, "hyperframe.manifest.json"), "utf8"));
const iifeFileName = manifest.artifacts?.iife ?? "hyperframe.runtime.iife.js";
// Copy the pre-built artifacts from core/dist — these have matching SHA256
// checksums. Do NOT regenerate via loadHyperframeRuntimeSource() as that
// produces output without the trailing newline, causing a checksum mismatch.
copyFileSync(resolve(coreDistDir, "hyperframe.manifest.json"), "dist/hyperframe.manifest.json");
copyFileSync(resolve(coreDistDir, iifeFileName), `dist/${iifeFileName}`);
// Keep legacy name for backward compat (e.g. studio dev server)
copyFileSync(resolve(coreDistDir, iifeFileName), "dist/hyperframe-runtime.js");
@@ -0,0 +1,86 @@
// Generate (or verify) skills-manifest.json (repo root) — the published
// "latest" fingerprint of the HyperFrames skill bundle.
//
// bun run --cwd packages/cli gen:skills-manifest # write/update
// bun run --cwd packages/cli gen:skills-manifest --check # verify only (CI)
//
// The manifest is just per-skill content hashes (no version / timestamp), so it
// is fully deterministic: same skill content ⇒ byte-identical manifest. `--check`
// exits non-zero when the committed manifest doesn't match current skill content.
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { buildManifest, MANIFEST_FILE, type SkillsManifest } from "../src/utils/skillsManifest.js";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..", ".."); // packages/cli/scripts → repo root
const skillsRoot = join(repoRoot, "skills");
const outPath = join(repoRoot, MANIFEST_FILE);
const isCheck = process.argv.includes("--check");
/** Stable signature of the content hashes (order-independent). */
function signature(skills: SkillsManifest["skills"]): string {
return Object.keys(skills)
.sort()
.map((name) => `${name}:${skills[name]!.hash}`)
.join("\n");
}
function driftLine(name: string, oldHash?: string, newHash?: string): string | null {
if (oldHash === newHash) return null;
if (!oldHash) return ` + ${name} (new)`;
if (!newHash) return ` - ${name} (removed)`;
return ` ~ ${name} (${oldHash}${newHash})`;
}
function hashOf(skills: SkillsManifest["skills"], name: string): string | undefined {
return skills[name]?.hash;
}
function reportDrift(fresh: SkillsManifest, committed: SkillsManifest | null): void {
const oldSkills = committed === null ? {} : committed.skills;
const names = [...new Set([...Object.keys(fresh.skills), ...Object.keys(oldSkills)])].sort();
for (const name of names) {
const line = driftLine(name, hashOf(oldSkills, name), hashOf(fresh.skills, name));
if (line) console.log(line);
}
}
const fresh = buildManifest(skillsRoot, { source: "heygen-com/hyperframes" });
// Read the committed manifest directly (no existsSync precheck) so there's no
// check-then-write race on outPath — a missing or unreadable file just means
// "no committed manifest yet", and we write a fresh one below.
let committed: SkillsManifest | null = null;
try {
committed = JSON.parse(readFileSync(outPath, "utf8")) as SkillsManifest;
} catch {
committed = null;
}
const inSync = committed !== null && signature(committed.skills) === signature(fresh.skills);
const count = Object.keys(fresh.skills).length;
if (isCheck) {
if (inSync) {
console.log(`${MANIFEST_FILE} is in sync (${count} skills)`);
process.exit(0);
}
console.error(`${MANIFEST_FILE} is out of date — a skill changed without regenerating it.`);
reportDrift(fresh, committed);
console.error(
`\nRun: bun run --cwd packages/cli gen:skills-manifest (then commit ${MANIFEST_FILE})`,
);
process.exit(1);
}
// Write mode — churn-free: only rewrite when a content hash actually changed.
if (inSync) {
console.log(`${MANIFEST_FILE} already in sync — no change (${count} skills)`);
process.exit(0);
}
writeFileSync(outPath, JSON.stringify(fresh, null, 2) + "\n");
console.log(`Wrote ${outPath} (${count} skills)`);
reportDrift(fresh, committed);
+169
View File
@@ -0,0 +1,169 @@
// Generate (or verify) packages/cli/src/utils/agentDirs.generated.ts — the
// home-relative GLOBAL skills directory for every agent the upstream
// vercel-labs/skills CLI knows about, plus a marker dir that means "this agent
// is installed on this machine".
//
// bun run --cwd packages/cli gen:agent-dirs # write/update (fetches upstream)
// bun run --cwd packages/cli gen:agent-dirs --check # verify only (CI / pre-commit)
// bun packages/cli/scripts/sync-agent-dirs.ts --src <path/to/agents.ts> # offline
//
// Why generated, not hand-maintained: `skills add --global` installs into the
// per-agent dirs encoded in upstream's `src/agents.ts` (~70 agents). We mirror
// the canonical store into those same dirs, so the list must track upstream. The
// `skills` npm package exports nothing importable (CLI-only, bundled dist), so
// we parse the source at a PINNED tag and commit the result — deterministic at
// runtime, no network at install time. Bump SKILLS_REF and re-run on upgrade.
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// Pin to the upstream release whose dir layout we install against. Bump this
// (and re-run) when the bundled `skills` version moves.
const SKILLS_REPO = "vercel-labs/skills";
const SKILLS_REF = "v1.5.13";
const AGENTS_TS_URL = `https://raw.githubusercontent.com/${SKILLS_REPO}/${SKILLS_REF}/src/agents.ts`;
const here = dirname(fileURLToPath(import.meta.url));
const outPath = join(here, "..", "src", "utils", "agentDirs.generated.ts");
const isCheck = process.argv.includes("--check");
const srcFlag = process.argv.indexOf("--src");
const srcPath = srcFlag !== -1 ? process.argv[srcFlag + 1] : undefined;
// The base directories agents.ts builds globalSkillsDir from. Each is an env
// override with a documented default — the mirror resolves them at runtime so a
// machine with XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR set lands in the
// same place the agent actually reads. We store the base NAME + suffix here
// rather than a frozen path so that resolution stays faithful to upstream.
const BASE_VARS = [
"home",
"configHome",
"codexHome",
"claudeHome",
"vibeHome",
"hermesHome",
"autohandHome",
] as const;
type BaseVar = (typeof BASE_VARS)[number];
interface AgentGlobalDir {
agent: string;
base: BaseVar;
sub: string;
}
function posixJoin(parts: string[]): string {
return parts
.flatMap((p) => p.split("/"))
.filter((s) => s && s !== ".")
.join("/");
}
/** Resolve a globalSkillsDir expression to { base, sub }, or null if none. */
function resolveGlobalExpr(expr: string): { base: BaseVar; sub: string } | null {
const e = expr.trim();
if (e === "undefined") return null; // agent defines no global skills dir
// openclaw's helper falls back to ~/.openclaw/skills when no variant is present.
if (e.startsWith("getOpenClawGlobalSkillsDir")) return { base: "home", sub: ".openclaw/skills" };
const m = e.match(/^join\(([\s\S]+)\)$/);
if (!m) throw new Error(`Unparseable globalSkillsDir: ${expr}`);
const args = m[1]!.split(",").map((a) => a.trim());
const first = args[0]!;
if (!(BASE_VARS as readonly string[]).includes(first)) {
throw new Error(`globalSkillsDir does not start with a known base var: ${expr}`);
}
const segs: string[] = [];
for (const raw of args.slice(1)) {
const lit = raw.match(/^['"]([^'"]*)['"]$/);
if (!lit) throw new Error(`Non-literal segment "${raw}" in globalSkillsDir: ${expr}`);
segs.push(lit[1]!);
}
return { base: first as BaseVar, sub: posixJoin(segs) };
}
function parseAgents(source: string): AgentGlobalDir[] {
const re = /^ {2}(?:"([a-z0-9-]+)"|'([a-z0-9-]+)'|([a-z0-9-]+)):\s*\{/gm;
const blocks: { key: string; pos: number }[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(source))) blocks.push({ key: m[1] || m[2] || m[3]!, pos: m.index });
if (blocks.length < 60) {
throw new Error(`Parsed only ${blocks.length} agent blocks — upstream layout likely changed`);
}
const out: AgentGlobalDir[] = [];
for (let i = 0; i < blocks.length; i++) {
const seg = source.slice(blocks[i]!.pos, blocks[i + 1] ? blocks[i + 1]!.pos : source.length);
const gd = seg.match(/globalSkillsDir:\s*([\s\S]+?),\n/);
const expr = gd ? gd[1]!.trim().replace(/\s+/g, " ") : "undefined";
const resolved = resolveGlobalExpr(expr);
if (resolved === null) continue; // no global dir (e.g. eve, promptscript)
out.push({ agent: blocks[i]!.key, base: resolved.base, sub: resolved.sub });
}
return out;
}
function render(rows: AgentGlobalDir[]): string {
const lines = rows.map(
(r) =>
` { agent: ${JSON.stringify(r.agent)}, base: ${JSON.stringify(r.base)}, sub: ${JSON.stringify(r.sub)} },`,
);
return `// @generated by packages/cli/scripts/sync-agent-dirs.ts — DO NOT EDIT.
// Source: ${SKILLS_REPO}@${SKILLS_REF} (src/agents.ts). Regenerate with:
// bun run --cwd packages/cli gen:agent-dirs
//
// Each entry is one agent the upstream \`skills\` CLI installs to. The agent's
// GLOBAL skills directory is \`join(<base>, <sub>)\`, where \`base\` is one of the
// env-overridable home dirs below (resolved at runtime by skillsMirror.ts, so
// XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR are honored). Agents with no
// global skills dir upstream (eve, promptscript) are omitted.
/** Env-overridable base dirs, matching upstream agents.ts. */
export type AgentDirBase =
${BASE_VARS.map((b) => ` | ${JSON.stringify(b)}`).join("\n")};
export interface AgentGlobalDir {
/** Upstream agent key. */
agent: string;
/** Base directory the global skills dir is rooted at. */
base: AgentDirBase;
/** POSIX suffix joined onto the resolved base. */
sub: string;
}
export const AGENT_GLOBAL_DIRS: readonly AgentGlobalDir[] = [
${lines.join("\n")}
];
`;
}
async function loadAgentsSource(): Promise<string> {
if (srcPath) return readFileSync(srcPath, "utf8");
const res = await fetch(AGENTS_TS_URL);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${AGENTS_TS_URL}`);
return res.text();
}
const source = await loadAgentsSource();
const rows = parseAgents(source);
const next = render(rows);
if (isCheck) {
let current = "";
try {
current = readFileSync(outPath, "utf8");
} catch {
/* missing → drift */
}
if (current !== next) {
console.error(
`agentDirs.generated.ts is out of date (parsed ${rows.length} agents from ${SKILLS_REPO}@${SKILLS_REF}).\n` +
`Run: bun run --cwd packages/cli gen:agent-dirs`,
);
process.exit(1);
}
console.log(`agentDirs.generated.ts is up to date (${rows.length} agents).`);
} else {
writeFileSync(outPath, next, "utf8");
console.log(`Wrote ${rows.length} agents → ${outPath}`);
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { decideMusic, decideVoice, KOKORO_PIP, MUSICGEN_PIP } from "./providers.js";
describe("decideVoice — mirrors the skill's heygen → elevenlabs → kokoro order", () => {
it("prefers HeyGen when configured", () => {
const r = decideVoice({ hasHeygen: true, elevenlabs: true, kokoro: true });
expect(r.engine).toBe("heygen");
expect(r.ready).toBe(true);
});
it("falls to ElevenLabs only when key + module are both present", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: true, kokoro: true }).engine).toBe(
"elevenlabs",
);
});
it("falls to Kokoro when no cloud provider is usable", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).engine).toBe(
"kokoro",
);
});
it("flags Kokoro as not-ready with a pip hint when deps are missing", () => {
const r = decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: false });
expect(r.engine).toBe("kokoro");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(KOKORO_PIP);
});
it("omits the hint when Kokoro is ready", () => {
expect(
decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).setupHint,
).toBeUndefined();
});
});
describe("decideMusic — mirrors the skill's heygen → lyria → musicgen order", () => {
it("prefers HeyGen, then Lyria, then MusicGen", () => {
expect(decideMusic({ hasHeygen: true, lyria: true, musicgen: true }).engine).toBe("heygen");
expect(decideMusic({ hasHeygen: false, lyria: true, musicgen: true }).engine).toBe("lyria");
expect(decideMusic({ hasHeygen: false, lyria: false, musicgen: true }).engine).toBe("musicgen");
});
it("flags MusicGen as not-ready with a pip hint when deps are missing", () => {
const r = decideMusic({ hasHeygen: false, lyria: false, musicgen: false });
expect(r.engine).toBe("musicgen");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(MUSICGEN_PIP);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* Which voice / music engine a workflow will actually use, and whether
* its local dependencies are present. Mirrors the resolution order the
* media-use skill scripts use, so `auth status` and `doctor`
* report the same engine the render pipeline would pick:
*
* voice: HeyGen Starfish → ElevenLabs (key + `elevenlabs`) → Kokoro (local)
* music: HeyGen library → Lyria (key + `google.genai`) → MusicGen (local)
*
* The decision is split from the probing: `decide*` is pure (unit-tested
* without spawning Python); `gather*` collects the live facts.
*/
import { hasPythonModules } from "../tts/python.js";
/** Python import names probed for each local engine. */
export const KOKORO_MODULES = ["kokoro_onnx", "soundfile"];
export const MUSICGEN_MODULES = ["transformers", "torch", "soundfile", "numpy"];
/** pip one-liners shown when a local engine's deps are missing. */
export const KOKORO_PIP = "pip install kokoro-onnx soundfile";
export const MUSICGEN_PIP = "pip install transformers torch soundfile numpy";
export type VoiceEngine = "heygen" | "elevenlabs" | "kokoro";
export type MusicEngine = "heygen" | "lyria" | "musicgen";
export interface EngineReadiness<E> {
engine: E;
/** Human label, e.g. "Kokoro". */
label: string;
/** A local engine (no account needed) vs a cloud provider keyed by env. */
local: boolean;
/** Usable right now: cloud key present, or local deps installed. */
ready: boolean;
/** Shown when `ready` is false — how to make it ready. */
setupHint?: string;
}
export interface VoiceFacts {
hasHeygen: boolean;
/** ELEVENLABS_API_KEY set AND the `elevenlabs` module importable. */
elevenlabs: boolean;
/** Kokoro's local deps importable. */
kokoro: boolean;
}
export interface MusicFacts {
hasHeygen: boolean;
/** A Gemini/Google key set AND `google.genai` importable. */
lyria: boolean;
/** MusicGen's local deps importable. */
musicgen: boolean;
}
export function decideVoice(f: VoiceFacts): EngineReadiness<VoiceEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen Starfish", local: false, ready: true };
if (f.elevenlabs) return { engine: "elevenlabs", label: "ElevenLabs", local: false, ready: true };
return {
engine: "kokoro",
label: "Kokoro",
local: true,
ready: f.kokoro,
...(f.kokoro ? {} : { setupHint: KOKORO_PIP }),
};
}
export function decideMusic(f: MusicFacts): EngineReadiness<MusicEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen library", local: false, ready: true };
if (f.lyria) return { engine: "lyria", label: "Lyria (Gemini)", local: false, ready: true };
return {
engine: "musicgen",
label: "MusicGen",
local: true,
ready: f.musicgen,
...(f.musicgen ? {} : { setupHint: MUSICGEN_PIP }),
};
}
/** Collect live voice facts. Skips Python probes when HeyGen is configured. */
function gatherVoiceFacts(hasHeygen: boolean): VoiceFacts {
if (hasHeygen) return { hasHeygen, elevenlabs: false, kokoro: false };
const elevenlabs = Boolean(process.env["ELEVENLABS_API_KEY"]) && hasPythonModules(["elevenlabs"]);
const kokoro = hasPythonModules(KOKORO_MODULES);
return { hasHeygen, elevenlabs, kokoro };
}
/** Collect live music facts. Skips Python probes when HeyGen is configured. */
function gatherMusicFacts(hasHeygen: boolean): MusicFacts {
if (hasHeygen) return { hasHeygen, lyria: false, musicgen: false };
const hasLyriaKey = Boolean(process.env["GEMINI_API_KEY"] || process.env["GOOGLE_API_KEY"]);
const lyria = hasLyriaKey && hasPythonModules(["google.genai"]);
const musicgen = hasPythonModules(MUSICGEN_MODULES);
return { hasHeygen, lyria, musicgen };
}
export function resolveVoice(hasHeygen: boolean): EngineReadiness<VoiceEngine> {
return decideVoice(gatherVoiceFacts(hasHeygen));
}
export function resolveMusic(hasHeygen: boolean): EngineReadiness<MusicEngine> {
return decideMusic(gatherMusicFacts(hasHeygen));
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared fixtures for auth-module tests. Centralises the env-snapshot
* + tmp-config-dir pattern so resolver.test.ts and oauth.test.ts don't
* each maintain a copy of the same beforeEach/afterEach plumbing.
*
* Only loaded by `*.test.ts` — runtime code doesn't depend on it.
*/
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ENV_KEYS = [
"HEYGEN_API_KEY",
"HYPERFRAMES_API_KEY",
"HEYGEN_CONFIG_DIR",
"HEYGEN_API_URL",
"HYPERFRAMES_OAUTH_CLIENT_ID",
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
export interface EnvFixture {
/** Tmp config dir; deleted on `restore()`. */
dir: string;
/** Restore env + delete tmp dir. Idempotent. */
restore: () => Promise<void>;
}
/**
* Take a snapshot of the auth-related env, clear them, make a tmp
* `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of
* the above.
*/
export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFixture> {
const dir = await fs.mkdtemp(join(tmpdir(), prefix));
const saved: Partial<Record<EnvKey, string | undefined>> = {};
for (const k of ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
const restore = async (): Promise<void> => {
for (const k of ENV_KEYS) {
const v = saved[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
await fs.rm(dir, { recursive: true, force: true });
};
return { dir, restore };
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Open a URL in the user's default browser. Falls back to printing the
* URL when no browser is openable (SSH session, CI, `BROWSER=none`,
* or `open` rejects).
*/
import { c } from "../ui/colors.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export interface OpenBrowserResult {
/** True when we successfully invoked the platform "open" command. */
opened: boolean;
}
export async function openBrowser(url: string): Promise<OpenBrowserResult> {
if (process.env["BROWSER"] === "none" || process.env["HF_NO_BROWSER"] === "1") {
printManualInstructions(url);
return { opened: false };
}
try {
const open = (await import("open")).default;
await open(url);
return { opened: true };
} catch (err) {
printManualInstructions(url, normalizeErrorMessage(err));
return { opened: false };
}
}
function printManualInstructions(url: string, detail?: string): void {
if (detail) {
console.error(c.warn(`Could not open browser automatically (${detail}).`));
} else {
console.error(c.warn("Browser auto-open is disabled."));
}
console.error(`Open this URL manually to continue:\n ${c.accent(url)}`);
}
+306
View File
@@ -0,0 +1,306 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
AuthClient,
HEYGEN_CLI_SOURCE,
HEYGEN_CLI_SOURCE_HEADER,
apiBaseUrl,
buildAuthHeaders,
} from "./client.js";
import { isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
function jsonFetch(body: unknown, status = 200): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
function textFetch(body: string, status: number): typeof fetch {
return (async () => new Response(body, { status })) as unknown as typeof fetch;
}
function apiKeyCred(): ResolvedCredential {
return { type: "api_key", key: "hg_x", source: "env" };
}
function makeClient(fetchImpl: typeof fetch): AuthClient {
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
}
// getCurrentUser is expected to reject with a specific auth-error code.
async function expectAuthCode(promise: Promise<unknown>, code: string): Promise<void> {
await expect(promise).rejects.toSatisfy(
(err) => isAuthError(err) && (err as { code: string }).code === code,
);
}
// getCurrentUser is expected to reject; assertMessage inspects the scrubbed message.
async function expectRejectionMessage(
client: AuthClient,
assertMessage: (msg: string) => void,
): Promise<void> {
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
assertMessage((err as Error).message);
return;
}
throw new Error("expected rejection");
}
describe("auth/client", () => {
const original = process.env["HEYGEN_API_URL"];
beforeEach(() => {
delete process.env["HEYGEN_API_URL"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_API_URL"] = original;
else delete process.env["HEYGEN_API_URL"];
});
it("apiBaseUrl defaults to https://api.heygen.com", () => {
expect(apiBaseUrl()).toBe("https://api.heygen.com");
});
it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => {
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/";
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
});
it("buildAuthHeaders uses Bearer for oauth", () => {
const cred: ResolvedCredential = {
type: "oauth",
access_token: "at_123",
source: "file_json",
refreshable: false,
};
expect(buildAuthHeaders(cred)).toEqual({
authorization: "Bearer at_123",
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
});
});
it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({
"x-api-key": "hg_x",
});
});
it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
const client = makeClient(
jsonFetch({
code: 100,
message: "ok",
data: {
username: "alice",
email: "alice@example.com",
billing_type: "subscription",
subscription: {
plan: "team",
credits: {
premium_credits: { remaining: 4200, resets_at: "2026-12-01T00:00:00Z" },
add_on_credits: { remaining: 9 },
},
},
},
}),
);
const user = await client.getCurrentUser(apiKeyCred());
expect(user.username).toBe("alice");
expect(user.email).toBe("alice@example.com");
expect(user.subscription?.plan).toBe("team");
expect(user.subscription?.credits?.premium_credits?.remaining).toBe(4200);
expect(user.subscription?.credits?.premium_credits?.resets_at).toBe("2026-12-01T00:00:00Z");
expect(user.subscription?.credits?.add_on_credits?.remaining).toBe(9);
});
it("getCurrentUser parses an unwrapped payload", async () => {
const client = makeClient(jsonFetch({ email: "bob@example.com" }));
const user = await client.getCurrentUser(apiKeyCred());
expect(user.email).toBe("bob@example.com");
});
it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
const client = makeClient(textFetch("invalid token", 401));
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "UNAUTHENTICATED");
});
it("getCurrentUser throws ErrApi on 5xx", async () => {
const client = makeClient(textFetch("upstream", 503));
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
});
it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
const fetchImpl = (async () =>
new Response("<html>not json</html>", {
status: 200,
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
});
it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] }));
const user = await client.getCurrentUser(apiKeyCred());
expect(user).toEqual({});
});
it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => {
const fetchImpl = textFetch(
'invalid request — got header "x-api-key: hg_supersecret_abc123"',
401,
);
const client = makeClient(fetchImpl);
await expectRejectionMessage(client, (msg) => {
expect(msg).not.toContain("hg_supersecret_abc123");
expect(msg).toContain("<redacted>");
});
});
it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
const fetchImpl = textFetch(
"rejected — echoed Authorization: Bearer at_opaque_secret_999",
401,
);
const client = makeClient(fetchImpl);
await expectRejectionMessage(client, (msg) => {
expect(msg).not.toContain("at_opaque_secret_999");
expect(msg).not.toContain("Bearer at_opaque_secret_999");
expect(msg).toContain("<redacted>");
});
});
it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => {
let callCount = 0;
const observed: string[] = [];
const fetchImpl = (async (_url: string, init?: RequestInit) => {
callCount++;
const headers = (init?.headers as Record<string, string>) ?? {};
observed.push(headers["authorization"] ?? "");
if (callCount === 1) return new Response("expired", { status: 401 });
return new Response(JSON.stringify({ email: "a@b" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async () => ({
access_token: "fresh_at",
refresh_token: "fresh_rt",
}),
});
const user = await client.getCurrentUser({
type: "oauth",
access_token: "stale_at",
refresh_token: "rt",
source: "file_json",
refreshable: true,
});
expect(user.email).toBe("a@b");
expect(observed[0]).toBe("Bearer stale_at");
expect(observed[1]).toBe("Bearer fresh_at");
expect(callCount).toBe(2);
});
it("getCurrentUser hook returns full token set so the retry credential carries any rotated refresh_token", async () => {
// The hook contract now returns `OAuthTokens` (access_token plus an
// optional rotated refresh_token), not just an access_token string.
// For IdPs that rotate refresh_tokens on every exchange, any future
// retry on the in-memory credential needs the FRESH rt — the in-
// memory credential must be rebuilt from the hook's return, not
// re-use the stale rt from the original credential.
let receivedRt = "";
const fetchImpl = (async () =>
new Response("expired", { status: 401 })) as unknown as typeof fetch;
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async (rt) => {
receivedRt = rt;
// Hook MUST be able to return a rotated refresh_token — this
// would have been impossible with the old `Promise<string>`
// contract. The type checker fails the build if the shape drifts.
return { access_token: "fresh_at", refresh_token: "rotated_rt" };
},
});
await expect(
client.getCurrentUser({
type: "oauth",
access_token: "stale_at",
refresh_token: "ORIGINAL_rt",
source: "file_json",
refreshable: true,
}),
).rejects.toSatisfy((err) => isAuthError(err));
expect(receivedRt).toBe("ORIGINAL_rt");
});
it("getCurrentUser does NOT retry on 401 for api_key credentials", async () => {
let callCount = 0;
const fetchImpl = (async () => {
callCount++;
return new Response("invalid", { status: 401 });
}) as unknown as typeof fetch;
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async () => ({ access_token: "fresh" }),
});
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
});
expect(callCount).toBe(1);
});
it("getCurrentUser surfaces 401 when refresh hook returns null (refresh failed)", async () => {
const fetchImpl = (async () =>
new Response("nope", { status: 401 })) as unknown as typeof fetch;
const { ErrRefreshFailed } = await import("./errors.js");
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async () => {
throw ErrRefreshFailed("invalid_grant");
},
});
await expect(
client.getCurrentUser({
type: "oauth",
access_token: "stale",
refresh_token: "rt",
source: "file_json",
refreshable: true,
}),
).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
});
});
it("getCurrentUser sends the right header for oauth credentials", async () => {
let captured: Record<string, string> = {};
const fetchImpl = (async (_url: string, init?: RequestInit) => {
captured = (init?.headers as Record<string, string>) ?? {};
return new Response(JSON.stringify({ email: "alice@example.com" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await client.getCurrentUser({
type: "oauth",
access_token: "at_xyz",
source: "file_json",
refreshable: false,
});
expect(captured["authorization"]).toBe("Bearer at_xyz");
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* Minimal typed HTTP client for HeyGen endpoints needed by the auth
* commands. Hand-written rather than codegen'd because the surface is
* one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is
* disproportionate.
*
* Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev
* testing is one env var away.
*
* Auth header selection:
* - OAuth → `Authorization: Bearer <token>`
* - API key → `x-api-key: <key>`
*
* The backend `/v3/users/me` accepts both. See
* `movio/api_service/app/controller/user_v3.py`.
*/
import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
import { scrubCredentials } from "./scrub.js";
import type { OAuthTokens } from "./store.js";
const DEFAULT_BASE_URL = "https://api.heygen.com";
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
export const HEYGEN_CLI_SOURCE = "cli";
export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
}
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
export interface WalletInfo {
currency?: string;
remaining_balance?: number;
auto_reload?: boolean;
}
/**
* The API returns `credits.{premium,add_on}_credits` as nested objects
* (`{ remaining, resets_at? }`), not bare numbers — discovered live
* against api.heygen.com. Modelling them as nested objects so the row
* formatter can render them properly instead of `[object Object]`.
*/
export interface CreditBalance {
remaining?: number;
resets_at?: string;
}
export interface SubscriptionInfo {
plan?: string;
credits?: {
premium_credits?: CreditBalance;
add_on_credits?: CreditBalance;
};
}
export interface UsageBasedInfo {
spending_current_usd?: number;
spending_cap_usd?: number;
}
/** Subset of the backend response we surface to users today. */
export interface UserInfo {
username?: string;
email?: string;
first_name?: string;
last_name?: string;
billing_type?: BillingType;
wallet?: WalletInfo;
subscription?: SubscriptionInfo;
usage_based?: UsageBasedInfo;
}
export interface AuthClientOptions {
/** Override base URL (otherwise `HEYGEN_API_URL` / default). */
baseUrl?: string;
/** Inject a custom fetch (used by tests). */
fetchImpl?: typeof fetch;
/**
* Hook for refreshing an OAuth credential on 401. The hook should
* exchange the supplied refresh_token for new tokens, persist them,
* and return the FULL new token set — at minimum a fresh
* `access_token`, plus a fresh `refresh_token` if the IdP rotated it.
* Returning the full set lets the retry's credential carry the new
* refresh_token, so any subsequent refresh on the same in-memory
* credential doesn't re-use a now-invalidated rotated RT.
* Wired in by the auth commands; injectable for tests.
*/
onUnauthenticatedRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
}
export class AuthClient {
private readonly base: string;
private readonly fetchImpl: typeof fetch;
private readonly onRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
constructor(opts: AuthClientOptions = {}) {
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
this.fetchImpl = opts.fetchImpl ?? fetch;
this.onRefresh = opts.onUnauthenticatedRefresh;
}
/**
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
* on any other non-2xx or non-JSON body.
*
* On OAuth 401 with a refresh hook configured, the request is
* retried once after refreshing the access token. The retry's
* outcome is what the caller sees — if the refresh itself fails
* (REFRESH_FAILED) or the retry still 401s, the user lands on a
* "please log in again" path upstream.
*/
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
const url = `${this.base}/v3/users/me`;
return await this.fetchUser(url, credential, true);
}
// fallow-ignore-next-line complexity
private async fetchUser(
url: string,
credential: ResolvedCredential,
allowRefresh: boolean,
): Promise<UserInfo> {
const headers = buildAuthHeaders(credential);
const res = await this.fetchImpl(url, { method: "GET", headers });
if (res.status === 401) {
if (
allowRefresh &&
credential.type === "oauth" &&
credential.refresh_token &&
this.onRefresh
) {
const refreshed = await this.tryRefresh(credential.refresh_token);
if (refreshed) {
// Carry the new refresh_token forward too — for IdPs that
// rotate RTs on every refresh, a future retry on this
// in-memory credential would otherwise re-send the old
// (now-invalidated) one.
const next: ResolvedCredential = {
...credential,
access_token: refreshed.access_token,
...(refreshed.refresh_token ? { refresh_token: refreshed.refresh_token } : {}),
};
return await this.fetchUser(url, next, false);
}
}
const detail = await safeText(res);
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
let payload: unknown;
try {
payload = await res.json();
} catch (err) {
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
}
return extractUserInfo(payload);
}
private async tryRefresh(refresh_token: string): Promise<OAuthTokens | null> {
if (!this.onRefresh) return null;
try {
return await this.onRefresh(refresh_token);
} catch (err) {
// Refresh failure should be surfaced upstream by the caller via
// the retry's 401, not by throwing here — so callers consistently
// see "please log in again" rather than mixed error types.
if (isAuthError(err) && err.code === "REFRESH_FAILED") return null;
throw err;
}
}
}
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
if (credential.type === "oauth") {
return {
authorization: `Bearer ${credential.access_token}`,
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
};
}
// API-key traffic keeps the normal billing path; the backend ignores the
// cli-source header for it, so we don't send it (avoids a contradictory
// "cli-source claim on an API-key request").
return { "x-api-key": credential.key };
}
async function safeText(res: Response): Promise<string> {
try {
const body = (await res.text()).slice(0, 500);
return scrubCredentials(body);
} catch {
return "";
}
}
/**
* The backend wraps responses in `{code, message, data: {...}}` for some
* endpoints and returns raw fields directly for others. Handle both.
*/
function extractUserInfo(payload: unknown): UserInfo {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
const obj = payload as Record<string, unknown>;
const wrapped = obj["data"];
const data =
wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)
? (wrapped as Record<string, unknown>)
: obj;
return {
username: pickString(data, "username"),
email: pickString(data, "email"),
first_name: pickString(data, "first_name"),
last_name: pickString(data, "last_name"),
billing_type: pickString(data, "billing_type"),
wallet: pickObject(data, "wallet") as WalletInfo | undefined,
subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined,
usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined,
};
}
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" ? v : undefined;
}
function pickObject(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const v = obj[key];
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: undefined;
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
AuthError,
ErrApi,
ErrInvalidStore,
ErrNotConfigured,
ErrUnauthenticated,
isAuthError,
} from "./errors.js";
describe("auth/errors", () => {
it("ErrNotConfigured carries the right code + hint", () => {
const err = ErrNotConfigured();
expect(err).toBeInstanceOf(AuthError);
expect(err.code).toBe("NOT_CONFIGURED");
expect(err.hint).toContain("hyperframes auth login");
});
it("ErrInvalidStore wraps the detail", () => {
const err = ErrInvalidStore("malformed at line 3");
expect(err.code).toBe("INVALID_STORE");
expect(err.message).toContain("malformed at line 3");
});
it("ErrUnauthenticated includes detail when provided", () => {
expect(ErrUnauthenticated().code).toBe("UNAUTHENTICATED");
expect(ErrUnauthenticated("invalid token").message).toContain("invalid token");
});
it("ErrApi captures status + detail", () => {
const err = ErrApi(503, "upstream timeout");
expect(err.code).toBe("API_ERROR");
expect(err.message).toContain("503");
expect(err.message).toContain("upstream timeout");
});
it("isAuthError narrows properly", () => {
expect(isAuthError(ErrNotConfigured())).toBe(true);
expect(isAuthError(new Error("plain"))).toBe(false);
expect(isAuthError(null)).toBe(false);
expect(isAuthError("string")).toBe(false);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Typed errors for the auth layer. Callers branch on `code` so commands
* can map specific failures to friendly UX without parsing messages.
*/
export type AuthErrorCode =
| "NOT_CONFIGURED"
| "INVALID_STORE"
| "API_ERROR"
| "UNAUTHENTICATED"
| "OAUTH_NOT_CONFIGURED"
| "REFRESH_FAILED";
export class AuthError extends Error {
readonly code: AuthErrorCode;
readonly hint?: string;
constructor(code: AuthErrorCode, message: string, hint?: string) {
super(message);
this.name = "AuthError";
this.code = code;
this.hint = hint;
}
}
export const ErrNotConfigured = () =>
new AuthError(
"NOT_CONFIGURED",
"No HeyGen credentials found",
"Run `hyperframes auth login` to sign in.",
);
export const ErrInvalidStore = (detail: string) =>
new AuthError(
"INVALID_STORE",
`Credential file is unreadable: ${detail}`,
"Delete ~/.heygen/credentials and run `hyperframes auth login` to re-create it.",
);
export const ErrUnauthenticated = (detail?: string) =>
new AuthError(
"UNAUTHENTICATED",
detail ? `HeyGen rejected the credential: ${detail}` : "HeyGen rejected the credential",
"Run `hyperframes auth login` to re-authenticate.",
);
export const ErrApi = (status: number, detail: string) =>
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
export const ErrOAuthNotConfigured = () =>
new AuthError(
"OAUTH_NOT_CONFIGURED",
"OAuth client is not configured",
"Set HYPERFRAMES_OAUTH_CLIENT_ID, or run `hyperframes auth login --api-key`.",
);
export const ErrRefreshFailed = (detail?: string) =>
new AuthError(
"REFRESH_FAILED",
detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens",
"Run `hyperframes auth login` to re-authenticate.",
);
export function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Public surface of the auth library — only the symbols the auth
* commands consume today. Internal types stay in their source files.
*/
export { isAuthError } from "./errors.js";
export {
clearOAuth,
deleteStore,
hasPreservedUnknownData,
isHeaderSafe,
readStore,
writeStore,
} from "./store.js";
export type { Credentials, StoredUserInfo } from "./store.js";
export {
clearUserInfo,
isUserInfoEmpty,
loadUserInfo,
saveUserInfo,
userDisplayName,
} from "./user.js";
export { configDir, credentialPath } from "./paths.js";
export { tryResolveCredential } from "./resolver.js";
export type { ResolvedCredential } from "./resolver.js";
export { AuthClient } from "./client.js";
export type { UserInfo } from "./client.js";
export {
assertOAuthConfiguredOrExit,
refreshTokens,
revokeTokens,
startAuthorizationCodeFlow,
} from "./oauth.js";
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { startLoopback, type LoopbackHandle } from "./loopback.js";
describe("auth/loopback", () => {
let active: LoopbackHandle | null = null;
afterEach(async () => {
if (active) {
await active.close().catch(() => {});
active = null;
}
});
it("captures `code` when state matches", async () => {
const handle = await startLoopback({ state: "expected_state", timeoutMs: 5_000 });
active = handle;
const redirect = new URL(handle.redirectUri);
const callback = new URL(`${handle.redirectUri}?code=abc123&state=expected_state`);
const fetchPromise = fetch(callback.toString());
const result = await handle.result;
const res = await fetchPromise;
expect(result.code).toBe("abc123");
expect(result.redirectUri).toContain(redirect.host);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("Signed in");
});
async function expectRejection(args: {
expectedState: string;
query: string;
pattern: RegExp;
}): Promise<void> {
const handle = await startLoopback({ state: args.expectedState, timeoutMs: 5_000 });
active = handle;
await fetch(`${handle.redirectUri}?${args.query}`).catch(() => {});
await expect(handle.result).rejects.toThrow(args.pattern);
}
it("rejects when state does not match", async () => {
await expectRejection({
expectedState: "expected",
query: "code=abc&state=wrong",
pattern: /state mismatch/i,
});
});
it("rejects when the IdP returns an error", async () => {
await expectRejection({
expectedState: "s",
query: "error=access_denied&error_description=user+denied&state=s",
pattern: /access_denied/,
});
});
it("rejects when code is missing from the callback", async () => {
await expectRejection({ expectedState: "s", query: "state=s", pattern: /code/ });
});
it("times out when no callback arrives", async () => {
const handle = await startLoopback({ state: "s", timeoutMs: 200 });
active = handle;
await expect(handle.result).rejects.toThrow(/timed out/i);
});
it("404s non-callback paths and does not resolve the flow", async () => {
const handle = await startLoopback({ state: "s", timeoutMs: 1_000 });
active = handle;
const res = await fetch(`${handle.redirectUri.replace("/oauth/callback", "/other")}`);
expect(res.status).toBe(404);
// Flow is still waiting — kill it via timeout.
await expect(handle.result).rejects.toThrow(/timed out/i);
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Loopback HTTP server for the OAuth authorization-code callback.
*
* The CLI binds a server to `127.0.0.1:0` (ephemeral port), sends the
* user's browser to `/v1/oauth/authorize?redirect_uri=…` pointing at
* this server, and waits for the redirect carrying `?code=…&state=…`.
*
* The backend wildcards localhost ports for public clients
* (`movio/model/oauth2.py:check_redirect_uri`), so the registered
* redirect URI's port is just a placeholder — the actual port the
* server lands on is what matters at runtime.
*
* Times out after 120s. Validates `state` matches the value we
* generated. Renders a small "you can close this window" page back
* to the browser before shutting down.
*/
import { timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { AddressInfo } from "node:net";
export interface LoopbackOptions {
/** Expected `state` value — flow fails if it doesn't match. */
state: string;
/** Timeout in ms (default 120s). */
timeoutMs?: number;
/** Override port for tests (default 0 = ephemeral). */
port?: number;
}
export interface LoopbackResult {
/** Authorization code from the IdP. */
code: string;
/** The full redirect_uri (with port) we listened on. */
redirectUri: string;
}
export interface LoopbackHandle {
/** Promise that resolves with the captured code, or rejects on timeout/error. */
result: Promise<LoopbackResult>;
/** Redirect URI to pass to /v1/oauth/authorize. */
redirectUri: string;
/** Stop the server early (e.g. user cancels). */
close: () => Promise<void>;
}
const CALLBACK_PATH = "/oauth/callback";
const DEFAULT_TIMEOUT_MS = 120_000;
export async function startLoopback(opts: LoopbackOptions): Promise<LoopbackHandle> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let resolveResult!: (value: LoopbackResult) => void;
let rejectResult!: (err: Error) => void;
const result = new Promise<LoopbackResult>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
// redirectUri is the value the IdP sees on /authorize. RFC 6749 §4.1.3
// requires the token exchange's redirect_uri to be byte-identical to
// it, so we capture this string once and reuse it on both hops — never
// reconstructing from req.socket.localAddress later (which can drift
// on dual-stack hosts).
let redirectUri = "";
const server = createServer((req, res) =>
handleRequest(req, res, opts.state, redirectUri, resolveResult, rejectResult),
);
await listen(server, opts.port ?? 0);
const address = server.address() as AddressInfo;
redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
let closed = false;
const close = async (): Promise<void> => {
if (closed) return;
closed = true;
clearTimeout(timer);
// `server.close()` only refuses NEW connections — it does NOT
// terminate existing keep-alive sockets, which browsers default to
// and idle for minutes (Chrome ~5min). Without `closeAllConnections`
// the CLI process hangs after "Signed in" until the browser closes
// its idle socket. `respond()` also emits `Connection: close` so the
// browser doesn't try to keep-alive in the first place.
server.closeAllConnections?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
};
const timer = setTimeout(() => {
rejectResult(new Error(`OAuth callback timed out after ${timeoutMs}ms`));
void close();
}, timeoutMs);
// When result settles, drain the timer + shutdown.
result.finally(close).catch(() => {});
return { result, redirectUri, close };
}
async function listen(server: Server, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
// fallow-ignore-next-line complexity
function handleRequest(
req: IncomingMessage,
res: ServerResponse,
expectedState: string,
redirectUri: string,
resolveResult: (value: LoopbackResult) => void,
rejectResult: (err: Error) => void,
): void {
// Only GET is part of the OAuth redirect contract. Anything else is
// probe-traffic on the ephemeral port; reject without leaking that a
// CLI is listening there.
if (req.method !== "GET") {
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed");
return;
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { "content-type": "text/plain" }).end("Not Found");
return;
}
const params = url.searchParams;
const error = params.get("error");
if (error) {
const desc = params.get("error_description") ?? "";
respond(res, 400, errorPage(error, desc));
rejectResult(new Error(`OAuth authorize returned error: ${error}${desc ? `${desc}` : ""}`));
return;
}
const state = params.get("state");
if (!state || !stateMatches(state, expectedState)) {
respond(res, 400, errorPage("invalid_state", "State parameter did not match."));
rejectResult(new Error("OAuth state mismatch — possible CSRF, aborting."));
return;
}
const code = params.get("code");
if (!code) {
respond(
res,
400,
errorPage("missing_code", "Authorization code is missing from the redirect."),
);
rejectResult(new Error("OAuth redirect did not include `code`."));
return;
}
respond(res, 200, successPage());
resolveResult({ code, redirectUri });
}
/**
* Constant-time comparison for the OAuth `state` parameter. Real
* exploitability is very low (loopback, 256-bit entropy, narrow flow
* window), but the rest of the auth path uses crypto-grade primitives
* and a `!==` here would be a gratuitous deviation in security review.
*/
function stateMatches(actual: string, expected: string): boolean {
const a = Buffer.from(actual, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
function respond(res: ServerResponse, status: number, body: string): void {
res
.writeHead(status, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
// Tell the browser not to keep the TCP socket alive — otherwise
// `server.close()` blocks on the idle keep-alive timeout
// (Chrome ~5min). Combined with `server.closeAllConnections()`
// in `close()` this guarantees the CLI exits promptly after the
// user sees the success page.
connection: "close",
})
.end(body);
}
function successPage(): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Signed in to HeyGen</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0b0f14;color:#e6e8eb}main{max-width:480px;text-align:center;padding:32px;border-radius:12px;background:#11161d;border:1px solid #1f2630}h1{font-weight:600;margin:0 0 8px;color:#3CE6AC}p{margin:0;color:#9aa3ad}</style>
</head><body><main><h1>You're signed in.</h1><p>You can close this tab and return to your terminal.</p></main></body></html>`;
}
function errorPage(code: string, description: string): string {
const safeCode = escapeHtml(code);
const safeDesc = escapeHtml(description);
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign-in failed</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1a0b0b;color:#e6e8eb}main{max-width:480px;text-align:center;padding:32px;border-radius:12px;background:#1d1111;border:1px solid #301f1f}h1{font-weight:600;margin:0 0 8px;color:#ff7a7a}code{background:#2a1414;padding:2px 6px;border-radius:4px}p{margin:8px 0 0;color:#9aa3ad}</style>
</head><body><main><h1>Sign-in failed</h1><p><code>${safeCode}</code></p><p>${safeDesc}</p></main></body></html>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+359
View File
@@ -0,0 +1,359 @@
import { promises as fs } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js";
import {
parseTokenResponse,
refreshTokens,
resolveClientId,
revokeTokens,
startAuthorizationCodeFlow,
} from "./oauth.js";
import { readStore, writeStore } from "./store.js";
// Mock the interactive bits so startAuthorizationCodeFlow runs headless.
vi.mock("./loopback.js", () => ({
startLoopback: vi.fn(async () => ({
result: Promise.resolve({
code: "auth_code_123",
redirectUri: "http://127.0.0.1:12345/oauth/callback",
}),
redirectUri: "http://127.0.0.1:12345/oauth/callback",
close: vi.fn(async () => {}),
})),
}));
vi.mock("./browser.js", () => ({
openBrowser: vi.fn(async () => ({ opened: true })),
}));
describe("auth/oauth", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv("hf-oauth-");
});
afterEach(async () => {
await fixture.restore();
});
describe("resolveClientId", () => {
it("returns the env override when set", () => {
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "test_client_id";
expect(resolveClientId()).toBe("test_client_id");
});
it("returns the build-time default when env is unset", () => {
expect(resolveClientId()).toMatch(/.+/);
});
});
describe("parseTokenResponse", () => {
it("parses a full token response", () => {
const tokens = parseTokenResponse({
access_token: "at_123",
refresh_token: "rt_456",
token_type: "Bearer",
expires_in: 3600,
scope: "openid profile",
});
expect(tokens.access_token).toBe("at_123");
expect(tokens.refresh_token).toBe("rt_456");
expect(tokens.token_type).toBe("Bearer");
expect(tokens.scope).toBe("openid profile");
expect(tokens.expires_at).toBeDefined();
const expiresAt = new Date(tokens.expires_at!);
// Should be approximately 1 hour in the future
const diff = expiresAt.getTime() - Date.now();
expect(diff).toBeGreaterThan(3500 * 1000);
expect(diff).toBeLessThan(3700 * 1000);
});
it("accepts expires_in as a string (some servers serialize as string)", () => {
const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" });
expect(tokens.expires_at).toBeDefined();
});
it("rejects responses missing access_token", () => {
expect(() => parseTokenResponse({ token_type: "Bearer" })).toThrow();
});
it("rejects array payloads", () => {
expect(() => parseTokenResponse([])).toThrow();
});
it("rejects null payloads", () => {
expect(() => parseTokenResponse(null)).toThrow();
});
it("rejects access_token containing CR/LF", () => {
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toSatisfy(
() => true, // assertion done below via toThrow
);
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toThrow(
/control characters/,
);
});
it("rejects refresh_token containing CR/LF", () => {
expect(() => parseTokenResponse({ access_token: "at", refresh_token: "rt\nbad" })).toThrow(
/control characters/,
);
});
it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => {
const zero = parseTokenResponse({ access_token: "at", expires_in: 0 });
const negative = parseTokenResponse({ access_token: "at", expires_in: -100 });
// both should resolve to a future time
expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
});
it("uses REFRESH_FAILED error code on shape failures (not API_ERROR)", () => {
try {
parseTokenResponse(null);
} catch (err) {
expect(isAuthError(err)).toBe(true);
if (isAuthError(err)) expect(err.code).toBe("REFRESH_FAILED");
return;
}
throw new Error("expected throw");
});
});
describe("refreshTokens", () => {
it("posts grant_type=refresh_token and persists the response", async () => {
process.env["HEYGEN_API_URL"] = "https://api.test.example";
let capturedBody: string | undefined;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
return new Response(
JSON.stringify({
access_token: "new_at",
refresh_token: "new_rt",
expires_in: 3600,
token_type: "Bearer",
scope: "openid profile",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as unknown as typeof fetch;
const tokens = await refreshTokens("old_rt", { fetchImpl });
expect(tokens.access_token).toBe("new_at");
expect(tokens.refresh_token).toBe("new_rt");
expect(capturedBody).toContain("grant_type=refresh_token");
expect(capturedBody).toContain("refresh_token=old_rt");
// Should have persisted.
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
});
it("preserves the prior refresh_token when the server omits it (no rotation)", async () => {
await writeStore({
oauth: {
access_token: "old_at",
refresh_token: "keep_me_rt",
expires_at: "2026-01-01T00:00:00Z",
},
});
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("keep_me_rt", { fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
// Critical: refresh_token MUST survive a no-rotation refresh.
expect(credentials.oauth?.refresh_token).toBe("keep_me_rt");
});
it("preserves an existing api_key when persisting refreshed oauth", async () => {
await writeStore({ api_key: "hg_keep" });
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("old_rt", { fetchImpl });
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_keep");
expect(credentials.oauth?.access_token).toBe("new_at");
});
it("preserves an unknown key INSIDE the oauth sub-object across a refresh", async () => {
// The refresh path is `persistOAuth(preserveMissing: true)`, i.e.
// `{ ...existing.oauth, ...tokens }` — object spread carries the
// hidden Symbol-keyed unknown bag from `existing.oauth`, so a key
// another CLI wrote inside `oauth` (e.g. an `id_token`) must survive
// the no-rotation refresh. Refresh is the most-frequent write path,
// so a silent regression here would be the worst case.
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(
path,
JSON.stringify({
oauth: {
access_token: "old_at",
refresh_token: "keep_me_rt",
id_token: "future_id_token_value",
},
}),
{ mode: 0o600 },
);
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("keep_me_rt", { fetchImpl });
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth.access_token).toBe("new_at");
// The unknown oauth sub-key rode through on the hidden slot.
expect(onDisk.oauth.id_token).toBe("future_id_token_value");
});
it("throws REFRESH_FAILED on 400/401", async () => {
const fetchImpl = (async () =>
new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch;
await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED";
});
});
it("throws API_ERROR on 5xx", async () => {
const fetchImpl = (async () =>
new Response("upstream", { status: 503 })) as unknown as typeof fetch;
await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
});
});
});
describe("revokeTokens", () => {
it("never throws on network failure (best-effort)", async () => {
const fetchImpl = (async () => {
throw new Error("connection refused");
}) as unknown as typeof fetch;
await expect(revokeTokens("any_token", { fetchImpl })).resolves.toBeUndefined();
});
it("respects the timeout", async () => {
let aborted = false;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
const signal = init?.signal;
return new Promise<Response>((_resolve, reject) => {
signal?.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
}) as unknown as typeof fetch;
await revokeTokens("token", { fetchImpl, timeoutMs: 50 });
expect(aborted).toBe(true);
});
it("sends token_type_hint when provided", async () => {
let capturedBody = "";
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
return new Response("", { status: 200 });
}) as unknown as typeof fetch;
await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" });
expect(capturedBody).toContain("token_type_hint=refresh_token");
});
it("returns silently when client_id is unconfigured (no throw)", async () => {
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "";
// With the baked-in default cleared from env, revokeTokens still has
// the build-time default. Force it to fail by setting the override to
// a value AND nulling the default isn't possible from a test — instead
// verify that the function never throws via the standard path.
const fetchImpl = (async () => new Response("", { status: 200 })) as unknown as typeof fetch;
await expect(revokeTokens("tok", { fetchImpl })).resolves.toBeUndefined();
});
});
describe("error scrubbing", () => {
it("refreshTokens does not leak token-shaped secrets from the error body", async () => {
const fetchImpl = (async () =>
new Response(
'{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}',
{ status: 400 },
)) as unknown as typeof fetch;
try {
await refreshTokens("rt_leak_secret", { fetchImpl });
throw new Error("expected rejection");
} catch (err) {
const msg = (err as Error).message;
expect(msg).not.toContain("rt_leak_secret");
expect(msg).not.toContain("cv_leak");
expect(msg).toContain("<redacted>");
}
});
});
describe("startAuthorizationCodeFlow persistence", () => {
function tokenFetch(body: Record<string, unknown>): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => {
// Pre-seed a prior session whose refresh_token must NOT leak into
// the new login when the new response omits one.
await writeStore({
oauth: { access_token: "old_at", refresh_token: "OLD_rt_should_not_survive" },
});
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
// Fresh login is a clean session — the old refresh_token is gone.
expect(credentials.oauth?.refresh_token).toBeUndefined();
});
it("preserves a co-located api_key across fresh login", async () => {
await writeStore({ api_key: "hg_keep_me" });
const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_keep_me");
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.oauth?.refresh_token).toBe("new_rt");
});
it("preserves the user block AND unknown/foreign keys across fresh login", async () => {
// The OAuth write path must not drop co-located data the other CLI
// (or a prior login) wrote. Seed a user block plus a future key,
// then log in fresh — both must survive the OAuth-block overwrite.
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "old_at" },
user: { email: "jane@example.com", username: "jdoe" },
future_field: { keep: true },
}),
{ mode: 0o600 },
);
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.user).toEqual({ email: "jane@example.com", username: "jdoe" });
// The unknown key is on a hidden slot — assert via the raw file.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.future_field).toEqual({ keep: true });
});
});
});
+441
View File
@@ -0,0 +1,441 @@
/**
* OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow.
*
* Entry points:
* - `startAuthorizationCodeFlow()` — full interactive login (browser
* opens, loopback waits, code → tokens, persist).
* - `refreshTokens()` — POST <token-endpoint> with grant_type=refresh_token.
* - `revokeTokens()` — best-effort POST <revoke-endpoint>.
*
* Endpoints split across two hosts (verified live):
* - **Authorize** — GET https://app.heygen.com/oauth/authorize
* Renders the consent screen, has to live on the same origin as the
* user's web session (cookies). The Next.js SPA shell serves this.
* - **Token** — POST https://api2.heygen.com/v1/oauth/token
* Server-to-server JSON API. `app.heygen.com/oauth/token` returns
* the SPA HTML for direct POSTs — confirmed by curl; only the api2
* route returns JSON OAuth responses.
* - **Revoke** — POST https://api2.heygen.com/v1/oauth/revoke
* Same host/prefix as token.
*
* The `heygen-oauth-urls.ts` default in `hyperframes-internal/demo-next`
* lists `app.heygen.com/oauth/token` as the default — that's either
* proxied via a Next.js rewrite or set via `HEYGEN_OAUTH_TOKEN_URL` in
* their prod env. A direct fetch to it returns the SPA shell, so we
* use the api2 endpoint here.
*
* Overrides:
* - `HYPERFRAMES_OAUTH_AUTHORIZE_URL`
* - `HYPERFRAMES_OAUTH_TOKEN_URL`
* - `HYPERFRAMES_OAUTH_REVOKE_URL`
* - `HYPERFRAMES_OAUTH_CLIENT_ID`
*
* Public client — no `client_secret`.
*/
import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js";
import { generatePkcePair, generateState } from "./pkce.js";
import { startLoopback } from "./loopback.js";
import { openBrowser } from "./browser.js";
import { scrubCredentials } from "./scrub.js";
import {
isHeaderSafe,
readStore,
writeStore,
type Credentials,
type OAuthTokens,
} from "./store.js";
import { c } from "../ui/colors.js";
const REVOKE_TIMEOUT_MS = 5_000;
const MIN_EXPIRES_IN_SECONDS = 30;
/**
* Default OAuth client_id baked at build time. Override with the
* `HYPERFRAMES_OAUTH_CLIENT_ID` env var. Empty string means "not
* configured" — `resolveClientId()` errors cleanly with a pointer
* at `--api-key`.
*/
const DEFAULT_CLIENT_ID = "q2A2QRSke2LrFTPJhoDbHtXh";
const DEFAULT_SCOPES = "openid profile email";
// Endpoint defaults — see file-header comment for why these straddle two
// hosts. Each is independently overridable.
const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token";
const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke";
function authorizeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
}
function tokenEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] || DEFAULT_TOKEN_URL;
}
function revokeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL;
}
export interface AuthorizeFlowOptions {
/** Override scopes (default `openid profile email`). */
scope?: string;
/** Inject a custom fetch (used by tests). */
fetchImpl?: typeof fetch;
/** Override timeout in ms (default 120s). */
timeoutMs?: number;
}
export interface AuthorizeFlowResult {
tokens: OAuthTokens;
/** Returned identity info for friendly post-login UX. */
userInfo?: Record<string, unknown>;
}
export interface RefreshOptions {
fetchImpl?: typeof fetch;
}
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
export function resolveClientId(): string {
const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
const id = override && override.length > 0 ? override : DEFAULT_CLIENT_ID;
if (!id || id.length === 0) throw ErrOAuthNotConfigured();
return id;
}
/**
* For command-entry points: throw the standard `ErrOAuthNotConfigured`
* via `resolveClientId()`; on a real misconfig, print a friendly hint
* and exit with status 1 rather than dumping a stack. Throws non-auth
* errors so callers can surface programmer bugs.
*/
export function assertOAuthConfiguredOrExit(): void {
try {
resolveClientId();
} catch (err) {
if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") {
console.error(`Error: ${err.message}`);
if (err.hint) console.error(err.hint);
process.exit(1);
}
throw err;
}
}
export async function startAuthorizationCodeFlow(
opts: AuthorizeFlowOptions = {},
): Promise<AuthorizeFlowResult> {
const clientId = resolveClientId();
const scope = opts.scope ?? DEFAULT_SCOPES;
const pkce = generatePkcePair();
const state = generateState();
const loopback = await startLoopback({ state, timeoutMs: opts.timeoutMs });
const authorizeUrl = buildAuthorizeUrl({
clientId,
redirectUri: loopback.redirectUri,
scope,
state,
challenge: pkce.challenge,
});
// Print the host+path only (no state / code_challenge) so live
// values can't leak into scrollback / CI logs during the 120s window.
console.log(`Opening browser to ${c.dim(authorizeHost(authorizeUrl))} ...`);
const { opened } = await openBrowser(authorizeUrl);
if (!opened) {
// openBrowser already printed the manual URL; surface it as the
// last on-screen instruction so it isn't buried above "Waiting…".
console.log(c.dim("(open the URL above to continue)"));
}
console.log(`Waiting for callback on ${c.accent(loopback.redirectUri)} ...`);
let codeResult;
try {
codeResult = await loopback.result;
} catch (err) {
await loopback.close().catch(() => {});
throw err;
}
const tokens = await exchangeCodeForTokens({
clientId,
code: codeResult.code,
redirectUri: codeResult.redirectUri,
verifier: pkce.verifier,
fetchImpl: opts.fetchImpl,
});
// Fresh login → clean OAuth block (no inherited refresh_token).
await persistOAuth(tokens, { preserveMissing: false });
return { tokens };
}
export async function refreshTokens(
refresh_token: string,
opts: RefreshOptions = {},
): Promise<OAuthTokens> {
const clientId = resolveClientId();
const fetchImpl = opts.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
client_id: clientId,
});
const res = await fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: body.toString(),
});
if (res.status === 400 || res.status === 401) {
throw ErrRefreshFailed(await safeText(res));
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
const payload = await readJsonOrThrow(res);
const tokens = parseTokenResponse(payload);
// Refresh grant → preserve a refresh_token the server didn't rotate.
await persistOAuth(tokens, { preserveMissing: true });
return tokens;
}
export interface RevokeOptions extends RefreshOptions {
/** Hint to the server about which token we're sending (RFC 7009). */
token_type_hint?: "access_token" | "refresh_token";
/** Abort the revoke after this many ms (default 5s). */
timeoutMs?: number;
}
/**
* RFC 7009 revoke. Best-effort: never throws. A hung IdP or unset
* client_id MUST NOT block local logout — both are caught here so the
* caller can wipe local state immediately afterward regardless.
*/
export async function revokeTokens(token: string, opts: RevokeOptions = {}): Promise<void> {
let clientId: string;
try {
clientId = resolveClientId();
} catch {
// OAuth not configured — nothing to revoke server-side.
return;
}
const fetchImpl = opts.fetchImpl ?? fetch;
const params: Record<string, string> = { token, client_id: clientId };
if (opts.token_type_hint) params["token_type_hint"] = opts.token_type_hint;
const body = new URLSearchParams(params);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? REVOKE_TIMEOUT_MS);
try {
const res = await fetchImpl(revokeEndpoint(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: controller.signal,
});
if (!res.ok) {
console.error(
c.dim(`Note: token revoke returned HTTP ${res.status}; local credentials cleared anyway.`),
);
}
} catch {
/* timeout or network error — silent, this is best-effort */
} finally {
clearTimeout(timer);
}
}
function authorizeHost(fullUrl: string): string {
try {
const u = new URL(fullUrl);
return `${u.origin}${u.pathname}`;
} catch {
return fullUrl.split("?")[0] ?? fullUrl;
}
}
function buildAuthorizeUrl(args: {
clientId: string;
redirectUri: string;
scope: string;
state: string;
challenge: string;
}): string {
const params = new URLSearchParams({
response_type: "code",
client_id: args.clientId,
redirect_uri: args.redirectUri,
scope: args.scope,
state: args.state,
code_challenge: args.challenge,
code_challenge_method: "S256",
});
return `${authorizeEndpoint()}?${params.toString()}`;
}
async function exchangeCodeForTokens(args: {
clientId: string;
code: string;
redirectUri: string;
verifier: string;
fetchImpl?: typeof fetch;
}): Promise<OAuthTokens> {
const fetchImpl = args.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: "authorization_code",
code: args.code,
redirect_uri: args.redirectUri,
client_id: args.clientId,
code_verifier: args.verifier,
});
const res = await fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: body.toString(),
});
if (res.status === 400 || res.status === 401) {
// The authorization code is single-use and short-lived. A 400/401
// here almost always means it expired during the loopback wait or
// was already redeemed — surface an actionable message instead of
// a bare "HeyGen API error (400)".
const detail = (await safeText(res)) || res.statusText;
throw ErrRefreshFailed(
`authorization code rejected (${detail}); please run \`auth login\` again`,
);
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
return parseTokenResponse(await readJsonOrThrow(res));
}
/**
* Parse the RFC 6749 token response. Backend may also include
* `id_token` (OIDC) but we ignore that today.
*
* Throws `ErrInvalidTokenResponse` (an AuthError carrying
* REFRESH_FAILED code, so callers using `tryRefresh` consistently
* route the user to "log in again" instead of a generic API error)
* on shape failures.
*/
// fallow-ignore-next-line complexity
export function parseTokenResponse(payload: unknown): OAuthTokens {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw ErrRefreshFailed("token endpoint returned a non-object payload");
}
const obj = payload as Record<string, unknown>;
const accessToken = stringField(obj, "access_token");
if (!accessToken) {
throw ErrRefreshFailed("token endpoint did not return an access_token");
}
if (!isHeaderSafe(accessToken)) {
throw ErrRefreshFailed("access_token contains control characters");
}
const out: OAuthTokens = { access_token: accessToken };
const refreshToken = stringField(obj, "refresh_token");
if (refreshToken) {
if (!isHeaderSafe(refreshToken)) {
throw ErrRefreshFailed("refresh_token contains control characters");
}
out.refresh_token = refreshToken;
}
const tokenType = stringField(obj, "token_type");
if (tokenType) out.token_type = tokenType;
const scope = stringField(obj, "scope");
if (scope) out.scope = scope;
const expiresIn = numericField(obj, "expires_in");
if (expiresIn !== undefined) {
// Clamp to a sensible minimum so a misbehaving / clock-skewed
// server returning 0 or a negative value doesn't put expires_at in
// the past and cause the 401-refresh path to loop.
const clamped = Math.max(expiresIn, MIN_EXPIRES_IN_SECONDS);
out.expires_at = new Date(Date.now() + clamped * 1000).toISOString();
}
return out;
}
function stringField(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function numericField(obj: Record<string, unknown>, key: string): number | undefined {
const v = obj[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
}
/**
* Persist a new OAuth token set, always preserving a co-located
* `api_key`.
*
* `preserveMissing` controls how the new tokens combine with whatever
* OAuth block is already on disk:
* - `false` (fresh authorization-code login): overwrite the OAuth
* block entirely. A new interactive login is a clean session — it
* must NOT inherit the previous session's refresh_token, or a
* response that omits one would pair a new access token with a
* stale refresh token and break/misroute the next refresh.
* - `true` (refresh grant): keep the prior refresh_token / scope /
* token_type when the response omits them. RFC 6749 §6 lets the
* token endpoint skip refresh_token on a no-rotation refresh, and
* dropping it would brick future refreshes.
*/
async function persistOAuth(
tokens: OAuthTokens,
opts: { preserveMissing: boolean },
): Promise<void> {
let existing: Credentials = {};
try {
const { credentials } = await readStore();
existing = credentials;
} catch {
// Treat unreadable existing file as empty — we're about to
// overwrite the OAuth block anyway.
existing = {};
}
const oauth: OAuthTokens = opts.preserveMissing
? { ...existing.oauth, ...tokens }
: { ...tokens };
// Start from the existing record so co-located data survives: the
// api_key, the friendly-display `user` block, AND any unknown/foreign
// keys another CLI wrote (carried on a hidden symbol slot by spread).
// Only the `oauth` block is overwritten here.
await writeStore({ ...existing, oauth });
}
async function readJsonOrThrow(res: Response): Promise<unknown> {
try {
return await res.json();
} catch (err) {
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
}
}
async function safeText(res: Response): Promise<string> {
try {
// Token/revoke requests carry refresh_token, authorization code, and
// code_verifier in the form body; a server/proxy error page can echo
// request data back. Scrub before the text reaches any error message.
return scrubCredentials((await res.text()).slice(0, 500));
} catch {
return "";
}
}
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js";
describe("auth/paths", () => {
const original = process.env["HEYGEN_CONFIG_DIR"];
beforeEach(() => {
delete process.env["HEYGEN_CONFIG_DIR"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
else delete process.env["HEYGEN_CONFIG_DIR"];
});
it("defaults to ~/.heygen", () => {
expect(configDir()).toBe(join(homedir(), ".heygen"));
});
it("honors HEYGEN_CONFIG_DIR override", () => {
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
expect(configDir()).toBe("/tmp/some-test-dir");
expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME));
});
it("treats empty HEYGEN_CONFIG_DIR as unset", () => {
process.env["HEYGEN_CONFIG_DIR"] = "";
expect(configDir()).toBe(join(homedir(), ".heygen"));
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Filesystem layout for the shared HeyGen credential store. Mirrors
* `heygen-cli/internal/paths/paths.go` so both CLIs read the same file.
* `HEYGEN_CONFIG_DIR` overrides the directory.
*/
import { homedir } from "node:os";
import { join } from "node:path";
/**
* Filename for the credential store. Matches heygen-cli (no `.json`
* suffix) so a `~/.heygen/credentials` written by either CLI is
* readable by the other — see `heygen-cli/internal/auth/file_resolver.go`.
*/
export const CREDENTIAL_FILENAME = "credentials";
export function configDir(): string {
const override = process.env["HEYGEN_CONFIG_DIR"];
if (override && override.length > 0) return override;
return join(homedir(), ".heygen");
}
export function credentialPath(): string {
return join(configDir(), CREDENTIAL_FILENAME);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import { generatePkcePair, generateState } from "./pkce.js";
describe("auth/pkce", () => {
it("generates a verifier within RFC 7636 length bounds (43-128 chars)", () => {
const { verifier } = generatePkcePair();
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
});
it("uses only URL-safe base64 characters", () => {
const { verifier, challenge } = generatePkcePair();
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("challenge is the SHA-256 hash of the verifier, base64url-encoded", () => {
const { verifier, challenge } = generatePkcePair();
const expected = createHash("sha256")
.update(verifier)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(challenge).toBe(expected);
});
it("always uses S256 method", () => {
expect(generatePkcePair().method).toBe("S256");
});
it("generates distinct verifiers on each call", () => {
const a = generatePkcePair().verifier;
const b = generatePkcePair().verifier;
expect(a).not.toBe(b);
});
it("generates state values with sufficient entropy", () => {
const state = generateState();
expect(state.length).toBeGreaterThanOrEqual(40);
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
expect(state).not.toBe(generateState());
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers.
*
* The flow:
* 1. Generate a high-entropy `code_verifier` (43128 URL-safe chars).
* 2. Send `code_challenge = base64url(SHA-256(code_verifier))` to
* `/v1/oauth/authorize`.
* 3. After the user consents, exchange the returned `code` + the
* original `code_verifier` at `/v1/oauth/token`. The server hashes
* the verifier and rejects the exchange if it doesn't match the
* challenge that opened the flow.
*
* PKCE removes the need for a client secret — perfect for a CLI that
* can't keep one.
*/
import { createHash, randomBytes } from "node:crypto";
const VERIFIER_BYTES = 64; // 64 random bytes → 86 base64url chars (well within 43-128)
export interface PkcePair {
/** Sent on the exchange; kept secret by the CLI between the two HTTP hops. */
verifier: string;
/** Sent on the authorize URL. */
challenge: string;
/** Always "S256" for HeyGen's backend (`code_challenge_method`). */
method: "S256";
}
export function generatePkcePair(): PkcePair {
const verifier = base64UrlEncode(randomBytes(VERIFIER_BYTES));
const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
return { verifier, challenge, method: "S256" };
}
/** OAuth `state` parameter — a CSRF token bound to this flow. */
export function generateState(): string {
return base64UrlEncode(randomBytes(32));
}
function base64UrlEncode(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
+143
View File
@@ -0,0 +1,143 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js";
import { resolveCredential, tryResolveCredential } from "./resolver.js";
import { writeStore } from "./store.js";
describe("auth/resolver", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
let dir: string;
beforeEach(async () => {
fixture = await setupTempAuthEnv("hf-auth-resolve-");
dir = fixture.dir;
});
afterEach(async () => {
await fixture.restore();
});
it("prefers HEYGEN_API_KEY over everything else", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "env-key", source: "env" });
});
it("falls through to HYPERFRAMES_API_KEY", async () => {
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" });
});
it("returns file api_key when no env is set", async () => {
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") {
expect(r.key).toBe("file-key");
expect(r.source).toBe("file_json");
}
});
it("prefers fresh oauth over api_key", async () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "file-key",
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: future },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") {
expect(r.access_token).toBe("fresh-at");
// Fresh access_token does NOT need refresh, even with refresh_token present.
expect(r.refreshable).toBe(false);
}
});
it("oauth without expires_at is treated as fresh (refreshable=false)", async () => {
await writeStore({
oauth: { access_token: "at", refresh_token: "rt" },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") expect(r.refreshable).toBe(false);
});
it("marks expired-but-refreshable oauth as refreshable", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") expect(r.refreshable).toBe(true);
});
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "fallback",
oauth: { access_token: "stale-at", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") expect(r.key).toBe("fallback");
});
it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => {
process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1";
await expect(resolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "INVALID_STORE";
});
});
it("throws ErrNotConfigured when nothing is configured", async () => {
await expect(resolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "NOT_CONFIGURED";
});
});
it("identifies legacy plaintext file source", async () => {
const path = join(dir, "credentials");
await fs.writeFile(path, "hg_legacy_key", { mode: 0o600 });
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") {
expect(r.key).toBe("hg_legacy_key");
expect(r.source).toBe("file_legacy");
}
});
it("tryResolveCredential returns null when not configured", async () => {
expect(await tryResolveCredential()).toBeNull();
});
it("tryResolveCredential surfaces broken-file errors", async () => {
const path = join(dir, "credentials");
await fs.writeFile(path, "{not valid", { mode: 0o600 });
await expect(tryResolveCredential()).rejects.toSatisfy((err) => isAuthError(err));
});
it("uses injected now() for expiry decisions", async () => {
// expires_at is one hour ago in real time. Injecting `now` two
// hours in the past makes the token appear fresh (still valid for
// another hour), so the resolver should NOT mark it refreshable.
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
oauth: { access_token: "at", refresh_token: "rt", expires_at: oneHourAgo },
});
const r = await resolveCredential({
now: () => new Date(Date.now() - 2 * 60 * 60 * 1000),
});
expect(r.type).toBe("oauth");
if (r.type === "oauth") {
expect(r.access_token).toBe("at");
expect(r.refreshable).toBe(false);
}
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Chain resolver for HeyGen credentials.
*
* Priority — first non-empty wins:
* 1. `HEYGEN_API_KEY` env (matches heygen-cli)
* 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools)
* 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
*
* Absent sources fall through. A broken file (parse error, bad shape)
* surfaces immediately as `ErrInvalidStore` — silently falling back
* would mask user config bugs.
*
* Expiry policy: an OAuth access_token whose `expires_at` is in the
* past (60s skew) is considered expired. If a `refresh_token` is also
* present, callers can still use it via `refreshable: true`. Otherwise
* the api_key (if any) wins.
*/
import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js";
type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
interface ApiKeyCredential {
type: "api_key";
key: string;
source: CredentialSource;
}
interface OAuthCredential {
type: "oauth";
access_token: string;
refresh_token?: string;
expires_at?: Date;
scope?: string;
source: CredentialSource;
/** True when the access_token is expired but a refresh_token exists. */
refreshable: boolean;
}
export type ResolvedCredential = ApiKeyCredential | OAuthCredential;
const EXPIRY_SKEW_MS = 60 * 1000;
export interface ResolveOptions {
now?: () => Date;
}
export async function resolveCredential(opts: ResolveOptions = {}): Promise<ResolvedCredential> {
const now = (opts.now ?? (() => new Date()))();
const heygenEnv = process.env["HEYGEN_API_KEY"];
if (heygenEnv && heygenEnv.length > 0) {
if (!isHeaderSafe(heygenEnv)) {
throw ErrInvalidStore("HEYGEN_API_KEY contains control characters");
}
return { type: "api_key", key: heygenEnv, source: "env" };
}
const hfEnv = process.env["HYPERFRAMES_API_KEY"];
if (hfEnv && hfEnv.length > 0) {
if (!isHeaderSafe(hfEnv)) {
throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters");
}
return { type: "api_key", key: hfEnv, source: "env_alias" };
}
const { credentials, source } = await readStore();
if (source === "absent") throw ErrNotConfigured();
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
if (credentials.oauth) {
const oauth = pickOAuth(credentials.oauth, now, fileSource);
if (oauth) return oauth;
}
if (credentials.api_key) {
return { type: "api_key", key: credentials.api_key, source: fileSource };
}
throw ErrNotConfigured();
}
/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
export async function tryResolveCredential(
opts: ResolveOptions = {},
): Promise<ResolvedCredential | null> {
try {
return await resolveCredential(opts);
} catch (err) {
if (isAuthError(err) && err.code === "NOT_CONFIGURED") {
return null;
}
throw err;
}
}
function pickOAuth(
tokens: NonNullable<Awaited<ReturnType<typeof readStore>>["credentials"]["oauth"]>,
now: Date,
source: CredentialSource,
): OAuthCredential | null {
const expiresAt = parseDate(tokens.expires_at);
const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime();
if (expired && !tokens.refresh_token) return null;
const out: OAuthCredential = {
type: "oauth",
access_token: tokens.access_token,
source,
refreshable: expired && tokens.refresh_token !== undefined,
};
if (tokens.refresh_token) out.refresh_token = tokens.refresh_token;
if (expiresAt) out.expires_at = expiresAt;
if (tokens.scope) out.scope = tokens.scope;
return out;
}
function parseDate(s: string | undefined): Date | undefined {
if (!s) return undefined;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? undefined : d;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { scrubCredentials } from "./scrub.js";
describe("auth/scrub", () => {
it("redacts HeyGen API keys", () => {
const out = scrubCredentials("rejected key hg_supersecret_abc123 from header");
expect(out).not.toContain("hg_supersecret_abc123");
expect(out).toContain("hg_<redacted>");
});
it("redacts sk_V2_ keys echoed inline (not just after Authorization:)", () => {
// Real HeyGen keys are `sk_V2_…`. A token echoed in a stack trace or
// JSON payload — without a header-name anchor — must still be
// redacted, per the threat model the scrubber is written for.
const out = scrubCredentials(
"error: token sk_V2_hgu_supersecret_real999 was rejected upstream",
);
expect(out).not.toContain("sk_V2_hgu_supersecret_real999");
expect(out).toContain("sk_<redacted>");
});
it("redacts authorization / x-api-key header lines", () => {
const out = scrubCredentials("x-api-key: hg_zzz999\nauthorization: Bearer abc");
expect(out).not.toContain("hg_zzz999");
expect(out).not.toContain("Bearer abc");
expect(out).toContain("x-api-key: <redacted>");
expect(out).toContain("authorization: <redacted>");
});
it("redacts the full Authorization: Bearer value, not just the scheme", () => {
const out = scrubCredentials("echoed Authorization: Bearer at_opaque_secret_999\nnext line");
// The opaque token after `Bearer` must be gone.
expect(out).not.toContain("at_opaque_secret_999");
expect(out).not.toContain("Bearer at_opaque_secret_999");
expect(out).toContain("Authorization: <redacted>");
// Redaction stops at the line break — unrelated following lines survive.
expect(out).toContain("next line");
});
it("redacts JWT-shaped tokens", () => {
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcDEF_123-xyz";
const out = scrubCredentials(`token was ${jwt}`);
expect(out).not.toContain(jwt);
expect(out).toContain("<jwt-redacted>");
});
it("redacts form-encoded OAuth secrets (refresh_token, code, code_verifier)", () => {
const body =
"error: grant_type=refresh_token&refresh_token=rt_secret_abc&code=auth_code_xyz&code_verifier=verif_123";
const out = scrubCredentials(body);
expect(out).not.toContain("rt_secret_abc");
expect(out).not.toContain("auth_code_xyz");
expect(out).not.toContain("verif_123");
expect(out).toContain("refresh_token=<redacted>");
expect(out).toContain("code=<redacted>");
expect(out).toContain("code_verifier=<redacted>");
// grant_type is not a secret — keep it for debuggability.
expect(out).toContain("grant_type=refresh_token");
});
it("redacts JSON-encoded OAuth secrets", () => {
const body = '{"error":"invalid","access_token":"at_leak","refresh_token":"rt_leak"}';
const out = scrubCredentials(body);
expect(out).not.toContain("at_leak");
expect(out).not.toContain("rt_leak");
expect(out).toContain('"access_token":"<redacted>"');
expect(out).toContain('"refresh_token":"<redacted>"');
});
it("does not over-redact unrelated text", () => {
const out = scrubCredentials("error_code=42 the request failed validation");
expect(out).toBe("error_code=42 the request failed validation");
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Redact credential-shaped substrings from error bodies before they
* reach user-facing messages, `--json` output, or logs.
*
* Both the `/v3/users/me` client and the OAuth token/revoke endpoints
* surface upstream error bodies. A misbehaving server or proxy can echo
* request data (API keys, OAuth `refresh_token` / `code` / `code_verifier`,
* bearer tokens, JWTs) into those bodies — this scrubber makes sure none
* of it lands in scrollback or CI logs.
*/
// Both HeyGen key prefixes: legacy `hg_…` and current `sk_V2_…` (plus
// any `sk_<segment>_…` partner format). A bare key echoed inline in an
// error body — without an Authorization:/x-api-key: header anchor —
// must still be redacted, so we match the prefix directly.
const HEYGEN_KEY = /\b(hg|sk)_[A-Za-z0-9_-]{4,}/g;
// Redact the ENTIRE header value to end-of-line. `Authorization: Bearer
// <token>` is two whitespace-separated words, so a `\S+` would stop after
// the scheme and leave the opaque token exposed.
const HEADER_LINE = /(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
// Sensitive OAuth/credential field names. Matched both as
// form-encoded (`name=value`) and JSON (`"name":"value"`).
const SECRET_FIELDS = [
"access_token",
"refresh_token",
"code_verifier",
"client_secret",
"id_token",
"token",
"code",
];
const FORM_FIELD = new RegExp(`\\b(${SECRET_FIELDS.join("|")})=[^&\\s"']+`, "gi");
const JSON_FIELD = new RegExp(`("(?:${SECRET_FIELDS.join("|")})"\\s*:\\s*)"[^"]*"`, "gi");
export function scrubCredentials(s: string): string {
return s
.replace(HEYGEN_KEY, "$1_<redacted>")
.replace(HEADER_LINE, "$1: <redacted>")
.replace(JWT, "<jwt-redacted>")
.replace(FORM_FIELD, "$1=<redacted>")
.replace(JSON_FIELD, '$1"<redacted>"');
}
+458
View File
@@ -0,0 +1,458 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isAuthError } from "./errors.js";
import {
clearOAuth,
deleteStore,
hasPreservedUnknownData,
readStore,
writeStore,
type Credentials,
} from "./store.js";
async function makeTmpDir(): Promise<string> {
return fs.mkdtemp(join(tmpdir(), "hf-auth-store-"));
}
// POSIX file modes don't apply on Windows — `fs.chmod` only toggles the
// read-only bit there, so `stat.mode & 0o777` reports 0o666/0o444
// regardless of what we requested. Skip the mode assertions on win32;
// the 0600/0700 hardening is a Unix concern.
const IS_POSIX = process.platform !== "win32";
describe("auth/store", () => {
let dir: string;
let path: string;
beforeEach(async () => {
dir = await makeTmpDir();
path = join(dir, "credentials");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("returns absent when the file does not exist", async () => {
const result = await readStore(path);
expect(result).toEqual({ credentials: {}, source: "absent" });
});
it("round-trips api_key only", async () => {
const creds: Credentials = { api_key: "hg_test_abc" };
await writeStore(creds, path);
const result = await readStore(path);
expect(result.source).toBe("file_json");
expect(result.credentials).toEqual(creds);
});
it("round-trips oauth tokens", async () => {
const creds: Credentials = {
oauth: {
access_token: "at_123",
refresh_token: "rt_456",
expires_at: "2026-06-25T12:00:00.000Z",
scope: "openid profile",
token_type: "Bearer",
},
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials).toEqual(creds);
});
it("round-trips both api_key and oauth", async () => {
const creds: Credentials = {
api_key: "hg_test_abc",
oauth: { access_token: "at_123" },
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials.api_key).toBe("hg_test_abc");
expect(result.credentials.oauth?.access_token).toBe("at_123");
});
it("reads legacy one-line plaintext format", async () => {
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("file_legacy");
expect(result.credentials.api_key).toBe("hg_legacy_key");
});
it("treats empty file as absent", async () => {
await fs.writeFile(path, "", { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("absent");
});
it("throws ErrInvalidStore on garbage JSON", async () => {
await fs.writeFile(path, "{not valid json", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("throws ErrInvalidStore on multi-line non-JSON content", async () => {
await fs.writeFile(path, "not\na\nkey", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it.skipIf(!IS_POSIX)("writes file 0600 and dir 0700", async () => {
const nested = join(dir, "sub", "deeper");
const p = join(nested, "credentials");
await writeStore({ api_key: "hg_x" }, p);
expect((await fs.stat(p)).mode & 0o777).toBe(0o600);
expect((await fs.stat(nested)).mode & 0o777).toBe(0o700);
});
it("preserves content across overwrites", async () => {
await writeStore({ api_key: "first" }, path);
await writeStore({ api_key: "second" }, path);
if (IS_POSIX) {
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
}
const result = await readStore(path);
expect(result.credentials.api_key).toBe("second");
});
it("rejects empty-string api_key", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "" }), { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("rejects api_key with CR/LF (header-injection guard)", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x\r\nX-Evil: foo" }), { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("strips oauth fields containing CR/LF rather than crashing later", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: {
access_token: "good_at",
refresh_token: "bad_rt\r\nX-Smuggle: 1",
},
}),
{ mode: 0o600 },
);
const result = await readStore(path);
expect(result.credentials.oauth?.access_token).toBe("good_at");
expect(result.credentials.oauth?.refresh_token).toBeUndefined();
});
it("rejects access_token containing CR/LF (header-injection guard)", async () => {
await fs.writeFile(path, JSON.stringify({ oauth: { access_token: "at\r\nX-Evil: 1" } }), {
mode: 0o600,
});
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("accepts a legacy plaintext key of any HeyGen key format", async () => {
// Real HeyGen keys come in multiple formats (`sk_V2_…`, `hg_…`,
// partner keys, etc.). The CLI doesn't shape-check — the backend
// does. Any single-line printable non-empty value is accepted as
// a legacy key here; the next /v3/users/me call decides validity.
await fs.writeFile(path, "sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN", {
mode: 0o600,
});
const result = await readStore(path);
expect(result.source).toBe("file_legacy");
expect(result.credentials.api_key).toBe(
"sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN",
);
});
it("still rejects plaintext that contains a space (not a credential shape)", async () => {
await fs.writeFile(path, "hello world this is not a key", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("still rejects too-short plaintext", async () => {
await fs.writeFile(path, "tiny", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("rejects oauth without access_token", async () => {
await fs.writeFile(path, JSON.stringify({ oauth: { refresh_token: "rt" } }), {
mode: 0o600,
});
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("exposes only the typed surface for known keys (unknown keys hidden from callers)", async () => {
// The typed `credentials` view shows only the modelled keys —
// unknown/foreign keys are captured in a hidden (symbol-keyed)
// passthrough slot, not the enumerable surface, so callers can't
// accidentally read them. Round-trip preservation is covered below.
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: { stuff: 1 } }), {
mode: 0o600,
});
const result = await readStore(path);
expect(Object.keys(result.credentials)).toEqual(["api_key"]);
expect(result.credentials.api_key).toBe("hg_x");
});
// --- Cross-CLI forward compatibility: unknown-field preservation. ---
// The credentials file is SHARED with the Go `heygen` CLI. If this CLI
// strips keys it doesn't model when it writes the file back, it
// silently destroys the other CLI's data (and vice versa). The writer
// MUST round-trip unknown fields untouched.
it("preserves an unknown TOP-LEVEL key across a read → write round-trip", async () => {
// Simulate a file another CLI version wrote with a future key.
await fs.writeFile(
path,
JSON.stringify({ api_key: "hg_x", future_field: { nested: [1, 2], flag: true } }),
{ mode: 0o600 },
);
// Read it, then write back a typed update (here: just the api_key it
// surfaced). The unknown key must survive.
const { credentials } = await readStore(path);
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_x");
expect(onDisk.future_field).toEqual({ nested: [1, 2], flag: true });
});
it("preserves the heygen-cli `user` block when this CLI rewrites only the credential", async () => {
// The exact cross-CLI data-loss scenario: heygen-cli wrote a `user`
// block; hyperframes-cli updates the api_key and must not drop it.
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_old",
user: { email: "jane@example.com", first_name: "Jane", last_name: "Doe", username: "jdoe" },
}),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
credentials.api_key = "hg_new";
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_new");
expect(onDisk.user).toEqual({
email: "jane@example.com",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
});
});
it("preserves an unknown key INSIDE the oauth sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "at_1", id_token: "future_id_token_value" },
}),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth.access_token).toBe("at_1");
expect(onDisk.oauth.id_token).toBe("future_id_token_value");
});
it("preserves an unknown key INSIDE the user sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_x",
user: { email: "u@example.com", avatar_url: "https://cdn/x.png" },
}),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user.email).toBe("u@example.com");
expect(onDisk.user.avatar_url).toBe("https://cdn/x.png");
});
it("round-trips the user block (schema + omitempty: empty fields are not written)", async () => {
const creds: Credentials = {
api_key: "hg_x",
user: { email: "u@example.com", username: "u" },
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials.user).toEqual({ email: "u@example.com", username: "u" });
// omitempty: only the populated fields appear on disk — no empty
// first_name / last_name strings littering the file.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user).toEqual({ email: "u@example.com", username: "u" });
expect(Object.keys(onDisk.user)).toEqual(["email", "username"]);
});
it('omits an all-empty user block entirely (no `"user": {}` litter)', async () => {
await writeStore({ api_key: "hg_x", user: {} }, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user).toBeUndefined();
expect(onDisk.api_key).toBe("hg_x");
});
it("backwards-compat: a legacy file WITHOUT a user block parses with user undefined", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_legacy" }), { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("file_json");
expect(result.credentials.api_key).toBe("hg_legacy");
expect(result.credentials.user).toBeUndefined();
});
it("ignores a malformed user sub-field rather than rejecting the whole file", async () => {
// The user block is additive metadata — a junk sub-field must never
// block resolving a perfectly good api_key. Non-string fields are
// dropped; the credential survives.
await fs.writeFile(
path,
JSON.stringify({ api_key: "hg_x", user: { email: "u@example.com", first_name: 12345 } }),
{ mode: 0o600 },
);
const result = await readStore(path);
expect(result.credentials.api_key).toBe("hg_x");
expect(result.credentials.user).toEqual({ email: "u@example.com" });
});
it("deleteStore is idempotent", async () => {
await writeStore({ api_key: "hg_x" }, path);
await deleteStore(path);
await deleteStore(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearOAuth removes only the oauth field", async () => {
await writeStore({ api_key: "hg_keep", oauth: { access_token: "drop_me" } }, path);
await clearOAuth(path);
const result = await readStore(path);
expect(result.credentials.oauth).toBeUndefined();
expect(result.credentials.api_key).toBe("hg_keep");
});
it("clearOAuth removes the whole file when no api_key remains", async () => {
await writeStore({ oauth: { access_token: "only" } }, path);
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearOAuth keeps the user block (and unknown keys) when an api_key survives", async () => {
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_keep",
oauth: { access_token: "drop_me" },
user: { email: "u@example.com" },
future_field: 1,
}),
{ mode: 0o600 },
);
await clearOAuth(path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toBeUndefined();
expect(onDisk.api_key).toBe("hg_keep");
expect(onDisk.user).toEqual({ email: "u@example.com" });
expect(onDisk.future_field).toBe(1);
});
it("clearOAuth is a no-op when file is absent", async () => {
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
// --- Destructive paths must not clobber preserved unknown data. ---
// When clearing the only known credential would otherwise delete the
// file, a surviving unknown/foreign top-level key (a future credential
// another CLI owns) must keep the file alive — deleting would clobber
// exactly the cross-CLI data this machinery exists to preserve.
it("clearOAuth keeps the file (writing the unknown bag) when no api_key but a foreign top-level key survives", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "drop_me" },
future_credential: { token: "owned_by_other_cli" },
}),
{ mode: 0o600 },
);
await clearOAuth(path);
// File must still exist and carry the foreign key.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toBeUndefined();
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("clearOAuth keeps the file when no api_key but a foreign key survives inside the user block", async () => {
// The user block has no known friendly fields, only a foreign sub-key
// — the block itself survives the oauth clear, so its unknown data
// must too.
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "drop_me" },
user: { external_org_id: "org_123" },
}),
{ mode: 0o600 },
);
await clearOAuth(path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toBeUndefined();
expect(onDisk.user).toEqual({ external_org_id: "org_123" });
});
it("clearOAuth still deletes the file when only a known (empty-after-clear) surface remains", async () => {
// No api_key, no foreign data — just the oauth block being cleared.
// Nothing worth preserving, so the file goes.
await writeStore({ oauth: { access_token: "only" } }, path);
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
describe("hasPreservedUnknownData", () => {
it("false for an empty record", () => {
expect(hasPreservedUnknownData({})).toBe(false);
});
it("false for a record with only known fields", () => {
expect(
hasPreservedUnknownData({
api_key: "hg_x",
oauth: { access_token: "at" },
user: { email: "u@example.com" },
}),
).toBe(false);
});
it("true when a top-level unknown key was captured at read time", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: 1 }), {
mode: 0o600,
});
const { credentials } = await readStore(path);
expect(hasPreservedUnknownData(credentials)).toBe(true);
});
it("true when an unknown key was captured inside the oauth sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({ oauth: { access_token: "at", id_token: "future" } }),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
expect(hasPreservedUnknownData(credentials)).toBe(true);
});
it("true when an unknown key was captured inside the user sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({ api_key: "hg_x", user: { email: "u@example.com", avatar_url: "x" } }),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
expect(hasPreservedUnknownData(credentials)).toBe(true);
});
});
});
+464
View File
@@ -0,0 +1,464 @@
/**
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
* no `.json` extension — the path matches heygen-cli).
*
* Current format:
* {
* "api_key": "hg_...",
* "oauth": {
* "access_token": "...",
* "refresh_token": "...",
* "expires_at": "<ISO-8601 UTC>",
* "scope": "openid profile",
* "token_type": "Bearer"
* },
* "user": {
* "email": "...",
* "first_name": "...",
* "last_name": "...",
* "username": "..."
* }
* }
*
* Legacy: a single-line plaintext API key (the format heygen-cli has
* written historically). If `JSON.parse` rejects the file, we treat the
* trimmed contents as an API key; the next write upgrades to JSON.
*
* Writes go to a temp file + rename, 0600 mode, parent dir 0700.
*
* Cross-CLI forward compatibility: this file is SHARED with the Go
* `heygen` CLI (and any future tool). Either CLI may write keys this
* version doesn't model yet. To avoid one CLI silently clobbering the
* other's data on round-trip, the reader stashes every unrecognized
* top-level key (and every unrecognized key inside the `oauth` / `user`
* sub-objects) into a hidden passthrough bag, and the writer re-emits
* them verbatim. Known fields are still strictly validated; the
* passthrough is purely additive and never feeds an HTTP header.
*
* The same contract binds the destructive paths (`clearOAuth`,
* `clearUserInfo`, and the failed `auth login --api-key` rollback): when
* removing a credential would leave the file with no known credential but
* a surviving unknown/foreign top-level (or user-block) key, they write
* the credential-less remnant rather than deleting the file — see
* `hasPreservedUnknownData`. Deleting there would clobber exactly the
* cross-CLI data this machinery exists to preserve.
*/
import { promises as fs } from "node:fs";
import { dirname } from "node:path";
import { credentialPath } from "./paths.js";
import { ErrInvalidStore } from "./errors.js";
const FILE_MODE = 0o600;
const DIR_MODE = 0o700;
/**
* Symbol-keyed slot holding the raw JSON of any keys this CLI version
* doesn't model, captured at read time and re-emitted verbatim at write
* time. A symbol (rather than a string key) keeps it off the typed
* surface so callers can't accidentally read/write it, and `Object.keys`
* / `JSON.stringify` skip it. See the module header for the rationale.
*/
const UNKNOWN = Symbol("hf.credentials.unknownFields");
/** Keys this CLI version models at the top level of the credentials file. */
const KNOWN_ROOT_KEYS = new Set(["api_key", "oauth", "user"]);
/** Keys this CLI version models inside the `oauth` sub-object. */
const KNOWN_OAUTH_KEYS = new Set([
"access_token",
"refresh_token",
"expires_at",
"scope",
"token_type",
]);
/** Keys this CLI version models inside the `user` sub-object. */
const KNOWN_USER_KEYS = new Set(["email", "first_name", "last_name", "username"]);
export interface OAuthTokens {
access_token: string;
refresh_token?: string;
/** ISO-8601 UTC. */
expires_at?: string;
scope?: string;
token_type?: string;
/** Unknown/future keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
/**
* Friendly-display metadata captured at login time from `/v3/users/me`.
* NOT a credential — additive identity info persisted alongside the
* credential so `auth status` can show "Logged in as ..." without
* re-hitting the API. All fields optional; a file with no `user` block
* (a pre-this-change login) is fully backwards-compatible. Mirrors the
* `user` block heygen-cli writes — see `internal/auth/user_store.go`.
*/
export interface StoredUserInfo {
email?: string;
first_name?: string;
last_name?: string;
username?: string;
/** Unknown/future keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
export interface Credentials {
api_key?: string;
oauth?: OAuthTokens;
user?: StoredUserInfo;
/** Unknown/future top-level keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
export type StoreSource = "file_json" | "file_legacy" | "absent";
export interface ReadResult {
credentials: Credentials;
source: StoreSource;
}
export async function readStore(path = credentialPath()): Promise<ReadResult> {
let raw: string;
try {
raw = await fs.readFile(path, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return { credentials: {}, source: "absent" };
}
throw ErrInvalidStore(`unable to read ${path}: ${(err as Error).message}`);
}
const trimmed = raw.trim();
if (trimmed.length === 0) return { credentials: {}, source: "absent" };
if (trimmed.startsWith("{")) {
return { credentials: parseJsonStore(trimmed), source: "file_json" };
}
if (looksLikeApiKey(trimmed)) {
return { credentials: { api_key: trimmed }, source: "file_legacy" };
}
throw ErrInvalidStore("file is not JSON and does not look like a plain API key");
}
export async function writeStore(credentials: Credentials, path = credentialPath()): Promise<void> {
await ensureDir(dirname(path));
const body = JSON.stringify(serializeCredentials(credentials), null, 2);
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, `${body}\n`, { mode: FILE_MODE, encoding: "utf8" });
// `mode` on `writeFile` is masked by umask and only applies on file
// creation — explicit chmod is the only reliable way to land on 0600.
// `rename` moves the (already-0600) tmp inode over the destination,
// so the final file carries the tmp's mode; no post-rename chmod
// needed even when overwriting a looser-permissioned file.
await fs.chmod(tmp, FILE_MODE);
await fs.rename(tmp, path);
}
export async function deleteStore(path = credentialPath()): Promise<void> {
try {
await fs.unlink(path);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
}
/**
* True when `credentials` carries any unrecognized/foreign data captured
* on a hidden passthrough slot — either a top-level unknown key, or an
* unknown key inside the `oauth` / `user` sub-objects.
*
* The cleanup / rollback paths (`clearOAuth`, `clearUserInfo`, the failed
* `auth login --api-key` rollback) use this to decide between deleting the
* file and writing a credential-less remnant. When no known credential
* survives BUT foreign data does, that data may be a future credential or
* metadata key another CLI owns — deleting the file would clobber exactly
* what the cross-CLI forward-compatibility contract promises to preserve.
* So those paths write the remaining record (carrying the unknown bag)
* instead of deleting. Only when nothing worth preserving remains do they
* delete.
*/
export function hasPreservedUnknownData(credentials: Credentials): boolean {
if (hasUnknownBag(credentials[UNKNOWN])) return true;
if (hasUnknownBag(credentials.oauth?.[UNKNOWN])) return true;
if (hasUnknownBag(credentials.user?.[UNKNOWN])) return true;
return false;
}
function hasUnknownBag(bag: Record<string, unknown> | undefined): boolean {
return bag !== undefined && Object.keys(bag).length > 0;
}
/** Remove only the `oauth` block. Used by `auth logout --keep-api-key`. */
export async function clearOAuth(path = credentialPath()): Promise<void> {
const { credentials, source } = await readStore(path);
if (source === "absent" || !credentials.oauth) return;
// Drop oauth, keep everything else (api_key, the friendly-display
// user block, and any unknown/foreign keys) so a logout that only
// clears the OAuth session doesn't silently wipe co-located data.
const next: Credentials = { ...credentials };
delete next.oauth;
if (!next.api_key && !hasPreservedUnknownData(next)) {
// Nothing worth preserving survives. The leftover user block had no
// friendly fields and there's no foreign/unknown data to round-trip,
// so this is orphaned metadata with no credential to attach to — drop
// the file. (A surviving top-level / user-block unknown key, by
// contrast, may be a future credential another CLI owns, so we keep
// the file in that case.)
await deleteStore(path);
return;
}
await writeStore(next, path);
}
async function ensureDir(dir: string): Promise<void> {
try {
const stat = await fs.stat(dir);
if (!stat.isDirectory()) {
throw ErrInvalidStore(`${dir} exists and is not a directory`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
await fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
}
try {
await fs.chmod(dir, DIR_MODE);
} catch {
/* perm-less filesystems are fine */
}
}
function parseJsonStore(text: string): Credentials {
const obj = parseJsonObject(text, "credential file root");
const out: Credentials = {};
const apiKey = pickRequiredStringOrAbsent(obj, "api_key", "api_key");
if (apiKey !== undefined) {
if (!isHeaderSafe(apiKey)) {
throw ErrInvalidStore("api_key must not contain control characters");
}
out.api_key = apiKey;
}
if (obj["oauth"] !== undefined && obj["oauth"] !== null) {
out.oauth = parseOAuth(obj["oauth"]);
}
if (obj["user"] !== undefined && obj["user"] !== null) {
out.user = parseUser(obj["user"]);
}
// Capture any top-level keys this CLI version doesn't model so the
// next write round-trips them instead of dropping another CLI's data.
const unknownRoot = collectUnknown(obj, KNOWN_ROOT_KEYS);
if (unknownRoot) out[UNKNOWN] = unknownRoot;
return out;
}
/** Optional-field picker variants used by the data-driven parsers. */
type FieldPicker = "header_safe" | "non_empty";
const PICKERS: Record<
FieldPicker,
(obj: Record<string, unknown>, key: string) => string | undefined
> = {
header_safe: pickHeaderSafeString,
non_empty: pickNonEmptyString,
};
/**
* Copy each `[field, picker]` from `obj` onto `out` when the picker
* yields a value. Data-driven so the optional-field handling stays a
* single loop instead of a long if-chain per parser. `out` is written
* through an index cast — the `spec` field names are the contract that
* keeps the assignments type-correct at the call site.
*/
function assignOptionalStrings(
out: object,
obj: Record<string, unknown>,
spec: readonly [string, FieldPicker][],
): void {
const target = out as Record<string, unknown>;
for (const [field, picker] of spec) {
const v = PICKERS[picker](obj, field);
if (v) target[field] = v;
}
}
const OAUTH_OPTIONAL: readonly [string, FieldPicker][] = [
["refresh_token", "header_safe"],
["expires_at", "non_empty"],
["scope", "non_empty"],
["token_type", "non_empty"],
];
function parseOAuth(raw: unknown): OAuthTokens {
const obj = asJsonObject(raw, "oauth");
const accessToken = pickHeaderSafeString(obj, "access_token");
if (!accessToken) {
throw ErrInvalidStore("oauth.access_token must be a non-empty string with no control chars");
}
const out: OAuthTokens = { access_token: accessToken };
assignOptionalStrings(out, obj, OAUTH_OPTIONAL);
const unknownOAuth = collectUnknown(obj, KNOWN_OAUTH_KEYS);
if (unknownOAuth) out[UNKNOWN] = unknownOAuth;
return out;
}
const USER_OPTIONAL: readonly [string, FieldPicker][] = [
["email", "non_empty"],
["first_name", "non_empty"],
["last_name", "non_empty"],
["username", "non_empty"],
];
/**
* Parse the friendly-display `user` block. Every field is optional and
* lenient — a wrong-typed or empty value is simply skipped rather than
* rejected, because this is additive METADATA, not a credential, and a
* malformed sub-field must never block resolving a perfectly good
* api_key / oauth token. (Contrast with `parseOAuth`, where a missing
* access_token is a hard error.) Unknown keys round-trip.
*/
function parseUser(raw: unknown): StoredUserInfo {
const obj = asJsonObject(raw, "user");
const out: StoredUserInfo = {};
assignOptionalStrings(out, obj, USER_OPTIONAL);
const unknownUser = collectUnknown(obj, KNOWN_USER_KEYS);
if (unknownUser) out[UNKNOWN] = unknownUser;
return out;
}
/** Narrow `raw` to a plain JSON object or throw a labelled error. */
function asJsonObject(raw: unknown, label: string): Record<string, unknown> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw ErrInvalidStore(`${label} must be a JSON object`);
}
return raw as Record<string, unknown>;
}
/**
* Return a shallow copy of every entry in `obj` whose key is not in
* `known`, or `undefined` when there are none. Used to capture
* unrecognized JSON for verbatim re-emission on the next write.
*/
function collectUnknown(
obj: Record<string, unknown>,
known: Set<string>,
): Record<string, unknown> | undefined {
let bag: Record<string, unknown> | undefined;
for (const key of Object.keys(obj)) {
if (known.has(key)) continue;
(bag ??= {})[key] = obj[key];
}
return bag;
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch (err) {
throw ErrInvalidStore(`invalid JSON: ${(err as Error).message}`);
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw ErrInvalidStore(`${label} must be a JSON object`);
}
return raw as Record<string, unknown>;
}
function pickNonEmptyString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
/** Like `pickNonEmptyString` but rejects values containing control chars. */
function pickHeaderSafeString(obj: Record<string, unknown>, key: string): string | undefined {
const v = pickNonEmptyString(obj, key);
return v !== undefined && isHeaderSafe(v) ? v : undefined;
}
/**
* Header-safety check for credential strings: reject any string with
* CR, LF, NUL, or other C0 control characters. Without this, a
* malicious credentials.json could smuggle extra request headers via
* `Authorization` / `x-api-key` (RFC 7230 header injection).
*/
export function isHeaderSafe(s: string): boolean {
// Reject U+0000-U+001F (C0 controls) and U+007F (DEL) — bytes that
// aren't allowed in HTTP header values. Using charCodeAt avoids
// embedding control characters in regex source (lint requirement).
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x20 || c === 0x7f) return false;
}
return true;
}
/**
* Strict variant: returns the string when present and non-empty,
* `undefined` when the key is absent or null, and throws when the
* field is present-but-invalid (wrong type or empty string).
*/
function pickRequiredStringOrAbsent(
obj: Record<string, unknown>,
key: string,
errorLabel: string,
): string | undefined {
const v = obj[key];
if (v === undefined || v === null) return undefined;
if (typeof v !== "string" || v.length === 0) {
throw ErrInvalidStore(`${errorLabel} must be a non-empty string`);
}
return v;
}
function serializeCredentials(c: Credentials): Record<string, unknown> {
// Re-emit unrecognized top-level keys first so the known fields below
// are authoritative (collectUnknown already excludes known keys, so
// there's no real collision — this is belt-and-suspenders).
const out: Record<string, unknown> = { ...(c[UNKNOWN] ?? {}) };
if (c.api_key) out["api_key"] = c.api_key;
if (c.oauth) out["oauth"] = serializeOAuth(c.oauth);
if (c.user) {
const user = serializeUser(c.user);
// Omit an all-empty user block entirely (no empty `"user": {}` litter).
if (Object.keys(user).length > 0) out["user"] = user;
}
return out;
}
function serializeOAuth(o: OAuthTokens): Record<string, unknown> {
const oauth: Record<string, unknown> = { ...(o[UNKNOWN] ?? {}) };
oauth["access_token"] = o.access_token;
if (o.refresh_token) oauth["refresh_token"] = o.refresh_token;
if (o.expires_at) oauth["expires_at"] = o.expires_at;
if (o.scope) oauth["scope"] = o.scope;
if (o.token_type) oauth["token_type"] = o.token_type;
return oauth;
}
function serializeUser(u: StoredUserInfo): Record<string, unknown> {
const user: Record<string, unknown> = { ...(u[UNKNOWN] ?? {}) };
if (u.email) user["email"] = u.email;
if (u.first_name) user["first_name"] = u.first_name;
if (u.last_name) user["last_name"] = u.last_name;
if (u.username) user["username"] = u.username;
return user;
}
/**
* Legacy-plaintext heuristic. HeyGen API keys come in multiple formats
* (`sk_V2_…`, historic `hg_…`, partner keys, etc.) and the CLI should
* NOT shape-check them — the backend's `/v3/users/me` is the source of
* truth and the existing `auth login` rollback handles bad keys cleanly.
* We only require: a single line, printable, of reasonable length, and
* header-safe (no CR/LF). JSON files are detected separately by the
* leading `{`, so this path can't swallow a JSON fragment.
*/
function looksLikeApiKey(s: string): boolean {
if (s.length < 8) return false;
if (!isHeaderSafe(s)) return false;
// Single line of printable ASCII (excluding space, since real keys
// don't contain spaces — a space-bearing blob is almost certainly
// not a credential).
return /^[!-~]+$/.test(s);
}
+167
View File
@@ -0,0 +1,167 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readStore, writeStore } from "./store.js";
import {
clearUserInfo,
isUserInfoEmpty,
loadUserInfo,
saveUserInfo,
userDisplayName,
type StoredUserInfo,
} from "./user.js";
async function makeTmpDir(): Promise<string> {
return fs.mkdtemp(join(tmpdir(), "hf-auth-user-"));
}
describe("auth/user — userDisplayName priority", () => {
const cases: { name: string; ui: StoredUserInfo; want: string | undefined }[] = [
{
name: "email wins over everything",
ui: { email: "u@example.com", first_name: "Jane", last_name: "Doe", username: "jdoe" },
want: "u@example.com",
},
{
name: "no email → first last",
ui: { first_name: "Jane", last_name: "Doe", username: "jdoe" },
want: "Jane Doe",
},
{ name: "only first name", ui: { first_name: "Jane", username: "jdoe" }, want: "Jane" },
{ name: "only last name", ui: { last_name: "Doe", username: "jdoe" }, want: "Doe" },
{ name: "only username", ui: { username: "jdoe" }, want: "jdoe" },
{ name: "all empty → undefined", ui: {}, want: undefined },
];
for (const tc of cases) {
it(tc.name, () => {
expect(userDisplayName(tc.ui)).toBe(tc.want);
});
}
});
describe("auth/user — isUserInfoEmpty", () => {
it("true for an all-empty block", () => {
expect(isUserInfoEmpty({})).toBe(true);
});
it("false when any field is set", () => {
expect(isUserInfoEmpty({ email: "u@example.com" })).toBe(false);
expect(isUserInfoEmpty({ username: "u" })).toBe(false);
expect(isUserInfoEmpty({ first_name: "J" })).toBe(false);
expect(isUserInfoEmpty({ last_name: "D" })).toBe(false);
});
});
describe("auth/user — save / load / clear", () => {
let dir: string;
let path: string;
beforeEach(async () => {
dir = await makeTmpDir();
path = join(dir, "credentials");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("round-trips a user block through the credentials file", async () => {
await writeStore({ api_key: "hg_x" }, path);
const ui: StoredUserInfo = {
email: "u@example.com",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
};
await saveUserInfo(ui, path);
expect(await loadUserInfo(path)).toEqual(ui);
});
it("saveUserInfo preserves a co-located api_key", async () => {
await writeStore({ api_key: "hg_keep" }, path);
await saveUserInfo({ email: "u@example.com" }, path);
const { credentials } = await readStore(path);
expect(credentials.api_key).toBe("hg_keep");
expect(credentials.user).toEqual({ email: "u@example.com" });
});
it("saveUserInfo upgrades a legacy plaintext file to JSON with the user block", async () => {
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
await saveUserInfo({ email: "u@example.com" }, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_legacy_key");
expect(onDisk.user).toEqual({ email: "u@example.com" });
});
it("saveUserInfo with an empty block is a no-op (does not blank an existing block)", async () => {
await writeStore({ api_key: "hg_x", user: { email: "keep@example.com" } }, path);
const before = await fs.readFile(path, "utf8");
await saveUserInfo({}, path);
const after = await fs.readFile(path, "utf8");
expect(after).toBe(before);
expect(await loadUserInfo(path)).toEqual({ email: "keep@example.com" });
});
it("loadUserInfo returns null for an absent file", async () => {
expect(await loadUserInfo(path)).toBeNull();
});
it("loadUserInfo returns null for a legacy file without a user block (backwards-compat)", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_legacy" }), { mode: 0o600 });
expect(await loadUserInfo(path)).toBeNull();
});
it("clearUserInfo removes only the user block, keeping the credential", async () => {
await writeStore({ api_key: "hg_keep", user: { email: "u@example.com" } }, path);
await clearUserInfo(path);
const { credentials } = await readStore(path);
expect(credentials.api_key).toBe("hg_keep");
expect(credentials.user).toBeUndefined();
});
it("clearUserInfo removes the whole file when no credential survives", async () => {
// A file holding ONLY a user block (no credential) — clearing leaves
// nothing, so the file should be removed entirely.
await fs.writeFile(path, JSON.stringify({ user: { email: "u@example.com" } }), { mode: 0o600 });
await clearUserInfo(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearUserInfo is a no-op when there is no user block", async () => {
await writeStore({ api_key: "hg_only" }, path);
const before = await fs.readFile(path, "utf8");
await clearUserInfo(path);
const after = await fs.readFile(path, "utf8");
expect(after).toBe(before);
});
it("clearUserInfo keeps the file (preserving a foreign top-level key) when no credential survives", async () => {
// The file holds a user block plus a future/foreign top-level key but
// NO known credential. Clearing the user block must NOT delete the
// file — the foreign key may be a credential another CLI owns, and
// dropping it would clobber the cross-CLI data the store preserves.
await fs.writeFile(
path,
JSON.stringify({
user: { email: "u@example.com" },
future_credential: { token: "owned_by_other_cli" },
}),
{ mode: 0o600 },
);
await clearUserInfo(path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user).toBeUndefined();
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("saveUserInfo does not clobber an unknown/foreign top-level key", async () => {
// The cross-CLI invariant exercised through the persistence helper.
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: 42 }), {
mode: 0o600,
});
await saveUserInfo({ email: "u@example.com" }, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.future_field).toBe(42);
expect(onDisk.user).toEqual({ email: "u@example.com" });
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Persistence + display helpers for the friendly-display `user` block in
* the shared `~/.heygen/credentials` file.
*
* The `user` block is additive METADATA captured at login time from
* `GET /v3/users/me` — NOT a credential. It lets `auth status` (and the
* post-login "Logged in as ..." line) show a recognizable identity
* without re-hitting the API on every invocation, and keeps working when
* the API is unreachable.
*
* This file is SHARED with the Go `heygen` CLI, which writes the same
* `user` block (see `heygen-cli/internal/auth/user_store.go`). Persisting
* here goes through `readStore` / `writeStore`, which round-trip every
* unrecognized key — so saving our user block never clobbers a key the
* other CLI wrote.
*/
import { credentialPath } from "./paths.js";
import {
readStore,
writeStore,
deleteStore,
hasPreservedUnknownData,
type StoredUserInfo,
} from "./store.js";
export type { StoredUserInfo } from "./store.js";
/** True when `u` carries no friendly fields worth surfacing. */
export function isUserInfoEmpty(u: StoredUserInfo): boolean {
return !u.email && !u.first_name && !u.last_name && !u.username;
}
/**
* Most friendly name available, in priority order:
* email > "first last" > first-only > last-only > username > undefined.
* The "first-only" / "last-only" intermediates come from `combineName`,
* which returns `first || last || ""` when only one of the two is present
* (so a user with just a first name still resolves to that first name,
* not straight to username). The caller falls back to its own marker
* (e.g. "(unknown user)") on `undefined`. Mirrors `UserInfo.DisplayName()`
* in heygen-cli.
*/
export function userDisplayName(u: StoredUserInfo): string | undefined {
if (u.email) return u.email;
const name = combineName(u.first_name, u.last_name);
if (name) return name;
return u.username || undefined;
}
/**
* `"first last"` when both are present; otherwise `first || last || ""`
* (one-sided fall-through), and `""` when neither is set. The empty-string
* return is treated as falsy by `userDisplayName`, so it falls through to
* `username`.
*/
function combineName(first?: string, last?: string): string {
if (first && last) return `${first} ${last}`;
return first || last || "";
}
/**
* Persist the friendly-display block, preserving any co-located api_key /
* oauth blocks and unknown/foreign keys. An all-empty `StoredUserInfo`
* is a no-op (the caller should typically gate on `isUserInfoEmpty`
* before calling, but we guard here too so a failed probe can't blank an
* existing block by accident).
*
* A broken pre-existing file surfaces as a thrown `ErrInvalidStore` from
* `readStore` — callers treat persistence as best-effort and warn rather
* than fail the login.
*/
export async function saveUserInfo(info: StoredUserInfo, path = credentialPath()): Promise<void> {
if (isUserInfoEmpty(info)) return;
// readStore preserves co-located api_key / oauth blocks and any
// unknown/foreign keys (captured on a hidden slot), so writing back
// only attaches the user block. A legacy single-line plaintext file
// parses into { api_key }, so this upgrades it to JSON in passing.
const { credentials } = await readStore(path);
credentials.user = { ...info };
await writeStore(credentials, path);
}
/**
* Read the friendly-display block. Returns `null` when the file is absent
* or carries no `user` block (a pre-this-change login). Genuine parse
* errors propagate so the caller can warn rather than silently pretend
* the file is clean.
*/
export async function loadUserInfo(path = credentialPath()): Promise<StoredUserInfo | null> {
const { credentials, source } = await readStore(path);
if (source === "absent") return null;
if (!credentials.user || isUserInfoEmpty(credentials.user)) return null;
return credentials.user;
}
/**
* Remove the friendly-display block, leaving any co-located credential
* (and unknown/foreign keys) intact. When neither a known credential NOR
* any preserved unknown/foreign data survives, the orphaned-metadata file
* is removed entirely. A surviving unknown top-level key (a future
* credential another CLI owns) keeps the file — deleting it would clobber
* exactly the cross-CLI data the store machinery exists to preserve. A
* no-op when there is nothing to clear.
*/
export async function clearUserInfo(path = credentialPath()): Promise<void> {
const { credentials, source } = await readStore(path);
if (source === "absent" || !credentials.user) return;
delete credentials.user;
if (!credentials.api_key && !credentials.oauth && !hasPreservedUnknownData(credentials)) {
await deleteStore(path);
return;
}
await writeStore(credentials, path);
}
@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MEAN, STD, applyMask } from "./inference.js";
// Regression: the u2net_human_seg model was trained with ImageNet
// normalization. Drifting away from these exact values changes the input
// tensor at every pixel and shifts the predicted alpha mask noticeably
// (Miguel reproduced 8,317 pixel changes with delta up to 78/255 when std
// was set to (1, 1, 1)). Reference:
// https://github.com/danielgatis/rembg/blob/main/rembg/sessions/u2net_human_seg.py#L33
describe("background-removal/inference — rembg u2net_human_seg parity", () => {
it("MEAN matches U2netHumanSegSession reference", () => {
expect(MEAN).toEqual([0.485, 0.456, 0.406]);
});
it("STD matches U2netHumanSegSession reference (ImageNet, not the base u2net's (1,1,1))", () => {
expect(STD).toEqual([0.229, 0.224, 0.225]);
});
});
// These tests pin the contract that `--background-output` is built on:
// fg.alpha + bg.alpha === 255 per pixel, and the RGB plane is byte-identical
// between fg and bg. A future change to the postprocess loop (different mask
// threshold, premultiplied alpha, gamma-corrected compositing) that breaks
// either invariant should fail here loudly.
describe("background-removal/inference — applyMask invariants", () => {
function makeRgb(pixels: number): Buffer {
// Deterministic but non-trivial RGB so byte equality is meaningful.
const buf = Buffer.allocUnsafe(pixels * 3);
for (let i = 0; i < pixels; i++) {
buf[i * 3] = (i * 7) & 0xff;
buf[i * 3 + 1] = (i * 13 + 31) & 0xff;
buf[i * 3 + 2] = (i * 19 + 61) & 0xff;
}
return buf;
}
function makeMask(pixels: number): Buffer {
// Hit the saturation endpoints (0, 255) and a few mid-tone values so the
// 255-m inversion is exercised across the full byte range.
const buf = Buffer.allocUnsafe(pixels);
for (let i = 0; i < pixels; i++) buf[i] = (i * 37) & 0xff;
return buf;
}
it("dual-output: fg.alpha + bg.alpha === 255 for every pixel", () => {
const pixels = 64;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const bg = Buffer.allocUnsafe(pixels * 4);
const result = applyMask(rgb, mask, fg, bg, pixels);
expect(result.fg).toBe(fg);
expect(result.bg).toBe(bg);
for (let i = 0; i < pixels; i++) {
const sum = fg[i * 4 + 3]! + bg[i * 4 + 3]!;
expect(sum).toBe(255);
}
});
it("dual-output: RGB triples are byte-identical between fg and bg", () => {
const pixels = 64;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const bg = Buffer.allocUnsafe(pixels * 4);
applyMask(rgb, mask, fg, bg, pixels);
for (let i = 0; i < pixels; i++) {
expect(fg[i * 4]).toBe(bg[i * 4]);
expect(fg[i * 4 + 1]).toBe(bg[i * 4 + 1]);
expect(fg[i * 4 + 2]).toBe(bg[i * 4 + 2]);
// And both match the source.
expect(fg[i * 4]).toBe(rgb[i * 3]);
expect(fg[i * 4 + 1]).toBe(rgb[i * 3 + 1]);
expect(fg[i * 4 + 2]).toBe(rgb[i * 3 + 2]);
}
});
it("dual-output: fg.alpha equals the input mask", () => {
const pixels = 32;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const bg = Buffer.allocUnsafe(pixels * 4);
applyMask(rgb, mask, fg, bg, pixels);
for (let i = 0; i < pixels; i++) {
expect(fg[i * 4 + 3]).toBe(mask[i]);
}
});
it("single-output: bg=null returns bg=null and writes only fg", () => {
const pixels = 32;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const result = applyMask(rgb, mask, fg, null, pixels);
expect(result.bg).toBeNull();
expect(result.fg).toBe(fg);
for (let i = 0; i < pixels; i++) {
expect(fg[i * 4]).toBe(rgb[i * 3]);
expect(fg[i * 4 + 3]).toBe(mask[i]);
}
});
it("saturates correctly at mask=0 and mask=255", () => {
// mask=0 → fg.alpha=0 (transparent subject), bg.alpha=255 (fully opaque plate)
// mask=255 → fg.alpha=255 (fully opaque subject), bg.alpha=0 (transparent plate)
const rgb = Buffer.from([10, 20, 30, 40, 50, 60]);
const mask = Buffer.from([0, 255]);
const fg = Buffer.allocUnsafe(8);
const bg = Buffer.allocUnsafe(8);
applyMask(rgb, mask, fg, bg, 2);
expect(fg[3]).toBe(0);
expect(bg[3]).toBe(255);
expect(fg[7]).toBe(255);
expect(bg[7]).toBe(0);
});
});
// onnxruntime-node and sharp are optional native modules; when their platform
// binary can't load, createSession must fail with an actionable install hint
// (and before touching the network / model download), not a raw module error.
describe("background-removal/inference — missing optional native modules", () => {
beforeEach(() => {
vi.resetModules();
});
it("createSession throws an actionable error when onnxruntime-node can't load", async () => {
vi.doMock("onnxruntime-node", () => {
throw new Error("Cannot find module 'onnxruntime-node'");
});
const { createSession } = await import("./inference.js");
await expect(createSession()).rejects.toThrow(
/onnxruntime-node.*isn't available[\s\S]*npm i onnxruntime-node/,
);
vi.doUnmock("onnxruntime-node");
});
it("createSession throws an actionable error when sharp can't load", async () => {
vi.doMock("onnxruntime-node", () => ({ InferenceSession: {}, Tensor: {} }));
vi.doMock("sharp", () => {
throw new Error("Could not load the sharp module");
});
const { createSession } = await import("./inference.js");
await expect(createSession()).rejects.toThrow(/sharp.*isn't available[\s\S]*npm i sharp/);
vi.doUnmock("onnxruntime-node");
vi.doUnmock("sharp");
});
});
@@ -0,0 +1,265 @@
/**
* u2net_human_seg inference: RGB frame → RGBA frame (alpha = human mask).
*
* Pre/postprocessing matches rembg's u2net session
* (https://github.com/danielgatis/rembg/blob/main/rembg/sessions/u2net.py)
* so output should be pixel-equivalent to `rembg new_session("u2net_human_seg")`.
*/
import type { InferenceSession, Tensor } from "onnxruntime-node";
import type sharpType from "sharp";
import { ensureModel, selectProviders, type Device, type ModelId } from "./manager.js";
const INPUT_SIZE = 320;
const INPUT_PLANE = INPUT_SIZE * INPUT_SIZE;
// Must match rembg's U2netHumanSegSession.predict — ImageNet mean/std, NOT the
// (1.0, 1.0, 1.0) std used by the general-purpose u2net session.
// https://github.com/danielgatis/rembg/blob/main/rembg/sessions/u2net_human_seg.py#L33
export const MEAN = [0.485, 0.456, 0.406] as const;
export const STD = [0.229, 0.224, 0.225] as const;
type Sharp = typeof sharpType;
interface OrtModule {
InferenceSession: typeof InferenceSession;
Tensor: typeof Tensor;
}
export interface SessionResult {
/** Subject opaque, background fully transparent. */
fg: Buffer;
/** Inverse-alpha plate: same RGB, alpha is `255 mask`. Null unless `withBackground` was true. */
bg: Buffer | null;
}
export interface Session {
/**
* Both `fg` and `bg` (when requested) are session-owned buffers reused on the
* next call — drain the encoder's stdin before invoking `process` again.
*/
process(
rgb: Buffer,
width: number,
height: number,
withBackground?: boolean,
): Promise<SessionResult>;
provider: string;
close(): Promise<void>;
}
export interface CreateSessionOptions {
model?: ModelId;
device?: Device;
onProgress?: (message: string) => void;
}
// onnxruntime-node and sharp are optional native modules — their platform
// binaries don't install everywhere. Surface an actionable error instead of a
// raw "Cannot find module" when one can't load.
async function loadNative<T>(name: string, load: () => Promise<T>): Promise<T> {
try {
return await load();
} catch (err) {
throw new Error(
`remove-background needs the optional native module '${name}', which isn't available ` +
`(${(err as Error).message}). Install it with \`npm i ${name}\`, or reinstall hyperframes with optional dependencies enabled.`,
);
}
}
export async function createSession(options: CreateSessionOptions = {}): Promise<Session> {
const ort = (await loadNative(
"onnxruntime-node",
() => import("onnxruntime-node"),
)) as unknown as OrtModule;
const sharp = (await loadNative("sharp", () => import("sharp"))).default as Sharp;
const choice = selectProviders(options.device ?? "auto");
const path = await ensureModel(options.model, { onProgress: options.onProgress });
options.onProgress?.(`Loading model on ${choice.label}...`);
const tryCreate = (providers: string[]) =>
ort.InferenceSession.create(path, {
executionProviders: providers,
graphOptimizationLevel: "all",
});
let session: InferenceSession;
let providerUsed = choice.label;
try {
session = await tryCreate(choice.providers);
} catch (err) {
if (choice.providers[0] === "cpu") throw err;
options.onProgress?.(
`${choice.label} provider failed (${(err as Error).message}); falling back to CPU.`,
);
session = await tryCreate(["cpu"]);
providerUsed = "CPU";
}
const inputName = session.inputNames[0];
const outputName = session.outputNames[0];
if (!inputName || !outputName) {
throw new Error("ONNX session is missing input or output bindings");
}
// Reused across calls; sized lazily on first frame. Saves ~9 MB/frame at 1080p.
const inputData = new Float32Array(3 * INPUT_PLANE);
const maskBuf = Buffer.allocUnsafe(INPUT_PLANE);
let rgbaBuf: Buffer | null = null;
let rgbaBgBuf: Buffer | null = null;
return {
provider: providerUsed,
async process(rgb, width, height, withBackground = false) {
const tensor = await preprocess(sharp, ort, rgb, width, height, inputData);
const outputs = await session.run({ [inputName]: tensor });
const output = outputs[outputName];
if (!output) throw new Error(`Model did not return output '${outputName}'`);
const expectedBytes = width * height * 4;
if (!rgbaBuf || rgbaBuf.length !== expectedBytes) {
rgbaBuf = Buffer.allocUnsafe(expectedBytes);
}
if (withBackground) {
if (!rgbaBgBuf || rgbaBgBuf.length !== expectedBytes) {
rgbaBgBuf = Buffer.allocUnsafe(expectedBytes);
}
}
return await postprocess(
sharp,
output,
rgb,
width,
height,
maskBuf,
rgbaBuf,
withBackground ? rgbaBgBuf : null,
);
},
async close() {
await session.release();
},
};
}
async function preprocess(
sharp: Sharp,
ort: OrtModule,
rgb: Buffer,
width: number,
height: number,
inputData: Float32Array,
): Promise<Tensor> {
const resized = await sharp(rgb, { raw: { width, height, channels: 3 } })
.resize(INPUT_SIZE, INPUT_SIZE, { kernel: "lanczos3", fit: "fill" })
.raw()
.toBuffer();
// rembg's normalize divides by `np.max(im_ary)` (NOT 255). Match exactly so
// we hit the same operating point as the model's training distribution.
let maxPixel = 0;
for (let i = 0; i < resized.length; i++) {
if (resized[i]! > maxPixel) maxPixel = resized[i]!;
}
if (maxPixel === 0) maxPixel = 1;
for (let y = 0; y < INPUT_SIZE; y++) {
for (let x = 0; x < INPUT_SIZE; x++) {
const src = (y * INPUT_SIZE + x) * 3;
const dst = y * INPUT_SIZE + x;
inputData[dst] = (resized[src]! / maxPixel - MEAN[0]) / STD[0];
inputData[INPUT_PLANE + dst] = (resized[src + 1]! / maxPixel - MEAN[1]) / STD[1];
inputData[2 * INPUT_PLANE + dst] = (resized[src + 2]! / maxPixel - MEAN[2]) / STD[2];
}
}
return new ort.Tensor("float32", inputData, [1, 3, INPUT_SIZE, INPUT_SIZE]);
}
async function postprocess(
sharp: Sharp,
output: Tensor,
rgb: Buffer,
width: number,
height: number,
maskBuf: Buffer,
rgbaBuf: Buffer,
rgbaBgBuf: Buffer | null,
): Promise<SessionResult> {
const raw = output.data as Float32Array;
let lo = Infinity;
let hi = -Infinity;
for (let i = 0; i < INPUT_PLANE; i++) {
const v = raw[i]!;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
const range = hi - lo || 1;
for (let i = 0; i < INPUT_PLANE; i++) {
const norm = (raw[i]! - lo) / range;
maskBuf[i] = Math.max(0, Math.min(255, Math.round(norm * 255)));
}
// lanczos3 keeps soft edges; nearest leaves visible jaggies on hair.
// Sharp upcasts the single-channel raw input to a 3-channel buffer during
// resize, so the output is laid out as RGB-interleaved (R0,G0,B0,R1,G1,B1,...)
// even though all three channels carry the same grayscale value. Force the
// output back to single channel with toColourspace("b-w") so we can index
// it linearly as a mask.
const fullMask = await sharp(maskBuf, {
raw: { width: INPUT_SIZE, height: INPUT_SIZE, channels: 1 },
})
.resize(width, height, { kernel: "lanczos3", fit: "fill" })
.toColourspace("b-w")
.raw()
.toBuffer();
return applyMask(rgb, fullMask, rgbaBuf, rgbaBgBuf, width * height);
}
/**
* Composite the RGB source frame with the segmentation mask into one or two
* RGBA buffers. The contract this PR is built on:
* - `fg`'s alpha is the mask, `bg`'s alpha (when provided) is `255 mask`,
* so `fg.alpha + bg.alpha === 255` for every pixel.
* - RGB triples are byte-identical between `fg` and `bg`.
* - When `bg` is null, only `fg` is touched.
*
* Exported for direct unit testing of the invariants above without spinning
* up an ONNX session.
*/
export function applyMask(
rgb: Buffer,
mask: Buffer,
fg: Buffer,
bg: Buffer | null,
pixels: number,
): SessionResult {
if (bg) {
for (let i = 0; i < pixels; i++) {
const r = rgb[i * 3]!;
const g = rgb[i * 3 + 1]!;
const b = rgb[i * 3 + 2]!;
const m = mask[i]!;
const o = i * 4;
fg[o] = r;
fg[o + 1] = g;
fg[o + 2] = b;
fg[o + 3] = m;
bg[o] = r;
bg[o + 1] = g;
bg[o + 2] = b;
bg[o + 3] = 255 - m;
}
return { fg, bg };
}
for (let i = 0; i < pixels; i++) {
fg[i * 4] = rgb[i * 3]!;
fg[i * 4 + 1] = rgb[i * 3 + 1]!;
fg[i * 4 + 2] = rgb[i * 3 + 2]!;
fg[i * 4 + 3] = mask[i]!;
}
return { fg, bg: null };
}
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("background-removal/manager — selectProviders", () => {
beforeEach(() => {
vi.resetModules();
delete process.env["HYPERFRAMES_CUDA"];
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns CPU explicitly when --device cpu", async () => {
vi.doMock("node:os", () => ({
platform: () => "darwin",
arch: () => "arm64",
homedir: () => "/tmp",
}));
const { selectProviders } = await import("./manager.js");
const choice = selectProviders("cpu");
expect(choice.providers).toEqual(["cpu"]);
expect(choice.label).toBe("CPU");
});
it("auto picks CoreML on darwin-arm64", async () => {
vi.doMock("node:os", () => ({
platform: () => "darwin",
arch: () => "arm64",
homedir: () => "/tmp",
}));
const { selectProviders } = await import("./manager.js");
const choice = selectProviders("auto");
expect(choice.providers).toEqual(["coreml", "cpu"]);
expect(choice.label).toBe("CoreML");
});
it("auto falls back to CPU on linux without HYPERFRAMES_CUDA", async () => {
vi.doMock("node:os", () => ({
platform: () => "linux",
arch: () => "x64",
homedir: () => "/tmp",
}));
const { selectProviders } = await import("./manager.js");
const choice = selectProviders("auto");
expect(choice.providers).toEqual(["cpu"]);
expect(choice.label).toBe("CPU");
});
it("auto picks CUDA on linux when HYPERFRAMES_CUDA=1", async () => {
process.env["HYPERFRAMES_CUDA"] = "1";
vi.doMock("node:os", () => ({
platform: () => "linux",
arch: () => "x64",
homedir: () => "/tmp",
}));
const { selectProviders } = await import("./manager.js");
const choice = selectProviders("auto");
expect(choice.providers).toEqual(["cuda", "cpu"]);
expect(choice.label).toBe("CUDA");
});
it("--device coreml on linux throws", async () => {
vi.doMock("node:os", () => ({
platform: () => "linux",
arch: () => "x64",
homedir: () => "/tmp",
}));
const { selectProviders } = await import("./manager.js");
expect(() => selectProviders("coreml")).toThrow(/CoreML execution provider not available/);
});
it("--device cuda without env var throws", async () => {
vi.doMock("node:os", () => ({
platform: () => "linux",
arch: () => "x64",
homedir: () => "/tmp",
}));
const { selectProviders } = await import("./manager.js");
expect(() => selectProviders("cuda")).toThrow(/CUDA execution provider not available/);
});
});
@@ -0,0 +1,96 @@
import { existsSync, mkdirSync } from "node:fs";
import { homedir, platform, arch } from "node:os";
import { join } from "node:path";
import { downloadFile } from "../utils/download.js";
export const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "background-removal", "models");
export const DEFAULT_MODEL = "u2net_human_seg" as const;
export type ModelId = typeof DEFAULT_MODEL;
const MODEL_URLS: Record<ModelId, string> = {
u2net_human_seg:
"https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net_human_seg.onnx",
};
export const MODEL_MEMORY_MB: Record<ModelId, number> = {
u2net_human_seg: 1500,
};
export const DEVICES = ["auto", "cpu", "coreml", "cuda"] as const;
export type Device = (typeof DEVICES)[number];
export function isDevice(value: unknown): value is Device {
return typeof value === "string" && (DEVICES as readonly string[]).includes(value);
}
export interface ProviderChoice {
providers: string[];
label: "CoreML" | "CUDA" | "CPU";
}
export function selectProviders(device: Device = "auto"): ProviderChoice {
if (device === "cpu") return { providers: ["cpu"], label: "CPU" };
const available = listAvailableProviders();
const hasCoreML = available.includes("coreml");
const hasCUDA = available.includes("cuda");
if (device === "coreml") {
if (!hasCoreML) {
throw new Error(
"CoreML execution provider not available. Install onnxruntime-node on Apple Silicon, or use --device cpu.",
);
}
return { providers: ["coreml", "cpu"], label: "CoreML" };
}
if (device === "cuda") {
if (!hasCUDA) {
throw new Error(
"CUDA execution provider not available. Use --device cpu or install an onnxruntime-node build with CUDA support.",
);
}
return { providers: ["cuda", "cpu"], label: "CUDA" };
}
if (hasCoreML && platform() === "darwin" && arch() === "arm64") {
return { providers: ["coreml", "cpu"], label: "CoreML" };
}
if (hasCUDA) return { providers: ["cuda", "cpu"], label: "CUDA" };
return { providers: ["cpu"], label: "CPU" };
}
let _cachedProviders: string[] | undefined;
export function listAvailableProviders(): string[] {
if (_cachedProviders) return _cachedProviders;
// The npm onnxruntime-node ships with CPU on every platform and bundles the
// CoreML EP only on darwin-arm64. CUDA is opt-in via a separate gpu build —
// gate behind an env var so we don't try to bind to a missing EP.
const out: string[] = ["cpu"];
if (platform() === "darwin" && arch() === "arm64") out.push("coreml");
if (process.env["HYPERFRAMES_CUDA"] === "1") out.push("cuda");
_cachedProviders = out;
return out;
}
export function modelPath(model: ModelId = DEFAULT_MODEL): string {
return join(MODELS_DIR, `${model}.onnx`);
}
export async function ensureModel(
model: ModelId = DEFAULT_MODEL,
options?: { onProgress?: (message: string) => void },
): Promise<string> {
const dest = modelPath(model);
if (existsSync(dest)) return dest;
mkdirSync(MODELS_DIR, { recursive: true });
options?.onProgress?.(`Downloading ${model} weights (~168 MB)...`);
await downloadFile(MODEL_URLS[model], dest);
if (!existsSync(dest)) {
throw new Error(`Model download failed: ${model}`);
}
return dest;
}
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { EventEmitter } from "node:events";
import type { spawn } from "node:child_process";
import {
inferOutputFormat,
inferInputKind,
buildEncoderArgs,
resolveRenderTargets,
waitForExit,
} from "./pipeline.js";
describe("background-removal/pipeline — inferOutputFormat", () => {
it("maps .webm → webm", () => {
expect(inferOutputFormat("/tmp/out.webm")).toBe("webm");
});
it("maps .mov → mov", () => {
expect(inferOutputFormat("/tmp/out.mov")).toBe("mov");
});
it("maps .png → png", () => {
expect(inferOutputFormat("/tmp/out.png")).toBe("png");
});
it("rejects unknown extensions", () => {
expect(() => inferOutputFormat("/tmp/out.mp4")).toThrow(/Unsupported output extension/);
});
});
describe("background-removal/pipeline — inferInputKind", () => {
it("recognizes mp4/mov/webm/mkv/avi as video", () => {
for (const ext of [".mp4", ".mov", ".webm", ".mkv", ".avi"]) {
expect(inferInputKind(`/tmp/clip${ext}`)).toBe("video");
}
});
it("recognizes jpg/png/webp as image", () => {
for (const ext of [".jpg", ".jpeg", ".png", ".webp"]) {
expect(inferInputKind(`/tmp/img${ext}`)).toBe("image");
}
});
it("rejects unknown extensions", () => {
expect(() => inferInputKind("/tmp/file.gif")).toThrow(/Unsupported input/);
});
});
describe("background-removal/pipeline — buildEncoderArgs", () => {
it("webm preset emits VP9 + alpha_mode metadata", () => {
const args = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/out.webm");
expect(args).toContain("libvpx-vp9");
expect(args).toContain("yuva420p");
expect(args[args.indexOf("-cpu-used") + 1]).toBe("4");
// The alpha_mode metadata must be present; without it Chrome ignores the alpha plane.
const idx = args.indexOf("-metadata:s:v:0");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("alpha_mode=1");
expect(args[args.length - 1]).toBe("/tmp/out.webm");
});
it("webm preset tags BT.709 colorspace + limited range", () => {
// Without these tags, ffmpeg's RGB→YUV conversion uses the BT.601 default,
// and Chrome's YUV→RGB pass on the resulting webm produces a different
// RGB triple than the source mp4 (visible color shift on overlay). Pin
// BT.709 limited-range so the cutout matches modern Rec.709 sources.
const args = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/out.webm");
const csIdx = args.indexOf("-colorspace");
expect(csIdx).toBeGreaterThan(-1);
expect(args[csIdx + 1]).toBe("bt709");
const rangeIdx = args.indexOf("-color_range");
expect(rangeIdx).toBeGreaterThan(-1);
expect(args[rangeIdx + 1]).toBe("tv");
});
it("webm quality presets map to crf 30/18/12", () => {
const fast = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm", "fast");
const balanced = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm", "balanced");
const best = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm", "best");
const crf = (args: string[]) => args[args.indexOf("-crf") + 1];
expect(crf(fast)).toBe("30");
expect(crf(balanced)).toBe("18");
expect(crf(best)).toBe("12");
});
it("webm default quality is balanced (crf 18)", () => {
const args = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm");
expect(args[args.indexOf("-crf") + 1]).toBe("18");
});
it("mov preset emits ProRes 4444 + yuva444p10le", () => {
const args = buildEncoderArgs("mov", 1920, 1080, 30, "/tmp/out.mov");
expect(args).toContain("prores_ks");
expect(args).toContain("4444");
expect(args).toContain("yuva444p10le");
});
it("png preset emits a single RGBA frame", () => {
const args = buildEncoderArgs("png", 1920, 1080, 30, "/tmp/out.png");
expect(args).toContain("-frames:v");
expect(args).toContain("rgba");
});
it("threads input dimensions and fps into raw video header", () => {
const args = buildEncoderArgs("webm", 640, 480, 24, "/tmp/o.webm");
const sIdx = args.indexOf("-s");
expect(args[sIdx + 1]).toBe("640x480");
const rIdx = args.indexOf("-r");
expect(args[rIdx + 1]).toBe("24");
});
});
describe("background-removal/pipeline — resolveRenderTargets", () => {
it("resolves a normal video → webm render", () => {
const t = resolveRenderTargets("/tmp/clip.mp4", "/tmp/cutout.webm");
expect(t.format).toBe("webm");
expect(t.inputKind).toBe("video");
expect(t.bgFormat).toBeUndefined();
});
it("resolves an image → png render", () => {
const t = resolveRenderTargets("/tmp/portrait.jpg", "/tmp/cutout.png");
expect(t.format).toBe("png");
expect(t.inputKind).toBe("image");
});
it("rejects image input with a video output extension", () => {
expect(() => resolveRenderTargets("/tmp/portrait.jpg", "/tmp/cutout.webm")).toThrow(
/Image input requires a \.png output/,
);
});
it("rejects video input with a .png output", () => {
expect(() => resolveRenderTargets("/tmp/clip.mp4", "/tmp/cutout.png")).toThrow(
/Video input requires a \.webm or \.mov output/,
);
});
it("threads background-output format through when valid", () => {
const t = resolveRenderTargets("/tmp/clip.mp4", "/tmp/fg.webm", "/tmp/bg.webm");
expect(t.bgFormat).toBe("webm");
const tMov = resolveRenderTargets("/tmp/clip.mp4", "/tmp/fg.webm", "/tmp/bg.mov");
expect(tMov.bgFormat).toBe("mov");
});
it("rejects --background-output for image inputs (no temporal pairing to do)", () => {
expect(() =>
resolveRenderTargets("/tmp/portrait.jpg", "/tmp/cutout.png", "/tmp/bg.png"),
).toThrow(/--background-output is not supported for image inputs/);
});
it("rejects .png as the --background-output extension", () => {
// .png is only valid for single-image inputs, and image inputs themselves
// can't have a background-output anyway. So .png here is always a misuse.
expect(() => resolveRenderTargets("/tmp/clip.mp4", "/tmp/fg.webm", "/tmp/bg.png")).toThrow(
/--background-output must be \.webm or \.mov/,
);
});
});
// Regression: a previous version of waitForExit treated `code === null` as
// success. Per Node's child_process docs, that's the signal-killed case —
// reporting it as success means a SIGTERM/SIGKILL'd ffmpeg encoder produces
// a "successful" render with a missing or truncated output file.
describe("background-removal/pipeline — waitForExit signal handling", () => {
function fakeProc(): ReturnType<typeof spawn> {
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
}
it("resolves on a clean exit (code=0, signal=null)", async () => {
const proc = fakeProc();
const promise = waitForExit(proc, "ffmpeg encoder", () => "");
proc.emit("exit", 0, null);
await expect(promise).resolves.toBeUndefined();
});
it("rejects when killed by signal (code=null, signal='SIGTERM')", async () => {
const proc = fakeProc();
const promise = waitForExit(proc, "ffmpeg encoder", () => "tail of stderr");
proc.emit("exit", null, "SIGTERM");
await expect(promise).rejects.toThrow(/killed by SIGTERM/);
await expect(promise).rejects.toThrow(/tail of stderr/);
});
it("rejects on non-zero exit code", async () => {
const proc = fakeProc();
const promise = waitForExit(proc, "ffmpeg encoder", () => "");
proc.emit("exit", 1, null);
await expect(promise).rejects.toThrow(/exited with code 1/);
});
it("rejects on SIGKILL", async () => {
const proc = fakeProc();
const promise = waitForExit(proc, "ffmpeg encoder", () => "");
proc.emit("exit", null, "SIGKILL");
await expect(promise).rejects.toThrow(/killed by SIGKILL/);
});
});
@@ -0,0 +1,491 @@
// fallow-ignore-file complexity
/**
* Background-removal rendering pipeline.
*
* Decode source frames via ffmpeg → run inference per frame → encode the RGBA
* stream via a second ffmpeg process. Output formats:
* .webm → VP9 with alpha (HTML5-native, ~1 MB / 4s @ 1080p)
* .mov → ProRes 4444 with alpha (editing round-trip)
* .png → single RGBA still (only when input is also a single image)
*
* The encode flags for VP9-with-alpha mirror the `chunkEncoder.ts` pattern in
* @hyperframes/engine — `-pix_fmt yuva420p` plus the
* `-metadata:s:v:0 alpha_mode=1` tag are what make Chrome's `<video>` element
* decode the alpha plane.
*/
import { spawn } from "node:child_process";
import { extname } from "node:path";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { createSession, type Session } from "./inference.js";
import { type Device, type ModelId } from "./manager.js";
import { DEFAULT_VP9_CPU_USED } from "@hyperframes/engine";
export type OutputFormat = "webm" | "mov" | "png";
const QUALITY_CRF = {
fast: 30,
balanced: 18,
best: 12,
} as const;
export type Quality = keyof typeof QUALITY_CRF;
export const QUALITIES = Object.keys(QUALITY_CRF) as readonly Quality[];
export const DEFAULT_QUALITY: Quality = "balanced";
export const isQuality = (v: unknown): v is Quality =>
typeof v === "string" && (QUALITIES as readonly string[]).includes(v);
export interface RenderOptions {
inputPath: string;
outputPath: string;
/**
* Optional second output: an inverse-alpha background plate (same source
* RGB, transparent where the subject was). Only valid for video inputs and
* .webm/.mov outputs — not allowed alongside a .png output. The plate's
* format is inferred from this path independently of the foreground's.
*
* NOTE: this is a hole-cut plate, not an inpainted clean plate. Composite
* something opaque (graphics, blur, scene) under it to fill the hole.
*/
backgroundOutputPath?: string;
device?: Device;
model?: ModelId;
/** Encoder CRF preset for `.webm`. See `QUALITY_CRF`. Ignored for `.mov`/`.png`. */
quality?: Quality;
onProgress?: (event: ProgressEvent) => void;
}
export type ProgressEvent =
| { kind: "info"; message: string }
| { kind: "metadata"; width: number; height: number; fps: number; frameCount: number }
| { kind: "frame"; index: number; total: number; avgMsPerFrame: number };
export interface RenderResult {
outputPath: string;
/** Present only when `backgroundOutputPath` was set. */
backgroundOutputPath?: string;
framesProcessed: number;
durationSeconds: number;
avgMsPerFrame: number;
provider: string;
format: OutputFormat;
}
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
interface MediaInfo {
width: number;
height: number;
fps: number;
frameCount: number;
}
export function inferOutputFormat(outputPath: string): OutputFormat {
const ext = extname(outputPath).toLowerCase();
if (ext === ".webm") return "webm";
if (ext === ".mov") return "mov";
if (ext === ".png") return "png";
throw new Error(
`Unsupported output extension: ${ext}. Use .webm (VP9 alpha), .mov (ProRes 4444), or .png.`,
);
}
export function inferInputKind(inputPath: string): "video" | "image" {
const ext = extname(inputPath).toLowerCase();
if (VIDEO_EXTENSIONS.has(ext)) return "video";
if (IMAGE_EXTENSIONS.has(ext)) return "image";
throw new Error(
`Unsupported input: ${ext}. Use a video (mp4/mov/webm/mkv/avi) or image (jpg/png/webp).`,
);
}
interface EngineMetadata {
width: number;
height: number;
fps: number;
durationSeconds: number;
}
async function probeMedia(inputPath: string): Promise<MediaInfo> {
const isImage = inferInputKind(inputPath) === "image";
const engine = (await import("@hyperframes/engine")) as {
extractMediaMetadata: (path: string) => Promise<EngineMetadata>;
};
const meta = await engine.extractMediaMetadata(inputPath);
if (isImage) {
return { width: meta.width, height: meta.height, fps: 0, frameCount: 1 };
}
const fps = meta.fps || 30;
const frameCount = meta.durationSeconds ? Math.round(meta.durationSeconds * fps) : 0;
return { width: meta.width, height: meta.height, fps, frameCount };
}
export function buildEncoderArgs(
format: OutputFormat,
width: number,
height: number,
fps: number,
outputPath: string,
quality: Quality = DEFAULT_QUALITY,
): string[] {
const base = [
"-y",
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"-s",
`${width}x${height}`,
"-r",
String(fps || 30),
"-i",
"-",
];
if (format === "webm") {
return [
...base,
"-c:v",
"libvpx-vp9",
"-b:v",
"0",
"-crf",
String(QUALITY_CRF[quality]),
"-deadline",
"good",
"-row-mt",
"1",
"-cpu-used",
String(DEFAULT_VP9_CPU_USED),
"-auto-alt-ref",
"0",
"-pix_fmt",
"yuva420p",
// Tag the output as BT.709 limited range so browsers use the same
// YUV→RGB matrix the source video was encoded with. Without these tags
// ffmpeg's default RGB→YUV conversion is BT.601, which causes a visible
// color shift (red/skin tones in particular) when the matted overlay is
// composited over the original mp4.
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
"-metadata:s:v:0",
"alpha_mode=1",
"-an",
outputPath,
];
}
if (format === "mov") {
return [
...base,
"-c:v",
"prores_ks",
"-profile:v",
"4444",
"-vendor",
"apl0",
"-pix_fmt",
"yuva444p10le",
"-an",
outputPath,
];
}
return [...base, "-frames:v", "1", "-pix_fmt", "rgba", "-update", "1", outputPath];
}
async function* readFrames(
stream: NodeJS.ReadableStream,
frameBytes: number,
): AsyncGenerator<Buffer> {
let buffered: Buffer = Buffer.alloc(0);
for await (const chunk of stream) {
buffered =
buffered.length === 0 ? (chunk as Buffer) : Buffer.concat([buffered, chunk as Buffer]);
while (buffered.length >= frameBytes) {
// Copy because the next concat would clobber the underlying memory.
yield Buffer.from(buffered.subarray(0, frameBytes));
buffered = buffered.subarray(frameBytes);
}
}
}
export interface RenderTargets {
format: OutputFormat;
inputKind: "video" | "image";
bgFormat: OutputFormat | undefined;
}
/**
* Resolve and validate the input/output combination before any I/O. Pure;
* exported so unit tests can pin the error messages without spawning ffmpeg.
*/
export function resolveRenderTargets(
inputPath: string,
outputPath: string,
backgroundOutputPath?: string,
): RenderTargets {
const format = inferOutputFormat(outputPath);
const inputKind = inferInputKind(inputPath);
if (inputKind === "image" && format !== "png") {
throw new Error(
`Image input requires a .png output (got ${extname(outputPath)}). Use a video input for .webm/.mov.`,
);
}
if (inputKind === "video" && format === "png") {
throw new Error(
`Video input requires a .webm or .mov output (got .png). Use an image input for .png.`,
);
}
let bgFormat: OutputFormat | undefined;
if (backgroundOutputPath) {
if (inputKind === "image") {
throw new Error(
"--background-output is not supported for image inputs. Use a video input (mp4/mov/webm) to produce both a cutout and a background plate.",
);
}
bgFormat = inferOutputFormat(backgroundOutputPath);
if (bgFormat === "png") {
throw new Error(
"--background-output must be .webm or .mov; .png is only valid for single-image inputs.",
);
}
}
return { format, inputKind, bgFormat };
}
export async function render(options: RenderOptions): Promise<RenderResult> {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath || !findFFprobe()) {
throw new Error(`ffmpeg and ffprobe are required. Install: ${getFFmpegInstallHint()}`);
}
const { format, bgFormat } = resolveRenderTargets(
options.inputPath,
options.outputPath,
options.backgroundOutputPath,
);
const media = await probeMedia(options.inputPath);
options.onProgress?.({
kind: "metadata",
width: media.width,
height: media.height,
fps: media.fps,
frameCount: media.frameCount,
});
const session = await createSession({
model: options.model,
device: options.device,
onProgress: (msg) => options.onProgress?.({ kind: "info", message: msg }),
});
try {
const start = Date.now();
const framesProcessed = await runPipeline(
options,
session,
media,
format,
bgFormat,
ffmpegPath,
);
const durationSeconds = (Date.now() - start) / 1000;
const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0;
return {
outputPath: options.outputPath,
backgroundOutputPath: options.backgroundOutputPath,
framesProcessed,
durationSeconds,
avgMsPerFrame,
provider: session.provider,
format,
};
} finally {
await session.close();
}
}
const RECENT_WINDOW = 30;
interface FfmpegProc {
proc: ReturnType<typeof spawn>;
exit: Promise<void>;
/** Tail of stderr, captured for inclusion in error messages. */
getStderr: () => string;
}
type StdioFd = "ignore" | "pipe";
type StdioTuple = [StdioFd, StdioFd, StdioFd];
function spawnFfmpeg(
ffmpegPath: string,
args: string[],
label: string,
stdio: StdioTuple,
): FfmpegProc {
const proc = spawn(ffmpegPath, args, { stdio });
let stderrBuf = "";
proc.stderr?.on("data", (d: Buffer) => {
stderrBuf += d.toString();
});
// If the encoder dies mid-render, the next .write() to its stdin emits an
// 'error' event on the writable. Without a listener, Node treats it as
// unhandled and crashes the CLI before waitForExit's reject path can
// surface the real cause (encoder stderr tail). Swallowing here is safe —
// the process exit is the source of truth.
proc.stdin?.on("error", () => {});
const exit = waitForExit(proc, label, () => stderrBuf);
return { proc, exit, getStderr: () => stderrBuf };
}
async function runPipeline(
options: RenderOptions,
session: Session,
media: MediaInfo,
format: OutputFormat,
bgFormat: OutputFormat | undefined,
ffmpegPath: string,
): Promise<number> {
const { inputPath, outputPath, backgroundOutputPath } = options;
const { width, height, fps, frameCount } = media;
const frameBytes = width * height * 3;
const quality = options.quality ?? DEFAULT_QUALITY;
const decoder = spawnFfmpeg(
ffmpegPath,
["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"],
"ffmpeg decoder",
["ignore", "pipe", "pipe"],
);
const fg = spawnFfmpeg(
ffmpegPath,
buildEncoderArgs(format, width, height, fps || 30, outputPath, quality),
"ffmpeg encoder",
["pipe", "ignore", "pipe"],
);
const bg =
backgroundOutputPath && bgFormat
? spawnFfmpeg(
ffmpegPath,
buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality),
"ffmpeg background encoder",
["pipe", "ignore", "pipe"],
)
: null;
let processed = 0;
const total = frameCount;
const recentMs = new Array<number>(RECENT_WINDOW).fill(0);
let recentSum = 0;
let recentSlot = 0;
let recentCount = 0;
try {
for await (const rgb of readFrames(decoder.proc.stdout!, frameBytes)) {
const t0 = Date.now();
const result = await session.process(rgb, width, height, bg !== null);
const elapsed = Date.now() - t0;
recentSum += elapsed - recentMs[recentSlot]!;
recentMs[recentSlot] = elapsed;
recentSlot = (recentSlot + 1) % RECENT_WINDOW;
if (recentCount < RECENT_WINDOW) recentCount++;
// Issue both writes before any await so a slow encoder doesn't block
// the other. Drain anything that returned false before the next
// session.process() — its output buffers are reused per frame.
//
// Subtlety: write() returning true means "highWaterMark not exceeded,"
// NOT "libuv has flushed the chunk." The buffer reference is held by
// libuv until the underlying syscall completes. Reusing the session's
// output buffer is safe because the next session.process() call takes
// ~1050ms (ORT inference) — plenty of event-loop turns for libuv to
// drain. If that ever stops being true, we'd need to copy here.
const fgWroteFully = fg.proc.stdin!.write(result.fg);
const bgWroteFully = bg && result.bg ? bg.proc.stdin!.write(result.bg) : true;
if (!fgWroteFully || !bgWroteFully) {
const drains: Promise<void>[] = [];
if (!fgWroteFully) {
drains.push(
new Promise<void>((resolve) => fg.proc.stdin!.once("drain", () => resolve())),
);
}
if (!bgWroteFully && bg) {
drains.push(
new Promise<void>((resolve) => bg.proc.stdin!.once("drain", () => resolve())),
);
}
await Promise.all(drains);
}
processed++;
options.onProgress?.({
kind: "frame",
index: processed,
total,
avgMsPerFrame: recentSum / recentCount,
});
}
} catch (err) {
decoder.proc.kill("SIGKILL");
fg.proc.kill("SIGKILL");
bg?.proc.kill("SIGKILL");
throw err;
}
fg.proc.stdin!.end();
bg?.proc.stdin!.end();
const exits: Promise<void>[] = [decoder.exit, fg.exit];
if (bg) exits.push(bg.exit);
await Promise.all(exits);
if (processed === 0) {
throw new Error(
`No frames produced from ${inputPath}. Decoder stderr:\n${decoder.getStderr().slice(-400)}`,
);
}
return processed;
}
export function waitForExit(
proc: ReturnType<typeof spawn>,
label: string,
getStderr: () => string,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
proc.on("error", reject);
// Per Node docs the exit callback is (code, signal): on a normal exit
// `code` is the numeric exit status and `signal` is null; on a
// signal-killed exit `code` is null and `signal` is the signal name.
// Treating null-code as success would silently report SIGTERM/SIGKILL
// as a successful render.
proc.on("exit", (code, signal) => {
if (code === 0 && !signal) {
resolve();
return;
}
const cause = signal ? `killed by ${signal}` : `exited with code ${code}`;
reject(new Error(`${label} ${cause}: ${getStderr().slice(-400)}`));
});
});
}
+152
View File
@@ -0,0 +1,152 @@
// Run the shared beat detection (@hyperframes/core/beats) in a headless Chrome
// so results match the Studio exactly — same Web Audio decode + same
// bpm-detective. Used by the `beats` CLI command to write the beat file before
// the Studio is ever opened.
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Browser, Page } from "puppeteer-core";
const require = createRequire(import.meta.url);
// The detection is browser code. We need it as an IIFE that exposes
// analyzeMusicFromBuffer on the page. Prefer the artifact prebuilt at CLI build
// time (shipped in dist); fall back to bundling from core source at runtime
// (dev/monorepo, where core's src is on disk).
let bundlePromise: Promise<string> | null = null;
function findPrebuiltBundle(): string | null {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, "beat-analyzer.global.js"), // dist root (tsup-bundled cli)
join(here, "../beat-analyzer.global.js"), // dist/beats → dist
join(here, "../dist/beat-analyzer.global.js"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}
async function buildFromCoreSource(): Promise<string> {
const esbuild = await import("esbuild");
const coreRoot = dirname(require.resolve("@hyperframes/core/package.json"));
const entry = join(coreRoot, "src/beats/beatDetection.ts");
const result = await esbuild.build({
stdin: {
contents:
`import { analyzeMusicFromBuffer } from ${JSON.stringify(entry)};\n` +
`globalThis.__hfAnalyze = analyzeMusicFromBuffer;`,
resolveDir: coreRoot,
loader: "ts",
},
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
write: false,
});
const out = result.outputFiles?.[0];
if (!out) throw new Error("Failed to bundle beat analyzer");
return out.text;
}
function buildAnalyzerBundle(): Promise<string> {
if (bundlePromise) return bundlePromise;
bundlePromise = (async () => {
const prebuilt = findPrebuiltBundle();
if (prebuilt) return readFileSync(prebuilt, "utf8");
return buildFromCoreSource();
})().catch((err) => {
bundlePromise = null; // don't poison the process with a cached rejection
throw err;
});
return bundlePromise;
}
export interface HeadlessBeatResult {
beatTimes: number[];
beatStrengths: number[];
bpm: number | null;
bpmConfidence: string;
}
// Guard against pathological inputs that would blow CDP message limits when
// transferred to the page as base64 (≈ +33% over the raw bytes).
const MAX_AUDIO_BYTES = 80 * 1024 * 1024;
// Runs inside the headless page: decode the base64 audio and analyze it.
function inPageAnalyze(data: string) {
const bin = atob(data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const win = window as unknown as {
AudioContext: typeof AudioContext;
webkitAudioContext?: typeof AudioContext;
__hfAnalyze?: (buffer: AudioBuffer) => Promise<HeadlessBeatResult>;
};
if (typeof win.__hfAnalyze !== "function") throw new Error("beat analyzer not loaded");
const ctx = new (win.AudioContext || win.webkitAudioContext!)();
return (
ctx
.decodeAudioData(bytes.buffer)
.then((buf) => win.__hfAnalyze!(buf))
// analyzeMusicFromBuffer also returns the decoded PCM (channelData) + sampleRate;
// project to only the fields we need so page.evaluate doesn't serialize an
// ~8-million-element Float32Array back across the CDP boundary.
.then((r) => ({
beatTimes: r.beatTimes,
beatStrengths: r.beatStrengths,
bpm: r.bpm,
bpmConfidence: r.bpmConfidence,
}))
.finally(() => ctx.close())
);
}
// Load the analyzer bundle into the page, run analysis, and surface in-page
// errors (decode/codec failures, missing global) instead of an opaque rejection.
async function detectOnPage(page: Page, bundle: string, b64: string): Promise<HeadlessBeatResult> {
const pageErrors: string[] = [];
page.on("pageerror", (e) => {
pageErrors.push((e as Error).message);
});
page.on("console", (m) => {
if (m.type() === "error") pageErrors.push(m.text());
});
await page.setContent("<!doctype html><html><body></body></html>");
await page.addScriptTag({ content: bundle });
try {
return (await page.evaluate(inPageAnalyze, b64)) as HeadlessBeatResult;
} catch (err) {
const detail = pageErrors.length ? ` (${pageErrors.join("; ")})` : "";
throw new Error(`${err instanceof Error ? err.message : String(err)}${detail}`);
}
}
/** Decode + analyze the given audio bytes in headless Chrome. */
export async function analyzeBeatsHeadless(audioBytes: Buffer): Promise<HeadlessBeatResult> {
if (audioBytes.length > MAX_AUDIO_BYTES) {
const mb = Math.round(audioBytes.length / 1e6);
throw new Error(
`Audio file too large for headless analysis (${mb}MB > ${MAX_AUDIO_BYTES / 1e6}MB).`,
);
}
const bundle = await buildAnalyzerBundle();
const { ensureBrowser } = await import("../browser/manager.js");
const puppeteer = await import("puppeteer-core");
const browser = await ensureBrowser();
const chrome: Browser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--autoplay-policy=no-user-gesture-required"],
});
try {
const page = await chrome.newPage();
return await detectOnPage(page, bundle, audioBytes.toString("base64"));
} finally {
await chrome.close();
}
}
+55
View File
@@ -0,0 +1,55 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => ({ execSync: vi.fn() }));
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
const mockExec = vi.mocked(execSync);
const mockExists = vi.mocked(existsSync);
// The common-dir fallback list is platform-gated (empty on win32), so pin the
// platform to a POSIX value to keep the test deterministic on Windows CI.
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
vi.resetModules();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
vi.clearAllMocks();
delete process.env.HYPERFRAMES_FFMPEG_PATH;
});
describe("findFFmpeg", () => {
it("prefers the real Windows exe when where lists a cmd shim first", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
mockExec.mockReturnValue("C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n");
const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
});
it("falls back to a common install dir when `which` fails (GUI-launched PATH)", async () => {
// Simulate a process whose PATH lacks /opt/homebrew/bin: `which ffmpeg` throws.
mockExec.mockImplementation(() => {
throw new Error("which: no ffmpeg in PATH");
});
mockExists.mockImplementation((p) => p === "/opt/homebrew/bin/ffmpeg");
const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe("/opt/homebrew/bin/ffmpeg");
});
it("returns undefined when ffmpeg is on neither PATH nor a common dir", async () => {
mockExec.mockImplementation(() => {
throw new Error("not found");
});
mockExists.mockReturnValue(false);
const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBeUndefined();
});
});
+94
View File
@@ -0,0 +1,94 @@
// fallow-ignore-file code-duplication
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
function chooseBestPathCandidate(
name: "ffmpeg" | "ffprobe",
candidates: string[],
): string | undefined {
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
if (normalized.length === 0) return undefined;
const lowerName = name.toLowerCase();
const preferredExe = normalized.find((candidate) =>
candidate.toLowerCase().endsWith(`${lowerName}.exe`),
);
if (preferredExe) return preferredExe;
const exact = normalized.find((candidate) => candidate.toLowerCase().endsWith(lowerName));
if (exact) return exact;
const nonShellShim = normalized.find((candidate) => {
const lower = candidate.toLowerCase();
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
});
return nonShellShim ?? normalized[0];
}
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
try {
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/));
return candidate ? resolve(candidate) : undefined;
} catch {
return undefined;
}
}
// GUI/Dock/launchd-spawned processes on macOS don't inherit the shell PATH, so
// `which ffmpeg` fails even when ffmpeg is installed via Homebrew. Probe the
// well-known install dirs as a fallback. (No-op on Windows, where `where` and
// installer-added PATH entries cover it.)
const COMMON_BIN_DIRS =
process.platform === "win32"
? []
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
function findInCommonDirs(name: "ffmpeg" | "ffprobe"): string | undefined {
for (const dir of COMMON_BIN_DIRS) {
const candidate = `${dir}/${name}`;
if (existsSync(candidate)) return candidate;
}
return undefined;
}
function findConfiguredBinary(
envName: string,
binaryName: "ffmpeg" | "ffprobe",
): string | undefined {
const configured = process.env[envName]?.trim();
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
return findOnPath(binaryName) ?? findInCommonDirs(binaryName);
}
export function findFFmpeg(): string | undefined {
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
}
export function findFFprobe(): string | undefined {
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
}
export function getFFmpegInstallHint(): string {
switch (process.platform) {
case "darwin":
return "brew install ffmpeg";
case "linux": {
// Distro-aware so WSL/Fedora/Arch/Alpine users get a command that
// actually works instead of a Debian-only `apt` line.
const distro = detectLinuxDistro();
return ffmpegInstallCommand(distro.family);
}
case "win32":
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
default:
return "https://ffmpeg.org/download.html";
}
}
+181
View File
@@ -0,0 +1,181 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import {
chromeDepsInstallCommand,
chromeLaunchRemediation,
distroFamilyFromOsRelease,
distroLabel,
ffmpegInstallCommand,
isSharedLibLaunchError,
parseLddMissingLibs,
parseOsRelease,
} from "./linuxDeps.js";
describe("parseOsRelease", () => {
it("parses quoted and unquoted key=value pairs", () => {
const parsed = parseOsRelease(
['NAME="Ubuntu"', "ID=ubuntu", 'ID_LIKE="debian"', 'PRETTY_NAME="Ubuntu 22.04.3 LTS"'].join(
"\n",
),
);
expect(parsed["ID"]).toBe("ubuntu");
expect(parsed["ID_LIKE"]).toBe("debian");
expect(parsed["PRETTY_NAME"]).toBe("Ubuntu 22.04.3 LTS");
});
it("ignores comments and blank lines", () => {
const parsed = parseOsRelease("# a comment\n\nID=fedora\n");
expect(parsed).toEqual({ ID: "fedora" });
});
});
describe("distroFamilyFromOsRelease", () => {
it("maps Ubuntu/Debian derivatives to debian", () => {
expect(distroFamilyFromOsRelease("ubuntu", "debian")).toBe("debian");
expect(distroFamilyFromOsRelease("debian")).toBe("debian");
expect(distroFamilyFromOsRelease("linuxmint", "ubuntu debian")).toBe("debian");
});
it("maps RHEL family to fedora", () => {
expect(distroFamilyFromOsRelease("fedora")).toBe("fedora");
expect(distroFamilyFromOsRelease("rocky", "rhel centos fedora")).toBe("fedora");
expect(distroFamilyFromOsRelease("amzn")).toBe("fedora");
});
it("maps Arch derivatives to arch", () => {
expect(distroFamilyFromOsRelease("arch")).toBe("arch");
expect(distroFamilyFromOsRelease("manjaro", "arch")).toBe("arch");
});
it("maps Alpine to alpine", () => {
expect(distroFamilyFromOsRelease("alpine")).toBe("alpine");
});
it("returns unknown for unrecognized ids", () => {
expect(distroFamilyFromOsRelease("void")).toBe("unknown");
expect(distroFamilyFromOsRelease(undefined, undefined)).toBe("unknown");
});
});
describe("chromeDepsInstallCommand", () => {
it("emits an apt-get line with libnss3 for debian", () => {
const cmd = chromeDepsInstallCommand("debian");
expect(cmd).toContain("apt-get install -y");
expect(cmd).toContain("libnss3");
expect(cmd).toContain("libatk1.0-0");
});
it("emits a dnf line with nss for fedora", () => {
const cmd = chromeDepsInstallCommand("fedora");
expect(cmd).toContain("dnf install -y");
expect(cmd).toContain("nss");
});
it("emits a pacman line for arch", () => {
expect(chromeDepsInstallCommand("arch")).toContain("pacman -S");
});
it("emits an apk line for alpine", () => {
expect(chromeDepsInstallCommand("alpine")).toContain("apk add");
});
it("gives generic guidance for unknown distros", () => {
expect(chromeDepsInstallCommand("unknown").toLowerCase()).toContain("nss");
});
});
describe("ffmpegInstallCommand", () => {
it("uses the distro package manager", () => {
expect(ffmpegInstallCommand("debian")).toBe(
"sudo apt-get update && sudo apt-get install -y ffmpeg",
);
expect(ffmpegInstallCommand("fedora")).toBe("sudo dnf install -y ffmpeg");
expect(ffmpegInstallCommand("arch")).toBe("sudo pacman -S --needed ffmpeg");
expect(ffmpegInstallCommand("alpine")).toBe("sudo apk add ffmpeg");
});
it("gives generic guidance for unknown distros", () => {
expect(ffmpegInstallCommand("unknown").toLowerCase()).toContain("ffmpeg");
});
});
describe("parseLddMissingLibs", () => {
it("collects libraries reported as not found", () => {
const output = [
"\tlinux-vdso.so.1 (0x00007fff...)",
"\tlibnss3.so => not found",
"\tlibatk-1.0.so.0 => not found",
"\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)",
].join("\n");
const result = parseLddMissingLibs(output);
expect(result.ok).toBe(false);
expect(result.missing).toEqual(["libnss3.so", "libatk-1.0.so.0"]);
expect(result.probeUnavailable).toBe(false);
});
it("reports ok when every library resolves", () => {
const output = "\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)";
const result = parseLddMissingLibs(output);
expect(result.ok).toBe(true);
expect(result.missing).toEqual([]);
});
it("does not false-positive on a resolved path containing 'not found'", () => {
// A directory literally named "not found" must not trip the missing-lib
// detection — only the `=> not found` marker counts.
const output = "\tlibfoo.so.1 => /opt/not found/libfoo.so.1 (0x00007f...)";
const result = parseLddMissingLibs(output);
expect(result.ok).toBe(true);
expect(result.missing).toEqual([]);
});
});
describe("distroLabel", () => {
it("returns WSL when running under WSL", () => {
expect(distroLabel({ family: "debian", prettyName: "Ubuntu", isWsl: true })).toBe("WSL");
});
it("returns the pretty name off WSL", () => {
expect(distroLabel({ family: "fedora", prettyName: "Fedora Linux 40", isWsl: false })).toBe(
"Fedora Linux 40",
);
});
it("falls back to Linux when no pretty name", () => {
expect(distroLabel({ family: "unknown", isWsl: false })).toBe("Linux");
});
});
describe("isSharedLibLaunchError", () => {
it("matches the libnss3 cannot-open message", () => {
expect(
isSharedLibLaunchError(
"libnss3.so: cannot open shared object file: No such file or directory",
),
).toBe(true);
});
it("matches the dynamic-linker phrasing", () => {
expect(isSharedLibLaunchError("error while loading shared libraries: libatk-1.0.so.0")).toBe(
true,
);
});
it("does not match unrelated errors", () => {
expect(isSharedLibLaunchError("Composition has zero duration")).toBe(false);
});
});
describe("chromeLaunchRemediation", () => {
it("returns undefined for non-launch errors", () => {
expect(chromeLaunchRemediation("Composition HTML is empty")).toBeUndefined();
});
// Platform-dependent output (Linux distro detection) is covered by the
// preflight tests that mock `platform()`; here we only assert the non-Linux /
// non-launch short-circuits, which are deterministic on the macOS CI host.
it("returns undefined off Linux even for a launch failure", () => {
if (process.platform === "linux") return;
expect(chromeLaunchRemediation("Failed to launch the browser process")).toBeUndefined();
});
});
+350
View File
@@ -0,0 +1,350 @@
// fallow-ignore-file code-duplication
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { detectWSL } from "../telemetry/platform.js";
/**
* Linux/WSL Chrome & ffmpeg dependency detection and remediation.
*
* WSL first-render success is only ~34.7% (vs ~53% local_agent) — the dominant
* cause is a downloaded `chrome-headless-shell` that launches into
* `libnss3.so: cannot open shared object file` (and friends) because the
* headless Chromium shared-library set is not installed. A binary existing on
* disk is NOT sufficient; `doctor`/preflight must verify it can actually load
* its shared libraries and, when it can't, print the exact per-distro install
* command.
*
* Design decision: detect + print precise remediation, do NOT auto-install.
* Auto-install needs sudo + network and is surprising in an agent/CI context;
* remediation is a copy-paste line the user (or their provisioning script) runs.
*/
export type LinuxDistroFamily = "debian" | "fedora" | "arch" | "alpine" | "unknown";
export interface LinuxDistroInfo {
/** Package-manager family used to pick the install command. */
family: LinuxDistroFamily;
/** `ID` from /etc/os-release (e.g. "ubuntu", "debian", "fedora"), if any. */
id?: string;
/** Human-readable name from /etc/os-release `PRETTY_NAME`, if any. */
prettyName?: string;
/** True when running under Windows Subsystem for Linux. */
isWsl: boolean;
}
/**
* Full per-distro package list that provides the headless Chrome dependency
* set. Kept as the complete set (not just the missing ones) so the remediation
* line is a single deterministic, copy-pasteable command that fixes the whole
* class of failure in one shot rather than one library at a time.
*
* The headless Chrome shared-library set (libnss3, libnspr4, libatk,
* at-spi2, cups, libdrm, libxkbcommon, gbm, pango, cairo, alsa, ...) surfaces
* at launch as `error while loading shared libraries: <lib>: cannot open shared
* object file`, which Puppeteer wraps as `Failed to launch the browser process`.
* `ldd` reports the exact missing `.so`; the package list below is what provides
* them.
*/
const DISTRO_PACKAGES: Record<Exclude<LinuxDistroFamily, "unknown">, string[]> = {
debian: [
"libnss3",
"libnspr4",
"libatk1.0-0",
"libatk-bridge2.0-0",
"libcups2",
"libdrm2",
"libxkbcommon0",
"libatspi2.0-0",
"libxcomposite1",
"libxdamage1",
"libxfixes3",
"libxrandr2",
"libgbm1",
"libpango-1.0-0",
"libcairo2",
"libasound2",
],
fedora: [
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libxkbcommon",
"at-spi2-core",
"libXcomposite",
"libXdamage",
"libXfixes",
"libXrandr",
"mesa-libgbm",
"pango",
"cairo",
"alsa-lib",
],
arch: [
"nss",
"nspr",
"atk",
"at-spi2-atk",
"libcups",
"libdrm",
"libxkbcommon",
"at-spi2-core",
"libxcomposite",
"libxdamage",
"libxfixes",
"libxrandr",
"mesa",
"pango",
"cairo",
"alsa-lib",
],
alpine: [
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libxkbcommon",
"at-spi2-core",
"libxcomposite",
"libxdamage",
"libxfixes",
"libxrandr",
"mesa-gbm",
"pango",
"cairo",
"alsa-lib",
],
};
const INSTALL_PREFIX: Record<Exclude<LinuxDistroFamily, "unknown">, string> = {
debian: "sudo apt-get update && sudo apt-get install -y",
fedora: "sudo dnf install -y",
arch: "sudo pacman -S --needed",
alpine: "sudo apk add",
};
/**
* Map an /etc/os-release `ID` / `ID_LIKE` to a package-manager family.
* Exported for direct unit testing without touching the filesystem.
*/
export function distroFamilyFromOsRelease(id?: string, idLike?: string): LinuxDistroFamily {
const haystack = `${id ?? ""} ${idLike ?? ""}`.toLowerCase();
if (/\b(debian|ubuntu|linuxmint|pop|elementary|raspbian|kali)\b/.test(haystack)) return "debian";
if (/\b(fedora|rhel|centos|rocky|almalinux|amzn|ol)\b/.test(haystack)) return "fedora";
if (/\b(arch|manjaro|endeavouros|garuda)\b/.test(haystack)) return "arch";
if (/\b(alpine)\b/.test(haystack)) return "alpine";
return "unknown";
}
/** Parse the shell-style key=value contents of /etc/os-release. */
export function parseOsRelease(contents: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of contents.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
// Strip surrounding single/double quotes.
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) {
value = value.slice(1, -1);
}
if (key) out[key] = value;
}
return out;
}
/**
* Detect the running Linux distribution family and WSL status. Reads
* /etc/os-release; falls back to "unknown" when it can't be read or matched.
*/
export function detectLinuxDistro(): LinuxDistroInfo {
const isWsl = detectWSL();
try {
const contents = readFileSync("/etc/os-release", "utf-8");
const parsed = parseOsRelease(contents);
const family = distroFamilyFromOsRelease(parsed["ID"], parsed["ID_LIKE"]);
return {
family,
...(parsed["ID"] ? { id: parsed["ID"] } : {}),
...(parsed["PRETTY_NAME"] ? { prettyName: parsed["PRETTY_NAME"] } : {}),
isWsl,
};
} catch {
return { family: "unknown", isWsl };
}
}
/**
* Build the exact command to install the full Chrome shared-library set for a
* distro family, or a generic pointer for unknown families.
*/
export function chromeDepsInstallCommand(family: LinuxDistroFamily): string {
if (family === "unknown") {
return "Install the Chrome headless dependencies for your distro (nss, atk, at-spi2, cups, libdrm, libxkbcommon, gbm, pango, cairo, alsa), then re-run.";
}
return `${INSTALL_PREFIX[family]} ${DISTRO_PACKAGES[family].join(" ")}`;
}
/**
* Per-distro ffmpeg install command (ffprobe ships in the same `ffmpeg` package
* on every family we support).
*/
export function ffmpegInstallCommand(family: LinuxDistroFamily): string {
if (family === "unknown") {
return "Install ffmpeg (which includes ffprobe) via your distro package manager, then re-run.";
}
return `${INSTALL_PREFIX[family]} ffmpeg`;
}
/**
* Human-readable environment label for a detected distro — "WSL" when running
* under WSL, else the /etc/os-release PRETTY_NAME, else "Linux". Shared so the
* preflight check and the render-failure remediation name the environment
* identically for the same machine.
*/
export function distroLabel(distro: LinuxDistroInfo): string {
if (distro.isWsl) return "WSL";
return distro.prettyName ?? "Linux";
}
export interface SharedLibProbeResult {
/** True when the probe ran and every required library resolved. */
ok: boolean;
/** Shared libs reported by `ldd` as "not found". Empty when ok. */
missing: string[];
/**
* True when the probe itself could not run (no `ldd`, non-Linux, exec
* failure). Distinct from `ok:false` — we can't conclude libs are missing, so
* callers should not fabricate a false "Chrome broken" error.
*/
probeUnavailable: boolean;
}
/**
* True when a child-process error indicates the process was killed (e.g. by the
* `timeout` option → SIGTERM), whose stdout is only a partial capture.
*/
function isKilledExecError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string | null };
return e.killed === true || e.signal != null;
}
/** Extract captured stdout from a child-process error, if present, as a string. */
function execErrorStdout(err: unknown): string | undefined {
if (typeof err !== "object" || err === null || !("stdout" in err)) return undefined;
const stdout = (err as { stdout?: string | Buffer | null }).stdout;
if (stdout == null) return undefined;
return stdout.toString();
}
/**
* Run `ldd <chromeBinary>` and report any shared libraries the dynamic linker
* cannot resolve (lines containing "=> not found"). This is the check that
* catches the `libnss3.so: cannot open shared object file` launch failure
* BEFORE a render is attempted — a binary can exist on disk yet be unlaunchable.
*
* Only meaningful on Linux. Returns `probeUnavailable:true` on any platform
* where `ldd` isn't applicable or the probe can't run, so callers treat it as
* "inconclusive" rather than "missing".
*/
export function probeChromeSharedLibs(chromeBinaryPath: string): SharedLibProbeResult {
if (process.platform !== "linux") {
return { ok: true, missing: [], probeUnavailable: true };
}
if (!existsSync(chromeBinaryPath)) {
return { ok: true, missing: [], probeUnavailable: true };
}
let output: string;
try {
// ldd exits non-zero when libs are missing but still prints the resolution
// table to stdout, so capture stdout even on failure.
output = execFileSync("ldd", [chromeBinaryPath], {
encoding: "utf-8",
timeout: 5000,
});
} catch (err) {
// A timed-out/killed ldd leaves only a partial resolution table, which we
// must NOT parse as authoritative (would report spurious missing libs).
// Anything other than a clean non-zero exit with full stdout is
// inconclusive.
if (isKilledExecError(err)) {
return { ok: true, missing: [], probeUnavailable: true };
}
const stdout = execErrorStdout(err);
if (stdout == null) {
// `ldd` not installed / not executable — we cannot conclude anything.
return { ok: true, missing: [], probeUnavailable: true };
}
output = stdout;
}
return parseLddMissingLibs(output);
}
/**
* Parse `ldd` output into the set of unresolved libraries. Exported so the
* parsing (the actual logic) is unit-tested without spawning a real process.
*
* A line like `libnss3.so => not found` means the loader can't find it.
*/
export function parseLddMissingLibs(lddOutput: string): SharedLibProbeResult {
const missing: string[] = [];
for (const rawLine of lddOutput.split("\n")) {
const line = rawLine.trim();
// Match the exact unresolved marker `=> not found` — NOT a bare "not found"
// substring, which would false-positive on a resolved lib whose path
// happens to contain that text (e.g. `libfoo.so => /opt/not found/...`).
if (!/=>\s*not found\b/.test(line)) continue;
const soname = line.split("=>")[0]?.trim();
if (soname) missing.push(soname);
}
return { ok: missing.length === 0, missing, probeUnavailable: false };
}
/**
* True when an error message is the "Chrome couldn't load its shared libraries"
* launch failure — the exact class `doctor` remediates. Matches both the raw
* dynamic-linker text and Puppeteer's wrapper.
*/
export function isSharedLibLaunchError(message: string): boolean {
return (
/cannot open shared object file/i.test(message) ||
/error while loading shared libraries/i.test(message) ||
/lib[\w.+-]*\.so[\w.]*: cannot open/i.test(message)
);
}
/**
* Turn a cryptic Chrome launch failure into actionable, per-distro guidance.
* Returns the remediation block to show the user, or `undefined` when the error
* isn't a shared-library/launch failure this module can help with.
*/
export function chromeLaunchRemediation(errorMessage: string): string | undefined {
const isLaunchFailure =
/Failed to launch the browser process/i.test(errorMessage) ||
isSharedLibLaunchError(errorMessage);
if (!isLaunchFailure) return undefined;
if (process.platform !== "linux") return undefined;
// On Linux ARM64 the render browser is system Chromium (see
// ensureLinuxArmBrowser in manager.ts), so a launch failure there is almost
// always a missing/mis-pathed chromium — NOT the headless-shell .so set. The
// shared-lib install line would be wrong advice, so defer to that path's own
// guidance instead of emitting it here.
if (process.arch === "arm64") return undefined;
const distro = detectLinuxDistro();
const lines: string[] = [];
lines.push(
`Chrome could not launch on ${distroLabel(distro)} — this is almost always missing system libraries (e.g. libnss3).`,
);
lines.push("Install the Chrome headless dependencies:");
lines.push(` ${chromeDepsInstallCommand(distro.family)}`);
lines.push("Then verify with: npx hyperframes doctor");
return lines.join("\n");
}
+697
View File
@@ -0,0 +1,697 @@
// fallow-ignore-file code-duplication
/**
* Browser-binary resolution tests for `findBrowser()`.
*
* The CLI's `ensureBrowser` is responsible for picking the Chrome binary the
* engine will be launched with. There are two real-world failure modes this
* suite guards against:
*
* 1. `chrome-headless-shell` is installed in the puppeteer cache (the
* directory the engine itself reads), but the CLI used to only scan its
* own `~/.cache/hyperframes/chrome` cache — leaving the engine without a
* headless-shell binary and silently disabling the BeginFrame capture
* path.
* 2. The CLI falls back to system Chrome (`/usr/bin/google-chrome`) on
* Linux, which still launches successfully but has dropped
* `HeadlessExperimental.enable` — again disabling the BeginFrame path
* with no user-visible signal.
*
* Each test stubs filesystem + `@puppeteer/browsers` access using `vi.doMock`
* + dynamic import (the same pattern other modules in this package use, e.g.
* `background-removal/manager.test.ts`) so we don't touch the real
* `HOME` cache.
*/
import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CHROME_VERSION } from "./manager.js";
// Use `path.join` so the fake paths line up with whatever separator Node's
// real `path.join` produces in `manager.ts` on the host running the test
// (forward slashes on Linux/macOS, backslashes on Windows CI). Hardcoded
// `/fake/home/...` literals would fail on Windows because the set lookup
// would never match the `\\`-joined real paths.
const FAKE_HOME = join("/", "fake", "home");
const CACHE_ROOT = join(FAKE_HOME, ".cache", "hyperframes");
const HF_CACHE = join(FAKE_HOME, ".cache", "hyperframes", "chrome");
const HF_LOCK = join(CACHE_ROOT, ".chrome.install.lock");
const HF_RECLAIM_LOCK = join(CACHE_ROOT, ".chrome.install.reclaim.lock");
const PUPPETEER_CACHE = join(FAKE_HOME, ".cache", "puppeteer", "chrome-headless-shell");
const PUPPETEER_BINARY = join(
PUPPETEER_CACHE,
"linux-148.0.7778.97",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const HF_BINARY = join(
HF_CACHE,
"chrome-headless-shell",
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const SYSTEM_CHROME = "/usr/bin/google-chrome";
const TEST_LOCK_TIMINGS = {
staleMs: 50,
pollMs: 5,
heartbeatMs: 10,
waitNoticeMs: 1_000,
};
interface FsMockOptions {
existing: ReadonlySet<string>;
/** map of dir path -> entries returned by readdirSync */
dirs?: Record<string, string[]>;
touchError?: Error;
}
function installFsMocks({ existing, dirs, touchError }: FsMockOptions) {
// Mutable, and returned, so tests can pre-seed a "lock already held" path or
// assert the lock dir doesn't leak after ensureBrowser resolves.
const paths = new Set(existing);
const mtimes = new Map([...existing].map((p) => [p, 0]));
vi.doMock("node:fs", () => ({
existsSync: (p: string) => paths.has(p),
readdirSync: (p: string) => {
const entries = dirs?.[p];
if (!entries) throw new Error(`ENOENT: readdirSync mock had no entry for ${p}`);
return entries;
},
mkdirSync: (p: string, opts?: { recursive?: boolean }) => {
if (!opts?.recursive && paths.has(p)) {
const err = new Error(`EEXIST: file already exists, mkdir '${p}'`);
(err as NodeJS.ErrnoException).code = "EEXIST";
throw err;
}
paths.add(p);
mtimes.set(p, Date.now());
},
rmSync: (p: string) => {
// Real rmSync({recursive:true}) removes the target AND everything under
// it; the mock's flat path Set has no real tree structure, so simulate
// that by also dropping any tracked path nested under `p`.
for (const existingPath of [...paths]) {
if (existingPath === p || existingPath.startsWith(p + sep)) {
paths.delete(existingPath);
mtimes.delete(existingPath);
}
}
},
statSync: (p: string) => {
if (!paths.has(p)) {
const err = new Error(`ENOENT: no such file or directory, stat '${p}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return { mtimeMs: mtimes.get(p) ?? 0 };
},
utimesSync: (p: string, _atime: Date, mtime: Date) => {
if (touchError) throw touchError;
if (!paths.has(p)) {
const err = new Error(`ENOENT: no such file or directory, utimes '${p}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
mtimes.set(p, mtime.getTime());
},
}));
vi.doMock("node:os", () => ({
homedir: () => FAKE_HOME,
platform: () => "linux",
arch: () => "x64",
}));
return paths;
}
function installPuppeteerBrowsersMock(
opts: {
installedInHfCache?: Array<{
browser: string;
executablePath: string;
path?: string;
buildId?: string;
}>;
installedInHfCacheError?: Error;
installResult?: { executablePath: string };
installImpl?: () => Promise<{ executablePath: string }>;
} = {},
) {
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => "linux",
getInstalledBrowsers: opts.installedInHfCacheError
? vi.fn().mockRejectedValue(opts.installedInHfCacheError)
: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
install: vi
.fn()
.mockImplementation(
opts.installImpl ?? (async () => opts.installResult ?? { executablePath: HF_BINARY }),
),
}));
}
function installChildProcessMocks() {
vi.doMock("node:child_process", () => ({
execSync: vi.fn(() => {
throw new Error("not found");
}),
spawnSync: vi.fn(),
}));
}
describe("findBrowser — cache resolution", () => {
const origPlatform = process.platform;
const origArch = process.arch;
beforeEach(() => {
vi.resetModules();
// Force Linux for the system-fallback warning assertions. The
// `Object.defineProperty` dance is needed because `process.platform` is a
// getter on Node — direct assignment is silently a no-op.
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
delete process.env["HYPERFRAMES_BROWSER_PATH"];
installChildProcessMocks();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:os");
vi.doUnmock("node:child_process");
vi.doUnmock("@puppeteer/browsers");
});
it("resolves to the hyperframes-managed cache when puppeteer cache is empty", async () => {
// Only HF cache populated. Puppeteer cache is the higher-priority path
// (see "prefers puppeteer cache" test below), so this exercises the
// last-resort fallback.
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: CHROME_VERSION },
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
});
it("does not resolve to a hyperframes-cache build from an older CHROME_VERSION pin", async () => {
// A build downloaded by a prior hyperframes version (this pin has moved
// 131 -> 151 -> 152 across releases) must not satisfy resolution, or an
// upgrade silently keeps running a stale build forever instead of ever
// fetching the version the new release actually needs (HF#2060 review).
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, SYSTEM_CHROME]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: "131.0.6778.85" },
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).not.toBe(HF_BINARY);
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
});
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
const redownloadedBinary = join(
HF_CACHE,
"chrome-headless-shell",
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"redownloaded-chrome-headless-shell",
);
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
// The stale install DIR is present (extraction got partway through, e.g. an
// ABOUT/LICENSE-only extract) even though the exe itself is missing —
// exercises the purge-before-redownload fix, not just the redownload path.
const paths = installFsMocks({ existing: new Set([HF_CACHE, staleInstallDir]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{
browser: "chrome-headless-shell",
executablePath: HF_BINARY,
path: staleInstallDir,
buildId: CHROME_VERSION,
},
],
installResult: { executablePath: redownloadedBinary },
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
// The stale directory must be gone before @puppeteer/browsers' install()
// sees it — otherwise install() throws "folder exists but exe missing"
// instead of re-extracting (the exact bug both feedback reports hit).
expect(paths.has(staleInstallDir)).toBe(false);
});
it("ensureBrowser({force: true}) purges the whole cache before downloading, bypassing any cache/system shortcut", async () => {
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
// A HEALTHY cached binary AND system Chrome are both present — force must
// ignore both shortcuts and always re-download, which is the whole point
// of the flag (the reported bug: --force did nothing, a stale dir kept
// winning over every retry).
const paths = installFsMocks({
existing: new Set([HF_CACHE, HF_BINARY, staleInstallDir, SYSTEM_CHROME]),
});
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, path: staleInstallDir },
],
installResult: { executablePath: downloadedBinary },
});
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser({ force: true });
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
// clearBrowser() wipes prior contents; withInstallLock uses a sibling lock
// outside CACHE_DIR, so assert the purge on what was actually INSIDE it,
// not the directory's own existence.
expect(paths.has(staleInstallDir)).toBe(false);
expect(paths.has(HF_BINARY)).toBe(false);
});
it("serializes concurrent force downloads so one purge cannot delete another installer's lock", async () => {
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_CACHE, HF_BINARY]) });
let activeInstalls = 0;
let maxActiveInstalls = 0;
installPuppeteerBrowsersMock({
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
installImpl: async () => {
activeInstalls += 1;
maxActiveInstalls = Math.max(maxActiveInstalls, activeInstalls);
expect(paths.has(HF_LOCK)).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 20));
activeInstalls -= 1;
return { executablePath: downloadedBinary };
},
});
const { ensureBrowser } = await import("./manager.js");
await expect(
Promise.all([ensureBrowser({ force: true }), ensureBrowser({ force: true })]),
).resolves.toEqual([
{ executablePath: downloadedBinary, source: "download" },
{ executablePath: downloadedBinary, source: "download" },
]);
expect(maxActiveInstalls).toBe(1);
expect(paths.has(HF_LOCK)).toBe(false);
});
it("ensureBrowser does not leak the install lock directory after a successful download", async () => {
// Regression: @puppeteer/browsers' install() has no concurrency guard —
// two CLI invocations that both miss the cache AND system Chrome (the
// reported scenario: two `hyperframes browser ensure` runs racing) hit
// ensureBrowser's final download-of-last-resort at once, racing on the
// same extract target. mkdirSync as an atomic mutex closes that race;
// this asserts the lock is actually released afterward (a leaked lock
// would permanently wedge every future render on this machine).
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "downloaded");
// Cache dir exists but is empty (no manifest entries) — distinct from the
// ENOTDIR "cache unreadable" case, which falls back to system instead.
const paths = installFsMocks({ existing: new Set([HF_CACHE]) });
installPuppeteerBrowsersMock({
installedInHfCache: [],
installResult: { executablePath: downloadedBinary },
});
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser();
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock reclaims a lock held past the timeout instead of hanging forever", async () => {
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const { withInstallLock } = await import("./manager.js");
const result = await withInstallLock(async () => "done", TEST_LOCK_TIMINGS);
expect(result).toBe("done");
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock does not reclaim another waiter's fresh lock after this waiter timed out", async () => {
// Regression guard for the timeout-reclaim race: if multiple waiters cross
// the stale-lock deadline together, waiter A can reclaim the stale lock and
// acquire a fresh one. Waiter B's old deadline is still expired, but it must
// not delete A's fresh lock.
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const { withInstallLock } = await import("./manager.js");
const reclaimOnlyTimings = { ...TEST_LOCK_TIMINGS, heartbeatMs: 1_000 };
const first = withInstallLock(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
return "first";
}, reclaimOnlyTimings);
const second = withInstallLock(async () => "second", reclaimOnlyTimings);
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
expect(paths.has(HF_LOCK)).toBe(false);
expect(paths.has(HF_RECLAIM_LOCK)).toBe(false);
});
it("withInstallLock does not let a second caller run concurrently with a slow-but-alive holder", async () => {
const paths = installFsMocks({ existing: new Set([CACHE_ROOT]) });
const { withInstallLock } = await import("./manager.js");
let concurrent = 0;
let maxConcurrent = 0;
const trackConcurrency = async (label: string, durationMs: number) => {
concurrent += 1;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((resolve) => setTimeout(resolve, durationMs));
concurrent -= 1;
return label;
};
const first = withInstallLock(() => trackConcurrency("first", 120), TEST_LOCK_TIMINGS);
await new Promise((resolve) => setTimeout(resolve, 5)); // let `first` acquire the lock
const second = withInstallLock(() => trackConcurrency("second", 10), TEST_LOCK_TIMINGS);
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
expect(maxConcurrent).toBe(1);
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock reports progress while waiting instead of staying silent", async () => {
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { withInstallLock } = await import("./manager.js");
await withInstallLock(async () => "done", { ...TEST_LOCK_TIMINGS, waitNoticeMs: 20 });
expect(paths.has(HF_LOCK)).toBe(false);
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Waiting for another hyperframes process"),
),
).toBe(true);
});
it("keeps the holder running when a heartbeat cannot touch the lock", async () => {
installFsMocks({
existing: new Set([CACHE_ROOT]),
touchError: Object.assign(new Error("EACCES"), { code: "EACCES" }),
});
const { withInstallLock } = await import("./manager.js");
await expect(
withInstallLock(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
return "done";
}, TEST_LOCK_TIMINGS),
).resolves.toBe("done");
});
it("warns and falls through when the hyperframes cache cannot be read", async () => {
installFsMocks({ existing: new Set([HF_CACHE, SYSTEM_CHROME]) });
installPuppeteerBrowsersMock({
installedInHfCacheError: Object.assign(new Error("ENOTDIR: not a directory"), {
code: "ENOTDIR",
}),
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
expect(warnSpy.mock.calls[0]?.[0]).toContain("Browser cache read failed (ENOTDIR)");
expect(warnSpy.mock.calls[0]?.[0]).toContain("Falling back to system Chrome");
});
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
// Empty hyperframes cache, populated puppeteer cache — the regression
// scenario from the hf#677 spike.
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
});
it("prefers the puppeteer cache over the hyperframes cache when BOTH are populated", async () => {
// The HF cache is pinned to `CHROME_VERSION` (131-era) which lags upstream
// by many releases. The engine's `resolveHeadlessShellPath` scans the
// puppeteer cache and selects newest-version-first; if the CLI handed
// engine the older HF-cache binary while a newer puppeteer-cache binary
// exists, the two would silently disagree on which binary to use.
// This test pins the priority: puppeteer cache wins when both are populated.
installFsMocks({
existing: new Set([HF_CACHE, HF_BINARY, PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock({
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
expect(result?.source).toBe("cache");
});
it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => {
const olderBinary = join(
PUPPETEER_CACHE,
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]),
dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
});
it("uses numeric (not lexicographic) version ordering — linux-148 beats linux-99", async () => {
// Regression guard for the lexicographic-sort bug: `"linux-99..."` sorts
// after `"linux-148..."` character-by-character (because `'9' > '1'`),
// which would have caused the CLI to hand engine an ancient 99-era binary
// when a fresh 148 was sitting right next to it. Numeric semver-style
// ordering is the only correct semantic.
const linux99Binary = join(
PUPPETEER_CACHE,
"linux-99.0.6533.123",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, linux99Binary]),
// Intentionally list the entries in an order that would expose the bug
// under naive `.sort().reverse()` (which puts `linux-99...` first).
dirs: { [PUPPETEER_CACHE]: ["linux-99.0.6533.123", "linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
});
it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => {
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
installPuppeteerBrowsersMock();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
expect(warnSpy).toHaveBeenCalledTimes(1);
const message = warnSpy.mock.calls[0]?.[0];
expect(message).toContain(SYSTEM_CHROME);
expect(message).toContain("HeadlessExperimental");
expect(message).toContain("chrome-headless-shell");
});
it("does NOT warn when the system path happens to be chrome-headless-shell", async () => {
// HYPERFRAMES_BROWSER_PATH-style override pointing directly at a
// headless-shell binary should NOT trigger the system-Chrome warning. The
// warning is gated on the binary name, not the path source.
const directShell = "/opt/chrome-headless-shell/chrome-headless-shell";
installFsMocks({ existing: new Set([directShell]) });
installPuppeteerBrowsersMock();
process.env["HYPERFRAMES_BROWSER_PATH"] = directShell;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(directShell);
expect(warnSpy).not.toHaveBeenCalled();
});
it("does NOT warn on macOS when falling back to system Chrome", async () => {
// macOS Chrome still works fine for the screenshot path and the perf
// claims around BeginFrame are Linux-only — keep the warning Linux-scoped
// so darwin users don't get spammed about a "fix" that doesn't apply.
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
const darwinChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
installFsMocks({ existing: new Set([darwinChrome]) });
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => "mac_arm",
getInstalledBrowsers: vi.fn().mockResolvedValue([]),
install: vi.fn(),
}));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(darwinChrome);
expect(warnSpy).not.toHaveBeenCalled();
});
it("only warns once across repeated findBrowser() calls", async () => {
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
installPuppeteerBrowsersMock();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
await findBrowser();
await findBrowser();
await findBrowser();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
describe("isCorruptArchiveError", () => {
it("matches truncated / corrupt archive extraction failures", async () => {
const { isCorruptArchiveError } = await import("./manager.js");
for (const msg of [
"invalid end-of-central-directory record",
"end of central directory record signature not found",
"invalid or corrupt zip file",
"File is not a zip file",
"unexpected end of file",
"the archive is corrupted",
]) {
expect(isCorruptArchiveError(new Error(msg))).toBe(true);
}
});
it("does not match network or unrelated errors", async () => {
const { isCorruptArchiveError } = await import("./manager.js");
for (const msg of ["ECONNRESET", "socket hang up", "ENOENT: no such file", "boom"]) {
expect(isCorruptArchiveError(new Error(msg))).toBe(false);
}
});
});
describe("installWithCorruptArchiveRecovery", () => {
it("clears the cache and re-downloads once on a corrupt archive, then succeeds", async () => {
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
const runInstall = vi
.fn()
.mockRejectedValueOnce(new Error("invalid end-of-central-directory record"))
.mockResolvedValueOnce({ executablePath: "/ok" });
const clearCache = vi.fn();
const onRecover = vi.fn();
const result = await installWithCorruptArchiveRecovery(runInstall, clearCache, onRecover);
expect(result).toEqual({ executablePath: "/ok" });
expect(runInstall).toHaveBeenCalledTimes(2);
expect(clearCache).toHaveBeenCalledTimes(1);
expect(onRecover).toHaveBeenCalledTimes(1);
});
it("propagates a non-corruption error without clearing the cache", async () => {
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
const runInstall = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
const clearCache = vi.fn();
await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow(
"ECONNRESET",
);
expect(runInstall).toHaveBeenCalledTimes(1);
expect(clearCache).not.toHaveBeenCalled();
});
it("does not retry forever: a second corruption propagates", async () => {
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
const runInstall = vi.fn().mockRejectedValue(new Error("end of central directory not found"));
const clearCache = vi.fn();
await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow(
"end of central directory",
);
expect(runInstall).toHaveBeenCalledTimes(2);
expect(clearCache).toHaveBeenCalledTimes(1);
});
});
// Regression guard for HF#2103: `hyperframes render` hung forever on macOS
// (Apple Silicon) under Node >= 24.16. Root cause was NOT in this file — it was
// the extractor `@puppeteer/browsers` <3.0.2 shells out to. That chain
// (`@puppeteer/browsers` -> `extract-zip@2.0.1` -> `yauzl@2.10.0`) hits a
// classic-stream backpressure regression (nodejs/node#63487) that surfaces a
// latent fd-slicer `destroy()` bug in yauzl 2.x (yauzl#169): the inflate read
// stream stalls partway through the first entry large enough to cross the write
// highWaterMark, never emits `end`, and `stream.pipeline` never settles — so
// extraction busy-spins forever, leaving a half-extracted cache with no
// executable (puppeteer/puppeteer#14957).
//
// `@puppeteer/browsers` 3.0.2 dropped `extract-zip` as a dependency and now
// extracts with `modern-tar` by default (`yauzl` lingers only as an optional
// peer fallback — no longer a runtime dependency), which is the fix. This test
// fails if a dependency change ever drags the pin back below 3.x — i.e.
// reintroduces the broken extractor as a hard dependency.
describe("@puppeteer/browsers pin (HF#2103 extractor-hang regression guard)", () => {
it("stays on the major (>= 3) that dropped extract-zip and no longer depends on yauzl", async () => {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const pkg = require("@puppeteer/browsers/package.json") as {
version: string;
dependencies?: Record<string, string>;
};
const major = Number.parseInt(pkg.version.split(".")[0] ?? "0", 10);
expect(major).toBeGreaterThanOrEqual(3);
// Belt and suspenders: the durable fix is the *absence* of the broken
// extractor, not just a version number, so assert it directly.
const deps = pkg.dependencies ?? {};
expect(deps["extract-zip"]).toBeUndefined();
expect(deps["yauzl"]).toBeUndefined();
});
});
+671
View File
@@ -0,0 +1,671 @@
// fallow-ignore-file code-duplication
import { execSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs";
import { basename } from "node:path";
import { homedir } from "node:os";
import { join } from "node:path";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
type PuppeteerBrowsers = typeof import("@puppeteer/browsers");
async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
try {
return await import("@puppeteer/browsers");
} catch (err) {
const cause = normalizeErrorMessage(err);
throw new Error(
`Failed to load @puppeteer/browsers: ${cause}\n` +
`Fix: run \`npm install\` or \`bun install\` to restore missing packages, then retry.`,
);
}
}
const CHROME_VERSION = "152.0.7928.2";
const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes");
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
// Puppeteer's managed cache — where `@puppeteer/browsers install
// chrome-headless-shell` (and `puppeteer install`) drop binaries. The engine's
// `resolveHeadlessShellPath` scans the same directory; the CLI must look here
// too or it silently picks system Chrome over a perfectly good headless-shell.
const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
// `@puppeteer/browsers`' install() has no concurrency guard of its own — two
// CLI invocations that both miss the cache at the same time both extract into
// the same target directory simultaneously. A killed/interrupted extraction
// from that race can leave a binary that merely *exists* (so doctor/lint/
// validate all report healthy) while missing bits a clean install sets (e.g.
// macOS Gatekeeper/quarantine + GPU/Metal entitlements) — reported as headless
// GPU frame capture silently returning all-black frames despite --browser-gpu
// auto/hardware, invisible until someone inspects the actual pixels.
//
// mkdirSync is atomic (EEXIST if another process already holds it), so it
// doubles as a zero-dependency cross-process mutex — no lockfile library needed.
const INSTALL_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.lock");
const INSTALL_RECLAIM_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.reclaim.lock");
const INSTALL_LOCK_TIMINGS = {
staleMs: 120_000,
pollMs: 200,
heartbeatMs: 15_000,
waitNoticeMs: 10_000,
};
interface InstallLockTimings {
staleMs: number;
pollMs: number;
heartbeatMs: number;
waitNoticeMs: number;
}
function isErrno(err: unknown, code: string): boolean {
return (err as NodeJS.ErrnoException).code === code;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function tryAcquireDirLock(lockDir: string): boolean {
try {
// recursive:false is load-bearing: it's what makes this throw EEXIST
// (and therefore act as a mutex) instead of silently no-op'ing like
// `mkdir -p` when the lock dir already exists.
mkdirSync(lockDir, { recursive: false });
return true;
} catch (err) {
if (!isErrno(err, "EEXIST")) throw err;
return false;
}
}
function reclaimStaleInstallLock(timeoutMs: number): void {
if (!tryAcquireDirLock(INSTALL_RECLAIM_LOCK_DIR)) return;
try {
const mtimeMs = statSync(INSTALL_LOCK_DIR).mtimeMs;
if (Date.now() - mtimeMs > timeoutMs) {
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
}
} catch (err) {
if (!isErrno(err, "ENOENT")) throw err;
} finally {
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
}
}
function touchInstallLock(): void {
try {
const now = new Date();
utimesSync(INSTALL_LOCK_DIR, now, now);
} catch {
// ponytail: heartbeat is best-effort; stale-lock reclaim remains the fallback.
}
}
export async function withInstallLock<T>(
fn: () => Promise<T>,
timings: InstallLockTimings = INSTALL_LOCK_TIMINGS,
): Promise<T> {
// recursive:false below needs the parent to already exist (unlike `mkdir -p`).
// Keep lock dirs outside CACHE_DIR so force-clearing the Chrome cache cannot
// delete another installer's in-flight lock.
if (!existsSync(CACHE_ROOT_DIR)) mkdirSync(CACHE_ROOT_DIR, { recursive: true });
let deadline = Date.now() + timings.staleMs;
const waitStart = Date.now();
let lastNoticeMs = 0;
for (;;) {
if (existsSync(INSTALL_RECLAIM_LOCK_DIR)) {
await sleep(timings.pollMs);
continue;
}
if (tryAcquireDirLock(INSTALL_LOCK_DIR)) {
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
break;
}
const waitedMs = Date.now() - waitStart;
if (waitedMs - lastNoticeMs >= timings.waitNoticeMs) {
lastNoticeMs = waitedMs;
console.warn(
`[browser] Waiting for another hyperframes process to finish installing chrome-headless-shell (${Math.round(waitedMs / 1000)}s elapsed)...`,
);
}
if (Date.now() > deadline) {
// The reclaim gate matters when multiple waiters cross the timeout at
// once: without it, waiter A can delete the stale lock and acquire a
// fresh one, then waiter B (whose old deadline also expired) can delete
// A's fresh lock. The gate serializes reclaimers, and the mtime re-check
// after the gate prevents deleting a fresh lock another waiter just won.
reclaimStaleInstallLock(timings.staleMs);
deadline = Date.now() + timings.staleMs;
continue;
}
await sleep(timings.pollMs);
}
const heartbeat = setInterval(touchInstallLock, timings.heartbeatMs);
if (typeof heartbeat.unref === "function") heartbeat.unref();
try {
return await fn();
} finally {
clearInterval(heartbeat);
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
}
}
export type BrowserSource = "env" | "cache" | "system" | "download";
export interface BrowserResult {
executablePath: string;
source: BrowserSource;
}
export interface EnsureBrowserOptions {
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
// Purge any cached HF-managed download before resolving, so a stale or
// partially-extracted install can't make the retry look like a no-op.
force?: boolean;
// Always resolve to OUR pinned `CHROME_VERSION` build (cached, or freshly
// downloaded) — skip both the shared puppeteer-cache preference (some other
// tool's install, arbitrary version) and system Chrome (tracks Stable,
// arbitrary version, doesn't get updated in lockstep with this codebase).
// Rendering behavior should not vary with whatever Chrome happens to be
// sitting on the machine: it's the version we've actually tested against,
// and the one that implements `canvas.drawElementImage` (Dev/Canary-only —
// Stable doesn't have it, so system Chrome used to crash drawElement-
// eligible renders outright; HF#2060). `HYPERFRAMES_BROWSER_PATH` still
// wins over this — an explicit override is still an explicit override.
preferManagedChrome?: boolean;
}
interface CacheLookupResult {
result?: BrowserResult;
staleHyperframesCachePath?: string;
// Root install-folder path for the stale entry (InstalledBrowser#path), NOT
// the missing executablePath above — this is what actually needs deleting.
staleInstallPath?: string;
}
/**
* Remove one browser version's install directory (not the whole CACHE_DIR).
* @puppeteer/browsers' install() treats an existing-but-incomplete directory
* as already installed and throws "folder exists but executable is missing"
* rather than re-extracting — so an extraction interrupted by a Windows AV
* lock, a sleep/wake cycle, or ctrl-C (left with only alphabetically-early
* files like ABOUT/LICENSE, no exe) wedges every subsequent ensure/render
* with the same error until someone manually deletes the directory. Purging
* it first makes the retry actually retry.
*/
function purgeStaleInstall(installPath: string): void {
rmSync(installPath, { recursive: true, force: true });
}
// --- 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 cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const first = output
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
return first || undefined;
} catch {
return undefined;
}
}
function findFromEnv(): BrowserResult | undefined {
const envPath = process.env["HYPERFRAMES_BROWSER_PATH"];
if (envPath && existsSync(envPath)) {
return { executablePath: envPath, source: "env" };
}
return undefined;
}
/**
* Hyperframes-managed cache only (populated by `ensureBrowser` as a
* download-of-last-resort, pinned to `CHROME_VERSION`).
*/
async function findFromHyperframesCache(): Promise<CacheLookupResult> {
if (!existsSync(CACHE_DIR)) return {};
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
// A corrupt cache (stub file where a browser dir is expected, malformed
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
// browser" so resolution falls through to system/download instead of
// crashing every caller.
let installed: Awaited<ReturnType<typeof getInstalledBrowsers>>;
try {
installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const suffix = code ? ` (${code})` : "";
console.warn(
`[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`,
);
installed = [];
}
// Match on buildId too, not just browser type — an install left over from
// an older hyperframes version (this pin has moved 131 → 151 → 152 across
// releases) must NOT satisfy resolution, or an upgrade silently keeps
// running whatever build happened to be cached instead of ever fetching
// the version this release actually needs (HF#2060 review).
const match = installed.find(
(b) => b.browser === Browser.CHROMEHEADLESSSHELL && b.buildId === CHROME_VERSION,
);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { staleHyperframesCachePath: match.executablePath, staleInstallPath: match.path };
}
return {};
}
async function findFromCache(): Promise<CacheLookupResult> {
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
// chrome-headless-shell` lands, and where `puppeteer install` from a project
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
// `resolveHeadlessShellPath` reads from here and selects newest-version-
// first; the CLI must match that semantic or it will silently hand the
// engine an older binary than the engine itself would pick.
//
// We intentionally check puppeteer BEFORE the hyperframes-managed cache:
// this is the non-`preferManagedChrome` path, which exists so a user who
// installed chrome-headless-shell separately (via `@puppeteer/browsers
// install`) keeps using that binary instead of being silently switched to
// the HF-pinned one. Note `CHROME_VERSION` (above) is a Dev-channel pin
// that may be NEWER than a user's puppeteer-cache Stable build — this is
// about respecting an explicit prior choice, not "newest wins".
const fromPuppeteer = findFromPuppeteerCache();
if (fromPuppeteer) {
return { result: fromPuppeteer };
}
// 2) Hyperframes-managed cache. This is the fallback path: only reached
// when no puppeteer-cache binary exists.
return findFromHyperframesCache();
}
/**
* Parse a puppeteer-cache version directory name (`linux-148.0.7778.97`,
* `mac_arm-131.0.6778.85`, etc.) into a numeric tuple for ordering.
*
* Lexicographic sort on these strings is buggy because `"99"` > `"148"` (the
* `9` outranks the `1` character-wise), so a 99-era binary would beat a
* 148-era binary in `.sort().reverse()`. We split on `-` to drop the platform
* prefix, then on `.` to get integer segments. Returns `undefined` for names
* that don't have at least one parseable numeric segment so they sort last.
*/
function parseVersionSegments(versionDir: string): number[] | undefined {
const dashIdx = versionDir.indexOf("-");
const versionPart = dashIdx >= 0 ? versionDir.slice(dashIdx + 1) : versionDir;
const segments = versionPart.split(".");
const parsed: number[] = [];
for (const seg of segments) {
const n = parseInt(seg, 10);
if (!Number.isFinite(n)) {
// Stop at the first non-numeric segment but keep what we've collected.
break;
}
parsed.push(n);
}
return parsed.length > 0 ? parsed : undefined;
}
/** Numeric semver-style descending comparator for puppeteer cache dirs. */
function compareVersionDirsDescending(a: string, b: string): number {
const pa = parseVersionSegments(a);
const pb = parseVersionSegments(b);
// Unparseable names sort after parseable ones (so we still try them, just last).
if (!pa && !pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const av = pa[i] ?? 0;
const bv = pb[i] ?? 0;
if (av !== bv) return bv - av; // descending (newest first)
}
return 0;
}
function findFromPuppeteerCache(): BrowserResult | undefined {
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
let versions: string[];
try {
// Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()`
// (the previous implementation, still in engine `resolveHeadlessShellPath`)
// mis-orders `linux-99...` ahead of `linux-148...` because character `'9'`
// outranks `'1'`. See `parseVersionSegments` above.
versions = [...readdirSync(PUPPETEER_CACHE_DIR)].sort(compareVersionDirsDescending);
} catch {
return undefined;
}
for (const version of versions) {
// Same shape as `resolveHeadlessShellPath` in engine/browserManager.ts —
// keep them aligned. If puppeteer ever changes the on-disk layout the two
// need to move together.
const candidates = [
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(
PUPPETEER_CACHE_DIR,
version,
"chrome-headless-shell-mac-arm64",
"chrome-headless-shell",
),
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
join(
PUPPETEER_CACHE_DIR,
version,
"chrome-headless-shell-win64",
"chrome-headless-shell.exe",
),
];
for (const binary of candidates) {
if (existsSync(binary)) {
return { executablePath: binary, source: "cache" };
}
}
}
return undefined;
}
/**
* True iff the binary at `executablePath` is `chrome-headless-shell` (i.e. the
* Chromium build that still exposes `HeadlessExperimental.enable` /
* `beginFrame`). Regular Chrome and `chromium` have dropped those domains, so
* the engine's perf-optimized BeginFrame capture path silently degrades to
* screenshot mode when those are used.
*/
function isHeadlessShellBinary(executablePath: string): boolean {
const name = basename(executablePath).toLowerCase();
return name === "chrome-headless-shell" || name === "chrome-headless-shell.exe";
}
/**
* Emit a one-time warning when the CLI selects a non-headless-shell binary on
* Linux. Idempotent across repeated `findBrowser()` calls so a long-running
* `hyperframes studio` process doesn't get spammed.
*/
let _warnedSystemFallback = false;
function warnSystemFallbackOnce(executablePath: string): void {
if (_warnedSystemFallback) return;
if (process.platform !== "linux") return;
if (isHeadlessShellBinary(executablePath)) return;
_warnedSystemFallback = true;
console.warn(
`[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell\n(Or set HYPERFRAMES_BROWSER_PATH to point at an existing chrome-headless-shell binary.)`,
);
}
/** Test-only: reset the one-shot warn latch. */
export function _resetSystemFallbackWarnForTests(): void {
_warnedSystemFallback = false;
}
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.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
try {
return await withInstallLock(async () => {
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
return downloadBrowser();
});
} catch (err) {
const cause = normalizeErrorMessage(err);
throw new Error(
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Run \`hyperframes browser ensure --force\` to re-download.`,
);
}
}
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
}
return fromSystem;
}
/**
* On Linux ARM64, attempt to auto-install system Chromium if not found.
* This makes `hyperframes render` work out-of-the-box on DGX Spark / GB10 / Jetson.
*/
async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
void options;
// If already available (env var or system path), use it directly.
const existing = await findBrowser();
if (existing) return existing;
// Try auto-installing via apt (common on Ubuntu-based ARM systems).
const hasApt = existsSync("/usr/bin/apt-get");
if (hasApt) {
console.error(
"\n🔍 Linux ARM64 detected — Chrome Headless Shell is not available for this platform.",
);
console.error("📦 Auto-installing system Chromium via apt-get (this only happens once)...\n");
// Use spawnSync so output streams to the terminal in real time.
const result = spawnSync("apt-get", ["install", "-y", "chromium-browser"], {
stdio: "inherit",
timeout: 120_000,
});
if (result.status === 0) {
const afterInstall = await findBrowser();
if (afterInstall) {
console.error(`\n✅ Chromium installed at ${afterInstall.executablePath}\n`);
return afterInstall;
}
} else {
// apt succeeded but binary not found, or apt failed — fall through to helpful error.
console.error("\n⚠️ apt-get exited with errors. Trying anyway...\n");
const afterAttempt = await findBrowser();
if (afterAttempt) return afterAttempt;
}
}
// Could not auto-install — give clear manual instructions.
throw new Error(
`Chrome Headless Shell is not available for Linux ARM64 (DGX Spark, GB10, Jetson).\n\n` +
`Install Chromium manually and point hyperframes to it:\n\n` +
` sudo apt-get install -y chromium-browser\n` +
` export HYPERFRAMES_BROWSER_PATH=$(which chromium-browser)\n\n` +
`Then re-run your command. The HYPERFRAMES_BROWSER_PATH env var persists for the session.`,
);
}
/**
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
* With `preferManagedChrome`: env var -> OUR pinned cache -> auto-download
* (puppeteer-cache preference and system Chrome are both skipped).
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
if (!options?.force) {
const fromCache = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
return withInstallLock(async () => {
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
return downloadBrowser(options);
});
}
if (!options?.preferManagedChrome) {
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
return fromSystem;
}
}
}
return withInstallLock(async () => {
if (options?.force) {
// `--force` means "always get a fresh managed download" — purging the
// whole HF-managed cache after acquiring the install lock keeps two
// concurrent force retries from deleting each other's in-flight lock or
// partially extracted install.
clearBrowser();
}
// Re-check after acquiring the lock: a concurrent invocation may have
// finished installing while we were waiting, in which case reuse its
// result instead of downloading and extracting a second time. Skipped
// under --force, which already purged and always wants a fresh download.
if (!options?.force) {
const afterLock = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
if (afterLock.result) return afterLock.result;
if (afterLock.staleInstallPath) purgeStaleInstall(afterLock.staleInstallPath);
}
return downloadBrowser(options);
});
}
/**
* True when `err` is a corrupt/truncated-archive extraction failure, as opposed
* to a network error or a genuine platform problem. A partially-downloaded or
* interrupted browser archive left in the cache makes `install()`'s extraction
* throw "invalid end-of-central-directory" (a zip whose central directory is
* missing/truncated). Left unhandled, that hard-blocks every render on the box
* until the user manually clears the cache — so we detect it and re-download.
*/
export function isCorruptArchiveError(err: unknown): boolean {
const msg = normalizeErrorMessage(err).toLowerCase();
return (
msg.includes("end of central directory") ||
msg.includes("end-of-central-directory") ||
msg.includes("invalid or corrupt") ||
msg.includes("corrupt zip") ||
msg.includes("not a zip") ||
msg.includes("unexpected end of") ||
msg.includes("corrupted")
);
}
/**
* Run a browser install; if it fails because the cached archive is corrupt,
* clear the cache (dropping the bad archive) and retry the download exactly
* once. Non-corruption errors propagate unchanged, and a second corruption
* propagates too (no infinite retry).
*/
export async function installWithCorruptArchiveRecovery<T>(
runInstall: () => Promise<T>,
clearCache: () => void,
onRecover?: (err: unknown) => void,
): Promise<T> {
try {
return await runInstall();
} catch (err) {
if (!isCorruptArchiveError(err)) throw err;
onRecover?.(err);
clearCache();
return await runInstall();
}
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
const platform = detectBrowserPlatform();
if (!platform) {
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
const runInstall = () =>
install({
cacheDir: CACHE_DIR,
browser: Browser.CHROMEHEADLESSSHELL,
buildId: CHROME_VERSION,
platform,
downloadProgressCallback: options?.onProgress,
});
const installed = await installWithCorruptArchiveRecovery(
runInstall,
() => {
rmSync(CACHE_DIR, { recursive: true, force: true });
mkdirSync(CACHE_DIR, { recursive: true });
},
(err) =>
console.warn(
`[hyperframes] Cached browser archive was corrupt (${normalizeErrorMessage(err)}); clearing the cache and re-downloading.`,
),
);
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 function isLinuxArm(): boolean {
return process.platform === "linux" && process.arch === "arm64";
}
export { CHROME_VERSION, CACHE_DIR };
+189
View File
@@ -0,0 +1,189 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseToolVersion, runEnvironmentChecks } from "./preflight.js";
import * as manager from "./manager.js";
import * as linuxDeps from "./linuxDeps.js";
describe("runEnvironmentChecks", () => {
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
beforeEach(() => {
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
});
afterEach(() => {
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
});
it("returns configured FFmpeg and FFprobe paths when checks pass", async () => {
const result = await runEnvironmentChecks();
expect(result.outcomes.find((outcome) => outcome.name === "FFmpeg")?.ok).toBe(true);
expect(result.outcomes.find((outcome) => outcome.name === "FFprobe")?.ok).toBe(true);
expect(result.ffmpegPath).toBe(process.execPath);
expect(result.ffprobePath).toBe(process.execPath);
});
it("reports ffprobe as a render-blocking error when the explicit path is missing", async () => {
process.env.HYPERFRAMES_FFPROBE_PATH = "/missing/ffprobe.exe";
const result = await runEnvironmentChecks();
expect(result.outcomes).toContainEqual(
expect.objectContaining({
name: "FFprobe",
ok: false,
level: "error",
title: "FFprobe not found",
}),
);
expect(result.ffprobePath).toBeUndefined();
});
it("fails early when an explicit FFmpeg env override points at a missing file", async () => {
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
const result = await runEnvironmentChecks();
const ffmpeg = result.outcomes.find((outcome) => outcome.name === "FFmpeg");
expect(ffmpeg).toMatchObject({
ok: false,
detail: 'Configured path does not exist: HYPERFRAMES_FFMPEG_PATH="/missing/ffmpeg.exe"',
});
});
it("validates an explicit browser path without needing browser discovery", async () => {
const result = await runEnvironmentChecks({
includeBrowser: true,
browserPath: process.execPath,
});
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
ok: true,
path: process.execPath,
});
});
it("reports Chrome as not found (no throw) when browser discovery throws on a corrupt cache", async () => {
const spy = vi.spyOn(manager, "findBrowser").mockRejectedValue(
Object.assign(new Error("ENOTDIR: not a directory, scandir 'chrome-headless-shell'"), {
code: "ENOTDIR",
}),
);
try {
const result = await runEnvironmentChecks({ includeBrowser: true });
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
ok: false,
title: "Chrome not found",
hint: "Run: npx hyperframes browser ensure",
});
expect(result.browser).toBeUndefined();
} finally {
spy.mockRestore();
}
});
it("reports an explicit missing browser path before render starts", async () => {
const result = await runEnvironmentChecks({
includeBrowser: true,
browserPath: "/missing/chrome-headless-shell.exe",
});
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
ok: false,
title: "Chrome not found",
});
});
});
describe("runEnvironmentChecks — Chrome shared libraries (Linux/WSL)", () => {
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
vi.restoreAllMocks();
});
it("downgrades a found Chrome to a render-blocking error when libs are missing", async () => {
vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: "/root/.cache/hyperframes/chrome-headless-shell",
source: "cache",
});
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
ok: false,
missing: ["libnss3.so", "libatk-1.0.so.0"],
probeUnavailable: false,
});
vi.spyOn(linuxDeps, "detectLinuxDistro").mockReturnValue({
family: "debian",
id: "ubuntu",
prettyName: "Ubuntu 22.04.3 LTS",
isWsl: true,
});
const result = await runEnvironmentChecks({ includeBrowser: true });
const chrome = result.outcomes.find((o) => o.name === "Chrome");
expect(chrome).toMatchObject({
ok: false,
level: "error",
title: "Chrome cannot launch (missing system libraries)",
});
expect(chrome?.detail).toContain("WSL");
expect(chrome?.detail).toContain("libnss3.so");
expect(chrome?.hint).toContain("apt-get install -y");
expect(chrome?.hint).toContain("libnss3");
// A lib-broken Chrome must NOT be handed to the render pipeline as usable.
expect(result.browser).toBeUndefined();
});
it("keeps Chrome ok when the shared-lib probe passes", async () => {
vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: "/usr/bin/chromium",
source: "system",
});
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
ok: true,
missing: [],
probeUnavailable: false,
});
const result = await runEnvironmentChecks({ includeBrowser: true });
expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true });
expect(result.browser?.executablePath).toBe("/usr/bin/chromium");
});
it("keeps Chrome ok when the probe is inconclusive (no ldd)", async () => {
vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: "/usr/bin/chromium",
source: "system",
});
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
ok: false,
missing: [],
probeUnavailable: true,
});
const result = await runEnvironmentChecks({ includeBrowser: true });
expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true });
});
});
describe("parseToolVersion", () => {
it("extracts ffprobe versions with Windows build suffixes", () => {
expect(parseToolVersion("ffprobe version 7.1.1-essentials_build-www.gyan.dev Copyright")).toBe(
"ffprobe 7.1.1-essentials_build-www.gyan.dev",
);
});
});
+277
View File
@@ -0,0 +1,277 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { findBrowser, type BrowserResult } from "./manager.js";
import {
FFMPEG_PATH_ENV,
FFPROBE_PATH_ENV,
findFFmpeg,
findFFprobe,
getFFmpegInstallHint,
} from "./ffmpeg.js";
import {
chromeDepsInstallCommand,
detectLinuxDistro,
distroLabel,
probeChromeSharedLibs,
} from "./linuxDeps.js";
import { getFreeDiskMb } from "../telemetry/system.js";
export type EnvironmentCheckLevel = "ok" | "warn" | "error";
export interface EnvironmentCheckOutcome {
name: string;
ok: boolean;
detail: string;
level: EnvironmentCheckLevel;
title?: string;
hint?: string;
path?: string;
}
export interface EnvironmentCheckResult {
outcomes: EnvironmentCheckOutcome[];
ffmpegPath?: string;
ffprobePath?: string;
browser?: BrowserResult;
}
export interface EnvironmentCheckOptions {
projectDir?: string;
browserPath?: string;
includeBrowser?: boolean;
includeDisk?: boolean;
includeWindowsUnc?: boolean;
}
export function parseToolVersion(raw: string): string {
const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i);
return m ? `${m[1]} ${m[2]}` : raw.trim();
}
function configuredMissingDetail(envName: string): string | undefined {
const configured = process.env[envName]?.trim();
if (!configured || existsSync(configured)) return undefined;
return `Configured path does not exist: ${envName}="${configured}"`;
}
function readToolVersion(binaryPath: string): string {
try {
const raw =
execFileSync(binaryPath, ["-version"], { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ??
"";
const version = parseToolVersion(raw);
return version ? `${version} at ${binaryPath}` : binaryPath;
} catch {
return binaryPath;
}
}
function checkFFmpeg(): EnvironmentCheckOutcome {
const missingConfigured = configuredMissingDetail(FFMPEG_PATH_ENV);
if (missingConfigured) {
return {
name: "FFmpeg",
ok: false,
level: "error",
title: "FFmpeg not found",
detail: missingConfigured,
hint: getFFmpegInstallHint(),
};
}
const path = findFFmpeg();
if (path) {
return { name: "FFmpeg", ok: true, level: "ok", detail: readToolVersion(path), path };
}
return {
name: "FFmpeg",
ok: false,
level: "error",
title: "FFmpeg not found",
detail: "FFmpeg is required to encode video. The render cannot proceed without it.",
hint: getFFmpegInstallHint(),
};
}
function checkFFprobe(): EnvironmentCheckOutcome {
const missingConfigured = configuredMissingDetail(FFPROBE_PATH_ENV);
if (missingConfigured) {
return {
name: "FFprobe",
ok: false,
level: "error",
title: "FFprobe not found",
detail: missingConfigured,
hint: getFFmpegInstallHint(),
};
}
const path = findFFprobe();
if (path) {
return { name: "FFprobe", ok: true, level: "ok", detail: readToolVersion(path), path };
}
return {
name: "FFprobe",
ok: false,
level: "error",
title: "FFprobe not found",
detail:
"FFprobe is required to probe media assets. It ships with FFmpeg but was not found on PATH.",
hint: getFFmpegInstallHint(),
};
}
/**
* A Chrome binary can exist on disk yet be unlaunchable because its system
* shared libraries (libnss3, libatk, ...) aren't installed — the dominant WSL
* first-render failure. When that's the case, downgrade the "found" outcome to
* a render-blocking error carrying the exact per-distro install command, so the
* user hits it in `doctor`/pre-flight instead of a cryptic
* `Failed to launch the browser process` mid-render. No-op off Linux and when
* `ldd` can't run (probe inconclusive).
*/
function chromeSharedLibOutcome(
executablePath: string,
found: EnvironmentCheckOutcome,
): EnvironmentCheckOutcome {
if (process.platform !== "linux") return found;
const probe = probeChromeSharedLibs(executablePath);
if (probe.probeUnavailable || probe.ok) return found;
const distro = detectLinuxDistro();
return {
name: "Chrome",
ok: false,
level: "error",
title: "Chrome cannot launch (missing system libraries)",
detail: `Chrome at ${executablePath} is missing shared libraries on ${distroLabel(distro)}: ${probe.missing.join(", ")}`,
hint: chromeDepsInstallCommand(distro.family),
path: executablePath,
};
}
async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcome> {
if (browserPath) {
if (existsSync(browserPath)) {
return chromeSharedLibOutcome(browserPath, {
name: "Chrome",
ok: true,
level: "ok",
detail: `explicit: ${browserPath}`,
path: browserPath,
});
}
return {
name: "Chrome",
ok: false,
level: "error",
title: "Chrome not found",
detail: `Chrome binary not found at "${browserPath}".`,
hint: "Run: npx hyperframes browser ensure",
};
}
// A corrupt/partial browser cache (stub files where a version dir is
// expected, missing executable, malformed metadata) makes findBrowser throw.
// That is the exact condition this check exists to report, so treat any
// failure as "Chrome not found" rather than letting it crash the caller
// (notably `doctor`, which is documented to exit 0 even when checks fail).
let info: Awaited<ReturnType<typeof findBrowser>>;
try {
info = await findBrowser();
} catch {
info = undefined;
}
if (info) {
return chromeSharedLibOutcome(info.executablePath, {
name: "Chrome",
ok: true,
level: "ok",
detail: `${info.source}: ${info.executablePath}`,
path: info.executablePath,
});
}
return {
name: "Chrome",
ok: false,
level: "error",
title: "Chrome not found",
detail: "Chrome Headless Shell is required for local rendering.",
hint: "Run: npx hyperframes browser ensure",
};
}
function checkDisk(projectDir = "."): EnvironmentCheckOutcome {
const freeMb = getFreeDiskMb(projectDir);
if (freeMb === null) {
return { name: "Disk", ok: true, level: "ok", detail: "Unable to check" };
}
const freeGb = (freeMb / 1024).toFixed(1);
if (freeMb < 1024) {
return {
name: "Disk",
ok: false,
level: "error",
title: "Low disk space",
detail: `${freeGb} GB free`,
hint: "Renders produce large temp files. Free disk space before rendering.",
};
}
return { name: "Disk", ok: true, level: "ok", detail: `${freeGb} GB free` };
}
function checkWindowsUncPath(projectDir = process.cwd()): EnvironmentCheckOutcome | undefined {
if (platform() !== "win32") return undefined;
if (!projectDir.startsWith("\\\\")) return undefined;
return {
name: "Windows path",
ok: true,
level: "warn",
detail: `UNC path: ${projectDir}`,
hint: "Chrome may fail to launch from a network share. Use a local drive if render startup fails.",
};
}
export async function runEnvironmentChecks(
options: EnvironmentCheckOptions = {},
): Promise<EnvironmentCheckResult> {
const outcomes: EnvironmentCheckOutcome[] = [];
const ffmpeg = checkFFmpeg();
outcomes.push(ffmpeg);
const ffprobe = checkFFprobe();
outcomes.push(ffprobe);
let browser: BrowserResult | undefined;
if (options.includeBrowser) {
const chrome = await checkChrome(options.browserPath);
outcomes.push(chrome);
if (chrome.ok && chrome.path) {
browser = {
executablePath: chrome.path,
source: options.browserPath ? "env" : "cache",
};
}
}
if (options.includeDisk) {
outcomes.push(checkDisk(options.projectDir));
}
if (options.includeWindowsUnc) {
const unc = checkWindowsUncPath(options.projectDir);
if (unc) outcomes.push(unc);
}
return {
outcomes,
...(ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}),
...(ffprobe.path ? { ffprobePath: ffprobe.path } : {}),
...(browser ? { browser } : {}),
};
}
@@ -0,0 +1,213 @@
/**
* Generate AGENTS.md and CLAUDE.md for captured website projects.
*
* Writes the same content to both filenames so any AI agent auto-discovers it:
* - AGENTS.md — universal convention (Cursor, Codex, Gemini CLI, Windsurf, Aider, Jules)
* - CLAUDE.md — Claude Code convention
*
* This file generates a DATA INVENTORY that tells the AI agent what files
* exist and what they contain. The actual workflow lives in the
* website-to-video skill — this file points agents there.
*/
import { writeFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import type { DesignTokens } from "./types.js";
import type { AnimationCatalog } from "./animationCataloger.js";
import type { CatalogedAsset } from "./assetCataloger.js";
/**
* Infer a human-readable role hint from a hex color based on luminance and saturation.
* Not a substitute for DESIGN.md — just helps orient agents scanning the brand summary.
*/
// fallow-ignore-next-line complexity
function inferColorRole(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
if (isNaN(r) || isNaN(g) || isNaN(b)) return "color";
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const saturation = max === 0 ? 0 : (max - min) / max;
if (luminance < 0.04) return "bg-dark";
if (luminance > 0.9) return "bg-light";
if (saturation > 0.4 && luminance > 0.05 && luminance < 0.7) return "accent";
if (luminance < 0.2) return "surface-dark";
if (luminance > 0.7) return "surface-light";
return "neutral";
}
export function generateAgentPrompt(
outputDir: string,
url: string,
tokens: DesignTokens,
_animations: AnimationCatalog | undefined, // reserved for future animation summary
hasScreenshot: boolean,
hasLottie?: boolean,
hasShaders?: boolean,
_catalogedAssets?: CatalogedAsset[], // reserved for future asset inventory
_detectedLibraries?: string[],
): void {
const prompt = buildPrompt(outputDir, url, tokens, hasScreenshot, hasLottie, hasShaders);
writeFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
writeFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
writeFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
}
// fallow-ignore-next-line complexity
function buildPrompt(
outputDir: string,
url: string,
tokens: DesignTokens,
hasScreenshot: boolean,
hasLottie?: boolean,
hasShaders?: boolean,
): string {
const title = tokens.title || new URL(url).hostname.replace(/^www\./, "");
const colorSummary = tokens.colors
.slice(0, 10)
.map((hex) => `${hex} (${inferColorRole(hex)})`)
.join(", ");
const fontSummary =
tokens.fonts
.map(
(f) =>
f.family +
(f.variable && f.weightRange
? ` (${f.weightRange[0]}-${f.weightRange[1]} variable)`
: f.weights.length > 0
? ` (${f.weights.join(",")})`
: ""),
)
.join(", ") || "none detected";
// Build the data inventory table rows
// Helper: find all contact sheet pages for a given base name. Matches the
// exact base file plus paginated variants only (e.g. `contact-sheet.jpg`,
// `contact-sheet-2.jpg`, `contact-sheet-3.jpg`). The "-NNN" suffix is digits
// only, so unrelated files that happen to share the prefix (notably the
// `contact-sheet-svgs.jpg` SVG fallback sheet in assets/) don't get mixed in.
function contactSheetRows(dir: string, baseFile: string, label: string): string[] {
const fullDir = join(outputDir, dir);
if (!existsSync(fullDir)) return [];
const baseName = baseFile.replace(/\.jpg$/, "");
// Escape regex metacharacters in baseName so future callers can pass
// filenames containing `.`, `+`, `(`, etc. without the regex breaking.
const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const paginatedRe = new RegExp(`^${escapedBase}(?:-(\\d+))?\\.jpg$`);
// Sort by the numeric page suffix so `contact-sheet-10.jpg` lands after
// `contact-sheet-2.jpg`, not before (default string sort orders them
// lexicographically and breaks at 10+ pages). Unpaginated `contact-sheet.jpg`
// gets page 0 so it sorts first if it co-exists with paginated files.
const all = readdirSync(fullDir)
.filter((f) => paginatedRe.test(f))
.map((f) => ({ name: f, page: parseInt(f.match(paginatedRe)?.[1] ?? "0", 10) }))
.sort((a, b) => a.page - b.page)
.map((entry) => entry.name);
if (all.length === 0) return [];
if (all.length === 1) {
return [`| \`${dir}/${all[0]}\` | ${label} |`];
}
return all.map((f, i) => `| \`${dir}/${f}\` | ${label} — page ${i + 1} of ${all.length} |`);
}
const tableRows: string[] = [];
if (hasScreenshot) {
const screenshotRows = contactSheetRows(
"screenshots",
"contact-sheet.jpg",
"**View this first.** All scroll screenshots in labeled grid — see the entire page at a glance",
);
if (screenshotRows.length > 0) {
tableRows.push(...screenshotRows);
} else {
tableRows.push(
"| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in one labeled grid. |",
);
}
tableRows.push(
"| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |",
);
}
tableRows.push(
`| \`extracted/tokens.json\` | Design tokens: ${tokens.colors.length} colors, ${tokens.fonts.length} fonts, ${tokens.headings?.length ?? 0} headings, ${tokens.ctas?.length ?? 0} CTAs |`,
);
// design-styles.json is written from a try/catch in capture/index.ts and
// gets skipped when the live-DOM style extraction fails. Only list it in the
// agent prompt when it actually exists, so the agent isn't pointed at a 404.
if (existsSync(join(outputDir, "extracted", "design-styles.json"))) {
tableRows.push(
"| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |",
);
}
tableRows.push(
"| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |",
);
tableRows.push(
"| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |",
);
if (hasLottie) {
tableRows.push(
"| `extracted/lottie-manifest.json` | Lottie animations with previews at `assets/lottie/previews/`. |",
);
}
if (hasShaders) {
tableRows.push("| `extracted/shaders.json` | WebGL shader source (GLSL). |");
}
// Asset contact sheets — dynamically list all pages
const assetSheetRows = contactSheetRows(
"assets",
"contact-sheet.jpg",
"Downloaded images in labeled grid — view before opening individual files",
);
if (assetSheetRows.length > 0) {
tableRows.push(...assetSheetRows);
} else {
tableRows.push("| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |");
}
// SVG contact sheets — check both assets/svgs/ and assets/ root fallback
const svgSubdirRows = contactSheetRows(
"assets/svgs",
"contact-sheet.jpg",
"SVGs rendered as thumbnails in labeled grid",
);
const svgRootRows = contactSheetRows(
"assets",
"contact-sheet-svgs.jpg",
"SVGs rendered as thumbnails in labeled grid",
);
const svgRows = svgSubdirRows.length > 0 ? svgSubdirRows : svgRootRows;
if (svgRows.length > 0) {
tableRows.push(...svgRows);
}
tableRows.push("| `assets/` | Individual downloaded images, SVGs, and font files. |");
// Brand summary — just the essentials
const brandLines: string[] = [];
brandLines.push(`- **Colors**: ${colorSummary || "see tokens.json"}`);
brandLines.push(`- **Fonts**: ${fontSummary}`);
return `# ${title}
Source: ${url}
To create a video from this capture, use the \`website-to-video\` skill.
## What's in This Capture
| File | Contents |
|------|----------|
${tableRows.join("\n")}
## Brand Summary
${brandLines.join("\n")}
`;
}
@@ -0,0 +1,232 @@
/**
* Catalog all animations on a rendered page.
*
* Captures:
* 1. Web Animations API — active animations with full keyframes + timing
* 2. CSS animation/transition declarations via getComputedStyle
* 3. IntersectionObserver targets (scroll-triggered elements)
* 4. CDP Animation domain events
*
* The catalog is saved as animations.json and gives Claude Code
* everything needed to recreate animations in GSAP.
*
* NOTE: Must be used on a page with ALL scripts running (not stripped).
* Call setupAnimationCapture() BEFORE page.goto() for IO patching.
* Call collectAnimationCatalog() AFTER page has loaded and settled.
*/
import type { Page, CDPSession } from "puppeteer-core";
export interface AnimationCatalog {
/** Active animations via document.getAnimations() — includes keyframes */
webAnimations: WebAnimationEntry[];
/** Elements with CSS animation/transition properties declared */
cssDeclarations: CssAnimationEntry[];
/** Elements being watched by IntersectionObserver (scroll triggers) */
scrollTargets: ScrollTarget[];
/** CDP Animation domain events captured during page lifecycle */
cdpAnimations: CdpAnimationEntry[];
/** Total counts summary */
summary: {
webAnimations: number;
cssDeclarations: number;
scrollTargets: number;
cdpAnimations: number;
canvases: number;
};
}
export interface WebAnimationEntry {
type: string;
playState: string;
animationName?: string;
targetSelector?: string;
targetRect?: { x: number; y: number; width: number; height: number };
keyframes?: Array<Record<string, string | number | null>>;
timing?: {
duration: number;
delay: number;
iterations: number;
easing: string;
direction: string;
};
}
export interface CssAnimationEntry {
selector: string;
animation?: { name: string; duration: string; easing: string };
transition?: { property: string; duration: string };
}
export interface ScrollTarget {
selector: string;
rect: { top: number; height: number; width: number };
}
export interface CdpAnimationEntry {
id: string;
name: string;
type: string;
duration?: number;
delay?: number;
}
/**
* Set up animation capture hooks BEFORE navigating to the page.
* This patches IntersectionObserver to track scroll-triggered elements.
*/
export async function setupAnimationCapture(page: Page): Promise<void> {
await page.evaluateOnNewDocument(`
window.__hf_io_targets = [];
var OrigIO = window.IntersectionObserver;
window.IntersectionObserver = function(callback, options) {
var observer = new OrigIO(callback, options);
var origObserve = observer.observe.bind(observer);
observer.observe = function(target) {
var sel = target.id ? '#' + target.id : target.tagName.toLowerCase();
if (target.className && typeof target.className === 'string') {
var cls = Array.from(target.classList).slice(0, 2).join('.');
if (cls) sel += '.' + cls;
}
try {
var rect = target.getBoundingClientRect();
window.__hf_io_targets.push({
selector: sel,
rect: { top: Math.round(rect.top + window.scrollY), height: Math.round(rect.height), width: Math.round(rect.width) }
});
} catch(e) {}
return origObserve(target);
};
return observer;
};
window.IntersectionObserver.prototype = OrigIO.prototype;
`);
}
/**
* Start CDP Animation domain listener.
* Returns the CDPSession and a reference to the captured array.
*/
export async function startCdpAnimationCapture(
page: Page,
): Promise<{ cdp: CDPSession; animations: CdpAnimationEntry[] }> {
const cdp = await page.createCDPSession();
await cdp.send("Animation.enable");
const animations: CdpAnimationEntry[] = [];
cdp.on("Animation.animationStarted", (event: any) => {
animations.push({
id: event.animation.id,
name: event.animation.name || "",
type: event.animation.type,
duration: event.animation.source?.duration,
delay: event.animation.source?.delay,
});
});
return { cdp, animations };
}
/**
* Collect the full animation catalog after page has loaded and settled.
* Should be called after scrolling through the page to trigger all animations.
*/
export async function collectAnimationCatalog(
page: Page,
cdpAnimations: CdpAnimationEntry[],
cdp: CDPSession,
): Promise<AnimationCatalog> {
// Scroll through page to trigger scroll-based animations
await page.evaluate(`(async () => {
var height = document.body.scrollHeight;
for (var y = 0; y < height; y += window.innerHeight * 0.5) {
window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 400); });
}
window.scrollTo(0, 0);
await new Promise(function(r) { setTimeout(r, 1000); });
})()`);
// Collect from Web Animations API + computed styles + IO targets
const result = (await page.evaluate(`(() => {
var webAnimations = [];
var cssDeclarations = [];
// 1. Web Animations API
try {
var anims = document.getAnimations();
webAnimations = anims.map(function(anim) {
var r = { type: anim.constructor.name, playState: anim.playState, animationName: anim.animationName || null };
var effect = anim.effect;
if (effect && effect.target) {
var t = effect.target;
r.targetSelector = t.id ? '#' + t.id : t.tagName.toLowerCase();
if (t.className && typeof t.className === 'string') {
var cls = Array.from(t.classList).slice(0, 3).join('.');
if (cls) r.targetSelector += '.' + cls;
}
try { r.targetRect = t.getBoundingClientRect().toJSON(); } catch(e) {}
}
if (effect && typeof effect.getKeyframes === 'function') {
try { r.keyframes = effect.getKeyframes(); } catch(e) {}
}
if (effect && typeof effect.getComputedTiming === 'function') {
try {
var timing = effect.getComputedTiming();
r.timing = { duration: timing.duration, delay: timing.delay, iterations: timing.iterations, easing: timing.easing, direction: timing.direction };
} catch(e) {}
}
return r;
});
} catch(e) {}
// 2. CSS animation/transition scan
var allEls = document.querySelectorAll('*');
for (var i = 0; i < allEls.length && i < 5000; i++) {
var el = allEls[i];
try {
var cs = getComputedStyle(el);
var hasAnim = cs.animationName && cs.animationName !== 'none';
var hasTrans = cs.transitionProperty && cs.transitionProperty !== 'all' && cs.transitionProperty !== 'none' && cs.transitionDuration !== '0s';
if (hasAnim || hasTrans) {
var sel = el.id ? '#' + el.id : el.tagName.toLowerCase();
if (el.className && typeof el.className === 'string') {
var cls = Array.from(el.classList).slice(0, 2).join('.');
if (cls) sel += '.' + cls;
}
var entry = { selector: sel };
if (hasAnim) entry.animation = { name: cs.animationName, duration: cs.animationDuration, easing: cs.animationTimingFunction };
if (hasTrans) entry.transition = { property: cs.transitionProperty, duration: cs.transitionDuration };
cssDeclarations.push(entry);
}
} catch(e) {}
}
// 3. IO targets (collected by monkey-patch)
var scrollTargets = (window.__hf_io_targets || []).map(function(t) {
return { selector: t.selector, rect: t.rect };
});
// 4. Canvas summary
var canvasCount = document.querySelectorAll('canvas').length;
return { webAnimations: webAnimations, cssDeclarations: cssDeclarations, scrollTargets: scrollTargets, canvasCount: canvasCount };
})()`)) as any;
// Stop CDP listener
await cdp.send("Animation.disable");
return {
webAnimations: result.webAnimations,
cssDeclarations: result.cssDeclarations,
scrollTargets: result.scrollTargets,
cdpAnimations,
summary: {
webAnimations: result.webAnimations.length,
cssDeclarations: result.cssDeclarations.length,
scrollTargets: result.scrollTargets.length,
cdpAnimations: cdpAnimations.length,
canvases: result.canvasCount,
},
};
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { annotateGifAssetMetadata, type CatalogedAsset } from "./assetCataloger.js";
function u16(value: number): number[] {
return [value & 0xff, (value >> 8) & 0xff];
}
function ascii(value: string): number[] {
return Array.from(value).map((char) => char.charCodeAt(0));
}
function frame(delayCentiseconds: number): number[] {
return [
0x21,
0xf9,
0x04,
0x00,
...u16(delayCentiseconds),
0x00,
0x00,
0x2c,
0x00,
0x00,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0x02,
0x02,
0x4c,
0x01,
0x00,
];
}
function gif(frames: number[], loopCount?: number): Uint8Array {
const loop =
loopCount === undefined
? []
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
return Uint8Array.from([
...ascii("GIF89a"),
...u16(1),
...u16(1),
0x00,
0x00,
0x00,
...loop,
...frames,
0x3b,
]);
}
describe("annotateGifAssetMetadata", () => {
it("adds frame, duration, and loop notes for animated GIF assets", async () => {
const assets: CatalogedAsset[] = [
{
url: "https://cdn.example.com/reaction.gif?v=1",
type: "Image",
contexts: ["img[src]"],
notes: "reaction",
},
{
url: "https://cdn.example.com/logo.png",
type: "Image",
contexts: ["img[src]"],
},
];
const readUrls: string[] = [];
const annotated = await annotateGifAssetMetadata(assets, async (url) => {
readUrls.push(url);
return gif([...frame(5), ...frame(15)], 0);
});
expect(readUrls).toEqual(["https://cdn.example.com/reaction.gif?v=1"]);
expect(annotated[0]?.notes).toBe("reaction; animated GIF: 2 frames, 0.200s, loops forever");
expect(annotated[1]?.notes).toBeUndefined();
});
it("marks single-frame GIF assets without changing non-GIF assets", async () => {
const assets: CatalogedAsset[] = [
{
url: "https://cdn.example.com/still.gif",
type: "Image",
contexts: ["img[src]"],
},
{
url: "https://cdn.example.com/hero.webp",
type: "Image",
contexts: ["img[src]"],
notes: "hero",
},
];
const annotated = await annotateGifAssetMetadata(assets, async () => gif(frame(10)));
expect(annotated[0]?.notes).toBe("single-frame GIF");
expect(annotated[1]?.notes).toBe("hero");
});
});
+381
View File
@@ -0,0 +1,381 @@
/**
* Comprehensive asset cataloger.
*
* Scans rendered HTML and CSS for every referenced asset (images, videos,
* fonts, icons, stylesheets, backgrounds) and records the HTML context
* where each was found (e.g., img[src], css url(), link[rel=preload]).
*
* This is the programmatic Part 1 of DESIGN.md generation — deterministic
* extraction, no AI involved.
*/
import type { Page } from "puppeteer-core";
import { parseAnimatedGifMetadata } from "@hyperframes/core";
export interface CatalogedAsset {
url: string;
type: "Image" | "Video" | "Font" | "Icon" | "Background" | "Other";
contexts: string[];
notes?: string;
/** Alt text, figcaption, or aria-label */
description?: string;
/** Nearest heading (h1-h4) text */
nearestHeading?: string;
/** Parent section/container class names */
sectionClasses?: string;
/** Whether the image is above the fold (visible without scrolling) */
aboveFold?: boolean;
/** Element sits inside <header>, <nav>, or [role="banner"] — logo signal */
inBanner?: boolean;
/** Element sits inside <a> with site-root href ("/", "#", origin-only) — brand-home link */
inHomeLink?: boolean;
/** alt/aria-label/title contains the brand segment of document.title */
matchesTitleBrand?: boolean;
}
/**
* Extract all referenced assets from the rendered page with their HTML contexts.
*/
export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
const assets = await page.evaluate(`(() => {
var assetMap = {};
// Extract rich DOM context from any element (heading, section, position)
function getElementContext(el) {
var ctx = {};
// Alt text, aria-label, figcaption
var desc = el.alt || el.getAttribute('aria-label') || el.getAttribute('title') || '';
var fig = el.closest('figure');
if (fig) {
var cap = fig.querySelector('figcaption');
if (cap) desc = desc || cap.textContent.trim().slice(0, 100);
}
var ariaBy = el.getAttribute('aria-describedby');
if (ariaBy) {
var descEl = document.getElementById(ariaBy);
if (descEl) desc = desc || descEl.textContent.trim().slice(0, 100);
}
if (desc) ctx.description = desc.slice(0, 150);
// Nearest heading
var section = el.closest('section, article, header, footer, main, [class*="hero"], [class*="banner"], [class*="feature"]');
if (section) {
var heading = section.querySelector('h1, h2, h3, h4');
if (heading) ctx.nearestHeading = heading.textContent.trim().slice(0, 80);
ctx.sectionClasses = (section.className || '').toString().slice(0, 120);
}
// Above fold?
try {
var rect = el.getBoundingClientRect();
ctx.aboveFold = rect.top < window.innerHeight;
} catch(e) {}
// Structural logo-candidate signals: class-substring alone caught 0/32 SVGs on heygen.com.
ctx.inBanner = el.closest('header, nav, [role="banner"]') !== null;
var homeAnchor = el.closest('a[href]');
if (homeAnchor) {
var aHref = homeAnchor.getAttribute('href') || '';
ctx.inHomeLink = aHref === '/' || aHref === '#' || aHref === './' ||
/^https?:\\/\\/[^/]+\\/?$/.test(aHref);
}
// Brand can be first ("HeyGen - Ideas"), last ("Ideas - HeyGen"), or colon-separated ("Vercel: Build").
var titleParts = (document.title || '').split(/[-|—:]/);
if (desc) {
for (var ti = 0; ti < titleParts.length; ti++) {
var part = titleParts[ti].trim();
if (part.length > 1 && part.length < 30 &&
desc.toLowerCase().indexOf(part.toLowerCase()) !== -1) {
ctx.matchesTitleBrand = true;
break;
}
}
}
return ctx;
}
function add(url, type, context, notes, richCtx) {
if (!url || url === '' || url.startsWith('data:') || url.startsWith('blob:') || url === 'about:blank') return;
// Normalize URL
try { url = new URL(url, document.baseURI).href; } catch(e) { return; }
// Skip tiny inline data URIs but keep base64 SVGs
if (url.length > 50000) return;
// Filter tracking pixels and analytics
var lurl = url.toLowerCase();
if (lurl.indexOf('analytics.') > -1 || lurl.indexOf('adsct') > -1 || lurl.indexOf('pixel.') > -1 || lurl.indexOf('tracking.') > -1 || lurl.indexOf('pdscrb.') > -1 || lurl.indexOf('doubleclick') > -1 || lurl.indexOf('googlesyndication') > -1 || lurl.indexOf('facebook.com/tr') > -1 || lurl.indexOf('bat.bing') > -1 || lurl.indexOf('clarity.ms') > -1) return;
if (lurl.indexOf('bci=') > -1 && lurl.indexOf('twpid=') > -1) return;
if (lurl.indexOf('cachebust=') > -1 || lurl.indexOf('event_id=') > -1) return;
// Filter CSS fragment references to SVG filter IDs (not real downloadable assets)
if (url.indexOf('.css#') > -1) return;
if (url.indexOf('.css%23') > -1) return;
// Filter same-page fragment references like "https://site.com/#clip-1"
try { var parsed = new URL(url); if (parsed.hash && parsed.pathname.length <= 1) return; } catch(e2) {}
if (!assetMap[url]) {
assetMap[url] = { url: url, type: type, contexts: [], notes: null };
}
var entry = assetMap[url];
if (entry.contexts.indexOf(context) === -1) {
entry.contexts.push(context);
}
if (notes && !entry.notes) {
entry.notes = notes;
}
// Text fields: first-occurrence wins. Boolean signals: any positive sample wins.
if (richCtx) {
if (richCtx.description && !entry.description) entry.description = richCtx.description;
if (richCtx.nearestHeading && !entry.nearestHeading) entry.nearestHeading = richCtx.nearestHeading;
if (richCtx.sectionClasses && !entry.sectionClasses) entry.sectionClasses = richCtx.sectionClasses;
if (richCtx.aboveFold !== undefined && entry.aboveFold === undefined) entry.aboveFold = richCtx.aboveFold;
if (richCtx.inBanner) entry.inBanner = true;
if (richCtx.inHomeLink) entry.inHomeLink = true;
if (richCtx.matchesTitleBrand) entry.matchesTitleBrand = true;
}
}
// ── Images: <img src="..."> and <img srcset="..."> ──
document.querySelectorAll('img[src]').forEach(function(img) {
var notes = img.alt || img.getAttribute('aria-label') || null;
var ctx = getElementContext(img);
add(img.src, 'Image', 'img[src]', notes, ctx);
if (img.srcset) {
img.srcset.split(',').forEach(function(entry) {
var u = entry.trim().split(/\\s+/)[0];
if (u) add(u, 'Image', 'img[srcset]', notes, ctx);
});
}
});
// ── Lazy-loaded images: data-src, data-lazy-src, data-original ──
document.querySelectorAll('img[data-src], img[data-lazy-src], img[data-original], [data-background-image]').forEach(function(el) {
var dataSrc = el.getAttribute('data-src') || el.getAttribute('data-lazy-src') || el.getAttribute('data-original') || el.getAttribute('data-background-image');
if (dataSrc) add(dataSrc, 'Image', 'data-src', el.alt || el.getAttribute('aria-label') || null, getElementContext(el));
});
// ── CSS background-image on divs (Framer, Webflow, etc.) ──
document.querySelectorAll('div, section, [class*="hero"], [class*="card"], [class*="image"], [data-framer-background]').forEach(function(el) {
var bg = getComputedStyle(el).backgroundImage;
if (bg && bg !== 'none') {
var match = bg.match(/url\\(["']?(https?:\\/\\/[^"')]+)["']?\\)/);
if (match && match[1]) {
add(match[1], 'Background', 'css url()', el.getAttribute('aria-label') || null, getElementContext(el));
}
}
});
// ── Picture sources: <source srcset="..."> ──
document.querySelectorAll('source[srcset]').forEach(function(src) {
src.srcset.split(',').forEach(function(entry) {
var u = entry.trim().split(/\\s+/)[0];
if (u) add(u, 'Image', 'source[srcset]', null);
});
});
// ── Videos: <video src="..."> and <video poster="..."> ──
document.querySelectorAll('video[src]').forEach(function(v) {
add(v.src, 'Video', 'video[src]', null);
});
document.querySelectorAll('video source[src]').forEach(function(s) {
add(s.src, 'Video', 'video source[src]', null);
});
document.querySelectorAll('video[poster]').forEach(function(v) {
add(v.poster, 'Image', 'video[poster]', null);
});
// ── Links: preload, icon, apple-touch-icon, stylesheet ──
document.querySelectorAll('link[rel]').forEach(function(link) {
var rel = link.rel.toLowerCase();
var href = link.href;
if (!href) return;
if (rel.includes('preload')) {
var asType = link.getAttribute('as') || '';
if (asType === 'font') add(href, 'Font', 'link[rel="preload"]', null);
else if (asType === 'image') add(href, 'Image', 'link[rel="preload"]', null);
else if (asType === 'video') add(href, 'Video', 'link[rel="preload"]', null);
else if (asType === 'style') add(href, 'Other', 'link[rel="preload"]', null);
else add(href, 'Other', 'link[rel="preload"]', null);
}
if (rel.includes('icon')) add(href, 'Icon', 'link[rel="' + rel + '"]', null);
if (rel === 'apple-touch-icon') add(href, 'Icon', 'link[rel="apple-touch-icon"]', null);
});
// ── Meta: og:image, twitter:image ──
document.querySelectorAll('meta[property="og:image"], meta[content][name="twitter:image"]').forEach(function(m) {
var content = m.getAttribute('content');
if (content) {
var prop = m.getAttribute('property') || m.getAttribute('name') || '';
add(content, 'Image', 'meta[' + prop + ']', null);
}
});
// ── CSS url() references from all stylesheets ──
try {
for (var i = 0; i < document.styleSheets.length; i++) {
try {
var sheet = document.styleSheets[i];
var rules = sheet.cssRules || sheet.rules;
if (!rules) continue;
for (var j = 0; j < rules.length; j++) {
var rule = rules[j];
var cssText = rule.cssText || '';
var urlMatches = cssText.match(/url\\(["']?([^"')]+)["']?\\)/g);
if (urlMatches) {
urlMatches.forEach(function(m) {
var u = m.replace(/url\\(["']?/, '').replace(/["']?\\)/, '');
if (u.startsWith('data:')) return;
// Classify by file extension
if (/\\.(woff2?|ttf|otf|eot)$/i.test(u)) {
add(u, 'Font', 'css url()', null);
} else if (/\\.(png|jpg|jpeg|gif|webp|avif|svg)$/i.test(u)) {
add(u, 'Background', 'css url()', null);
} else {
add(u, 'Other', 'css url()', null);
}
});
}
}
} catch(e) { /* cross-origin stylesheet */ }
}
} catch(e) {}
// ── Inline style url() references ──
document.querySelectorAll('[style]').forEach(function(el) {
var style = el.getAttribute('style') || '';
var urlMatches = style.match(/url\\(["']?([^"')]+)["']?\\)/g);
if (urlMatches) {
urlMatches.forEach(function(m) {
var u = m.replace(/url\\(["']?/, '').replace(/["']?\\)/, '');
if (u.startsWith('data:')) return;
if (/\\.(woff2?|ttf|otf|eot)$/i.test(u)) {
add(u, 'Font', 'html inline style url()', null);
} else {
add(u, 'Other', 'html inline style url()', null);
}
});
}
});
return Object.values(assetMap);
})()`);
const raw = (assets as CatalogedAsset[]) || [];
// Deduplicate srcset resolution variants — keep highest resolution per base URL
return annotateGifAssetMetadata(deduplicateSrcsetVariants(raw));
}
function isGifUrl(url: string): boolean {
try {
return new URL(url).pathname.toLowerCase().endsWith(".gif");
} catch {
return url.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".gif") ?? false;
}
}
function appendNote(existing: string | undefined, note: string): string {
return existing ? `${existing}; ${note}` : note;
}
async function readAssetBytes(url: string): Promise<Uint8Array | null> {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!response.ok) return null;
const contentLength = response.headers.get("content-length");
if (contentLength && Number.parseInt(contentLength, 10) > 25 * 1024 * 1024) return null;
return new Uint8Array(await response.arrayBuffer());
} catch {
return null;
}
}
export async function annotateGifAssetMetadata(
assets: CatalogedAsset[],
readBytes: (url: string) => Promise<Uint8Array | null> = readAssetBytes,
): Promise<CatalogedAsset[]> {
return Promise.all(
assets.map(async (asset) => {
if (!isGifUrl(asset.url)) return asset;
const bytes = await readBytes(asset.url);
if (!bytes) return asset;
const metadata = parseAnimatedGifMetadata(bytes);
if (!metadata) return asset;
if (!metadata.animated) {
return {
...asset,
notes: appendNote(asset.notes, "single-frame GIF"),
};
}
const loop =
metadata.loopCount === 0
? "loops forever"
: metadata.loopCount == null
? "no loop metadata"
: `loop count ${metadata.loopCount}`;
return {
...asset,
notes: appendNote(
asset.notes,
`animated GIF: ${metadata.frameCount} frames, ${metadata.durationSeconds.toFixed(3)}s, ${loop}`,
),
};
}),
);
}
/**
* Deduplicate Next.js image variants (same image at different w= sizes).
* Keeps the highest resolution version and merges contexts.
*/
function deduplicateSrcsetVariants(assets: CatalogedAsset[]): CatalogedAsset[] {
const byBase = new Map<string, CatalogedAsset>();
for (const a of assets) {
// Extract base URL by stripping w= and q= params from _next/image URLs
let baseKey = a.url;
try {
const u = new URL(a.url);
if (u.pathname.includes("_next/image") || u.searchParams.has("w")) {
u.searchParams.delete("w");
u.searchParams.delete("q");
baseKey = u.toString();
}
} catch {
/* not a valid URL, keep as-is */
}
const existing = byBase.get(baseKey);
if (existing) {
// Merge contexts
for (const ctx of a.contexts) {
if (!existing.contexts.includes(ctx)) {
existing.contexts.push(ctx);
}
}
// Keep notes from whichever has them
if (a.notes && !existing.notes) {
existing.notes = a.notes;
}
if (a.inBanner) existing.inBanner = true;
if (a.inHomeLink) existing.inHomeLink = true;
if (a.matchesTitleBrand) existing.matchesTitleBrand = true;
// Keep the URL with highest w= value (largest image)
const existingW = getWidthParam(existing.url);
const newW = getWidthParam(a.url);
if (newW > existingW) {
existing.url = a.url;
}
} else {
byBase.set(baseKey, { ...a, contexts: [...a.contexts] });
}
}
return [...byBase.values()];
}
function getWidthParam(url: string): number {
try {
const u = new URL(url);
const w = u.searchParams.get("w");
return w ? parseInt(w) : 0;
} catch {
return 0;
}
}
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
it("blocks loopback, private, and metadata IPv4", () => {
for (const u of [
"http://127.0.0.1/",
"http://10.0.0.5/",
"http://172.16.0.1/",
"http://192.168.1.1/",
"http://169.254.169.254/", // cloud metadata
]) {
expect(isPrivateUrl(u), u).toBe(true);
}
});
it("blocks 0.0.0.0 and the 0.0.0.0/8 range", () => {
expect(isPrivateUrl("http://0.0.0.0/")).toBe(true);
expect(isPrivateUrl("http://0.1.2.3/")).toBe(true);
});
it("blocks IPv6 loopback, IPv4-mapped, ULA, and link-local", () => {
for (const u of [
"http://[::1]/",
"http://[::ffff:169.254.169.254]/", // IPv4-mapped metadata
"http://[fd00::1]/", // unique-local fc00::/7
"http://[fe80::1]/", // link-local fe80::/10
]) {
expect(isPrivateUrl(u), u).toBe(true);
}
});
it("still blocks alternate IPv4 encodings (WHATWG canonicalization)", () => {
expect(isPrivateUrl("http://2130706433/")).toBe(true); // decimal 127.0.0.1
expect(isPrivateUrl("http://0x7f000001/")).toBe(true); // hex
});
it("blocks non-http(s) schemes and internal suffixes", () => {
expect(isPrivateUrl("file:///etc/passwd")).toBe(true);
expect(isPrivateUrl("http://db.internal/")).toBe(true);
expect(isPrivateUrl("http://svc.local/")).toBe(true);
});
it("allows ordinary public URLs", () => {
expect(isPrivateUrl("https://example.com/logo.png")).toBe(false);
expect(isPrivateUrl("https://cdn.jsdelivr.net/a.svg")).toBe(false);
});
});
describe("safeFetch — re-validates the denylist on every redirect hop (security: F-002)", () => {
afterEach(() => vi.unstubAllGlobals());
it("blocks a public URL that redirects to a private/metadata host", async () => {
const fetchMock = vi.fn(async (input: string, _init?: RequestInit) => {
if (input === "https://public.example/logo.png") {
return new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data/" },
});
}
// The metadata host must NEVER be fetched.
throw new Error(`safeFetch followed a redirect to a private host: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const res = await safeFetch("https://public.example/logo.png");
expect(res).toBeNull();
// First (public) hop fetched; the redirect target was rejected before fetch.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
});
it("follows a redirect to another public host and returns the final response", async () => {
const fetchMock = vi.fn(async (input: string, _init?: RequestInit) => {
if (input === "https://a.example/x")
return new Response(null, { status: 301, headers: { location: "https://b.example/y" } });
return new Response("ok", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const res = await safeFetch("https://a.example/x");
expect(res?.status).toBe(200);
expect(await res?.text()).toBe("ok");
});
it("returns null when the initial URL is private", async () => {
const fetchMock = vi.fn(async () => new Response("ok"));
vi.stubGlobal("fetch", fetchMock);
const res = await safeFetch("http://169.254.169.254/");
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});
+465
View File
@@ -0,0 +1,465 @@
/**
* Download assets (SVGs, images, favicon, video posters) from extracted tokens + asset catalog.
*
* Uses the asset catalog (which already deduplicates srcset variants and keeps the highest
* resolution) as the single source of truth for images. Favicon links are passed separately.
*/
import { writeFileSync, mkdirSync } from "node:fs";
import { join, extname } from "node:path";
import { createHash } from "node:crypto";
import type { DesignTokens, DownloadedAsset } from "./types.js";
import type { CatalogedAsset } from "./assetCataloger.js";
// SVGs: hash-of-bytes filename so it can't drift from content; label-derived names mis-assigned brands.
function svgContentHashSlug(svgSource: string | Buffer, isLogo: boolean): string {
const hash = createHash("sha1").update(svgSource).digest("hex").slice(0, 8);
return isLogo ? `logo-${hash}` : `svg-${hash}`;
}
export async function downloadAssets(
tokens: DesignTokens,
outputDir: string,
catalogedAssets?: CatalogedAsset[],
faviconLinks?: Array<{ rel: string; href: string }>,
): Promise<DownloadedAsset[]> {
const assetsDir = join(outputDir, "assets");
mkdirSync(assetsDir, { recursive: true });
const assets: DownloadedAsset[] = [];
const downloadedUrls = new Set<string>();
mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true });
const usedSvgNames = new Set<string>();
for (let i = 0; i < tokens.svgs.length && i < 30; i++) {
const svg = tokens.svgs[i]!;
if (!svg.outerHTML || svg.outerHTML.length < 50) continue;
const slug = svgContentHashSlug(svg.outerHTML, !!svg.isLogo);
let finalSlug = slug;
let suffix = 2;
while (usedSvgNames.has(finalSlug)) {
finalSlug = `${slug}-${suffix}`;
suffix++;
}
usedSvgNames.add(finalSlug);
const name = `${finalSlug}.svg`;
const localPath = `assets/svgs/${name}`;
try {
writeFileSync(join(outputDir, localPath), svg.outerHTML, "utf-8");
assets.push({ url: "", localPath, type: "svg" });
} catch {
/* skip */
}
}
// 2. Favicon
for (const icon of faviconLinks || []) {
if (!icon.href) continue;
try {
const ext = extname(new URL(icon.href).pathname) || ".ico";
const name = `favicon${ext}`;
const localPath = `assets/${name}`;
const buffer = await fetchBuffer(icon.href);
if (buffer) {
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: icon.href, localPath, type: "favicon" });
break;
}
} catch {
/* skip */
}
}
// 3. Images — use the catalog as the single source of truth (highest resolution, deduplicated)
// If the catalog is empty, asset download produces zero images — this is surfaced as a warning
// so the capture doesn't silently produce a half-empty dataset.
const imageUrls: { url: string; isPoster: boolean }[] = [];
if (catalogedAssets && catalogedAssets.length > 0) {
// Use catalog — already deduplicated with highest-res srcset variants
for (const a of catalogedAssets) {
if (a.type !== "Image" && a.type !== "Background") continue;
if (!a.url.startsWith("http")) continue;
// Skip junk
if (a.url.includes("pixel") || a.url.includes("beacon") || a.url.includes("analytics"))
continue;
if (a.url.includes("/favicon")) continue;
// Download images from standard img/video contexts + CSS backgrounds (for hero sections, feature illustrations)
const hasGoodContext = a.contexts.some(
(c) =>
c === "img[src]" ||
c === "img[srcset]" ||
c === "video[poster]" ||
c === "source[srcset]" ||
c === "data-src" ||
c === "css url()",
);
if (!hasGoodContext) continue;
const isPoster = a.contexts.includes("video[poster]");
imageUrls.push({ url: a.url, isPoster });
}
}
// Download all images — use catalog context for human-readable filenames.
// Pre-filter to deduplicate before downloading.
const toDownload: {
url: string;
isPoster: boolean;
normalized: string;
catalog?: CatalogedAsset;
}[] = [];
for (const { url, isPoster } of imageUrls) {
const normalized = normalizeUrl(url);
if (downloadedUrls.has(normalized)) continue;
downloadedUrls.add(normalized);
const catalog = catalogedAssets?.find((a) => normalizeUrl(a.url) === normalized);
toDownload.push({ url, isPoster, normalized, catalog });
}
// Download in parallel batches of 5
const BATCH_SIZE = 5;
let imgIdx = 0;
const usedNames = new Set<string>();
for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
const batch = toDownload.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async ({ url, isPoster, catalog }) => {
const parsedUrl = new URL(url);
const pathExt = extname(parsedUrl.pathname);
const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg";
const buffer = await fetchBuffer(url);
if (!buffer) return null;
const isSvg = ext === ".svg" || url.includes(".svg");
const minSize = isSvg ? 200 : 10000;
if (buffer.length < minSize) return null;
return { url, isPoster, parsedUrl, ext, buffer, catalog };
}),
);
for (const result of results) {
if (result.status !== "fulfilled" || !result.value) continue;
const { url, isPoster, parsedUrl, ext, buffer, catalog } = result.value;
try {
let slug: string;
if (ext === ".svg") {
const c = catalog;
const brandRe = /logo|brand|wordmark/i;
const isLogo = !!(
c?.inBanner ||
c?.inHomeLink ||
c?.matchesTitleBrand ||
c?.contexts?.some((s) => brandRe.test(s)) ||
(c?.description && brandRe.test(c.description)) ||
(c?.nearestHeading && brandRe.test(c.nearestHeading)) ||
(c?.sectionClasses && brandRe.test(c.sectionClasses))
);
slug = svgContentHashSlug(buffer, isLogo);
} else {
slug = deriveAssetName(parsedUrl, catalog, isPoster, imgIdx, usedNames);
}
const name = `${slug}${ext}`;
usedNames.add(slug);
const localPath = `assets/${name}`;
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url, localPath, type: "image" });
imgIdx++;
} catch {
/* skip */
}
}
}
// 4. OG image (if not already downloaded)
if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) {
try {
const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg";
const localPath = `assets/og-image${ext}`;
const buffer = await fetchBuffer(tokens.ogImage);
if (buffer && buffer.length > 5000) {
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: tokens.ogImage, localPath, type: "image" });
}
} catch {
/* skip */
}
}
return assets;
}
/** Normalize URL for deduplication — unwrap Next.js image proxy, strip w/q params */
function normalizeUrl(u: string): string {
try {
const parsed = new URL(u);
if (parsed.pathname.includes("_next/image") && parsed.searchParams.has("url")) {
return decodeURIComponent(parsed.searchParams.get("url")!);
}
parsed.searchParams.delete("w");
parsed.searchParams.delete("q");
parsed.searchParams.delete("dpr");
return parsed.toString();
} catch {
return u;
}
}
/**
* Download fonts referenced in CSS and rewrite URLs to local paths.
* Returns the modified CSS string with local font paths.
*/
export async function downloadAndRewriteFonts(css: string, outputDir: string): Promise<string> {
const assetsDir = join(outputDir, "assets", "fonts");
mkdirSync(assetsDir, { recursive: true });
const fontUrlRegex = /url\(['"]?(https?:\/\/[^'")\s]+\.(?:woff2?|ttf|otf)[^'")\s]*?)['"]?\)/g;
const fontUrls = new Set<string>();
let match;
while ((match = fontUrlRegex.exec(css)) !== null) {
if (match[1]) fontUrls.add(match[1]);
}
if (fontUrls.size === 0) return css;
// Limit font downloads to avoid bloat. Google Fonts serves 20+ unicode-range
// subsets per weight — we only need a few per family for video production.
const MAX_FONTS_PER_FAMILY = 6;
const MAX_TOTAL_FONTS = 30;
const familyCounts = new Map<string, number>();
// Extract font-family from the @font-face rule containing each URL
const getFamilyForUrl = (url: string): string => {
const idx = css.indexOf(url);
if (idx === -1) return "_unknown";
const blockStart = css.lastIndexOf("@font-face", idx);
if (blockStart === -1) return "_unknown";
const blockSlice = css.slice(blockStart, idx);
const familyMatch = blockSlice.match(/font-family\s*:\s*['"]?([^'";}\n]+)/i);
return familyMatch?.[1] ? familyMatch[1].trim().toLowerCase() : "_unknown";
};
// Prioritize Latin subsets over CJK/Arabic/etc unicode ranges
const sortedUrls = Array.from(fontUrls).sort((a, b) => {
const aLatin = /latin|[A-Za-z0-9]{10,}\.woff/.test(a) ? 0 : 1;
const bLatin = /latin|[A-Za-z0-9]{10,}\.woff/.test(b) ? 0 : 1;
return aLatin - bLatin;
});
let rewritten = css;
let count = 0;
for (const fontUrl of sortedUrls) {
if (count >= MAX_TOTAL_FONTS) break;
const family = getFamilyForUrl(fontUrl);
const familyCount = familyCounts.get(family) || 0;
if (familyCount >= MAX_FONTS_PER_FAMILY) continue;
try {
const urlObj = new URL(fontUrl);
const filename = urlObj.pathname.split("/").pop() || `font-${count}.woff2`;
const localPath = join(assetsDir, filename);
const relativePath = `assets/fonts/${filename}`;
const buffer = await fetchBuffer(fontUrl);
if (buffer) {
writeFileSync(localPath, buffer);
rewritten = rewritten.split(fontUrl).join(relativePath);
familyCounts.set(family, familyCount + 1);
count++;
}
} catch {
/* skip */
}
}
return rewritten;
}
// Reserved/loopback/private IPv4 blocks as [firstOctet, secondOctetLo, secondOctetHi].
const PRIVATE_V4_BLOCKS: ReadonlyArray<readonly [number, number, number]> = [
[0, 0, 255], // 0.0.0.0/8 (incl. 0.0.0.0, which routes to localhost)
[10, 0, 255], // 10.0.0.0/8
[127, 0, 255], // 127.0.0.0/8 loopback
[172, 16, 31], // 172.16.0.0/12
[192, 168, 168], // 192.168.0.0/16
[169, 254, 254], // 169.254.0.0/16 link-local (cloud metadata)
];
/** True for a dotted-quad IPv4 literal in a loopback/private/reserved range. */
function isPrivateIpv4(host: string): boolean {
const octets = host.split(".").map(Number);
if (octets.length !== 4) return false;
const [a, b] = octets as [number, number, number, number];
return PRIVATE_V4_BLOCKS.some(([first, lo, hi]) => a === first && b >= lo && b <= hi);
}
/** True for a bracketed IPv6 hostname in a loopback/private/reserved range. */
function isPrivateIpv6(bracketed: string): boolean {
const addr = bracketed.replace(/^\[|\]$/g, "").toLowerCase();
if (addr === "::1" || addr === "::") return true; // loopback / unspecified
const mapped = /^::ffff:(.+)$/.exec(addr); // IPv4-mapped ::ffff:a.b.c.d or ::ffff:hhhh:hhhh
if (mapped) {
const tail = mapped[1]!;
if (tail.includes(".")) return isPrivateIpv4(tail);
const hex = tail.split(":");
if (hex.length === 2) {
const n = ((parseInt(hex[0]!, 16) << 16) | parseInt(hex[1]!, 16)) >>> 0;
return isPrivateIpv4(
[(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."),
);
}
}
if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local
return false;
}
/**
* Block requests to private/internal hosts to prevent SSRF. WHATWG URL parsing
* canonicalizes alternate IPv4 encodings (decimal/octal/hex) to dotted-quad
* before we see them, so only dotted IPv4 and bracketed IPv6 literals reach the
* classifiers below.
*/
export function isPrivateUrl(url: string): boolean {
try {
const u = new URL(url);
if (u.protocol !== "http:" && u.protocol !== "https:") return true; // no file:, etc.
const hostname = u.hostname;
if (hostname === "localhost") return true;
if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true;
if (hostname.startsWith("[")) return isPrivateIpv6(hostname);
if (/^\d+(\.\d+){3}$/.test(hostname)) return isPrivateIpv4(hostname);
return false;
} catch {
return true; // reject unparseable URLs
}
}
/** Max redirect hops safeFetch will follow before giving up. */
const MAX_FETCH_REDIRECTS = 5;
/**
* fetch() that re-validates the SSRF denylist on EVERY redirect hop. A bare
* `redirect: "follow"` only checks the initial URL, so a public URL can 30x to
* an internal/metadata host. We resolve redirects manually and re-run
* isPrivateUrl on each Location. Returns null when blocked, on too many hops,
* or on network error.
*/
export async function safeFetch(url: string, init?: RequestInit): Promise<Response | null> {
let current = url;
for (let hop = 0; hop <= MAX_FETCH_REDIRECTS; hop++) {
if (isPrivateUrl(current)) return null;
const res = await fetch(current, { ...init, redirect: "manual" });
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location");
if (!loc) return res;
try {
current = new URL(loc, current).toString();
} catch {
return null; // malformed Location header
}
continue;
}
return res;
}
return null; // too many redirects
}
async function fetchBuffer(url: string): Promise<Buffer | null> {
try {
const res = await safeFetch(url, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "HyperFrames/1.0" },
});
if (!res || !res.ok) return null;
// Reject XML/HTML error pages disguised as 200 OK (common with S3/CloudFront)
const ct = res.headers.get("content-type") || "";
if (ct.includes("text/xml") || ct.includes("text/html") || ct.includes("application/xml")) {
return null;
}
const ab = await res.arrayBuffer();
return Buffer.from(ab);
} catch {
return null;
}
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40);
}
/**
* Derive a human-readable filename from catalog context.
* Priority: alt text > nearest heading > meaningful URL path > fallback index.
*/
function deriveAssetName(
parsedUrl: URL,
catalog: CatalogedAsset | undefined,
isPoster: boolean,
idx: number,
usedNames: Set<string>,
): string {
const candidates: string[] = [];
// 1. Alt text / description from catalog
if (catalog?.description) {
const desc = catalog.description.replace(/[^a-zA-Z0-9 -]/g, "").trim();
if (desc.length > 3 && desc.length < 80) candidates.push(desc);
}
// 2. Nearest heading context
if (catalog?.nearestHeading) {
const heading = catalog.nearestHeading.replace(/[^a-zA-Z0-9 -]/g, "").trim();
if (heading.length > 3 && heading.length < 60) candidates.push(heading);
}
// 3. Meaningful URL path segment
const rawName =
parsedUrl.pathname
.split("/")
.pop()
?.replace(/\.[^.]+$/, "") || "";
const isMeaningful =
rawName.length > 2 &&
rawName.length < 50 &&
!/^[a-f0-9]{8,}$/i.test(rawName) &&
!/^\d+$/.test(rawName) &&
!rawName.includes("_next") &&
!rawName.includes("?");
if (isMeaningful) candidates.push(rawName);
// 4. Section classes as context
if (catalog?.sectionClasses) {
const classes = catalog.sectionClasses
.split(/\s+/)
.filter((c) => c.length > 3 && c.length < 30 && !/^(w-|h-|p-|m-|flex|grid|block)/.test(c))
.slice(0, 2)
.join("-");
if (classes.length > 3) candidates.push(classes);
}
// Pick the best candidate
const prefix = isPoster ? "poster" : catalog?.aboveFold ? "hero" : "image";
let slug = "";
for (const c of candidates) {
slug = slugify(c);
if (slug.length > 3 && !usedNames.has(slug)) break;
}
if (!slug || slug.length <= 3 || usedNames.has(slug)) {
slug = `${prefix}-${idx}`;
}
// Deduplicate
let final = slug;
let suffix = 2;
while (usedNames.has(final)) {
final = `${slug}-${suffix}`;
suffix++;
}
return final;
}
@@ -0,0 +1,372 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
captureRegionCrop,
clampCropRegion,
installPageFunctionGuard,
padCropRegion,
parseZoomTarget,
resolveCliChromeGpuMode,
resolveCropRegion,
runFfmpegOnce,
seekCompositionTimeline,
type CompositionSeekPage,
} from "./captureCompositionFrame.js";
function tempDir(): string {
return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-"));
}
function fakeSeekPage() {
const evaluate = vi.fn(
async (
_pageFunction: Parameters<CompositionSeekPage["evaluate"]>[0],
_value?: number,
_fallbackToBridgeAndTimelines?: boolean,
): Promise<unknown> => undefined,
);
const waitForFunction = vi.fn(
async (_pageFunction: () => boolean, _options: { timeout: number }): Promise<unknown> =>
undefined,
);
const page: CompositionSeekPage = { evaluate, waitForFunction };
return { page, evaluate, waitForFunction };
}
function runBrowserSeek(evaluate: ReturnType<typeof fakeSeekPage>["evaluate"]): void {
const seekInBrowser = evaluate.mock.calls[0]?.[0];
if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function");
Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []);
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("seekCompositionTimeline", () => {
it("keeps the raced double-frame settle and adds a bounded font wait by default", async () => {
const { page, evaluate, waitForFunction } = fakeSeekPage();
await seekCompositionTimeline(page, 1.25);
expect(waitForFunction).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(3);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false);
expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)");
// Post-seek font settle: a seek can reveal glyphs whose unicode-range
// subsets only start loading after the next layout (CJK snapshot reports).
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500);
});
it("waitForFontsMs: 0 disables the post-seek font wait", async () => {
const { page, evaluate } = fakeSeekPage();
await seekCompositionTimeline(page, 1.25, { waitForFontsMs: 0 });
expect(evaluate).toHaveBeenCalledTimes(2);
});
it("prefers renderSeek so the runtime synchronizes clip visibility", async () => {
const { page, evaluate } = fakeSeekPage();
const renderSeek = vi.fn();
const bridgeSeek = vi.fn();
const playerSeek = vi.fn();
const timelineSeek = vi.fn();
vi.stubGlobal("window", {
__player: { renderSeek, seek: playerSeek },
__hf: { seek: bridgeSeek },
__timelines: { main: { seek: timelineSeek } },
});
await seekCompositionTimeline(page, 2.25);
runBrowserSeek(evaluate);
expect(renderSeek).toHaveBeenCalledWith(2.25);
expect(bridgeSeek).not.toHaveBeenCalled();
expect(playerSeek).not.toHaveBeenCalled();
expect(timelineSeek).not.toHaveBeenCalled();
});
function fakeBridgeOnlySeekPage() {
const { page, evaluate } = fakeSeekPage();
const bridgeSeek = vi.fn();
const tickerTick = vi.fn();
vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } });
return { page, evaluate, bridgeSeek, tickerTick };
}
it("keeps bridge and raw fallbacks disabled for default capture callers", async () => {
const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage();
await seekCompositionTimeline(page, 2.5);
runBrowserSeek(evaluate);
expect(bridgeSeek).not.toHaveBeenCalled();
expect(tickerTick).not.toHaveBeenCalled();
});
it("opts into the bridge before player and raw timeline fallbacks", async () => {
const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage();
await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true });
runBrowserSeek(evaluate);
expect(bridgeSeek).toHaveBeenCalledWith(2.5);
expect(tickerTick).toHaveBeenCalledOnce();
});
it("opts into pausing and seeking raw timelines when no preferred target exists", async () => {
const { page, evaluate } = fakeSeekPage();
const pause = vi.fn();
const seek = vi.fn();
vi.stubGlobal("window", { __timelines: { main: { pause, seek } } });
await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true });
runBrowserSeek(evaluate);
expect(pause).toHaveBeenCalledOnce();
expect(seek).toHaveBeenCalledWith(1.75);
});
it("supports validate settling without adding an animation-frame or font wait", async () => {
vi.useFakeTimers();
const { page, evaluate, waitForFunction } = fakeSeekPage();
const pending = seekCompositionTimeline(page, 3, {
fallbackToBridgeAndTimelines: true,
waitForPreferredSeekTargetMs: 500,
animationFrameSettle: "none",
waitForFontsMs: 0,
settleMs: 150,
});
await vi.advanceTimersByTimeAsync(150);
await pending;
expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 });
expect(evaluate).toHaveBeenCalledTimes(1);
expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true);
});
it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => {
vi.useFakeTimers();
const { page, evaluate } = fakeSeekPage();
const pending = seekCompositionTimeline(page, 4, {
fallbackToBridgeAndTimelines: true,
animationFrameSettle: "double",
waitForFontsMs: 500,
settleMs: 120,
});
await vi.advanceTimersByTimeAsync(120);
await pending;
expect(evaluate).toHaveBeenCalledTimes(3);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500);
});
});
describe("resolveCliChromeGpuMode", () => {
it("preserves validate's software-only opt-in mapping", () => {
expect(resolveCliChromeGpuMode("software")).toBe("software");
expect(resolveCliChromeGpuMode("hardware")).toBe("hardware");
expect(resolveCliChromeGpuMode("auto")).toBe("hardware");
expect(resolveCliChromeGpuMode("")).toBe("hardware");
});
});
describe("screenshot Chrome arguments", () => {
it("leaves shared capture and layout on the engine's software default", () => {
const defaultScreenshotArgs =
/args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/;
const captureSource = readFileSync(
new URL("./captureCompositionFrame.ts", import.meta.url),
"utf8",
);
const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8");
// openSettledCompositionPage threads the caller's optional browserGpuMode;
// callers that omit it (snapshot, compare) fall through to the engine's
// software default for screenshot capture.
expect(captureSource).toMatch(
/args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/,
);
expect(layoutSource).toMatch(defaultScreenshotArgs);
});
});
describe("parseZoomTarget", () => {
it("parses four comma-separated numbers as an exact region", () => {
expect(parseZoomTarget("100,50,400,300")).toEqual({
kind: "region",
region: { x: 100, y: 50, width: 400, height: 300 },
});
});
it("treats anything else as a CSS selector", () => {
expect(parseZoomTarget("#headline")).toEqual({ kind: "selector", selector: "#headline" });
expect(parseZoomTarget(".card:nth-of-type(2)")).toEqual({
kind: "selector",
selector: ".card:nth-of-type(2)",
});
});
});
describe("clampCropRegion / padCropRegion", () => {
it("clamps a region to the canvas bounds", () => {
expect(
clampCropRegion({ x: -10, y: -10, width: 50, height: 50 }, { width: 30, height: 30 }),
).toEqual({
x: 0,
y: 0,
width: 30,
height: 30,
});
});
it("pads a region on every side when it fits within the canvas", () => {
expect(
padCropRegion({ x: 500, y: 500, width: 100, height: 40 }, { width: 1920, height: 1080 }, 24),
).toEqual({ x: 476, y: 476, width: 148, height: 88 });
});
it("pads then clamps when padding would spill outside the canvas", () => {
expect(
padCropRegion({ x: 10, y: 10, width: 20, height: 20 }, { width: 200, height: 200 }, 24),
).toEqual({ x: 0, y: 0, width: 54, height: 54 });
});
});
describe("resolveCropRegion", () => {
it("resolves a selector to its bbox, padded 24px and clamped", async () => {
const page = { evaluate: vi.fn(async () => ({ x: 500, y: 500, width: 100, height: 40 })) };
const region = await resolveCropRegion(
page,
{ kind: "selector", selector: "#headline" },
{ width: 1920, height: 1080 },
);
expect(region).toEqual({ x: 476, y: 476, width: 148, height: 88 });
});
it("crops a region exactly, without padding, when it already fits the canvas", async () => {
const page = { evaluate: vi.fn() };
const region = await resolveCropRegion(
page,
{ kind: "region", region: { x: 100, y: 50, width: 400, height: 300 } },
{ width: 1920, height: 1080 },
);
expect(region).toEqual({ x: 100, y: 50, width: 400, height: 300 });
expect(page.evaluate).not.toHaveBeenCalled();
});
it("throws a clear, loud error when the selector matches nothing", async () => {
const page = { evaluate: vi.fn(async () => null) };
await expect(
resolveCropRegion(
page,
{ kind: "selector", selector: "#missing" },
{ width: 640, height: 360 },
),
).rejects.toThrow("--zoom selector matched no element: #missing");
});
it("returns null when the clamped region is a sliver (element animated off-canvas)", async () => {
// Element slid past the right edge: raw bbox is large, but clamping the
// padded region to the canvas leaves ~1px — a useless crop.
const page = { evaluate: vi.fn(async () => ({ x: 2500, y: 400, width: 600, height: 250 })) };
const region = await resolveCropRegion(
page,
{ kind: "selector", selector: "#gone-by-now" },
{ width: 1920, height: 1080 },
);
expect(region).toBeNull();
});
});
describe("captureRegionCrop", () => {
it("raises deviceScaleFactor for the clip shot, then restores the original viewport", async () => {
const original = { width: 1920, height: 1080, deviceScaleFactor: 1 };
const setViewport = vi.fn(async () => undefined);
const screenshot = vi.fn(async () => new Uint8Array([1, 2, 3]));
const page = { viewport: () => original, setViewport, screenshot };
const region = { x: 10, y: 20, width: 100, height: 50 };
const buffer = await captureRegionCrop(page, region, 3);
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 });
expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" });
expect(setViewport).toHaveBeenNthCalledWith(2, original);
expect(buffer).toBeInstanceOf(Buffer);
expect(Array.from(buffer)).toEqual([1, 2, 3]);
});
it("honors an explicit scale other than the default", async () => {
const original = { width: 800, height: 600 };
const setViewport = vi.fn(async () => undefined);
const screenshot = vi.fn(async () => new Uint8Array());
const page = { viewport: () => original, setViewport, screenshot };
await captureRegionCrop(page, { x: 0, y: 0, width: 10, height: 10 }, 2);
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 2 });
});
});
describe("runFfmpegOnce", () => {
it("returns the process exit code and collected stderr", async () => {
const dir = tempDir();
try {
const script = join(dir, "fail.cjs");
writeFileSync(script, 'process.stderr.write("ffmpeg failed"); process.exit(3);\n');
const result = await runFfmpegOnce(process.execPath, [script], 1000);
expect(result).toEqual({ code: 3, stderr: "ffmpeg failed", timedOut: false });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("terminates the process when the timeout elapses", async () => {
const dir = tempDir();
try {
const script = join(dir, "hang.cjs");
writeFileSync(script, "setTimeout(() => {}, 10000);\n");
const result = await runFfmpegOnce(process.execPath, [script], 50);
expect(result.timedOut).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("installPageFunctionGuard", () => {
it("defines the keepNames __name shim in the page before any script runs", async () => {
const evaluateOnNewDocument = vi.fn(async (_source: string) => undefined);
await installPageFunctionGuard({ evaluateOnNewDocument });
expect(evaluateOnNewDocument).toHaveBeenCalledOnce();
const source = evaluateOnNewDocument.mock.calls[0]?.[0] ?? "";
expect(source).toContain("self.__name");
// The shim must be a no-op passthrough so wrapped functions stay callable.
const shim = new Function(`const self = {}; ${source}; return self.__name;`)() as (
fn: unknown,
) => unknown;
const marker = () => 42;
expect(shim(marker)).toBe(marker);
});
});
@@ -0,0 +1,501 @@
import { spawn } from "node:child_process";
import type { Browser, Page } from "puppeteer-core";
import { c } from "../ui/colors.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000;
const CAPTURE_SETTLE_MS = 1500;
const PREFERRED_SEEK_TARGET_WAIT_MS = 500;
const DEFAULT_POST_SEEK_FONT_WAIT_MS = 500;
// The audit-grade seek tuning shared by check and the deprecated inspect/layout:
// bridge+timeline fallback, ordered double-rAF settle, bounded font wait, sleep.
export const AUDIT_SEEK_OPTIONS = {
fallbackToBridgeAndTimelines: true,
animationFrameSettle: "double",
waitForFontsMs: 500,
settleMs: 120,
} as const;
export interface SeekCompositionTimelineOptions {
fallbackToBridgeAndTimelines?: boolean;
waitForPreferredSeekTargetMs?: number;
animationFrameSettle?: "race" | "double" | "none";
waitForFontsMs?: number;
settleMs?: number;
}
type CompositionPageFunction =
| string
| (() => unknown)
| ((value: number) => unknown)
| ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown);
export interface CompositionEvaluationPage {
evaluate(
pageFunction: CompositionPageFunction,
value?: number,
fallbackToBridgeAndTimelines?: boolean,
): Promise<unknown>;
}
export interface CompositionSeekPage extends CompositionEvaluationPage {
waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise<unknown>;
}
export interface SettledCompositionPage {
browser: Browser;
page: Page;
// True when the runtime never signaled __renderReady within the timeout — the
// capture proceeds anyway (possibly mid-animation), so callers can surface it.
renderReadyTimedOut: boolean;
}
export interface OpenSettledCompositionPageOptions {
renderReadyTimeoutMs: number;
renderReadyWarningSuffix: string;
// Screenshot paths take the engine's software-GPU default; validate/check
// thread the PRODUCER_BROWSER_GPU_MODE opt-in through here.
browserGpuMode?: "software" | "hardware";
// Runs after the page exists but before page.goto, so console/pageerror/
// request listeners can attach without missing load-time events.
beforeNavigate?: (page: Page) => void | Promise<void>;
}
export interface FfmpegRunResult {
code: number | null;
stderr: string;
timedOut: boolean;
}
export function resolveCliChromeGpuMode(
envMode = process.env.PRODUCER_BROWSER_GPU_MODE,
): "software" | "hardware" {
return envMode === "software" ? "software" : "hardware";
}
function compositionRuntimeReadyInBrowser(): boolean {
return Boolean(Reflect.get(window, "__renderReady"));
}
function shaderTransitionsReadyInBrowser(): boolean {
function shaderTransitionRegistryReady(): boolean | undefined {
const hf = Reflect.get(window, "__hf");
if (typeof hf !== "object" || hf === null) return undefined;
const shaderTransitions = Reflect.get(hf, "shaderTransitions");
if (typeof shaderTransitions !== "object" || shaderTransitions === null) return undefined;
for (const key of Object.keys(shaderTransitions)) {
const entry = Reflect.get(shaderTransitions, key);
if (typeof entry !== "object" || entry === null) return false;
if (Reflect.get(entry, "ready") !== true) return false;
}
return true;
}
function shaderLoadingOverlayReady(): boolean {
const overlay = document.querySelector("[data-hyper-shader-loading]");
if (!overlay) return true;
if (!(overlay instanceof HTMLElement)) return true;
return window.getComputedStyle(overlay).display === "none";
}
return shaderTransitionRegistryReady() ?? shaderLoadingOverlayReady();
}
async function waitForCompositionSettle(
page: Page,
options: OpenSettledCompositionPageOptions,
): Promise<boolean> {
const runtimeReady = await page
.waitForFunction(compositionRuntimeReadyInBrowser, { timeout: options.renderReadyTimeoutMs })
.then(() => true)
.catch(() => false);
if (!runtimeReady) {
console.warn(
`\n ${c.warn("⚠")} Runtime did not become render-ready within ${options.renderReadyTimeoutMs}ms — ${options.renderReadyWarningSuffix}`,
);
}
await page
.waitForFunction(shaderTransitionsReadyInBrowser, {
timeout: SHADER_TRANSITIONS_TIMEOUT_MS,
})
.catch(() => {
console.warn(` ${c.warn("⚠")} Shader transitions did not finish pre-rendering`);
});
await page.evaluate(() => document.fonts.ready).catch(() => {});
await new Promise((resolveSettle) => setTimeout(resolveSettle, CAPTURE_SETTLE_MS));
return runtimeReady;
}
// tsx/esbuild-style dev transpilers run with keepNames, which rewrites named
// inner functions in serialized page closures into __name(...) calls — a
// helper that exists in the Node bundle but not in the browser realm, so any
// page.evaluate/waitForFunction whose closure defines a named function throws
// "__name is not defined" when the CLI runs from source. Defining a no-op in
// the page before any script runs immunizes every serialized closure, current
// and future, regardless of how the CLI was built.
export async function installPageFunctionGuard(page: {
evaluateOnNewDocument(source: string): Promise<unknown>;
}): Promise<void> {
await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
}
export async function openSettledCompositionPage(
html: string,
url: string,
options: OpenSettledCompositionPageOptions,
): Promise<SettledCompositionPage> {
const viewport = resolveCompositionViewportFromHtml(html);
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const { buildChromeArgs } = await import("@hyperframes/engine");
let chromeBrowser: Browser | undefined;
try {
chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: buildChromeArgs(
{ ...viewport, captureMode: "screenshot" },
{ browserGpuMode: options.browserGpuMode },
),
});
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
await page.setViewport(viewport);
await options.beforeNavigate?.(page);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
return { browser: chromeBrowser, page, renderReadyTimedOut };
} catch (err) {
await chromeBrowser?.close().catch(() => {});
throw err;
}
}
export async function seekCompositionTimeline(
page: CompositionSeekPage,
timeSeconds: number,
options: SeekCompositionTimelineOptions = {},
): Promise<void> {
if (options.waitForPreferredSeekTargetMs !== undefined) {
await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs);
}
await page.evaluate(
// Serialized into the page; the seek-target cascade must stay one function.
// fallow-ignore-next-line complexity
(t: number, fallbackToBridgeAndTimelines: boolean) => {
const getProperty = (target: unknown, key: string): unknown => {
if ((typeof target !== "object" || target === null) && typeof target !== "function") {
return undefined;
}
return Reflect.get(target, key);
};
const call = (fn: unknown, receiver: unknown, args: unknown[]): boolean => {
if (typeof fn !== "function") return false;
Reflect.apply(fn, receiver, args);
return true;
};
const player = Reflect.get(window, "__player");
if (!player && !fallbackToBridgeAndTimelines) return;
const safe = Math.max(0, Number(t) || 0);
const renderSeek = getProperty(player, "renderSeek");
const playerSeek = getProperty(player, "seek");
const hf = Reflect.get(window, "__hf");
const bridgeSeek = getProperty(hf, "seek");
// Prefer renderSeek because it also runs the runtime's data-start/data-duration
// visibility sync; raw timeline seeks leave off-window clips visible to audits.
if (call(renderSeek, player, [safe])) {
// Preferred runtime target handled the seek.
} else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) {
// Producer bridge handled the seek.
} else if (call(playerSeek, player, [safe])) {
// Legacy player target handled the seek.
} else if (fallbackToBridgeAndTimelines) {
const timelines = Reflect.get(window, "__timelines");
if (typeof timelines === "object" && timelines !== null) {
for (const key of Object.keys(timelines)) {
const timeline = Reflect.get(timelines, key);
call(getProperty(timeline, "pause"), timeline, []);
call(getProperty(timeline, "seek"), timeline, [safe]);
}
}
}
const gsap = Reflect.get(window, "gsap");
const ticker = getProperty(gsap, "ticker");
call(getProperty(ticker, "tick"), ticker, []);
},
timeSeconds,
options.fallbackToBridgeAndTimelines === true,
);
const animationFrameSettle = options.animationFrameSettle ?? "race";
if (animationFrameSettle === "race") {
await page.evaluate(`new Promise(function(r) {
var settled = false;
function finish() { if (settled) return; settled = true; r(); }
window.setTimeout(finish, 100);
requestAnimationFrame(function() { requestAnimationFrame(finish); });
})`);
} else if (animationFrameSettle === "double") {
await page.evaluate(
() =>
new Promise<void>((resolveFrame) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())),
),
);
}
// On by default: every seek caller here is a visual-audit path (snapshot,
// check, compare, validate, layout) and a seek that reveals unrequested
// glyphs must not screenshot before their font subsets load. Costs one
// evaluate + one rAF when nothing is loading. Pass 0 to disable.
const waitForFontsMs = options.waitForFontsMs ?? DEFAULT_POST_SEEK_FONT_WAIT_MS;
if (waitForFontsMs > 0) {
await waitForCompositionFonts(page, waitForFontsMs);
}
if (options.settleMs !== undefined) {
const settleMs = Math.max(0, options.settleMs);
await new Promise((resolveSettle) => setTimeout(resolveSettle, settleMs));
}
}
export async function waitForPreferredSeekTarget(
page: Pick<CompositionSeekPage, "waitForFunction">,
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
): Promise<void> {
if (!page.waitForFunction) return;
try {
await page.waitForFunction(
() => {
const player = Reflect.get(window, "__player");
const hf = Reflect.get(window, "__hf");
const renderSeek =
typeof player === "object" && player !== null
? Reflect.get(player, "renderSeek")
: undefined;
const bridgeSeek =
typeof hf === "object" && hf !== null ? Reflect.get(hf, "seek") : undefined;
return typeof renderSeek === "function" || typeof bridgeSeek === "function";
},
{ timeout: timeoutMs },
);
} catch {
// Legacy/static pages may only expose raw timelines; keep that fallback available.
}
}
export async function waitForCompositionFonts(
page: CompositionEvaluationPage,
timeoutMs: number,
): Promise<void> {
await page
.evaluate((ms: number) => {
const fonts = Reflect.get(document, "fonts");
if (typeof fonts !== "object" || fonts === null) return Promise.resolve();
// A seek can reveal glyphs whose font faces were never requested —
// CJK @font-face splits into unicode-range subsets that only load when
// first laid out. `fonts.ready` is already-resolved at that moment, so
// awaiting it immediately races the load request and screenshots blank
// glyphs (wild reports: Traditional Chinese / Microsoft YaHei check
// snapshots). Force a synchronous layout so pending subsets actually
// start loading, give the loader one frame to flip `fonts.status`,
// THEN await readiness.
void document.body?.offsetHeight;
return new Promise<void>((resolveWait) => {
const deadline = setTimeout(resolveWait, ms);
requestAnimationFrame(() => {
const status = Reflect.get(fonts, "status");
const ready = Reflect.get(fonts, "ready");
if (status !== "loading" || !ready) {
clearTimeout(deadline);
resolveWait();
return;
}
Promise.resolve(ready).then(() => {
clearTimeout(deadline);
resolveWait();
});
});
});
}, timeoutMs)
.catch(() => {});
}
export interface CropRegion {
x: number;
y: number;
width: number;
height: number;
}
export interface CropCanvas {
width: number;
height: number;
}
export type ZoomTarget =
| { kind: "selector"; selector: string }
| { kind: "region"; region: CropRegion };
// Four bare comma-separated numbers is unambiguous — no valid CSS selector
// parses as that shape — so it always means "exact pixel region".
const ZOOM_REGION_PATTERN = /^-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){3}$/;
export const DEFAULT_ZOOM_PADDING_PX = 24;
// One knob for every zoom/crop consumer (snapshot --zoom-scale default, check's
// finding crops): density of the captured pixels relative to CSS pixels.
export const DEFAULT_ZOOM_SCALE = 3;
/** Parse `snapshot --zoom` into either a CSS selector or an exact pixel region "x,y,w,h". */
export function parseZoomTarget(value: string): ZoomTarget {
const trimmed = value.trim();
if (ZOOM_REGION_PATTERN.test(trimmed)) {
const [x, y, width, height] = trimmed.split(",").map(Number) as [
number,
number,
number,
number,
];
return { kind: "region", region: { x, y, width, height } };
}
return { kind: "selector", selector: trimmed };
}
/** Clamp a region to the canvas bounds — Puppeteer's clip screenshot rejects a
* region that spills outside the viewport. Keeps at least 1px on each side. */
export function clampCropRegion(region: CropRegion, canvas: CropCanvas): CropRegion {
const x = Math.max(0, Math.min(region.x, canvas.width));
const y = Math.max(0, Math.min(region.y, canvas.height));
const x2 = Math.max(x + 1, Math.min(region.x + region.width, canvas.width));
const y2 = Math.max(y + 1, Math.min(region.y + region.height, canvas.height));
return { x, y, width: x2 - x, height: y2 - y };
}
/** Pad a region on every side (context around a zoomed element), then clamp. */
export function padCropRegion(
region: CropRegion,
canvas: CropCanvas,
paddingPx: number,
): CropRegion {
return clampCropRegion(
{
x: region.x - paddingPx,
y: region.y - paddingPx,
width: region.width + paddingPx * 2,
height: region.height + paddingPx * 2,
},
canvas,
);
}
export interface ZoomSelectorPage {
evaluate(
pageFunction: (selector: string) => CropRegion | null,
selector: string,
): Promise<CropRegion | null>;
}
/**
* Resolve a `--zoom` target to a concrete crop region. A selector resolves to
* its live bbox (padded ~24px, then clamped); an explicit region is used
* as-is (clamped only, never padded — region form crops exactly). A selector
* matching nothing throws: a loud error beats a silent full-frame fallback.
*/
// A selector can match an element whose visible box is gone at the sampled
// time — collapsed (display:none mid-timeline) or animated off-canvas, where
// clamping leaves a pixel-wide remnant. Either way the crop would be a sliver
// that tells an agent nothing, so the final clamped region is what's guarded
// and callers skip the frame on null. Explicit x,y,w,h regions stay literal.
const MIN_CROP_REGION_PX = 8;
export async function resolveCropRegion(
page: ZoomSelectorPage,
target: ZoomTarget,
canvas: CropCanvas,
paddingPx = DEFAULT_ZOOM_PADDING_PX,
): Promise<CropRegion | null> {
if (target.kind === "region") return clampCropRegion(target.region, canvas);
const bbox = await page.evaluate((selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}, target.selector);
if (!bbox) throw new Error(`--zoom selector matched no element: ${target.selector}`);
const region = padCropRegion(bbox, canvas, paddingPx);
if (region.width < MIN_CROP_REGION_PX || region.height < MIN_CROP_REGION_PX) return null;
return region;
}
export interface CropCapturePage {
viewport(): { width: number; height: number; deviceScaleFactor?: number } | null;
setViewport(viewport: {
width: number;
height: number;
deviceScaleFactor?: number;
}): Promise<void>;
screenshot(options: { clip: CropRegion; type: "png" }): Promise<Uint8Array>;
}
/**
* Capture a high-density crop of `region`: raise `deviceScaleFactor` to
* `scale`, take a clip screenshot, then restore the original viewport.
* Deliberately NOT CSS zoom or a viewport resize — DSF only changes how
* densely Chrome rasterizes the existing CSS-pixel layout, so the
* composition's layout (and its render determinism) is untouched. The PNG
* comes out at `region.width * scale` real device pixels, not an upscale.
*/
export async function captureRegionCrop(
page: CropCapturePage,
region: CropRegion,
scale: number,
): Promise<Buffer> {
const original = page.viewport();
if (original) await page.setViewport({ ...original, deviceScaleFactor: scale });
try {
const shot = await page.screenshot({ clip: region, type: "png" });
return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
} finally {
if (original) await page.setViewport(original);
}
}
export async function runFfmpegOnce(
ffmpegPath: string,
args: readonly string[],
timeoutMs: number,
): Promise<FfmpegRunResult> {
return await new Promise((resolvePromise) => {
const ff = spawn(ffmpegPath, args);
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ff.kill("SIGTERM");
}, timeoutMs);
ff.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
ff.on("close", (code) => {
clearTimeout(timer);
resolvePromise({ code, stderr, timedOut });
});
ff.on("error", () => {
clearTimeout(timer);
resolvePromise({ code: null, stderr: "ffmpeg spawn failed", timedOut });
});
});
}
@@ -0,0 +1,52 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { createContactSheet } from "./contactSheet.js";
function tempDir(): string {
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
}
describe("createContactSheet", () => {
it("writes PNG output when the output path uses a .png extension", async () => {
const dir = tempDir();
try {
const a = join(dir, "a.png");
const b = join(dir, "b.png");
const out = join(dir, "sheet.png");
await sharp({
create: {
width: 16,
height: 9,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toFile(a);
await sharp({
create: {
width: 16,
height: 9,
channels: 3,
background: { r: 0, g: 255, b: 0 },
},
})
.png()
.toFile(b);
await createContactSheet([a, b], out, {
cols: 2,
labelMode: "custom",
labels: ["A", "B"],
maxImages: 2,
});
await expect(sharp(out).metadata()).resolves.toMatchObject({ format: "png" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+352
View File
@@ -0,0 +1,352 @@
/**
* Generate labeled contact sheet grids from images.
*
* Stitches images into a numbered grid with cell labels.
* Saves 50-65% tokens vs. AI agents reading images individually.
*/
import sharp from "sharp";
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
import { join, extname, basename, dirname } from "node:path";
interface ContactSheetOptions {
cols?: number;
maxImages?: number;
padding?: number;
labelMode?: "index" | "filename" | "custom";
labels?: string[];
quality?: number;
/** Target width per cell in pixels (default: 600) */
cellWidth?: number;
}
/**
* Create a contact sheet from a list of image paths.
* Returns the output file path, or null if no images.
*/
export async function createContactSheet(
imagePaths: string[],
outputPath: string,
opts: ContactSheetOptions = {},
): Promise<string | null> {
const {
cols = 3,
maxImages = 16,
padding = 4,
labelMode = "index",
labels,
quality = 88,
cellWidth = 600,
} = opts;
const files = imagePaths.slice(0, maxImages);
if (files.length === 0) return null;
// Read first image to determine aspect ratio
const firstMeta = await sharp(files[0]!).metadata();
const srcW = firstMeta.width || 1920;
const srcH = firstMeta.height || 1080;
// Scale to target cell width, maintain aspect ratio
const scale = cellWidth / srcW;
const cellW = cellWidth;
const cellH = Math.round(srcH * scale);
const rows = Math.ceil(files.length / cols);
const labelH = 26;
const totalW = cols * cellW + (cols + 1) * padding;
const totalH = rows * (cellH + labelH) + (rows + 1) * padding;
const overlays: sharp.OverlayOptions[] = [];
for (let i = 0; i < files.length; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = padding + col * (cellW + padding);
const y = padding + row * (cellH + labelH + padding);
// Resize image to cell size — contain keeps full image visible (no cropping)
const resized = await sharp(files[i]!)
.resize(cellW, cellH, { fit: "contain", background: { r: 26, g: 26, b: 26 } })
.toBuffer();
overlays.push({ input: resized, left: x, top: y + labelH });
// Label text
let labelText = `${i + 1}`;
if (labelMode === "filename") {
labelText = `${i + 1}. ${basename(files[i]!).replace(extname(files[i]!), "")}`;
} else if (labelMode === "custom" && labels?.[i]) {
labelText = `${i + 1}. ${labels[i]}`;
}
// Truncate label to fit cell
if (labelText.length > 60) labelText = labelText.slice(0, 57) + "...";
const labelSvg = Buffer.from(
`<svg width="${cellW}" height="${labelH}">` +
`<rect width="${cellW}" height="${labelH}" fill="#1a1a1a"/>` +
`<text x="8" y="18" font-family="Arial,Helvetica,sans-serif" font-size="13" font-weight="bold" fill="#ffffff">${escapeXml(labelText)}</text>` +
`</svg>`,
);
overlays.push({ input: labelSvg, left: x, top: y });
}
const sheet = sharp({
create: {
width: totalW,
height: totalH,
channels: 3,
background: { r: 26, g: 26, b: 26 },
},
}).composite(overlays);
if (extname(outputPath).toLowerCase() === ".png") {
await sheet.png().toFile(outputPath);
} else {
await sheet.jpeg({ quality }).toFile(outputPath);
}
return outputPath;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Split imagePaths into pages of `pageSize`, write one contact sheet per page.
* Output files: basePath → base-1.jpg, base-2.jpg, ...
* Returns the list of written file paths (empty if no images).
*/
async function createContactSheetPages(
imagePaths: string[],
outputBasePath: string,
opts: ContactSheetOptions & { pageSize?: number } = {},
labelOffset = 0,
customLabels?: string[],
): Promise<string[]> {
if (imagePaths.length === 0) return [];
const { pageSize = imagePaths.length, ...sheetOpts } = opts;
const ext = outputBasePath.match(/\.[^.]+$/)?.[0] ?? ".jpg";
const base = outputBasePath.slice(0, -ext.length);
const pages = Math.ceil(imagePaths.length / pageSize);
const results: string[] = [];
for (let p = 0; p < pages; p++) {
const chunk = imagePaths.slice(p * pageSize, (p + 1) * pageSize);
const chunkLabels = customLabels?.slice(p * pageSize, (p + 1) * pageSize);
const outPath = pages === 1 ? outputBasePath : `${base}-${p + 1}${ext}`;
const labelsForChunk = chunkLabels
? { labelMode: "custom" as const, labels: chunkLabels }
: sheetOpts.labelMode === "filename"
? { labelMode: "filename" as const }
: { labelMode: "index" as const };
const written = await createContactSheet(chunk, outPath, {
...sheetOpts,
...labelsForChunk,
maxImages: chunk.length,
});
if (written) results.push(written);
void labelOffset; // used by callers that pre-compute labels
}
return results;
}
/**
* Contact sheet for scroll screenshots. Paginated — all screenshots covered.
* Labels: "1. 0% scroll", "2. 23% scroll", etc.
* Returns array of written file paths.
*/
export async function createScrollContactSheet(
screenshotsDir: string,
outputPath: string,
): Promise<string[]> {
if (!existsSync(screenshotsDir)) return [];
const scrollFiles = readdirSync(screenshotsDir)
.filter((f) => f.startsWith("scroll-") && f.endsWith(".png"))
.sort();
if (scrollFiles.length === 0) return [];
const paths = scrollFiles.map((f) => join(screenshotsDir, f));
const labels = scrollFiles.map((f) => {
const m = f.match(/scroll-(\d+)\.png/);
return m ? `${m[1]}% scroll` : f;
});
// 3 cols max for readability; 9 per page (3×3) so cells stay large enough to read
return createContactSheetPages(
paths,
outputPath,
{ cols: 3, cellWidth: 600, pageSize: 9 },
0,
labels,
);
}
/**
* Contact sheet for snapshot frames. All frames covered across pages.
* Labels: "1. 1.0s", "2. 3.0s", etc.
* Returns array of written file paths.
*/
export async function createSnapshotContactSheet(
snapshotsDir: string,
outputPath: string,
): Promise<string[]> {
if (!existsSync(snapshotsDir)) return [];
const snapshotFiles = readdirSync(snapshotsDir)
.filter((f) => f.startsWith("frame-") && f.endsWith(".png"))
.sort();
if (snapshotFiles.length === 0) return [];
const paths = snapshotFiles.map((f) => join(snapshotsDir, f));
const labels = snapshotFiles.map((f) => {
const m = f.match(/at-([\d.]+)s/);
return m ? `${m[1]}s` : f;
});
// 3 cols, 9 per page (3×3)
return createContactSheetPages(
paths,
outputPath,
{ cols: 3, cellWidth: 600, pageSize: 9 },
0,
labels,
);
}
/**
* Contact sheet for captured assets. Paginated — all assets covered.
* Labels: "1. filename"
* Returns array of written file paths.
*/
export async function createAssetContactSheet(
assetsDir: string,
outputPath: string,
): Promise<string[]> {
if (!existsSync(assetsDir)) return [];
const imageExts = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const assetFiles = readdirSync(assetsDir)
.filter((f) => imageExts.has(extname(f).toLowerCase()) && !f.includes("contact-sheet"))
.sort();
if (assetFiles.length === 0) return [];
const paths = assetFiles.map((f) => join(assetsDir, f));
// 4 cols, 12 per page (4×3) — covers all assets across as many pages as needed
return createContactSheetPages(paths, outputPath, {
cols: 4,
cellWidth: 480,
labelMode: "filename",
pageSize: 12,
});
}
/**
* Contact sheet for SVGs — renders each SVG to a thumbnail PNG, then grids them.
* Sharp supports SVG input natively, so no browser needed.
* Labels: "1. filename"
*
* Accepts one or two directories: the primary svgs/ subdir and optionally the
* parent assets/ root (for external SVGs downloaded as <img src="*.svg">).
* Files are deduplicated by basename so duplicates across dirs are collapsed.
*/
// fallow-ignore-next-line complexity
export async function createSvgContactSheet(
svgsDir: string,
outputPath: string,
assetsRootDir?: string,
): Promise<string[]> {
const dirsToScan = [svgsDir, assetsRootDir].filter(
(d): d is string => d !== undefined && existsSync(d),
);
if (dirsToScan.length === 0) return [];
const seen = new Set<string>();
const svgPaths: string[] = [];
for (const dir of dirsToScan) {
for (const f of readdirSync(dir)
.filter((f) => f.endsWith(".svg"))
.sort()) {
if (!seen.has(f)) {
seen.add(f);
svgPaths.push(join(dir, f));
}
}
}
if (svgPaths.length === 0) return [];
const svgFileNames = svgPaths.map((p) => p.split("/").pop()!);
// Render ALL SVGs to PNG thumbnails first, then paginate the sheets
const thumbSize = 200;
const tmpDir = dirname(outputPath);
const tmpPaths: string[] = [];
const labels: string[] = [];
for (let i = 0; i < svgPaths.length; i++) {
const svgPath = svgPaths[i]!;
const tmpPath = join(tmpDir, `.thumb-${i}.png`);
try {
const svgBuf = readFileSync(svgPath);
const thumb = await sharp(svgBuf)
.resize(thumbSize, thumbSize, {
fit: "contain",
background: { r: 245, g: 245, b: 245, alpha: 1 },
})
.flatten({ background: { r: 245, g: 245, b: 245 } })
.png()
.toBuffer();
writeFileSync(tmpPath, thumb);
tmpPaths.push(tmpPath);
labels.push(svgFileNames[i]!.replace(".svg", ""));
} catch {
// SVG might be malformed — skip
}
}
if (tmpPaths.length === 0) return [];
// 5 cols, 15 per page (5×3) — all SVGs covered across pages
let results: string[] = [];
try {
results = await createContactSheetPages(
tmpPaths,
outputPath,
{
cols: 5,
cellWidth: thumbSize,
pageSize: 15,
},
0,
labels,
);
} finally {
for (const tmp of tmpPaths) {
try {
unlinkSync(tmp);
} catch {
/* best effort */
}
}
}
return results;
}
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { captionImagesWithGemini } from "./contentExtractor.js";
// These tests exercise the OpenRouter provider path only — it makes a plain
// `fetch` call we can stub, with no native (`sharp`) or `@google/genai`
// dependency. OpenRouter wins over Gemini when OPENROUTER_API_KEY is set, so we
// don't need to clear the Gemini keys for the OpenRouter cases.
function makeProjectWithImage(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-caption-"));
mkdirSync(join(dir, "assets"), { recursive: true });
// Contents are irrelevant to the OpenRouter path (it just base64-encodes the
// bytes); only the .png extension matters for the image filter.
writeFileSync(join(dir, "assets", "hero.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
return dir;
}
describe("captionImagesWithGemini — OpenRouter provider", () => {
const dirs: string[] = [];
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it("captions via OpenRouter when OPENROUTER_API_KEY is set", async () => {
const dir = makeProjectWithImage();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
vi.stubEnv("HYPERFRAMES_OPENROUTER_MODEL", "google/gemini-3.1-flash-lite");
// Capture the request inside the mock, where the args are well-typed —
// avoids casting `mock.calls` (and the repo's ban on `as` assertions).
let capturedUrl: string | undefined;
let capturedInit: RequestInit | undefined;
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
capturedUrl = url;
capturedInit = init;
return new Response(
JSON.stringify({ choices: [{ message: { content: "A dark hero with blue accents." } }] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const warnings: string[] = [];
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
expect(captions).toEqual({ "hero.png": "A dark hero with blue accents." });
expect(warnings).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(capturedUrl).toBe("https://openrouter.ai/api/v1/chat/completions");
expect(new Headers(capturedInit?.headers).get("authorization")).toBe("Bearer or-test-key");
const body = JSON.parse(typeof capturedInit?.body === "string" ? capturedInit.body : "{}");
expect(body.model).toBe("google/gemini-3.1-flash-lite");
const image = body.messages[0].content.find((p: { type: string }) => p.type === "image_url");
expect(image?.image_url?.url).toMatch(/^data:image\/png;base64,/);
});
it("degrades gracefully (no throw, no captions) when OpenRouter returns a non-OK status", async () => {
const dir = makeProjectWithImage();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-bad-key");
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response("invalid api key", { status: 401, statusText: "Unauthorized" }),
),
);
const warnings: string[] = [];
// captionOne throws on !res.ok, but the throw is per-image inside
// Promise.allSettled, so it's filtered out as a rejected result rather than
// bubbling up — same silent degradation as the existing Gemini path.
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
expect(captions).toEqual({});
});
it("skips captioning entirely when no provider key is present", async () => {
const dir = makeProjectWithImage();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "");
vi.stubEnv("GEMINI_API_KEY", "");
vi.stubEnv("GOOGLE_API_KEY", "");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const warnings: string[] = [];
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
expect(captions).toEqual({});
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,533 @@
/**
* Content extraction helpers for the website capture pipeline.
*
* Handles library detection, visible text extraction, vision captioning,
* and asset description generation.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import { existsSync, readdirSync, statSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import type sharpType from "sharp";
import type { CatalogedAsset } from "./assetCataloger.js";
import type { DesignTokens } from "./types.js";
/**
* Detect JS libraries via window globals, DOM fingerprints, script URLs,
* and WebGL shader analysis.
*
* Returns a deduplicated list of detected library names.
*/
export async function detectLibraries(
page: Page,
capturedShaders?: Array<{ type: string; source: string }>,
): Promise<string[]> {
let detectedLibraries: string[] = [];
try {
detectedLibraries = (await page.evaluate(`(() => {
var libs = [];
function add(name) { if (libs.indexOf(name) === -1) libs.push(name); }
// 1. Window globals (works for CDN-loaded / non-bundled libraries)
if (typeof window.gsap !== 'undefined' || typeof window.TweenMax !== 'undefined') add('GSAP');
if (typeof window.ScrollTrigger !== 'undefined') add('GSAP ScrollTrigger');
if (typeof window.THREE !== 'undefined') add('Three.js');
if (typeof window.PIXI !== 'undefined') add('PixiJS');
if (typeof window.BABYLON !== 'undefined') add('Babylon.js');
if (typeof window.Lottie !== 'undefined' || typeof window.lottie !== 'undefined') add('Lottie');
if (typeof window.__NEXT_DATA__ !== 'undefined') add('Next.js');
if (typeof window.__NUXT__ !== 'undefined') add('Nuxt');
if (typeof window.Webflow !== 'undefined') add('Webflow');
// 2. DOM fingerprints (survive bundling — most reliable for modern sites)
// Three.js sets data-engine on every canvas it creates
var threeCanvas = document.querySelector('canvas[data-engine*="three"]');
if (threeCanvas) add('Three.js (' + (threeCanvas.getAttribute('data-engine') || '') + ')');
// Babylon.js also sets data-engine
var babylonCanvas = document.querySelector('canvas[data-engine*="Babylon"]');
if (babylonCanvas) add('Babylon.js');
// Lottie web components
if (document.querySelector('dotlottie-wc, lottie-player, dotlottie-player')) add('Lottie');
// Rive
if (document.querySelector('canvas[class*="rive"], rive-canvas')) add('Rive');
// React/Next.js
if (document.getElementById('__next')) add('Next.js');
if (document.getElementById('__nuxt')) add('Nuxt');
if (document.querySelector('[data-reactroot], [data-react-helmet]')) add('React');
// Svelte
if (document.querySelector('[class*="svelte-"]')) add('Svelte');
// Tailwind (utility class detection)
if (document.querySelector('[class*="flex "], [class*="grid "], [class*="px-"], [class*="py-"]')) add('Tailwind CSS');
// Framer Motion
if (document.querySelector('[style*="--framer-"], [data-framer-component-type]')) add('Framer Motion');
// 3. Script URL patterns
document.querySelectorAll('script[src]').forEach(function(s) {
var src = s.src.toLowerCase();
if (src.includes('gsap') || src.includes('tweenmax') || src.includes('greensock')) add('GSAP');
if (src.includes('scrolltrigger')) add('GSAP ScrollTrigger');
if (src.includes('three.module') || src.includes('three.min')) add('Three.js');
if (src.includes('pixi')) add('PixiJS');
if (src.includes('lottie') || src.includes('bodymovin')) add('Lottie');
if (src.includes('framer-motion')) add('Framer Motion');
if (src.includes('anime.min') || src.includes('animejs')) add('Anime.js');
if (src.includes('matter.min') || src.includes('matter-js')) add('Matter.js');
if (src.includes('lenis')) add('Lenis (smooth scroll)');
});
return libs;
})()`)) as string[];
} catch {
// Non-blocking
}
// 4. Shader fingerprinting — infer WebGL framework from captured GLSL
try {
const shaders = capturedShaders || [];
if (shaders.length > 0) {
const allSource = shaders.map((s) => s.source).join("\n");
const add = (name: string) => {
if (!detectedLibraries.includes(name)) detectedLibraries.push(name);
};
add("WebGL");
// Three.js shader fingerprints (built-in uniforms that survive bundling)
if (allSource.includes("modelViewMatrix") && allSource.includes("projectionMatrix"))
add("Three.js (confirmed via shaders)");
// PixiJS shader fingerprints
else if (
allSource.includes("vTextureCoord") &&
allSource.includes("uSampler") &&
!allSource.includes("modelViewMatrix")
)
add("PixiJS (confirmed via shaders)");
// Babylon.js shader fingerprints
else if (allSource.includes("viewProjection") && allSource.includes("world"))
add("Babylon.js (confirmed via shaders)");
}
} catch {
/* non-blocking */
}
return detectedLibraries;
}
/**
* Extract all visible text from the page in DOM order using a TreeWalker.
* Truncates to ~30K chars to avoid blowing up downstream prompts.
*/
export async function extractVisibleText(page: Page): Promise<string> {
let visibleTextContent = "";
try {
visibleTextContent = (await page.evaluate(`(() => {
var cookieRe = /^(accept|cookie|privacy|that's fine|got it|i agree|reject all|accept all|manage cookies|consent)$/i;
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
var texts = [];
var node;
while (node = walker.nextNode()) {
var text = (node.textContent || '').trim();
if (text.length < 3) continue;
var el = node.parentElement;
if (!el) continue;
var style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
var tag = el.tagName.toLowerCase();
if (tag === 'script' || tag === 'style' || tag === 'noscript') continue;
// Skip very short text inside nav/footer (catches single-word nav links)
// Threshold is 8 chars to preserve footer copy like "© 2026 Stripe" (16 chars)
var inNavOrFooter = el.closest('nav, footer, [role="navigation"]');
if (inNavOrFooter && text.length < 8) continue;
// Skip common cookie/consent patterns
if (cookieRe.test(text)) continue;
texts.push('[' + tag + '] ' + text);
}
return texts.join('\\n');
})()`)) as string;
// Truncate to ~30K chars to avoid blowing up the prompt
if (visibleTextContent.length > 30000) {
visibleTextContent = visibleTextContent.slice(0, 30000) + "\n[...truncated]";
}
} catch {
// Non-blocking
}
return visibleTextContent;
}
/**
* Caption downloaded images using a vision model.
*
* Provider is chosen by which API key is present: OPENROUTER_API_KEY → OpenRouter
* (any vision model via its OpenAI-style API), else GEMINI_API_KEY/GOOGLE_API_KEY
* → Google Gemini, else no captioning. OpenRouter wins if both are set.
*
* Batches requests to stay under free-tier rate limits.
* Returns a map of filename -> caption string.
*/
export async function captionImagesWithGemini(
outputDir: string,
progress: (stage: string, detail?: string) => void,
warnings: string[],
): Promise<Record<string, string>> {
const geminiCaptions: Record<string, string> = {};
const openRouterKey = process.env.OPENROUTER_API_KEY;
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!openRouterKey && !geminiKey) return geminiCaptions;
// OpenRouter takes priority when both keys are set — it's the explicit opt-in
// for users without Google access. Both providers satisfy the same
// single-image → one-line-caption contract (`captionOne`), so the batching and
// SVG-rasterization loops below stay provider-agnostic.
const useOpenRouter = Boolean(openRouterKey);
const providerName = useOpenRouter ? "OpenRouter" : "Gemini";
// Default mirrors the Gemini path's tier (3.x flash-lite). Override per
// provider via HYPERFRAMES_OPENROUTER_MODEL / HYPERFRAMES_GEMINI_MODEL.
const model = useOpenRouter
? process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite"
: process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
progress("design", `Captioning images with ${providerName} vision...`);
try {
// One image → one short caption. Each provider implements this contract;
// everything below is provider-agnostic.
type CaptionOne = (args: {
mimeType: string;
base64: string;
prompt: string;
maxTokens: number;
}) => Promise<string>;
let captionOne: CaptionOne;
if (openRouterKey) {
captionOne = async ({ mimeType, base64, prompt, maxTokens }) => {
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${openRouterKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64}` } },
],
},
],
max_tokens: maxTokens,
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`OpenRouter ${res.status} ${res.statusText}: ${detail.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
return data.choices?.[0]?.message?.content?.trim() || "";
};
} else {
// Unreachable when geminiKey is unset (guarded above); re-narrow for TS.
if (!geminiKey) return geminiCaptions;
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: geminiKey });
captionOne = async ({ mimeType, base64, prompt, maxTokens }) => {
const response = await ai.models.generateContent({
model,
contents: [
{ role: "user", parts: [{ inlineData: { mimeType, data: base64 } }, { text: prompt }] },
],
config: { maxOutputTokens: maxTokens },
});
return response.text?.trim() || "";
};
}
const imageFiles = readdirSync(join(outputDir, "assets")).filter((f: string) =>
/\.(png|jpg|jpeg|webp|gif)$/i.test(f),
);
// Caption in parallel batches. Gemini free tier is ~5 RPM (slow but $0),
// paid/OpenRouter ~2000 RPM. We batch 20 with a 2s inter-batch pause and rely
// on Promise.allSettled so a rate-limited image degrades to "" rather than
// failing the batch.
const BATCH_SIZE = 20;
for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) {
const batch = imageFiles.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (file: string) => {
const filePath = join(outputDir, "assets", file);
const stat = statSync(filePath);
if (stat.size > 4_000_000) return { file, caption: "" }; // skip images > 4 MB (provider inline limit)
const buffer = readFileSync(filePath);
const base64 = buffer.toString("base64");
const ext = file.split(".").pop()?.toLowerCase() || "png";
const mimeType = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
const caption = await captionOne({
mimeType,
base64,
prompt:
"Describe this website image in ONE short sentence for a video storyboard. Focus on: what it shows, dominant colors, whether background is light or dark. Be factual, not creative.",
maxTokens: 500,
});
return { file, caption };
}),
);
for (const result of results) {
if (result.status === "fulfilled" && result.value.caption) {
geminiCaptions[result.value.file] = result.value.caption;
}
}
// Pace requests between batches (paid tier: 2000+ RPM, free tier: rate-limited)
if (i + BATCH_SIZE < imageFiles.length) {
await new Promise((r) => setTimeout(r, 2000)); // 2s pause between batches — paid tier handles 2000 RPM, free tier retries via Promise.allSettled
}
progress(
"design",
`Captioned ${Math.min(i + BATCH_SIZE, imageFiles.length)}/${imageFiles.length} images...`,
);
}
progress(
"design",
`${Object.keys(geminiCaptions).length} images captioned with ${providerName}`,
);
// Rasterize SVGs to PNG before captioning — Vision hallucinates wordmarks when reading SVG path text.
const svgFiles: Array<{ file: string; relPath: string }> = [];
const assetsDir = join(outputDir, "assets");
for (const f of readdirSync(assetsDir)) {
if (/\.svg$/i.test(f)) svgFiles.push({ file: f, relPath: f });
}
const svgsSubdir = join(assetsDir, "svgs");
if (existsSync(svgsSubdir)) {
for (const f of readdirSync(svgsSubdir)) {
if (/\.svg$/i.test(f)) svgFiles.push({ file: f, relPath: `svgs/${f}` });
}
}
if (svgFiles.length > 0) {
// sharp is an optional native module; its platform binary fails to load
// on some installs (omit-optional, musl/glibc, monorepo hoisting, broken
// cache). Load it lazily and degrade to skipping SVG captioning rather
// than crashing the whole capture command on import.
let sharp: typeof sharpType;
try {
sharp = (await import("sharp")).default as typeof sharpType;
} catch (err) {
warnings.push(
`Skipped ${svgFiles.length} SVG caption(s): sharp could not load (${(err as Error).message}). ` +
`Reinstall with optional dependencies enabled (e.g. \`npm i sharp\`) to caption SVG assets.`,
);
return geminiCaptions;
}
progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`);
const SVG_BATCH = 20;
const SVG_RENDER_SIZE = 256; // px — enough resolution for Gemini to read wordmarks, small enough to keep payload sub-MB
let svgsSkipped = 0;
for (let i = 0; i < svgFiles.length; i += SVG_BATCH) {
const batch = svgFiles.slice(i, i + SVG_BATCH);
const results = await Promise.allSettled(
batch.map(async ({ relPath }) => {
const filePath = join(assetsDir, relPath);
let pngBase64: string;
try {
// Flatten against a contrasting background — white-on-white SVGs render invisible to Vision.
const svgSource = readFileSync(filePath, "utf-8");
const lightFillHits = (
svgSource.match(/fill\s*=\s*["'](#fff(fff)?|white|#[ef][ef][ef]|#[ef]{6})["']/gi) ||
[]
).length;
const darkFillHits = (
svgSource.match(/fill\s*=\s*["'](#000(000)?|black|#[0-3]{6}|#[0-3]{3})["']/gi) || []
).length;
const bg =
lightFillHits > darkFillHits
? { r: 32, g: 32, b: 32 } // dark slate behind light glyphs
: { r: 255, g: 255, b: 255 }; // white behind dark glyphs (default)
const pngBuffer = await sharp(filePath)
.resize({
width: SVG_RENDER_SIZE,
height: SVG_RENDER_SIZE,
fit: "inside",
withoutEnlargement: false,
})
.flatten({ background: bg })
.png()
.toBuffer();
pngBase64 = pngBuffer.toString("base64");
} catch {
// exotic SVG features may break sharp; skip caption rather than block
svgsSkipped++;
return { file: relPath, caption: "" };
}
const caption = await captionOne({
mimeType: "image/png",
base64: pngBase64,
prompt:
"Describe this SVG asset rendered from a website in ONE short sentence for a video storyboard. " +
"Focus on: what shape/icon/illustration/wordmark it is, its colors, any text it contains. " +
"If you see a wordmark, READ THE LETTERS LITERALLY — do not guess a brand from context. " +
"Be factual.",
maxTokens: 300,
});
return { file: relPath, caption };
}),
);
for (const result of results) {
if (result.status === "fulfilled" && result.value.caption) {
geminiCaptions[result.value.file] = result.value.caption;
}
}
if (i + SVG_BATCH < svgFiles.length) {
await new Promise((r) => setTimeout(r, 2000));
}
progress(
"design",
`Captioned ${Math.min(i + SVG_BATCH, svgFiles.length)}/${svgFiles.length} SVGs...`,
);
}
progress("design", `${Object.keys(geminiCaptions).length} total assets captioned`);
if (svgsSkipped > 0) {
progress(
"design",
`skipped rasterizing ${svgsSkipped} SVG(s) — fell back to label-derived`,
);
}
}
} catch (err) {
warnings.push(`${providerName} captioning failed: ${err}`);
}
return geminiCaptions;
}
/**
* Generate asset-descriptions.md — one-line descriptions for each downloaded asset.
*
* Returns the description lines (without the markdown header).
*/
export function generateAssetDescriptions(
outputDir: string,
tokens: DesignTokens,
catalogedAssets: CatalogedAsset[],
geminiCaptions: Record<string, string>,
): string[] {
// Sort: Gemini-captioned images first (richest descriptions), then uncaptioned, then SVGs, then fonts
const captionedLines: string[] = [];
const uncaptionedLines: string[] = [];
const svgLines: string[] = [];
const fontLines: string[] = [];
// Describe downloaded images
const assetsPath = join(outputDir, "assets");
try {
for (const file of readdirSync(assetsPath)) {
if (file === "svgs" || file === "fonts" || file === "lottie" || file === "videos") continue;
const filePath = join(assetsPath, file);
const stat = statSync(filePath);
if (!stat.isFile()) continue;
const sizeKb = Math.round(stat.size / 1024);
const catalogMatch = catalogedAssets.find(
(a) => a.url && file.includes(a.url.split("/").pop()?.split("?")[0]?.slice(0, 20) || "___"),
);
const desc = catalogMatch?.description || catalogMatch?.notes || "";
const heading = catalogMatch?.nearestHeading || "";
const section = catalogMatch?.sectionClasses || "";
const aboveFold = catalogMatch?.aboveFold ? "above fold" : "";
const geminiCaption = geminiCaptions[file];
const cleanName = file.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
const parts = [`${file}${sizeKb}KB`];
if (geminiCaption) {
parts.push(geminiCaption);
captionedLines.push(parts.join(", "));
} else {
if (desc) parts.push(`"${desc.slice(0, 80)}"`);
if (heading) parts.push(`section: "${heading.slice(0, 60)}"`);
else if (section) parts.push(`in: ${section.split(" ").slice(0, 3).join(" ")}`);
if (aboveFold) parts.push(aboveFold);
if (!desc && !heading) parts.push(cleanName);
uncaptionedLines.push(parts.join(", "));
}
}
} catch {
/* no assets dir */
}
// Describe SVGs
try {
const svgsPath = join(assetsPath, "svgs");
for (const file of readdirSync(svgsPath)) {
if (!file.endsWith(".svg")) continue;
const svgMatch = tokens.svgs.find(
(s) =>
s.label &&
file.includes(
s.label
.toLowerCase()
.replace(/[^a-z0-9]/g, "-")
.slice(0, 15),
),
);
const geminiCaption = geminiCaptions[`svgs/${file}`];
if (geminiCaption) {
svgLines.push(`svgs/${file}${geminiCaption}`);
continue;
}
const label = svgMatch?.label || file.replace(".svg", "").replace(/-/g, " ");
svgLines.push(`svgs/${file}${label}`);
}
} catch {
/* no svgs dir */
}
// Describe fonts
try {
const fontsPath = join(assetsPath, "fonts");
for (const file of readdirSync(fontsPath)) {
fontLines.push(`fonts/${file} — font file`);
}
} catch {
/* no fonts dir */
}
// Describe videos — high-value motion clips. The video-manifest.json (written
// earlier by captureVideoManifest) carries each clip's DOM heading/caption +
// dims. Surfaced FIRST and tagged `[video]`: for a product/demo these moving
// clips are usually the strongest hero material, and downstream planners key off
// the `[video]` marker. (The `videos/` dir is skipped in the image walk above —
// its entries come from the manifest, which has the captions the bare files lack.)
const videoLines: string[] = [];
try {
const manifest = JSON.parse(
readFileSync(join(outputDir, "extracted", "video-manifest.json"), "utf-8"),
) as Array<{
filename?: string;
localPath?: string;
caption?: string;
heading?: string;
width?: number;
height?: number;
sourceWidth?: number;
sourceHeight?: number;
}>;
for (const v of manifest) {
if (!v.localPath) continue; // only describe clips that actually downloaded
const base = basename(v.localPath) || v.filename || "";
if (!base) continue;
const desc =
(v.caption || v.heading || "").trim().replace(/\s+/g, " ").slice(0, 140) || "motion clip";
const dimW = v.sourceWidth || v.width;
const dimH = v.sourceHeight || v.height;
const dims = dimW && dimH ? `, ~${dimW}×${dimH}` : "";
videoLines.push(`${base} — [video] ${desc}${dims}`);
}
} catch {
/* no video manifest */
}
return [...videoLines, ...captionedLines, ...uncaptionedLines, ...svgLines, ...fontLines];
}
@@ -0,0 +1,497 @@
/**
* Extract computed design styles from key DOM elements.
*
* Targets ~50 elements (headings, body text, buttons, cards, nav) and extracts
* only design-relevant CSS properties. Output is a compact, pre-clustered
* design system summary — not raw computed styles per element.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import type { DesignStyles } from "./types.js";
const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
var isVisible = (el) => {
var s = getComputedStyle(el);
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0" && el.getBoundingClientRect().height > 0;
};
function rgbToHex(color) {
if (!color) return "";
if (color.startsWith('#')) return color.toUpperCase();
// capture optional alpha (group 4), allowing both comma and modern slash (rgb r g b / a) syntax.
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*[,/]\\s*([\\d.]+))?/);
if (!m) return color;
// fully-transparent fill (rgba(...,0)) → sentinel, NOT #000000 — otherwise a transparent
// chip/tab/stat ground reads as solid black on a light-ground site.
if (m[4] !== undefined && parseFloat(m[4]) === 0) return "transparent";
return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase();
}
function cleanFont(f) {
return f.split(",")[0].replace(/['"]/g, "").trim();
}
// keep only gradient background-images (drop url() sprites + "none"); gradients are a core
// brand signal (Stripe/ElevenLabs/Snowflake mesh washes) that a flat background-color misses.
function gradientOf(v) {
return v && v.indexOf("gradient") >= 0 ? v.trim() : "";
}
function getStyles(el) {
var s = getComputedStyle(el);
var bf = s.backdropFilter || s.webkitBackdropFilter || "";
return {
fontFamily: cleanFont(s.fontFamily),
fontSize: s.fontSize,
fontWeight: s.fontWeight,
lineHeight: s.lineHeight,
letterSpacing: s.letterSpacing,
color: rgbToHex(s.color),
background: rgbToHex(s.backgroundColor),
backgroundImage: gradientOf(s.backgroundImage),
backdropFilter: bf === "none" ? "" : bf,
padding: s.padding,
borderRadius: s.borderRadius,
border: s.border,
boxShadow: s.boxShadow === "none" ? "none" : s.boxShadow,
height: s.height
};
}
// ── 1. Typography hierarchy ──
// Sample each text role and deduplicate by fontSize
var typographyMap = {};
var roleSelectors = [
{ role: "display", sel: "h1", max: 3 },
{ role: "heading-2", sel: "h2", max: 5 },
{ role: "heading-3", sel: "h3", max: 5 },
{ role: "heading-4", sel: "h4", max: 3 },
{ role: "body", sel: "p", max: 10 },
{ role: "body-small", sel: "figcaption, .caption, [class*='caption'], [class*='subtitle'], small", max: 5 },
{ role: "label", sel: "label, [class*='label'], [class*='tag'], [class*='badge']", max: 5 },
{ role: "link", sel: "a:not([class*='btn']):not([class*='button']):not([role='button'])", max: 5 },
{ role: "code", sel: "code, pre, [class*='mono']", max: 3 }
];
for (var ri = 0; ri < roleSelectors.length; ri++) {
var spec = roleSelectors[ri];
var els = Array.from(document.querySelectorAll(spec.sel)).slice(0, spec.max);
for (var ei = 0; ei < els.length; ei++) {
if (!isVisible(els[ei])) continue;
var s = getStyles(els[ei]);
var key = s.fontSize + "|" + s.fontWeight + "|" + s.fontFamily;
if (!typographyMap[key]) {
var text = (els[ei].textContent || "").trim().replace(/\\s+/g, " ").slice(0, 60);
typographyMap[key] = {
role: spec.role,
fontFamily: s.fontFamily,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
lineHeight: s.lineHeight,
letterSpacing: s.letterSpacing,
color: s.color,
sampleText: text
};
}
}
}
// Sort by font size descending
var typography = Object.values(typographyMap);
typography.sort(function(a, b) {
return parseFloat(b.fontSize) - parseFloat(a.fontSize);
});
// Deduplicate roles — keep only the first (largest) for each role prefix
var seenRoles = {};
var uniqueTypo = [];
for (var ti = 0; ti < typography.length; ti++) {
var baseRole = typography[ti].role.replace(/-\\d+$/, "");
if (!seenRoles[baseRole]) {
seenRoles[baseRole] = true;
typography[ti].role = baseRole;
uniqueTypo.push(typography[ti]);
} else if (baseRole === "heading") {
// Keep multiple heading levels
uniqueTypo.push(typography[ti]);
}
}
// ── 2. Buttons ──
// a page's primary CTA is very often a FILLED pill in the nav/header ("Sign up", "Start for free").
// Old code dropped everything under <nav>, losing that CTA; keep nav elements that carry a solid
// fill (a real button), still dropping plain nav text links.
var isFilledEl = (el) => {
var cs = getComputedStyle(el);
var bg = cs.backgroundColor;
var solid = !!bg && bg !== "transparent" && !/rgba?\\([^)]*,\\s*0\\s*\\)/.test(bg);
// a gradient-filled CTA (e.g. Snowflake's blue pill = background: var(--ui-background-03)) has a
// transparent background-COLOR but a gradient background-IMAGE — count it as filled too.
return solid || (cs.backgroundImage || "").indexOf("gradient") >= 0;
};
var buttonEls = Array.from(document.querySelectorAll(
'button, a[class*="btn"], a[class*="button"], a[role="button"], ' +
'[class*="btn-"], [class*="button-"], [class*="cta"]'
)).filter(function(el) {
if (!isVisible(el)) return false;
return el.closest('nav, [role="navigation"]') ? isFilledEl(el) : true;
}).slice(0, 16);
var buttonMap = {};
for (var bi = 0; bi < buttonEls.length; bi++) {
var bs = getStyles(buttonEls[bi]);
// Deduplicate by visual appearance (gradient fill kept distinct so a gradient CTA survives)
var bKey = bs.background + "|" + bs.backgroundImage + "|" + bs.borderRadius + "|" + bs.border;
if (!buttonMap[bKey]) {
var btnText = (buttonEls[bi].textContent || "").trim().slice(0, 40);
buttonMap[bKey] = {
label: btnText || "button",
background: bs.background,
backgroundImage: bs.backgroundImage,
backdropFilter: bs.backdropFilter,
color: bs.color,
padding: bs.padding,
borderRadius: bs.borderRadius,
border: bs.border,
boxShadow: bs.boxShadow,
fontSize: bs.fontSize,
fontWeight: bs.fontWeight,
height: bs.height
};
}
}
var buttons = Object.values(buttonMap).slice(0, 6);
// ── 3. Cards / containers ──
var cardEls = Array.from(document.querySelectorAll(
'[class*="card"], [class*="Card"], [class*="tile"], [class*="Tile"], ' +
'[class*="panel"], [class*="Panel"], [class*="feature"], ' +
'article, [class*="box"]:not(select):not(input)'
)).filter(function(el) {
var rect = el.getBoundingClientRect();
return isVisible(el) && rect.width > 100 && rect.height > 80;
}).slice(0, 10);
var cardMap = {};
for (var ci = 0; ci < cardEls.length; ci++) {
var cs = getStyles(cardEls[ci]);
// gradient fill + glass blur kept in the key so a gradient/frosted card is a distinct variant
var cKey = cs.background + "|" + cs.backgroundImage + "|" + cs.backdropFilter + "|" + cs.borderRadius + "|" + cs.border;
if (!cardMap[cKey]) {
cardMap[cKey] = {
label: "card",
background: cs.background,
backgroundImage: cs.backgroundImage,
backdropFilter: cs.backdropFilter,
color: cs.color,
padding: cs.padding,
borderRadius: cs.borderRadius,
border: cs.border,
boxShadow: cs.boxShadow,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
height: cs.height
};
}
}
var cards = Object.values(cardMap).slice(0, 4);
// ── 4. Navigation ──
var navEl = document.querySelector('nav, header, [role="navigation"], [class*="navbar"], [class*="header"]');
var nav = null;
if (navEl && isVisible(navEl)) {
var ns = getStyles(navEl);
nav = {
label: "navigation",
background: ns.background,
backgroundImage: ns.backgroundImage,
backdropFilter: ns.backdropFilter,
color: ns.color,
padding: ns.padding,
borderRadius: ns.borderRadius,
border: ns.border,
boxShadow: ns.boxShadow,
fontSize: ns.fontSize,
fontWeight: ns.fontWeight,
height: ns.height
};
}
// ── 4b. Chips / pills / badges / tags ──
// selector by class-substring, PLUS a shape fallback (small + fully-rounded + short text) so
// hashed/utility class names (Tailwind, CSS-modules) still get caught.
var chipEls = Array.from(document.querySelectorAll(
'[class*="pill"], [class*="Pill"], [class*="badge"], [class*="Badge"], ' +
'[class*="chip"], [class*="Chip"], [class*="tag"], [class*="Tag"]'
)).filter(function(el) {
if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false;
var r = el.getBoundingClientRect();
var txt = (el.textContent || "").trim();
return r.height > 0 && r.height <= 60 && r.width <= 360 && txt.length > 0 && txt.length <= 40;
});
var shapeChips = Array.from(document.querySelectorAll('span, div, li, a')).slice(0, 500).filter(function(el) {
if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false;
var st = getComputedStyle(el);
var r = el.getBoundingClientRect();
var rad = parseFloat(st.borderRadius) || 0;
var txt = (el.textContent || "").trim();
var hasSkin = (st.backgroundColor && st.backgroundColor !== "rgba(0, 0, 0, 0)" && st.backgroundColor !== "transparent") || (parseFloat(st.borderTopWidth) || 0) > 0;
return hasSkin && r.height > 0 && r.height <= 44 && r.width <= 260 && rad >= (r.height / 2) - 1 && txt.length > 0 && txt.length <= 24 && el.children.length <= 1;
});
var allChips = chipEls.concat(shapeChips);
var chipMap = {};
for (var chi = 0; chi < allChips.length; chi++) {
var chs = getStyles(allChips[chi]);
var chKey = chs.background + "|" + chs.borderRadius + "|" + chs.border;
if (!chipMap[chKey]) {
chipMap[chKey] = {
label: (allChips[chi].textContent || "").trim().slice(0, 24) || "chip",
background: chs.background, color: chs.color, padding: chs.padding, borderRadius: chs.borderRadius,
border: chs.border, boxShadow: chs.boxShadow, fontSize: chs.fontSize, fontWeight: chs.fontWeight, height: chs.height
};
}
}
var chips = Object.values(chipMap).slice(0, 4);
// ── 4c. Stat / metric cells (a big numeral + a small label) ──
var statEls = Array.from(document.querySelectorAll(
'[class*="stat"], [class*="Stat"], [class*="metric"], [class*="Metric"], ' +
'[class*="kpi"], [class*="KPI"], [class*="figure"], [class*="Figure"]'
)).filter(function(el) {
if (!isVisible(el)) return false;
var r = el.getBoundingClientRect();
return r.height > 0 && r.height <= 400 && r.width <= 600;
}).slice(0, 14);
function biggestFontChild(el) {
var best = 0, bestEl = null, kids = el.querySelectorAll("*");
for (var i = 0; i < kids.length; i++) {
if (!isVisible(kids[i])) continue;
var fz = parseFloat(getComputedStyle(kids[i]).fontSize) || 0;
if (fz > best) { best = fz; bestEl = kids[i]; }
}
return bestEl;
}
var statMap = {};
for (var sti = 0; sti < statEls.length; sti++) {
var numEl = biggestFontChild(statEls[sti]) || statEls[sti];
var numFz = parseFloat(getComputedStyle(numEl).fontSize) || 0;
if (numFz < 28) continue; // needs a genuinely large numeral to count as a stat cell
var cst = getStyles(statEls[sti]);
var nst = getStyles(numEl);
var stKey = Math.round(numFz) + "|" + cst.background;
if (!statMap[stKey]) {
statMap[stKey] = {
background: cst.background, borderRadius: cst.borderRadius, border: cst.border, boxShadow: cst.boxShadow,
numberFontSize: nst.fontSize, numberFontWeight: nst.fontWeight, numberColor: nst.color
};
}
}
var statCells = Object.values(statMap).slice(0, 3);
// ── 4d. Tabs ──
var tabEls = Array.from(document.querySelectorAll(
'[role="tab"], [class*="tab"]:not([class*="table"]):not([class*="Table"])'
)).filter(function(el) {
if (!isVisible(el)) return false;
var r = el.getBoundingClientRect();
var txt = (el.textContent || "").trim();
return r.height > 0 && r.height <= 64 && txt.length > 0 && txt.length <= 30;
}).slice(0, 12);
var tabMap = {};
for (var tbi = 0; tbi < tabEls.length; tbi++) {
var tst = getStyles(tabEls[tbi]);
var tKey = tst.background + "|" + tst.color + "|" + tst.border;
if (!tabMap[tKey]) {
tabMap[tKey] = {
label: (tabEls[tbi].textContent || "").trim().slice(0, 24) || "tab",
background: tst.background, color: tst.color, padding: tst.padding, borderRadius: tst.borderRadius,
border: tst.border, boxShadow: tst.boxShadow, fontSize: tst.fontSize, fontWeight: tst.fontWeight, height: tst.height
};
}
}
var tabs = Object.values(tabMap).slice(0, 4);
// ── 5. Spacing scale ──
// Collect padding and margin values from visible elements
var spacingCounts = {};
var spacingSamples = Array.from(document.querySelectorAll(
"section, div, article, main, aside, header, footer, nav, " +
"button, a, p, h1, h2, h3, h4, li, ul, ol"
)).slice(0, 200);
for (var si = 0; si < spacingSamples.length; si++) {
if (!isVisible(spacingSamples[si])) continue;
var ss = getComputedStyle(spacingSamples[si]);
var props = [ss.paddingTop, ss.paddingRight, ss.paddingBottom, ss.paddingLeft,
ss.marginTop, ss.marginRight, ss.marginBottom, ss.marginLeft,
ss.gap, ss.rowGap, ss.columnGap];
for (var pi = 0; pi < props.length; pi++) {
var val = parseFloat(props[pi]);
if (val > 0 && val <= 200) {
var rounded = Math.round(val);
spacingCounts[rounded] = (spacingCounts[rounded] || 0) + 1;
}
}
}
// Find the most common spacing values (at least 3 occurrences)
var spacingEntries = Object.entries(spacingCounts)
.filter(function(e) { return e[1] >= 3; })
.sort(function(a, b) { return b[1] - a[1]; });
var observedSpacing = spacingEntries.map(function(e) { return parseInt(e[0]); }).sort(function(a,b) { return a - b; });
// Detect base unit — GCD of the top spacing values, clamped to 4 or 8
var baseUnit = 8;
if (observedSpacing.length >= 3) {
var divisible4 = observedSpacing.filter(function(v) { return v % 4 === 0; }).length;
var divisible8 = observedSpacing.filter(function(v) { return v % 8 === 0; }).length;
baseUnit = (divisible4 > divisible8 * 1.5) ? 4 : 8;
}
// ── 6. Border radius scale ──
var radiusCounts = {};
var radiusSamples = Array.from(document.querySelectorAll(
"button, a, [class*='card'], [class*='btn'], input, select, textarea, " +
"[class*='badge'], [class*='tag'], [class*='chip'], img, video"
)).slice(0, 100);
for (var rsi = 0; rsi < radiusSamples.length; rsi++) {
if (!isVisible(radiusSamples[rsi])) continue;
var br = getComputedStyle(radiusSamples[rsi]).borderRadius;
if (br && br !== "0px") {
radiusCounts[br] = (radiusCounts[br] || 0) + 1;
}
}
var radius = Object.entries(radiusCounts)
.filter(function(e) { return e[1] >= 2; })
.sort(function(a, b) { return parseFloat(a[0]) - parseFloat(b[0]); })
.map(function(e) { return e[0]; });
// ── 7. Box shadows ──
var shadowCounts = {};
var shadowSamples = Array.from(document.querySelectorAll(
"[class*='card'], [class*='Card'], button, [class*='btn'], " +
"[class*='dropdown'], [class*='modal'], [class*='popover'], " +
"nav, header, [class*='panel'], article"
)).slice(0, 100);
for (var shi = 0; shi < shadowSamples.length; shi++) {
if (!isVisible(shadowSamples[shi])) continue;
var shVal = getComputedStyle(shadowSamples[shi]).boxShadow;
if (shVal && shVal !== "none") {
shadowCounts[shVal] = (shadowCounts[shVal] || 0) + 1;
}
}
var shadows = Object.entries(shadowCounts)
.sort(function(a, b) { return b[1] - a[1]; })
.slice(0, 5)
.map(function(e) { return { value: e[0], count: e[1] }; });
// ── 8. Dominant gradient / mesh backgrounds ──
// A site's signature color wash (Stripe/ElevenLabs/Snowflake) lives in gradient background-images
// on large blocks — often on a pseudo-element (::before glow orbs) rather than the block itself.
// Weight each distinct gradient by the total on-screen area it covers; return the top few.
var gradientArea = {};
var gradSamples = Array.from(document.querySelectorAll(
"body, main, section, header, div, [class*='hero'], [class*='gradient'], [class*='bg'], [class*='background']"
)).slice(0, 400);
// max stop chroma (maxmin RGB) of a gradient: a vivid brand wash scores high, a neutral
// white/cream scrim ~0. Used to rank washes so a small vivid gradient beats a big grey scrim.
function gradientChroma(g) {
var max = 0, re = /rgba?\\(\\s*(\\d+)[,\\s]+(\\d+)[,\\s]+(\\d+)/g, m;
while ((m = re.exec(g))) {
var r = +m[1], gr = +m[2], b = +m[3];
var c = Math.max(r, gr, b) - Math.min(r, gr, b);
if (c > max) max = c;
}
return max;
}
function addGradient(val, area) {
var g = gradientOf(val);
if (!g || area < 20000) return; // ignore tiny decorative gradients
var norm = g.replace(/\\s+/g, " ");
gradientArea[norm] = (gradientArea[norm] || 0) + area;
}
for (var gi = 0; gi < gradSamples.length; gi++) {
var gel = gradSamples[gi];
if (!isVisible(gel)) continue;
var grect = gel.getBoundingClientRect();
var garea = grect.width * grect.height;
if (garea < 20000) continue;
addGradient(getComputedStyle(gel).backgroundImage, garea);
addGradient(getComputedStyle(gel, "::before").backgroundImage, garea);
addGradient(getComputedStyle(gel, "::after").backgroundImage, garea);
}
// rank by chroma-weighted area so the brand's signature color wash outranks a larger neutral scrim
var backgrounds = Object.entries(gradientArea)
.map(function(e) { return { value: e[0], area: Math.round(e[1]), score: e[1] * (1 + 2 * gradientChroma(e[0]) / 255) }; })
.sort(function(a, b) { return b.score - a.score; })
.slice(0, 6)
.map(function(e) { return { value: e.value, area: e.area }; });
// ── 9. Frosted-glass panels (backdrop-filter) ──
// A defining material on modern hero UIs (HeyGen's prompt box, Stripe's floating chrome): a
// translucent surface with a backdrop blur. Capture the RAW fill (rgba/gradient — alpha preserved,
// unlike rgbToHex) + the blur, ranked by area. This is what lets a frame render a real frosted card.
var glassSamples = Array.from(document.querySelectorAll(
"div, section, header, nav, aside, [class*='card'], [class*='panel'], [class*='glass'], [class*='blur'], [class*='modal'], [class*='overlay'], [class*='input']"
)).slice(0, 400);
var glassByKey = {};
for (var qi = 0; qi < glassSamples.length; qi++) {
var qel = glassSamples[qi];
if (!isVisible(qel)) continue;
var qs = getComputedStyle(qel);
var bf = qs.backdropFilter || qs.webkitBackdropFilter || "";
if (!bf || bf === "none" || bf.indexOf("blur") < 0) continue;
var qrect = qel.getBoundingClientRect();
var qarea = qrect.width * qrect.height;
if (qarea < 8000) continue; // ignore tiny blurred chips
// raw fill with alpha intact: prefer a translucent gradient, else the rgba background-color
var gi2 = qs.backgroundImage;
var rawFill = gi2 && gi2.indexOf("gradient") >= 0 ? gi2.replace(/\\s+/g, " ").trim() : qs.backgroundColor;
var key = bf + "|" + rawFill + "|" + qs.borderRadius;
if (!glassByKey[key]) {
glassByKey[key] = {
backdropFilter: bf,
background: rawFill,
border: qs.border,
borderRadius: qs.borderRadius,
boxShadow: qs.boxShadow === "none" ? "" : qs.boxShadow,
area: 0
};
}
glassByKey[key].area += qarea;
}
var glass = Object.values(glassByKey)
.sort(function(a, b) { return b.area - a.area; })
.slice(0, 3)
.map(function(g) { return { backdropFilter: g.backdropFilter, background: g.background, border: g.border, borderRadius: g.borderRadius, boxShadow: g.boxShadow, area: Math.round(g.area) }; });
return {
typography: uniqueTypo,
spacing: { observed: observedSpacing.slice(0, 15), baseUnit: baseUnit },
radius: radius,
shadows: shadows,
buttons: buttons,
cards: cards,
nav: nav,
chips: chips,
statCells: statCells,
tabs: tabs,
backgrounds: backgrounds,
glass: glass
};
})()`;
export async function extractDesignStyles(page: Page): Promise<DesignStyles> {
return page.evaluate(EXTRACT_DESIGN_STYLES_SCRIPT) as Promise<DesignStyles>;
}
@@ -0,0 +1,215 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
canonicalizeFamily,
extractFontMetadata,
inferWeightFromSubfamily,
isIconCharacterSet,
} from "./fontMetadataExtractor.js";
describe("inferWeightFromSubfamily", () => {
// The concatenated forms were always handled. The spaced and hyphenated
// forms were the bug Copilot flagged on PR #987 — "Extra Light" used to
// fall through to the 400 default before the whitespace-normalization fix.
describe("concatenated forms (already handled)", () => {
it.each([
["Thin", 100],
["ExtraLight", 200],
["UltraLight", 200],
["Light", 300],
["Regular", 400],
["Medium", 500],
["SemiBold", 600],
["DemiBold", 600],
["Bold", 700],
["ExtraBold", 800],
["UltraBold", 800],
["Black", 900],
["Heavy", 900],
])("%s → %d", (subfamily, expected) => {
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
});
});
describe("spaced forms (the bug fix)", () => {
it.each([
["Extra Light", 200],
["Ultra Light", 200],
["Semi Bold", 600],
["Demi Bold", 600],
["Extra Bold", 800],
["Ultra Bold", 800],
])("%s → %d", (subfamily, expected) => {
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
});
});
describe("hyphenated forms (the bug fix)", () => {
it.each([
["Extra-Light", 200],
["Semi-Bold", 600],
["Extra-Bold", 800],
])("%s → %d", (subfamily, expected) => {
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
});
});
describe("composite styles", () => {
it("Bold Italic still detects Bold", () => {
expect(inferWeightFromSubfamily("Bold Italic")).toBe(700);
});
it("Semi Bold Italic still detects SemiBold (priority over Bold)", () => {
expect(inferWeightFromSubfamily("Semi Bold Italic")).toBe(600);
});
it("ExtraBold Italic still detects ExtraBold (priority over Bold)", () => {
expect(inferWeightFromSubfamily("ExtraBold Italic")).toBe(800);
});
});
it("unknown subfamily falls back to 400 (Regular)", () => {
expect(inferWeightFromSubfamily("Headline")).toBe(400);
expect(inferWeightFromSubfamily("")).toBe(400);
expect(inferWeightFromSubfamily("Some Random Style")).toBe(400);
});
it("is case-insensitive", () => {
expect(inferWeightFromSubfamily("EXTRA LIGHT")).toBe(200);
expect(inferWeightFromSubfamily("extra light")).toBe(200);
expect(inferWeightFromSubfamily("ExTrA LiGhT")).toBe(200);
});
});
describe("canonicalizeFamily", () => {
it("returns family unchanged when no weight token is trailing", () => {
expect(canonicalizeFamily("Inter")).toEqual({
canonical: "Inter",
inferredWeight: null,
});
expect(canonicalizeFamily("Tiempos Headline")).toEqual({
canonical: "Tiempos Headline",
inferredWeight: null,
});
expect(canonicalizeFamily("Söhne Breit")).toEqual({
canonical: "Söhne Breit",
inferredWeight: null,
});
});
it("strips trailing weight tokens and surfaces the implied weight", () => {
expect(canonicalizeFamily("Inter Medium")).toEqual({
canonical: "Inter",
inferredWeight: 500,
});
expect(canonicalizeFamily("Inter Light")).toEqual({
canonical: "Inter",
inferredWeight: 300,
});
expect(canonicalizeFamily("Inter Bold")).toEqual({
canonical: "Inter",
inferredWeight: 700,
});
expect(canonicalizeFamily("Funnel Display Light")).toEqual({
canonical: "Funnel Display",
inferredWeight: 300,
});
});
it("preserves width modifiers before the weight token", () => {
expect(canonicalizeFamily("Inter Tight Medium")).toEqual({
canonical: "Inter Tight",
inferredWeight: 500,
});
});
it("emits 950 for ExtraBlack / UltraBlack (mirrors foundry intent)", () => {
expect(canonicalizeFamily("Inter ExtraBlack")).toEqual({
canonical: "Inter",
inferredWeight: 950,
});
});
it("returns empty input unchanged", () => {
expect(canonicalizeFamily("")).toEqual({
canonical: "",
inferredWeight: null,
});
});
});
describe("extractFontMetadata", () => {
// Light integration tests against the public surface — uses a real
// temp directory and verifies the manifest shape. Doesn't require
// fixture font binaries; the non-existent and empty-directory cases
// exercise the happy paths for the surrounding pipeline.
it("returns an empty manifest when the fonts directory doesn't exist", () => {
const tmp = mkdtempSync(join(tmpdir(), "hf-font-test-"));
try {
const outputPath = join(tmp, "manifest.json");
const manifest = extractFontMetadata(join(tmp, "does-not-exist"), outputPath);
expect(manifest.files).toEqual([]);
expect(manifest.families).toEqual([]);
expect(manifest.unidentified).toEqual([]);
expect(existsSync(outputPath)).toBe(true);
const written = JSON.parse(readFileSync(outputPath, "utf-8")) as typeof manifest;
expect(written.files).toEqual([]);
expect(written.meta.tool).toBe("fontkit");
expect(typeof written.meta.generatedAt).toBe("string");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("writes a manifest with the documented meta shape", () => {
const tmp = mkdtempSync(join(tmpdir(), "hf-font-test-"));
try {
const outputPath = join(tmp, "manifest.json");
const manifest = extractFontMetadata(tmp, outputPath);
expect(manifest.meta.tool).toBe("fontkit"); // no version hardcoded — moves with the dep
// generatedAt is an ISO string
expect(manifest.meta.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("isIconCharacterSet", () => {
// codepoint helpers
const latin = Array.from({ length: 26 }, (_, i) => 0x41 + i); // A-Z
const pua = Array.from({ length: 20 }, (_, i) => 0xe000 + i); // Private Use Area
const puaSupp = [0xf0000, 0xf0001, 0x10fffd]; // supplementary PUA-A/B
it("flags a font whose glyphs are mostly Private-Use-Area (an icon font)", () => {
// ~63% PUA, mirroring a real "hushly" icon set
expect(isIconCharacterSet([...pua, ...latin.slice(0, 12)])).toBe(true);
});
it("flags a near-100% PUA set (Font-Awesome-style)", () => {
expect(isIconCharacterSet([...pua, ...puaSupp])).toBe(true);
});
it("does NOT flag a normal Latin text font", () => {
expect(isIconCharacterSet(latin)).toBe(false);
});
it("does NOT flag a unicode-range subset with no Latin letters but 0% PUA", () => {
// e.g. a cyrillic/latin-ext subset served by Next.js/Google Fonts — the false-positive case
const cyrillic = Array.from({ length: 40 }, (_, i) => 0x0410 + i);
expect(isIconCharacterSet(cyrillic)).toBe(false);
});
it("does NOT flag a PUA-heavy TEXT font that still has a full Latin alphabet", () => {
// Apple SF Pro (~81% PUA, ships SF Symbols in the PUA) and Descript's Booton (~50% PUA) are
// full text fonts — the Latin alphabet gate must keep them out of the icon bucket.
const fullAlphabet = latin; // A-Z (26)
const sfProLike = [...fullAlphabet, ...Array.from({ length: 200 }, (_, i) => 0xe000 + i)];
expect(isIconCharacterSet(sfProLike)).toBe(false);
});
it("returns false for an empty character set", () => {
expect(isIconCharacterSet([])).toBe(false);
});
});
@@ -0,0 +1,379 @@
/**
* Extract font metadata from downloaded font files.
*
* Modern web frameworks (Next.js, Webpack) rename fonts with content hashes for
* cache-busting, leaving downloaded files like `19cfc7226ec3afaa-s.woff2` with
* no human-readable identification. The CSS @font-face mapping that originally
* tied each hash back to a family name is often lost during capture.
*
* Every OpenType / WOFF / WOFF2 file embeds a `name` table (part of the spec
* since 1996) containing the family, subfamily, full name, PostScript name,
* weight class, and variation axes. Subsetting and hashing do not strip it.
* This extractor uses `fontkit` to read the name table from each downloaded
* font and writes a manifest the rest of the pipeline can consult instead of
* guessing from filename patterns.
*
* Output: extracted/fonts-manifest.json with per-file metadata + per-family
* aggregation. See FontsManifest type for shape.
*/
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import * as fontkit from "fontkit";
import type { Font, FontCollection } from "fontkit";
function isFontCollection(value: Font | FontCollection): value is FontCollection {
return value.type === "TTC" || value.type === "DFont";
}
export interface FontFileMetadata {
/** Filename relative to capture/assets/fonts/ (e.g. "19cfc7226ec3afaa-s.woff2") */
file: string;
/**
* Canonical family name. Many static-weight font files package each weight as
* a separate "family" in nameID 1 (e.g. "Inter Medium" instead of "Inter").
* This field strips trailing weight tokens so multiple weights of the same
* typographic family aggregate cleanly. See rawFamily for the unmodified value.
*/
family: string;
/**
* Raw family name as extracted, before canonicalization. Source precedence:
* 1. OpenType `name` table (nameID 16 if present, else nameID 1)
* 2. Fallback: derived from the PostScript name (nameID 6) before the first
* `-` (e.g. PostScript "Inter-Regular" → "Inter")
* Empty string when both the name table and PostScript name are absent
* (i.e. when `identified` is false).
*/
rawFamily: string;
/** Subfamily / style name from nameID 17 or 2 (e.g. "Regular", "Bold Italic") */
subfamily: string;
/** PostScript name from nameID 6 (e.g. "Inter-Regular") */
postscript: string;
/**
* Weight value. Typically the OS/2 `usWeightClass` (100900) when present.
* Other values you may see:
* - `0`: returned when the file is `identified: false` (no name-table data
* to infer from); treat as unknown.
* - `950`: emitted by the family-name canonicalization when a foundry
* packaged "ExtraBlack" or "UltraBlack" as its own family. This is
* outside the 100-900 standard range but mirrors the foundry intent.
* For variable fonts, this is the file's default axis position — see
* `variationAxes` for the available `wght` range.
*/
weight: number;
/** "normal" or "italic" — derived from subfamily and OS/2 fsSelection */
style: "normal" | "italic";
/** If this is a variable font, the axes present (e.g. ["wght", "slnt"]). Empty for static fonts. */
variationAxes: string[];
/** Whether identification came from the binary name table (the trustworthy source). */
identified: boolean;
/**
* True when this is an ICON font — it has no basic Latin letters, or its glyphs live mostly in
* the Unicode Private Use Area (Font Awesome, swiper-icons, a custom "hushly" icon set, …).
* Consumers must NOT treat it as a text family: binding it to one renders headings as tofu/icons.
*/
isIcon: boolean;
}
export interface FontFamilySummary {
/** Family name */
family: string;
/** Distinct weights captured (from OS/2 weight class — for variable fonts shows the default) */
weights: number[];
/** Whether any file in this family is a variable font */
variable: boolean;
/** Number of files in this family (typically subsets of the same weight) */
fileCount: number;
/** Files in this family — useful for picking the @font-face src */
files: string[];
}
export interface FontsManifest {
/** Per-file metadata, one entry per downloaded font */
files: FontFileMetadata[];
/** Aggregated per-family summary — most useful for DESIGN.md authoring */
families: FontFamilySummary[];
/** Files where identification failed entirely. Should be empty for typical captures. */
unidentified: string[];
/** Generated-at timestamp + tool version for debugging */
meta: { generatedAt: string; tool: string };
}
/**
* Read all font files in fontsDir, extract metadata via fontkit, and write
* the manifest to outputPath. Returns the manifest in case callers want to log it.
*
* Failures are non-fatal: if a single font's name table is missing or corrupt,
* the file is added to `unidentified` and the rest continue. If the fonts
* directory doesn't exist, returns an empty manifest without throwing.
*/
export function extractFontMetadata(fontsDir: string, outputPath: string): FontsManifest {
const files: FontFileMetadata[] = [];
const unidentified: string[] = [];
if (existsSync(fontsDir)) {
const fontFiles = readdirSync(fontsDir).filter((f) => /\.(woff2?|ttf|otf)$/i.test(f));
for (const filename of fontFiles) {
const fullPath = join(fontsDir, filename);
const meta = readSingleFont(fullPath, filename);
if (meta.identified) {
files.push(meta);
} else {
files.push(meta);
unidentified.push(filename);
}
}
}
const families = aggregateFamilies(files);
const manifest: FontsManifest = {
files,
families,
unidentified,
meta: {
generatedAt: new Date().toISOString(),
// Record just the tool name; the version moves with the dep and would
// otherwise drift from a hardcoded string on every fontkit bump.
tool: "fontkit",
},
};
writeFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
return manifest;
}
// fallow-ignore-next-line complexity
function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
const empty: FontFileMetadata = {
file: filename,
family: "",
rawFamily: "",
subfamily: "",
postscript: "",
weight: 0,
style: "normal",
variationAxes: [],
identified: false,
isIcon: false,
};
try {
const buf = readFileSync(fullPath);
// fontkit.create returns Font | FontCollection. For TTC/DFont collections,
// take the first font inside; otherwise the value is already a single Font.
const created: Font | FontCollection = fontkit.create(buf);
const font: Font | undefined = isFontCollection(created) ? created.fonts[0] : created;
if (!font) return empty;
const rawFamily = (font.familyName || "").trim();
const subfamily = (font.subfamilyName || "").trim();
const postscript = (font.postscriptName || "").trim();
const fsSelection = font["OS/2"]?.fsSelection;
const italicBit = Boolean(fsSelection?.italic || fsSelection?.oblique);
const style: "normal" | "italic" =
italicBit || /italic|oblique/i.test(subfamily) ? "italic" : "normal";
const variationAxes = font.variationAxes ? Object.keys(font.variationAxes) : [];
if (!rawFamily && !postscript) return empty; // name table empty — cannot identify
const familyForCanonicalization = rawFamily || deriveFamilyFromPostscript(postscript);
const { canonical, inferredWeight } = canonicalizeFamily(familyForCanonicalization);
const weight =
font["OS/2"]?.usWeightClass ?? inferredWeight ?? inferWeightFromSubfamily(subfamily);
return {
file: filename,
family: canonical || familyForCanonicalization,
rawFamily: familyForCanonicalization,
subfamily,
postscript,
weight,
style,
variationAxes,
identified: true,
isIcon: detectIconFont(font),
};
} catch {
return empty;
}
}
/**
* Detect an ICON font by glyph coverage rather than by name (icon fonts often have arbitrary
* names like "hushly" or "swiper-icons" that no name-list can enumerate). See isIconCharacterSet
* for the rule (lacks a Latin alphabet AND mostly Private-Use-Area glyphs). `characterSet` is a
* fontkit runtime member not always in its typings, so it's read through a narrow local shape.
*/
function detectIconFont(font: Font): boolean {
const f = font as unknown as { characterSet?: number[] };
try {
return isIconCharacterSet(Array.isArray(f.characterSet) ? f.characterSet : []);
} catch {
return false;
}
}
/**
* Detect an icon font from glyph coverage. Two conditions must BOTH hold:
* 1. it lacks a real Latin alphabet (< 26 of A-Za-z) — a text font ships the full alphabet;
* 2. most of its glyphs (> 50%) live in a Unicode Private Use Area.
* The Latin gate is essential: some text fonts pack thousands of PUA glyphs yet are plainly text —
* Apple's SF Pro (81% PUA, full A-Za-z, ships SF Symbols in the PUA), Descript's Booton (50% PUA,
* full A-Za-z). Flagging those by PUA ratio alone strips a brand's real typeface. Measured icon
* fonts: "hushly" 63% PUA / 7 letters, Font Awesome 95% PUA / 0 letters. Exported for testing.
*/
export function isIconCharacterSet(characterSet: number[]): boolean {
if (!characterSet.length) return false;
const isLatinLetter = (cp: number) => (cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a);
if (characterSet.filter(isLatinLetter).length >= 26) return false; // has an alphabet → a text font
const inPua = (cp: number) => (cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0x10fffd);
return characterSet.filter(inPua).length / characterSet.length > 0.5;
}
/** Aggregate per-file entries into per-family summaries — most useful shape for DESIGN.md. */
// fallow-ignore-next-line complexity
function aggregateFamilies(files: FontFileMetadata[]): FontFamilySummary[] {
const byFamily = new Map<string, FontFamilySummary>();
for (const f of files) {
if (!f.family) continue;
let entry = byFamily.get(f.family);
if (!entry) {
entry = { family: f.family, weights: [], variable: false, fileCount: 0, files: [] };
byFamily.set(f.family, entry);
}
entry.fileCount++;
entry.files.push(f.file);
if (f.variationAxes.length > 0) entry.variable = true;
if (f.weight && !entry.weights.includes(f.weight)) entry.weights.push(f.weight);
}
for (const entry of byFamily.values()) {
entry.weights.sort((a, b) => a - b);
entry.files.sort();
}
return Array.from(byFamily.values()).sort((a, b) => a.family.localeCompare(b.family));
}
/**
* PostScript names follow the convention `Family-Style`. When the family name
* record (nameID 1) is missing but PostScript is present, recover the family
* portion as a best-effort fallback.
*/
function deriveFamilyFromPostscript(postscript: string): string {
if (!postscript) return "";
const dashIdx = postscript.indexOf("-");
return (dashIdx > 0 ? postscript.slice(0, dashIdx) : postscript).trim();
}
/**
* Fallback when OS/2 table is missing — guess weight from "Bold", "Light", etc.
*
* Normalizes spaces and hyphens out of the subfamily before matching so that
* fonts using spaced names ("Extra Light", "Semi Bold") or hyphenated names
* ("Extra-Light", "Semi-Bold") resolve to the same weight as the concatenated
* forms ("ExtraLight", "SemiBold"). Without this, a font subfamily of
* "Extra Light" would fall through every concat check and end at the 400
* default, misreporting a 200-weight font as 400.
*
* Exported for unit testing.
*/
// fallow-ignore-next-line complexity
export function inferWeightFromSubfamily(subfamily: string): number {
const s = subfamily.toLowerCase().replace(/[\s-]+/g, "");
if (s.includes("thin")) return 100;
if (s.includes("extralight") || s.includes("ultralight")) return 200;
if (s.includes("light")) return 300;
if (s.includes("medium")) return 500;
if (s.includes("semibold") || s.includes("demibold")) return 600;
if (s.includes("extrabold") || s.includes("ultrabold")) return 800;
if (s.includes("black") || s.includes("heavy")) return 900;
if (s.includes("bold")) return 700;
return 400;
}
/**
* Map of trailing weight tokens found in family names (e.g. "Inter Medium" →
* "Inter") to their numeric OS/2 weight equivalent. Used to canonicalize family
* names when a foundry packaged each weight as a separate "family" instead of
* setting nameID 16 / 17 (Preferred Family / Subfamily).
*
* Conservative: only strips well-known English weight tokens. Width modifiers
* like "Tight", "Condensed", "Extended" are intentionally NOT stripped — they
* denote separate typographic families, not weight variants. Localized weight
* tokens (German "Fett", "Extrafett"; French "Maigre"; etc.) and abbreviations
* ("ExtBd", "ExtBlk") are not stripped either — the resulting family stays
* separate, which is an honest representation of what's in the file.
*/
const WEIGHT_TOKEN_TO_VALUE: Record<string, number> = {
Thin: 100,
Hairline: 100,
ExtraLight: 200,
UltraLight: 200,
Light: 300,
Book: 400,
Regular: 400,
Normal: 400,
Medium: 500,
SemiBold: 600,
DemiBold: 600,
Bold: 700,
ExtraBold: 800,
UltraBold: 800,
Black: 900,
Heavy: 900,
ExtraBlack: 950,
UltraBlack: 950,
};
const WEIGHT_TOKEN_RE = new RegExp(`\\s+(${Object.keys(WEIGHT_TOKEN_TO_VALUE).join("|")})$`, "i");
/**
* Strip a trailing weight token from a family name and return both the
* canonicalized form and the weight value the stripped token implied.
*
* Examples:
* "Inter Medium" → { canonical: "Inter", inferredWeight: 500 }
* "Inter Tight Medium" → { canonical: "Inter Tight", inferredWeight: 500 }
* "Funnel Display Light" → { canonical: "Funnel Display", inferredWeight: 300 }
* "Tiempos Headline" → { canonical: "Tiempos Headline", inferredWeight: null }
* "Söhne Breit Extrafett" → { canonical: "Söhne Breit Extrafett", inferredWeight: null }
*
* Trailing "Italic"/"Oblique" is stripped before weight detection so families
* like "Inter Italic" or "Inter Medium Italic" canonicalize correctly. The
* italic flag is recovered separately from the OS/2 fsSelection bit, so no
* information is lost.
*/
// Exported for unit testing.
// fallow-ignore-next-line complexity
export function canonicalizeFamily(family: string): {
canonical: string;
inferredWeight: number | null;
} {
if (!family) return { canonical: family, inferredWeight: null };
let result = family.trim();
// Strip trailing "Italic" or "Oblique" first — handled by the style field.
result = result.replace(/\s+(Italic|Oblique)$/i, "").trim();
// Normalize compound weight tokens written with a space ("Semi Bold" → "SemiBold")
// so the single-token matcher below catches them. Anchored to end-of-string to
// avoid touching family names that legitimately contain these words mid-string.
result = result.replace(
/\s+(Semi|Extra|Ultra|Demi)\s+(Bold|Black|Light)$/i,
(_, prefix: string, suffix: string) => ` ${capitalize(prefix)}${capitalize(suffix)}`,
);
// Strip trailing weight token if any.
const match = result.match(WEIGHT_TOKEN_RE);
if (match && match[1]) {
// Look up the canonical (case-sensitive) key for the matched token.
const matchedKey = Object.keys(WEIGHT_TOKEN_TO_VALUE).find(
(k) => k.toLowerCase() === match[1]!.toLowerCase(),
);
const inferredWeight = matchedKey ? WEIGHT_TOKEN_TO_VALUE[matchedKey]! : null;
result = result.slice(0, result.length - match[0].length).trim();
return { canonical: result, inferredWeight };
}
return { canonical: result, inferredWeight: null };
}
function capitalize(s: string): string {
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1).toLowerCase();
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Extract full-page HTML from a website using Puppeteer CDP.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import type { ExtractedHtml } from "./types.js";
import { isPrivateUrl } from "./assetDownloader.js";
const DEFAULT_SETTLE_TIME = 3000;
// Pre-existing capture pipeline size — surfaced by a one-line escape fix, not new logic.
// fallow-ignore-next-line complexity
export async function extractHtml(
page: Page,
opts: { settleTime?: number } = {},
): Promise<ExtractedHtml> {
const settleTime = opts.settleTime ?? DEFAULT_SETTLE_TIME;
// Lazy-load scroll removed — index.ts already scrolls before calling extractHtml.
// Images are loaded by the time we get here.
// Settle wait kept as buffer before DOM extraction.
await new Promise((r) => setTimeout(r, settleTime));
// Step 2: Inline external stylesheets
// Fetch CSS from Node.js (bypasses CORS) then inject into page
const stylesheetUrls = (await page.evaluate(`(() => {
return Array.from(document.querySelectorAll('link[rel="stylesheet"][href]')).map(function(l) { return l.href; });
})()`)) as string[];
for (const href of stylesheetUrls) {
try {
if (isPrivateUrl(href)) continue;
const res = await fetch(href, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "Mozilla/5.0" },
});
if (!res.ok) continue;
let css = await res.text();
// Fix relative url() references
// fallow-ignore-next-line complexity
css = css.replace(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g, (match: string, url: string) => {
if (url.startsWith("data:") || url.startsWith("http") || url.startsWith("//")) return match;
try {
return `url('${new URL(url, href).href}')`;
} catch {
return match;
}
});
// Add the CSS as a <style> tag in <head> via Puppeteer's addStyleTag
await page.addStyleTag({ content: css });
// Remove the original <link> tag (use parameterized evaluate to avoid injection)
await page.evaluate((targetHref: string) => {
const links = document.querySelectorAll('link[rel="stylesheet"]');
for (const link of links) {
if ((link as HTMLLinkElement).href === targetHref) {
link.remove();
break;
}
}
}, href);
} catch {
/* network error — skip */
}
}
// Step 3: Make URLs absolute and fix HTML entity encoding in src attributes
await page.evaluate(`(() => {
document.querySelectorAll("img[src]").forEach(function(el) {
try {
// getAttribute returns the raw HTML attribute (with &amp;)
// .src returns the resolved URL (with &) — use .src for the correct value
var resolved = el.src;
if (resolved) el.setAttribute("src", resolved);
} catch(e) {}
});
// Fix srcset attributes too (Next.js image optimization)
document.querySelectorAll("img[srcset]").forEach(function(el) {
try {
var srcset = el.getAttribute("srcset") || "";
// Decode &amp; entities in srcset
srcset = srcset.replace(/&amp;/g, "&");
el.setAttribute("srcset", srcset);
} catch(e) {}
});
document.querySelectorAll('[style*="url("]').forEach(function(el) {
el.style.cssText = el.style.cssText.replace(/url\\(['"]?([^'"\\)\\s]+)['"]?\\)/g, function(_, url) {
try { return "url('" + new URL(url, location.href).href + "')"; } catch(e) { return "url('" + url + "')"; }
});
});
})()`);
// Step 3b: Convert cross-origin images to data URLs
// Some CDNs (Contentful, etc.) block direct access but images are already
// loaded in the browser. We convert loaded images to data URLs via canvas.
await page.evaluate(`(async () => {
var imgs = Array.from(document.querySelectorAll("img"));
for (var i = 0; i < imgs.length; i++) {
var img = imgs[i];
try {
if (!img.src || img.src.startsWith("data:")) continue;
if (img.naturalWidth < 10 || img.naturalHeight < 10) continue;
// Only convert cross-origin images (same-origin ones will load fine)
var imgUrl = new URL(img.src);
if (imgUrl.origin === location.origin) continue;
// Try to draw to canvas — will fail if CORS blocks it
var canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
var dataUrl = canvas.toDataURL("image/png");
if (dataUrl.length > 100) {
img.setAttribute("src", dataUrl);
img.removeAttribute("srcset");
}
} catch(e) {
// Canvas CORS failed — try fetch + blob as fallback
try {
var resp = await fetch(img.src, { mode: "cors" });
if (resp.ok) {
var blob = await resp.blob();
var reader = new FileReader();
var dataUrl2 = await new Promise(function(resolve) {
reader.onloadend = function() { resolve(reader.result); };
reader.readAsDataURL(blob);
});
if (dataUrl2 && typeof dataUrl2 === "string" && dataUrl2.length > 100) {
img.setAttribute("src", dataUrl2);
img.removeAttribute("srcset");
}
}
} catch(e2) {
// Both methods failed — image stays as original URL
}
}
}
})()`);
// Step 4: Extract everything
const result = (await page.evaluate(`(() => {
// Capture styles AND scripts from head separately then combine
// Scripts include Three.js, animation libraries that we want to preserve
var styles = Array.from(document.head.querySelectorAll("style")).map(function(s) { return s.outerHTML; }).join("\\n");
var scripts = Array.from(document.head.querySelectorAll("script")).map(function(s) { return s.outerHTML; }).join("\\n");
var headHtml = styles + "\\n" + scripts;
var bodyHtml = document.body.innerHTML;
var cssomRules = [];
for (var i = 0; i < document.styleSheets.length; i++) {
var sheet = document.styleSheets[i];
try {
var ownerNode = sheet.ownerNode;
if (ownerNode && ownerNode.textContent && ownerNode.textContent.trim()) continue;
if (sheet.href) continue;
for (var j = 0; j < sheet.cssRules.length; j++) {
cssomRules.push(sheet.cssRules[j].cssText);
}
} catch(e) {}
}
var htmlEl = document.documentElement;
var attrParts = [];
for (var i = 0; i < htmlEl.attributes.length; i++) {
var attr = htmlEl.attributes[i];
if (attr.name === "lang" || attr.name === "class" || attr.name === "style" || attr.name === "dir" || attr.name.startsWith("data-")) {
attrParts.push(attr.name + '="' + attr.value.replace(/&/g, "&amp;").replace(/"/g, "&quot;") + '"');
}
}
return {
headHtml: headHtml,
bodyHtml: bodyHtml,
cssomRules: cssomRules.join("\\n"),
htmlAttrs: attrParts.join(" "),
viewportWidth: Math.max(window.innerWidth, document.documentElement.scrollWidth),
viewportHeight: window.innerHeight,
fullPageHeight: document.body.scrollHeight
};
})()`)) as ExtractedHtml;
// Post-process in Node.js (more reliable than browser-side fixing):
// 1. Decode &amp; in image src/srcset attributes
// 2. Make relative image URLs absolute using the page's origin
const pageOrigin = new URL(page.url()).origin;
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<img\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
let fixed = url.replace(/&amp;/g, "&");
// Make relative URLs absolute
if (fixed.startsWith("/") && !fixed.startsWith("//")) {
fixed = pageOrigin + fixed;
}
return pre + fixed + post;
},
);
result.bodyHtml = result.bodyHtml.replace(
/(<img\b[^>]*\bsrcset=")([^"]*?)(")/g,
(_match: string, pre: string, urls: string, post: string) => {
const fixed = urls
.replace(/&amp;/g, "&")
.replace(
/(^|,\s*)(\/[^\s,]+)/g,
(_m: string, sep: string, path: string) => sep + pageOrigin + path,
);
return pre + fixed + post;
},
);
// Also fix video src/poster URLs
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
let fixed = url.replace(/&amp;/g, "&");
if (fixed.startsWith("/") && !fixed.startsWith("//")) fixed = pageOrigin + fixed;
return pre + fixed + post;
},
);
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bposter=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
let fixed = url.replace(/&amp;/g, "&");
if (fixed.startsWith("/") && !fixed.startsWith("//")) fixed = pageOrigin + fixed;
return pre + fixed + post;
},
);
return result;
}
+681
View File
@@ -0,0 +1,681 @@
/**
* Website capture orchestrator.
*
* Two-pass capture approach:
* Pass 1: Full page load (all JS) → catalog animations + snapshot canvases
* Pass 2: Framework scripts blocked → extract stable HTML/CSS
*
* This ensures we get both:
* - Rich animation metadata for Claude Code to recreate
* - Stable, renderable HTML that won't crash in Puppeteer
*/
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { extractHtml } from "./htmlExtractor.js";
// captureScreenshots removed — full-page screenshot replaces per-section shots
import { extractTokens } from "./tokenExtractor.js";
import { extractDesignStyles } from "./designStyleExtractor.js";
import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js";
import { extractFontMetadata } from "./fontMetadataExtractor.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
// briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them
import {
setupAnimationCapture,
startCdpAnimationCapture,
collectAnimationCatalog,
} from "./animationCataloger.js";
import {
saveLottieAnimations,
renderLottiePreviews,
captureVideoManifest,
} from "./mediaCapture.js";
import type { DiscoveredLottie } from "./mediaCapture.js";
import {
detectLibraries,
extractVisibleText,
captionImagesWithGemini,
generateAssetDescriptions,
} from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
import type { CaptureOptions, CaptureResult } from "./types.js";
export type { CaptureOptions, CaptureResult } from "./types.js";
// fallow-ignore-next-line complexity
export async function captureWebsite(
opts: CaptureOptions,
onProgress?: (stage: string, detail?: string) => void,
): Promise<CaptureResult> {
const {
url,
outputDir,
viewportWidth = 1920,
viewportHeight = 1080,
timeout = 120000,
settleTime = 3000,
maxScreenshots: _maxScreenshots = 24,
skipAssets = false,
} = opts;
const warnings: string[] = [];
const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail);
};
// Load .env file from repo root if it exists (for GEMINI_API_KEY, etc.)
loadEnvFile(outputDir);
// Create output directories
mkdirSync(join(outputDir, "extracted"), { recursive: true });
mkdirSync(join(outputDir, "screenshots"), { recursive: true });
mkdirSync(join(outputDir, "assets"), { recursive: true });
// Launch browser
progress("browser", "Launching headless Chrome...");
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
"--use-gl=angle",
"--use-angle=swiftshader",
"--disable-blink-features=AutomationControlled",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
`--window-size=${viewportWidth},${viewportHeight}`,
],
});
let animationCatalog: CaptureResult["animationCatalog"];
try {
// ═══════════════════════════════════════════════════════════════
// PASS 1: Full page load — all JS runs
// Goal: Catalog animations + take screenshots (with JS rendering)
// ═══════════════════════════════════════════════════════════════
progress("animations", "Cataloging animations (full JS)...");
const page1 = await chromeBrowser.newPage();
await page1.setViewport({ width: viewportWidth, height: viewportHeight });
await page1.setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
);
// Set up hooks BEFORE navigation
await setupAnimationCapture(page1);
const { cdp, animations: cdpAnims } = await startCdpAnimationCapture(page1);
// Hook WebGL to capture shader source code (GLSL)
// Captured shaders inform Claude Code about the site's visual effects
// and enable reliable library detection (Three.js/PixiJS/Babylon.js uniforms survive bundling)
await page1.evaluateOnNewDocument(`
var origGetContext = HTMLCanvasElement.prototype.getContext;
window.__capturedShaders = [];
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
var ctx = origGetContext.call(this, type, attrs);
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
if (ctx.shaderSource && !ctx.__hfHooked) {
var origShaderSource = ctx.shaderSource.bind(ctx);
ctx.shaderSource = function(shader, source) {
try {
var shaderType = ctx.getShaderParameter(shader, ctx.SHADER_TYPE);
window.__capturedShaders.push({
type: shaderType === ctx.VERTEX_SHADER ? 'vertex' : 'fragment',
source: source.slice(0, 5000)
});
} catch(e) {}
return origShaderSource(shader, source);
};
ctx.__hfHooked = true;
}
}
return ctx;
};
`);
// Intercept network responses to detect Lottie JSON files
const discoveredLotties: DiscoveredLottie[] = [];
// Layer 1 (passive video discovery): every direct-video URL the page fetches
// over the whole session (load / scroll / carousel rotation), independent of
// whether a <video> for it exists at snapshot time. captureVideoManifest
// downloads these (guarded) and merges them into the manifest.
const discoveredVideoUrls = new Set<string>();
// fallow-ignore-next-line complexity
page1.on("response", async (response) => {
try {
const responseUrl = response.url();
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
discoveredVideoUrls.add(responseUrl);
}
const contentType = response.headers()["content-type"] || "";
const isJsonUrl = responseUrl.endsWith(".json");
const isLottieUrl = responseUrl.endsWith(".lottie");
const isJson =
contentType.includes("application/json") || contentType.includes("text/plain");
if (isLottieUrl) {
discoveredLotties.push({ url: responseUrl });
return;
}
if (isJsonUrl || isJson) {
// Check Content-Length before downloading to avoid OOM on huge responses
const cl = parseInt(response.headers()["content-length"] || "0", 10);
if (cl > 5_000_000) return;
const buffer = await response.buffer();
if (buffer.length < 100 || buffer.length > 5_000_000) return; // Skip tiny or huge
const text = buffer.toString("utf-8");
const json = JSON.parse(text);
// Validate Lottie structure: must have version, in/out points, layers, dimensions, framerate
if (
json &&
typeof json === "object" &&
["v", "ip", "op", "layers", "w", "h", "fr"].every((k: string) => k in json)
) {
discoveredLotties.push({
url: responseUrl,
data: json,
dimensions: { w: json.w, h: json.h },
frameRate: json.fr,
});
}
}
} catch {
/* not JSON or parse error — skip */
}
});
// Use networkidle2 (allows 2 ongoing connections) instead of networkidle0 —
// modern SPAs often have persistent WebSocket/analytics connections that
// prevent networkidle0 from ever resolving.
await page1.goto(url, { waitUntil: "networkidle2", timeout });
await new Promise((r) => setTimeout(r, settleTime));
// Check if the page loaded real content or an anti-bot challenge
// Use structural detection (DOM elements + cookies), not text regex matching —
// text matching causes false positives on sites that mention "blocked" or "verify" in copy
const pageContentCheck = (await page1.evaluate(`(() => {
var text = (document.body.innerText || "").trim();
var title = document.title || "";
// Structural: Cloudflare Turnstile widget or challenge iframe
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
// Structural: page is almost empty (challenge pages have minimal DOM)
var bodyChildCount = document.body.children.length;
var isMinimalDom = bodyChildCount <= 5 && text.length < 500;
// Title-based: only check title on near-empty pages
var hasChallengeTitle = isMinimalDom && /just a moment|attention required|access denied/i.test(title);
var isChallenged = hasCfTurnstile || hasChallengeTitle;
return { textLength: text.length, title: title, isChallenged: isChallenged, bodyChildCount: bodyChildCount };
})()`)) as { textLength: number; title: string; isChallenged: boolean; bodyChildCount: number };
if (pageContentCheck.isChallenged || pageContentCheck.textLength < 100) {
const reason = pageContentCheck.isChallenged
? "Anti-bot protection detected (Cloudflare challenge or similar)"
: "Page has very little text content (" +
pageContentCheck.textLength +
" chars) — may be blocked or a client-rendered SPA that needs more time";
warnings.push(reason);
progress("warn", reason);
}
// Scroll through page to trigger lazy-loaded images and Lottie animations
// Framer and other modern sites use IntersectionObserver — images only load
// when scrolled into view. We scroll the full page, then wait for all images
// to finish loading before proceeding.
await page1.evaluate(`(async () => {
var h = document.body.scrollHeight;
for (var y = 0; y < h; y += window.innerHeight * 0.7) {
window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 400); });
}
// Scroll to very bottom to catch footer lazy-loads
window.scrollTo(0, document.body.scrollHeight);
await new Promise(function(r) { setTimeout(r, 800); });
// Wait for all images to finish loading
var imgs = Array.from(document.querySelectorAll('img'));
var pending = imgs.filter(function(img) { return !img.complete; });
if (pending.length > 0) {
await Promise.race([
Promise.all(pending.map(function(img) {
return new Promise(function(r) { img.onload = r; img.onerror = r; });
})),
new Promise(function(r) { setTimeout(r, 5000); })
]);
}
window.scrollTo(0, 0);
await new Promise(function(r) { setTimeout(r, 500); });
})()`);
await page1.evaluate(`window.scrollTo(0, 0)`);
await new Promise((r) => setTimeout(r, 300));
// Save discovered Lottie animations
// Also scan DOM for Lottie web components not caught by network interception
try {
const domLotties = await page1.evaluate(`(() => {
var urls = [];
document.querySelectorAll('dotlottie-wc, lottie-player, dotlottie-player').forEach(function(el) {
var src = el.getAttribute('src');
if (src) urls.push(src);
});
// Also check lottie-web registered animations
if (window.lottie && window.lottie.getRegisteredAnimations) {
window.lottie.getRegisteredAnimations().forEach(function(anim) {
if (anim.path) urls.push(anim.path);
});
}
return urls;
})()`);
if (Array.isArray(domLotties)) {
for (const lottieUrl of domLotties) {
if (
typeof lottieUrl === "string" &&
!discoveredLotties.some((l) => l.url === lottieUrl)
) {
discoveredLotties.push({ url: lottieUrl });
}
}
}
} catch {
/* DOM scan failed — non-critical */
}
if (discoveredLotties.length > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
mkdirSync(lottieDir, { recursive: true });
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0) {
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir);
progress("lottie", `${savedCount} Lottie animation(s) saved`);
}
}
// Save captured WebGL shaders (useful context for shader transitions + library detection)
let capturedShaders: Array<{ type: string; source: string }> | undefined;
try {
const shaders = await page1.evaluate(`window.__capturedShaders || []`);
if (Array.isArray(shaders) && shaders.length > 0) {
const seen = new Set<string>();
const unique = (shaders as Array<{ type: string; source: string }>).filter((s) => {
if (seen.has(s.source)) return false;
seen.add(s.source);
return true;
});
capturedShaders = unique;
writeFileSync(
join(outputDir, "extracted", "shaders.json"),
JSON.stringify(unique, null, 2),
"utf-8",
);
progress("shaders", `${unique.length} WebGL shader(s) captured`);
}
} catch {
/* shader extraction failed — non-critical */
}
// ── READ-ONLY phase: extract data from the live DOM before any mutations ──
// extractHtml (below) converts image src to data URLs and removes scripts —
// all read-only operations must run BEFORE it to see the original DOM.
// Extract design tokens
progress("tokens", "Extracting design tokens...");
const tokens = await extractTokens(page1);
// Save tokens.json without SVG outerHTML (kept in memory for asset downloader)
const tokensForDisk = {
...tokens,
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
};
writeFileSync(
join(outputDir, "extracted", "tokens.json"),
JSON.stringify(tokensForDisk, null, 2),
"utf-8",
);
// Extract computed design styles (typography, buttons, cards, spacing, shadows)
progress("style", "Extracting design styles...");
try {
const designStyles = await extractDesignStyles(page1);
writeFileSync(
join(outputDir, "extracted", "design-styles.json"),
JSON.stringify(designStyles, null, 2),
"utf-8",
);
progress(
"tokens",
`${designStyles.typography.length} typography roles, ${designStyles.buttons.length} button styles, ${designStyles.shadows.length} shadow values extracted`,
);
} catch (err) {
const errMsg =
err instanceof Error ? `${err.message}\n${err.stack}` : normalizeErrorMessage(err);
console.error(` ⚠ Design style extraction failed: ${errMsg}`);
warnings.push(`Design style extraction failed: ${errMsg}`);
}
// Collect animation catalog
progress("animations", "Cataloging animations...");
animationCatalog = await collectAnimationCatalog(page1, cdpAnims, cdp);
// Capture scroll-position viewport screenshots
progress("screenshots", "Capturing scroll screenshots...");
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
const screenshots = await captureScrollScreenshots(page1, outputDir);
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
// Catalog all assets (must run before extractHtml which converts img src to data URLs)
progress("design", "Cataloging assets...");
let catalogedAssets: import("./assetCataloger.js").CatalogedAsset[] = [];
try {
const { catalogAssets } = await import("./assetCataloger.js");
catalogedAssets = await catalogAssets(page1);
progress("design", `${catalogedAssets.length} assets cataloged`);
if (catalogedAssets.length === 0) {
warnings.push(
"Asset catalog is empty — no images will be downloaded. The page may use non-standard image loading.",
);
}
} catch (err) {
warnings.push(`Asset cataloging failed (no images will be downloaded): ${err}`);
}
// ── MUTATION phase: extractHtml modifies the live DOM (converts images to data URLs) ──
progress("extract", "Extracting HTML & CSS...");
const extracted = await extractHtml(page1, { settleTime: 1000 });
// Strip framework scripts from the extracted body — keep visual library scripts
// IMPORTANT: Use non-greedy matching within individual script tags only
extracted.bodyHtml = extracted.bodyHtml
// Remove __NEXT_DATA__ (has its own ID so safe to target)
.replace(/<script\s+id="__NEXT_DATA__"[^>]*>[\s\S]*?<\/script>/gi, "")
// Remove React hydration markers
.replace(/\s*data-reactroot="[^"]*"/g, "")
.replace(/\s*data-reactroot/g, "");
// Remove Next.js bootstrap scripts individually (match each script tag separately)
extracted.bodyHtml = extracted.bodyHtml.replace(
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
// fallow-ignore-next-line complexity
(match: string, content: string) => {
// Only remove if this specific script contains Next.js bootstrap code
if (
content.includes("__next_f") ||
content.includes("self.__next_f") ||
content.includes("__NEXT_LOADED_PAGES__") ||
content.includes("_N_E") ||
content.includes("__NEXT_P")
) {
return "";
}
return match;
},
);
// Strip framework script tags from head (keep styles + visual library scripts)
const FRAMEWORK_SRC_PATTERNS = [
/_next\/static\/chunks\/(main|framework|webpack|pages\/)/,
/_next\/static\/chunks\/app\//,
/_buildManifest\.js/,
/_ssgManifest\.js/,
];
extracted.headHtml = extracted.headHtml.replace(
/<script[^>]*src="([^"]*)"[^>]*><\/script>/gi,
(match: string, src: string) => {
if (FRAMEWORK_SRC_PATTERNS.some((p) => p.test(src))) return "";
return match;
},
);
// Generate video manifest — screenshot each <video> element + extract surrounding context
// so Claude Code can SEE what each video shows and WHERE it was used on the page.
try {
await captureVideoManifest(page1, outputDir, progress, {
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
sampleMs: 12000, // Layer 2: poll DOM ≤12s so auto-rotating carousels reveal each slide
});
} catch {
/* non-blocking — video manifest is best-effort */
}
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
const detectedLibraries = await detectLibraries(page1, capturedShaders);
// Extract all visible text in DOM order
const visibleTextContent = await extractVisibleText(page1);
// Extract favicon links before closing page (removed from tokens to reduce noise)
const faviconLinks = (await page1.evaluate(`(() => {
var iconEls = Array.from(document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]'));
return iconEls.map(function(l) { return { rel: l.rel, href: l.href }; });
})()`)) as Array<{ rel: string; href: string }>;
await page1.close();
// Download fonts and rewrite URLs to local paths
extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir);
// Identify each downloaded font by reading its OpenType name table.
// Modern frameworks hash font filenames; this manifest tells the
// downstream pipeline (DESIGN.md authoring, beat sub-agents) which file
// belongs to which family without guessing from filename patterns.
try {
const fontsManifest = extractFontMetadata(
join(outputDir, "assets", "fonts"),
join(outputDir, "extracted", "fonts-manifest.json"),
);
if (fontsManifest.families.length > 0) {
const summary = fontsManifest.families
.map((f) => `${f.family}${f.variable ? " (variable)" : ""} × ${f.fileCount}`)
.join(", ");
console.log(`Font metadata extracted: ${summary}`);
if (fontsManifest.unidentified.length > 0) {
console.warn(
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
);
}
}
} catch (err) {
console.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
}
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
if (animationCatalog) {
// Extract just what's useful: counts, named animations, a few representative keyframed entries
const uniqueAnimNames = new Set<string>();
for (const d of animationCatalog.cssDeclarations || []) {
if (d.animation?.name) uniqueAnimNames.add(d.animation.name);
}
// Keep up to 10 Web Animations that have actual keyframe data (most useful for recreation)
const representativeAnims = (animationCatalog.webAnimations || [])
.filter((a) => a.keyframes && a.keyframes.length > 0)
.slice(0, 10);
const leanCatalog = {
summary: animationCatalog.summary,
namedAnimations: Array.from(uniqueAnimNames),
scrollTriggeredElements: (animationCatalog.scrollTargets || []).length,
representativeAnimations: representativeAnims,
};
writeFileSync(
join(outputDir, "extracted", "animations.json"),
JSON.stringify(leanCatalog, null, 2),
"utf-8",
);
}
// Download assets — single pass using the catalog for best image quality
let assets: CaptureResult["assets"] = [];
if (!skipAssets) {
progress("assets", "Downloading assets...");
assets = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks);
}
// Join in-section media URLs → downloaded local paths, then re-write
// tokens.json. Downstream page recreation MUST reference local files:
// remote URLs fail at render time (hotlink/CORS 403, no egress in
// Docker/Lambda, frame-timing blanks for not-yet-loaded images).
if (assets.length && Array.isArray(tokens.sections)) {
const base = (u: string): string => u.split(/[#?]/)[0] ?? u;
const localByUrl = new Map<string, string>();
for (const a of assets) {
if (!a.url || !a.localPath) continue;
localByUrl.set(a.url, a.localPath);
localByUrl.set(base(a.url), a.localPath);
}
for (const sec of tokens.sections) {
const local: string[] = [];
for (const u of sec.assetUrls || []) {
const hit = localByUrl.get(u) || localByUrl.get(base(u));
if (hit && !local.includes(hit)) local.push(hit);
}
if (local.length) sec.assets = local;
}
const tokensForDisk2 = {
...tokens,
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
};
writeFileSync(
join(outputDir, "extracted", "tokens.json"),
JSON.stringify(tokensForDisk2, null, 2),
"utf-8",
);
}
// Persist a self-contained page recreation (extracted/page.html) as the
// high-fidelity structural reference for the page-card rebuild. NOT a
// composition — kept under extracted/ so the producer (which discovers
// compositions by index.html) never picks it up. Images are already inlined
// as data URLs by extractHtml, so it renders standalone.
try {
const pageHtml = `<!doctype html>\n<html ${extracted.htmlAttrs || ""}>\n<head>\n${extracted.headHtml}\n</head>\n<body>\n${extracted.bodyHtml}\n</body>\n</html>\n`;
writeFileSync(join(outputDir, "extracted", "page.html"), pageHtml, "utf-8");
} catch (err) {
warnings.push(`page.html write failed: ${err}`);
}
// Save visible text content for AI agent to use
if (visibleTextContent) {
writeFileSync(join(outputDir, "extracted", "visible-text.txt"), visibleTextContent, "utf-8");
}
// detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing
// AI-powered image captioning via Gemini (optional — enriches asset descriptions)
const geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings);
// Generate asset descriptions for the AI agent
progress("design", "Generating asset descriptions...");
try {
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
if (lines.length > 0) {
const hasGeminiKey = !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
const header = hasGeminiKey
? "# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file — that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim — many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n"
: "# Asset Descriptions\n\n⚠️ GEMINI_API_KEY not set — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY) and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n";
writeFileSync(
join(outputDir, "extracted", "asset-descriptions.md"),
header + lines.map((l) => "- " + l).join("\n") + "\n",
"utf-8",
);
progress(
"design",
`${lines.length} asset descriptions written${hasGeminiKey ? "" : " (no Gemini key — catalog-fallback mode)"}`,
);
}
} catch {
/* non-critical */
}
progress("design", "DESIGN.md will be created by your AI agent");
// Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually)
// All functions return string[] — paginated so every image is covered
try {
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
await import("./contactSheet.js");
const scrollSheets = await createScrollContactSheet(
join(outputDir, "screenshots"),
join(outputDir, "screenshots", "contact-sheet.jpg"),
);
if (scrollSheets.length > 0)
progress(
"design",
`Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
);
const assetsImgDir = join(outputDir, "assets");
if (existsSync(assetsImgDir)) {
const assetSheets = await createAssetContactSheet(
assetsImgDir,
join(outputDir, "assets", "contact-sheet.jpg"),
);
if (assetSheets.length > 0)
progress(
"design",
`Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`,
);
}
// Scan assets/svgs/ (inline SVGs) AND assets/ root (external SVGs from <img src="*.svg">)
// so sites like huly.io that only use external SVGs still get a grid
const svgsDir = join(outputDir, "assets", "svgs");
const assetsRootDir = join(outputDir, "assets");
const svgOutputPath = existsSync(svgsDir)
? join(outputDir, "assets", "svgs", "contact-sheet.jpg")
: join(outputDir, "assets", "contact-sheet-svgs.jpg");
const svgSheets = await createSvgContactSheet(svgsDir, svgOutputPath, assetsRootDir);
if (svgSheets.length > 0)
progress(
"design",
`SVG contact sheet generated (${svgSheets.length} page${svgSheets.length > 1 ? "s" : ""})`,
);
} catch {
/* contact sheets are non-critical — agent can still read images individually */
}
// Generate project scaffold (index.html, meta.json, CLAUDE.md)
await generateProjectScaffold(
outputDir,
url,
tokens,
animationCatalog,
screenshots.length > 0,
discoveredLotties.length > 0,
existsSync(join(outputDir, "extracted", "shaders.json")),
catalogedAssets,
progress,
warnings,
detectedLibraries,
);
progress("done", "Capture complete");
return {
ok: true,
projectDir: outputDir,
url,
title: tokens.title,
extracted,
screenshots,
tokens,
assets,
animationCatalog,
warnings,
};
} finally {
await chromeBrowser.close();
}
}
// visual-style.md and capture-summary.md generators removed — DESIGN.md replaces them
+548
View File
@@ -0,0 +1,548 @@
/**
* Media capture helpers for the website capture pipeline.
*
* Handles Lottie animation preview rendering and video element manifest capture.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Browser, Page } from "puppeteer-core";
import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, extname } from "node:path";
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
/** Discovered Lottie item from network interception or DOM scan. */
export interface DiscoveredLottie {
url: string;
data?: unknown;
dimensions?: { w: number; h: number };
frameRate?: number;
}
/**
* Download and save discovered Lottie animations to disk.
*
* Handles both plain JSON and dotLottie (.lottie ZIP) formats.
* Deduplicates by content hash. Returns the count of saved files.
*/
// fallow-ignore-next-line complexity
export async function saveLottieAnimations(
discoveredLotties: DiscoveredLottie[],
lottieDir: string,
): Promise<number> {
let savedCount = 0;
const savedHashes = new Set<string>(); // Deduplicate by content
for (let li = 0; li < discoveredLotties.length && li < 10; li++) {
const lottieItem = discoveredLotties[li]!;
try {
let jsonData: string | undefined;
if (lottieItem.data) {
// Already have the JSON data from network interception
jsonData = JSON.stringify(lottieItem.data);
} else if (lottieItem.url) {
// SSRF guard — safeFetch re-checks the denylist on every redirect hop
const res = await safeFetch(lottieItem.url, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "HyperFrames/1.0" },
});
if (!res || !res.ok) continue;
const buf = Buffer.from(await res.arrayBuffer());
if (lottieItem.url.endsWith(".lottie")) {
// dotLottie is a ZIP — extract the animation JSON
try {
const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip(buf);
const entries = zip.getEntries();
// Look for animation JSON in both v1 (animations/) and v2 (a/) paths
const animEntry = entries.find(
(e) =>
(e.entryName.startsWith("a/") || e.entryName.startsWith("animations/")) &&
e.entryName.endsWith(".json"),
);
if (animEntry) {
jsonData = animEntry.getData().toString("utf-8");
}
} catch {
// adm-zip not available or extraction failed — save raw .lottie
const hash = buf.toString("base64").slice(0, 100);
if (savedHashes.has(hash)) continue;
savedHashes.add(hash);
writeFileSync(join(lottieDir, `animation-${savedCount}.lottie`), buf);
savedCount++;
continue;
}
} else {
// Plain JSON file
jsonData = buf.toString("utf-8");
}
}
if (jsonData) {
// Deduplicate by content hash (first 100 chars of stringified JSON)
const hash = jsonData.slice(0, 200);
if (savedHashes.has(hash)) continue;
savedHashes.add(hash);
// Validate it's actually Lottie
try {
const parsed = JSON.parse(jsonData);
if (!parsed.layers || !parsed.w) continue;
} catch {
continue;
}
writeFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
savedCount++;
}
} catch {
/* skip */
}
}
return savedCount;
}
/**
* Render preview thumbnails for saved Lottie animation JSON files.
*
* Opens each Lottie JSON in a headless Chrome page via lottie-web,
* seeks to ~30% through the animation, and takes a transparent screenshot.
* Writes a lottie-manifest.json with metadata + preview paths.
*/
// fallow-ignore-next-line complexity
export async function renderLottiePreviews(
chromeBrowser: Browser,
lottieDir: string,
outputDir: string,
): Promise<void> {
const manifest: Array<{
file: string;
preview: string;
name: string;
width: number;
height: number;
duration: number;
frameRate: number;
layers: number;
}> = [];
const previewDir = join(lottieDir, "previews");
mkdirSync(previewDir, { recursive: true });
for (const file of readdirSync(lottieDir)) {
if (!file.endsWith(".json")) continue;
try {
const raw = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const fr = raw.fr || 30;
const dur = ((raw.op || 0) - (raw.ip || 0)) / fr;
const previewName = file.replace(".json", "-preview.png");
// Render a mid-frame thumbnail using Puppeteer + lottie-web
// Skip huge Lottie files for preview (CDP has a ~256MB message limit)
const fileSize = statSync(join(lottieDir, file)).size;
if (fileSize > 2_000_000) continue;
let previewPage;
try {
previewPage = await chromeBrowser.newPage();
await previewPage.setViewport({ width: 400, height: 400 });
const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3);
// Load the shell page first (no untrusted data in the HTML)
await previewPage.setContent(
`<!DOCTYPE html>
<html><head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<style>*{margin:0;padding:0;background:transparent}#c{width:400px;height:400px}</style>
</head><body><div id="c"></div></body></html>`,
{ waitUntil: "load", timeout: 10000 },
);
// Pass animation data safely via parameterized evaluate (no string interpolation)
await previewPage.evaluate(
(data: unknown, frame: number) => {
const a = (window as any).lottie.loadAnimation({
container: document.getElementById("c"),
renderer: "svg",
loop: false,
autoplay: false,
animationData: data,
});
a.addEventListener("DOMLoaded", () => {
a.goToAndStop(frame, true);
(window as any).__READY = true;
});
},
animData,
midFrame,
);
await previewPage
.waitForFunction(() => (window as any).__READY === true, { timeout: 5000 })
.catch(() => {});
await previewPage.screenshot({
path: join(previewDir, previewName),
type: "png",
omitBackground: true,
});
} catch {
/* preview rendering failed — non-critical */
} finally {
await previewPage?.close().catch(() => {});
}
manifest.push({
file: `assets/lottie/${file}`,
preview: `assets/lottie/previews/${previewName}`,
name: raw.nm || file,
width: raw.w || 0,
height: raw.h || 0,
duration: Math.round(dur * 10) / 10,
frameRate: fr,
layers: (raw.layers || []).length,
});
} catch {
/* skip */
}
}
if (manifest.length > 0) {
writeFileSync(
join(outputDir, "extracted", "lottie-manifest.json"),
JSON.stringify(manifest, null, 2),
"utf-8",
);
}
}
const MAX_VIDEO_BYTES = 75 * 1024 * 1024; // 75 MB — hero/demo clips, not full films
const DOWNLOADABLE_VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".m4v"]);
/**
* Download a <video> body to assets/videos/<file>, returning the
* capture-relative path when saved (else null).
*
* Guards, in order: direct-file extension only — HLS (.m3u8) / DASH (.mpd) /
* blob: streams are skipped · SSRF via safeFetch, which re-validates isPrivateUrl
* on EVERY redirect hop (a bare redirect:"follow" only checks the initial URL,
* so a public URL could 30x to an internal/metadata host) · Content-Type must be
* video/* or octet-stream · a hard byte cap enforced WHILE streaming so a
* missing or lying Content-Length cannot exhaust memory. Streams from the
* Response body rather than buffering whole because videos are large.
*/
// fallow-ignore-next-line complexity
async function downloadVideoBody(
srcUrl: string,
filename: string,
videosDir: string,
): Promise<string | null> {
if (isPrivateUrl(srcUrl)) return null; // cheap pre-check; safeFetch re-checks every hop
let ext = "";
try {
ext = extname(new URL(srcUrl).pathname).toLowerCase();
} catch {
return null;
}
if (!DOWNLOADABLE_VIDEO_EXTS.has(ext)) return null; // streaming manifest / unknown — leave on origin
try {
// safeFetch resolves redirects manually and re-runs isPrivateUrl on each
// Location hop, so a public URL cannot 30x to an internal/metadata host.
const res = await safeFetch(srcUrl, {
signal: AbortSignal.timeout(120000), // up to ~75 MB on a slow link; aborts cleanly → still-frame fallback
headers: { "User-Agent": "HyperFrames/1.0" },
});
if (!res || !res.ok || !res.body) return null;
const ct = (res.headers.get("content-type") || "").toLowerCase();
if (ct && !ct.startsWith("video/") && !ct.includes("octet-stream")) return null;
const declared = Number(res.headers.get("content-length") || 0);
if (declared && declared > MAX_VIDEO_BYTES) return null; // too big — leave on origin
// Stream with a hard cap; a chunked response has no Content-Length to trust.
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
total += chunk.length;
if (total > MAX_VIDEO_BYTES) return null; // abort oversized stream — no partial file written
chunks.push(Buffer.from(chunk));
}
if (total < 1024) return null; // too small to be a real video (likely an error blob)
const safe = /\.[a-z0-9]+$/i.test(filename) ? filename.replace(/[^\w.-]/g, "_") : `video${ext}`;
writeFileSync(join(videosDir, safe), Buffer.concat(chunks));
return `assets/videos/${safe}`;
} catch {
return null;
}
}
/** A <video> descriptor scanned from the DOM (rich: has rect + nearby text). */
interface VideoDescriptor {
src: string;
width: number;
height: number;
sourceWidth: number;
sourceHeight: number;
top: number;
left: number;
heading: string;
caption: string;
ariaLabel: string;
filename: string;
}
// In-page expression: scan every <video> for src + bounding box + nearest
// heading/caption/aria. Shared by the one-shot scan and the time-sampling pass.
const VIDEO_SCAN_EXPR = `(() => {
var videos = Array.from(document.querySelectorAll('video'));
return videos.map(function(v) {
var src = v.src || v.currentSrc || (v.querySelector('source') ? v.querySelector('source').src : '');
if (!src || !src.startsWith('http')) return null;
var rect = v.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return null;
var heading = '';
var el = v;
for (var i = 0; i < 8; i++) {
el = el.parentElement;
if (!el) break;
var h = el.querySelector('h1,h2,h3,h4');
if (h) { heading = h.textContent.trim().slice(0, 100); break; }
}
var caption = '';
el = v;
for (var j = 0; j < 5; j++) {
el = el.parentElement;
if (!el) break;
var p = el.querySelector('p,figcaption,[class*="caption"],[class*="desc"]');
if (p) { caption = p.textContent.trim().slice(0, 200); break; }
}
var ariaLabel = v.getAttribute('aria-label') || v.getAttribute('title') || '';
var wrapper = v.parentElement;
if (!ariaLabel && wrapper) ariaLabel = wrapper.getAttribute('aria-label') || '';
return {
src: src,
// width/height are the DOM display box (what the page laid the element out
// at); sourceWidth/Height are the clip's intrinsic resolution. Size planners
// off the source dims, not the display box (a 1920x1080 clip can display at
// 904x613). 0 when metadata has not loaded yet.
width: Math.round(rect.width),
height: Math.round(rect.height),
sourceWidth: v.videoWidth || 0,
sourceHeight: v.videoHeight || 0,
top: Math.round(rect.top),
left: Math.round(rect.left),
heading: heading,
caption: caption,
ariaLabel: ariaLabel,
filename: src.split('/').pop().split('?')[0],
};
}).filter(Boolean);
})()`;
async function scanVideoDom(page: Page): Promise<VideoDescriptor[]> {
return (await page.evaluate(VIDEO_SCAN_EXPR)) as VideoDescriptor[];
}
/**
* Layer 2 (passive): poll the DOM over a bounded window so auto-rotating
* carousels reveal each slide, AND so the Layer 1 network listener (whose live
* Set is `netSet`) gets time to record videos fetched on rotation. Accumulates
* unique-by-src descriptors. Exits early once neither the DOM set nor the
* network set has grown for a few rounds, so static single-video pages stay
* cheap (~6s) while a rotating carousel keeps sampling up to the budget.
*/
// fallow-ignore-next-line complexity
async function sampleVideoDom(
page: Page,
budgetMs: number,
netSet: Set<string>,
): Promise<VideoDescriptor[]> {
const seen = new Map<string, VideoDescriptor>();
const start = Date.now();
let stale = 0;
while (Date.now() - start < budgetMs && stale < 3) {
let grew = false;
const netBefore = netSet.size;
for (const d of await scanVideoDom(page)) {
if (!seen.has(d.src)) {
seen.set(d.src, d);
grew = true;
}
}
if (netSet.size > netBefore) grew = true;
stale = grew ? 0 : stale + 1;
await new Promise((r) => setTimeout(r, 2000));
}
return [...seen.values()];
}
/**
* Capture video element manifest — screenshot each <video> element, extract
* surrounding context (heading, caption, aria-label), and download the video
* body when it is a direct file (see downloadVideoBody guards).
*
* Two PASSIVE discovery layers widen coverage past a single snapshot (which
* misses carousels / tabs / lazy media):
* • Layer 1 — opts.networkVideoUrls: a LIVE Set the caller fills from the
* page "response" listener with every direct-video URL the page fetches
* (load / scroll / auto-rotation), independent of DOM presence. Read after
* sampling so rotation fetches during the window are included.
* • Layer 2 — opts.sampleMs: poll the DOM over that window so an
* auto-rotating carousel surfaces each slide.
* The manifest is the union, deduped by download filename. DOM-scanned videos
* get a still preview; network-only videos are downloaded without one. (Active
* click-through of carousels/tabs is intentionally NOT done here.)
*
* Writes video-manifest.json + preview screenshots to assets/videos/previews/,
* and the video bodies (when downloadable) to assets/videos/.
*/
// fallow-ignore-next-line complexity
export async function captureVideoManifest(
page: Page,
outputDir: string,
progress: (stage: string, detail?: string) => void,
opts?: { networkVideoUrls?: Set<string>; sampleMs?: number; downloadBudgetMs?: number },
): Promise<void> {
const netSet = opts?.networkVideoUrls ?? new Set<string>();
const sampleMs = opts?.sampleMs ?? 0;
const downloadBudgetMs = opts?.downloadBudgetMs ?? 180000;
// DOM scan, optionally sampled over time (Layer 2) when videos are present.
const initial = await scanVideoDom(page);
const domVideos =
initial.length > 0 && sampleMs > 0 ? await sampleVideoDom(page, sampleMs, netSet) : initial;
// Merge DOM (rich) + network-only (thin, Layer 1), deduped by download
// filename so a clip seen in both lands once. netSet is read here — AFTER
// sampling — so rotation fetches that arrived during the window count.
const fileKey = (s: string) => (s.split("/").pop() || s).split("?")[0]!;
const byKey = new Map<string, VideoDescriptor & { rich: boolean }>();
for (const d of domVideos) {
const k = d.filename || fileKey(d.src);
if (!byKey.has(k)) byKey.set(k, { ...d, rich: true });
}
for (const url of netSet) {
if (!url.startsWith("http")) continue;
const k = fileKey(url);
if (!byKey.has(k)) {
byKey.set(k, {
src: url,
filename: k,
width: 0,
height: 0,
sourceWidth: 0,
sourceHeight: 0,
top: 0,
left: 0,
heading: "",
caption: "",
ariaLabel: "",
rich: false,
});
}
}
const merged = [...byKey.values()];
if (merged.length === 0) return;
const videoManifestDir = join(outputDir, "assets", "videos");
mkdirSync(videoManifestDir, { recursive: true });
const previewDir = join(videoManifestDir, "previews");
mkdirSync(previewDir, { recursive: true });
const videoManifest: Array<{
index: number;
url: string;
filename: string;
width: number;
height: number;
sourceWidth: number;
sourceHeight: number;
heading: string;
caption: string;
ariaLabel: string;
preview?: string;
localPath?: string;
}> = [];
const dlStart = Date.now();
for (let vi = 0; vi < merged.length && vi < 20; vi++) {
const v = merged[vi]!;
let preview: string | undefined;
// DOM-scanned videos can be screenshotted for a still preview; network-only
// videos have no element on the page, so they go straight to download.
if (v.rich) {
const previewName = `video-${vi}-preview.png`;
try {
// Scroll to the video element so it's in the viewport
await page.evaluate(`window.scrollTo(0, ${Math.max(0, v.top - 100)})`);
await new Promise((r) => setTimeout(r, 300));
// Re-measure position after scroll (layout may have shifted)
const rect = (await page.evaluate((fn) => {
const vid = [...document.querySelectorAll("video")].find((x) =>
(x.src || x.currentSrc || "").includes(fn),
);
if (!vid) return null;
// Seek to 0.1s and wait for a frame to decode
vid.currentTime = 0.1;
return vid.getBoundingClientRect().toJSON();
}, v.filename)) as { x: number; y: number; width: number; height: number } | null;
if (rect && rect.width >= 10) {
await new Promise((r) => setTimeout(r, 200)); // let decoder settle
await page.screenshot({
path: join(previewDir, previewName),
clip: {
x: Math.max(0, rect.x),
y: Math.max(0, rect.y),
width: Math.min(rect.width, 1920),
height: Math.min(rect.height, 1080),
},
});
preview = `assets/videos/previews/${previewName}`;
}
} catch {
/* preview failed — non-critical */
}
}
// Download the video body (guarded). null when skipped / too big / not a
// direct file. Cumulative budget caps total download time so a throttled
// host or many large clips can't stall capture — over budget, keep the
// preview (if any) and stop fetching bodies.
const savedPath =
Date.now() - dlStart < downloadBudgetMs
? await downloadVideoBody(v.src, v.filename, videoManifestDir)
: null;
// A network-only video with neither a preview nor a downloaded body carries
// nothing usable downstream — drop it rather than list a dead reference.
if (!preview && !savedPath) continue;
videoManifest.push({
index: vi,
url: v.src,
filename: v.filename,
width: v.width,
height: v.height,
sourceWidth: v.sourceWidth,
sourceHeight: v.sourceHeight,
heading: v.heading,
caption: v.caption,
ariaLabel: v.ariaLabel,
...(preview ? { preview } : {}),
...(savedPath ? { localPath: savedPath } : {}),
});
}
if (videoManifest.length > 0) {
writeFileSync(
join(outputDir, "extracted", "video-manifest.json"),
JSON.stringify(videoManifest, null, 2),
"utf-8",
);
const downloaded = videoManifest.filter((v) => v.localPath).length;
const previews = videoManifest.filter((v) => v.preview).length;
progress(
"design",
`${videoManifest.length} video(s) discovered` +
(previews ? `, ${previews} preview(s)` : "") +
(downloaded ? `, ${downloaded} body downloaded` : ""),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Project scaffolding helpers for the website capture pipeline.
*
* Handles .env file loading and HyperFrames project scaffold generation
* (index.html, meta.json, AGENTS.md, CLAUDE.md).
*/
import { existsSync, writeFileSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { CatalogedAsset } from "./assetCataloger.js";
import type { CaptureResult, DesignTokens } from "./types.js";
/**
* Load .env file by walking up from startDir (up to 5 levels).
* Sets process.env keys that are not already set. Best-effort — never throws.
*/
export function loadEnvFile(startDir: string): void {
try {
let dir = resolve(startDir);
for (let i = 0; i < 5; i++) {
const envPath = resolve(dir, ".env");
try {
const envContent = readFileSync(envPath, "utf-8");
for (const line of envContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed
.slice(eq + 1)
.trim()
.replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = val;
}
break;
} catch {
dir = resolve(dir, "..");
}
}
} catch {
/* .env loading is best-effort */
}
}
/**
* Generate the project scaffold files: index.html, meta.json, AGENTS.md, CLAUDE.md.
*
* Only creates files that don't already exist (index.html, meta.json).
* Always (re)generates AGENTS.md + CLAUDE.md via agentPromptGenerator.
*/
export async function generateProjectScaffold(
outputDir: string,
url: string,
tokens: DesignTokens,
animationCatalog: CaptureResult["animationCatalog"],
hasScreenshots: boolean,
hasLotties: boolean,
hasShaders: boolean,
catalogedAssets: CatalogedAsset[],
progress: (stage: string, detail?: string) => void,
warnings: string[],
detectedLibraries?: string[],
): Promise<void> {
// Capture output is a DATA folder, not a video project.
// The agent builds index.html + compositions/ during step 6.
// We only write meta.json (project metadata) — NOT index.html.
// Writing index.html here caused a double-audio bug: the runtime
// discovered both the scaffold and the agent's real index.html as
// valid compositions, playing two audio tracks offset in time.
const metaPath = join(outputDir, "meta.json");
if (!existsSync(metaPath)) {
const hostname = new URL(url).hostname.replace(/^www\./, "");
writeFileSync(
metaPath,
JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2),
"utf-8",
);
}
// Generate AGENTS.md + CLAUDE.md (AI agent instructions — always, regardless of API keys)
try {
const { generateAgentPrompt } = await import("./agentPromptGenerator.js");
generateAgentPrompt(
outputDir,
url,
tokens,
animationCatalog,
hasScreenshots,
hasLotties,
hasShaders,
catalogedAssets,
detectedLibraries,
);
progress("agent", "AGENTS.md + CLAUDE.md generated");
} catch (err) {
warnings.push(`AGENTS.md/CLAUDE.md generation failed: ${err}`);
}
}
@@ -0,0 +1,160 @@
/**
* Screenshot capture for the website capture pipeline.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
/**
* Capture viewport screenshots covering the entire page height.
*
* Scrolls down the page in viewport-sized steps (with slight overlap),
* taking a 1920x1080 screenshot at each position. The number of screenshots
* depends on the page height — short pages get fewer, long pages get more.
* Capped at 20 to avoid excessive output on extremely long pages.
*
* Unlike the old section-tiling approach, this does NOT disable sticky/fixed
* elements — screenshots show the page in its natural browsing state with
* scroll-triggered animations fired.
*/
export async function captureScrollScreenshots(page: Page, outputDir: string): Promise<string[]> {
const screenshotsDir = join(outputDir, "screenshots");
mkdirSync(screenshotsDir, { recursive: true });
const MAX_SCREENSHOTS = 20;
const filePaths: string[] = [];
try {
// Dismiss marketing banners, cookie consents, and popups before scrolling.
// These overlay content and contaminate screenshots with UI that doesn't
// belong in video compositions (cookie popups, newsletter modals, etc.)
await page
.evaluate(() => {
// Click common dismiss/accept buttons
const selectors = [
// Cookie consent
'[id*="cookie"] button[class*="accept"]',
'[id*="cookie"] button[class*="agree"]',
'[id*="cookie"] button[class*="allow"]',
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button',
// Generic close buttons on overlays/modals
'[class*="banner"] [class*="close"]',
'[class*="banner"] [class*="dismiss"]',
'[class*="popup"] [class*="close"]',
'[class*="modal"] [class*="close"]',
'[class*="overlay"] [class*="close"]',
// Common GDPR patterns — scoped under a cookie/consent/gdpr ancestor
// so we don't click "Accept invitation" / "Accept terms" / etc. on
// unrelated buttons elsewhere on the page.
'[id*="cookie" i] button[id*="accept" i]',
'[id*="consent" i] button[id*="accept" i]',
'[id*="gdpr" i] button[id*="accept" i]',
'[class*="cookie" i] button[class*="accept-all" i]',
'[class*="cookie" i] button[class*="acceptAll" i]',
'[class*="consent" i] button[class*="accept-all" i]',
// Notification prompts
'button[class*="decline"]',
'button[class*="not-now"]',
'button[class*="no-thanks"]',
];
for (const sel of selectors) {
try {
const el = document.querySelector<HTMLElement>(sel);
if (el) el.click();
} catch {
/* ignore */
}
}
// Hide fixed/sticky overlays that aren't the main nav. Scanning every
// element with querySelectorAll('*') + getComputedStyle is O(n) DOM
// calls and can dominate evaluate() time on large pages. Narrow the
// candidate set with a TreeWalker that early-exits on viewport-sized
// rect checks (cheap) before reaching the expensive getComputedStyle.
const SCAN_CAP = 5000;
const minWidth = window.innerWidth * 0.3;
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
let visited = 0;
let node = walker.nextNode();
while (node && visited < SCAN_CAP) {
visited++;
const el = node as HTMLElement;
const rect = el.getBoundingClientRect();
// Cheap viewport-size filter first — eliminates the vast majority of
// tiny / hidden / off-screen elements without touching getComputedStyle.
if (rect.height > 80 && rect.width > minWidth) {
const tag = el.tagName;
if (tag !== "HEADER" && tag !== "NAV" && !el.closest("header") && !el.closest("nav")) {
const style = window.getComputedStyle(el);
if (
(style.position === "fixed" || style.position === "sticky") &&
style.zIndex !== "auto" &&
parseInt(style.zIndex) > 100
) {
el.style.display = "none";
}
}
}
node = walker.nextNode();
}
})
.catch(() => {});
await new Promise((r) => setTimeout(r, 400));
const scrollHeight = (await page.evaluate(
`Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`,
)) as number;
const viewportHeight = (await page.evaluate(`window.innerHeight`)) as number;
// Calculate scroll positions: step by 70% of viewport (30% overlap between shots)
const step = Math.floor(viewportHeight * 0.7);
const positions: number[] = [0];
for (let y = step; y < scrollHeight - viewportHeight; y += step) {
positions.push(y);
}
// Always include the bottom of the page
const lastPos = Math.max(0, scrollHeight - viewportHeight);
if (positions[positions.length - 1] !== lastPos) {
positions.push(lastPos);
}
// Downsample if too many positions
let finalPositions = positions;
if (positions.length > MAX_SCREENSHOTS) {
finalPositions = [positions[0]!];
const stride = (positions.length - 1) / (MAX_SCREENSHOTS - 1);
for (let i = 1; i < MAX_SCREENSHOTS - 1; i++) {
finalPositions.push(positions[Math.round(i * stride)]!);
}
finalPositions.push(positions[positions.length - 1]!);
}
for (let i = 0; i < finalPositions.length; i++) {
await page.evaluate(`window.scrollTo(0, ${finalPositions[i]})`);
await new Promise((r) => setTimeout(r, 400));
const pct = Math.round(
(finalPositions[i]! / Math.max(1, scrollHeight - viewportHeight)) * 100,
);
const filename = `scroll-${String(Math.min(pct, 100)).padStart(3, "0")}.png`;
const filePath = join(screenshotsDir, filename);
const buffer = await page.screenshot({ type: "png" });
writeFileSync(filePath, buffer);
filePaths.push(`screenshots/${filename}`);
}
// Reset scroll
await page.evaluate(`window.scrollTo(0, 0)`);
await new Promise((r) => setTimeout(r, 200));
// full-page.png removed — 1/8 agents read it, contact sheet covers the same content
} catch {
/* scroll screenshots are non-critical */
}
return filePaths;
}
+528
View File
@@ -0,0 +1,528 @@
/**
* Extract design tokens from a rendered page.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import type { DesignTokens } from "./types.js";
// The entire extraction runs as a single string-based evaluate
// to avoid tsx __name injection into the browser context.
const EXTRACT_SCRIPT = `(() => {
var isVisible = (el) => {
var s = getComputedStyle(el);
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0" && el.getBoundingClientRect().height > 0;
};
// 1. CSS custom properties from :root
var cssVariables = {};
for (var i = 0; i < document.styleSheets.length; i++) {
try {
var rules = document.styleSheets[i].cssRules;
for (var j = 0; j < rules.length; j++) {
if (rules[j].selectorText === ":root") {
for (var k = 0; k < rules[j].style.length; k++) {
var prop = rules[j].style[k];
if (prop.startsWith("--")) {
cssVariables[prop] = rules[j].style.getPropertyValue(prop).trim();
}
}
}
}
} catch(e) {}
}
// 2. Meta
var title = document.title || "";
var descEl = document.querySelector('meta[name="description"]') || document.querySelector('meta[property="og:description"]');
var description = descEl ? descEl.content : "";
var ogImgEl = document.querySelector('meta[property="og:image"]');
var ogImage = ogImgEl ? ogImgEl.content : undefined;
// 3. Fonts — enumerate loaded FontFaces + supplement with DOM sampling
var fontMap = {};
function ensureFont(name) {
if (!fontMap[name]) fontMap[name] = { family: name, weights: [], variable: false, weightRange: undefined };
return fontMap[name];
}
function addWeight(entry, w) {
var n = parseInt(w, 10);
if (!isNaN(n) && entry.weights.indexOf(n) === -1) entry.weights.push(n);
}
var genericFonts = ["serif","sans-serif","monospace","cursive","system-ui","ui-serif","ui-sans-serif","ui-monospace","ui-rounded","emoji","math","fangsong"];
try {
document.fonts.forEach(function(face) {
var name = face.family.replace(/['"]/g, "").trim();
if (!name || genericFonts.indexOf(name.toLowerCase()) !== -1) return;
// Skip placeholder/fallback fonts (Framer loads hundreds of these)
if (name.indexOf("Placeholder") !== -1 || name.indexOf("Fallback") !== -1) return;
var entry = ensureFont(name);
var w = (face.weight || "").trim();
if (w.indexOf(" ") !== -1) {
var parts = w.split(" ");
var lo = parseInt(parts[0], 10);
var hi = parseInt(parts[1], 10);
if (!isNaN(lo) && !isNaN(hi)) {
entry.variable = true;
entry.weightRange = [lo, hi];
}
} else {
addWeight(entry, w);
}
});
} catch(e) {}
// Supplement with DOM sampling
var domSamples = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6,p,a,button,span,li,strong,b")).slice(0, 100);
for (var fi = 0; fi < domSamples.length; fi++) {
try {
var cs = getComputedStyle(domSamples[fi]);
var family = cs.fontFamily.split(",")[0].replace(/['"]/g, "").trim();
if (family && genericFonts.indexOf(family.toLowerCase()) === -1) {
var entry = ensureFont(family);
addWeight(entry, cs.fontWeight);
}
} catch(e) {}
}
// 4. Colors — hybrid: DOM computed styles + visual pixel sampling
var colorSet = {};
function addColor(c, weight) {
if (!c || c === "rgba(0, 0, 0, 0)" || c === "transparent" || c === "inherit" || c === "initial" || c === "currentcolor") return;
var hex = rgbToHex(c);
if (hex) colorSet[hex] = (colorSet[hex] || 0) + (weight || 1);
}
function rgbToHex(color) {
if (!color) return null;
if (color.startsWith('#')) return (color.length === 4
? '#' + color[1]+color[1] + color[2]+color[2] + color[3]+color[3]
: color).toUpperCase();
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/);
if (!m) {
// Handle color(srgb ...) format
var cm = color.match(/color\\(srgb\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)/);
if (cm) {
m = [null, Math.round(parseFloat(cm[1])*255), Math.round(parseFloat(cm[2])*255), Math.round(parseFloat(cm[3])*255)];
} else {
// Handle modern color functions (oklch, oklab, lch, lab, hsl, color-mix)
// Use a 1x1 canvas to resolve ANY CSS color to RGB — this works even when
// getComputedStyle returns the color in its original color space (Chrome 131+)
if (/oklch|oklab|lch|lab|hsla?|color-mix|color\\(/.test(color)) {
try {
var cvs = document.createElement('canvas');
cvs.width = 1; cvs.height = 1;
var ctx2d = cvs.getContext('2d');
if (ctx2d) {
ctx2d.fillStyle = color;
ctx2d.fillRect(0, 0, 1, 1);
var px = ctx2d.getImageData(0, 0, 1, 1).data;
if (px[3] > 0) return '#' + ((1<<24) + (px[0]<<16) + (px[1]<<8) + px[2]).toString(16).slice(1).toUpperCase();
}
} catch(e2) {}
// Fallback: temp element approach
var tmp = document.createElement('div');
tmp.style.color = color;
document.body.appendChild(tmp);
var resolved = getComputedStyle(tmp).color;
document.body.removeChild(tmp);
if (resolved !== color) return rgbToHex(resolved);
return null;
}
return null;
}
}
return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase();
}
// 4a. Sample DOM elements (text colors, borders, branded elements)
var colorCandidates = Array.from(document.querySelectorAll(
"body, header, nav, main, footer, section, " +
"h1, h2, h3, h4, h5, h6, " +
"a, button, [role='button'], " +
"[class*='hero'], [class*='cta'], [class*='btn'], [class*='card'], " +
"[class*='badge'], [class*='tag'], [class*='accent'], [class*='highlight']"
)).slice(0, 200);
for (var ci = 0; ci < colorCandidates.length; ci++) {
try {
var cs = getComputedStyle(colorCandidates[ci]);
addColor(cs.backgroundColor);
addColor(cs.color);
addColor(cs.borderColor);
addColor(cs.outlineColor);
// Extract colors from gradients in background-image
var bgImg = cs.backgroundImage;
if (bgImg && bgImg !== 'none') {
var gradColors = bgImg.match(/(?:#[0-9a-fA-F]{3,8}|rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|hsla?\\([^)]+\\)|lab\\([^)]+\\))/g);
if (gradColors) gradColors.forEach(function(gc) { addColor(gc); });
}
// Extract colors from box-shadow
var shadow = cs.boxShadow;
if (shadow && shadow !== 'none') {
var shadowColors = shadow.match(/(?:#[0-9a-fA-F]{3,8}|rgba?\\([^)]+\\))/g);
if (shadowColors) shadowColors.forEach(function(sc) { addColor(sc); });
}
} catch(e) {}
}
// 4b. Explicitly sample html/body backgrounds (the dominant canvas color)
// These often define the site's light/dark character
try {
var htmlBg = getComputedStyle(document.documentElement).backgroundColor;
var bodyBg = getComputedStyle(document.body).backgroundColor;
addColor(htmlBg, 10);
addColor(bodyBg, 10);
// Also check the background shorthand which may contain gradients
var bodyBgFull = getComputedStyle(document.body).background;
var gradColors = bodyBgFull.match(/(?:#[0-9a-fA-F]{3,8}|rgba?\\([^)]+\\)|oklch\\([^)]+\\)|hsla?\\([^)]+\\))/g);
if (gradColors) gradColors.forEach(function(gc) { addColor(gc, 8); });
} catch(e) {}
// 4c. Visual pixel sampling — sample what the user actually SEES
// Walk a grid of points across the viewport and read background + text color
var vpW = window.innerWidth;
var vpH = window.innerHeight;
var gridCols = 6;
var gridRows = 5;
for (var gy = 0; gy < gridRows; gy++) {
for (var gx = 0; gx < gridCols; gx++) {
try {
var px = Math.round((gx + 0.5) * vpW / gridCols);
var py = Math.round((gy + 0.5) * vpH / gridRows);
var elAt = document.elementFromPoint(px, py);
if (elAt) {
var elStyle = getComputedStyle(elAt);
addColor(elStyle.color, 2);
var bgc = elStyle.backgroundColor;
// Walk up parents until we find a non-transparent background
var bgWalker = elAt;
while (bgWalker && (!bgc || bgc === "rgba(0, 0, 0, 0)" || bgc === "transparent")) {
bgWalker = bgWalker.parentElement;
if (bgWalker) bgc = getComputedStyle(bgWalker).backgroundColor;
}
addColor(bgc, 3);
}
} catch(e) {}
}
}
// 4c2. Broad sweep — find ANY element with a non-white/non-transparent background
// This catches colored blocks that the grid might miss (code blocks, banners, cards)
var allEls = document.querySelectorAll('*');
var colorSweepCount = 0;
for (var si = 0; si < allEls.length && colorSweepCount < 500; si++) {
try {
var elCs = getComputedStyle(allEls[si]);
var elBg = elCs.backgroundColor;
if (elBg && elBg !== "rgba(0, 0, 0, 0)" && elBg !== "transparent") {
var hex = rgbToHex(elBg);
if (hex && hex !== "#FFFFFF" && hex !== "#000000") {
addColor(elBg, 1);
}
}
colorSweepCount++;
} catch(e) {}
}
// 4d. Resolve CSS custom properties from :root to actual color values
var rootStyle = getComputedStyle(document.documentElement);
var rootProps = Object.keys(cssVariables);
for (var ri = 0; ri < rootProps.length; ri++) {
var val = rootStyle.getPropertyValue(rootProps[ri]).trim();
if (val && /^(#|rgb|hsl|oklch|oklab|lch|lab|color)/.test(val)) {
addColor(val);
}
}
// 4e. Per-color signal stats — distinguish FILL vs TEXT vs INTERACTIVE vs
// large-AREA usage. The flat colorSet above ranks by total weight (so the
// canvas/text dominate); these per-color signals let downstream code find the
// BRAND color (chromatic, used on interactive/repeated fills) apart from
// section surfaces (one big block) and link/text colors. Single pass.
var colorStats = {};
function statFor(hex) {
if (!colorStats[hex]) colorStats[hex] = { count: 0, bgCount: 0, interactiveBg: 0, areaBg: 0, textCount: 0, maxArea: 0 };
return colorStats[hex];
}
var statEls = Array.from(allEls).slice(0, 9000);
for (var ti = 0; ti < statEls.length; ti++) {
try {
var sEl = statEls[ti];
var sCs = getComputedStyle(sEl);
if (sCs.display === "none" || sCs.visibility === "hidden") continue;
var sRect = sEl.getBoundingClientRect();
var sArea = sRect.width * sRect.height;
var sTag = sEl.tagName.toLowerCase();
var sRole = sEl.getAttribute("role") || "";
var sCls = sEl.getAttribute("class") || "";
var sInteractive = sTag === "a" || sTag === "button" ||
sRole === "button" || sRole === "link" || sRole === "menuitem" || sRole === "tab" ||
/\\b(btn|button|cta|primary|action)\\b/i.test(sCls);
var sBg = sCs.backgroundColor;
if (sBg && sBg !== "rgba(0, 0, 0, 0)" && sBg !== "transparent") {
var bgHex = rgbToHex(sBg);
if (bgHex) {
var st = statFor(bgHex);
st.count++; st.bgCount++;
if (sInteractive) st.interactiveBg++;
if (sArea > 50000) st.areaBg++;
if (sArea > st.maxArea) st.maxArea = Math.round(sArea);
}
}
var sColor = sCs.color;
if (sColor && sColor !== "rgba(0, 0, 0, 0)" && sColor !== "transparent") {
var txHex = rgbToHex(sColor);
if (txHex) { var st2 = statFor(txHex); st2.count++; st2.textCount++; }
}
} catch(e) {}
}
var colorStatsArr = Object.keys(colorStats).map(function(h) {
var s = colorStats[h];
return { hex: h, count: s.count, bgCount: s.bgCount, interactiveBg: s.interactiveBg, areaBg: s.areaBg, textCount: s.textCount, maxArea: s.maxArea };
}).filter(function(s) { return s.bgCount > 0 || s.interactiveBg > 0 || s.count >= 3; })
.sort(function(a, b) { return (b.bgCount + b.interactiveBg * 3 + b.textCount) - (a.bgCount + a.interactiveBg * 3 + a.textCount); })
.slice(0, 48);
// 5. Headings
var headingEls = Array.from(document.querySelectorAll("h1, h2, h3, h4")).slice(0, 20);
var headings = headingEls.filter(isVisible).map(function(h) {
var s = getComputedStyle(h);
return { level: parseInt(h.tagName[1]), text: (h.innerText || h.textContent || "").trim().replace(/\\s+/g, ' ').slice(0, 200), fontSize: s.fontSize, fontWeight: s.fontWeight, color: rgbToHex(s.color) || s.color };
});
// 6. CTAs — match by class AND by text content patterns
// Conservative class selectors (avoid nav links with "action" or "start" in class)
var ctaSelectors = 'a[class*="btn"], a[class*="button"], a[class*="cta"], button[class*="primary"], button[class*="cta"], [role="button"]';
var ctaEls = Array.from(document.querySelectorAll(ctaSelectors));
// Filter out nav links (common false positives)
ctaEls = ctaEls.filter(function(el) {
return !el.closest('nav, [role="navigation"], [class*="nav"], [class*="menu"], [class*="dropdown"]');
});
// Also find links/buttons by text content (catches CTAs without class hints)
// Require short text (real CTAs are concise) and exclude nav context
var ctaTextPatterns = /^(get started|sign up|start free|try (it )?free|start (a )?trial|book a demo|request (a )?demo|contact (us|sales)|start for free|create account|register now)$/i;
var allButtons = Array.from(document.querySelectorAll('a, button'));
for (var bi = 0; bi < allButtons.length && ctaEls.length < 20; bi++) {
var btnText = (allButtons[bi].textContent || "").trim();
if (btnText.length > 30) continue;
if (allButtons[bi].closest('nav, [role="navigation"], [class*="nav"], [class*="menu"]')) continue;
if (ctaTextPatterns.test(btnText) && ctaEls.indexOf(allButtons[bi]) === -1) {
ctaEls.push(allButtons[bi]);
}
}
ctaEls = ctaEls.slice(0, 10);
var ctas = ctaEls.filter(isVisible).map(function(c) { return { text: (c.textContent || "").trim().slice(0, 60), href: c.href || undefined }; }).filter(function(c) { return c.text.length > 1; });
// 8. SVGs
var svgEls = Array.from(document.querySelectorAll("svg"));
var svgs = svgEls.map(function(svg) {
var label = svg.getAttribute("aria-label") || svg.getAttribute("title") || svg.getAttribute("alt");
// Try harder to find a name: check class, id, parent context, inner text
if (!label) {
// Extract meaningful class name, skipping utility classes (tailwind, size, color)
var svgClasses = (svg.getAttribute("class") || "").split(/\\s+/);
var utilityPattern = /^(w-|h-|p-|m-|text-|bg-|border-|flex|grid|block|hidden|inline|absolute|relative|transition|duration|rotate|scale|opacity|group|sm:|md:|lg:|xl:)/;
for (var ci = 0; ci < svgClasses.length; ci++) {
var cls = svgClasses[ci];
if (cls.length > 3 && cls.length < 40 && !utilityPattern.test(cls) && cls !== "lucide") {
label = cls;
break;
}
}
}
if (!label) {
var svgId = svg.getAttribute("id") || "";
if (svgId && svgId.length > 2 && svgId.length < 40) label = svgId;
}
if (!label) {
// Check parent element for clues
var parent = svg.closest("[class*='icon'], [class*='logo'], [class*='nav'], [class*='btn'], [class*='social']");
if (parent) {
var parentClass = (parent.getAttribute("class") || "").split(" ").find(function(c) { return c.length > 3 && c.length < 30; });
if (parentClass) label = parentClass;
}
}
if (!label) {
// Check for text content inside the SVG (e.g. <text>NeetCode</text>)
var textEl = svg.querySelector("text");
if (textEl && textEl.textContent && textEl.textContent.trim().length > 1 && textEl.textContent.trim().length < 30) {
label = textEl.textContent.trim();
}
}
var w = svg.getAttribute("width");
// Keep SVGs that have a label OR are at least 16px wide OR are inside a logo/brand context
var inLogoContext = svg.closest('[class*="logo"], [class*="brand"], [class*="partner"], [class*="customer"], [class*="marquee"]') !== null;
if (!label && !inLogoContext && (!w || parseInt(w) < 16)) return null;
var isLogo = (label && label.toLowerCase().indexOf("logo") !== -1) ||
svg.closest('[class*="logo"], [class*="brand"], [class*="home"], [class*="marquee"], [class*="partner"], [class*="customer"]') !== null;
if (!isLogo) {
var bannerEl = svg.closest('header, nav, [role="banner"]');
if (bannerEl) {
var firstSvg = bannerEl.querySelector('svg');
if (firstSvg === svg) isLogo = true;
}
}
if (!isLogo) {
var anchor = svg.closest('a[href]');
if (anchor) {
var href = anchor.getAttribute('href') || '';
if (href === '/' || href === '#' || href === './' ||
/^https?:\\/\\/[^/]+\\/?$/.test(href)) {
isLogo = true;
}
}
}
if (!isLogo) {
var ariaLabel = svg.getAttribute('aria-label') || svg.getAttribute('title') || '';
var titleBrand = (document.title || '').split(/[-|—]/)[0].trim();
if (titleBrand.length > 1 && titleBrand.length < 30 &&
ariaLabel.toLowerCase().indexOf(titleBrand.toLowerCase()) !== -1) {
isLogo = true;
}
}
var rect = svg.getBoundingClientRect();
return {
label: label || undefined,
viewBox: svg.getAttribute("viewBox") || undefined,
width: Math.round(rect.width),
height: Math.round(rect.height),
outerHTML: svg.outerHTML.slice(0, 10000),
isLogo: isLogo
};
}).filter(Boolean).slice(0, 50);
// 9. Sections — find large visual blocks regardless of HTML tag
var sectionResults = [];
// Start with semantic elements, then fall back to large direct children of body/main
var candidates = Array.from(document.querySelectorAll(
'section, main > div, main > section, article, ' +
'body > div > div, body > main > div, body > div, ' +
'[class*="hero"], [class*="Hero"], [class*="section"], [class*="Section"], ' +
'[class*="container"], [class*="wrapper"], [class*="block"], ' +
'[id*="section"], [id*="hero"], footer, [role="region"], [role="banner"]'
));
// Deduplicate (a div can match multiple selectors)
var seenEls = new Set();
candidates = candidates.filter(function(el) {
if (seenEls.has(el)) return false;
seenEls.add(el);
return true;
});
for (var si = 0; si < candidates.length; si++) {
var el = candidates[si];
var rect = el.getBoundingClientRect();
if (rect.height < 200 || rect.width < 400 || !isVisible(el)) continue;
// Skip page-level wrappers (a single div wrapping the entire page is not a section)
var pageHeight = document.body.scrollHeight || document.documentElement.scrollHeight;
if (rect.height > pageHeight * 0.8) continue;
var y = rect.top + window.scrollY;
var heading = el.querySelector("h1, h2, h3, h4");
var headingText = heading ? (heading.innerText || heading.textContent || "").trim().replace(/\\s+/g, ' ').slice(0, 80) : "";
var classes = (el.className || "").toString().toLowerCase();
var type = "content";
if (y < 200 || classes.indexOf("hero") !== -1) type = "hero";
else if (el.tagName === "FOOTER" || classes.indexOf("footer") !== -1) type = "footer";
else if (classes.indexOf("cta") !== -1) type = "cta";
else if (classes.indexOf("logo") !== -1 || classes.indexOf("customer") !== -1) type = "logos";
else if (classes.indexOf("testimonial") !== -1 || classes.indexOf("quote") !== -1) type = "testimonials";
else if (classes.indexOf("feature") !== -1 || classes.indexOf("section") !== -1) type = "features";
var selector = el.id ? "#" + el.id : el.tagName.toLowerCase();
var sectionBg = getComputedStyle(el).backgroundColor;
// Walk up DOM to find nearest non-transparent background (don't default to white)
if (!sectionBg || sectionBg === "rgba(0, 0, 0, 0)" || sectionBg === "transparent") {
var bgWalker = el.parentElement;
while (bgWalker) {
var parentBg = getComputedStyle(bgWalker).backgroundColor;
if (parentBg && parentBg !== "rgba(0, 0, 0, 0)" && parentBg !== "transparent") {
sectionBg = parentBg;
break;
}
bgWalker = bgWalker.parentElement;
}
if (!sectionBg || sectionBg === "rgba(0, 0, 0, 0)" || sectionBg === "transparent") sectionBg = "#FFFFFF";
}
// Check for background-image when color is transparent/default white
var sectionBgImage = undefined;
var rawBgImg = getComputedStyle(el).backgroundImage;
if (rawBgImg && rawBgImg !== "none" && rawBgImg.indexOf("url(") !== -1) {
var start = rawBgImg.indexOf("url(") + 4;
var end = rawBgImg.indexOf(")", start);
if (end > start) {
sectionBgImage = rawBgImg.slice(start, end).replace(/['"]/g, "");
}
}
sectionBg = rgbToHex(sectionBg) || sectionBg;
// Inner content for faithful page-card recreation downstream: CTAs, body
// text, in-section media URLs (remote — joined to local paths in index.ts),
// and a coarse layout hint. Mirrors the prior capture framework's richer
// section model that the page-scroll-spotlight blueprint depends on.
var absUrl = function (u) {
try { return u ? new URL(u, location.href).href : ""; } catch (e) { return ""; }
};
var sectionText = (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 600);
var sectionCtas = [];
var ctaNodes = el.querySelectorAll("a, button");
for (var qi = 0; qi < ctaNodes.length && sectionCtas.length < 8; qi++) {
if (!isVisible(ctaNodes[qi])) continue;
var ctaTxt = (ctaNodes[qi].textContent || "").trim().replace(/\\s+/g, " ").slice(0, 60);
if (ctaTxt && ctaTxt.length > 1 && sectionCtas.indexOf(ctaTxt) === -1) sectionCtas.push(ctaTxt);
}
var sectionAssets = [];
var mediaNodes = el.querySelectorAll("img, video, source");
for (var ii = 0; ii < mediaNodes.length && sectionAssets.length < 10; ii++) {
var mn = mediaNodes[ii];
var msrc = mn.currentSrc || mn.src || mn.getAttribute("src") || mn.getAttribute("data-src") || mn.getAttribute("poster") || "";
var mau = absUrl(msrc);
if (mau && !mau.startsWith("data:") && sectionAssets.indexOf(mau) === -1) sectionAssets.push(mau);
}
if (sectionBgImage) {
var bau = absUrl(sectionBgImage);
if (bau && sectionAssets.indexOf(bau) === -1) sectionAssets.unshift(bau);
}
var imgCount = el.querySelectorAll("img").length;
var layout = "stacked";
if (imgCount >= 3) layout = "grid";
else if (el.querySelector("img, video") && headingText) layout = "split";
else if (headingText && imgCount === 0) layout = "centered";
var sectionEntry = {
selector: selector, type: type,
x: Math.round(rect.left + window.scrollX), y: Math.round(y),
width: Math.round(rect.width), height: Math.round(rect.height),
heading: headingText, backgroundColor: sectionBg,
callsToAction: sectionCtas, text: sectionText, layout: layout, assetUrls: sectionAssets
};
if (sectionBgImage) sectionEntry.backgroundImage = sectionBgImage;
sectionResults.push(sectionEntry);
}
sectionResults.sort(function(a, b) { return a.y - b.y; });
var filtered = sectionResults.filter(function(s, i) { return i === 0 || Math.abs(s.y - sectionResults[i-1].y) > 100; });
// Filter cssVariables — keep only color-like values or design-relevant names
var colorValueRe = /^(#|rgb|hsl|oklch|oklab|lch|lab|color)/i;
var designNameRe = /(color|bg|background|border|text|font|radius|shadow)/i;
var filteredVars = {};
var varKeys = Object.keys(cssVariables);
for (var vi = 0; vi < varKeys.length; vi++) {
var varName = varKeys[vi];
var varVal = cssVariables[varName];
if (colorValueRe.test(varVal) || designNameRe.test(varName)) {
filteredVars[varName] = varVal;
}
}
// Filter sections — only keep those with a non-empty heading
var filteredSections = filtered.filter(function(s) { return s.heading && s.heading.length > 0; });
return {
title: title, description: description, ogImage: ogImage,
cssVariables: filteredVars, fonts: Object.keys(fontMap).map(function(k) { var f = fontMap[k]; f.weights.sort(function(a,b){return a-b;}); return f; }).filter(function(f) { return f.weights.length > 0 || f.variable; }).slice(0, 20), colors: Object.keys(colorSet).sort(function(a,b) { return colorSet[b] - colorSet[a]; }).slice(0, 20),
headings: headings, ctas: ctas,
svgs: svgs, sections: filteredSections,
colorStats: colorStatsArr,
page: { width: Math.round(document.documentElement.scrollWidth), height: Math.round(document.documentElement.scrollHeight), viewport: { width: window.innerWidth, height: window.innerHeight } }
};
})()`;
export async function extractTokens(page: Page): Promise<DesignTokens> {
return page.evaluate(EXTRACT_SCRIPT) as Promise<DesignTokens>;
}
+246
View File
@@ -0,0 +1,246 @@
/**
* Types for the website capture pipeline.
*
* Phase 1: Capture — Extract HTML, CSS, screenshots, tokens, assets from a URL
* Phase 2: Split — Decompose into per-section sub-compositions
* Phase 3: Verify — Validate each section renders correctly
* Phase 4: Scaffold — Assemble standard HyperFrames project
*/
// ── Phase 1: Capture ────────────────────────────────────────────────────────
export interface CaptureOptions {
/** URL to capture */
url: string;
/** Output directory */
outputDir: string;
/** Viewport width (default: 1920) */
viewportWidth?: number;
/** Viewport height (default: 1080) */
viewportHeight?: number;
/** Page load timeout in ms (default: 120000) */
timeout?: number;
/** Extra wait after load for JS to settle (default: 3000) */
settleTime?: number;
/** Maximum screenshots to take (default: 24) */
maxScreenshots?: number;
/** Skip asset downloads */
skipAssets?: boolean;
/** Output JSON for programmatic use */
json?: boolean;
}
export interface CaptureResult {
/** Whether capture completed successfully */
ok: boolean;
/** Project output directory */
projectDir: string;
/** Source URL */
url: string;
/** Page title */
title: string;
/** Extracted HTML data */
extracted: ExtractedHtml;
/** Screenshot file paths (relative to projectDir) */
screenshots: string[];
/** Design tokens extracted from the page */
tokens: DesignTokens;
/** Downloaded asset paths (relative to projectDir) */
assets: DownloadedAsset[];
/** Animation catalog (captured during full-JS page load) */
animationCatalog?: import("./animationCataloger.js").AnimationCatalog;
/** Errors/warnings encountered during capture */
warnings: string[];
}
export interface ExtractedHtml {
/** All <style> tags from <head> (after stylesheet inlining) */
headHtml: string;
/** Full document.body.innerHTML */
bodyHtml: string;
/** CSS-in-JS rules from document.styleSheets (CSSOM) */
cssomRules: string;
/** <html> element attributes (class, data-theme, style, lang) */
htmlAttrs: string;
/** Original viewport width during capture */
viewportWidth: number;
/** Original viewport height during capture */
viewportHeight: number;
/** Full page scroll height */
fullPageHeight: number;
}
// ── Design Tokens ───────────────────────────────────────────────────────────
export interface FontToken {
family: string;
weights: number[];
variable?: boolean;
weightRange?: [number, number];
}
export interface DesignTokens {
/** Page title */
title: string;
/** Meta description */
description: string;
/** OG image URL */
ogImage?: string;
/** CSS custom properties from :root */
cssVariables: Record<string, string>;
/** Font families in use (with weights) */
fonts: FontToken[];
/** Extracted colors (background, text, accent), ranked by weighted usage */
colors: string[];
/**
* Per-color usage signals for brand classification (how each color is used:
* as a fill, on interactive elements, on large areas, or as text). Consumers
* (e.g. design-system build) use these to pick the brand primary — the
* chromatic color most used as an interactive/repeated FILL, as distinct from
* section surfaces (large blocks) and link/text colors. Top ~48 by usage.
*/
colorStats?: Array<{
hex: string;
/** total occurrences across bg + text */
count: number;
/** times used as a non-transparent background */
bgCount: number;
/** times that background sat on an interactive element (a/button/role) */
interactiveBg: number;
/** times that background covered a large area (> 50000px²) */
areaBg: number;
/** times used as a text color */
textCount: number;
/** largest single area (px²) this color filled */
maxArea: number;
}>;
/** Headings with text and basic styles */
headings: Array<{
level: number;
text: string;
fontSize: string;
fontWeight: string;
color: string;
}>;
/** CTA button/link text */
ctas: Array<{ text: string; href?: string }>;
/** SVG elements with labels (outerHTML kept in memory for asset downloader, stripped from saved JSON) */
svgs: Array<{
label?: string;
viewBox?: string;
width: number;
height: number;
outerHTML: string;
isLogo: boolean;
}>;
/** Detected page sections with bounding rects + inner content for recreation */
sections: Array<{
selector: string;
type: string;
x?: number;
y: number;
width?: number;
height: number;
heading: string;
backgroundColor?: string;
backgroundImage?: string;
/** Visible button/link labels inside the section */
callsToAction?: string[];
/** Squeezed body text (≤600 chars) */
text?: string;
/** Coarse layout hint for rebuild */
layout?: "stacked" | "grid" | "split" | "centered";
/** In-section media URLs (remote at extraction; joined to local in index.ts) */
assetUrls?: string[];
/** Local asset paths (assets/…) resolved from assetUrls after download */
assets?: string[];
}>;
/** Full-page + viewport geometry (drives measured scroll distance downstream) */
page?: {
width: number;
height: number;
viewport: { width: number; height: number };
};
}
// ── Design Styles (computed from live DOM) ──────────────────────────────────
export interface TypographyRole {
role: string;
fontFamily: string;
fontSize: string;
fontWeight: string;
lineHeight: string;
letterSpacing: string;
color: string;
sampleText: string;
}
export interface ComponentStyle {
label: string;
background: string;
/** Gradient background-image if any (url()/none dropped) — a core brand signal */
backgroundImage?: string;
/** backdrop-filter value if any (frosted-glass panels); "" when none */
backdropFilter?: string;
color: string;
padding: string;
borderRadius: string;
border: string;
boxShadow: string;
fontSize: string;
fontWeight: string;
height: string;
}
export interface StatCellStyle {
background: string;
borderRadius: string;
border: string;
boxShadow: string;
/** the large numeral's type */
numberFontSize: string;
numberFontWeight: string;
numberColor: string;
}
export interface DesignStyles {
typography: TypographyRole[];
spacing: {
observed: number[];
baseUnit: number;
};
radius: string[];
shadows: Array<{ value: string; count: number }>;
buttons: ComponentStyle[];
cards: ComponentStyle[];
nav: ComponentStyle | null;
/** pill / badge / chip / tag — small rounded labelled elements */
chips?: ComponentStyle[];
/** metric / KPI cells (a large numeral + label) */
statCells?: StatCellStyle[];
/** tab controls */
tabs?: ComponentStyle[];
/** Dominant gradient/mesh background washes, ranked by on-screen area covered */
backgrounds?: Array<{ value: string; area: number }>;
/** Frosted-glass panels (backdrop-filter): raw translucent fill + blur, ranked by area */
glass?: Array<{
backdropFilter: string;
background: string;
border: string;
borderRadius: string;
boxShadow: string;
area: number;
}>;
}
// ── Assets ──────────────────────────────────────────────────────────────────
export interface DownloadedAsset {
/** Original URL */
url: string;
/** Local file path (relative to projectDir) */
localPath: string;
/** Asset type */
type: "svg" | "image" | "favicon";
}
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const cliSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "cli.ts"), "utf8");
const helpSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "help.ts"), "utf8");
function commandLoaderBlock(): string {
const match = cliSource.match(/const commandLoaders = \{([\s\S]*?)\n\};/);
expect(match).toBeTruthy();
return match![1]!;
}
describe("CLI command registration", () => {
it("registers keyframes as the only keyframe inspection command", () => {
const loaders = commandLoaderBlock();
expect(loaders).toMatch(/\bkeyframes:\s*\(\)\s*=>\s*import\("\.\/commands\/keyframes\.js"\)/);
expect(loaders).not.toMatch(/\bmotion:\s*\(\)\s*=>/);
expect(loaders).not.toContain("./commands/motion.js");
});
it("shows keyframes in root help", () => {
expect(helpSource).toContain(
'["keyframes", "Inspect keyframes and render onion-shot diagnostics"]',
);
});
// A command actively reconciling skills (`skills check`/`skills update`)
// must not also nudge the user to go reconcile skills — that nudge is
// either redundant (it just ran) or misleading (a stale cached count from
// the 24h background check, contradicting whatever it just reported).
it("excludes 'skills' from the background skills-nudge gate, alongside 'upgrade' and 'events'", () => {
const match = cliSource.match(/if \(([\s\S]*?)\) \{\s*\/\/ Report any completed auto-install/);
expect(match, "expected to find the background nudge gate's if-condition").toBeTruthy();
const condition = match![1]!;
expect(condition).toContain('command !== "upgrade"');
expect(condition).toContain('command !== "events"');
expect(condition).toContain('command !== "skills"');
});
});
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env node
// ── EPIPE suppression (must run before ANY stdout/stderr write) ────────────
// When the CLI runs inside a piped agent environment (Claude Code, Codex,
// Cursor, etc.), the reader may close the pipe before we finish writing.
// Node treats EPIPE on stdout/stderr as an uncaughtException, which crashes
// the process. This is a normal lifecycle event — suppress it.
//
// commandFailed must be declared here (before the handlers) so the EPIPE
// stream-error path can set it before process.exit(0). The telemetry exit
// handler reads this flag to determine success/failure — an EPIPE exit
// should NOT score as success:true in telemetry.
let commandFailed = false;
for (const stream of [process.stdout, process.stderr]) {
stream.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
commandFailed = true;
process.exit(0);
}
});
}
// ── Worker entry path bootstrap (must run before any producer/engine load) ──
// The shaderTransitionWorkerPool lives in the producer package and resolves
// its worker entry by probing for a sibling `.js` file next to
// `import.meta.url`. When this CLI is bundled by tsup, the producer code is
// inlined into `cli.js`, but `import.meta.url` resolves to the producer's
// own dist path (NOT cli.js) on some module-graph layouts — so the sibling
// probe lands in a directory that does not contain the bundled worker.
// We emit the worker entry next to cli.js (see tsup.config.ts) and tell
// the pool where to find it via the published env-var override.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
(() => {
const here = dirname(fileURLToPath(import.meta.url));
const shader = join(here, "shaderTransitionWorker.js");
if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) {
process.env.HF_SHADER_WORKER_ENTRY = shader;
}
})();
// ── Fast-path exits ─────────────────────────────────────────────────────────
// Check --version before importing anything heavy. This makes
// `hyperframes --version` near-instant (~10ms vs ~80ms).
import { VERSION } from "./version.js";
const argv = process.argv.slice(2);
const commandArg = argv[0];
const rootVersionRequested =
commandArg === "--version" ||
commandArg === "-V" ||
(commandArg === undefined && (argv.includes("--version") || argv.includes("-V")));
if (rootVersionRequested) {
console.log(VERSION);
process.exit(0);
}
// ── Load .env from CWD ─────────────────────────────────────────────────────
// Agents run from the project directory where .env holds API keys (Gemini,
// HeyGen, ElevenLabs). Load it automatically so they don't need `source .env`.
try {
const { readFileSync } = await import("node:fs");
const { resolve } = await import("node:path");
const envPath = resolve(process.cwd(), ".env");
const envContent = readFileSync(envPath, "utf-8");
for (const rawLine of envContent.split("\n")) {
let line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
// Tolerate `export FOO=bar` (common in dotfile-style .env files).
if (line.startsWith("export ")) line = line.slice(7).trim();
const eqIdx = line.indexOf("=");
if (eqIdx < 1) continue;
const key = line.slice(0, eqIdx).trim();
let val = line.slice(eqIdx + 1).trim();
if (val.startsWith('"') || val.startsWith("'")) {
// Quoted value: take until the matching closing quote; leave the rest.
// Anything after a closing quote (including `# comment`) is dropped.
const quote = val.charAt(0);
const end = val.indexOf(quote, 1);
if (end > 0) val = val.slice(1, end);
else val = val.slice(1); // unterminated quote — best-effort, strip opener
} else {
// Unquoted value: strip inline `# comment` (requires whitespace before #
// to avoid eating `pass#word` style values).
const commentMatch = val.match(/\s+#/);
if (commentMatch?.index !== undefined) val = val.slice(0, commentMatch.index).trim();
}
if (key && !(key in process.env)) process.env[key] = val;
}
} catch {
/* .env not present — fine, env vars may be set another way */
}
// ── Lazy imports ────────────────────────────────────────────────────────────
// Telemetry, update checks, and heavy modules are imported only when needed.
// For --help we skip telemetry entirely.
import { defineCommand, runMain } from "citty";
import type { ArgsDef, CommandDef } from "citty";
import { getRunId } from "./telemetry/runId.js";
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
// ---------------------------------------------------------------------------
// CLI definition — all commands are lazy-loaded via dynamic import()
// ---------------------------------------------------------------------------
const commandLoaders = {
init: () => import("./commands/init.js").then((m) => m.default),
add: () => import("./commands/add.js").then((m) => m.default),
catalog: () => import("./commands/catalog.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
present: () => import("./commands/present.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
publish: () => import("./commands/publish.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.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),
"remove-background": () => import("./commands/remove-background.js").then((m) => m.default),
transcribe: () => import("./commands/transcribe.js").then((m) => m.default),
tts: () => import("./commands/tts.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),
skills: () => import("./commands/skills.js").then((m) => m.default),
feedback: () => import("./commands/feedback.js").then((m) => m.default),
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
events: () => import("./commands/events.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
"grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default),
compare: () => import("./commands/compare.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
lambda: () => import("./commands/lambda.js").then((m) => m.default),
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
cloud: () => import("./commands/cloud.js").then((m) => m.default),
auth: () => import("./commands/auth.js").then((m) => m.default),
figma: () => import("./commands/figma.js").then((m) => m.default),
};
// Wrap each command's run() so a thrown failure reports its reason to telemetry
// before citty catches the error and exits 1. The error is re-thrown unchanged,
// preserving citty's print + exit-1 behavior. Commands that call process.exit()
// themselves (e.g. `browser path`) bypass this and report inline.
const subCommands = Object.fromEntries(
Object.entries(commandLoaders).map(([name, load]) => [
name,
trackCommandFailures(load, (err) => reportCommandFailure(command, err)),
]),
);
const main = defineCommand({
meta: {
name: "hyperframes",
version: VERSION,
description: "Create and render HTML video compositions",
},
subCommands,
});
// ---------------------------------------------------------------------------
// Telemetry — lazy-loaded, captured references for exit handlers
// ---------------------------------------------------------------------------
const cliCommandArg = process.argv[2];
// Explicit annotation breaks a type cycle: `subCommands` references `command`
// (in the failure reporter) and `command` references `subCommands` (the `in`
// check), so its type can't be inferred from its own initializer.
const command: string = cliCommandArg && cliCommandArg in subCommands ? cliCommandArg : "unknown";
const hasJsonFlag = process.argv.includes("--json");
// Captured references — populated when the lazy imports resolve.
// Used in exit handlers where dynamic import() is unsafe (beforeExit loops,
// exit handler is synchronous-only).
let _flush: (() => Promise<void>) | undefined;
let _flushSync: (() => void) | undefined;
let _trackCliError:
| ((props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}) => void)
| undefined;
let _trackCommandResult:
| ((props: {
command: string;
success: boolean;
exitCode: number;
durationMs: number;
runId?: string;
}) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
// printed into a skill's captured output).
if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") {
import("./telemetry/index.js").then((mod) => {
_flush = mod.flush;
_flushSync = mod.flushSync;
_trackCliError = mod.trackCliError;
_trackCommandResult = mod.trackCommandResult;
mod.showTelemetryNotice();
mod.trackCommand(command, runId);
if (mod.shouldTrack()) mod.incrementCommandCount();
});
}
// `events` skips the update check too — a skill-usage beacon must not add
// network latency or trigger a background self-upgrade on the calling skill.
// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is
// excluded from the self-update notice: a command that is itself actively
// checking/reconciling skills (`skills check`, `skills update`) must not also
// tell the user to go run `skills update` — that's either redundant (it just
// did) or, worse, misleading (it printed a stale nudge count from the last
// cached check while reporting fresh results of its own).
if (
!isHelp &&
!hasJsonFlag &&
command !== "upgrade" &&
command !== "events" &&
command !== "skills"
) {
// Report any completed auto-install from the previous run first, before
// kicking off the next check — so the user sees "updated to vX" once and
// we don't over-print.
import("./utils/autoUpdate.js").then((mod) => mod.reportCompletedUpdate()).catch(() => {});
import("./utils/updateCheck.js").then(async (mod) => {
_printUpdateNotice = mod.printUpdateNotice;
const result = await mod.checkForUpdate().catch(() => null);
if (result?.updateAvailable) {
const auto = await import("./utils/autoUpdate.js").catch(() => null);
auto?.scheduleBackgroundInstall(result.latest, result.current);
}
});
// Skills freshness nudge — same gating as the CLI self-update notice. The
// check is cached (24h) and best-effort: it never blocks or fails the command.
import("./utils/skillsUpdateCheck.js").then(async (mod) => {
_printSkillsUpdateNotice = mod.printSkillsUpdateNotice;
await mod.checkSkillsForUpdate().catch(() => null);
});
}
const commandStart = Date.now();
const runId = getRunId();
// Async flush for normal exit. `beforeExit` re-fires every time the
// event loop drains, and the async `_flush()` itself schedules new
// work — so a plain `on` listener would print the update notice (and
// re-flush) once per drain (the user-reported double-print). `once`
// detaches after first invocation, which is what we want for both.
process.once("beforeExit", () => {
_flush?.().catch(() => {});
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printSkillsUpdateNotice?.();
}
});
// Sync-only: exit handlers cannot await promises or drain microtasks.
// _trackCommandResult / _trackCliError are captured references resolved
// at init time, so they're callable synchronously here.
process.on("exit", (code) => {
_trackCommandResult?.({
command,
success: code === 0 && !commandFailed,
exitCode: code,
durationMs: Date.now() - commandStart,
runId,
});
_flushSync?.();
});
process.on("uncaughtException", (error) => {
if ((error as NodeJS.ErrnoException).code === "EPIPE") {
commandFailed = true;
process.exit(0);
}
commandFailed = true;
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind: "uncaught_exception",
});
_flushSync?.();
process.exit(1);
});
// unhandledRejection does not call process.exit() — Node may continue
// running if the rejection is non-fatal (e.g. a fire-and-forget promise).
// The exit handler above will still fire with the real exit code.
process.on("unhandledRejection", (reason) => {
commandFailed = true;
const error = reason instanceof Error ? reason : new Error(String(reason));
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind: "unhandled_rejection",
});
});
// Lazy-load help renderer — avoids allocating help data on non-help invocations
async function showUsage<T extends ArgsDef>(
cmd: CommandDef<T>,
parent?: CommandDef<T>,
): Promise<void> {
const { showUsage: impl } = await import("./help.js");
return impl(cmd as CommandDef, parent as CommandDef | undefined);
}
runMain(main, { showUsage });
+346
View File
@@ -0,0 +1,346 @@
/**
* AUTO-GENERATED from experiment-framework/openapi/external-api.json.
* DO NOT EDIT MANUALLY. Re-run
* `python scripts/generate_hyperframes_cli_client.py` in
* experiment-framework to regenerate.
*/
import type {
CompleteAssetUploadRequest,
CompleteAssetUploadResponse,
CreateAssetUploadRequest,
CreateAssetUploadResponse,
CreateHyperframesRenderRequest,
CreateHyperframesRenderResponse,
DeleteHyperframesRenderResponse,
HyperframesRenderDetail,
UploadAssetV3Response,
} from "./types.js";
export type AuthHeaders = Record<string, string>;
/**
* Caller-provided context. Keep the shape narrow so the cli/src/auth/
* module stays the single owner of credential resolution.
*/
export interface HyperframesCloudClientOptions {
/** Base URL like "https://api.heygen.com" (no trailing slash). */
baseUrl: string;
/**
* Return the auth headers to attach to every request. Called once per
* request so callers can refresh OAuth tokens transparently.
*/
getAuthHeaders: () => Promise<AuthHeaders> | AuthHeaders;
/** Override fetch (used by tests). */
fetchImpl?: typeof fetch;
}
/**
* Standard error envelope used by /v3 endpoints. See StandardAPIError in
* types.ts for the field shape.
*/
export class HyperframesApiError extends Error {
readonly status: number;
readonly code?: string;
readonly param?: string | null;
readonly docUrl?: string | null;
readonly raw: unknown;
constructor(opts: {
status: number;
message: string;
code?: string;
param?: string | null;
docUrl?: string | null;
raw?: unknown;
}) {
super(opts.message);
this.name = "HyperframesApiError";
this.status = opts.status;
this.code = opts.code;
this.param = opts.param;
this.docUrl = opts.docUrl;
this.raw = opts.raw;
}
}
interface RequestOptions {
method: string;
path: string;
query?: Record<string, string | number | undefined>;
body?: unknown;
multipart?: FormData;
idempotencyKey?: string;
signal?: AbortSignal;
/**
* When true (default), the ``data`` wrapper of the standard /v3
* response envelope is unwrapped before returning. List endpoints set
* this to false so callers can read ``has_more`` / ``next_token``.
*/
unwrapData?: boolean;
}
/**
* Typed client for the HyperFrames cloud-render API. Auto-generated; do
* not hand-edit. Submit new endpoints by adding them to
* scripts/generate_hyperframes_cli_client.py in experiment-framework.
*/
export class HyperframesCloudClient {
private readonly baseUrl: string;
private readonly getAuthHeaders: () => Promise<AuthHeaders> | AuthHeaders;
private readonly fetchImpl: typeof fetch;
constructor(opts: HyperframesCloudClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
this.getAuthHeaders = opts.getAuthHeaders;
this.fetchImpl = opts.fetchImpl ?? fetch;
}
private async request<T>(opts: RequestOptions): Promise<T> {
const url = this.buildUrl(opts.path, opts.query);
const auth = await this.getAuthHeaders();
const headers: Record<string, string> = { ...auth };
let body: BodyInit | undefined;
if (opts.multipart) {
body = opts.multipart;
// fetch sets the multipart boundary automatically; do NOT set
// Content-Type here or the upload will be rejected.
} else if (opts.body !== undefined) {
headers["content-type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (opts.idempotencyKey) {
headers["Idempotency-Key"] = opts.idempotencyKey;
}
const res = await this.fetchImpl(url, {
method: opts.method,
headers,
body,
signal: opts.signal,
});
if (!res.ok) {
throw await this.toApiError(res);
}
// 204 No Content
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
if (!text) {
return undefined as T;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
throw new HyperframesApiError({
status: res.status,
message: `Invalid JSON response: ${(err as Error).message}`,
raw: text.slice(0, 500),
});
}
// The /v3 envelope is {data: T, ...}. Unwrap when present and the
// call site asked for it (the default) so consumers read the inner
// payload directly. List endpoints opt out so they can read
// ``has_more`` / ``next_token``.
const unwrap = opts.unwrapData !== false;
if (
unwrap &&
parsed &&
typeof parsed === "object" &&
"data" in (parsed as Record<string, unknown>)
) {
const envelope = parsed as { data: T };
return envelope.data;
}
return parsed as T;
}
private buildUrl(path: string, query?: Record<string, string | number | undefined>): string {
const url = new URL(this.baseUrl + path);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
url.searchParams.set(k, String(v));
}
}
return url.toString();
}
private async toApiError(res: Response): Promise<HyperframesApiError> {
let parsed: unknown;
try {
parsed = await res.json();
} catch {
parsed = undefined;
}
const err =
parsed && typeof parsed === "object" && "error" in (parsed as Record<string, unknown>)
? ((parsed as Record<string, unknown>).error as Record<string, unknown> | undefined)
: undefined;
return new HyperframesApiError({
status: res.status,
message:
(err && typeof err.message === "string" && err.message) ||
`HTTP ${res.status} ${res.statusText}`,
code: err && typeof err.code === "string" ? err.code : undefined,
param:
err && (typeof err.param === "string" || err.param === null)
? (err.param as string | null)
: undefined,
docUrl:
err && (typeof err.doc_url === "string" || err.doc_url === null)
? (err.doc_url as string | null)
: undefined,
raw: parsed,
});
}
/**
* Upload Asset
*
* Uploads a file (image, video, audio, or PDF) and returns an asset_id for use in other endpoints. Max 32 MB. Supported types: png, jpeg, mp4, webm, mp3, wav, pdf.
*/
async uploadAsset(args: {
file: Blob | Buffer | Uint8Array;
filename: string;
mimeType?: string;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<UploadAssetV3Response> {
const fd = new FormData();
const blobOpts = args.mimeType ? { type: args.mimeType } : undefined;
const blob =
args.file instanceof Blob
? args.file
: new Blob([args.file as unknown as BlobPart], blobOpts);
fd.append("file", blob, args.filename);
return await this.request<UploadAssetV3Response>({
method: "POST",
path: "/v3/assets",
multipart: fd,
idempotencyKey: args.idempotencyKey,
signal: args.signal,
});
}
/**
* Create Asset Upload
*
* Begin a direct-to-S3 upload. Returns an asset_id and a presigned upload_url; PUT the file bytes to upload_url, then call POST /v3/assets/{asset_id}/complete. Unlike POST /v3/assets (which proxies the bytes), this never sends the file through the API.
*/
async createAssetUpload(args: {
body: CreateAssetUploadRequest;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<CreateAssetUploadResponse> {
return await this.request<CreateAssetUploadResponse>({
method: "POST",
path: "/v3/assets/direct-uploads",
body: args.body,
idempotencyKey: args.idempotencyKey,
signal: args.signal,
});
}
/**
* Complete Asset Upload
*
* Finalize a direct-to-S3 upload into a reusable asset. Call after the upload PUT returns 200. Idempotent: repeated calls return the same finalized asset.
*/
async completeAssetUpload(args: {
asset_id: string;
body: CompleteAssetUploadRequest;
signal?: AbortSignal;
}): Promise<CompleteAssetUploadResponse> {
return await this.request<CompleteAssetUploadResponse>({
method: "POST",
path: `/v3/assets/${encodeURIComponent(args.asset_id)}/complete`,
body: args.body,
signal: args.signal,
});
}
/**
* Create HyperFrames Render
*
* Renders a HyperFrames composition (an HTML+JS+assets project bundled as a .zip) into a video. Submit the project via `url`, `asset_id` (pre-uploaded via POST /v3/assets), or inline `base64`. Returns a `render_id` to poll via GET /v3/hyperframes/renders/{render_id}.
*/
async createRender(args: {
body: CreateHyperframesRenderRequest;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<CreateHyperframesRenderResponse> {
return await this.request<CreateHyperframesRenderResponse>({
method: "POST",
path: "/v3/hyperframes/renders",
body: args.body,
idempotencyKey: args.idempotencyKey,
signal: args.signal,
});
}
/**
* List HyperFrames Renders
*
* Returns a cursor-paginated list of HyperFrames renders in the account, newest first.
*/
async listRenders(args: { limit?: number; token?: string; signal?: AbortSignal }): Promise<{
data?: Array<HyperframesRenderDetail>;
has_more?: boolean;
next_token?: string | null;
}> {
const query: Record<string, string | number | undefined> = {
limit: args.limit,
token: args.token,
};
return await this.request<{
data?: Array<HyperframesRenderDetail>;
has_more?: boolean;
next_token?: string | null;
}>({
method: "GET",
path: "/v3/hyperframes/renders",
query,
unwrapData: false,
signal: args.signal,
});
}
/**
* Get HyperFrames Render
*
* Returns full details for a single HyperFrames render, including status and signed video_url when complete.
*/
async getRender(args: {
render_id: string;
signal?: AbortSignal;
}): Promise<HyperframesRenderDetail> {
return await this.request<HyperframesRenderDetail>({
method: "GET",
path: `/v3/hyperframes/renders/${encodeURIComponent(args.render_id)}`,
signal: args.signal,
});
}
/**
* Delete HyperFrames Render
*
* Soft-deletes a HyperFrames render. Subsequent GETs return 404.
*/
async deleteRender(args: {
render_id: string;
signal?: AbortSignal;
}): Promise<DeleteHyperframesRenderResponse> {
return await this.request<DeleteHyperframesRenderResponse>({
method: "DELETE",
path: `/v3/hyperframes/renders/${encodeURIComponent(args.render_id)}`,
signal: args.signal,
});
}
}
+363
View File
@@ -0,0 +1,363 @@
/**
* AUTO-GENERATED from experiment-framework/openapi/external-api.json.
* DO NOT EDIT MANUALLY. Re-run
* `python scripts/generate_hyperframes_cli_client.py` in
* experiment-framework to regenerate.
*/
// Component schemas reachable from the cloud-render endpoint set.
// Add a new path/method to TARGET_ENDPOINTS in
// scripts/generate_hyperframes_cli_client.py to extend this list.
/**
* Asset input via base64-encoded content.
*/
export interface AssetBase64 {
/**
* Input type discriminator
*/
type: "base64";
/**
* MIME type of the encoded content (e.g. "image/png")
*/
media_type: string;
/**
* Base64-encoded file content
*/
data: string;
}
/**
* Asset input via HeyGen asset ID from the asset upload endpoint.
*/
export interface AssetId {
/**
* Input type discriminator
*/
type: "asset_id";
/**
* HeyGen asset ID from the asset upload endpoint
*/
asset_id: string;
}
/**
* Asset input via publicly accessible HTTPS URL.
*/
export interface AssetUrl {
/**
* Input type discriminator
*/
type: "url";
/**
* Publicly accessible HTTPS URL for the asset
*/
url: string;
}
/**
* Finalize a presigned upload (POST /v3/assets/{asset_id}/complete).
*/
export interface CompleteAssetUploadRequest {
/**
* Optional SHA256 (hex) cross-check.
*/
checksum_sha256?: string | null;
}
/**
* Result of finalizing an upload.
*/
export interface CompleteAssetUploadResponse {
/**
* The reusable asset identifier.
*/
asset_id: string;
/**
* Public URL of the finalized asset.
*/
url: string;
/**
* MIME type detected from the stored bytes.
*/
mime_type: string;
/**
* Size of the stored object in bytes.
*/
size_bytes: number;
/**
* Asset status, e.g. 'processing'.
*/
status: "processing";
}
/**
* Request to begin a presigned direct-to-S3 upload (POST
* /v3/assets/direct-uploads).
*/
export interface CreateAssetUploadRequest {
/**
* Original filename for reference/metadata. The stored object's extension is
* derived from content_type.
*/
filename: string;
/**
* Declared MIME type (e.g. 'video/mp4', 'image/png', 'audio/mpeg',
* 'application/pdf', 'application/zip'). Verified against the stored bytes at
* completion.
*/
content_type: string;
/**
* Exact byte size of the file. Signed into the upload URL so it cannot be
* exceeded.
*/
size_bytes: number;
/**
* Optional SHA256 of the file as hex. When provided, S3 enforces it on upload.
*/
checksum_sha256?: string | null;
}
/**
* Presigned upload instructions.
*/
export interface CreateAssetUploadResponse {
/**
* Reusable asset identifier. Becomes usable after POST
* /v3/assets/{asset_id}/complete.
*/
asset_id: string;
/**
* Presigned S3 URL. PUT the raw file bytes here.
*/
upload_url: string;
/**
* Headers that must be sent verbatim on the PUT request.
*/
upload_headers: Record<string, unknown>;
/**
* Seconds until the upload URL expires.
*/
expires_in_seconds: number;
/**
* Maximum allowed upload size in bytes.
*/
max_bytes: number;
/**
* Upload lifecycle status. Always 'pending_upload' here.
*/
status: "pending_upload";
}
/**
* Request body for POST /v3/hyperframes/renders.
*/
export interface CreateHyperframesRenderRequest {
/**
* HyperFrames composition .zip — provide as {type: 'url', url: '...'}, {type:
* 'asset_id', asset_id: '...'} (pre-uploaded via POST /v3/assets), or {type:
* 'base64', media_type: 'application/zip', data: '...'}. Zip must contain
* index.html at the root (or the path you set in `composition`).
*/
project: AssetUrl | AssetId | AssetBase64;
/**
* Output frames per second. Defaults to 30 if not provided.
*/
fps?: number | null;
/**
* Render quality preset; higher quality is slower.
*/
quality?: "draft" | "standard" | "high";
/**
* Output container/codec.
*/
format?: "mp4" | "webm" | "mov";
/**
* Output resolution tier. Defaults to '1080p'. Pass '4k' for 4K renders
* (billed at 1.5x).
*/
resolution?: HyperframesResolution;
/**
* Output aspect ratio. Defaults to '16:9' (landscape). Pass '9:16' for
* portrait or '1:1' for square.
*/
aspect_ratio?: HyperframesAspectRatio;
/**
* Entry HTML file relative to the project root (e.g. compositions/intro.html).
* Defaults to index.html when omitted.
*/
composition?: string | null;
/**
* Optional overrides for the composition's data-composition-variables. Use
* this to parameterise a single composition across multiple renders.
*/
variables?: Record<string, unknown> | null;
/**
* Free-text label for the render; echoed back in detail responses.
*/
title?: string | null;
/**
* Opaque client tracking ID, echoed back in webhook payloads.
*/
callback_id?: string | null;
/**
* Per-request HTTPS webhook URL the render fires when it terminates.
*/
callback_url?: string | null;
}
/**
* Response for POST /v3/hyperframes/renders.
*/
export interface CreateHyperframesRenderResponse {
/**
* HyperFrames render identifier — poll GET /v3/hyperframes/renders/{render_id}
* for status.
*/
render_id: string;
}
/**
* Response for DELETE /v3/hyperframes/renders/{render_id}.
*/
export interface DeleteHyperframesRenderResponse {
/**
* ID of the deleted render.
*/
render_id: string;
}
/**
* Output aspect ratio. Only the three ratios already supported end-to-end by
* the render pipeline are exposed today: ``16:9`` (landscape), ``9:16``
* (portrait), ``1:1`` (square). ``auto`` and other social-media ratios (4:5,
* 5:4) are reserved for a follow-up PR that wires composition-dim inference at
* the controller boundary.
*/
export type HyperframesAspectRatio = "16:9" | "9:16" | "1:1";
/**
* Detailed HyperFrames render resource.
*/
export interface HyperframesRenderDetail {
/**
* Unique render identifier.
*/
render_id: string;
/**
* Current lifecycle state.
*/
status: HyperframesRenderStatus;
/**
* Caller-supplied free-text label.
*/
title?: string | null;
/**
* Caller-supplied client tracking ID.
*/
callback_id?: string | null;
/**
* Presigned download URL for the rendered video. Present only when status is
* 'completed'.
*/
video_url?: string | null;
/**
* Presigned download URL for the auto-generated thumbnail.
*/
thumbnail_url?: string | null;
/**
* Video duration in seconds; null until completed.
*/
duration?: number | null;
/**
* Frames per second the render was created at.
*/
fps?: number | null;
/**
* Render quality preset.
*/
quality?: "draft" | "standard" | "high" | null;
/**
* Output container/codec.
*/
format: "mp4" | "webm" | "mov";
/**
* Resolution tier, if one was set.
*/
resolution?: HyperframesResolution | null;
/**
* Aspect ratio, if one was set.
*/
aspect_ratio?: HyperframesAspectRatio | null;
/**
* Composition entry file path.
*/
composition?: string | null;
/**
* Unix timestamp when the render was created.
*/
created_at?: number | null;
/**
* Unix timestamp when the render terminated. Null until status is 'completed'
* or 'failed'.
*/
completed_at?: number | null;
/**
* Error description. Present only when status is 'failed'.
*/
failure_message?: string | null;
}
/**
* Lifecycle status of a HyperFrames render.
*/
export type HyperframesRenderStatus = "queued" | "rendering" | "completed" | "failed";
/**
* Output resolution tier. Pricing diverges only at 4K (1.5x multiplier). The
* render-pipeline value set is intentionally narrow at launch; 720p and other
* tiers will follow once the producer/CLI surface catches up.
*/
export type HyperframesResolution = "1080p" | "4k";
export interface StandardAPIError {
/**
* Machine-readable error code
*/
code: string;
/**
* Human-readable error message
*/
message: string;
/**
* Which request field caused the error
*/
param?: string | null;
/**
* Link to error documentation
*/
doc_url?: string | null;
}
/**
* Response from uploading an asset via POST /v3/assets.
*/
export interface UploadAssetV3Response {
/**
* Unique asset identifier for use in other endpoints like POST
* /v3/video-agents
*/
asset_id: string;
/**
* Public URL of the uploaded asset
*/
url: string;
/**
* Detected MIME type of the file
*/
mime_type: string;
/**
* File size in bytes
*/
size_bytes: number;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { padEndVisible, stripAnsi, visibleLength } from "./ansi.js";
describe("cloud/ansi", () => {
describe("stripAnsi", () => {
it("strips basic SGR codes (16-color)", () => {
expect(stripAnsi("\x1b[32mok\x1b[39m")).toBe("ok");
});
it("strips bright SGR codes", () => {
expect(stripAnsi("\x1b[91merr\x1b[39m")).toBe("err");
});
it("strips 24-bit truecolor sequences (used by c.accent)", () => {
expect(stripAnsi("\x1b[38;2;60;230;172maccent\x1b[39m")).toBe("accent");
});
it("strips reset codes", () => {
expect(stripAnsi("\x1b[0mreset")).toBe("reset");
});
it("leaves non-ANSI text alone", () => {
expect(stripAnsi("plain")).toBe("plain");
});
it("handles nested codes", () => {
expect(stripAnsi("\x1b[1m\x1b[32mbold-green\x1b[39m\x1b[22m")).toBe("bold-green");
});
});
describe("visibleLength", () => {
it("returns the visible-only character count", () => {
expect(visibleLength("\x1b[32mok\x1b[39m")).toBe(2);
expect(visibleLength("\x1b[38;2;60;230;172maccent\x1b[39m")).toBe(6);
});
});
describe("padEndVisible", () => {
it("pads to target visible width regardless of ANSI overhead", () => {
const padded = padEndVisible("\x1b[32mok\x1b[39m", 6);
// visible "ok" is 2 chars; padded to 6 visible chars = "ok " plus the ANSI overhead
expect(visibleLength(padded)).toBe(6);
});
it("does not trim when input is already longer than target", () => {
expect(padEndVisible("longer", 3)).toBe("longer");
});
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Strip ANSI SGR escape sequences for visible-length column alignment.
*
* Earlier impl used `/\[\d+m/g` which (a) missed the ESC prefix
* (under-counting overhead by 1 per code) and (b) didn't match
* `ESC[38;2;…m` 24-bit truecolor sequences used by `c.accent`. This
* regex covers the full CSI SGR family.
*
* Constructed via `new RegExp("\\u001b...")` rather than a `/.../`
* literal because oxlint's `no-control-regex` rule flags ESC (0x1B)
* even in literal form, and the string-construction path keeps the
* intent obvious without needing a per-file disable.
*/
// CSI SGR: ESC `[` { params with digits + semicolons } `m`.
// oxlint-disable-next-line no-control-regex
const ANSI_SGR_RE = new RegExp("\\u001b\\[[\\d;]*m", "g");
export function stripAnsi(s: string): string {
return s.replace(ANSI_SGR_RE, "");
}
/** Length of `s` after ANSI SGR codes are stripped. */
export function visibleLength(s: string): number {
return stripAnsi(s).length;
}
/** Like `String.prototype.padEnd` but counts visible chars only. */
export function padEndVisible(s: string, target: number): string {
const overhead = s.length - visibleLength(s);
return s.padEnd(target + overhead);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Bridge between the existing credential resolution chain (auth/) and the
* generated cloud client (`_gen/client.ts`). Hands the client a
* `getAuthHeaders()` callback that resolves credentials fresh on every
* request — so OAuth refreshes that happen between calls (e.g. during a
* long poll loop) are picked up automatically the next time the callback
* fires.
*
* Why this lives in `cloud/` instead of extending `auth/client.ts`: the
* auth client is scoped to `/v3/users/me` (the credential-verification
* endpoint) and we want the cloud client to be a standalone surface. The
* shared primitives (`buildAuthHeaders`, `resolveCredential`) are pulled
* in here without coupling the two clients.
*/
import { apiBaseUrl, buildAuthHeaders } from "../auth/client.js";
import { refreshTokens } from "../auth/oauth.js";
import { resolveCredential, type ResolvedCredential } from "../auth/resolver.js";
/**
* Build the cloud client's `getAuthHeaders` callback. Each invocation
* re-resolves credentials so refreshes that happened since the last call
* are picked up. When the OAuth access token is past expiry AND a
* refresh_token is present, the token endpoint is hit before headers
* are returned.
*/
export async function resolveCloudAuthHeaders(): Promise<Record<string, string>> {
let credential = await resolveCredential();
credential = await refreshIfNeeded(credential);
return buildAuthHeaders(credential);
}
/**
* Return the base URL the cloud client should hit. Honors
* `HEYGEN_API_URL` (matches `auth/client.ts:apiBaseUrl`).
*/
export function resolveCloudBaseUrl(): string {
return apiBaseUrl();
}
// fallow-ignore-next-line complexity
async function refreshIfNeeded(credential: ResolvedCredential): Promise<ResolvedCredential> {
if (credential.type !== "oauth") return credential;
if (!credential.refreshable || !credential.refresh_token) return credential;
const fresh = await refreshTokens(credential.refresh_token);
return {
...credential,
access_token: fresh.access_token,
expires_at: parseDateOrUndef(fresh.expires_at),
refreshable: false,
...(fresh.refresh_token ? { refresh_token: fresh.refresh_token } : {}),
};
}
function parseDateOrUndef(value: string | undefined): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
/**
* Force-refresh the OAuth access token regardless of locally-known
* expiry. Used by `createCloudClient`'s 401-retry path when the server
* rejects a token that the local resolver thought was still valid (e.g.
* server-side revocation, clock skew, IdP rotation). No-op for API-key
* credentials.
*
* Throws if the credential can't be refreshed (no refresh_token, or the
* IdP refresh call itself fails). Callers should let the throw surface
* — the original 401 is what the user needs to see, not a confusing
* "refresh failed" message.
*/
export async function forceRefreshCredentials(): Promise<void> {
const credential = await resolveCredential();
if (credential.type !== "oauth" || !credential.refresh_token) return;
await refreshTokens(credential.refresh_token);
}
@@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import {
ASPECT_RATIO_MATCH_TOLERANCE,
detectAspectRatioFromHtmlString,
} from "./detectAspectRatio.js";
const ROOT_DIV = (width: string | number, height: string | number, extra = "") =>
`<div data-composition-id="root" data-width="${width}" data-height="${height}"${extra ? " " + extra : ""}></div>`;
describe("detectAspectRatioFromHtmlString — matches", () => {
it("matches 16:9 on canonical 1920x1080", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1920, 1080));
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("16:9");
expect(result.width).toBe(1920);
expect(result.height).toBe(1080);
}
});
it("matches 9:16 on canonical 1080x1920", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1080, 1920));
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("9:16");
}
});
it("matches 1:1 on canonical 1080x1080", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1080, 1080));
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("1:1");
}
});
it("matches 16:9 on 1280x720 (smaller resolution, same ratio)", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1280, 720));
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
it("matches 16:9 on 3840x2160 (4K)", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(3840, 2160));
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
it("matches within tolerance for 1916x1080 (16:9 with slight pixel slop)", () => {
// 1916/1080 = 1.774 — within ±0.05 of 16/9 = 1.778
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1916, 1080));
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
});
describe("detectAspectRatioFromHtmlString — non-matches surface as warnings", () => {
it("returns no-match for 4:5 portrait social (864x1080)", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(864, 1080));
expect(result.kind).toBe("no-match");
if (result.kind === "no-match") {
expect(result.width).toBe(864);
expect(result.height).toBe(1080);
expect(result.ratio).toBeCloseTo(0.8, 2);
}
});
it("returns no-match for 5:4 (1350x1080) — outside 1:1 tolerance band", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1350, 1080));
expect(result.kind).toBe("no-match");
if (result.kind === "no-match") {
expect(result.ratio).toBeCloseTo(1.25, 2);
}
});
it("returns no-match for 3:2 ratio (1500x1000) — outside all three bands", () => {
// 1500/1000 = 1.5. 16:9 = 1.778 (Δ ≈ 0.28), 1:1 = 1.0 (Δ = 0.5),
// 9:16 = 0.5625 (Δ ≈ 0.94). All outside ±0.05. Common ratio for
// older photo-style compositions; needs the no-match warning so
// the user opts into a supported ratio explicitly.
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1500, 1000));
expect(result.kind).toBe("no-match");
if (result.kind === "no-match") {
expect(result.ratio).toBeCloseTo(1.5, 2);
}
});
it("returns no-match for 21:9 ultrawide (2560x1080)", () => {
// 2560/1080 = 2.37. Outside ±0.05 of 16/9 = 1.778.
const result = detectAspectRatioFromHtmlString(ROOT_DIV(2560, 1080));
expect(result.kind).toBe("no-match");
});
it("4:5 is genuinely outside the 1:1 tolerance band — sanity-check the cutoff", () => {
// 4:5 = 0.8; 1:1 = 1.0; difference = 0.2 — well outside 0.05 tolerance.
// This pins the tolerance choice: if someone tightens or loosens it,
// 4:5 must still NOT match 1:1, otherwise we silently mis-classify
// portrait social as square.
const ratio = 0.8;
const diffFromOne = Math.abs(ratio - 1.0);
expect(diffFromOne).toBeGreaterThan(ASPECT_RATIO_MATCH_TOLERANCE);
});
});
describe("detectAspectRatioFromHtmlString — structural edge cases", () => {
it("returns no-root-div when HTML has no composition root", () => {
const html = "<html><body><h1>no composition here</h1></body></html>";
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-root-div");
});
it("returns no-dims when root div is missing data-width", () => {
const html = `<div data-composition-id="root" data-height="1080"></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});
it("returns no-dims when root div is missing data-height", () => {
const html = `<div data-composition-id="root" data-width="1920"></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});
it("returns invalid-dims when width/height are zero", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(0, 1080));
expect(result.kind).toBe("invalid-dims");
if (result.kind === "invalid-dims") expect(result.width).toBe(0);
});
it("returns no-dims when data-width includes a sign character", () => {
// The attribute extractor only captures unsigned numbers — a negative
// value (`-1920`) doesn't match the regex at all and the field reads
// as "missing". That's the desired outcome; we don't want to surface
// negative dims as a separate failure mode the user has to interpret.
const result = detectAspectRatioFromHtmlString(ROOT_DIV(-1920, 1080));
expect(result.kind).toBe("no-dims");
});
it("handles unquoted attribute values", () => {
const html = `<div data-composition-id=root data-width=1920 data-height=1080></div>`;
const result = detectAspectRatioFromHtmlString(html);
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
it("handles single-quoted attribute values", () => {
const html = `<div data-composition-id='root' data-width='1920' data-height='1080'></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
it("ignores attributes on the wrong tag", () => {
// A non-composition div has the dims; the actual root has neither. The
// extractor must NOT swap attributes across tags.
const html = `
<div class="header" data-width="100" data-height="100"></div>
<div data-composition-id="root"></div>
`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});
it("uses the FIRST composition root encountered (the page root)", () => {
// Sub-compositions also have data-composition-id; the regex finds the
// first match, which by HF convention is the page-level root.
const html = `
<div data-composition-id="root" data-width="1920" data-height="1080">
<div data-composition-id="sub" data-width="1080" data-height="1080"></div>
</div>
`;
const result = detectAspectRatioFromHtmlString(html);
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("16:9");
expect(result.width).toBe(1920);
expect(result.height).toBe(1080);
}
});
it("handles attributes in arbitrary order", () => {
const html = `<div data-width="1080" id="root" data-height="1920" data-composition-id="root" class="bg"></div>`;
const result = detectAspectRatioFromHtmlString(html);
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("9:16");
});
it("handles whitespace + newlines inside the opening tag", () => {
const html = `<div
data-composition-id="root"
data-width="1080"
data-height="1080"
></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
it("handles a self-closing-style tag (rare but valid in MDX/JSX-flavored input)", () => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080"/>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* Auto-detect a HyperFrames composition's aspect ratio from the entry HTML's
* root `<div data-composition-id ...>` `data-width` / `data-height` attributes.
*
* The cloud-render CLI uses this when the user hasn't passed `--aspect-ratio`
* explicitly AND the project source is a local directory (asset-id and url
* paths can't be inspected client-side). The result is passed through to the
* `/v3/hyperframes/renders` request body, so the rendered output preserves
* the composition's intended ratio without the user having to remember the
* flag.
*
* Detection only matches the three values the API currently supports:
* `16:9`, `9:16`, `1:1`. Anything else (e.g. 4:5, 5:4, or an unusual custom
* ratio) returns a `"no-match"` result with the computed ratio for the
* caller to surface to the user. `auto`-style fallbacks (server-side, etc.)
* are out of scope here.
*
* Parsing approach: a narrow regex over the HTML. The root composition div is
* a well-defined pattern (`data-composition-id` always appears on it) and we
* only need two attributes off the same tag. Pulling in `jsdom` or a full DOM
* parser is heavier than the problem warrants.
*/
import { readFileSync } from "node:fs";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export type SupportedAspectRatio = "16:9" | "9:16" | "1:1";
export type AspectRatioDetection =
| {
kind: "matched";
aspectRatio: SupportedAspectRatio;
width: number;
height: number;
}
| { kind: "no-root-div" }
| { kind: "no-dims" }
| { kind: "invalid-dims"; width: number; height: number }
| { kind: "no-match"; width: number; height: number; ratio: number }
| { kind: "read-error"; error: string };
// Absolute tolerance on the computed ratio. Wide enough to absorb
// floating-point sloppiness on canonical ratios (e.g. 1920×1080 = 1.7778);
// tight enough to keep 4:5 (0.8) and 5:4 (1.25) outside the bands so they
// fall through to the "no-match" warning instead of getting silently
// mis-classified as 1:1 or 16:9.
const RATIO_TOLERANCE = 0.05;
const SUPPORTED_RATIOS: Array<{ value: SupportedAspectRatio; ratio: number }> = [
{ value: "16:9", ratio: 16 / 9 },
{ value: "9:16", ratio: 9 / 16 },
{ value: "1:1", ratio: 1 },
];
// First `<div ... data-composition-id="..." ...>` opening tag in the file.
// Quote style is intentionally permissive — single, double, or unquoted all
// match. Case-insensitive to handle `<DIV>` or `Data-Composition-Id` mid-edit.
const ROOT_COMPOSITION_DIV_RE =
/<div\b[^>]*?\bdata-composition-id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)[^>]*>/i;
// `data-width` / `data-height` attribute extractors. Accept integer or float
// values, quoted or unquoted. The `\d` class restricts to ASCII digits — no
// locale comma surprises.
const DATA_WIDTH_RE =
/\bdata-width\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i;
const DATA_HEIGHT_RE =
/\bdata-height\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i;
function extractAttributeNumber(tag: string, re: RegExp): number | null {
const match = tag.match(re);
if (!match) return null;
// First capture group that matched (quoted-double | quoted-single | unquoted).
const raw = match[1] ?? match[2] ?? match[3];
if (raw === undefined) return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
/**
* Parse the HTML at `entryHtmlPath` and detect which supported aspect ratio
* the composition's root div is authored at.
*
* Pure function except for `readFileSync` — no logging, no `process.exit`.
* The caller decides how to surface each result kind to the user.
*/
export function detectAspectRatioFromHtml(entryHtmlPath: string): AspectRatioDetection {
let html: string;
try {
html = readFileSync(entryHtmlPath, "utf-8");
} catch (err) {
return { kind: "read-error", error: normalizeErrorMessage(err) };
}
return detectAspectRatioFromHtmlString(html);
}
/**
* Same as `detectAspectRatioFromHtml`, but takes the HTML as a string instead
* of a file path. Exposed for tests + composition-string callers.
*/
export function detectAspectRatioFromHtmlString(html: string): AspectRatioDetection {
const tagMatch = html.match(ROOT_COMPOSITION_DIV_RE);
if (!tagMatch) return { kind: "no-root-div" };
const openTag = tagMatch[0];
const width = extractAttributeNumber(openTag, DATA_WIDTH_RE);
const height = extractAttributeNumber(openTag, DATA_HEIGHT_RE);
if (width === null || height === null) return { kind: "no-dims" };
if (width <= 0 || height <= 0) return { kind: "invalid-dims", width, height };
const ratio = width / height;
for (const candidate of SUPPORTED_RATIOS) {
if (Math.abs(ratio - candidate.ratio) <= RATIO_TOLERANCE) {
return { kind: "matched", aspectRatio: candidate.value, width, height };
}
}
return { kind: "no-match", width, height, ratio };
}
/**
* The tolerance used when matching the computed ratio to a supported value.
* Exposed for tests + caller introspection (e.g. warning messages that want
* to mention the bounds).
*/
export const ASPECT_RATIO_MATCH_TOLERANCE = RATIO_TOLERANCE;
+112
View File
@@ -0,0 +1,112 @@
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { downloadToFile } from "./download.js";
function makeBytesFetch(bytes: Uint8Array, headers: Record<string, string> = {}): typeof fetch {
return (async () =>
new Response(new Blob([bytes as unknown as BlobPart]), {
status: 200,
headers: {
"content-type": "application/octet-stream",
...headers,
},
})) as unknown as typeof fetch;
}
function makeErrorFetch(status: number, statusText = "Not Found"): typeof fetch {
return (async () => new Response("nope", { status, statusText })) as unknown as typeof fetch;
}
describe("cloud/download", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-cloud-download-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("streams the response body to disk and returns the byte count", async () => {
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const dest = join(dir, "out.bin");
const result = await downloadToFile("https://example/x", dest, {
fetchImpl: makeBytesFetch(payload, { "content-length": String(payload.length) }),
});
expect(result.path).toBe(dest);
expect(result.bytes).toBe(payload.length);
const written = readFileSync(dest);
expect(written.equals(Buffer.from(payload))).toBe(true);
expect(statSync(dest).size).toBe(payload.length);
});
it("creates the destination's parent directory if missing", async () => {
const payload = new Uint8Array([42]);
const dest = join(dir, "nested", "subdir", "file.mp4");
const result = await downloadToFile("https://example/x", dest, {
fetchImpl: makeBytesFetch(payload),
});
expect(result.bytes).toBe(1);
expect(readFileSync(dest).at(0)).toBe(42);
});
it("reports progress with bytes downloaded and total when content-length is set", async () => {
const payload = new Uint8Array(64);
const dest = join(dir, "progress.bin");
const calls: { bytes: number; total: number | undefined }[] = [];
await downloadToFile("https://example/x", dest, {
fetchImpl: makeBytesFetch(payload, { "content-length": "64" }),
onProgress: (bytes, total) => calls.push({ bytes, total }),
});
expect(calls.length).toBeGreaterThanOrEqual(1);
const last = calls.at(-1)!;
expect(last.bytes).toBe(64);
expect(last.total).toBe(64);
});
it("throws on non-2xx responses", async () => {
const dest = join(dir, "missing.bin");
await expect(
downloadToFile("https://example/x", dest, {
fetchImpl: makeErrorFetch(404),
}),
).rejects.toThrow(/HTTP 404/);
});
it("rejects truncated downloads when bytes received < content-length", async () => {
// Returns 10 bytes but declares content-length: 20.
const dest = join(dir, "truncated.bin");
const lyingFetch: typeof fetch = (async () =>
new Response(new Blob([new Uint8Array(10) as unknown as BlobPart]), {
status: 200,
headers: { "content-length": "20" },
})) as unknown as typeof fetch;
await expect(
downloadToFile("https://example/x", dest, { fetchImpl: lyingFetch }),
).rejects.toThrow(/Truncated download/);
// Partial file must be cleaned up.
expect(() => statSync(dest)).toThrow();
});
it("deletes the partial file when the abort signal fires mid-stream", async () => {
const dest = join(dir, "aborted.bin");
// 1 MB payload should give the abort time to land mid-stream.
const payload = new Uint8Array(1024 * 1024);
const controller = new AbortController();
const fetchImpl: typeof fetch = (async () =>
new Response(new Blob([payload as unknown as BlobPart]), {
status: 200,
headers: { "content-length": String(payload.length) },
})) as unknown as typeof fetch;
// Abort almost immediately.
queueMicrotask(() => controller.abort(new Error("user cancelled")));
await expect(
downloadToFile("https://example/x", dest, {
fetchImpl,
signal: controller.signal,
}),
).rejects.toThrow();
expect(() => statSync(dest)).toThrow();
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* Stream a presigned `video_url` (or any HTTPS URL) into a local file.
*
* The presigned URLs returned by `GET /v3/hyperframes/renders/{id}` are
* S3 URLs scoped per-request — they don't take any HeyGen auth header.
* That's why this lives separate from the cloud client: the client
* threads auth headers, the download path explicitly does NOT.
*
* Failure behavior is "all or nothing": on any error we (1) listen for
* stream errors / aborts so awaits resolve promptly instead of hanging,
* (2) verify the final byte count matches `content-length` when the
* server supplied one, and (3) `unlinkSync` the partial output so a
* subsequent retry doesn't pick up a corrupted file.
*/
import { createWriteStream, mkdirSync, unlinkSync } from "node:fs";
import { dirname } from "node:path";
export interface DownloadOptions {
signal?: AbortSignal;
/** Inject fetch (used by tests). */
fetchImpl?: typeof fetch;
/** Called with (bytes downloaded, total or undefined). */
onProgress?: (bytes: number, total: number | undefined) => void;
}
export interface DownloadResult {
path: string;
bytes: number;
}
/**
* Stream `url` into `destPath`. Creates the parent directory if needed,
* truncates any existing file at the destination, and deletes the
* partial output on any error so the caller never observes a corrupt
* file at the returned path.
*/
// fallow-ignore-next-line complexity
export async function downloadToFile(
url: string,
destPath: string,
options: DownloadOptions = {},
): Promise<DownloadResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const res = await fetchImpl(url, { signal: options.signal });
if (!res.ok) {
throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`);
}
if (!res.body) {
throw new Error(`Failed to download ${url}: empty response body`);
}
mkdirSync(dirname(destPath), { recursive: true });
const totalHeader = res.headers.get("content-length");
const total = totalHeader ? Number.parseInt(totalHeader, 10) : undefined;
const totalOpt = total !== undefined && Number.isFinite(total) ? total : undefined;
const file = createWriteStream(destPath);
let bytes = 0;
let errored = false;
try {
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error("Download aborted");
}
bytes += chunk.byteLength;
options.onProgress?.(bytes, totalOpt);
if (!file.write(chunk)) {
await waitForDrain(file, options.signal);
}
}
if (totalOpt !== undefined && bytes !== totalOpt) {
throw new Error(
`Truncated download: got ${bytes} bytes, expected ${totalOpt} (content-length). ` +
`The presigned URL may have expired mid-transfer — refetch via \`hyperframes cloud get\`.`,
);
}
} catch (err) {
errored = true;
throw err;
} finally {
await closeFile(file);
if (errored) {
// Don't let a partial file pose as the final artifact. Best-
// effort unlink — if it fails (already gone, permission), we
// re-throw the original error.
try {
unlinkSync(destPath);
} catch {
/* swallow */
}
}
}
return { path: destPath, bytes };
}
/**
* Resolve when the write stream emits `drain`, or reject on `error` /
* `close` / signal abort — avoids the hang from awaiting a one-shot
* `drain` event that never fires because the stream tore down first.
*/
function waitForDrain(file: NodeJS.WritableStream, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
file.off("drain", onDrain);
file.off("error", onError);
file.off("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
const onDrain = (): void => {
cleanup();
resolve();
};
const onError = (err: Error): void => {
cleanup();
reject(err);
};
const onClose = (): void => {
cleanup();
reject(new Error("write stream closed before drain"));
};
const onAbort = (): void => {
cleanup();
const reason = signal?.reason;
reject(reason instanceof Error ? reason : new Error("Download aborted"));
};
file.once("drain", onDrain);
file.once("error", onError);
file.once("close", onClose);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function closeFile(file: NodeJS.WritableStream): Promise<void> {
return new Promise<void>((resolve) => {
// Best-effort cleanup: any underlying failure has already been
// surfaced as the original throw from the for-await loop. We
// listen for `error` so a failing close (bad fd, late ENOSPC on
// flush) doesn't leak an unhandled 'error' onto the stream, and
// resolve either way so the finally block proceeds to unlinkSync.
const done = (): void => {
file.off("error", done);
resolve();
};
file.once("error", done);
file.end(() => done());
});
}
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// errorBox writes to the console; mock it so we can assert which title /
// message / third line the cascade picked without matching ANSI output.
vi.mock("../ui/format.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../ui/format.js")>()),
errorBox: vi.fn(),
}));
import { errorBox } from "../ui/format.js";
import { HyperframesApiError } from "./_gen/client.js";
import { reportApiError } from "./errors.js";
describe("cloud/errors reportApiError", () => {
// process.exit has signature `(code?) => never` which doesn't unify with
// vi.spyOn's inference; cast through `unknown` like cloud/parsing.test.ts.
let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } };
beforeEach(() => {
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy;
vi.mocked(errorBox).mockClear();
});
afterEach(() => {
exitSpy.mockRestore();
});
it("short-circuits a 404 to a Not found box when notFound is provided", () => {
const err = new HyperframesApiError({ status: 404, message: "missing" });
expect(() =>
reportApiError("Get failed", err, { notFound: "render hfr_x no longer exists" }),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Not found", "render hfr_x no longer exists");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("uses the code-specific hint when the error code is known", () => {
const err = new HyperframesApiError({
status: 429,
message: "slow down",
code: "rate_limit_exceeded",
});
expect(() => reportApiError("Submit failed", err)).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 429)",
"slow down",
"Retry after the duration in the Retry-After header.",
);
});
it("prefers the code-specific hint over a caller suggestion", () => {
const err = new HyperframesApiError({
status: 400,
message: "bad param",
code: "invalid_parameter",
});
expect(() => reportApiError("Submit failed", err, { suggestion: "see --help" })).toThrow(
"process.exit called",
);
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 400)",
"bad param",
"Check the listed parameter against `hyperframes cloud render --help` for the accepted values.",
);
});
it("falls back to the caller suggestion when the code has no hint", () => {
const err = new HyperframesApiError({
status: 500,
message: "boom",
code: "some_unmapped_code",
});
expect(() => reportApiError("List failed", err, { suggestion: "retry shortly" })).toThrow(
"process.exit called",
);
expect(errorBox).toHaveBeenCalledWith("List failed (HTTP 500)", "boom", "retry shortly");
});
it("falls back to a bare code label when there is no hint and no suggestion", () => {
const err = new HyperframesApiError({
status: 500,
message: "boom",
code: "some_unmapped_code",
});
expect(() => reportApiError("List failed", err)).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith(
"List failed (HTTP 500)",
"boom",
"code: some_unmapped_code",
);
});
it("omits the third line when there is no code, hint, or suggestion", () => {
const err = new HyperframesApiError({ status: 503, message: "unavailable" });
expect(() => reportApiError("Get failed", err)).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Get failed (HTTP 503)", "unavailable");
});
it("merges extraHints on top of the built-in table", () => {
const err = new HyperframesApiError({ status: 418, message: "teapot", code: "custom_code" });
expect(() =>
reportApiError("Brew failed", err, { extraHints: { custom_code: "use coffee instead" } }),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Brew failed (HTTP 418)", "teapot", "use coffee instead");
});
it("lets an extraHints entry override a built-in hint", () => {
const err = new HyperframesApiError({
status: 429,
message: "slow down",
code: "rate_limit_exceeded",
});
expect(() =>
reportApiError("Submit failed", err, {
extraHints: { rate_limit_exceeded: "custom backoff guidance" },
}),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 429)",
"slow down",
"custom backoff guidance",
);
});
it("reports a plain Error with the stage title and suggestion", () => {
expect(() =>
reportApiError("Upload failed", new Error("disk full"), { suggestion: "free up space" }),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Upload failed", "disk full", "free up space");
});
it("stringifies a non-Error thrown value", () => {
expect(() => reportApiError("Weird failure", "just a string")).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Weird failure", "just a string", undefined);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Shared API-error reporter for the cloud subverbs.
*
* Centralizes the three-branch `instanceof HyperframesApiError` → `Error`
* → `String` cascade so the curated `ERROR_CODE_HINTS` table is applied
* uniformly across `render`/`list`/`get`/`delete`. Without this, each
* subverb had to remember to consult the hint table (and most didn't,
* which is why review finding 10 was that `hyperframes_render_not_found`
* was unreachable from get/delete).
*/
import { errorBox } from "../ui/format.js";
import { HyperframesApiError } from "./_gen/client.js";
/**
* Hints surfaced when a HyperframesApiError carries a known machine-
* readable code. Keep entries actionable; if there's nothing useful to
* say, leave the code out and let the message stand on its own.
*/
const ERROR_CODE_HINTS: Record<string, string> = {
hyperframes_project_too_large:
"The zip exceeded the 200 MB limit. Trim large media (or pre-host them and reference by URL), then try again.",
hyperframes_render_not_found:
"The render_id no longer exists — either soft-deleted or never created.",
invalid_parameter:
"Check the listed parameter against `hyperframes cloud render --help` for the accepted values.",
authentication_failed:
"Run `hyperframes auth status` to confirm your credential; `hyperframes auth login` to re-auth.",
rate_limit_exceeded: "Retry after the duration in the Retry-After header.",
};
/**
* Print an errorBox and `process.exit(1)` for any unknown error from
* the cloud subverbs. The `stage` is the human-readable name of the
* step that failed (e.g. "Upload failed", "Submit failed", "Could not
* list cloud renders"). Returns `never` so call sites can `throw` from
* the catch block without a separate exit.
*
* Options:
* - `notFound`: short-circuit on a 404 with this friendly message
* (the render-id, asset-id, etc. that wasn't found).
* - `extraHints`: per-code overrides merged on top of
* `ERROR_CODE_HINTS`.
* - `suggestion`: a fallback line shown when no code-specific hint
* matches. Use this for caller-context that's always actionable
* (e.g. "Resume with: hyperframes cloud get hfr_X" on poll
* errors) so the user can recover without having to remember the
* render_id.
*/
// fallow-ignore-next-line complexity
export function reportApiError(
stage: string,
err: unknown,
options: {
notFound?: string;
extraHints?: Record<string, string>;
suggestion?: string;
} = {},
): never {
const hints = { ...ERROR_CODE_HINTS, ...options.extraHints };
if (err instanceof HyperframesApiError) {
if (err.status === 404 && options.notFound) {
errorBox("Not found", options.notFound);
process.exit(1);
}
const hint = err.code ? hints[err.code] : undefined;
const title = `${stage} (HTTP ${err.status})`;
// Priority: code-specific hint > caller suggestion > bare code
// label > no third line. Code-specific hints win because they
// address the specific failure mode; the caller suggestion is a
// generic-context fallback.
if (hint) {
errorBox(title, err.message, hint);
} else if (options.suggestion) {
errorBox(title, err.message, options.suggestion);
} else if (err.code) {
errorBox(title, err.message, `code: ${err.code}`);
} else {
errorBox(title, err.message);
}
process.exit(1);
}
if (err instanceof Error) {
errorBox(stage, err.message, options.suggestion);
process.exit(1);
}
errorBox(stage, String(err), options.suggestion);
process.exit(1);
}
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The 401-retry decorator calls forceRefreshCredentials() and the factory
// resolves base URL / auth headers from auth.js. Mock the module so the
// tests control the token lifecycle without touching the real credential
// store on disk.
vi.mock("./auth.js", () => ({
forceRefreshCredentials: vi.fn(),
resolveCloudAuthHeaders: vi.fn(),
resolveCloudBaseUrl: vi.fn(() => "https://cloud.test"),
}));
import { forceRefreshCredentials, resolveCloudAuthHeaders } from "./auth.js";
import { createCloudClient } from "./index.js";
import { HyperframesApiError } from "./_gen/client.js";
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
const ok = (body: unknown): Response => jsonResponse(200, body);
const unauthorized = (): Response =>
jsonResponse(401, { error: { message: "token revoked", code: "unauthorized" } });
// Narrow a recorded fetch call to the headers of its RequestInit without
// casting; throws (failing the test) if the call shape is unexpected.
const headersOf = (call: readonly unknown[] | undefined): unknown => {
const init = call?.[1];
if (init === null || init === undefined || typeof init !== "object" || !("headers" in init)) {
throw new Error("expected fetch to be called with a RequestInit carrying headers");
}
return init.headers;
};
describe("createCloudClient 401-retry decorator", () => {
// The generated client falls back to global fetch when no fetchImpl is
// injected, and createCloudClient doesn't expose that knob — stub the
// global so the decorator under test wraps the same client the cloud
// commands get.
let fetchMock: ReturnType<typeof vi.fn>;
let token: string;
beforeEach(() => {
token = "tok-old";
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.mocked(resolveCloudAuthHeaders).mockImplementation(async () => ({
authorization: `Bearer ${token}`,
}));
vi.mocked(forceRefreshCredentials).mockReset();
vi.mocked(forceRefreshCredentials).mockImplementation(async () => {
token = "tok-new";
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("passes a successful call through without refreshing", async () => {
fetchMock.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
const client = await createCloudClient();
const render = await client.getRender({ render_id: "hfr_1" });
expect(render).toEqual({ id: "hfr_1", status: "complete" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(forceRefreshCredentials).not.toHaveBeenCalled();
});
it("refreshes once on 401 and retries with the new token", async () => {
fetchMock
.mockResolvedValueOnce(unauthorized())
.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
const client = await createCloudClient();
const render = await client.getRender({ render_id: "hfr_1" });
expect(render).toEqual({ id: "hfr_1", status: "complete" });
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
// The retry must re-resolve credentials, not replay the stale header:
// a refresh that isn't picked up would 401 forever.
expect(headersOf(fetchMock.mock.calls[0])).toMatchObject({
authorization: "Bearer tok-old",
});
expect(headersOf(fetchMock.mock.calls[1])).toMatchObject({
authorization: "Bearer tok-new",
});
});
it("surfaces the original 401 when the refresh itself fails", async () => {
fetchMock.mockResolvedValueOnce(unauthorized());
vi.mocked(forceRefreshCredentials).mockRejectedValueOnce(new Error("refresh_token expired"));
const client = await createCloudClient();
const call = client.getRender({ render_id: "hfr_1" });
// The decorator promises to surface the 401, not the refresh error —
// the 401 carries the API's message/code, which reportApiError needs.
await expect(call).rejects.toMatchObject({
name: "HyperframesApiError",
status: 401,
message: "token revoked",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("retries exactly once: a second 401 propagates", async () => {
fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(unauthorized());
const client = await createCloudClient();
const call = client.getRender({ render_id: "hfr_1" });
await expect(call).rejects.toMatchObject({ status: 401 });
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not refresh on non-401 API errors", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(500, { error: { message: "internal", code: "internal_error" } }),
);
const client = await createCloudClient();
const call = client.listRenders({});
await expect(call).rejects.toBeInstanceOf(HyperframesApiError);
await expect(call).rejects.toMatchObject({ status: 500 });
expect(forceRefreshCredentials).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not refresh on transport errors that aren't HyperframesApiError", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed"));
const client = await createCloudClient();
const call = client.getRender({ render_id: "hfr_1" });
await expect(call).rejects.toThrow("fetch failed");
expect(forceRefreshCredentials).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Internal surface of the `cloud` module — only the symbols the `cloud`
* commands consume today. Don't add re-exports speculatively; SDK
* consumers can import directly from `_gen/client.js` or `_gen/types.js`
* if they need the broader generated surface.
*/
export { PollTimeoutError, pollUntilTerminal } from "./poll.js";
export { DEFAULT_MAX_WAIT_MS, DEFAULT_POLL_INTERVAL_MS } from "./poll.js";
export { downloadToFile } from "./download.js";
export type { HyperframesCloudClient } from "./_gen/client.js";
export type { CreateHyperframesRenderRequest, HyperframesRenderDetail } from "./_gen/types.js";
import { HyperframesApiError, HyperframesCloudClient } from "./_gen/client.js";
import { forceRefreshCredentials, resolveCloudAuthHeaders, resolveCloudBaseUrl } from "./auth.js";
/**
* Convenience factory that wires the generated client to the standard
* credential resolver and adds a 401-retry-with-refresh decorator.
*
* The decorator catches `HyperframesApiError(status=401)` thrown from
* any method on the client, force-refreshes the OAuth token, and
* retries the call exactly once. This mirrors `auth/client.ts`'s
* `onUnauthenticatedRefresh` behavior so server-side revocations or
* clock-skew rejections don't fail the cloud command outright when a
* refresh would have fixed them.
*/
export async function createCloudClient(): Promise<HyperframesCloudClient> {
const client = new HyperframesCloudClient({
baseUrl: resolveCloudBaseUrl(),
getAuthHeaders: resolveCloudAuthHeaders,
});
return wrapWith401Retry(client);
}
function wrapWith401Retry(client: HyperframesCloudClient): HyperframesCloudClient {
return new Proxy(client, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") return value;
// Bind so internal `this`-references in the generated client
// resolve back to the original instance, not the Proxy.
const original = value.bind(target) as (...args: unknown[]) => Promise<unknown>;
// Only wrap the public endpoint methods (return Promises). Don't
// gate on method name — the generated client has stable shape,
// and a future endpoint would otherwise be missed.
// fallow-ignore-next-line complexity
return async (...args: unknown[]): Promise<unknown> => {
try {
return await original(...args);
} catch (err) {
if (err instanceof HyperframesApiError && err.status === 401) {
// Best-effort refresh; if it fails, surface the original
// 401 not the refresh error.
try {
await forceRefreshCredentials();
} catch {
throw err;
}
return await original(...args);
}
throw err;
}
};
},
});
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseEnumFlag, parseIntFlag, parseNumericFlag } from "./parsing.js";
describe("cloud/parsing", () => {
// process.exit has signature `(code?) => never` which doesn't unify
// with vi.spyOn's mock-function inference; cast through `unknown` so
// the test compiles. The spy itself still records calls correctly.
let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } };
let errorSpy: { mockRestore: () => void };
beforeEach(() => {
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy;
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
exitSpy.mockRestore();
errorSpy.mockRestore();
});
describe("parseIntFlag", () => {
it("returns undefined when raw is undefined", () => {
expect(parseIntFlag(undefined, { flag: "--x" })).toBeUndefined();
});
it("parses a clean integer", () => {
expect(parseIntFlag("42", { flag: "--x" })).toBe(42);
});
it("rejects trailing garbage that Number.parseInt would silently accept", () => {
expect(() => parseIntFlag("10abc", { flag: "--x" })).toThrow("process.exit called");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("rejects decimals", () => {
expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow("process.exit called");
});
it("enforces min", () => {
expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow("process.exit called");
});
it("enforces max", () => {
expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow("process.exit called");
});
it("accepts negative integers when no min is set", () => {
expect(parseIntFlag("-5", { flag: "--x" })).toBe(-5);
});
});
describe("parseNumericFlag", () => {
it("parses decimals", () => {
expect(parseNumericFlag("1.5", { flag: "--x" })).toBe(1.5);
});
it("parses integers", () => {
expect(parseNumericFlag("10", { flag: "--x" })).toBe(10);
});
it("rejects trailing garbage that Number.parseFloat would silently accept", () => {
expect(() => parseNumericFlag("10seconds", { flag: "--x" })).toThrow("process.exit called");
});
it("rejects NaN", () => {
expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow(
"process.exit called",
);
});
});
describe("parseEnumFlag", () => {
it("accepts a known value", () => {
expect(parseEnumFlag("draft", ["draft", "standard", "high"], { flag: "--quality" })).toBe(
"draft",
);
});
it("rejects an unknown value", () => {
expect(() =>
parseEnumFlag("ultra", ["draft", "standard", "high"], { flag: "--quality" }),
).toThrow("process.exit called");
});
it("returns undefined when raw is undefined", () => {
expect(
parseEnumFlag(undefined, ["draft", "standard", "high"], { flag: "--quality" }),
).toBeUndefined();
});
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Strict numeric parsers for CLI flags.
*
* `Number.parseInt`/`Number.parseFloat` silently truncate trailing
* garbage ("10abc" → 10), which we don't want for user-facing flags
* like `--limit`, `--fps`, `--poll-interval`. These wrappers reject
* anything that isn't a complete numeric literal.
*/
import { errorBox } from "../ui/format.js";
const INTEGER_RE = /^-?\d+$/;
const NUMERIC_RE = /^-?\d+(\.\d+)?$/;
export interface IntFlagOptions {
flag: string;
min?: number;
max?: number;
}
/**
* Parse an integer flag, exit(1) with an errorBox on any non-integer
* input (including trailing garbage like "10abc" and decimals like
* "10.5"). Returns `undefined` when `raw === undefined` so callers can
* supply their own defaults.
*/
export function parseIntFlag(raw: string | undefined, opts: IntFlagOptions): number | undefined {
if (raw === undefined) return undefined;
if (!INTEGER_RE.test(raw)) {
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be an integer${rangeSuffix(opts)}.`);
process.exit(1);
}
const n = Number.parseInt(raw, 10);
enforceRange(opts.flag, raw, n, opts);
return n;
}
export interface FloatFlagOptions {
flag: string;
min?: number;
max?: number;
}
/**
* Parse a non-NaN finite numeric flag, exit(1) on any non-numeric
* input. Accepts integers and decimals.
*/
export function parseNumericFlag(
raw: string | undefined,
opts: FloatFlagOptions,
): number | undefined {
if (raw === undefined) return undefined;
if (!NUMERIC_RE.test(raw)) {
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a number${rangeSuffix(opts)}.`);
process.exit(1);
}
const n = Number.parseFloat(raw);
if (!Number.isFinite(n)) {
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a finite number.`);
process.exit(1);
}
enforceRange(opts.flag, raw, n, opts);
return n;
}
function enforceRange(
flag: string,
raw: string,
value: number,
bounds: { min?: number; max?: number },
): void {
if (bounds.min !== undefined && value < bounds.min) {
errorBox(`Invalid ${flag}`, `Got "${raw}". Minimum is ${bounds.min}.`);
process.exit(1);
}
if (bounds.max !== undefined && value > bounds.max) {
errorBox(`Invalid ${flag}`, `Got "${raw}". Maximum is ${bounds.max}.`);
process.exit(1);
}
}
/** Parse an enum-typed flag against a closed set of allowed values. */
export function parseEnumFlag<T extends string>(
raw: string | undefined,
allowed: readonly T[],
opts: { flag: string },
): T | undefined {
if (raw === undefined) return undefined;
if ((allowed as readonly string[]).includes(raw)) return raw as T;
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be one of: ${allowed.join(", ")}.`);
process.exit(1);
}
function rangeSuffix(opts: { min?: number; max?: number }): string {
if (opts.min !== undefined && opts.max !== undefined) return ` (${opts.min}-${opts.max})`;
if (opts.min !== undefined) return ` (>= ${opts.min})`;
if (opts.max !== undefined) return ` (<= ${opts.max})`;
return "";
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_MAX_WAIT_MS,
DEFAULT_POLL_INTERVAL_MS,
PollTimeoutError,
isTerminal,
pollUntilTerminal,
} from "./poll.js";
import type { HyperframesCloudClient } from "./_gen/client.js";
import type { HyperframesRenderDetail } from "./_gen/types.js";
function makeDetail(overrides: Partial<HyperframesRenderDetail>): HyperframesRenderDetail {
return {
render_id: "hfr_test",
status: "queued",
format: "mp4",
...overrides,
};
}
/** Build a stub client that returns the supplied details in order. */
function stubClient(details: HyperframesRenderDetail[]): HyperframesCloudClient {
const stack = [...details];
return {
async getRender() {
const next = stack.shift();
if (!next) throw new Error("ran out of stubbed responses");
return next;
},
} as unknown as HyperframesCloudClient;
}
describe("cloud/poll", () => {
describe("isTerminal", () => {
it("treats completed/failed as terminal", () => {
expect(isTerminal("completed")).toBe(true);
expect(isTerminal("failed")).toBe(true);
});
it("treats queued/rendering as non-terminal", () => {
expect(isTerminal("queued")).toBe(false);
expect(isTerminal("rendering")).toBe(false);
});
});
describe("defaults", () => {
it("matches the documented 10s / 60min defaults", () => {
expect(DEFAULT_POLL_INTERVAL_MS).toBe(10_000);
expect(DEFAULT_MAX_WAIT_MS).toBe(60 * 60 * 1000);
});
});
describe("pollUntilTerminal", () => {
it("returns immediately when the first poll is terminal", async () => {
const client = stubClient([makeDetail({ status: "completed" })]);
const sleep = vi.fn(async () => {});
const result = await pollUntilTerminal(client, "hfr_test", { sleep });
expect(result.status).toBe("completed");
expect(sleep).not.toHaveBeenCalled();
});
it("sleeps between non-terminal polls and returns on the terminal one", async () => {
const client = stubClient([
makeDetail({ status: "queued" }),
makeDetail({ status: "rendering" }),
makeDetail({ status: "completed" }),
]);
const sleep = vi.fn(async () => {});
const now = (() => {
let t = 0;
return () => {
t += 1000;
return t;
};
})();
const ticks: string[] = [];
const result = await pollUntilTerminal(client, "hfr_test", {
sleep,
now,
intervalMs: 5_000,
onTick: (d) => ticks.push(d.status),
});
expect(result.status).toBe("completed");
expect(ticks).toEqual(["queued", "rendering", "completed"]);
// Two sleeps: one after queued, one after rendering. None after the
// terminal completed response.
expect(sleep).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(5_000);
});
it("throws PollTimeoutError when elapsed exceeds maxWaitMs", async () => {
const client = stubClient([
makeDetail({ status: "queued" }),
makeDetail({ status: "rendering" }),
makeDetail({ status: "rendering" }),
]);
const sleep = vi.fn(async () => {});
// Each `now()` returns +500ms; total elapses past 1s on the second
// call, so maxWaitMs=1 triggers immediately.
const now = (() => {
let t = 0;
return () => {
t += 1000;
return t;
};
})();
await expect(
pollUntilTerminal(client, "hfr_test", {
sleep,
now,
intervalMs: 5_000,
maxWaitMs: 1,
}),
).rejects.toBeInstanceOf(PollTimeoutError);
});
it("aborts when the AbortSignal is fired", async () => {
const client = stubClient([makeDetail({ status: "queued" })]);
const controller = new AbortController();
controller.abort(new Error("user cancelled"));
await expect(
pollUntilTerminal(client, "hfr_test", { signal: controller.signal }),
).rejects.toThrow("user cancelled");
});
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Poll a HyperFrames render until it reaches a terminal state.
*
* Defaults: 10s interval, 60min cap — confirmed with the API owner. The
* `start_to_close` timeout on the underlying Temporal workflow is the
* same order of magnitude, so polling longer would only hide a stuck
* workflow rather than recover from one.
*
* Spinner output is silenced when stdout isn't a TTY (CI, piped output)
* so the JSON-emitting modes upstream don't get garbled.
*/
import type { HyperframesCloudClient } from "./_gen/client.js";
import type { HyperframesRenderDetail, HyperframesRenderStatus } from "./_gen/types.js";
export interface PollOptions {
intervalMs?: number;
maxWaitMs?: number;
/** Called once per tick with the latest render state. */
onTick?: (detail: HyperframesRenderDetail, elapsedMs: number) => void;
/** Inject a clock for tests. */
now?: () => number;
/** Inject a sleep for tests. */
sleep?: (ms: number) => Promise<void>;
signal?: AbortSignal;
}
export const DEFAULT_POLL_INTERVAL_MS = 10_000;
export const DEFAULT_MAX_WAIT_MS = 60 * 60 * 1000;
const TERMINAL_STATUSES: ReadonlySet<HyperframesRenderStatus> = new Set(["completed", "failed"]);
export class PollTimeoutError extends Error {
readonly lastDetail: HyperframesRenderDetail;
constructor(lastDetail: HyperframesRenderDetail, elapsedMs: number) {
super(
`Render ${lastDetail.render_id} did not reach a terminal state within ${Math.round(elapsedMs / 1000)}s`,
);
this.name = "PollTimeoutError";
this.lastDetail = lastDetail;
}
}
export function isTerminal(status: HyperframesRenderStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
/**
* Poll `GET /v3/hyperframes/renders/{id}` until status is `completed` or
* `failed`, or `maxWaitMs` elapses (in which case throws
* {@link PollTimeoutError}). Errors from the underlying request bubble
* up immediately — they are not retried, because every error class the
* API can return at this stage (404, 401, 5xx) is unlikely to recover
* on a retry inside the poll window.
*/
export async function pollUntilTerminal(
client: HyperframesCloudClient,
renderId: string,
options: PollOptions = {},
): Promise<HyperframesRenderDetail> {
const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
const now = options.now ?? (() => Date.now());
// Default sleep honors the abort signal so Ctrl+C feels immediate
// instead of waiting out the full interval. Tests inject a no-op
// sleep that ignores the signal — that's fine, they don't abort.
const sleep = options.sleep ?? defaultAbortableSleep(options.signal);
const started = now();
while (true) {
if (options.signal?.aborted) {
throw signalAbortError(options.signal);
}
const detail = await client.getRender({ render_id: renderId, signal: options.signal });
const elapsed = now() - started;
options.onTick?.(detail, elapsed);
if (isTerminal(detail.status)) {
return detail;
}
if (elapsed >= maxWaitMs) {
throw new PollTimeoutError(detail, elapsed);
}
await sleep(intervalMs);
}
}
function signalAbortError(signal: AbortSignal): Error {
const reason = signal.reason;
return reason instanceof Error ? reason : new Error("Poll aborted");
}
function defaultAbortableSleep(signal?: AbortSignal): (ms: number) => Promise<void> {
// fallow-ignore-next-line complexity
return (ms: number) =>
new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer);
reject(signalAbortError(signal!));
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Shared status-string colorizer used by `cloud list/get/render`.
* Extracted so changes to the color palette propagate atomically; without
* this, fallow flagged the repeated switch as duplication.
*/
import { c } from "../ui/colors.js";
import type { HyperframesRenderStatus } from "./_gen/types.js";
export function colorStatus(status: HyperframesRenderStatus | string): string {
switch (status) {
case "completed":
return c.success(status);
case "failed":
return c.error(status);
case "rendering":
return c.progress(status);
default:
return c.dim(status);
}
}
+234
View File
@@ -0,0 +1,234 @@
import { describe, it, expect, vi } from "vitest";
import { createHash } from "node:crypto";
import { HyperframesApiError } from "./_gen/client.js";
import type { HyperframesCloudClient } from "./_gen/client.js";
import { uploadZipViaDirectUpload } from "./upload.js";
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function makeClient(overrides: Partial<HyperframesCloudClient> = {}): HyperframesCloudClient {
return {
createAssetUpload: vi.fn(async () => ({
asset_id: "asset_xyz",
upload_url: "https://s3.example/asset_xyz?sig=abc",
upload_headers: { "x-amz-checksum-sha256-b64": "..." },
expires_in_seconds: 3600,
max_bytes: 200 * 1024 * 1024,
status: "pending_upload" as const,
})),
completeAssetUpload: vi.fn(async () => ({
asset_id: "asset_xyz",
url: "https://files.heygen.com/document/asset_xyz/original.zip",
mime_type: "application/zip",
size_bytes: 42,
status: "processing" as const,
})),
...overrides,
} as unknown as HyperframesCloudClient;
}
function makeFetchOk(): typeof fetch {
return vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch;
}
describe("uploadZipViaDirectUpload", () => {
it("sends the correct filename, content_type, size_bytes, and SHA256 to createAssetUpload", async () => {
const bytes = new TextEncoder().encode("hello-hyperframes-zip");
const expectedSha = sha256Hex(bytes);
const client = makeClient();
const fetchImpl = makeFetchOk();
await uploadZipViaDirectUpload({
client,
bytes,
filename: "my-comp.zip",
fetchImpl,
});
expect(client.createAssetUpload).toHaveBeenCalledOnce();
const arg = (client.createAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(arg.body).toEqual({
filename: "my-comp.zip",
content_type: "application/zip",
size_bytes: bytes.byteLength,
checksum_sha256: expectedSha,
});
});
it("PUTs to the returned upload_url with the returned upload_headers verbatim + content-type", async () => {
const bytes = new Uint8Array([1, 2, 3, 4]);
const client = makeClient({
createAssetUpload: vi.fn(async () => ({
asset_id: "asset_xyz",
upload_url: "https://s3.example/target?sig=xxx",
upload_headers: { "x-signed-header": "signed-value", "x-other": "other-value" },
expires_in_seconds: 3600,
max_bytes: 200 * 1024 * 1024,
status: "pending_upload" as const,
})) as HyperframesCloudClient["createAssetUpload"],
});
const fetchImpl = makeFetchOk();
await uploadZipViaDirectUpload({ client, bytes, filename: "x.zip", fetchImpl });
expect(fetchImpl).toHaveBeenCalledOnce();
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(url).toBe("https://s3.example/target?sig=xxx");
expect(init.method).toBe("PUT");
expect(init.body).toBe(bytes);
expect(init.headers).toEqual({
"content-type": "application/zip",
"x-signed-header": "signed-value",
"x-other": "other-value",
});
});
it("does NOT attach CLI auth headers to the S3 PUT — presigned URL carries auth", async () => {
const client = makeClient();
const fetchImpl = makeFetchOk();
await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl,
});
const init = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0]![1];
// No Authorization / x-api-key / Bearer header should be present.
const headerKeys = Object.keys(init.headers as Record<string, string>).map((k) =>
k.toLowerCase(),
);
expect(headerKeys).not.toContain("authorization");
expect(headerKeys).not.toContain("x-api-key");
});
it("calls completeAssetUpload with the initialize's asset_id + same checksum", async () => {
const bytes = new TextEncoder().encode("determinism");
const expectedSha = sha256Hex(bytes);
const client = makeClient();
const fetchImpl = makeFetchOk();
const result = await uploadZipViaDirectUpload({
client,
bytes,
filename: "x.zip",
fetchImpl,
});
expect(client.completeAssetUpload).toHaveBeenCalledOnce();
const completeArg = (client.completeAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(completeArg.asset_id).toBe("asset_xyz");
expect(completeArg.body).toEqual({ checksum_sha256: expectedSha });
expect(result.asset_id).toBe("asset_xyz");
expect(result.size_bytes).toBe(bytes.byteLength);
});
it("retries completeAssetUpload on 409 and succeeds on a later attempt", async () => {
let completeCalls = 0;
const completeAssetUpload = vi.fn(async () => {
completeCalls++;
if (completeCalls < 3) {
throw new HyperframesApiError({
status: 409,
message: "Uploaded object not found yet. Retry after upload PUT returns 200.",
code: "conflict",
});
}
return {
asset_id: "asset_xyz",
url: "u",
mime_type: "application/zip",
size_bytes: 1,
status: "processing" as const,
};
});
const client = makeClient({
completeAssetUpload:
completeAssetUpload as unknown as HyperframesCloudClient["completeAssetUpload"],
});
const result = await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl: makeFetchOk(),
});
expect(completeCalls).toBe(3);
expect(result.asset_id).toBe("asset_xyz");
});
it("surfaces non-409 errors from complete without retrying", async () => {
let completeCalls = 0;
const completeAssetUpload = vi.fn(async () => {
completeCalls++;
throw new HyperframesApiError({
status: 400,
message: "invalid checksum",
code: "invalid_parameter",
});
});
const client = makeClient({
completeAssetUpload:
completeAssetUpload as unknown as HyperframesCloudClient["completeAssetUpload"],
});
await expect(
uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl: makeFetchOk(),
}),
).rejects.toThrow(/invalid checksum/);
expect(completeCalls).toBe(1);
});
it("surfaces PUT failures with response body detail", async () => {
const client = makeClient();
const fetchImpl = vi.fn(
async () =>
new Response("SignatureDoesNotMatch: request signature we calculated does not match", {
status: 403,
}),
) as unknown as typeof fetch;
await expect(
uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl,
}),
).rejects.toThrow(/Direct upload PUT failed: 403.*SignatureDoesNotMatch/);
});
it("passes idempotencyKey through to createAssetUpload", async () => {
const client = makeClient();
await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
idempotencyKey: "test-key-123",
fetchImpl: makeFetchOk(),
});
const arg = (client.createAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(arg.idempotencyKey).toBe("test-key-123");
});
it("emits progress events in order", async () => {
const client = makeClient();
const events: Array<{ phase: string }> = [];
await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl: makeFetchOk(),
onProgress: (e) => events.push({ phase: e.phase }),
});
expect(events.map((e) => e.phase)).toEqual(["initialize", "upload", "upload", "complete"]);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Direct-upload flow for `hyperframes cloud render` project asset uploads.
*
* The legacy `POST /v3/assets` path proxies bytes through the API and is
* capped at 32 MB in-memory. This module implements the three-step
* direct-to-S3 flow the CLI now uses instead, lifting the practical
* per-project ceiling to 200 MB (the direct-upload cap enforced by the
* signed presigned URL):
*
* 1. `client.createAssetUpload({filename, content_type, size_bytes,
* checksum_sha256})` → returns `{asset_id, upload_url,
* upload_headers, expires_in_seconds, max_bytes}`.
* 2. Raw `PUT` to `upload_url` with the zip bytes + `upload_headers`
* verbatim. No CLI auth headers on this call — the presigned URL
* signature carries authorization.
* 3. `client.completeAssetUpload({asset_id, body: {checksum_sha256}})`
* to finalize. Docs explicitly note a 409 "Uploaded object not
* found yet" is possible if the PUT hasn't been fully committed
* server-side; a small retry loop absorbs that.
*
* The returned `asset_id` is the same id namespace the legacy path
* produced, so `createRender({project: {type: "asset_id", asset_id}})`
* on the render side is a drop-in swap.
*/
import { createHash } from "node:crypto";
import type { HyperframesCloudClient } from "./_gen/client.js";
import { HyperframesApiError } from "./_gen/client.js";
export interface UploadZipViaDirectResult {
asset_id: string;
size_bytes: number;
duration_ms: number;
}
export type UploadProgressEvent =
| { phase: "initialize" }
| { phase: "upload"; percent: number }
| { phase: "complete" };
export interface UploadZipViaDirectOptions {
client: HyperframesCloudClient;
bytes: Uint8Array;
filename: string;
idempotencyKey?: string;
fetchImpl?: typeof fetch;
onProgress?: (event: UploadProgressEvent) => void;
}
// Per api-docs: completeAssetUpload can return 409 "Uploaded object not found
// yet" if the S3 PUT's write hasn't propagated to the read plane by the time
// complete runs. Small backoff loop absorbs it.
const COMPLETE_MAX_RETRIES = 5;
const COMPLETE_RETRY_BASE_MS = 500;
const CONTENT_TYPE_ZIP = "application/zip";
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// upload_headers is generated as `Record<string, unknown>` from the
// OpenAPI spec, but the values are always strings at runtime (HTTP header
// values). Coerce defensively so a spec quirk can't slip a non-string in.
function normalizeUploadHeaders(raw: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw ?? {})) {
if (typeof v === "string") out[k] = v;
else if (typeof v === "number" || typeof v === "boolean") out[k] = String(v);
// Skip anything else — better to omit than send `[object Object]`.
}
return out;
}
// PUT bytes to the presigned URL. Do NOT attach CLI auth headers; the
// presigned URL signature carries authorization and the signature is
// bound to the *headers signed at presign time* — extra headers invalidate
// the signature (S3 returns 403). Content-Type must match the declared
// content_type from step 1 (also signed).
async function putBytesToPresignedUrl(
fetchImpl: typeof fetch,
uploadUrl: string,
uploadHeaders: Record<string, unknown>,
bytes: Uint8Array,
): Promise<void> {
const headers: Record<string, string> = {
"content-type": CONTENT_TYPE_ZIP,
...normalizeUploadHeaders(uploadHeaders),
};
// `Uint8Array<ArrayBufferLike>` is a valid `BodyInit` at runtime but
// not strictly assignable per lib.dom.d.ts — cast rather than copy,
// since a 200MB buffer copy would be wasteful.
const res = await fetchImpl(uploadUrl, {
method: "PUT",
headers,
body: bytes as unknown as BodyInit,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`Direct upload PUT failed: ${res.status} ${res.statusText}${
detail ? `${detail.slice(0, 300)}` : ""
}`,
);
}
}
// Complete with retry-on-409. Retry ONLY on the documented "PUT not
// visible yet" race between S3 write consistency and finalize; any other
// error surfaces immediately. `completeAssetUpload` itself is idempotent,
// so retrying an already-succeeded call is safe.
async function completeWithRetry(
client: HyperframesCloudClient,
asset_id: string,
checksum_sha256: string,
): Promise<{ asset_id: string }> {
let lastErr: unknown;
for (let attempt = 0; attempt < COMPLETE_MAX_RETRIES; attempt++) {
try {
return await client.completeAssetUpload({
asset_id,
body: { checksum_sha256 },
});
} catch (err) {
const retryable = err instanceof HyperframesApiError && err.status === 409;
if (!retryable || attempt === COMPLETE_MAX_RETRIES - 1) {
throw err;
}
lastErr = err;
await sleep(COMPLETE_RETRY_BASE_MS * (attempt + 1));
}
}
throw lastErr ?? new Error("completeAssetUpload retries exhausted");
}
export async function uploadZipViaDirectUpload(
opts: UploadZipViaDirectOptions,
): Promise<UploadZipViaDirectResult> {
const start = Date.now();
const { bytes, filename, idempotencyKey } = opts;
const size_bytes = bytes.byteLength;
const checksum_sha256 = sha256Hex(bytes);
const fetchImpl = opts.fetchImpl ?? fetch;
opts.onProgress?.({ phase: "initialize" });
const initialize = await opts.client.createAssetUpload({
body: { filename, content_type: CONTENT_TYPE_ZIP, size_bytes, checksum_sha256 },
idempotencyKey,
});
opts.onProgress?.({ phase: "upload", percent: 0 });
await putBytesToPresignedUrl(fetchImpl, initialize.upload_url, initialize.upload_headers, bytes);
opts.onProgress?.({ phase: "upload", percent: 100 });
opts.onProgress?.({ phase: "complete" });
const completed = await completeWithRetry(opts.client, initialize.asset_id, checksum_sha256);
return {
asset_id: completed.asset_id,
size_bytes,
duration_ms: Date.now() - start,
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Shared type for CLI command examples.
* Each command file exports `examples` using this type.
* help.ts dynamically imports them at --help time.
*/
export type Example = [comment: string, command: string];
+389
View File
@@ -0,0 +1,389 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
import { AddError, buildSnippet, remapTarget, runAdd } from "./add.js";
// ── Fixtures ────────────────────────────────────────────────────────────────
const MANIFEST: RegistryManifest = {
$schema: "https://hyperframes.heygen.com/schema/registry.json",
name: "test",
homepage: "https://example.com",
items: [
{ name: "my-block", type: "hyperframes:block" },
{ name: "deprecated-block", type: "hyperframes:block" },
{ name: "future-block", type: "hyperframes:block" },
{ name: "dep-block", type: "hyperframes:block" },
{ name: "base-component", type: "hyperframes:component" },
{ name: "my-component", type: "hyperframes:component" },
{ name: "my-example", type: "hyperframes:example" },
],
};
const BLOCK_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "my-block",
type: "hyperframes:block",
title: "My Block",
description: "Block for tests",
dimensions: { width: 1080, height: 1350 },
duration: 6,
files: [
{
path: "my-block.html",
target: "compositions/my-block.html",
type: "hyperframes:composition",
},
],
};
const COMPONENT_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "my-component",
type: "hyperframes:component",
title: "My Component",
description: "Component for tests",
files: [
{
path: "my-component.html",
target: "compositions/components/my-component/my-component.html",
type: "hyperframes:snippet",
},
{
path: "my-component.css",
target: "compositions/components/my-component/my-component.css",
type: "hyperframes:style",
},
{
path: "assets/mask.png",
target: "assets/my-component/mask.png",
type: "hyperframes:asset",
},
],
};
const DEPRECATED_BLOCK_ITEM: RegistryItem = {
...BLOCK_ITEM,
name: "deprecated-block",
title: "Deprecated Block",
deprecated: "Use `my-block` instead.",
files: [
{
path: "deprecated-block.html",
target: "compositions/deprecated-block.html",
type: "hyperframes:composition",
},
],
};
const FUTURE_BLOCK_ITEM: RegistryItem = {
...BLOCK_ITEM,
name: "future-block",
title: "Future Block",
minCliVersion: "999.0.0",
files: [
{
path: "future-block.html",
target: "compositions/future-block.html",
type: "hyperframes:composition",
},
],
};
const BASE_COMPONENT_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "base-component",
type: "hyperframes:component",
title: "Base Component",
description: "Base component dependency for tests",
files: [
{
path: "base-component.css",
target: "compositions/components/base-component/base-component.css",
type: "hyperframes:style",
},
],
};
// A block that declares a transitive registryDependency on base-component.
const DEP_BLOCK_ITEM: RegistryItem = {
...BLOCK_ITEM,
name: "dep-block",
title: "Dependent Block",
registryDependencies: ["base-component"],
files: [
{
path: "dep-block.html",
target: "compositions/dep-block.html",
type: "hyperframes:composition",
},
],
};
const EXAMPLE_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "my-example",
type: "hyperframes:example",
title: "My Example",
description: "Example for tests",
dimensions: { width: 1920, height: 1080 },
duration: 10,
files: [{ path: "index.html", target: "index.html", type: "hyperframes:composition" }],
};
const ITEM_BY_NAME: Record<string, RegistryItem> = {
"my-block": BLOCK_ITEM,
"deprecated-block": DEPRECATED_BLOCK_ITEM,
"future-block": FUTURE_BLOCK_ITEM,
"dep-block": DEP_BLOCK_ITEM,
"base-component": BASE_COMPONENT_ITEM,
"my-component": COMPONENT_ITEM,
"my-example": EXAMPLE_ITEM,
};
function mockFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/registry.json")) {
return new Response(JSON.stringify(MANIFEST), { status: 200 });
}
const m = /\/(examples|blocks|components)\/([^/]+)\/registry-item\.json$/.exec(url);
if (m) {
const item = ITEM_BY_NAME[m[2]!];
if (item) return new Response(JSON.stringify(item), { status: 200 });
}
// File fetch — match `/<type-dir>/<name>/<rest>` and serve synthetic content.
const f = /\/(examples|blocks|components)\/([^/]+)\/(.+)$/.exec(url);
if (f) {
return new Response(`/* ${f[3]} */\n`, { status: 200 });
}
return new Response("not found", { status: 404 });
}),
);
}
function tmp(): string {
return mkdtempSync(join(tmpdir(), "hf-add-test-"));
}
function uniqueBase(): string {
return `https://test.invalid/${crypto.randomUUID()}`;
}
const DEFAULT_TEST_PATHS = {
blocks: "compositions",
components: "compositions/components",
assets: "assets",
};
function writeRegistryConfig(
dir: string,
paths: typeof DEFAULT_TEST_PATHS = DEFAULT_TEST_PATHS,
): void {
writeFileSync(
join(dir, "hyperframes.json"),
JSON.stringify({
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
registry: uniqueBase(),
paths,
}),
"utf-8",
);
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("add command pure helpers", () => {
describe("remapTarget", () => {
const PATHS = { blocks: "src/scenes", components: "src/fx" };
it("rewrites block default path to paths.blocks", () => {
expect(remapTarget(BLOCK_ITEM, "compositions/my-block.html", PATHS)).toBe(
"src/scenes/my-block.html",
);
});
it("rewrites component default path to paths.components", () => {
expect(
remapTarget(
COMPONENT_ITEM,
"compositions/components/my-component/my-component.html",
PATHS,
),
).toBe("src/fx/my-component/my-component.html");
});
it("leaves example targets alone", () => {
expect(remapTarget(EXAMPLE_ITEM, "index.html", PATHS)).toBe("index.html");
});
it("leaves non-default block paths alone (no blind string replace)", () => {
// A block's manifest could in future use a non-default target — make
// sure the prefix match is anchored.
expect(remapTarget(BLOCK_ITEM, "elsewhere/my-block.html", PATHS)).toBe(
"elsewhere/my-block.html",
);
});
});
describe("buildSnippet", () => {
it("wraps blocks in a div with data-composition-src and duration", () => {
const snip = buildSnippet(BLOCK_ITEM, "src/scenes/my-block.html");
expect(snip).toContain('data-composition-src="src/scenes/my-block.html"');
expect(snip).toContain('data-duration="6"');
});
it("emits a paste hint for components", () => {
const snip = buildSnippet(COMPONENT_ITEM, "src/fx/my-component/my-component.html");
expect(snip).toContain("paste from");
expect(snip).toContain("my-component.html");
});
it("returns empty string for examples", () => {
expect(buildSnippet(EXAMPLE_ITEM, "index.html")).toBe("");
});
});
});
describe("runAdd (integration, mocked registry)", () => {
beforeEach(() => mockFetch());
afterEach(() => {
vi.unstubAllGlobals();
});
it("installs a block into the default compositions/ path and returns the snippet", async () => {
const dir = tmp();
try {
// Write hyperframes.json so runAdd uses our unique baseUrl.
writeRegistryConfig(dir);
const result = await runAdd({ name: "my-block", projectDir: dir, skipClipboard: true });
expect(result.ok).toBe(true);
expect(result.name).toBe("my-block");
expect(result.type).toBe("hyperframes:block");
expect(result.written).toHaveLength(1);
expect(result.installed).toEqual(["my-block"]);
expect(result.warnings).toEqual([]);
expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true);
const installed = readFileSync(join(dir, "compositions/my-block.html"), "utf-8");
expect(installed).toContain("<!-- hyperframes-registry-item: my-block -->");
expect(installed).toContain("my-block.html");
expect(result.snippet).toContain("compositions/my-block.html");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns a warning for deprecated registry items while still installing", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);
const result = await runAdd({
name: "deprecated-block",
projectDir: dir,
skipClipboard: true,
});
expect(result.warnings).toEqual([
'Registry item "deprecated-block" is deprecated: Use `my-block` instead.',
]);
expect(existsSync(join(dir, "compositions/deprecated-block.html"))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("blocks registry items that require a newer CLI before writing files", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);
await expect(
runAdd({
name: "future-block",
projectDir: dir,
skipClipboard: true,
cliVersion: "0.6.79",
}),
).rejects.toMatchObject({
code: "incompatible-cli",
});
expect(existsSync(join(dir, "compositions/future-block.html"))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("remaps component snippet/style targets while leaving asset targets stable", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir, { blocks: "compositions", components: "src/fx", assets: "assets" });
const result = await runAdd({
name: "my-component",
projectDir: dir,
skipClipboard: true,
});
expect(result.written.length).toBe(3);
expect(existsSync(join(dir, "src/fx/my-component/my-component.html"))).toBe(true);
expect(existsSync(join(dir, "src/fx/my-component/my-component.css"))).toBe(true);
expect(existsSync(join(dir, "assets/my-component/mask.png"))).toBe(true);
expect(result.snippet).toContain("src/fx/my-component/my-component.html");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("installs transitive registryDependencies before the requested item", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);
const result = await runAdd({ name: "dep-block", projectDir: dir, skipClipboard: true });
expect(result.name).toBe("dep-block");
// Dependency first, requested item last.
expect(result.installed).toEqual(["base-component", "dep-block"]);
expect(result.written).toHaveLength(2);
expect(
existsSync(join(dir, "compositions/components/base-component/base-component.css")),
).toBe(true);
expect(existsSync(join(dir, "compositions/dep-block.html"))).toBe(true);
// Snippet points at the requested block, not the dependency.
expect(result.snippet).toContain("compositions/dep-block.html");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("throws AddError with code 'example-type' when asked to add an example", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);
await expect(
runAdd({ name: "my-example", projectDir: dir, skipClipboard: true }),
).rejects.toMatchObject({
code: "example-type",
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("throws AddError with code 'unknown-item' for a missing name", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);
await expect(
runAdd({ name: "nope", projectDir: dir, skipClipboard: true }),
).rejects.toBeInstanceOf(AddError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+357
View File
@@ -0,0 +1,357 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
["Add a block to the current project", "hyperframes add claude-code-window"],
["Add a component effect", "hyperframes add shader-wipe"],
["Add all HTML-in-Canvas blocks", "hyperframes add html-in-canvas"],
["Add all caption blocks", "hyperframes add captions"],
["Target a specific project directory", "hyperframes add shader-wipe --dir ./my-video"],
["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"],
];
import { existsSync } from "node:fs";
import { resolve, relative } from "node:path";
import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core";
import { c } from "../ui/colors.js";
import { installItem, resolveItemsByTag } from "../registry/index.js";
import { resolveItemWithDependencies } from "../registry/resolver.js";
import {
gateRegistryItemsCompatibility,
RegistryCompatibilityError,
} from "../registry/compatibility.js";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
projectConfigPath,
writeProjectConfig,
} from "../utils/projectConfig.js";
import { copyToClipboard } from "../utils/clipboard.js";
// ── Target-path resolution ──────────────────────────────────────────────────
// `registry-item.json` files specify `target` paths relative to the project
// root. For blocks and components we override the default path with the
// user's `hyperframes.json#paths` so a project can reshape its layout
// without editing every item's manifest.
export function remapTarget(
item: RegistryItem,
originalTarget: string,
paths: { blocks: string; components: string },
): string {
if (item.type === "hyperframes:block") {
// Anchored to the default target prefix from DEFAULT_PROJECT_CONFIG.paths.blocks.
// Targets that don't start with "compositions/" pass through unchanged.
// Strip trailing slashes to prevent double-slash in output.
const blocksDir = paths.blocks.replace(/\/+$/, "");
return originalTarget.replace(/^compositions\//, `${blocksDir}/`);
}
if (item.type === "hyperframes:component") {
// Anchored to the default target prefix from DEFAULT_PROJECT_CONFIG.paths.components.
const componentsDir = paths.components.replace(/\/+$/, "");
return originalTarget.replace(/^compositions\/components\//, `${componentsDir}/`);
}
// Examples are installed by `init`, not `add` — no remapping.
return originalTarget;
}
// ── Include-snippet builders ────────────────────────────────────────────────
// Shown to the user after install so they know how to wire the item into
// their host composition. Copied to clipboard by default.
export function buildSnippet(item: RegistryItem, relativeTarget: string): string {
if (item.type === "hyperframes:block") {
// data-start omitted — adjust to your timeline position after pasting.
const dims =
"dimensions" in item && item.dimensions
? ` data-width="${item.dimensions.width}" data-height="${item.dimensions.height}"`
: "";
return `<div data-composition-src="${relativeTarget}" data-duration="${item.duration}"${dims}></div>`;
}
if (item.type === "hyperframes:component") {
return `<!-- paste from ${relativeTarget} into your composition -->`;
}
return "";
}
// ── Core runner (tested) ────────────────────────────────────────────────────
export interface RunAddArgs {
name: string;
projectDir: string;
skipClipboard?: boolean;
/** Current CLI version used for registry metadata compatibility checks. */
cliVersion?: string;
}
export interface RunAddResult {
ok: true;
name: string;
type: RegistryItem["type"];
typeDir: string;
written: string[];
/** Names of every item installed, in order — dependencies first, then `name`. */
installed: string[];
snippet: string;
clipboardCopied: boolean;
warnings: string[];
}
export class AddError extends Error {
constructor(
message: string,
public readonly code:
| "unknown-item"
| "wrong-type"
| "install-failed"
| "example-type"
| "incompatible-cli",
) {
super(message);
this.name = "AddError";
}
}
// Compatibility-gate a set of resolved items before any install runs, mapping
// the shared gate's error into an AddError so the command surfaces the right
// exit code. Returns the accumulated (non-fatal) warnings from every item.
function assertCompatibleOrThrow(items: RegistryItem[], cliVersion?: string): string[] {
try {
return gateRegistryItemsCompatibility(items, cliVersion);
} catch (err) {
if (err instanceof RegistryCompatibilityError) {
throw new AddError(err.message, "incompatible-cli");
}
throw err;
}
}
// Install a topologically-ordered plan (dependencies first, requested item
// last). The installer validates every target before any write; a failure on
// any item surfaces as an install-failed AddError. Returns all written paths.
async function installAll(
installPlan: RegistryItem[],
destDir: string,
baseUrl: string | undefined,
): Promise<string[]> {
const written: string[] = [];
try {
for (const planItem of installPlan) {
const result = await installItem(planItem, { destDir, baseUrl });
written.push(...result.written);
}
} catch (err) {
throw new AddError(
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
"install-failed",
);
}
return written;
}
export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
const projectDir = resolve(opts.projectDir);
// 1. Load (or write default) project config.
let config = loadProjectConfig(projectDir);
const hasConfig = existsSync(projectConfigPath(projectDir));
if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) {
writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
config = DEFAULT_PROJECT_CONFIG;
}
// 2. Resolve the requested item and its transitive registryDependencies.
// The list comes back topologically sorted: dependencies first, the
// requested item last.
let resolved: RegistryItem[];
try {
resolved = await resolveItemWithDependencies(opts.name, { baseUrl: config.registry });
} catch (err) {
throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item");
}
// `resolveItemWithDependencies` always pushes the requested item last (or throws),
// so the final element is the item the user asked for.
const item = resolved[resolved.length - 1]!;
if (item.type === "hyperframes:example") {
throw new AddError(
`"${item.name}" is an example — use \`hyperframes init <dir> --example ${item.name}\` instead.`,
"example-type",
);
}
// 3. Compatibility-gate every item we're about to install (dependencies
// included) before writing anything.
const warnings = assertCompatibleOrThrow(resolved, opts.cliVersion);
// 4. Remap targets per project config — each item by its own type.
const installPlan: RegistryItem[] = resolved.map((resolvedItem) => ({
...resolvedItem,
files: resolvedItem.files.map((f) => ({
...f,
target: remapTarget(resolvedItem, f.target, config.paths),
})),
}));
// 5. Install — dependencies first, requested item last.
const written = await installAll(installPlan, projectDir, config.registry);
// 6. Build include snippet + clipboard copy for the requested item.
const itemForInstall = installPlan[installPlan.length - 1]!;
const primaryFile =
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
itemForInstall.files[0];
const snippetTargetRel = primaryFile?.target ?? "";
const snippet = buildSnippet(item, snippetTargetRel);
const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false;
return {
ok: true,
name: item.name,
type: item.type,
typeDir: ITEM_TYPE_DIRS[item.type],
written,
installed: installPlan.map((planItem) => planItem.name),
snippet,
clipboardCopied,
warnings,
};
}
// ── Command ─────────────────────────────────────────────────────────────────
export default defineCommand({
meta: {
name: "add",
description: "Install a block or component from the registry into this project",
},
args: {
name: {
type: "positional",
description:
"Registry item name or tag. Single items install directly (e.g. shader-wipe). " +
"If the name matches a tag instead, all blocks with that tag are installed (e.g. html-in-canvas, captions).",
required: true,
},
dir: {
type: "string",
description: "Project directory (defaults to the current working directory)",
},
clipboard: {
// Declared as the positive `clipboard` (default on) so citty's built-in
// `--no-<name>` negation handles `--no-clipboard`. Declaring the arg
// literally as `"no-clipboard"` made citty parse `--no-clipboard` as the
// negation of a (nonexistent) `clipboard` arg, so assertKnownFlags threw
// "Unknown flag: --clipboard" even though --help advertised it.
type: "boolean",
default: true,
description: "Copy the include snippet to the clipboard (use --no-clipboard to skip)",
},
json: {
type: "boolean",
description: "Print a machine-readable summary (written files + snippet) to stdout",
},
},
async run({ args }) {
const projectDir = resolve(args.dir ?? process.cwd());
const json = args.json === true;
const skipClipboard = args.clipboard === false;
const hasConfigBefore = existsSync(projectConfigPath(projectDir));
// Try single item first. If it fails, check if the name matches a tag.
try {
const result = await runAdd({ name: args.name, projectDir, skipClipboard });
const wroteConfig = !hasConfigBefore && existsSync(projectConfigPath(projectDir));
if (json) {
console.log(JSON.stringify(result));
return;
}
if (wroteConfig) {
console.log(c.dim(`Wrote default ${projectConfigPath(projectDir)}`));
}
for (const warning of result.warnings) {
console.warn(c.warn(`Warning: ${warning}`));
}
console.log("");
console.log(`${c.success("✓")} Added ${c.accent(result.name)} (${result.type})`);
for (const file of result.written) {
console.log(` ${c.dim(relative(projectDir, file))}`);
}
if (result.snippet) {
console.log("");
console.log(c.dim("Include snippet:"));
console.log(` ${result.snippet}`);
console.log("");
console.log(
result.clipboardCopied
? c.dim("Copied to clipboard — paste into your host composition.")
: c.dim("Paste the snippet above into your host composition."),
);
}
} catch (singleErr) {
// Not a single item — try as a tag for bulk install
if (!(singleErr instanceof AddError) || singleErr.code !== "unknown-item") {
const msg = singleErr instanceof Error ? singleErr.message : String(singleErr);
if (json) console.log(JSON.stringify({ ok: false, error: msg }));
else console.error(c.error(msg));
process.exit(1);
}
let config = loadProjectConfig(projectDir);
if (
!existsSync(projectConfigPath(projectDir)) &&
existsSync(resolve(projectDir, "index.html"))
) {
writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
config = DEFAULT_PROJECT_CONFIG;
}
let items: Awaited<ReturnType<typeof resolveItemsByTag>>;
try {
items = await resolveItemsByTag(args.name, { baseUrl: config.registry, skipCache: true });
} catch {
items = [];
}
if (items.length === 0) {
const msg = singleErr instanceof Error ? singleErr.message : String(singleErr);
if (json) console.log(JSON.stringify({ ok: false, error: msg }));
else console.error(c.error(msg));
process.exit(1);
}
if (!json) {
console.log("");
console.log(
`${c.accent("◆")} Installing ${c.accent(String(items.length))} blocks tagged ${c.accent(args.name)}`,
);
}
const results: RunAddResult[] = [];
for (const item of items) {
try {
const result = await runAdd({ name: item.name, projectDir, skipClipboard: true });
results.push(result);
for (const warning of result.warnings) {
if (!json) console.log(` ${c.warn("Warning:")} ${warning}`);
}
if (!json) console.log(` ${c.success("✓")} ${result.name}`);
} catch {
if (!json) console.log(` ${c.error("✗")} ${item.name} (skipped)`);
}
}
if (json) {
console.log(
JSON.stringify({ ok: true, tag: args.name, installed: results.map((r) => r.name) }),
);
} else {
console.log("");
console.log(`${c.success("✓")} Installed ${results.length}/${items.length} blocks`);
}
}
},
});
+58
View File
@@ -0,0 +1,58 @@
/**
* `hyperframes auth` — credential management for HeyGen.
*
* Subverbs:
* - `login` sign in via API key (OAuth coming next)
* - `status` show the active credential + identity
* - `logout` remove the stored credential
*
* Each subverb lives in `./auth/<name>.ts` and is dynamic-imported on
* demand. Keeps cold-start fast and lets the auth library load only
* when the user is doing auth work.
*/
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Sign in via browser (OAuth)", "hyperframes auth login"],
["Save an API key (interactive)", "hyperframes auth login --api-key"],
["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"],
["Check who you're signed in as", "hyperframes auth status"],
["Force-refresh the OAuth access token", "hyperframes auth refresh"],
["Sign out", "hyperframes auth logout"],
];
const HELP = `
${c.bold("hyperframes auth")} ${c.dim("<subcommand> [args]")}
Manage HeyGen credentials. Credentials live in
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
${c.bold("SUBCOMMANDS:")}
${c.accent("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")}
${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")}
${c.accent("refresh")} ${c.dim("Force-refresh the OAuth access token.")}
${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")}
${c.bold("ENV VARS:")}
${c.accent("HEYGEN_API_KEY")} Override the stored credential.
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
`;
export default defineCommand({
meta: { name: "auth", description: "Sign in to HeyGen and manage credentials" },
subCommands: {
login: () => import("./auth/login.js").then((m) => m.default),
status: () => import("./auth/status.js").then((m) => m.default),
logout: () => import("./auth/logout.js").then((m) => m.default),
refresh: () => import("./auth/refresh.js").then((m) => m.default),
},
async run({ args }) {
if (!args._?.[0]) console.log(HELP);
},
});
@@ -0,0 +1,215 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readStore, writeStore } from "../../auth/store.js";
// Mock only AuthClient — keep the real store/resolver so the test
// exercises the actual on-disk rollback / persistence behavior.
// `verifyState` controls what `getCurrentUser` returns per test:
// - reject: throw ErrUnauthenticated (invalid key path)
// - user: the /v3/users/me identity returned on success
const verifyState = vi.hoisted(
() =>
({ reject: false, user: { email: "alice@example.com" } }) as {
reject: boolean;
user: Record<string, unknown>;
},
);
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid key");
}
return verifyState.user;
}
}
return { ...actual, AuthClient: MockAuthClient };
});
// Spy on the telemetry the login flow emits, so we can assert the identity is
// attributed on success. login.ts imports these via a dynamic import of
// telemetry/index.js; the mock intercepts it.
const telemetry = vi.hoisted(() => ({
trackAuthLoginStarted: vi.fn(),
trackAuthLoginCompleted: vi.fn(),
trackAuthLoginFailed: vi.fn(),
identifyUser: vi.fn(),
}));
vi.mock("../../telemetry/index.js", () => telemetry);
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
describe("auth login --api-key rollback", () => {
let dir: string;
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
beforeEach(async () => {
dir = await fs.mkdtemp(join(tmpdir(), "hf-login-"));
for (const k of ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
verifyState.reject = false;
verifyState.user = { email: "alice@example.com" };
for (const fn of Object.values(telemetry)) fn.mockClear();
// process.exit throws so we can assert the post-rollback state.
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
for (const k of ENV_KEYS) {
const v = saved[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
await fs.rm(dir, { recursive: true, force: true });
});
async function runLogin(apiKey: string): Promise<void> {
const cmd = (await import("./login.js")).default;
// citty command run only reads `args` here.
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { "api-key": apiKey },
});
}
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
// The store must NOT retain the rejected key — otherwise the next
// command would silently resolve a known-bad credential.
const { source } = await readStore();
expect(source).toBe("absent");
});
it("restores the previous credential on a failed re-login", async () => {
await writeStore({ api_key: "hg_previous_good" });
verifyState.reject = true;
await expect(runLogin("hg_newbadkey99")).rejects.toThrow(/process\.exit:1/);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_previous_good");
});
it("persists the key on a successful login", async () => {
verifyState.reject = false;
await runLogin("hg_goodkey456");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_goodkey456");
});
it("persists the friendly user block from /v3/users/me on a successful login", async () => {
verifyState.user = {
email: "jane@example.com",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
};
await runLogin("hg_goodkey456");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_goodkey456");
expect(credentials.user).toEqual({
email: "jane@example.com",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
});
});
it("clears a stale user block when the new key's identity probe returns nothing", async () => {
// Prior login left a user block on disk. The new key is valid but
// /v3/users/me returns no identity fields — the stale block must be
// cleared so `auth status` can't surface the previous account.
await writeStore({ api_key: "hg_old", user: { email: "old@example.com" } });
verifyState.user = {}; // verified, but no identity returned
await runLogin("hg_newgoodkey");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_newgoodkey");
expect(credentials.user).toBeUndefined();
});
it("rollback on a rejected key restores the previous user block too", async () => {
await writeStore({ api_key: "hg_prev", user: { email: "prev@example.com" } });
verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_prev");
expect(credentials.user).toEqual({ email: "prev@example.com" });
});
it("rollback on a rejected key preserves a prior foreign top-level key (no known credential)", async () => {
// The prior file held ONLY a future/foreign top-level key — no
// api_key, no oauth. A rejected new key must roll back WITHOUT
// deleting the file, or the foreign credential another CLI owns is
// clobbered. (Before the fix, rollback deleted the file because
// neither api_key nor oauth was present.)
await fs.writeFile(
join(dir, "credentials"),
JSON.stringify({ future_credential: { token: "owned_by_other_cli" } }),
{ mode: 0o600 },
);
verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/);
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
expect(onDisk.api_key).toBeUndefined();
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("attributes a successful login to the account email", async () => {
verifyState.user = { email: "alice@example.com", username: "alice" };
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice@example.com");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice@example.com");
});
it("falls back to username when the account has no email", async () => {
verifyState.user = { username: "alice" };
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice");
});
it("does not identify when the identity probe returns nothing", async () => {
verifyState.user = {}; // verified key, but no identity fields
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).not.toHaveBeenCalled();
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", undefined);
});
it("records a rejected key as failed and never identifies", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
expect(telemetry.identifyUser).not.toHaveBeenCalled();
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("api_key", "rejected");
});
it("preserves an unknown/foreign top-level key across a successful re-login", async () => {
// Cross-CLI invariant end-to-end: a key heygen-cli (or a future
// version) wrote must survive a hyperframes-cli login round-trip.
await fs.writeFile(join(dir, "credentials"), JSON.stringify({ future_field: { x: 1 } }), {
mode: 0o600,
});
verifyState.user = { email: "jane@example.com" };
await runLogin("hg_goodkey456");
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
expect(onDisk.api_key).toBe("hg_goodkey456");
expect(onDisk.user).toEqual({ email: "jane@example.com" });
expect(onDisk.future_field).toEqual({ x: 1 });
});
});
+355
View File
@@ -0,0 +1,355 @@
/**
* `hyperframes auth login` — sign in to HeyGen.
*
* Default: OAuth 2.0 + PKCE via a loopback callback. The CLI opens
* the user's browser, captures the authorization code on an
* ephemeral 127.0.0.1 port, exchanges it for tokens, and persists
* them to `~/.heygen/credentials`.
*
* `--api-key`: opts into the legacy long-lived API-key path.
*
* Write semantics:
* - Snapshot existing credentials first; merge so a new OAuth session
* preserves an existing API key (and vice versa).
* - Sanity-check that the input is non-empty and header-safe (no
* CR/LF) before touching disk. The backend's `/v3/users/me` is the
* source of truth for whether the key is actually valid — we do
* NOT shape-check the prefix (real keys come in multiple formats:
* `sk_V2_…`, `hg_…`, partner keys, etc.).
* - Verify via `GET /v3/users/me`. On 401, roll back to the previous
* state. Network/5xx errors keep the new credential in place per
* the transient-blip rationale.
*/
import { defineCommand } from "citty";
import { stdin as input } from "node:process";
import {
AuthClient,
assertOAuthConfiguredOrExit,
clearUserInfo,
deleteStore,
hasPreservedUnknownData,
isAuthError,
isHeaderSafe,
isUserInfoEmpty,
readStore,
refreshTokens,
saveUserInfo,
startAuthorizationCodeFlow,
tryResolveCredential,
userDisplayName,
writeStore,
type Credentials,
type StoredUserInfo,
type UserInfo,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
const STDIN_TIMEOUT_MS = 30_000;
// Smallest plausible length for a real API key. We don't validate the
// prefix or character set — the backend's /v3/users/me is the source
// of truth and rolls back on rejection. The only must-check is
// header-safety (CR/LF), which `isHeaderSafe` covers.
const MIN_KEY_LENGTH = 8;
export default defineCommand({
meta: {
name: "login",
description: "Sign in to HeyGen (OAuth by default; --api-key for long-lived keys)",
},
args: {
"api-key": {
type: "string",
description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const inlineKey = args["api-key"];
if (inlineKey !== undefined) {
await runApiKeyLogin(inlineKey);
return;
}
await runOAuthLogin();
},
});
// fallow-ignore-next-line complexity
async function runOAuthLogin(): Promise<void> {
assertOAuthConfiguredOrExit();
const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js");
trackAuthLoginStarted("oauth");
try {
await startAuthorizationCodeFlow();
} catch (err) {
const message = (err as Error).message ?? "";
// The loopback server rejects with "OAuth callback timed out after …" when
// the user never completes the browser step (closed the tab / walked away).
// That is the dominant non-error dropout, so split it from real failures
// (IdP misconfig, network) instead of lumping everything as flow_error.
trackAuthLoginFailed("oauth", /timed out/i.test(message) ? "flow_timeout" : "flow_error");
console.error(c.error(`Sign-in failed: ${message}`));
process.exit(1);
}
await reportIdentity();
}
// fallow-ignore-next-line complexity
async function reportIdentity(): Promise<void> {
const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
const credential = await tryResolveCredential();
if (!credential) {
trackAuthLoginFailed("oauth", "no_credential");
console.error(c.warn("Sign-in completed but no credential was persisted."));
process.exit(1);
}
// Wire the refresh hook here too — a freshly-minted token shouldn't
// need it, but a fast IdP-side rotation (or a misconfigured short
// TTL) shouldn't punish the user with a hard failure when the
// refresh_token would have transparently fixed it.
const client = new AuthClient({
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
});
try {
const user = await client.getCurrentUser(credential);
// Persist the friendly-display block alongside the OAuth tokens so
// `auth status` can show "Logged in as ..." without re-hitting
// /v3/users/me. Best-effort — a persist failure never fails the login.
await persistUserInfo(user);
// Attribute this install to the signed-in account (and stitch its prior
// anonymous usage) before recording completion, so the completed event
// carries the identity. Both no-op under the telemetry opt-out.
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("oauth", id);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ Signed in as ${identity}.`));
} catch (err) {
// Don't roll back — the OAuth tokens are valid on disk; this is a
// transient verify-side issue. The credential is persisted and usable, so
// the sign-in still COMPLETED; we just have no resolved identity to
// attribute it to. The stale user block from a prior login (possibly a
// DIFFERENT account) is cleared so `auth status` can't surface it.
await clearUserInfoBestEffort();
trackAuthLoginCompleted("oauth");
console.error(
c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`),
);
}
}
/**
* The stable key we associate this install with in telemetry after sign-in.
* `/v3/users/me` exposes no opaque user_id, so we key on the HeyGen account
* EMAIL — the canonical account identifier and the reliable join key back to
* billing — falling back to username only when the account exposes no email.
* (Username is NOT a privacy win — HeyGen usernames are frequently email-shaped
* — it is purely a fallback so an emailless account is still attributable.)
* The privacy notice (showTelemetryNotice) and docs/packages/cli.mdx disclose
* both, so keep them in sync with whatever this returns.
*/
function identityKey(user: UserInfo): string | undefined {
return user.email ?? user.username;
}
/** Project the API `/v3/users/me` view onto the on-disk identity block. */
function toStoredUserInfo(user: UserInfo): StoredUserInfo {
const out: StoredUserInfo = {};
if (user.email) out.email = user.email;
if (user.first_name) out.first_name = user.first_name;
if (user.last_name) out.last_name = user.last_name;
if (user.username) out.username = user.username;
return out;
}
/**
* Persist the friendly-display block (best-effort). A non-empty block is
* saved; an empty one (the API returned no identity fields) clears any
* stale block so a wrong account can't surface in `auth status`. A
* persist/clear failure is warned, never fatal — the credential is valid
* on disk and that's what matters.
*/
async function persistUserInfo(user: UserInfo): Promise<void> {
const stored = toStoredUserInfo(user);
try {
if (isUserInfoEmpty(stored)) {
await clearUserInfo();
} else {
await saveUserInfo(stored);
}
} catch (err) {
console.error(c.dim(`(warning: could not persist user info: ${(err as Error).message})`));
}
}
/** Drop any stale user block; best-effort, never fatal. */
async function clearUserInfoBestEffort(): Promise<void> {
try {
await clearUserInfo();
} catch (err) {
console.error(c.dim(`(warning: could not clear stale user info: ${(err as Error).message})`));
}
}
// fallow-ignore-next-line complexity
async function runApiKeyLogin(inlineKey: string): Promise<void> {
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
trackAuthLoginStarted("api_key");
// collectApiKey throws when the user cancels the interactive prompt (Ctrl-C)
// or when no key arrives on stdin before the timeout — both are "user walked
// away", the abandonment signal we most want. Record it before the error
// propagates so `started` still reconciles to `completed + failed`.
let key: string;
try {
key = await collectApiKey(inlineKey);
} catch (err) {
trackAuthLoginFailed("api_key", "aborted");
console.error(c.error((err as Error).message || "Sign-in aborted."));
process.exit(1);
}
if (!key) {
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error("No API key provided."));
process.exit(1);
}
if (!isHeaderSafe(key)) {
// CR/LF in the value would smuggle headers when the key is sent
// via `x-api-key`. The backend handles "wrong key" itself, but
// header-injection has to be caught here.
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error("API key must not contain newline or control characters."));
process.exit(1);
}
if (key.length < MIN_KEY_LENGTH) {
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
process.exit(1);
}
const previous = await snapshotStore();
const next: Credentials = { ...previous, api_key: key };
await writeStore(next);
const user = await verifyAndReport(key);
if (!user) {
trackAuthLoginFailed("api_key", "rejected");
await rollback(previous);
process.exit(1);
}
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("api_key", id);
}
async function snapshotStore(): Promise<Credentials> {
try {
const { credentials } = await readStore();
return { ...credentials };
} catch {
return {};
}
}
async function rollback(previous: Credentials): Promise<void> {
try {
if (previous.api_key || previous.oauth || hasPreservedUnknownData(previous)) {
// Restore the prior state. This branch also covers the case where
// the only prior content was an unknown/foreign top-level key (a
// future credential another CLI owns): writing `previous` back
// re-emits that key, so the rollback doesn't clobber cross-CLI data
// the file had before this login attempt.
await writeStore(previous);
console.error(c.dim("Rolled back to the previous credential."));
} else {
// No prior credential and nothing worth preserving — restore true
// absence. Leaving the rejected key on disk would make the next
// `auth status` / command silently resolve a known-bad key.
await deleteStore();
console.error(c.dim("Removed the rejected credential."));
}
} catch (err) {
console.error(c.error(`Failed to roll back: ${(err as Error).message}`));
}
}
// Returns the verified user on success (so the caller can attribute the
// completed sign-in to that identity), or null when the backend rejects the
// key. Other errors propagate.
// fallow-ignore-next-line complexity
async function verifyAndReport(key: string): Promise<UserInfo | null> {
const client = new AuthClient();
try {
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
// Persist the friendly-display block next to the now-verified api_key
// so `auth status` can show a recognizable identity. Best-effort.
await persistUserInfo(user);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`));
return user;
} catch (err) {
if (isAuthError(err) && err.code === "UNAUTHENTICATED") {
console.error(
`${c.warn("HeyGen rejected the API key.")}\n` +
` ${c.dim(err.message)}\n` +
`Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`,
);
return null;
}
throw err;
}
}
async function collectApiKey(inline: string): Promise<string> {
if (inline.length > 0) return inline.trim();
if (!input.isTTY) {
return (await readAllWithTimeout(input, STDIN_TIMEOUT_MS)).trim();
}
return await promptForKey();
}
async function readAllWithTimeout(
stream: NodeJS.ReadableStream,
timeoutMs: number,
): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const timer = setTimeout(() => {
reject(new Error(`Timed out waiting for stdin (${timeoutMs}ms). Pipe the key explicitly.`));
}, timeoutMs);
stream.on("data", (chunk: Buffer | string) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
stream.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
stream.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
async function promptForKey(): Promise<string> {
const clack = await import("@clack/prompts");
const value = await clack.password({
message: "Enter HeyGen API key",
validate: (v) => {
if (!v || v.length < MIN_KEY_LENGTH) return "API key looks too short";
if (!isHeaderSafe(v)) return "API key must not contain newline or control characters";
return undefined;
},
});
if (clack.isCancel(value)) {
// Throw rather than exit here so the single catch in runApiKeyLogin records
// the abandonment (auth_login_failed: aborted) and then exits.
throw new Error("Aborted.");
}
return value.trim();
}
+105
View File
@@ -0,0 +1,105 @@
/**
* `hyperframes auth logout` — remove the credential file. With
* `--keep-api-key`, only the OAuth block is cleared (no-op for
* API-key-only stores).
*
* Env-only credentials (`HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`) can't
* be cleared by this command — we tell the user to unset them.
*/
import { defineCommand } from "citty";
import {
clearOAuth,
configDir,
credentialPath,
deleteStore,
readStore,
revokeTokens,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
export default defineCommand({
meta: { name: "logout", description: "Remove the stored HeyGen credential" },
args: {
"keep-api-key": {
type: "boolean",
description: "Only clear the OAuth session; preserve the API key.",
default: false,
},
yes: {
type: "boolean",
description: "Skip the confirmation prompt.",
default: false,
},
},
async run({ args }) {
warnIfEnvCredentialActive();
const keepApiKey = Boolean(args["keep-api-key"]);
if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) {
console.log("Aborted.");
process.exit(1);
}
// Best-effort revoke before we wipe local state. RFC 7009 says
// success is empty 200 — we ignore failure either way.
await bestEffortRevoke();
if (keepApiKey) {
await clearOAuth();
console.log(c.success("✓ OAuth session removed. API key retained."));
return;
}
await deleteStore();
console.log(c.success(`✓ Signed out. Removed ${credentialPath()}.`));
},
});
function warnIfEnvCredentialActive(): void {
if (process.env["HEYGEN_API_KEY"] || process.env["HYPERFRAMES_API_KEY"]) {
console.log(
c.warn(
"An env-var credential is active. Unset HEYGEN_API_KEY / HYPERFRAMES_API_KEY to remove it.",
),
);
}
}
async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise<boolean> {
if (yes) return true;
const prompt = keepApiKey
? `This will sign out of any active OAuth session on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `
: `This will sign out of HeyGen on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `;
return confirmInteractive(prompt);
}
// fallow-ignore-next-line complexity
async function bestEffortRevoke(): Promise<void> {
try {
const { credentials, source } = await readStore();
if (source === "absent" || !credentials.oauth) return;
const { access_token, refresh_token } = credentials.oauth;
// Revoke the refresh_token first (per RFC 7009, that typically
// invalidates all derived access tokens), but also revoke the
// access_token explicitly to cover servers that don't cascade.
if (refresh_token) {
await revokeTokens(refresh_token, { token_type_hint: "refresh_token" });
}
if (access_token) {
await revokeTokens(access_token, { token_type_hint: "access_token" });
}
} catch {
/* Best-effort — never block local wipe on a network/IdP issue. */
}
}
async function confirmInteractive(prompt: string): Promise<boolean> {
if (!process.stdin.isTTY) return false;
const { createInterface } = await import("node:readline");
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question(prompt, (line) => resolve(line));
});
rl.close();
return /^y(es)?$/i.test(answer.trim());
}
+48
View File
@@ -0,0 +1,48 @@
/**
* `hyperframes auth refresh` — force-refresh the OAuth access_token
* using the stored refresh_token.
*
* Mostly useful for testing the refresh path or for users on flaky
* networks who want to pre-emptively refresh before a long render
* job. Status's 401-retry path already does this automatically.
*/
import { defineCommand } from "citty";
import {
assertOAuthConfiguredOrExit,
isAuthError,
readStore,
refreshTokens,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
export default defineCommand({
meta: { name: "refresh", description: "Force-refresh the OAuth access token" },
args: {},
// fallow-ignore-next-line complexity
async run() {
assertOAuthConfiguredOrExit();
const { credentials, source } = await readStore();
if (source === "absent" || !credentials.oauth?.refresh_token) {
console.error(c.warn("No OAuth refresh token to use. Run `hyperframes auth login` first."));
process.exit(1);
}
try {
// refreshTokens persists via oauth.ts:persistOAuth, which merges
// into a freshly-read store (preserving api_key + any
// refresh_token the server didn't rotate). Re-writing here would
// use a stale snapshot and risks clobbering concurrent writes.
await refreshTokens(credentials.oauth.refresh_token);
console.log(c.success("✓ Refreshed OAuth access token."));
} catch (err) {
if (isAuthError(err) && err.code === "REFRESH_FAILED") {
console.error(c.error(err.message));
if (err.hint) console.error(c.dim(err.hint));
process.exit(1);
}
throw err;
}
},
});

Some files were not shown because too many files have changed in this diff Show More