diff --git a/skills-manifest.json b/skills-manifest.json index bb23beeb02..8e89f131f0 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "d28c7979e727a6a9", + "hash": "49352d2f3b42c0ba", "files": 101 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index 8385bd7a43..e46f7545f5 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -239,6 +239,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/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index 6952e8ccb2..cb37db9585 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/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 { join, dirname } 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,26 @@ test("ffprobeDuration returns NaN when neither ffprobe nor ffmpeg resolve", () = rmSync(dir, { recursive: true, force: true }); } }); + +test("synthesizeOne(elevenlabs) creates the output dir before writing", async () => { + const dir = mkdtempSync(join(tmpdir(), "tts-el-mkdir-")); + const wavAbs = join(dir, "assets", "voice", "line-0.wav"); // nested, not yet created + const savedKey = process.env.ELEVENLABS_API_KEY; + try { + // Unset the key so the Python side fails fast — the mkdir must run before + // the spawn regardless, which is what this guards. + delete process.env.ELEVENLABS_API_KEY; + await synthesizeOne({ + provider: "elevenlabs", + text: "hi", + voiceId: "v", + wavAbs, + hyperframesDir: dir, + }); + assert.ok(existsSync(dirname(wavAbs)), "output directory should be created"); + } finally { + if (savedKey === undefined) delete process.env.ELEVENLABS_API_KEY; + else process.env.ELEVENLABS_API_KEY = savedKey; + rmSync(dir, { recursive: true, force: true }); + } +});