From 09f4575b0ad144b76bc7d79d9f28d8224e456558 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 6 Jul 2026 04:56:14 -0400 Subject: [PATCH 1/3] fix(hyperframes-media): surface the real reason a TTS line failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit synthesizeHeygen() swallowed every failure into a bare { ok:false }: a thrown HTTP error (e.g. 402 plan_upgrade_required from heygenJSON) was caught and discarded, a missing audio_url / failed audio fetch / failed transcode all returned nothing. audio.mjs then logged 'TTS failed — omitted' for every line with zero detail, so the actual cause took a hand-rolled repro to find. Each failure path now returns an { error } string (the caught message, the HTTP status, or the specific stage that failed), and audio.mjs appends it to the anomaly. The subprocess providers (elevenlabs/kokoro) get the same treatment via a shared synthResult() helper. synthesizeHeygen takes an injectable deps arg so the failure paths are unit-tested (thrown 402, non-ok fetch, missing audio_url). --- skills-manifest.json | 52 +++++++++--------- skills/media-use/audio/scripts/audio.mjs | 4 +- skills/media-use/audio/scripts/lib/tts.mjs | 54 ++++++++++++++----- .../media-use/audio/scripts/lib/tts.test.mjs | 51 +++++++++++++++++- 4 files changed, 119 insertions(+), 42 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 9321089cb1..aae12feabc 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,83 +2,83 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "fe837c08dd47b077", + "hash": "d870992d206c9ddc", "files": 144 }, "faceless-explainer": { - "hash": "a09191a97564b114", + "hash": "80882d8135b2aeac", "files": 18 }, "figma": { - "hash": "0e6e96f5a76ff824", - "files": 2 + "hash": "26676992c1aed316", + "files": 1 }, "general-video": { - "hash": "1aed9f4f68414a45", + "hash": "6cb3fbdf9b860ba6", "files": 1 }, "hyperframes": { - "hash": "fce43b4c3355cde9", + "hash": "4e0795ead8c8bfa3", "files": 1 }, "hyperframes-animation": { - "hash": "b982a1af9821e326", - "files": 99 + "hash": "0d0bd375f292a04a", + "files": 116 }, "hyperframes-cli": { - "hash": "f1c8c70693101e59", + "hash": "493e59cafaab3d10", "files": 7 }, "hyperframes-core": { - "hash": "690ebb3ba5420b90", - "files": 14 + "hash": "788462fc5c3b9ee1", + "files": 13 }, "hyperframes-creative": { - "hash": "3e5bcbf46dc14427", - "files": 69 + "hash": "18a14a79da6cbc06", + "files": 68 }, "hyperframes-keyframes": { - "hash": "040453e302a0e15b", + "hash": "555c0cd491c40cea", "files": 3 }, "hyperframes-registry": { - "hash": "5f49178cc43e100b", + "hash": "e3b389526834109d", "files": 10 }, "media-use": { - "hash": "49aa5adae0800403", - "files": 122 + "hash": "79605197974012b2", + "files": 101 }, "motion-graphics": { - "hash": "dafcdce07d221aa4", + "hash": "f5a432862116b39a", "files": 23 }, "music-to-video": { - "hash": "ccb6181af8bcef09", + "hash": "42aba6b650599962", "files": 132 }, "pr-to-video": { - "hash": "21dcf8aa94fc1033", + "hash": "71dce61cff0233f2", "files": 22 }, "product-launch-video": { - "hash": "cb30ad95ba8863c7", - "files": 21 + "hash": "ac4e1e4aa671fe27", + "files": 20 }, "remotion-to-hyperframes": { - "hash": "aa599f027db4b994", + "hash": "6d4b352ecd46c8fb", "files": 70 }, "slideshow": { - "hash": "0f0364c54f77cb9b", + "hash": "764746e9199213b6", "files": 2 }, "talking-head-recut": { - "hash": "d5c51342625c9952", + "hash": "fbb95a4c6062c41a", "files": 27 }, "website-to-video": { - "hash": "e8143700c50b7469", + "hash": "d81ede8e9cabd09b", "files": 32 } } diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index a332dc7d3b..bc005eeff9 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -145,7 +145,7 @@ if (only.has("tts") && lines.length) { } const rel = `assets/voice/${id}.wav`; const abs = join(hyperframesDir, rel); - const { ok, words } = await synthesizeOne({ + const { ok, words, error } = await synthesizeOne({ provider: ttsProvider, text, voiceId, @@ -155,7 +155,7 @@ if (only.has("tts") && lines.length) { hyperframesDir, }); if (!ok) { - anomalies.push(`line ${id}: TTS failed — omitted`); + anomalies.push(`line ${id}: TTS failed — omitted${error ? ` (${error})` : ""}`); return null; } let wordArr = words; // heygen: native; else transcribe diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index e46f7545f5..a54c150e1b 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -225,9 +225,10 @@ save(audio, sys.argv[3]) `; // ── synthesize one line ─────────────────────────────────────────────────────── -// Writes wav at wavAbs. Returns { ok, words } — words is the raw +// Writes wav at wavAbs. Returns { ok, words, error } — words is the raw // [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro -// (caller must transcribeWav). Never throws; failures return { ok:false }. +// (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 async function synthesizeOne({ provider, text, @@ -259,34 +260,61 @@ export async function synthesizeOne({ wavAbs, ]); const r = await spawnP(cmd, args, {}); - return { ok: r.status === 0 && existsSync(wavAbs), words: null }; + return synthResult(r, wavAbs, "elevenlabs (python)"); } // 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 r = await spawnP("npx", args, { cwd: hyperframesDir }); - return { ok: r.status === 0 && existsSync(wavAbs), words: null }; + return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)"); } -async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) { +// Shape a spawn result into { ok, words, error }, naming why on failure so the +// caller surfaces it instead of a bare "TTS failed". +export function synthResult(r, wavAbs, label) { + if (r.status === 0 && existsSync(wavAbs)) return { ok: true, words: null }; + const why = + r.status !== 0 ? `${label} exited with status ${r.status}` : `${label} produced no wav file`; + return { ok: false, words: null, error: why }; +} + +// `deps` is injectable for tests; production uses the real network/ffmpeg impls. +// Every failure path returns an `error` string so the caller can surface WHY a +// line was dropped instead of the bare "TTS failed" that hid the real cause +// (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 fetchImpl = deps.fetch ?? fetch; + const transcode = deps.transcodeToWav ?? transcodeToWav; try { const body = { text, voice_id: voiceId, speed }; if (lang !== "en") body.language = lang; - const payload = await heygenJSON(`/voices/speech`, { + const payload = await requestJSON(`/voices/speech`, { method: "POST", - headers: heygenAuthHeaders(), + headers: authHeaders(), body, }); const inner = payload.data ?? payload; - if (!inner.audio_url) return { ok: false, words: null }; - const res = await fetch(inner.audio_url); - if (!res.ok) return { ok: false, words: null }; + if (!inner.audio_url) { + return { ok: false, words: null, error: "HeyGen /voices/speech returned no audio_url" }; + } + const res = await fetchImpl(inner.audio_url); + if (!res.ok) { + return { ok: false, words: null, error: `audio_url fetch failed: HTTP ${res.status}` }; + } const bytes = Buffer.from(await res.arrayBuffer()); // .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The // engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3. if (wavAbs.endsWith(".wav")) { - if (!transcodeToWav(bytes, wavAbs)) return { ok: false, words: null }; + if (!transcode(bytes, wavAbs)) { + return { + ok: false, + words: null, + error: "wav transcode failed (ffmpeg — see output above)", + }; + } } else { mkdirSync(dirname(wavAbs), { recursive: true }); writeFileSync(wavAbs, bytes); @@ -298,8 +326,8 @@ async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) { .map((w) => ({ text: w.word, start: w.start, end: w.end })) : []; return { ok: true, words }; - } catch { - return { ok: false, words: null }; + } catch (e) { + return { ok: false, words: null, error: e?.message ? String(e.message) : String(e) }; } } diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index cb37db9585..7e18b77a89 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -3,7 +3,13 @@ import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; -import { parseFfmpegDurationBanner, ffprobeDuration, synthesizeOne } from "./tts.mjs"; +import { + parseFfmpegDurationBanner, + ffprobeDuration, + synthesizeOne, + synthesizeHeygen, + synthResult, +} from "./tts.mjs"; test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => { const stderr = [ @@ -87,3 +93,46 @@ test("synthesizeOne(elevenlabs) creates the output dir before writing", async () rmSync(dir, { recursive: true, force: true }); } }); + +test("synthesizeHeygen surfaces a thrown HTTP error (e.g. 402) instead of swallowing it", async () => { + const res = await synthesizeHeygen( + { text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" }, + { + heygenAuthHeaders: () => ({}), + heygenJSON: async () => { + throw new Error("HeyGen POST /voices/speech → HTTP 402\nplan_upgrade_required"); + }, + }, + ); + assert.equal(res.ok, false); + assert.match(res.error, /402/); + assert.match(res.error, /plan_upgrade_required/); +}); + +test("synthesizeHeygen surfaces a failed audio_url fetch with its status", async () => { + const res = await synthesizeHeygen( + { text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" }, + { + heygenAuthHeaders: () => ({}), + heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }), + fetch: async () => ({ ok: false, status: 403 }), + }, + ); + assert.equal(res.ok, false); + assert.match(res.error, /HTTP 403/); +}); + +test("synthesizeHeygen reports a missing audio_url", async () => { + const res = await synthesizeHeygen( + { text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" }, + { heygenAuthHeaders: () => ({}), heygenJSON: async () => ({}) }, + ); + assert.equal(res.ok, false); + assert.match(res.error, /no audio_url/); +}); + +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); + assert.match(res.error, /kokoro .* exited with status 2/); +}); From 52a4335c6a50b4b0f5309bd6746b8b43d45685c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 11 Jul 2026 21:55:14 +0000 Subject: [PATCH 2/3] fix(media-use): report wav transcode failures accurately --- skills/media-use/audio/scripts/lib/tts.mjs | 2 +- .../media-use/audio/scripts/lib/tts.test.mjs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index a54c150e1b..1b61e58fcd 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -312,7 +312,7 @@ export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, d return { ok: false, words: null, - error: "wav transcode failed (ffmpeg — see output above)", + error: "wav transcode failed (ffmpeg)", }; } } else { diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index 7e18b77a89..a4f651908d 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -131,6 +131,25 @@ test("synthesizeHeygen reports a missing audio_url", async () => { assert.match(res.error, /no audio_url/); }); +test("synthesizeHeygen reports wav transcode failures", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-tts-test-")); + try { + const res = await synthesizeHeygen( + { text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: join(dir, "voice.wav") }, + { + heygenAuthHeaders: () => ({}), + heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }), + fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }), + transcodeToWav: () => false, + }, + ); + assert.equal(res.ok, false); + assert.equal(res.error, "wav transcode failed (ffmpeg)"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + 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 371b163c5140ec81a4692a5cc9d8ac3dc0bb1f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 11 Jul 2026 21:56:54 +0000 Subject: [PATCH 3/3] chore: regenerate skills manifest --- skills-manifest.json | 52 ++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index aae12feabc..5a28a54f50 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,83 +2,83 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "d870992d206c9ddc", + "hash": "fe837c08dd47b077", "files": 144 }, "faceless-explainer": { - "hash": "80882d8135b2aeac", + "hash": "a09191a97564b114", "files": 18 }, "figma": { - "hash": "26676992c1aed316", - "files": 1 + "hash": "0e6e96f5a76ff824", + "files": 2 }, "general-video": { - "hash": "6cb3fbdf9b860ba6", + "hash": "1aed9f4f68414a45", "files": 1 }, "hyperframes": { - "hash": "4e0795ead8c8bfa3", + "hash": "fce43b4c3355cde9", "files": 1 }, "hyperframes-animation": { - "hash": "0d0bd375f292a04a", - "files": 116 + "hash": "b982a1af9821e326", + "files": 99 }, "hyperframes-cli": { - "hash": "493e59cafaab3d10", + "hash": "f1c8c70693101e59", "files": 7 }, "hyperframes-core": { - "hash": "788462fc5c3b9ee1", - "files": 13 + "hash": "690ebb3ba5420b90", + "files": 14 }, "hyperframes-creative": { - "hash": "18a14a79da6cbc06", - "files": 68 + "hash": "3e5bcbf46dc14427", + "files": 69 }, "hyperframes-keyframes": { - "hash": "555c0cd491c40cea", + "hash": "040453e302a0e15b", "files": 3 }, "hyperframes-registry": { - "hash": "e3b389526834109d", + "hash": "5f49178cc43e100b", "files": 10 }, "media-use": { - "hash": "79605197974012b2", - "files": 101 + "hash": "389942983c2c78c2", + "files": 122 }, "motion-graphics": { - "hash": "f5a432862116b39a", + "hash": "dafcdce07d221aa4", "files": 23 }, "music-to-video": { - "hash": "42aba6b650599962", + "hash": "ccb6181af8bcef09", "files": 132 }, "pr-to-video": { - "hash": "71dce61cff0233f2", + "hash": "21dcf8aa94fc1033", "files": 22 }, "product-launch-video": { - "hash": "ac4e1e4aa671fe27", - "files": 20 + "hash": "cb30ad95ba8863c7", + "files": 21 }, "remotion-to-hyperframes": { - "hash": "6d4b352ecd46c8fb", + "hash": "aa599f027db4b994", "files": 70 }, "slideshow": { - "hash": "764746e9199213b6", + "hash": "0f0364c54f77cb9b", "files": 2 }, "talking-head-recut": { - "hash": "fbb95a4c6062c41a", + "hash": "d5c51342625c9952", "files": 27 }, "website-to-video": { - "hash": "d81ede8e9cabd09b", + "hash": "e8143700c50b7469", "files": 32 } }