Files
hyperframes/skills/hyperframes-media/scripts/lib/heygen.mjs
T
WaterrrForeverandClaude Opus 4.8 54cab331d0 feat(cli): shared TTS/BGM auth preflight + caption and skill-workflow fixes (#1697)
* 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>
2026-06-24 23:20:39 +08:00

132 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.50.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(", ")}`,
);
}