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
This commit is contained in:
路从今夜白
2026-10-12 21:08:50 +00:00
committed by GitHub
parent 4120042ca3
commit 73d89b81c7
3 changed files with 200 additions and 20 deletions
+2 -2
View File
@@ -54,8 +54,8 @@
"files": 1
},
"media-use": {
"hash": "1a841e4288d862af",
"files": 110
"hash": "f9ff3d9b23853032",
"files": 111
},
"motion-graphics": {
"hash": "6db77031ad839f6a",
@@ -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"),
);
+23 -18
View File
@@ -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;