diff --git a/skills-manifest.json b/skills-manifest.json index 0bb6f9c136..3a67f47987 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -42,7 +42,7 @@ "files": 3 }, "hyperframes-media": { - "hash": "60f73ecb91858ea3", + "hash": "899153ffac2d7e0d", "files": 47 }, "hyperframes-registry": { diff --git a/skills/hyperframes-media/scripts/lib/tts.mjs b/skills/hyperframes-media/scripts/lib/tts.mjs index afe353e676..99b75c432b 100644 --- a/skills/hyperframes-media/scripts/lib/tts.mjs +++ b/skills/hyperframes-media/scripts/lib/tts.mjs @@ -216,6 +216,18 @@ export async function synthesizeOne({ }) { if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }); if (provider === "elevenlabs") { + // The Python helper writes straight to wavAbs; unlike heygen (transcodeToWav) + // and kokoro (the `hyperframes tts` CLI), it does NOT create the parent dir, + // so on a fresh project (no assets/voice/ yet) the save fails and the line is + // silently dropped as "TTS failed - omitted". Create it first, like the + // other providers do. Guarded so a mkdir failure (EACCES/EROFS) returns + // { ok:false } like the rest of this branch rather than throwing — the + // function's contract is "never throws; failures return { ok:false }". + try { + mkdirSync(dirname(wavAbs), { recursive: true }); + } catch { + return { ok: false, words: null }; + } const { cmd, args } = pythonInvocation([ "-c", ELEVENLABS_PY, diff --git a/skills/hyperframes-media/scripts/lib/tts.test.mjs b/skills/hyperframes-media/scripts/lib/tts.test.mjs index 6952e8ccb2..d0b455220b 100644 --- a/skills/hyperframes-media/scripts/lib/tts.test.mjs +++ b/skills/hyperframes-media/scripts/lib/tts.test.mjs @@ -1,9 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { parseFfmpegDurationBanner, ffprobeDuration } from "./tts.mjs"; +import { parseFfmpegDurationBanner, ffprobeDuration, synthesizeOne } from "./tts.mjs"; test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => { const stderr = [ @@ -64,3 +64,25 @@ test("ffprobeDuration returns NaN when neither ffprobe nor ffmpeg resolve", () = rmSync(dir, { recursive: true, force: true }); } }); + +// Regression: the elevenlabs provider spawned Python to write straight to +// wavAbs without creating its parent dir first (unlike heygen/kokoro), so a +// fresh project with no assets/voice/ yet failed silently ("TTS failed - +// omitted"). synthesizeOne must create the output dir before synthesizing — +// asserted independently of whether the (keyless) Python actually produces a +// file, since the mkdir runs before the spawn either way. +test("synthesizeOne(elevenlabs) creates the output dir before writing", async () => { + const base = mkdtempSync(join(tmpdir(), "hf-tts-eleven-")); + const wavAbs = join(base, "assets", "voice", "line-0.wav"); // nested, not yet created + const originalKey = process.env.ELEVENLABS_API_KEY; + delete process.env.ELEVENLABS_API_KEY; // keeps the Python side a fast, clean failure + try { + // May resolve/spawn Python and fail (no key) — we only require the dir exists. + await synthesizeOne({ provider: "elevenlabs", text: "hi", voiceId: "v1", wavAbs }); + assert.ok(existsSync(dirname(wavAbs)), "output directory should be created"); + } finally { + if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY; + else process.env.ELEVENLABS_API_KEY = originalKey; + rmSync(base, { recursive: true, force: true }); + } +});