From c3e215b8000450877f9d1392ccd02dfb32f84cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 12:33:03 +0000 Subject: [PATCH 01/12] fix(media-use): forward Kokoro speech speed --- skills/media-use/audio/scripts/lib/tts.mjs | 25 +++++++++++++++++-- .../media-use/audio/scripts/lib/tts.test.mjs | 23 +++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index b708594744..f411d441e1 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -241,6 +241,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 +292,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)"); } diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index a4f651908d..5b0d0fc7d9 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -9,8 +9,31 @@ import { 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("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => { const stderr = [ "ffmpeg version 6.0", From 7b1c6ac19941510ec7ffc1d429098689e7d51e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 11:11:30 +0000 Subject: [PATCH 02/12] fix(skills): honor offline TTS provider with expired auth --- skills-manifest.json | 6 +-- skills/faceless-explainer/SKILL.md | 4 +- skills/faceless-explainer/scripts/audio.mjs | 3 +- .../faceless-explainer/scripts/audio.test.mjs | 50 +++++++++++++++++++ skills/media-use/audio/scripts/audio.mjs | 5 +- skills/media-use/audio/scripts/audio.test.mjs | 48 +++++++++++++++++- skills/media-use/audio/scripts/lib/tts.mjs | 2 +- .../media-use/audio/scripts/lib/tts.test.mjs | 30 +++++++++++ 8 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 skills/faceless-explainer/scripts/audio.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index c317f496c0..9c927ba3be 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,8 +6,8 @@ "files": 144 }, "faceless-explainer": { - "hash": "4d51aa098ae2ebcf", - "files": 18 + "hash": "3ec4145b533d112c", + "files": 19 }, "figma": { "hash": "0e6e96f5a76ff824", @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "7a51f53cf615fe97", + "hash": "2dabf861a884fff5", "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..31c4bc6beb 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -111,8 +111,9 @@ 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 ? heygenAuthHeaders() : 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/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index f411d441e1..b4f5def14c 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -22,7 +22,7 @@ import { pythonInvocation } from "./python.mjs"; // ── provider detection ──────────────────────────────────────────────────────── export function heygenAvailable() { - return heygenCredential() !== null; + return heygenCredential()?.headers != null; } export function elevenlabsAvailable() { if (!process.env.ELEVENLABS_API_KEY) return false; diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index 5b0d0fc7d9..b2dee2736f 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node: import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { + heygenAvailable, parseFfmpegDurationBanner, ffprobeDuration, synthesizeOne, @@ -34,6 +35,35 @@ test("forwards a non-default speed to the Kokoro CLI", () => { ]); }); +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("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => { const stderr = [ "ffmpeg version 6.0", From c609e4e32d1812b578aa215ab5a9b86226eda855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 05:57:26 +0000 Subject: [PATCH 03/12] fix(media-use): surface standalone HeyGen TTS errors --- skills-manifest.json | 2 +- skills/media-use/audio/scripts/heygen-tts.mjs | 6 ++++-- skills/media-use/audio/scripts/lib/tts.test.mjs | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 9c927ba3be..a607380934 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "2dabf861a884fff5", + "hash": "18778dd0ec03c8d5", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/heygen-tts.mjs b/skills/media-use/audio/scripts/heygen-tts.mjs index 3b6dcbf4a8..30aaf6f3d4 100755 --- a/skills/media-use/audio/scripts/heygen-tts.mjs +++ b/skills/media-use/audio/scripts/heygen-tts.mjs @@ -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/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index b2dee2736f..92933129b0 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -1,6 +1,13 @@ 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 { @@ -203,6 +210,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); From 2323242a0dabb289623437068c36bd57d53348be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 06:04:15 +0000 Subject: [PATCH 04/12] style(media-use): format TTS tests --- skills-manifest.json | 2 +- skills/media-use/audio/scripts/lib/tts.test.mjs | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index a607380934..d64bdcf6d8 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "18778dd0ec03c8d5", + "hash": "9aeba986fcf822f3", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index 92933129b0..15ce4fd5b4 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -1,13 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - mkdtempSync, - writeFileSync, - chmodSync, - rmSync, - existsSync, - readFileSync, -} 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 { From f1c02bf979a4b7c683b045e3160fb20f55c27fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 06:05:40 +0000 Subject: [PATCH 05/12] fix(media-use): refresh expired HeyGen OAuth automatically --- skills-manifest.json | 2 +- skills/media-use/audio/scripts/audio.mjs | 9 +- skills/media-use/audio/scripts/heygen-tts.mjs | 8 +- skills/media-use/audio/scripts/lib/heygen.mjs | 66 ++++++++++++++- .../audio/scripts/lib/heygen.test.mjs | 84 ++++++++++++++++++- skills/media-use/audio/scripts/lib/tts.mjs | 15 ++-- .../media-use/audio/scripts/lib/tts.test.mjs | 33 ++++++++ 7 files changed, 204 insertions(+), 13 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index d64bdcf6d8..1739e56563 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "9aeba986fcf822f3", + "hash": "ea13b8f4306e6351", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index 31c4bc6beb..39a8b3d30a 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -42,7 +42,11 @@ 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, @@ -112,7 +116,8 @@ const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0; // ── env + HeyGen availability (the single switch) ───────────────────────────── loadEnvFromDir(hyperframesDir); const heygenCred = heygenCredential(); -const headers = heygenCred?.headers ? heygenAuthHeaders() : null; +const headers = + heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null; const heygenOK = headers !== null; // ── merge base: preserve sections not selected by --only ────────────────────── diff --git a/skills/media-use/audio/scripts/heygen-tts.mjs b/skills/media-use/audio/scripts/heygen-tts.mjs index 30aaf6f3d4..22d8b9c286 100755 --- a/skills/media-use/audio/scripts/heygen-tts.mjs +++ b/skills/media-use/audio/scripts/heygen-tts.mjs @@ -17,7 +17,11 @@ 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 +70,7 @@ const listOnly = flag("list") === true; loadEnvFromDir(process.cwd()); let authHeaders; try { - authHeaders = heygenAuthHeaders(); + authHeaders = await heygenAuthHeadersWithRefresh(); } catch (e) { die(e.message); } 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..cd482568b7 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -1,9 +1,13 @@ 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 +28,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 +124,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 b4f5def14c..df58ebc96b 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -17,12 +17,17 @@ 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()?.headers != null; + const credential = heygenCredential(); + return credential?.headers != null || credential?.refreshable === true; } export function elevenlabsAvailable() { if (!process.env.ELEVENLABS_API_KEY) return false; @@ -66,7 +71,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]; @@ -318,7 +323,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 { @@ -326,7 +331,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 15ce4fd5b4..43a710dd78 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -5,6 +5,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { heygenAvailable, + pickProvider, parseFfmpegDurationBanner, ffprobeDuration, synthesizeOne, @@ -64,6 +65,38 @@ test("expired HeyGen OAuth is not an available TTS provider", () => { } }); +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", From 178d472aa309e869c253df22026ee6af1de9f163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 06:11:04 +0000 Subject: [PATCH 06/12] style(media-use): format OAuth refresh changes --- skills-manifest.json | 2 +- skills/media-use/audio/scripts/audio.mjs | 6 +----- skills/media-use/audio/scripts/heygen-tts.mjs | 6 +----- skills/media-use/audio/scripts/lib/heygen.test.mjs | 6 +----- skills/media-use/audio/scripts/lib/tts.mjs | 6 +----- 5 files changed, 5 insertions(+), 21 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 1739e56563..a76f456884 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "ea13b8f4306e6351", + "hash": "c7262e0f652322fd", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index 39a8b3d30a..d708404e87 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -42,11 +42,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { - heygenAuthHeadersWithRefresh, - heygenCredential, - loadEnvFromDir, -} from "./lib/heygen.mjs"; +import { heygenAuthHeadersWithRefresh, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs"; import { ffprobeDuration, pickProvider, diff --git a/skills/media-use/audio/scripts/heygen-tts.mjs b/skills/media-use/audio/scripts/heygen-tts.mjs index 22d8b9c286..6e1a550c14 100755 --- a/skills/media-use/audio/scripts/heygen-tts.mjs +++ b/skills/media-use/audio/scripts/heygen-tts.mjs @@ -17,11 +17,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import { - heygenAuthHeadersWithRefresh, - 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); diff --git a/skills/media-use/audio/scripts/lib/heygen.test.mjs b/skills/media-use/audio/scripts/lib/heygen.test.mjs index cd482568b7..8ed6024663 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -3,11 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - heygenAuthHeaders, - heygenAuthHeadersWithRefresh, - heygenAuthMethod, -} from "./heygen.mjs"; +import { heygenAuthHeaders, heygenAuthHeadersWithRefresh, heygenAuthMethod } from "./heygen.mjs"; function withCleanHeygenEnv(fn) { const previousApiKey = process.env.HEYGEN_API_KEY; diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index df58ebc96b..1304ae024d 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -17,11 +17,7 @@ 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 { - heygenAuthHeadersWithRefresh, - heygenCredential, - heygenJSON, -} from "./heygen.mjs"; +import { heygenAuthHeadersWithRefresh, heygenCredential, heygenJSON } from "./heygen.mjs"; import { pythonInvocation } from "./python.mjs"; // ── provider detection ──────────────────────────────────────────────────────── From f7b8a9b507ba94b2e8a51eb62a5c11ec2f4d1e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 22:36:38 +0000 Subject: [PATCH 07/12] fix(skills): serialize OAuth credential refresh --- skills-manifest.json | 4 +- skills/media-use/audio/scripts/lib/heygen.mjs | 109 +++++++++++++++--- .../audio/scripts/lib/heygen.test.mjs | 92 ++++++++++++++- 3 files changed, 189 insertions(+), 16 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index a76f456884..d4079537c2 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 144 }, "faceless-explainer": { - "hash": "3ec4145b533d112c", + "hash": "988b7b689da9f33f", "files": 19 }, "figma": { @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "c7262e0f652322fd", + "hash": "b96e861d2f5dd746", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/lib/heygen.mjs b/skills/media-use/audio/scripts/lib/heygen.mjs index bcd91996ea..b32a0881f4 100644 --- a/skills/media-use/audio/scripts/lib/heygen.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.mjs @@ -4,7 +4,18 @@ // 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 { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -13,6 +24,9 @@ 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"; +const REFRESH_LOCK_RETRY_MS = 25; +const REFRESH_LOCK_TIMEOUT_MS = 10_000; +const REFRESH_LOCK_STALE_MS = 30_000; 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. @@ -72,9 +86,9 @@ 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 (!oauth.refresh_token && !cred.api_key) return { expired: true }; } + if (oauth?.refresh_token) return { expired: true, refreshable: true, file, credentials: cred }; if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } }; return null; } @@ -86,6 +100,7 @@ export function heygenCredential() { // with nothing to tag. export function heygenAuthMethod() { const cred = heygenCredential(); + if (cred?.refreshable) return "oauth"; if (!cred?.headers) return null; return "Authorization" in cred.headers ? "oauth" : "api_key"; } @@ -115,10 +130,59 @@ export function heygenAuthHeaders() { // 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(); +function oauthHeaders(accessToken) { + return { + Authorization: `Bearer ${accessToken}`, + ...HEYGEN_CLI_SOURCE_HEADERS, + ...HEYGEN_CLIENT_SOURCE_HEADERS, + }; +} +function writeCredentialsAtomically(file, credentials) { + const temporary = `${file}.tmp-${process.pid}-${Date.now()}`; + try { + writeFileSync(temporary, `${JSON.stringify(credentials, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + renameSync(temporary, file); + chmodSync(file, 0o600); + } finally { + rmSync(temporary, { force: true }); + } +} + +async function acquireRefreshLock(credentialsFile) { + const lockFile = `${credentialsFile}.refresh.lock`; + const deadline = Date.now() + REFRESH_LOCK_TIMEOUT_MS; + + while (true) { + try { + const descriptor = openSync(lockFile, "wx", 0o600); + return () => { + closeSync(descriptor); + rmSync(lockFile, { force: true }); + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + try { + if (Date.now() - statSync(lockFile).mtimeMs > REFRESH_LOCK_STALE_MS) { + rmSync(lockFile, { force: true }); + continue; + } + } catch (statError) { + if (statError?.code !== "ENOENT") throw statError; + continue; + } + if (Date.now() >= deadline) { + throw new Error("timed out waiting for another HeyGen OAuth refresh to finish"); + } + await new Promise((resolve) => setTimeout(resolve, REFRESH_LOCK_RETRY_MS)); + } + } +} + +async function refreshCredential(cred, fetchImpl) { const oauth = cred.credentials.oauth; const body = new URLSearchParams({ grant_type: "refresh_token", @@ -140,7 +204,13 @@ export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { } const payload = await res.json().catch(() => null); const accessToken = payload?.access_token; - if (typeof accessToken !== "string" || !accessToken || /[\r\n\0]/.test(accessToken)) { + const invalidAccessToken = + typeof accessToken !== "string" || + !accessToken || + accessToken.includes("\r") || + accessToken.includes("\n") || + accessToken.includes(String.fromCharCode(0)); + if (invalidAccessToken) { throw new Error( "HeyGen OAuth refresh returned an invalid access token — run `npx hyperframes auth login`", ); @@ -162,12 +232,25 @@ export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { 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, - }; + writeCredentialsAtomically(cred.file, saved); + return oauthHeaders(accessToken); +} + +export async function heygenAuthHeadersWithRefresh(fetchImpl = fetch) { + const cred = heygenCredential(); + if (!cred?.refreshable) return heygenAuthHeaders(); + + const release = await acquireRefreshLock(cred.file); + try { + const current = heygenCredential(); + if (current?.headers?.Authorization) { + return oauthHeaders(current.headers.Authorization.slice("Bearer ".length)); + } + if (!current?.refreshable) return heygenAuthHeaders(); + return await refreshCredential(current, fetchImpl); + } finally { + release(); + } } // Authed JSON request against the v3 API; throws on a non-OK status. diff --git a/skills/media-use/audio/scripts/lib/heygen.test.mjs b/skills/media-use/audio/scripts/lib/heygen.test.mjs index 8ed6024663..894c977d8f 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { heygenAuthHeaders, heygenAuthHeadersWithRefresh, heygenAuthMethod } from "./heygen.mjs"; @@ -109,6 +109,27 @@ test("heygenAuthMethod returns oauth for a live OAuth credential", () => { }); }); +test("heygenAuthMethod returns oauth for a refreshable expired credential", () => { + withCleanHeygenEnv(() => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ + oauth: { + refresh_token: "current-refresh", + expires_at: "2000-01-01T00:00:00Z", + }, + }), + ); + assert.equal(heygenAuthMethod(), "oauth"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + test("heygenAuthMethod returns null with no credential at all", () => { withCleanHeygenEnv(() => { const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); @@ -172,6 +193,75 @@ test("heygenAuthHeadersWithRefresh silently renews an expired OAuth credential", 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()); + assert.equal(statSync(credentialsPath).mode & 0o777, 0o600); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +test("heygenAuthHeadersWithRefresh renews a credential with only a refresh token", async () => { + await withCleanHeygenEnvAsync(async () => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ oauth: { refresh_token: "current-refresh" } }), + ); + + const headers = await heygenAuthHeadersWithRefresh( + async () => + new Response(JSON.stringify({ access_token: "renewed-access", expires_in: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + assert.equal(headers.Authorization, "Bearer renewed-access"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +test("concurrent OAuth refreshes share the credential rotation", async () => { + await withCleanHeygenEnvAsync(async () => { + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); + try { + process.env.HEYGEN_CONFIG_DIR = dir; + writeFileSync( + join(dir, "credentials"), + JSON.stringify({ + oauth: { + access_token: "expired-access", + refresh_token: "current-refresh", + expires_at: "2000-01-01T00:00:00Z", + }, + }), + ); + + let requests = 0; + const fetchImpl = async () => { + requests += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + return new Response( + JSON.stringify({ + access_token: "renewed-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const [first, second] = await Promise.all([ + heygenAuthHeadersWithRefresh(fetchImpl), + heygenAuthHeadersWithRefresh(fetchImpl), + ]); + + assert.equal(requests, 1); + assert.deepEqual(second, first); } finally { rmSync(dir, { recursive: true, force: true }); } From 6c4f6f545e33fd48000e13a8e7a14b65709e5fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 22:57:03 +0000 Subject: [PATCH 08/12] fix(skills): degrade when HeyGen refresh is unavailable --- skills-manifest.json | 2 +- skills/media-use/audio/scripts/audio.mjs | 9 +++++++-- skills/media-use/audio/scripts/audio.test.mjs | 12 ++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index d4079537c2..0b1c7dec66 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "b96e861d2f5dd746", + "hash": "fc79a8c5d6d86c6a", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index d708404e87..278cc72b35 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -112,8 +112,13 @@ const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0; // ── env + HeyGen availability (the single switch) ───────────────────────────── loadEnvFromDir(hyperframesDir); const heygenCred = heygenCredential(); -const headers = - heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null; +let headers = null; +try { + headers = + heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null; +} catch (error) { + console.error(`· heygen auth: ${error.message} — proceeding without HeyGen`); +} const heygenOK = headers !== null; // ── merge base: preserve sections not selected by --only ────────────────────── diff --git a/skills/media-use/audio/scripts/audio.test.mjs b/skills/media-use/audio/scripts/audio.test.mjs index a42c4a2ab6..5002a11f72 100644 --- a/skills/media-use/audio/scripts/audio.test.mjs +++ b/skills/media-use/audio/scripts/audio.test.mjs @@ -25,14 +25,22 @@ test("explicit offline TTS provider bypasses expired HeyGen OAuth", () => { writeFileSync( join(configDir, "credentials"), JSON.stringify({ - oauth: { access_token: "expired", expires_at: "2000-01-01T00:00:00Z" }, + oauth: { + access_token: "expired", + refresh_token: "offline-refresh-token", + 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 }; + const env = { + ...process.env, + HEYGEN_CONFIG_DIR: configDir, + HYPERFRAMES_OAUTH_TOKEN_URL: "http://127.0.0.1:1/token", + }; delete env.HEYGEN_API_KEY; delete env.HYPERFRAMES_API_KEY; const result = spawnSync( From 072aefd514797afeccb988cf91c6c7430eb4f06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 23:32:36 +0000 Subject: [PATCH 09/12] fix(skills): defer HeyGen auth until needed --- skills-manifest.json | 2 +- skills/media-use/audio/scripts/audio.mjs | 22 +++++++--- skills/media-use/audio/scripts/audio.test.mjs | 43 +++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 0b1c7dec66..45ed6cb549 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "fc79a8c5d6d86c6a", + "hash": "413de6609bb5adae", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index 278cc72b35..912d84e8e5 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -111,15 +111,21 @@ const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0; // ── env + HeyGen availability (the single switch) ───────────────────────────── loadEnvFromDir(hyperframesDir); -const heygenCred = heygenCredential(); let headers = null; -try { - headers = - heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null; -} catch (error) { - console.error(`· heygen auth: ${error.message} — proceeding without HeyGen`); +let heygenOK = false; +let heygenAuthResolved = false; +async function resolveHeygenAuth() { + if (heygenAuthResolved) return; + heygenAuthResolved = true; + const heygenCred = heygenCredential(); + try { + headers = + heygenCred?.headers || heygenCred?.refreshable ? await heygenAuthHeadersWithRefresh() : null; + } catch (error) { + console.error(`· heygen auth: ${error.message} — proceeding without HeyGen`); + } + heygenOK = headers !== null; } -const heygenOK = headers !== null; // ── merge base: preserve sections not selected by --only ────────────────────── const prev = existsSync(outPath) ? JSON.parse(readFileSync(outPath, "utf8")) : {}; @@ -203,6 +209,7 @@ if (only.has("bgm")) { // a pending job it can't await). Only the UNSET/auto default picks generate // when HeyGen is absent. const explicitMode = bgmModeOverride || request.bgm?.mode || null; + if (!noBgm && (!explicitMode || explicitMode === "retrieve")) await resolveHeygenAuth(); let mode = noBgm ? "none" : explicitMode || (heygenOK ? "retrieve" : "generate"); if (mode === "retrieve" && !heygenOK) { anomalies.push( @@ -267,6 +274,7 @@ if (only.has("sfx")) { .map((name) => ({ id: String(l.id), name: String(name).trim() })) .filter((c) => c.name), ); + if (cues.length) await resolveHeygenAuth(); const res = await resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir }); sfx = res.sfx; anomalies.push(...res.anomalies); diff --git a/skills/media-use/audio/scripts/audio.test.mjs b/skills/media-use/audio/scripts/audio.test.mjs index 5002a11f72..d41f26cf28 100644 --- a/skills/media-use/audio/scripts/audio.test.mjs +++ b/skills/media-use/audio/scripts/audio.test.mjs @@ -61,12 +61,55 @@ test("explicit offline TTS provider bypasses expired HeyGen OAuth", () => { { encoding: "utf8", env }, ); assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stderr, /heygen auth|proceeding without HeyGen/i); assert.equal(JSON.parse(readFileSync(outPath, "utf8")).tts_provider, null); } finally { rmSync(dir, { recursive: true, force: true }); } }); +test("HeyGen-dependent retrieval degrades with an explicit auth diagnostic", () => { + 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", + refresh_token: "offline-refresh-token", + expires_at: "2000-01-01T00:00:00Z", + }, + }), + ); + writeFileSync( + requestPath, + JSON.stringify({ provider: "kokoro", lines: [], bgm: { mode: "retrieve" } }), + ); + const env = { + ...process.env, + HEYGEN_CONFIG_DIR: configDir, + HYPERFRAMES_OAUTH_TOKEN_URL: "http://127.0.0.1:1/token", + }; + delete env.HEYGEN_API_KEY; + delete env.HYPERFRAMES_API_KEY; + const result = spawnSync( + process.execPath, + [engine, "--request", requestPath, "--hyperframes", dir, "--out", outPath, "--only", "bgm"], + { encoding: "utf8", env }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /heygen auth: .*proceeding without HeyGen/i); + assert.match(result.stdout, /retrieve requires a HeyGen credential/); + } 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-")); From eec38fe56b7e59cfba1ff25863e9da60adfade00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 23:47:23 +0000 Subject: [PATCH 10/12] test(skills): lock HeyGen-first auto TTS selection --- skills-manifest.json | 2 +- .../media-use/audio/scripts/lib/tts.test.mjs | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/skills-manifest.json b/skills-manifest.json index 45ed6cb549..427d24650c 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "413de6609bb5adae", + "hash": "47d8821fbc59bf6f", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index 43a710dd78..5ed59937a6 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -70,9 +70,11 @@ test("expired HeyGen OAuth with a refresh token remains an available TTS provide const previousConfigDir = process.env.HEYGEN_CONFIG_DIR; const previousApiKey = process.env.HEYGEN_API_KEY; const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY; + const previousElevenlabsApiKey = process.env.ELEVENLABS_API_KEY; try { delete process.env.HEYGEN_API_KEY; delete process.env.HYPERFRAMES_API_KEY; + process.env.ELEVENLABS_API_KEY = "configured-offline-fallback"; process.env.HEYGEN_CONFIG_DIR = dir; writeFileSync( join(dir, "credentials"), @@ -94,6 +96,27 @@ test("expired HeyGen OAuth with a refresh token remains an available TTS provide else process.env.HEYGEN_API_KEY = previousApiKey; if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY; else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey; + if (previousElevenlabsApiKey === undefined) delete process.env.ELEVENLABS_API_KEY; + else process.env.ELEVENLABS_API_KEY = previousElevenlabsApiKey; + } +}); + +test("auto provider selects a HeyGen API key before configured fallbacks", () => { + const previousApiKey = process.env.HEYGEN_API_KEY; + const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY; + const previousElevenlabsApiKey = process.env.ELEVENLABS_API_KEY; + try { + process.env.HEYGEN_API_KEY = "heygen-first"; + delete process.env.HYPERFRAMES_API_KEY; + process.env.ELEVENLABS_API_KEY = "configured-offline-fallback"; + assert.equal(pickProvider(), "heygen"); + } 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 (previousElevenlabsApiKey === undefined) delete process.env.ELEVENLABS_API_KEY; + else process.env.ELEVENLABS_API_KEY = previousElevenlabsApiKey; } }); From 81412ecd2fa2c90d7b534f0c8d812e43ba5fb65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 01:07:35 +0000 Subject: [PATCH 11/12] fix(faceless): preserve audio metadata and language --- skills-manifest.json | 2 +- skills/faceless-explainer/SKILL.md | 2 +- skills/faceless-explainer/scripts/audio.mjs | 17 +++++- .../faceless-explainer/scripts/audio.test.mjs | 61 ++++++++++++++++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 427d24650c..67a31a58ef 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 144 }, "faceless-explainer": { - "hash": "988b7b689da9f33f", + "hash": "0ed7d992d246bea4", "files": 19 }, "figma": { diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 1c339093c6..aeabe1841c 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -107,7 +107,7 @@ Start audio after Step 3 approval. Run it in the background, then continue to St **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 --provider --voice &` +`node /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --provider --voice --lang &` 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 8a5a24670f..b7179d8756 100644 --- a/skills/faceless-explainer/scripts/audio.mjs +++ b/skills/faceless-explainer/scripts/audio.mjs @@ -132,6 +132,7 @@ function runGenerate(argv) { if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8")); const g = manifest.globals; + const lang = flag(argv, "lang", g.extra?.language || "en"); const lines = existsSync(scriptPath) ? parseScript(readFileSync(scriptPath, "utf8")).map((l) => ({ @@ -146,6 +147,7 @@ function runGenerate(argv) { const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore"; const request = { provider, + lang, speed, lines, bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" }, @@ -174,6 +176,14 @@ function runFetchSfx(argv) { if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8")); + let currentMeta = null; + if (existsSync(outPath)) { + try { + currentMeta = JSON.parse(readFileSync(outPath, "utf8")); + } catch (e) { + die(`audio_meta.json parse: ${e.message}`); + } + } // Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx. const lines = []; @@ -192,7 +202,12 @@ function runFetchSfx(argv) { // voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved. runEngine({ request, hyperframesDir, neutral, only: "sfx" }, die); - const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8"))); + const generated = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8"))); + // fetch-sfx is a subset update. audio_meta.json is the workflow-facing + // source of truth and may contain duration corrections or a manually staged + // BGM newer than the engine's neutral sidecar. Preserve it field-for-field and + // replace only the SFX section this command was asked to recompute. + const meta = currentMeta ? { ...currentMeta, sfx: generated.sfx } : generated; writeFileSync(outPath, JSON.stringify(meta, null, 2)); console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`); } diff --git a/skills/faceless-explainer/scripts/audio.test.mjs b/skills/faceless-explainer/scripts/audio.test.mjs index f45b6909b5..f0e89448d0 100644 --- a/skills/faceless-explainer/scripts/audio.test.mjs +++ b/skills/faceless-explainer/scripts/audio.test.mjs @@ -7,11 +7,11 @@ import test from "node:test"; const script = new URL("./audio.mjs", import.meta.url).pathname; -function runAudio({ args = [], env = {} } = {}) { +function runAudio({ args = [], env = {}, storyboard = "---\nmessage: Test\n---\n" } = {}) { const dir = mkdtempSync(join(tmpdir(), "faceless-audio-")); const engine = join(dir, "engine.mjs"); try { - writeFileSync(join(dir, "STORYBOARD.md"), "---\nmessage: Test\n---\n"); + writeFileSync(join(dir, "STORYBOARD.md"), storyboard); writeFileSync( engine, `import { readFileSync, writeFileSync } from "node:fs"; @@ -48,3 +48,60 @@ test("--provider takes precedence over HF_TTS_PROVIDER", () => { "kokoro", ); }); + +test("passes the storyboard language to multilingual transcription", () => { + assert.equal(runAudio({ storyboard: "---\nmessage: Test\nlanguage: zh\n---\n" }).lang, "zh"); +}); + +test("--lang takes precedence over the storyboard language", () => { + assert.equal( + runAudio({ + args: ["--lang", "ja"], + storyboard: "---\nmessage: Test\nlanguage: zh\n---\n", + }).lang, + "ja", + ); +}); + +test("fetch-sfx preserves the current voice durations and manually staged BGM", () => { + const dir = mkdtempSync(join(tmpdir(), "faceless-audio-sfx-")); + const engine = join(dir, "engine.mjs"); + try { + writeFileSync( + join(dir, "STORYBOARD.md"), + "---\nmessage: Test\n---\n\n## Frame 1\n\n- sfx: whoosh\n", + ); + writeFileSync( + join(dir, "audio_meta.json"), + JSON.stringify({ + voices: [{ frame: 1, path: "voice.wav", duration_s: 7.5, words: [] }], + bgm: { path: "manual-bgm.wav", volume: 0.2 }, + sfx: [], + }), + ); + writeFileSync( + engine, + `import { writeFileSync } from "node:fs"; +const argv = process.argv.slice(2); +const flag = (name) => argv[argv.indexOf(name) + 1]; +writeFileSync(flag("--out"), JSON.stringify({ + voices: [{ id: "01", path: "voice.wav", duration_s: 3, words: [] }], + bgm: { path: "stale-bgm.wav", volume: 0.1 }, + sfx: [{ id: "01", file: "whoosh.mp3", offset_s: 0, duration_s: 1, volume: 0.35 }] +}));`, + ); + + const result = spawnSync( + process.execPath, + [script, "fetch-sfx", "--hyperframes", dir, "--storyboard", join(dir, "STORYBOARD.md")], + { encoding: "utf8", env: { ...process.env, HF_MEDIA_ENGINE: engine } }, + ); + assert.equal(result.status, 0, result.stderr); + const meta = JSON.parse(readFileSync(join(dir, "audio_meta.json"), "utf8")); + assert.equal(meta.voices[0].duration_s, 7.5); + assert.equal(meta.bgm.path, "manual-bgm.wav"); + assert.equal(meta.sfx[0].file, "whoosh.mp3"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 07ab8affc9375853560d89631f91f3cc37c0be33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 01:18:51 +0000 Subject: [PATCH 12/12] fix(faceless): preserve Chinese caption spacing --- skills-manifest.json | 4 +- .../faceless-explainer/scripts/captions.mjs | 162 ++++++++++++++---- .../scripts/captions.test.mjs | 60 +++++++ 3 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 skills/faceless-explainer/scripts/captions.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index 67a31a58ef..51b53d12e7 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,8 +6,8 @@ "files": 144 }, "faceless-explainer": { - "hash": "0ed7d992d246bea4", - "files": 19 + "hash": "2d1cb6eabafebdda", + "files": 20 }, "figma": { "hash": "0e6e96f5a76ff824", diff --git a/skills/faceless-explainer/scripts/captions.mjs b/skills/faceless-explainer/scripts/captions.mjs index 433248deb8..e7e7e401d4 100644 --- a/skills/faceless-explainer/scripts/captions.mjs +++ b/skills/faceless-explainer/scripts/captions.mjs @@ -32,7 +32,13 @@ // Grouping mirrors the proven heuristics (frame boundary · sentence-end punct · // silence gap · density-aware word cap); word timings come inline from audio_meta. -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { dirname, join, resolve } from "node:path"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; @@ -64,26 +70,42 @@ function runBuild(argv) { }; const hyperframesDir = resolve(flag(argv, "hyperframes", ".")); - const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md"))); - const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json"))); - const outPath = resolve(flag(argv, "out", join(hyperframesDir, "caption_groups.json"))); + const storyboardPath = resolve( + flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")), + ); + const audioMetaPath = resolve( + flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")), + ); + const outPath = resolve( + flag(argv, "out", join(hyperframesDir, "caption_groups.json")), + ); const htmlPath = join(hyperframesDir, "compositions/captions.html"); const overridesPath = join(hyperframesDir, "caption-overrides.json"); const skinArg = flag(argv, "skin", null); - const hiddenSkinPath = join(hyperframesDir, ".hyperframes", "caption-skin.html"); + const hiddenSkinPath = join( + hyperframesDir, + ".hyperframes", + "caption-skin.html", + ); const legacySkinPath = join(hyperframesDir, "caption-skin.html"); const skinPath = resolve( skinArg ?? (existsSync(hiddenSkinPath) ? hiddenSkinPath : legacySkinPath), ); - const framePath = resolve(flag(argv, "frame", join(hyperframesDir, "frame.md"))); + const framePath = resolve( + flag(argv, "frame", join(hyperframesDir, "frame.md")), + ); - if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); + if (!existsSync(storyboardPath)) + die(`STORYBOARD.md not found at ${storyboardPath}`); const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8")); const { width: W, height: H } = parseFormat(manifest.globals.format); + const language = String(manifest.globals.extra?.language ?? "").toLowerCase(); + const compactWordSpacing = /^(?:zh|ja)(?:[-_]|$)/.test(language); if (!existsSync(audioMetaPath)) skip("no audio_meta.json (silent film)"); const meta = JSON.parse(readFileSync(audioMetaPath, "utf8")); - if (!Array.isArray(meta.voices) || meta.voices.length === 0) skip("no narration"); + if (!Array.isArray(meta.voices) || meta.voices.length === 0) + skip("no narration"); // cumulative frame starts (by frame number) + total duration, from STORYBOARD. const startByFrame = new Map(); @@ -103,7 +125,12 @@ function runBuild(argv) { const text = String(w.text ?? "").trim(); if (!text || /^[.?!,;:—–-]+$/.test(text)) continue; // drop empties + bare punctuation if (!isFinite(w.start) || !isFinite(w.end)) continue; - words.push({ text, start: r3(base + w.start), end: r3(base + w.end), frame: v.frame }); + words.push({ + text, + start: r3(base + w.start), + end: r3(base + w.end), + frame: v.frame, + }); } } words.sort((a, b) => a.start - b.start); @@ -113,7 +140,12 @@ function runBuild(argv) { const densityAt = (i) => { const t0 = words[i].start; let n = 0; - for (let j = i; j < words.length && words[j].start < t0 + DENSITY_WINDOW; j++) n++; + for ( + let j = i; + j < words.length && words[j].start < t0 + DENSITY_WINDOW; + j++ + ) + n++; return n / DENSITY_WINDOW; }; @@ -151,7 +183,7 @@ function runBuild(argv) { frame: g.frame, start: r3(first.start), end, - text: g.words.map((w) => w.text).join(" "), + text: g.words.map((w) => w.text).join(compactWordSpacing ? "" : " "), words: g.words.map((w, wi) => ({ id: `caption-word-${gi}-${wi}`, text: w.text, @@ -165,7 +197,11 @@ function runBuild(argv) { mkdirSync(dirname(outPath), { recursive: true }); writeFileSync( outPath, - JSON.stringify({ total_duration_s: total, width: W, height: H, groups: finalized }, null, 2), + JSON.stringify( + { total_duration_s: total, width: W, height: H, groups: finalized }, + null, + 2, + ), ); // ── write compositions/captions.html (preset skin if present, else default) ── @@ -174,7 +210,9 @@ function runBuild(argv) { if (existsSync(skinPath)) { const tokens = frameTokensCss(framePath, H); const faces = brandFontFaces(framePath, hyperframesDir); - const fonts = existsSync(framePath) ? parseFonts(readFileSync(framePath, "utf8")) : {}; + const fonts = existsSync(framePath) + ? parseFonts(readFileSync(framePath, "utf8")) + : {}; writeFileSync( htmlPath, buildFromSkin( @@ -187,11 +225,15 @@ function runBuild(argv) { die, faces, fonts, + compactWordSpacing, ), ); source = `preset skin (${skinPath.replace(hyperframesDir + "/", "")})`; } else { - writeFileSync(htmlPath, buildCaptionsHtml(finalized, total, W, H)); + writeFileSync( + htmlPath, + buildCaptionsHtml(finalized, total, W, H, compactWordSpacing), + ); source = "default (built-in pill)"; } @@ -225,10 +267,22 @@ function runBuild(argv) { // so the active-word highlight clips — a line-height floor + word padding fixes it // · data-composition-id + dimensions on the