85453da49f
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
149 lines
6.4 KiB
JavaScript
149 lines
6.4 KiB
JavaScript
// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
|
||
// pipeline. The credential resolver 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";
|
||
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
|
||
|
||
// 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;
|
||
}
|
||
|
||
// → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
|
||
// makes internally, exposed on its own so callers that only need to *tag* the
|
||
// auth path (telemetry) don't have to parse headers back apart. Never throws:
|
||
// no credential (or an expired one) is just `null`, same as a fresh resolve
|
||
// with nothing to tag.
|
||
export function heygenAuthMethod() {
|
||
const cred = heygenCredential();
|
||
if (!cred?.headers) return null;
|
||
return "Authorization" in cred.headers ? "oauth" : "api_key";
|
||
}
|
||
|
||
// → auth headers object, or throw with a fix hint.
|
||
export function heygenAuthHeaders() {
|
||
const cred = heygenCredential();
|
||
if (cred?.headers) {
|
||
// Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
|
||
// grant the free allowance for OAuth requests and ignores it for API-key
|
||
// (X-Api-Key) traffic, where it's dead metadata.
|
||
const isOauth = "Authorization" in cred.headers;
|
||
return isOauth ? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS } : { ...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(", ")}`,
|
||
);
|
||
}
|