mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* fix: handle caption skin workflow * docs(skills): simplify the finalize step across video workflows - Drop --strict-layout; all skills use plain `hyperframes inspect` - Add the caption text_box_overflow false-positive note to faceless-explainer - On a failed check, the orchestrator makes the cheapest safe edit itself (no worker re-dispatch / Step 3 backtrack language) - Snapshot: glance at the stitched contact-sheet.jpg and move on Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): onboarding-first `auth status` + shared TTS/BGM preflight When no HeyGen credential is configured, `hyperframes auth status` now prints registration-first guidance instead of a terse error: - Interactive / agent-driven sessions get sign-in guidance led by `hyperframes auth login` (the OAuth step that also creates an account and is shared with heygen-cli), and never steer users to a per-repo `.env`. CI / non-interactive runs get a terse note. Exit 1 is kept so the "am I logged in?" `$?` contract still holds. - It probes which local engine voice/music will fall back to (Kokoro / MusicGen, mirroring the skill resolution order) and whether their Python deps are installed, with a pip hint when missing. `--json` exposes `recommended_action` + `offline_engines` for skills to branch. - `doctor` gains matching "TTS (Kokoro)" / "BGM (MusicGen)" checks via the same shared probe (findPython/hasPythonModules extracted to tts/python.ts; provider resolution in audio/providers.ts). Every TTS/BGM workflow now relays this at Step 0 (setup) instead of improvising its own "missing key" prompt: pr-to-video, product-launch- video, faceless-explainer, website-to-video, music-to-video. The canonical behavior + key-priority table live once in hyperframes-media. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pr-to-video): scale recommended video length to PR change size Step 0 led with a fixed ~60-90s length default. Now the recommended length is derived from the PR's diff stat (lines added+deleted, nudged by file count) on a tier scale (trivial ~20-40s → large ~110-180s, hard cap ~3 min), reusing the same PR peek already done to infer the angle. The agent states the basis when proposing it, and a huge PR with one headline change still stays tight. User can always override. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(captions): embed brand fonts whose files use separators brandFontFaces() matched font files by stripping only whitespace, so an underscore/hyphen-named file (TT_Norms_Pro_Bold.woff2) never matched the family key "ttnormspro" — captions shipped with no @font-face, the font_family_without_font_face bug. Now both family and filename normalize away all non-alphanumerics; families match longest-key-first so a parent family can't swallow a more specific one's files (TT Norms Pro vs Mono); each file is claimed once; "demibold" ranks before "bold"; and when nothing matches it warns loudly at build time instead of returning "". Also: parseFonts() falls back to h1/h2/title/hero display roles, and the frame-worker + caption authoring docs spell out that only shipped font files render — no system CJK/Devanagari families on the headless renderer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hyperframes-media): enforce sign-in preflight on standalone BGM/TTS A one-off "generate me a BGM" request went straight to local MusicGen without recommending sign-in: bgm.md/tts.md framed the no-credential path as an automatic fallback, so the generation path bypassed the Preflight stop, and the preflight used a bare `hyperframes auth status` that isn't on PATH in a fresh `npx skills` project. - Preflight now applies to one-off generation as well as workflows, uses `npx hyperframes auth status`, and says: if the CLI can't run, still recommend signing in and STOP — never treat "no credential" as a silent green light for local generation. - bgm.md and tts.md point at the Preflight before generating, reframing local generation as the fallback the user opts into, not a default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(auth): add Authentication & API keys guide Document signing in, the keys each capability (voice, music, capture) uses, their resolution priority, and the fully local fallback. Add the guide to the nav and cross-link it from the cloud deploy note and the CLI env-var reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): strip HTML comments in a fixpoint loop (CodeQL) Single-pass <!-- --> removal can re-form a complete comment from adjacent markers (e.g. `<<!-- -->!-- ... -->`), letting a decoy <template> survive and hijack the template-boundary match. Loop to a fixpoint, mirroring the captions.mjs precedent; add a regression test that fails on single-pass (2 root findings) and passes on the loop. Also wrap the build-frame.mjs node:fs imports to satisfy oxfmt — the new copyFileSync import pushed the line past the width limit, which was the sole cause of the Format / Preflight CI failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): strip HTML comments with a linear scan (CodeQL ReDoS) The fixpoint loop still ran a /<!--[\s\S]*?-->/ regex per pass, which backtracks O(n^2) on inputs with many unterminated "<!--" — CodeQL js/polynomial-redos (high). Looping the same regex (the prescribed fix) never addressed this; only the regex itself does. Replace it with an indexOf-based linear strip in utils.ts (stripHtmlComments), kept in a fixpoint loop so markers that re-form when a comment is removed are still stripped. 200k unterminated "<!--" now strips in ~3ms instead of quadratic time; behavior is otherwise unchanged — unterminated comments are kept verbatim, as the old regex left them. The re-forming regression test still guards it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): make TTS/BGM sign-in guidance accurate and runnable From team review of the not-signed-in onboarding: - OAuth is a `hyperframes auth login` feature only. The separate `heygen` CLI is API-key-only — `heygen auth login` stores a pasted key, it is not OAuth and does not create an account. Stop presenting the two CLIs as the same OAuth/sign-up step. - Use `npx hyperframes` in every imperative and runtime hint. Bare `hyperframes` is not on PATH on a fresh machine (command not found); only `npx hyperframes` is guaranteed. Also updates the JSON recommended_action. - Drop `heygen auth login` from the terminal/skill onboarding: it needs its own install and there is no `npx heygen`, so it was a command-not-found trap. The shared-credential fact stays in the reference docs. Covers the `auth status` guidance + tests, the Authentication docs, the shared hyperframes-media preflight (SKILL, requirements, tts, error hints), and the `npx hyperframes auth status` preflight in every TTS/BGM workflow (pr-to-video, product-launch-video, faceless-explainer, website-to-video, music-to-video). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
5.5 KiB
JavaScript
132 lines
5.5 KiB
JavaScript
// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
|
||
// pipeline. The credential resolver is copied from hyperframes-media's
|
||
// heygen-tts.mjs (and matches the hyperframes CLI auth): first usable source
|
||
// wins — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
|
||
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
|
||
// overrides the dir). Vendored so the skill ships standalone. Pure node.
|
||
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||
import { homedir } from "node:os";
|
||
import { dirname, join, resolve } from "node:path";
|
||
|
||
export const HEYGEN_BASE = "https://api.heygen.com/v3";
|
||
|
||
// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
|
||
export function loadEnvFromDir(startDir) {
|
||
let dir = resolve(startDir);
|
||
for (let i = 0; i < 5; i++) {
|
||
const envPath = join(dir, ".env");
|
||
if (existsSync(envPath)) {
|
||
for (const raw of readFileSync(envPath, "utf8").split("\n")) {
|
||
let line = raw.trim();
|
||
if (!line || line.startsWith("#")) continue;
|
||
if (line.startsWith("export ")) line = line.slice(7).trim();
|
||
const eq = line.indexOf("=");
|
||
if (eq < 1) continue;
|
||
const key = line.slice(0, eq).trim();
|
||
let val = line.slice(eq + 1).trim();
|
||
if (val.startsWith('"') || val.startsWith("'")) {
|
||
const q = val[0];
|
||
const end = val.indexOf(q, 1);
|
||
val = end > 0 ? val.slice(1, end) : val.slice(1);
|
||
}
|
||
if (!(key in process.env)) process.env[key] = val;
|
||
}
|
||
return;
|
||
}
|
||
const parent = dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
}
|
||
|
||
// → { headers } | { expired: true } | null. Never throws.
|
||
export function heygenCredential() {
|
||
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
|
||
if (envKey) return { headers: { "X-Api-Key": envKey } };
|
||
|
||
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
|
||
if (!existsSync(file)) return null;
|
||
const raw = readFileSync(file, "utf8").trim();
|
||
if (!raw) return null;
|
||
if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
|
||
|
||
// A malformed credentials file (partial write / wrong shape) must degrade to
|
||
// "no credential", not crash the engine at startup — this function never throws.
|
||
let cred;
|
||
try {
|
||
cred = JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const oauth = cred.oauth;
|
||
if (oauth?.access_token) {
|
||
const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
|
||
if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
|
||
if (!cred.api_key) return { expired: true };
|
||
}
|
||
if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
|
||
return null;
|
||
}
|
||
|
||
// → auth headers object, or throw with a fix hint.
|
||
export function heygenAuthHeaders() {
|
||
const cred = heygenCredential();
|
||
if (cred?.headers) return cred.headers;
|
||
if (cred?.expired)
|
||
throw new Error(
|
||
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
|
||
);
|
||
throw new Error(
|
||
"no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
|
||
);
|
||
}
|
||
|
||
// Authed JSON request against the v3 API; throws on a non-OK status.
|
||
export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
|
||
const opts = { method, headers: { ...headers } };
|
||
if (body !== undefined) {
|
||
opts.headers["Content-Type"] = "application/json";
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
const res = await fetch(`${HEYGEN_BASE}${path}`, opts);
|
||
if (!res.ok) {
|
||
const detail = await res.text().catch(() => "");
|
||
throw new Error(
|
||
`HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`,
|
||
);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
// Download a (presigned) URL to destPath; returns byte length.
|
||
export async function downloadTo(url, destPath) {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
|
||
const bytes = Buffer.from(await res.arrayBuffer());
|
||
mkdirSync(dirname(destPath), { recursive: true });
|
||
writeFileSync(destPath, bytes);
|
||
return bytes.length;
|
||
}
|
||
|
||
// Retrieval search over HeyGen's audio catalog (NOT generation). type =
|
||
// "music" | "sound_effects". Returns the ranked results array (best first); each
|
||
// item has a presigned `audio_url` (+ `duration`, `description`, `name`, `score`).
|
||
// `query` is required (≥1 char, empty → HTTP 400) and `limit` is capped at 50.
|
||
// `minScore`: omit to use the server default (0.7). That default is TOO HIGH for
|
||
// sound_effects — good SFX hits score ~0.5–0.67, so callers wanting SFX should
|
||
// pass a lower floor (~0.4); music scores high and is fine at the default.
|
||
export async function searchSounds(query, type, headers, { limit = 5, minScore } = {}) {
|
||
const params = new URLSearchParams({ query, type, limit: String(limit) });
|
||
if (minScore != null) params.set("min_score", String(minScore));
|
||
const payload = await heygenJSON(`/audio/sounds?${params.toString()}`, { headers });
|
||
// `data` comes back as a ranked array (best first). Older responses keyed it by
|
||
// numeric index ("0","1",…); normalize both shapes to an array (empty → []).
|
||
const data = payload?.data ?? payload;
|
||
if (Array.isArray(data)) return data;
|
||
if (data && typeof data === "object") return Object.values(data);
|
||
throw new Error(
|
||
`unexpected /audio/sounds shape — top keys: ${Object.keys(payload ?? {}).join(", ")}`,
|
||
);
|
||
}
|