Skills › HyperFrames › media-use › heygen.mjs
heygen.mjs
281 lines · skills/media-use/audio/scripts/lib/heygen.mjs at a595755
1import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
2// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
3// pipeline. The credential resolver matches the hyperframes CLI auth: first
4// usable source wins — a host gateway ($HEYGEN_API_BASE with its own
5// $HEYGEN_API_KEY) → a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
6// the live login in ~/.hyperframes/credentials → $HEYGEN_API_KEY /
7// $HYPERFRAMES_API_KEY (or a nearby .env) → the API key saved in
8// ~/.hyperframes/credentials. An expired login gives way to an env key but
9// never to the saved API key ($HYPERFRAMES_CONFIG_DIR overrides the dir).
10// $HEYGEN_API_BASE moves every request to that host, as it does for the
11// heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
12// Vendored so the skill ships standalone. Pure node.
14import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15import { homedir } from "node:os";
16import { dirname, join, resolve } from "node:path";
18export const HEYGEN_BASE = "https://api.heygen.com/v3";
20// The v3 base every request goes to: $HEYGEN_API_BASE when a host app names its own gateway (HyperFrames Desktop
21// forwards it to HeyGen with the API key saved in its Settings), else HeyGen's public API. Plain HTTP carries the key
22// in the clear, so it needs $HEYGEN_ALLOW_HTTP=1, the heygen CLI's own rule.
23export function heygenBase() {
24 const host = process.env.HEYGEN_API_BASE?.trim().replace(/\/+$/, "");
25 if (!host) return HEYGEN_BASE;
26 if (host.startsWith("http://") && process.env.HEYGEN_ALLOW_HTTP !== "1")
27 throw new Error(
28 `HEYGEN_API_BASE (${host}) uses HTTP, which sends the key in plaintext. Set HEYGEN_ALLOW_HTTP=1 to allow it.`,
29 );
30 return `${host}/v3`;
31}
33// No base override, or one on HeyGen's own hosts (a canary or dev API).
34function heygenOwnBase() {
35 const host = process.env.HEYGEN_API_BASE?.trim();
36 if (!host) return true;
37 try {
38 const name = new URL(host).hostname;
39 return name === "heygen.com" || name.endsWith(".heygen.com");
40 } catch {
41 return false;
42 }
43}
45// A host gateway: the host pointed the API base away from HeyGen and set the key that base accepts. It pays for every
46// call, so it wins over any other credential the environment carries.
47const hostGatewayKey = () =>
48 !heygenOwnBase() && process.env.HEYGEN_API_KEY ? process.env.HEYGEN_API_KEY : null;
49export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
50// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
51// the backend can isolate media-use consumption from other free TTS / avatar video.
52// Unconditional — a paying user's media-use call is still media-use — unlike the
53// OAuth-only cli-source header above, which also gates the free allowance.
54export const HEYGEN_CLIENT_SOURCE_HEADERS = { "X-HeyGen-Client-Source": "media-use" };
56// A missing `.env`, or a `.env` folder (some home dirs have one), is no env file: null. Read without checking first,
57// so the file cannot change between a check and the read.
58function envFileText(path) {
59 try {
60 return readFileSync(path, "utf8");
61 } catch (error) {
62 if (["ENOENT", "ENOTDIR", "EISDIR"].includes(error.code)) return null;
63 throw error;
64 }
65}
67// A host app sets these in the environment it spawns, never in a project file: a project's .env naming its own base
68// would send the person's shell HEYGEN_API_KEY to that host.
69const HOST_ONLY = new Set(["HEYGEN_API_BASE", "HEYGEN_ALLOW_HTTP"]);
71// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
72export function loadEnvFromDir(startDir) {
73 let dir = resolve(startDir);
74 for (let i = 0; i < 5; i++) {
75 const text = envFileText(join(dir, ".env"));
76 if (text != null) {
77 for (const raw of text.split("\n")) {
78 let line = raw.trim();
79 if (!line || line.startsWith("#")) continue;
80 if (line.startsWith("export ")) line = line.slice(7).trim();
81 const eq = line.indexOf("=");
82 if (eq < 1) continue;
83 const key = line.slice(0, eq).trim();
84 let val = line.slice(eq + 1).trim();
85 if (val.startsWith('"') || val.startsWith("'")) {
86 const q = val[0];
87 const end = val.indexOf(q, 1);
88 val = end > 0 ? val.slice(1, end) : val.slice(1);
89 }
90 if (!HOST_ONLY.has(key) && !(key in process.env)) process.env[key] = val;
91 }
92 return;
93 }
94 const parent = dirname(dir);
95 if (parent === dir) break;
96 dir = parent;
97 }
98}
100// → { headers } | { expired: true } | null. Never throws.
101export function heygenCredential() {
102 const cred = resolveCredential();
103 return cred?.unreadable ? null : cred;
104}
106// heygenCredential's answer, or { unreadable: { file, code } } when the credentials path exists but cannot be read
107// (a folder, a locked ~/.hyperframes), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
108// Read without checking first, so the file cannot change between a check and the read.
109function resolveCredential() {
110 const gatewayKey = hostGatewayKey();
111 if (gatewayKey) return { headers: { "X-Api-Key": gatewayKey } };
112 // Every other credential belongs to HeyGen: a base on any other host gets none of them.
113 if (!heygenOwnBase()) return null;
114 const accessToken = process.env.HEYGEN_ACCESS_TOKEN;
115 if (accessToken) return { headers: { Authorization: `Bearer ${accessToken}` } };
116 const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
117 const stored = storedCredential();
118 // A live login wins over an env key; an expired, foreign or unreadable store gives way to one.
119 if (stored?.headers && "Authorization" in stored.headers) return stored;
120 return envKey ? { headers: { "X-Api-Key": envKey } } : stored;
121}
123// The credential saved in ~/.hyperframes/credentials: { headers } | { expired: true } | { unreadable } | null.
124function storedCredential() {
125 const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
126 const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
127 let raw;
128 try {
129 raw = readFileSync(file, "utf8").trim();
130 } catch (error) {
131 return error.code === "ENOENT" ? null : { unreadable: { file, code: error.code } };
132 }
133 if (!raw) return null;
134 if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
136 // A malformed credentials file (partial write / wrong shape) must degrade to
137 // "no credential", not crash the engine at startup.
138 let cred;
139 try {
140 cred = JSON.parse(raw);
141 } catch {
142 return null;
143 }
144 const oauth = cred.oauth;
145 if (oauth?.access_token) {
146 const expectedClient = process.env.HYPERFRAMES_OAUTH_CLIENT_ID || "Zg3LF4FCLoeJoGs2YqMtZ17g";
147 const expectedTokenEndpoint =
148 process.env.HYPERFRAMES_OAUTH_TOKEN_URL || "https://api2.heygen.com/v1/oauth/token";
149 if (
150 !expectedClient ||
151 oauth.client_id !== expectedClient ||
152 oauth.token_endpoint !== expectedTokenEndpoint
153 )
154 return { expired: true };
155 const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
156 if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
157 return { expired: true };
158 }
159 if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
160 return null;
161}
163// → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
164// makes internally, exposed on its own so callers that only need to *tag* the
165// auth path (telemetry) don't have to parse headers back apart. Never throws:
166// no credential (or an expired one) is just `null`, same as a fresh resolve
167// with nothing to tag.
168export function heygenAuthMethod() {
169 const cred = heygenCredential();
170 if (!cred?.headers) return null;
171 return "Authorization" in cred.headers ? "oauth" : "api_key";
172}
174// → auth headers object, or throw with a fix hint.
175export function heygenAuthHeaders() {
176 const cred = resolveCredential();
177 if (cred?.headers) {
178 // Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
179 // grant the free allowance for OAuth requests and ignores it for API-key
180 // (X-Api-Key) traffic, where it's dead metadata.
181 const isOauth = "Authorization" in cred.headers;
182 return isOauth
183 ? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS, ...HEYGEN_CLIENT_SOURCE_HEADERS }
184 : { ...cred.headers, ...HEYGEN_CLIENT_SOURCE_HEADERS };
185 }
186 if (cred?.unreadable)
187 throw new Error(
188 `HeyGen credentials at ${cred.unreadable.file} can't be read (${cred.unreadable.code}) — fix or remove that path, then run \`npx hyperframes auth login\``,
189 );
190 if (cred?.expired)
191 throw new Error(
192 "HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
193 );
194 throw new Error(
195 "no HyperFrames credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.hyperframes/credentials)",
196 );
197}
199// Authed JSON request against the v3 API; throws on a non-OK status.
200export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
201 const opts = { method, headers: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } };
202 if (body !== undefined) {
203 opts.headers["Content-Type"] = "application/json";
204 opts.body = JSON.stringify(body);
205 }
206 const res = await fetch(`${heygenBase()}${path}`, opts);
207 if (!res.ok) {
208 const detail = await res.text().catch(() => "");
209 const message = `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`;
210 throw Object.assign(new Error(message), { status: res.status, body: detail });
211 }
212 // A DELETE may answer 204 with no body.
213 const text = await res.text();
214 return text ? JSON.parse(text) : {};
215}
217function errorCode(body) {
218 try {
219 const parsed = JSON.parse(body);
220 return parsed?.error?.code ?? parsed?.code ?? null;
221 } catch {
222 return null;
223 }
224}
226// One line the agent relays as written when the HyperFrames plan can't pay: no plan, out of credits, or a
227// sign-in the plan doesn't cover yet. Null for every other failure.
228export function billingRefusal(e) {
229 const code = e?.body ? errorCode(e.body) : null;
230 if (code === "hf_subscription_required")
231 return "This needs a HyperFrames plan. Run `npx hyperframes billing subscribe --skill media-use`: it opens the page and waits.";
232 if (code === "hf_usage_exhausted")
233 return "Out of HyperFrames credits. Run `npx hyperframes billing add-credits --skill media-use`: it opens the page and waits.";
234 if (code === "api_key_required")
235 return "This HeyGen feature isn't available with this sign-in yet. Nothing was charged. Use a HeyGen API key, or wait for HyperFrames billing in the CLI.";
236 return null;
237}
239// HeyGen's own words for a failed call: a billing refusal line first, then its {"error":{"message"}}, else the
240// raw body, else the error's message.
241export function heygenMessage(e) {
242 const refusal = billingRefusal(e);
243 if (refusal) return refusal;
244 if (!e?.body) return e?.message ? String(e.message) : String(e);
245 try {
246 return JSON.parse(e.body).error?.message ?? e.body;
247 } catch {
248 return e.body;
249 }
250}
252// Download a (presigned) URL to destPath; returns byte length.
253export async function downloadTo(url, destPath) {
254 const res = await fetchMedia(url);
255 if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
256 const bytes = Buffer.from(await res.arrayBuffer());
257 mkdirSync(dirname(destPath), { recursive: true });
258 writeFileSync(destPath, bytes);
259 return bytes.length;
260}
262// Retrieval search over HeyGen's audio catalog (NOT generation). type =
263// "music" | "sound_effects". Returns the ranked results array (best first); each
264// item has a presigned `audio_url` (+ `duration`, `description`, `name`, `score`).
265// `query` is required (≥1 char, empty → HTTP 400) and `limit` is capped at 50.
266// `minScore`: omit to use the server default (0.7). That default is TOO HIGH for
267// sound_effects — good SFX hits score ~0.5–0.67, so callers wanting SFX should
268// pass a lower floor (~0.4); music scores high and is fine at the default.
269export async function searchSounds(query, type, headers, { limit = 5, minScore } = {}) {
270 const params = new URLSearchParams({ query, type, limit: String(limit) });
271 if (minScore != null) params.set("min_score", String(minScore));
272 const payload = await heygenJSON(`/audio/sounds?${params.toString()}`, { headers });
273 // `data` comes back as a ranked array (best first). Older responses keyed it by
274 // numeric index ("0","1",…); normalize both shapes to an array (empty → []).
275 const data = payload?.data ?? payload;
276 if (Array.isArray(data)) return data;
277 if (data && typeof data === "object") return Object.values(data);
278 throw new Error(
279 `unexpected /audio/sounds shape — top keys: ${Object.keys(payload ?? {}).join(", ")}`,
280 );
281}