Setting the file. One moment. Heygen · Media Use · heygen-com/hyperframes · Skills Docs⋯
scripts/11 files
Previous
Bundled file Gemini TTS Test
audio/scripts/lib/heygen.mjs
JavaScript·156 lines·7 KB
from
"node:path"
;
11
12export const HEYGEN_BASE = "https://api.heygen.com/v3";
13export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
14// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
15// the backend can isolate media-use consumption from other free TTS / avatar video.
16// Unconditional — a paying user's media-use call is still media-use — unlike the
17// OAuth-only cli-source header above, which also gates the free allowance.
18export const HEYGEN_CLIENT_SOURCE_HEADERS = { "X-HeyGen-Client-Source": "media-use" };
19
20// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
21export function loadEnvFromDir(startDir) {
22 let dir = resolve(startDir);
23 for (let i = 0; i < 5; i++) {
24 const envPath = join(dir, ".env");
25 if (existsSync(envPath)) {
26 for (const raw of readFileSync(envPath, "utf8").split("\n")) {
27 let line = raw.trim();
28 if (!line || line.startsWith("#")) continue;
29 if (line.startsWith("export ")) line = line.slice(7).trim();
30 const eq = line.indexOf("=");
31 if (eq < 1) continue;
32 const key = line.slice(0, eq).trim();
33 let val = line.slice(eq + 1).trim();
34 if (val.startsWith('"') || val.startsWith("'")) {
35 const q = val[0];
36 const end = val.indexOf(q, 1);
37 val = end > 0 ? val.slice(1, end) : val.slice(1);
38 }
39 if (!(key in process.env)) process.env[key] = val;
40 }
41 return;
42 }
43 const parent = dirname(dir);
44 if (parent === dir) break;
45 dir = parent;
46 }
47}
48
49// → { headers } | { expired: true } | null. Never throws.
50export function heygenCredential() {
51 const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
52 if (envKey) return { headers: { "X-Api-Key": envKey } };
53
54 const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
55 if (!existsSync(file)) return null;
56 const raw = readFileSync(file, "utf8").trim();
57 if (!raw) return null;
58 if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
59
60 // A malformed credentials file (partial write / wrong shape) must degrade to
61 // "no credential", not crash the engine at startup — this function never throws.
62 let cred;
63 try {
64 cred = JSON.parse(raw);
65 } catch {
66 return null;
67 }
68 const oauth = cred.oauth;
69 if (oauth?.access_token) {
70 const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
71 if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
72 if (!cred.api_key) return { expired: true };
73 }
74 if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
75 return null;
76}
77
78// → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
79// makes internally, exposed on its own so callers that only need to *tag* the
80// auth path (telemetry) don't have to parse headers back apart. Never throws:
81// no credential (or an expired one) is just `null`, same as a fresh resolve
82// with nothing to tag.
83export function heygenAuthMethod() {
84 const cred = heygenCredential();
85 if (!cred?.headers) return null;
86 return "Authorization" in cred.headers ? "oauth" : "api_key";
87}
88
89// → auth headers object, or throw with a fix hint.
90export function heygenAuthHeaders() {
91 const cred = heygenCredential();
92 if (cred?.headers) {
93 // Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
94 // grant the free allowance for OAuth requests and ignores it for API-key
95 // (X-Api-Key) traffic, where it's dead metadata.
96 const isOauth = "Authorization" in cred.headers;
97 return isOauth
98 ? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS, ...HEYGEN_CLIENT_SOURCE_HEADERS }
99 : { ...cred.headers, ...HEYGEN_CLIENT_SOURCE_HEADERS };
100 }
101 if (cred?.expired)
102 throw new Error(
103 "HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
104 );
105 throw new Error(
106 "no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
107 );
108}
109
110// Authed JSON request against the v3 API; throws on a non-OK status.
111export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
112 const opts = { method, headers: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } };
113 if (body !== undefined) {
114 opts.headers["Content-Type"] = "application/json";
115 opts.body = JSON.stringify(body);
116 }
117 const res = await fetch(`${HEYGEN_BASE}${path}`, opts);
118 if (!res.ok) {
119 const detail = await res.text().catch(() => "");
120 throw new Error(
121 `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`,
122 );
123 }
124 return res.json();
125}
126
127// Download a (presigned) URL to destPath; returns byte length.
128export async function downloadTo(url, destPath) {
129 const res = await fetchMedia(url);
130 if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
131 const bytes = Buffer.from(await res.arrayBuffer());
132 mkdirSync(dirname(destPath), { recursive: true });
133 writeFileSync(destPath, bytes);
134 return bytes.length;
135}
136
137// Retrieval search over HeyGen's audio catalog (NOT generation). type =
138// "music" | "sound_effects". Returns the ranked results array (best first); each
139// item has a presigned `audio_url` (+ `duration`, `description`, `name`, `score`).
140// `query` is required (≥1 char, empty → HTTP 400) and `limit` is capped at 50.
141// `minScore`: omit to use the server default (0.7). That default is TOO HIGH for
142// sound_effects — good SFX hits score ~0.5–0.67, so callers wanting SFX should
143// pass a lower floor (~0.4); music scores high and is fine at the default.
144export async function searchSounds(query, type, headers, { limit = 5, minScore } = {}) {
145 const params = new URLSearchParams({ query, type, limit: String(limit) });
146 if (minScore != null) params.set("min_score", String(minScore));
147 const payload = await heygenJSON(`/audio/sounds?${params.toString()}`, { headers });
148 // `data` comes back as a ranked array (best first). Older responses keyed it by
149 // numeric index ("0","1",…); normalize both shapes to an array (empty → []).
150 const data = payload?.data ?? payload;
151 if (Array.isArray(data)) return data;
152 if (data && typeof data === "object") return Object.values(data);
153 throw new Error(
154 `unexpected /audio/sounds shape — top keys: ${Object.keys(payload ?? {}).join(", ")}`,
155 );
156}