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
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:
@@ -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
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers.
|
||||
*
|
||||
* The flow:
|
||||
* 1. Generate a high-entropy `code_verifier` (43–128 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(/=+$/, "");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>"');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ~10–50ms (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)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (max−min 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` (100–900) 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();
|
||||
}
|
||||
@@ -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 &)
|
||||
// .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 & entities in srcset
|
||||
srcset = srcset.replace(/&/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, "&").replace(/"/g, """) + '"');
|
||||
}
|
||||
}
|
||||
|
||||
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 & 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(/&/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(/&/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(/&/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(/&/g, "&");
|
||||
if (fixed.startsWith("/") && !fixed.startsWith("//")) fixed = pageOrigin + fixed;
|
||||
return pre + fixed + post;
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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` : ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user