hyperframes 0.8.58 → 0.8.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +4483 -3217
- package/dist/docs/data-attributes.md +2 -1
- package/dist/fontLocalizeCli.js +73 -12
- package/dist/hyperframe-runtime.js +25 -25
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +25 -25
- package/dist/hyperframes-player.global.js +3 -3
- package/dist/registry/localSemantic.js +548 -0
- package/dist/skills/hyperframes-cli/SKILL.md +2 -2
- package/dist/skills/media-use/SKILL.md +96 -0
- package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
- package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
- package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
- package/dist/skills/media-use/audio/references/bgm.md +74 -0
- package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
- package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
- package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
- package/dist/skills/media-use/audio/references/remove-background.md +143 -0
- package/dist/skills/media-use/audio/references/requirements.md +29 -0
- package/dist/skills/media-use/audio/references/sfx.md +42 -0
- package/dist/skills/media-use/audio/references/transcribe.md +52 -0
- package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
- package/dist/skills/media-use/audio/references/tts.md +165 -0
- package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
- package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
- package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
- package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
- package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
- package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
- package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
- package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
- package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
- package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
- package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
- package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
- package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
- package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
- package/dist/skills/media-use/luts/README.md +28 -0
- package/dist/skills/media-use/luts/index.json +57 -0
- package/dist/skills/media-use/references/audio.md +19 -0
- package/dist/skills/media-use/references/grading.md +160 -0
- package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
- package/dist/skills/media-use/references/media-treatments.md +202 -0
- package/dist/skills/media-use/references/memory.md +36 -0
- package/dist/skills/media-use/references/meta.md +61 -0
- package/dist/skills/media-use/references/operations.md +325 -0
- package/dist/skills/media-use/references/resolve.md +150 -0
- package/dist/skills/media-use/references/setup-providers.md +80 -0
- package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
- package/dist/skills/media-use/registry/localSemantic.js +548 -0
- package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
- package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
- package/dist/skills/media-use/scripts/dither.mjs +328 -0
- package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
- package/dist/skills/media-use/scripts/eval.mjs +369 -0
- package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
- package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
- package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
- package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
- package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
- package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
- package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
- package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
- package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
- package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
- package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
- package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
- package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
- package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
- package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
- package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
- package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
- package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
- package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
- package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
- package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
- package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
- package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
- package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
- package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
- package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
- package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
- package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
- package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
- package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
- package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
- package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
- package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
- package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
- package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
- package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
- package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
- package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
- package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
- package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
- package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
- package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
- package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
- package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
- package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
- package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
- package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
- package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
- package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
- package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
- package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
- package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
- package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
- package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
- package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
- package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
- package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
- package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
- package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
- package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
- package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
- package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
- package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
- package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
- package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
- package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
- package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
- package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
- package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
- package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
- package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
- package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
- package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
- package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
- package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
- package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
- package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
- package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
- package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
- package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
- package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
- package/dist/skills/media-use/scripts/prefs.mjs +70 -0
- package/dist/skills/media-use/scripts/recipe.mjs +103 -0
- package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
- package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
- package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
- package/dist/skills/registry/localSemantic.js +548 -0
- package/dist/studio/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/studio/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/studio/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
- package/dist/studio/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
- package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/studio/index.d.ts +548 -8
- package/dist/studio/index.html +2 -2
- package/dist/studio/index.js +21596 -20725
- package/dist/studio/index.js.map +1 -1
- package/dist/studio/styles.css +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import {
|
|
5
|
+
resolveNpxCliFromNpmExecPath,
|
|
6
|
+
resolveNpxCliPath,
|
|
7
|
+
resolveSpawnCommand,
|
|
8
|
+
spawnP,
|
|
9
|
+
_resetNpxResolutionWarnForTests,
|
|
10
|
+
} from "./tts.mjs";
|
|
11
|
+
|
|
12
|
+
// Regression: on Windows, npx resolves to npx.cmd, which spawn() cannot exec
|
|
13
|
+
// without shell:true — it fails ENOENT, silently swallowed as ok:false by the
|
|
14
|
+
// caller. spawnP takes injectable platform/spawnFn params so this doesn't
|
|
15
|
+
// need to touch the real process.platform or mock node:child_process (whose
|
|
16
|
+
// ESM exports are non-configurable).
|
|
17
|
+
function fakeSpawn(captured) {
|
|
18
|
+
return (cmd, args, opts) => {
|
|
19
|
+
captured.push({ cmd, args, opts });
|
|
20
|
+
const p = new EventEmitter();
|
|
21
|
+
setImmediate(() => p.emit("exit", 0));
|
|
22
|
+
return p;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const envWithNpxCli = {
|
|
27
|
+
npm_execpath: "/opt/node/lib/node_modules/npm/bin/npm-cli.js",
|
|
28
|
+
npm_node_execpath: "/opt/node/bin/node",
|
|
29
|
+
};
|
|
30
|
+
const npxCliPath = "/opt/node/lib/node_modules/npm/bin/npx-cli.js";
|
|
31
|
+
const pathExists = (path) => path === npxCliPath;
|
|
32
|
+
|
|
33
|
+
test("resolveNpxCliFromNpmExecPath finds npx-cli next to npm-cli", () => {
|
|
34
|
+
assert.equal(resolveNpxCliFromNpmExecPath(envWithNpxCli.npm_execpath, pathExists), npxCliPath);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("resolveNpxCliPath finds npx-cli beside node when npm_execpath is unset", () => {
|
|
38
|
+
const node = "C:/Program Files/nodejs/node.exe";
|
|
39
|
+
const expected = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
|
|
40
|
+
assert.equal(
|
|
41
|
+
resolveNpxCliPath(undefined, node, (path) => path === expected),
|
|
42
|
+
expected,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("resolveSpawnCommand routes npx through node+npx-cli on win32 without shell:true", () => {
|
|
47
|
+
const resolved = resolveSpawnCommand(
|
|
48
|
+
"npx",
|
|
49
|
+
["hyperframes", "tts", "C:\\Users\\Test User\\line.txt", "--voice", "am_michael"],
|
|
50
|
+
{},
|
|
51
|
+
"win32",
|
|
52
|
+
envWithNpxCli,
|
|
53
|
+
pathExists,
|
|
54
|
+
);
|
|
55
|
+
assert.ok(resolved);
|
|
56
|
+
assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath);
|
|
57
|
+
assert.deepEqual(resolved.args, [
|
|
58
|
+
npxCliPath,
|
|
59
|
+
"hyperframes",
|
|
60
|
+
"tts",
|
|
61
|
+
"C:\\Users\\Test User\\line.txt",
|
|
62
|
+
"--voice",
|
|
63
|
+
"am_michael",
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(resolved.opts.shell, undefined);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("resolveSpawnCommand preserves Windows npx shell metacharacters as argv data", () => {
|
|
69
|
+
const resolved = resolveSpawnCommand(
|
|
70
|
+
"npx",
|
|
71
|
+
["hyperframes", "tts", "hello & calc"],
|
|
72
|
+
{},
|
|
73
|
+
"win32",
|
|
74
|
+
envWithNpxCli,
|
|
75
|
+
pathExists,
|
|
76
|
+
);
|
|
77
|
+
assert.ok(resolved);
|
|
78
|
+
assert.deepEqual(resolved.args, [npxCliPath, "hyperframes", "tts", "hello & calc"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("spawnP uses the resolved node+npx-cli command for npx on win32", async () => {
|
|
82
|
+
const captured = [];
|
|
83
|
+
await spawnP(
|
|
84
|
+
"npx",
|
|
85
|
+
["hyperframes", "tts"],
|
|
86
|
+
{},
|
|
87
|
+
"win32",
|
|
88
|
+
fakeSpawn(captured),
|
|
89
|
+
envWithNpxCli,
|
|
90
|
+
pathExists,
|
|
91
|
+
);
|
|
92
|
+
assert.equal(captured.length, 1);
|
|
93
|
+
assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath);
|
|
94
|
+
assert.deepEqual(captured[0].args, [npxCliPath, "hyperframes", "tts"]);
|
|
95
|
+
assert.equal(captured[0].opts.shell, undefined);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("spawnP does not enable shell for npx on darwin/linux", async () => {
|
|
99
|
+
const captured = [];
|
|
100
|
+
await spawnP("npx", ["hyperframes", "tts"], {}, "darwin", fakeSpawn(captured));
|
|
101
|
+
assert.equal(captured[0].cmd, "npx");
|
|
102
|
+
assert.deepEqual(captured[0].args, ["hyperframes", "tts"]);
|
|
103
|
+
assert.equal(captured[0].opts.shell, undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("spawnP does not enable shell for non-npx commands even on win32", async () => {
|
|
107
|
+
const captured = [];
|
|
108
|
+
await spawnP("python3", ["-c", "pass"], {}, "win32", fakeSpawn(captured));
|
|
109
|
+
assert.equal(captured[0].cmd, "python3");
|
|
110
|
+
assert.deepEqual(captured[0].args, ["-c", "pass"]);
|
|
111
|
+
assert.equal(captured[0].opts.shell, undefined);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("spawnP resolves npx beside node when npm_execpath is unset on win32", async () => {
|
|
115
|
+
_resetNpxResolutionWarnForTests();
|
|
116
|
+
const captured = [];
|
|
117
|
+
const node = "C:/Program Files/nodejs/node.exe";
|
|
118
|
+
const npxCli = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
|
|
119
|
+
const result = await spawnP(
|
|
120
|
+
"npx",
|
|
121
|
+
["hyperframes", "tts"],
|
|
122
|
+
{},
|
|
123
|
+
"win32",
|
|
124
|
+
fakeSpawn(captured),
|
|
125
|
+
{ npm_node_execpath: node },
|
|
126
|
+
(path) => path === npxCli,
|
|
127
|
+
);
|
|
128
|
+
assert.equal(result.status, 0);
|
|
129
|
+
assert.equal(captured.length, 1);
|
|
130
|
+
assert.equal(captured[0].cmd, node);
|
|
131
|
+
assert.deepEqual(captured[0].args, [npxCli, "hyperframes", "tts"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("spawnP warns once with an accurate diagnostic when neither npx path exists", async () => {
|
|
135
|
+
_resetNpxResolutionWarnForTests();
|
|
136
|
+
const errors = [];
|
|
137
|
+
const originalError = console.error;
|
|
138
|
+
console.error = (message) => errors.push(String(message));
|
|
139
|
+
try {
|
|
140
|
+
const env = { npm_execpath: "C:/missing/npm-cli.js", npm_node_execpath: "C:/node/node.exe" };
|
|
141
|
+
const missing = () => false;
|
|
142
|
+
assert.equal(
|
|
143
|
+
(await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing))
|
|
144
|
+
.status,
|
|
145
|
+
-1,
|
|
146
|
+
);
|
|
147
|
+
assert.equal(
|
|
148
|
+
(await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing))
|
|
149
|
+
.status,
|
|
150
|
+
-1,
|
|
151
|
+
);
|
|
152
|
+
} finally {
|
|
153
|
+
console.error = originalError;
|
|
154
|
+
}
|
|
155
|
+
assert.equal(errors.length, 1);
|
|
156
|
+
assert.match(errors[0], /npm_execpath \(C:\/missing\/npm-cli\.js\)/);
|
|
157
|
+
assert.doesNotMatch(errors[0], /npm_execpath is not set/);
|
|
158
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
parseFfmpegDurationBanner,
|
|
8
|
+
ffprobeDuration,
|
|
9
|
+
synthesizeOne,
|
|
10
|
+
synthesizeHeygen,
|
|
11
|
+
synthResult,
|
|
12
|
+
} from "./tts.mjs";
|
|
13
|
+
|
|
14
|
+
test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => {
|
|
15
|
+
const stderr = [
|
|
16
|
+
"ffmpeg version 6.0",
|
|
17
|
+
"Input #0, wav, from 'a.wav':",
|
|
18
|
+
" Duration: 00:00:03.42, bitrate: 705 kb/s",
|
|
19
|
+
"At least one output file must be specified",
|
|
20
|
+
].join("\n");
|
|
21
|
+
assert.equal(parseFfmpegDurationBanner(stderr), 3.42);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("parseFfmpegDurationBanner handles an hours component", () => {
|
|
25
|
+
const stderr = " Duration: 01:02:03.50, start: 0.000000, bitrate: 128 kb/s";
|
|
26
|
+
assert.equal(parseFfmpegDurationBanner(stderr), 3723.5);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("parseFfmpegDurationBanner returns NaN when there is no Duration line", () => {
|
|
30
|
+
assert.ok(Number.isNaN(parseFfmpegDurationBanner("ffmpeg: command not found")));
|
|
31
|
+
assert.ok(Number.isNaN(parseFfmpegDurationBanner("")));
|
|
32
|
+
assert.ok(Number.isNaN(parseFfmpegDurationBanner(undefined)));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Regression for the actual bug: ffprobeDuration used to collapse "ffprobe
|
|
36
|
+
// binary is missing" (ENOENT — the "essentials"-style Windows ffmpeg build
|
|
37
|
+
// with no ffprobe.exe) and "file is genuinely unreadable" into the same NaN,
|
|
38
|
+
// giving audio.mjs no way to tell "measure differently" from "give up".
|
|
39
|
+
//
|
|
40
|
+
// Builds an isolated PATH containing only a fake `ffmpeg` stub (no `ffprobe`
|
|
41
|
+
// at all) so ffprobeDuration's spawnSync("ffprobe", ...) call ENOENTs for
|
|
42
|
+
// real, then verifies it recovers the duration via the ffmpeg fallback
|
|
43
|
+
// instead of returning NaN.
|
|
44
|
+
test("ffprobeDuration falls back to ffmpeg when the ffprobe binary itself is missing", () => {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), "tts-ffprobe-fallback-"));
|
|
46
|
+
const fakeFfmpeg = join(dir, "ffmpeg");
|
|
47
|
+
writeFileSync(
|
|
48
|
+
fakeFfmpeg,
|
|
49
|
+
"#!/bin/sh\necho 'Duration: 00:00:02.50, start: 0.000000, bitrate: 128 kb/s' 1>&2\nexit 1\n",
|
|
50
|
+
);
|
|
51
|
+
chmodSync(fakeFfmpeg, 0o755);
|
|
52
|
+
const originalPath = process.env.PATH;
|
|
53
|
+
try {
|
|
54
|
+
process.env.PATH = dir; // only the fake ffmpeg resolves; no real ffprobe on this PATH
|
|
55
|
+
assert.equal(ffprobeDuration("/does/not/matter.wav"), 2.5);
|
|
56
|
+
} finally {
|
|
57
|
+
process.env.PATH = originalPath;
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("ffprobeDuration returns NaN when neither ffprobe nor ffmpeg resolve", () => {
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), "tts-no-binaries-"));
|
|
64
|
+
const originalPath = process.env.PATH;
|
|
65
|
+
try {
|
|
66
|
+
process.env.PATH = dir; // empty directory — nothing resolves
|
|
67
|
+
assert.ok(Number.isNaN(ffprobeDuration("/does/not/matter.wav")));
|
|
68
|
+
} finally {
|
|
69
|
+
process.env.PATH = originalPath;
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("synthesizeOne(elevenlabs) creates the output dir before writing", async () => {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "tts-el-mkdir-"));
|
|
76
|
+
const wavAbs = join(dir, "assets", "voice", "line-0.wav"); // nested, not yet created
|
|
77
|
+
const savedKey = process.env.ELEVENLABS_API_KEY;
|
|
78
|
+
try {
|
|
79
|
+
// Unset the key so the Python side fails fast — the mkdir must run before
|
|
80
|
+
// the spawn regardless, which is what this guards.
|
|
81
|
+
delete process.env.ELEVENLABS_API_KEY;
|
|
82
|
+
await synthesizeOne({
|
|
83
|
+
provider: "elevenlabs",
|
|
84
|
+
text: "hi",
|
|
85
|
+
voiceId: "v",
|
|
86
|
+
wavAbs,
|
|
87
|
+
hyperframesDir: dir,
|
|
88
|
+
});
|
|
89
|
+
assert.ok(existsSync(dirname(wavAbs)), "output directory should be created");
|
|
90
|
+
} finally {
|
|
91
|
+
if (savedKey === undefined) delete process.env.ELEVENLABS_API_KEY;
|
|
92
|
+
else process.env.ELEVENLABS_API_KEY = savedKey;
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("synthesizeHeygen surfaces a thrown HTTP error (e.g. 402) instead of swallowing it", async () => {
|
|
98
|
+
const res = await synthesizeHeygen(
|
|
99
|
+
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
|
|
100
|
+
{
|
|
101
|
+
heygenAuthHeaders: () => ({}),
|
|
102
|
+
heygenJSON: async () => {
|
|
103
|
+
throw new Error("HeyGen POST /voices/speech → HTTP 402\nplan_upgrade_required");
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
assert.equal(res.ok, false);
|
|
108
|
+
assert.match(res.error, /402/);
|
|
109
|
+
assert.match(res.error, /plan_upgrade_required/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("synthesizeHeygen surfaces a failed audio_url fetch with its status", async () => {
|
|
113
|
+
const res = await synthesizeHeygen(
|
|
114
|
+
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
|
|
115
|
+
{
|
|
116
|
+
heygenAuthHeaders: () => ({}),
|
|
117
|
+
heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }),
|
|
118
|
+
fetch: async () => ({ ok: false, status: 403 }),
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
assert.equal(res.ok, false);
|
|
122
|
+
assert.match(res.error, /HTTP 403/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("synthesizeHeygen reports a missing audio_url", async () => {
|
|
126
|
+
const res = await synthesizeHeygen(
|
|
127
|
+
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
|
|
128
|
+
{ heygenAuthHeaders: () => ({}), heygenJSON: async () => ({}) },
|
|
129
|
+
);
|
|
130
|
+
assert.equal(res.ok, false);
|
|
131
|
+
assert.match(res.error, /no audio_url/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("synthesizeHeygen reports wav transcode failures", async () => {
|
|
135
|
+
const dir = mkdtempSync(join(tmpdir(), "hf-tts-test-"));
|
|
136
|
+
try {
|
|
137
|
+
const res = await synthesizeHeygen(
|
|
138
|
+
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: join(dir, "voice.wav") },
|
|
139
|
+
{
|
|
140
|
+
heygenAuthHeaders: () => ({}),
|
|
141
|
+
heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }),
|
|
142
|
+
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }),
|
|
143
|
+
transcodeToWav: () => false,
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
assert.equal(res.ok, false);
|
|
147
|
+
assert.equal(res.error, "wav transcode failed (ffmpeg)");
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(dir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("synthResult names a non-zero subprocess exit", () => {
|
|
154
|
+
const res = synthResult({ status: 2 }, "/tmp/none.wav", "kokoro (npx hyperframes tts)");
|
|
155
|
+
assert.equal(res.ok, false);
|
|
156
|
+
assert.match(res.error, /kokoro .* exited with status 2/);
|
|
157
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate BGM using Google Lyria RealTime API.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python lyria-recipe.py --output <path> --duration <seconds> [tuning flags]
|
|
6
|
+
|
|
7
|
+
Requires:
|
|
8
|
+
$GOOGLE_API_KEY or $GEMINI_API_KEY environment variable (treated as aliases).
|
|
9
|
+
pip install google-genai python-dotenv. audio.mjs Step 4b installs these on
|
|
10
|
+
demand when a key is set but google.genai is not importable; if that install
|
|
11
|
+
fails it falls back to local MusicGen rather than leaving the video with no BGM.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import wave
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Windows sizes stdio to the ANSI code page (cp1252). These scripts emit UTF-8 on
|
|
24
|
+
# every platform; say so rather than depending on the console's code page. Carry
|
|
25
|
+
# `errors` across: reconfigure() resets it to "strict", and CPython deliberately gives
|
|
26
|
+
# stderr "backslashreplace" so the diagnostic path can never itself raise.
|
|
27
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
28
|
+
if hasattr(_stream, "reconfigure"):
|
|
29
|
+
_stream.reconfigure(encoding="utf-8", errors=_stream.errors)
|
|
30
|
+
|
|
31
|
+
DEFAULT_PROMPT = "Uplifting corporate tech, bright and modern, gentle piano with synth pads"
|
|
32
|
+
SAMPLE_RATE = 48000
|
|
33
|
+
CHANNELS = 2
|
|
34
|
+
SAMPLE_WIDTH = 2 # 16-bit
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_args() -> argparse.Namespace:
|
|
38
|
+
p = argparse.ArgumentParser(description="Generate BGM via Google Lyria RealTime.")
|
|
39
|
+
p.add_argument("--output", required=True, help="Output WAV path.")
|
|
40
|
+
p.add_argument("--duration", type=float, required=True, help="Target duration in seconds.")
|
|
41
|
+
p.add_argument("--prompt", default=DEFAULT_PROMPT, help="Mood / instrumentation prompt.")
|
|
42
|
+
p.add_argument("--negative-prompt", default=None, help="Styles to exclude (optional).")
|
|
43
|
+
p.add_argument("--bpm", type=int, default=110)
|
|
44
|
+
p.add_argument("--brightness", type=float, default=0.8, help="0-1, higher = brighter mood.")
|
|
45
|
+
p.add_argument("--density", type=float, default=0.5, help="0-1, higher = fuller mix.")
|
|
46
|
+
p.add_argument(
|
|
47
|
+
"--scale",
|
|
48
|
+
default="MAJOR",
|
|
49
|
+
help="MAJOR / MINOR / PENTATONIC / etc. — see google.genai.types.Scale. Pass empty string for none.",
|
|
50
|
+
)
|
|
51
|
+
return p.parse_args()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def generate_bgm(args: argparse.Namespace) -> dict:
|
|
55
|
+
from google import genai
|
|
56
|
+
from google.genai import types
|
|
57
|
+
|
|
58
|
+
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") or ""
|
|
59
|
+
if not api_key:
|
|
60
|
+
raise RuntimeError("Neither GOOGLE_API_KEY nor GEMINI_API_KEY is set.")
|
|
61
|
+
|
|
62
|
+
client = genai.Client(
|
|
63
|
+
api_key=api_key,
|
|
64
|
+
http_options={"api_version": "v1alpha"},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
out_path = Path(args.output)
|
|
68
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
target_bytes = int(args.duration * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
|
|
71
|
+
|
|
72
|
+
cfg: dict = {"bpm": args.bpm, "temperature": 1.0}
|
|
73
|
+
if args.density is not None:
|
|
74
|
+
cfg["density"] = args.density
|
|
75
|
+
if args.brightness is not None:
|
|
76
|
+
cfg["brightness"] = args.brightness
|
|
77
|
+
if args.scale:
|
|
78
|
+
scale_enum = getattr(types.Scale, args.scale, None)
|
|
79
|
+
if scale_enum:
|
|
80
|
+
cfg["scale"] = scale_enum
|
|
81
|
+
|
|
82
|
+
prompts = [types.WeightedPrompt(text=args.prompt, weight=1.0)]
|
|
83
|
+
if args.negative_prompt:
|
|
84
|
+
prompts.append(types.WeightedPrompt(text=args.negative_prompt, weight=-1.0))
|
|
85
|
+
|
|
86
|
+
buf = bytearray()
|
|
87
|
+
timeout = args.duration + 8
|
|
88
|
+
|
|
89
|
+
async with client.aio.live.music.connect(
|
|
90
|
+
model="models/lyria-realtime-exp",
|
|
91
|
+
) as session:
|
|
92
|
+
await session.set_weighted_prompts(prompts=prompts)
|
|
93
|
+
await session.set_music_generation_config(
|
|
94
|
+
config=types.LiveMusicGenerationConfig(**cfg),
|
|
95
|
+
)
|
|
96
|
+
await session.play()
|
|
97
|
+
|
|
98
|
+
async def collect():
|
|
99
|
+
while len(buf) < target_bytes:
|
|
100
|
+
async for msg in session.receive():
|
|
101
|
+
sc = msg.server_content
|
|
102
|
+
if sc and sc.audio_chunks:
|
|
103
|
+
for chunk in sc.audio_chunks:
|
|
104
|
+
buf.extend(chunk.data)
|
|
105
|
+
if len(buf) >= target_bytes:
|
|
106
|
+
return
|
|
107
|
+
await asyncio.sleep(1e-6)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
await asyncio.wait_for(collect(), timeout=timeout)
|
|
111
|
+
except TimeoutError:
|
|
112
|
+
print(f"Timeout after {timeout:.0f}s, collected {len(buf)} bytes", file=sys.stderr)
|
|
113
|
+
|
|
114
|
+
audio = bytes(buf[:target_bytes])
|
|
115
|
+
with wave.open(str(out_path), "wb") as wf:
|
|
116
|
+
wf.setnchannels(CHANNELS)
|
|
117
|
+
wf.setsampwidth(SAMPLE_WIDTH)
|
|
118
|
+
wf.setframerate(SAMPLE_RATE)
|
|
119
|
+
wf.writeframes(audio)
|
|
120
|
+
|
|
121
|
+
actual_duration = len(audio) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
|
|
122
|
+
print(f"BGM: {out_path} ({actual_duration:.2f}s)")
|
|
123
|
+
return {"file": str(out_path), "duration_sec": round(actual_duration, 2)}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
args = parse_args()
|
|
128
|
+
try:
|
|
129
|
+
asyncio.run(generate_bgm(args))
|
|
130
|
+
except RuntimeError as exc:
|
|
131
|
+
print(f"BGM generation failed: {exc}", file=sys.stderr)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase 4c pre-assemble helper — wait for detached BGM, then write status.
|
|
3
|
+
//
|
|
4
|
+
// audio.mjs may launch Lyria / MusicGen in a detached process so voice work can
|
|
5
|
+
// keep moving. Before assemble-index.mjs decides whether to emit the BGM audio
|
|
6
|
+
// track, this script gives the background renderer a bounded chance to finish
|
|
7
|
+
// and converts log/process state into a small bgm_status.json file.
|
|
8
|
+
//
|
|
9
|
+
// Always exits 0 for normal pipeline use: missing/failed BGM should not block a
|
|
10
|
+
// voice/captions/SFX render. Structural invocation errors still exit 1.
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// node wait-bgm.mjs --audio-meta ./audio_meta.json --hyperframes . \
|
|
14
|
+
// [--timeout-ms 120000] [--interval-ms 2000] [--out ./bgm_status.json]
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const flag = (name, def) => {
|
|
21
|
+
const i = argv.indexOf(`--${name}`);
|
|
22
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function die(msg) {
|
|
26
|
+
console.error(`✗ wait-bgm.mjs: ${msg}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const audioMetaPath = resolve(flag("audio-meta", "./audio_meta.json"));
|
|
31
|
+
const hyperframesDir = resolve(flag("hyperframes", "."));
|
|
32
|
+
const outPath = resolve(flag("out", join(hyperframesDir, "bgm_status.json")));
|
|
33
|
+
const timeoutMs = Math.max(0, Number(flag("timeout-ms", "120000")) || 0);
|
|
34
|
+
const intervalMs = Math.max(250, Number(flag("interval-ms", "2000")) || 2000);
|
|
35
|
+
|
|
36
|
+
function sleep(ms) {
|
|
37
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isProcessAlive(pid) {
|
|
41
|
+
if (!pid || !Number.isFinite(Number(pid))) return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(Number(pid), 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readTail(path, maxChars = 6000) {
|
|
51
|
+
if (!path) return "";
|
|
52
|
+
try {
|
|
53
|
+
const txt = readFileSync(path, "utf8");
|
|
54
|
+
return txt.slice(Math.max(0, txt.length - maxChars));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return "";
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function detectFailure(logTail) {
|
|
62
|
+
if (!logTail) return "";
|
|
63
|
+
const lines = logTail.split("\n");
|
|
64
|
+
// Bare "out of range" over-matched benign BGM-renderer logs (e.g. a "sample rate
|
|
65
|
+
// out of range, resampling" notice), mislabelling a healthy track as failed and
|
|
66
|
+
// silently dropping the music. Anchor to the actual crash strings instead:
|
|
67
|
+
// Python "(list) index out of range" and torch "index … out of bounds".
|
|
68
|
+
const idx = lines.findIndex((line) =>
|
|
69
|
+
/(Traceback|IndexError|RuntimeError|Exception|Killed|No space left|Cannot allocate|index out of range|out of bounds)/i.test(
|
|
70
|
+
line,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
if (idx < 0) return "";
|
|
74
|
+
return lines.slice(idx).join("\n").trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeStatus(status) {
|
|
78
|
+
const payload = {
|
|
79
|
+
generated_at: new Date().toISOString(),
|
|
80
|
+
...status,
|
|
81
|
+
};
|
|
82
|
+
writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n");
|
|
83
|
+
return payload;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!existsSync(audioMetaPath)) die(`audio_meta.json missing at ${audioMetaPath}`);
|
|
87
|
+
|
|
88
|
+
const audioMeta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
|
|
89
|
+
const bgmPath = audioMeta.bgm?.path || "";
|
|
90
|
+
const bgmAbsPath = bgmPath ? join(hyperframesDir, bgmPath) : "";
|
|
91
|
+
const logPath = audioMeta.bgm_log || "";
|
|
92
|
+
const pid = audioMeta.bgm_pid || null;
|
|
93
|
+
|
|
94
|
+
const base = {
|
|
95
|
+
enabled: Boolean(audioMeta.bgm_pending && bgmPath),
|
|
96
|
+
provider: audioMeta.bgm_provider || null,
|
|
97
|
+
mode: audioMeta.bgm_mode || null,
|
|
98
|
+
path: bgmPath || null,
|
|
99
|
+
log: logPath || null,
|
|
100
|
+
pid,
|
|
101
|
+
target_duration_s: audioMeta.bgm_target_duration_s || null,
|
|
102
|
+
seed_duration_s: audioMeta.bgm_seed_duration_s || null,
|
|
103
|
+
loop_count: audioMeta.bgm_loop_count || null,
|
|
104
|
+
timeout_ms: timeoutMs,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (!base.enabled) {
|
|
108
|
+
const status = writeStatus({
|
|
109
|
+
...base,
|
|
110
|
+
status: "disabled",
|
|
111
|
+
ready: false,
|
|
112
|
+
waited_ms: 0,
|
|
113
|
+
message: "BGM not requested or disabled in audio_meta.json.",
|
|
114
|
+
});
|
|
115
|
+
console.log(`✓ bgm: ${status.status} (${status.message})`);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const started = Date.now();
|
|
120
|
+
let lastFailure = "";
|
|
121
|
+
let lastTail = "";
|
|
122
|
+
|
|
123
|
+
while (Date.now() - started <= timeoutMs) {
|
|
124
|
+
if (existsSync(bgmAbsPath)) {
|
|
125
|
+
const size = statSync(bgmAbsPath).size;
|
|
126
|
+
writeStatus({
|
|
127
|
+
...base,
|
|
128
|
+
status: "ready",
|
|
129
|
+
ready: true,
|
|
130
|
+
waited_ms: Date.now() - started,
|
|
131
|
+
size_bytes: size,
|
|
132
|
+
message: `BGM ready at ${bgmPath}.`,
|
|
133
|
+
});
|
|
134
|
+
console.log(`✓ bgm: ready (${bgmPath}, ${size}B)`);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
lastTail = readTail(logPath);
|
|
139
|
+
lastFailure = detectFailure(lastTail);
|
|
140
|
+
const alive = isProcessAlive(pid);
|
|
141
|
+
if (lastFailure || (!alive && logPath && existsSync(logPath))) {
|
|
142
|
+
const message = lastFailure
|
|
143
|
+
? `BGM renderer failed; see ${logPath}.`
|
|
144
|
+
: `BGM renderer exited without writing ${bgmPath}; see ${logPath}.`;
|
|
145
|
+
const status = writeStatus({
|
|
146
|
+
...base,
|
|
147
|
+
status: "failed",
|
|
148
|
+
ready: false,
|
|
149
|
+
waited_ms: Date.now() - started,
|
|
150
|
+
process_alive: alive,
|
|
151
|
+
message,
|
|
152
|
+
error_tail: lastFailure || lastTail.slice(-2000),
|
|
153
|
+
});
|
|
154
|
+
console.log(`! bgm: failed (${status.message})`);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (timeoutMs === 0) break;
|
|
159
|
+
await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (Date.now() - started))));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const status = writeStatus({
|
|
163
|
+
...base,
|
|
164
|
+
status: "timeout",
|
|
165
|
+
ready: false,
|
|
166
|
+
waited_ms: Date.now() - started,
|
|
167
|
+
process_alive: isProcessAlive(pid),
|
|
168
|
+
message: `Timed out waiting for ${bgmPath}; assemble-index will skip BGM if still absent.`,
|
|
169
|
+
log_tail: lastTail.slice(-2000),
|
|
170
|
+
});
|
|
171
|
+
console.log(`! bgm: timeout after ${status.waited_ms}ms (${bgmPath})`);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { runInNewContext } from "node:vm";
|
|
5
|
+
|
|
6
|
+
const source = readFileSync(new URL("./wait-bgm.mjs", import.meta.url), "utf8");
|
|
7
|
+
const start = source.indexOf("function readTail(");
|
|
8
|
+
const end = source.indexOf("\nfunction detectFailure(", start);
|
|
9
|
+
function load(read) {
|
|
10
|
+
return runInNewContext(`${source.slice(start, end)}; readTail`, {
|
|
11
|
+
readFileSync: read,
|
|
12
|
+
existsSync: () => true,
|
|
13
|
+
statSync: () => {
|
|
14
|
+
throw new Error("pathname stat must not precede log read");
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("reads a log once without a separate pathname check", () => {
|
|
20
|
+
let calls = 0;
|
|
21
|
+
const read = load((path, encoding) => {
|
|
22
|
+
calls++;
|
|
23
|
+
assert.equal(path, "renderer.log");
|
|
24
|
+
assert.equal(encoding, "utf8");
|
|
25
|
+
return "prefix\nRuntimeError: failed";
|
|
26
|
+
});
|
|
27
|
+
assert.equal(read("renderer.log", 20), "RuntimeError: failed");
|
|
28
|
+
assert.equal(calls, 1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("preserves UTF-16 character tail boundaries and zero-length tails", () => {
|
|
32
|
+
const txt = "🎵日本語".repeat(2000);
|
|
33
|
+
const read = load(() => txt);
|
|
34
|
+
assert.equal(read("log"), txt.slice(-6000));
|
|
35
|
+
assert.equal(read("log", 0), "");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
for (const code of ["ENOENT", "ENOTDIR"]) {
|
|
39
|
+
test(`a log removed during polling is absent (${code})`, () => {
|
|
40
|
+
const read = load(() => {
|
|
41
|
+
throw Object.assign(new Error(code), { code });
|
|
42
|
+
});
|
|
43
|
+
assert.equal(read("log"), "");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("preserves unexpected read errors", () => {
|
|
48
|
+
const error = Object.assign(new Error("denied"), { code: "EACCES" });
|
|
49
|
+
assert.throws(
|
|
50
|
+
() =>
|
|
51
|
+
load(() => {
|
|
52
|
+
throw error;
|
|
53
|
+
})("log"),
|
|
54
|
+
(caught) => caught === error,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("empty log path does not access the filesystem", () => {
|
|
59
|
+
assert.equal(
|
|
60
|
+
load(() => {
|
|
61
|
+
throw new Error("unexpected read");
|
|
62
|
+
})(""),
|
|
63
|
+
"",
|
|
64
|
+
);
|
|
65
|
+
});
|