mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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"),
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user