diff --git a/skills-manifest.json b/skills-manifest.json index 18be5bca59..78a2043e7f 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,8 +6,8 @@ "files": 144 }, "faceless-explainer": { - "hash": "b57c98179677e7a0", - "files": 18 + "hash": "3ec4145b533d112c", + "files": 19 }, "figma": { "hash": "0e6e96f5a76ff824", @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "7a51f53cf615fe97", + "hash": "c7262e0f652322fd", "files": 124 }, "motion-graphics": { diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index a402597b79..1c339093c6 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -105,9 +105,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro Start audio after Step 3 approval. Run it in the background, then continue to Step 4. (Sign-in status was already shown in Step 0; the engine falls back automatically.) -**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice `. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference. +**Choose the narration provider and voice from the user's ask before invoking.** Pass an explicit offline or alternate provider with `--provider kokoro|elevenlabs|heygen` (or `HF_TTS_PROVIDER`); the CLI flag takes precedence. If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice `. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference. -`node /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice &` +`node /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --provider --voice &` The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`. diff --git a/skills/faceless-explainer/scripts/audio.mjs b/skills/faceless-explainer/scripts/audio.mjs index 864e66214f..8a5a24670f 100644 --- a/skills/faceless-explainer/scripts/audio.mjs +++ b/skills/faceless-explainer/scripts/audio.mjs @@ -125,6 +125,7 @@ function runGenerate(argv) { const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md"))); const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md"))); const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json"))); + const provider = flag(argv, "provider", process.env.HF_TTS_PROVIDER || "auto"); const userVoice = flag(argv, "voice", null); const speed = Number(flag(argv, "speed", "1.0")) || 1.0; @@ -144,7 +145,7 @@ function runGenerate(argv) { // strict here (no wait-bgm step downstream). const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore"; const request = { - provider: "auto", + provider, speed, lines, bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" }, diff --git a/skills/faceless-explainer/scripts/audio.test.mjs b/skills/faceless-explainer/scripts/audio.test.mjs new file mode 100644 index 0000000000..f45b6909b5 --- /dev/null +++ b/skills/faceless-explainer/scripts/audio.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const script = new URL("./audio.mjs", import.meta.url).pathname; + +function runAudio({ args = [], env = {} } = {}) { + const dir = mkdtempSync(join(tmpdir(), "faceless-audio-")); + const engine = join(dir, "engine.mjs"); + try { + writeFileSync(join(dir, "STORYBOARD.md"), "---\nmessage: Test\n---\n"); + writeFileSync( + engine, + `import { readFileSync, writeFileSync } from "node:fs"; +const argv = process.argv.slice(2); +const flag = (name) => argv[argv.indexOf(name) + 1]; +const request = JSON.parse(readFileSync(flag("--request"), "utf8")); +writeFileSync(new URL("request.json", import.meta.url), JSON.stringify(request)); +writeFileSync(flag("--out"), JSON.stringify({ voices: [], bgm: null, sfx: [] })); +`, + ); + const result = spawnSync( + process.execPath, + [script, "--hyperframes", dir, "--storyboard", join(dir, "STORYBOARD.md"), ...args], + { encoding: "utf8", env: { ...process.env, HF_MEDIA_ENGINE: engine, ...env } }, + ); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(readFileSync(join(dir, "request.json"), "utf8")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("passes --provider to the shared audio engine", () => { + assert.equal(runAudio({ args: ["--provider", "kokoro"] }).provider, "kokoro"); +}); + +test("uses HF_TTS_PROVIDER when --provider is omitted", () => { + assert.equal(runAudio({ env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, "elevenlabs"); +}); + +test("--provider takes precedence over HF_TTS_PROVIDER", () => { + assert.equal( + runAudio({ args: ["--provider", "kokoro"], env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, + "kokoro", + ); +}); diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index bc005eeff9..d708404e87 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -42,7 +42,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { heygenAuthHeaders, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs"; +import { heygenAuthHeadersWithRefresh, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs"; import { ffprobeDuration, pickProvider, @@ -111,8 +111,10 @@ const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0; // ── env + HeyGen availability (the single switch) ───────────────────────────── loadEnvFromDir(hyperframesDir); -const heygenOK = heygenCredential() !== null; -const headers = heygenOK ? heygenAuthHeaders() : null; +const heygenCred = heygenCredential(); +const headers = + heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null; +const heygenOK = headers !== null; // ── merge base: preserve sections not selected by --only ────────────────────── const prev = existsSync(outPath) ? JSON.parse(readFileSync(outPath, "utf8")) : {}; diff --git a/skills/media-use/audio/scripts/audio.test.mjs b/skills/media-use/audio/scripts/audio.test.mjs index b229e40cc3..a42c4a2ab6 100644 --- a/skills/media-use/audio/scripts/audio.test.mjs +++ b/skills/media-use/audio/scripts/audio.test.mjs @@ -1,6 +1,7 @@ import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; import { test } from "node:test"; -import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -13,6 +14,51 @@ import { resolveSfx } from "./lib/sfx.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const sfxLibDir = join(HERE, "..", "assets", "sfx"); // same offset the engine uses +test("explicit offline TTS provider bypasses expired HeyGen OAuth", () => { + const dir = mkdtempSync(join(tmpdir(), "mu-audio-expired-auth-")); + const configDir = join(dir, "config"); + const requestPath = join(dir, "audio_request.json"); + const outPath = join(dir, "audio_meta.json"); + const engine = join(HERE, "audio.mjs"); + try { + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "credentials"), + JSON.stringify({ + oauth: { access_token: "expired", expires_at: "2000-01-01T00:00:00Z" }, + }), + ); + writeFileSync( + requestPath, + JSON.stringify({ provider: "kokoro", lines: [], bgm: { mode: "none" } }), + ); + const env = { ...process.env, HEYGEN_CONFIG_DIR: configDir }; + delete env.HEYGEN_API_KEY; + delete env.HYPERFRAMES_API_KEY; + const result = spawnSync( + process.execPath, + [ + engine, + "--request", + requestPath, + "--hyperframes", + dir, + "--out", + outPath, + "--only", + "tts", + "--provider", + "kokoro", + ], + { encoding: "utf8", env }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(readFileSync(outPath, "utf8")).tts_provider, null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("bundled SFX library resolves from the relocated path", async () => { assert.ok(existsSync(join(sfxLibDir, "manifest.json")), "moved manifest is present"); const dir = mkdtempSync(join(tmpdir(), "mu-audio-")); diff --git a/skills/media-use/audio/scripts/heygen-tts.mjs b/skills/media-use/audio/scripts/heygen-tts.mjs index 3b6dcbf4a8..6e1a550c14 100755 --- a/skills/media-use/audio/scripts/heygen-tts.mjs +++ b/skills/media-use/audio/scripts/heygen-tts.mjs @@ -17,7 +17,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import { heygenAuthHeaders, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs"; +import { heygenAuthHeadersWithRefresh, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs"; import { ffprobeDuration, resolveVoiceId, synthesizeOne, withWordIds } from "./lib/tts.mjs"; const argv = process.argv.slice(2); @@ -66,7 +66,7 @@ const listOnly = flag("list") === true; loadEnvFromDir(process.cwd()); let authHeaders; try { - authHeaders = heygenAuthHeaders(); + authHeaders = await heygenAuthHeadersWithRefresh(); } catch (e) { die(e.message); } @@ -94,7 +94,7 @@ const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang }); if (!userVoice) console.error(`· using voice ${voiceId}`); // ---------- synthesize (shared engine code) ---------- -const { ok, words } = await synthesizeOne({ +const { ok, words, error } = await synthesizeOne({ provider: "heygen", text, voiceId, @@ -103,7 +103,9 @@ const { ok, words } = await synthesizeOne({ wavAbs: output, hyperframesDir: process.cwd(), }); -if (!ok) die("synthesis failed (HeyGen request/transcode error)"); +if (!ok) { + die(error ? `synthesis failed: ${error}` : "synthesis failed (HeyGen request/transcode error)"); +} let wordCount = 0; if (wordsPath) { diff --git a/skills/media-use/audio/scripts/lib/heygen.mjs b/skills/media-use/audio/scripts/lib/heygen.mjs index ed784ffd40..bcd91996ea 100644 --- a/skills/media-use/audio/scripts/lib/heygen.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.mjs @@ -9,6 +9,10 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; export const HEYGEN_BASE = "https://api.heygen.com/v3"; +const HEYGEN_OAUTH_TOKEN_URL = + process.env.HYPERFRAMES_OAUTH_TOKEN_URL || "https://api2.heygen.com/v1/oauth/token"; +const HEYGEN_OAUTH_CLIENT_ID = + process.env.HYPERFRAMES_OAUTH_CLIENT_ID || "q2A2QRSke2LrFTPJhoDbHtXh"; export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" }; // Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so // the backend can isolate media-use consumption from other free TTS / avatar video. @@ -45,7 +49,7 @@ export function loadEnvFromDir(startDir) { } } -// → { headers } | { expired: true } | null. Never throws. +// → { headers } | { expired: true } | { refreshable: 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 } }; @@ -68,6 +72,7 @@ export function heygenCredential() { 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 (oauth.refresh_token) return { expired: true, refreshable: true, file, credentials: cred }; if (!cred.api_key) return { expired: true }; } if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } }; @@ -106,6 +111,65 @@ export function heygenAuthHeaders() { ); } +// Resolve auth for a network request, silently renewing an expired OAuth token +// when the persisted credential includes a refresh token. Access tokens remain +// short-lived; the refresh token provides the no-prompt UX without weakening +// OAuth's revocation and expiry guarantees. +export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { + const cred = heygenCredential(); + if (!cred?.refreshable) return heygenAuthHeaders(); + + const oauth = cred.credentials.oauth; + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: oauth.refresh_token, + client_id: HEYGEN_OAUTH_CLIENT_ID, + }); + const res = await fetchImpl(HEYGEN_OAUTH_TOKEN_URL, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: body.toString(), + }); + if (!res.ok) { + throw new Error( + `HeyGen OAuth refresh failed (HTTP ${res.status}) — run \`npx hyperframes auth login\``, + ); + } + const payload = await res.json().catch(() => null); + const accessToken = payload?.access_token; + if (typeof accessToken !== "string" || !accessToken || /[\r\n\0]/.test(accessToken)) { + throw new Error( + "HeyGen OAuth refresh returned an invalid access token — run `npx hyperframes auth login`", + ); + } + + const expiresIn = Number(payload.expires_in); + const renewed = { + ...oauth, + access_token: accessToken, + refresh_token: payload.refresh_token || oauth.refresh_token, + }; + if (typeof payload.token_type === "string" && payload.token_type) { + renewed.token_type = payload.token_type; + } + if (typeof payload.scope === "string" && payload.scope) renewed.scope = payload.scope; + if (Number.isFinite(expiresIn)) { + renewed.expires_at = new Date(Date.now() + Math.max(expiresIn, 30) * 1000).toISOString(); + } else { + delete renewed.expires_at; + } + const saved = { ...cred.credentials, oauth: renewed }; + writeFileSync(cred.file, `${JSON.stringify(saved, null, 2)}\n`, { mode: 0o600 }); + return { + Authorization: `Bearer ${accessToken}`, + ...HEYGEN_CLI_SOURCE_HEADERS, + ...HEYGEN_CLIENT_SOURCE_HEADERS, + }; +} + // 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: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } }; diff --git a/skills/media-use/audio/scripts/lib/heygen.test.mjs b/skills/media-use/audio/scripts/lib/heygen.test.mjs index 22542f3552..8ed6024663 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -1,9 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { heygenAuthHeaders, heygenAuthMethod } from "./heygen.mjs"; +import { heygenAuthHeaders, heygenAuthHeadersWithRefresh, heygenAuthMethod } from "./heygen.mjs"; function withCleanHeygenEnv(fn) { const previousApiKey = process.env.HEYGEN_API_KEY; @@ -24,6 +24,25 @@ function withCleanHeygenEnv(fn) { } } +async function withCleanHeygenEnvAsync(fn) { + const previousApiKey = process.env.HEYGEN_API_KEY; + const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY; + const previousConfigDir = process.env.HEYGEN_CONFIG_DIR; + try { + delete process.env.HEYGEN_API_KEY; + delete process.env.HYPERFRAMES_API_KEY; + delete process.env.HEYGEN_CONFIG_DIR; + return await fn(); + } finally { + if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY; + else process.env.HEYGEN_API_KEY = previousApiKey; + if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY; + else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey; + if (previousConfigDir === undefined) delete process.env.HEYGEN_CONFIG_DIR; + else process.env.HEYGEN_CONFIG_DIR = previousConfigDir; + } +} + test("heygenAuthHeaders does not tag API-key requests as CLI traffic, but still carries the media-use tool tag", () => { withCleanHeygenEnv(() => { process.env.HEYGEN_API_KEY = "hg_test"; @@ -101,3 +120,60 @@ test("heygenAuthMethod returns null with no credential at all", () => { } }); }); + +test("heygenAuthHeadersWithRefresh silently renews an expired OAuth credential", async () => { + await withCleanHeygenEnvAsync(async () => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + const credentialsPath = join(dir, "credentials"); + writeFileSync( + credentialsPath, + JSON.stringify({ + api_key: "preserved-api-key", + oauth: { + access_token: "expired-access", + refresh_token: "current-refresh", + expires_at: "2000-01-01T00:00:00Z", + scope: "openid profile", + }, + user: { email: "person@example.com" }, + }), + ); + + let request; + const headers = await heygenAuthHeadersWithRefresh(async (url, options) => { + request = { url, options }; + return new Response( + JSON.stringify({ + access_token: "renewed-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + assert.equal(request.url, "https://api2.heygen.com/v1/oauth/token"); + assert.equal(request.options.method, "POST"); + assert.match(request.options.body, /grant_type=refresh_token/); + assert.match(request.options.body, /refresh_token=current-refresh/); + assert.deepEqual(headers, { + Authorization: "Bearer renewed-access", + "X-HeyGen-Source": "cli", + "X-HeyGen-Client-Source": "media-use", + }); + + const saved = JSON.parse(readFileSync(credentialsPath, "utf8")); + assert.equal(saved.api_key, "preserved-api-key"); + assert.deepEqual(saved.user, { email: "person@example.com" }); + assert.equal(saved.oauth.access_token, "renewed-access"); + assert.equal(saved.oauth.refresh_token, "rotated-refresh"); + assert.equal(saved.oauth.scope, "openid profile"); + assert.ok(new Date(saved.oauth.expires_at).getTime() > Date.now()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index b708594744..1304ae024d 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -17,12 +17,13 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs"; +import { heygenAuthHeadersWithRefresh, heygenCredential, heygenJSON } from "./heygen.mjs"; import { pythonInvocation } from "./python.mjs"; // ── provider detection ──────────────────────────────────────────────────────── export function heygenAvailable() { - return heygenCredential() !== null; + const credential = heygenCredential(); + return credential?.headers != null || credential?.refreshable === true; } export function elevenlabsAvailable() { if (!process.env.ELEVENLABS_API_KEY) return false; @@ -66,7 +67,7 @@ export async function resolveVoiceId({ provider, userVoice, lang = "en" }) { if (lang === "en") return "05f19352e8f74b0392a8f411eba40de1"; // Marcia · English · female // Non-English: no fixed default — fall back to the first matching catalog voice. const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, { - headers: heygenAuthHeaders(), + headers: await heygenAuthHeadersWithRefresh(), }); const voices = payload.data ?? payload.voices ?? []; const pick = voices.find((v) => v.language === "English") ?? voices[0]; @@ -241,6 +242,22 @@ save(audio, sys.argv[3]) // [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro // (caller must transcribeWav). Never throws; failures return { ok:false, error } // where `error` states WHY (so the caller can surface it, not a bare "TTS failed"). +export function buildKokoroTtsArgs({ textPath, voiceId, wavRel, lang = "en", speed = 1.0 }) { + const args = [ + "hyperframes", + "tts", + textPath, + "--voice", + voiceId, + "--output", + wavRel, + "--speed", + String(speed), + ]; + if (lang !== "en") args.push("--lang", lang); + return args; +} + export async function synthesizeOne({ provider, text, @@ -276,8 +293,13 @@ export async function synthesizeOne({ } // kokoro — via the published CLI; --output is relative to the project dir. const wavRel = relTo(hyperframesDir, wavAbs); - const args = ["hyperframes", "tts", writeTmpText(text), "--voice", voiceId, "--output", wavRel]; - if (lang !== "en") args.push("--lang", lang); + const args = buildKokoroTtsArgs({ + textPath: writeTmpText(text), + voiceId, + wavRel, + lang, + speed, + }); const r = await spawnP("npx", args, { cwd: hyperframesDir }); return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)"); } @@ -297,7 +319,7 @@ export function synthResult(r, wavAbs, label) { // (e.g. an HTTP 402 plan_upgrade_required thrown by heygenJSON was swallowed). export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, deps = {}) { const requestJSON = deps.heygenJSON ?? heygenJSON; - const authHeaders = deps.heygenAuthHeaders ?? heygenAuthHeaders; + const authHeaders = deps.heygenAuthHeaders ?? heygenAuthHeadersWithRefresh; const fetchImpl = deps.fetch ?? fetch; const transcode = deps.transcodeToWav ?? transcodeToWav; try { @@ -305,7 +327,7 @@ export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, d if (lang !== "en") body.language = lang; const payload = await requestJSON(`/voices/speech`, { method: "POST", - headers: authHeaders(), + headers: await authHeaders(), body, }); const inner = payload.data ?? payload; diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index a4f651908d..43a710dd78 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -1,16 +1,102 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs"; +import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { + heygenAvailable, + pickProvider, parseFfmpegDurationBanner, ffprobeDuration, synthesizeOne, synthesizeHeygen, synthResult, + buildKokoroTtsArgs, } from "./tts.mjs"; +test("forwards a non-default speed to the Kokoro CLI", () => { + const args = buildKokoroTtsArgs({ + textPath: "/tmp/narration.txt", + voiceId: "am_michael", + wavRel: "audio/narration.wav", + lang: "en", + speed: 1.15, + }); + + assert.deepEqual(args, [ + "hyperframes", + "tts", + "/tmp/narration.txt", + "--voice", + "am_michael", + "--output", + "audio/narration.wav", + "--speed", + "1.15", + ]); +}); + +test("expired HeyGen OAuth is not an available TTS provider", () => { + const dir = mkdtempSync(join(tmpdir(), "tts-expired-heygen-")); + const saved = { + apiKey: process.env.HEYGEN_API_KEY, + hyperframesApiKey: process.env.HYPERFRAMES_API_KEY, + configDir: process.env.HEYGEN_CONFIG_DIR, + }; + try { + delete process.env.HEYGEN_API_KEY; + delete process.env.HYPERFRAMES_API_KEY; + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ + oauth: { access_token: "expired", expires_at: "2000-01-01T00:00:00Z" }, + }), + ); + assert.equal(heygenAvailable(), false); + } finally { + if (saved.apiKey === undefined) delete process.env.HEYGEN_API_KEY; + else process.env.HEYGEN_API_KEY = saved.apiKey; + if (saved.hyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY; + else process.env.HYPERFRAMES_API_KEY = saved.hyperframesApiKey; + if (saved.configDir === undefined) delete process.env.HEYGEN_CONFIG_DIR; + else process.env.HEYGEN_CONFIG_DIR = saved.configDir; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("expired HeyGen OAuth with a refresh token remains an available TTS provider", () => { + const dir = mkdtempSync(join(tmpdir(), "tts-refreshable-heygen-")); + const previousConfigDir = process.env.HEYGEN_CONFIG_DIR; + const previousApiKey = process.env.HEYGEN_API_KEY; + const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY; + try { + delete process.env.HEYGEN_API_KEY; + delete process.env.HYPERFRAMES_API_KEY; + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ + oauth: { + access_token: "expired", + refresh_token: "refreshable", + expires_at: "2000-01-01T00:00:00Z", + }, + }), + ); + assert.equal(heygenAvailable(), true); + assert.equal(pickProvider(), "heygen"); + } finally { + rmSync(dir, { recursive: true, force: true }); + if (previousConfigDir === undefined) delete process.env.HEYGEN_CONFIG_DIR; + else process.env.HEYGEN_CONFIG_DIR = previousConfigDir; + if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY; + else process.env.HEYGEN_API_KEY = previousApiKey; + if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY; + else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey; + } +}); + test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => { const stderr = [ "ffmpeg version 6.0", @@ -150,6 +236,12 @@ test("synthesizeHeygen reports wav transcode failures", async () => { } }); +test("standalone HeyGen TTS surfaces the provider error", () => { + const source = readFileSync(new URL("../heygen-tts.mjs", import.meta.url), "utf8"); + assert.match(source, /const \{ ok, words, error \} = await synthesizeOne/); + assert.match(source, /error \? `synthesis failed: \$\{error\}`/); +}); + test("synthResult names a non-zero subprocess exit", () => { const res = synthResult({ status: 2 }, "/tmp/none.wav", "kokoro (npx hyperframes tts)"); assert.equal(res.ok, false);