From 73d89b81c7617c22fcfcb2e5d182faa8b9410776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E4=BB=8E=E4=BB=8A=E5=A4=9C=E7=99=BD?= <1174273035@qq.com> Date: Mon, 12 Oct 2026 21:08:50 +0000 Subject: [PATCH] fix(media-use): clean up temporary TTS text directories (#5477) * fix(media-use): clean up temporary TTS text files * chore(skills): refresh manifest for TTS cleanup --- skills-manifest.json | 4 +- .../audio/scripts/lib/tts.cleanup.test.mjs | 175 ++++++++++++++++++ skills/media-use/audio/scripts/lib/tts.mjs | 41 ++-- 3 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index fbf87df126..237a19ed3e 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,8 +54,8 @@ "files": 1 }, "media-use": { - "hash": "1a841e4288d862af", - "files": 110 + "hash": "f9ff3d9b23853032", + "files": 111 }, "motion-graphics": { "hash": "6db77031ad839f6a", diff --git a/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs b/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs new file mode 100644 index 0000000000..1275e60706 --- /dev/null +++ b/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs @@ -0,0 +1,175 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ttsUrl = new URL("./tts.mjs", import.meta.url).href; +const stub = `#!${process.execPath} +const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +if (args[0] === "--version") { + console.log("Python 3.12.0"); + process.exit(0); +} +const input = args[2]; +const output = args[0] === "-c" ? args[4] : args[args.indexOf("--output") + 1]; +const text = fs.readFileSync(input, "utf8"); +fs.writeFileSync(output + ".started", input); +const finish = () => { + if (text === "slow" && !fs.existsSync(process.env.RELEASE_FILE)) { + setTimeout(finish, 5); + return; + } + const consumed = fs.readFileSync(input, "utf8"); + fs.writeFileSync(output + ".json", JSON.stringify({ input, text, consumed, args })); + if (process.env.CASE_MODE === "nonzero") process.exit(7); + if (process.env.CASE_MODE !== "no-output") fs.writeFileSync(output, consumed); +}; +setTimeout(finish, 10); +`; + +const exercise = ` +import assert from "node:assert/strict"; +import fs from "node:fs"; +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { join, dirname } from "node:path"; +const { CASE_ROOT: root, CASE_MODE: mode, CASE_PROVIDER: provider } = process.env; +const temp = join(root, "tmp"); +const preexisting = join(temp, "hf-txt-preexisting"); +fs.mkdirSync(preexisting); +fs.writeFileSync(join(preexisting, "marker"), "keep"); +const injected = Object.assign(new Error("synthetic " + mode), { code: "EACCES" }); +const originalMkdtemp = fs.mkdtempSync; +const originalWrite = fs.writeFileSync; +const originalRm = fs.rmSync; +if (mode === "allocation") fs.mkdtempSync = (prefix, ...args) => { + if (prefix.startsWith(join(temp, "hf-txt-"))) throw injected; + return originalMkdtemp(prefix, ...args); +}; +if (mode === "write") fs.writeFileSync = (file, ...args) => { + if (String(file).endsWith("line.txt")) { + originalWrite(file, "partial"); + throw injected; + } + return originalWrite(file, ...args); +}; +if (mode === "spawn-throw") childProcess.spawn = () => { throw injected; }; +if (mode === "cleanup") fs.rmSync = (dir, ...args) => { + if (String(dir).startsWith(join(temp, "hf-txt-"))) throw injected; + return originalRm(dir, ...args); +}; +syncBuiltinESMExports(); +const { synthesizeOne } = await import(${JSON.stringify(ttsUrl)}); +const project = join(root, "project"); +const wavAbs = join(project, "line.wav"); +const request = (chosen = provider, text = "synthetic café", output = wavAbs) => + synthesizeOne({ provider: chosen, text, voiceId: "synthetic-voice", lang: "es", + speed: 0.8, wavAbs: output, hyperframesDir: project }); +const owned = () => fs.readdirSync(temp).filter(name => name !== "hf-txt-preexisting"); +if (["allocation", "write", "spawn-throw"].includes(mode)) { + await assert.rejects(request(), error => error === injected); + assert.equal(fs.existsSync(wavAbs), false); +} else if (mode === "concurrent") { + const slowWav = join(project, "slow.wav"); + const slow = request("elevenlabs", "slow", slowWav); + try { + const deadline = Date.now() + 5000; + while (!fs.existsSync(slowWav + ".started")) { + assert.ok(Date.now() < deadline, "slow subprocess must start"); + await new Promise(resolve => setTimeout(resolve, 5)); + } + const slowInput = fs.readFileSync(slowWav + ".started", "utf8"); + assert.deepEqual(await request("kokoro"), { ok: true, words: null }); + assert.deepEqual(owned(), [dirname(slowInput).split(/[\\\\/]/).pop()]); + assert.equal(fs.readFileSync(slowInput, "utf8"), "slow"); + } finally { + fs.writeFileSync(process.env.RELEASE_FILE, "release"); + await slow; + } + assert.equal(fs.readFileSync(slowWav, "utf8"), "slow"); +} else { + const result = await request(); + const label = provider === "elevenlabs" ? "elevenlabs (python)" : "kokoro (npx hyperframes tts)"; + if (["nonzero", "enoent", "no-output"].includes(mode)) { + const error = mode === "no-output" ? label + " produced no wav file" : + label + " exited with status " + (mode === "enoent" ? -1 : 7); + assert.deepEqual(result, { ok: false, words: null, error }); + assert.equal(fs.existsSync(wavAbs), false); + } else { + assert.deepEqual(result, { ok: true, words: null }); + assert.equal(fs.readFileSync(wavAbs, "utf8"), "synthetic café"); + } + if (mode !== "enoent") { + const log = JSON.parse(fs.readFileSync(wavAbs + ".json", "utf8")); + assert.equal(log.text, "synthetic café"); + assert.equal(log.consumed, log.text); + if (provider === "kokoro") assert.deepEqual(log.args.slice(-4), ["--lang", "es", "--speed", "0.8"]); + } +} +if (mode === "cleanup") assert.equal(owned().length, 1); +else assert.deepEqual(owned(), [], "owned text directories must be removed"); +assert.equal(fs.readFileSync(join(preexisting, "marker"), "utf8"), "keep"); +`; + +function runCase(provider, mode) { + const root = mkdtempSync(join(tmpdir(), "tts-cleanup-test-")); + try { + for (const dir of ["home", "tmp", "bin", "project"]) mkdirSync(join(root, dir)); + if (mode !== "enoent") { + for (const name of ["npx", "python3"]) { + const executable = join(root, "bin", name); + writeFileSync(executable, stub); + chmodSync(executable, 0o755); + } + } + const result = spawnSync(process.execPath, ["--input-type=module", "-e", exercise], { + encoding: "utf8", + timeout: 15000, + env: { + HOME: join(root, "home"), + USERPROFILE: join(root, "home"), + TMPDIR: join(root, "tmp"), + TMP: join(root, "tmp"), + TEMP: join(root, "tmp"), + PATH: join(root, "bin"), + CASE_ROOT: root, + CASE_PROVIDER: provider, + CASE_MODE: mode, + RELEASE_FILE: join(root, "release"), + }, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stdout + result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +for (const provider of ["elevenlabs", "kokoro"]) { + for (const mode of [ + "success", + "nonzero", + "no-output", + "enoent", + "spawn-throw", + "allocation", + "write", + "cleanup", + ]) { + test( + `synthesizeOne(${provider}) preserves ${mode} behavior and cleans only its text directory`, + { skip: process.platform === "win32" }, + () => runCase(provider, mode), + ); + } +} + +test( + "concurrent providers retain each other's text until their subprocess finishes", + { skip: process.platform === "win32" }, + () => runCase("kokoro", "concurrent"), +); diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index 73d2a688e5..43f192e347 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -274,23 +274,21 @@ export async function synthesizeOne({ } catch { return { ok: false, words: null }; } - const { cmd, args } = pythonInvocation([ - "-c", - ELEVENLABS_PY, - writeTmpText(text), - voiceId, - wavAbs, - ]); - const r = await spawnP(cmd, args, {}); - return synthResult(r, wavAbs, "elevenlabs (python)"); + return withTmpText(text, async (textPath) => { + const { cmd, args } = pythonInvocation(["-c", ELEVENLABS_PY, textPath, voiceId, wavAbs]); + const r = await spawnP(cmd, args, {}); + 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); - if (speed !== 1) args.push("--speed", String(speed)); - const r = await spawnP("npx", args, { cwd: hyperframesDir }); - return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)"); + return withTmpText(text, async (textPath) => { + const args = ["hyperframes", "tts", textPath, "--voice", voiceId, "--output", wavRel]; + if (lang !== "en") args.push("--lang", lang); + if (speed !== 1) args.push("--speed", String(speed)); + const r = await spawnP("npx", args, { cwd: hyperframesDir }); + return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)"); + }); } // Shape a spawn result into { ok, words, error }, naming why on failure so the @@ -378,11 +376,18 @@ export async function transcribeWav({ wavRel, lang = "en", hyperframesDir }) { } // ── tiny local utils ────────────────────────────────────────────────────────── -function writeTmpText(text) { +async function withTmpText(text, run) { const td = mkdtempSync(join(tmpdir(), "hf-txt-")); - const p = join(td, "line.txt"); - writeFileSync(p, text); - return p; + try { + const p = join(td, "line.txt"); + writeFileSync(p, text); + return await run(p); + } finally { + // Cleanup must preserve the synthesis result or the original write/spawn error. + try { + rmSync(td, { recursive: true, force: true }); + } catch {} + } } function relTo(base, abs) { return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs;