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.
Files changed (182) hide show
  1. package/dist/cli.js +4483 -3217
  2. package/dist/docs/data-attributes.md +2 -1
  3. package/dist/fontLocalizeCli.js +73 -12
  4. package/dist/hyperframe-runtime.js +25 -25
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +25 -25
  7. package/dist/hyperframes-player.global.js +3 -3
  8. package/dist/registry/localSemantic.js +548 -0
  9. package/dist/skills/hyperframes-cli/SKILL.md +2 -2
  10. package/dist/skills/media-use/SKILL.md +96 -0
  11. package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
  12. package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
  13. package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
  14. package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
  15. package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
  16. package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
  17. package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
  18. package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
  19. package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
  20. package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
  21. package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
  22. package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
  23. package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
  24. package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
  25. package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
  26. package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
  27. package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
  28. package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
  29. package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
  30. package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
  31. package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
  32. package/dist/skills/media-use/audio/references/bgm.md +74 -0
  33. package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
  34. package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
  35. package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
  36. package/dist/skills/media-use/audio/references/remove-background.md +143 -0
  37. package/dist/skills/media-use/audio/references/requirements.md +29 -0
  38. package/dist/skills/media-use/audio/references/sfx.md +42 -0
  39. package/dist/skills/media-use/audio/references/transcribe.md +52 -0
  40. package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
  41. package/dist/skills/media-use/audio/references/tts.md +165 -0
  42. package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
  43. package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
  44. package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
  45. package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
  46. package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
  47. package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
  48. package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
  49. package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
  50. package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
  51. package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
  52. package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
  53. package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
  54. package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
  55. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
  56. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
  57. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
  58. package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
  59. package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
  60. package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
  61. package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
  62. package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
  63. package/dist/skills/media-use/luts/README.md +28 -0
  64. package/dist/skills/media-use/luts/index.json +57 -0
  65. package/dist/skills/media-use/references/audio.md +19 -0
  66. package/dist/skills/media-use/references/grading.md +160 -0
  67. package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
  68. package/dist/skills/media-use/references/media-treatments.md +202 -0
  69. package/dist/skills/media-use/references/memory.md +36 -0
  70. package/dist/skills/media-use/references/meta.md +61 -0
  71. package/dist/skills/media-use/references/operations.md +325 -0
  72. package/dist/skills/media-use/references/resolve.md +150 -0
  73. package/dist/skills/media-use/references/setup-providers.md +80 -0
  74. package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
  75. package/dist/skills/media-use/registry/localSemantic.js +548 -0
  76. package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
  77. package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
  78. package/dist/skills/media-use/scripts/dither.mjs +328 -0
  79. package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
  80. package/dist/skills/media-use/scripts/eval.mjs +369 -0
  81. package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
  82. package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  83. package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  84. package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  85. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  86. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  87. package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
  88. package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
  89. package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  90. package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  91. package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  92. package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
  93. package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  94. package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  95. package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
  96. package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  97. package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
  98. package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
  99. package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
  100. package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
  101. package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
  102. package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  103. package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
  104. package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
  105. package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
  106. package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  107. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
  108. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
  109. package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  110. package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  111. package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  112. package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  113. package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  114. package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
  115. package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  116. package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
  117. package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  118. package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
  119. package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  120. package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
  121. package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
  122. package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  123. package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  124. package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  125. package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  126. package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
  127. package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
  128. package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
  129. package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
  130. package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
  131. package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  132. package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
  133. package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  134. package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  135. package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
  136. package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
  137. package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
  138. package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  139. package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
  140. package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  141. package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
  142. package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
  143. package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
  144. package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
  145. package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
  146. package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
  147. package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
  148. package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
  149. package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  150. package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
  151. package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
  152. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
  153. package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
  154. package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  155. package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
  156. package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  157. package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
  158. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
  159. package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
  160. package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  161. package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
  162. package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  163. package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  164. package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  165. package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
  166. package/dist/skills/media-use/scripts/prefs.mjs +70 -0
  167. package/dist/skills/media-use/scripts/recipe.mjs +103 -0
  168. package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
  169. package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
  170. package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
  171. package/dist/skills/registry/localSemantic.js +548 -0
  172. package/dist/studio/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +548 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +21596 -20725
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolvePythonCommand, pythonInvocation } from "./python.mjs";
4
+
5
+ // Regression: on Windows a standard python.org install has no `python3.exe`
6
+ // (only `python.exe` + the `py` launcher), so `spawn("python3", …)` ENOENTs and
7
+ // every Python-backed audio feature silently no-ops. resolvePythonCommand takes
8
+ // injectable platform/probe params so all branches are testable without
9
+ // spawning a real interpreter.
10
+
11
+ // probeFor(names): a probe that reports success only for the given argv-0 names.
12
+ function probeFor(...names) {
13
+ const ok = new Set(names);
14
+ return (cmd) => ok.has(cmd);
15
+ }
16
+
17
+ test("non-win32 uses python3 when it runs", () => {
18
+ assert.deepEqual(resolvePythonCommand("linux", probeFor("python3")), ["python3"]);
19
+ assert.deepEqual(resolvePythonCommand("darwin", probeFor("python3")), ["python3"]);
20
+ });
21
+
22
+ test("win32 prefers python3 when the Microsoft Store build provides it", () => {
23
+ assert.deepEqual(resolvePythonCommand("win32", probeFor("python3", "python", "py")), ["python3"]);
24
+ });
25
+
26
+ test("win32 falls back to python.exe when python3 is absent (python.org install)", () => {
27
+ // The exact reported scenario: no python3, but `python` exists.
28
+ assert.deepEqual(resolvePythonCommand("win32", probeFor("python", "py")), ["python"]);
29
+ });
30
+
31
+ test("win32 falls back to the py launcher with -3 when only py exists", () => {
32
+ assert.deepEqual(resolvePythonCommand("win32", probeFor("py")), ["py", "-3"]);
33
+ });
34
+
35
+ test("py launcher is probed as `py -3 --version`, not bare `py`", () => {
36
+ const seen = [];
37
+ const probe = (cmd, args) => {
38
+ seen.push([cmd, ...args]);
39
+ return cmd === "py";
40
+ };
41
+ resolvePythonCommand("win32", probe);
42
+ assert.deepEqual(seen.at(-1), ["py", "-3", "--version"]);
43
+ });
44
+
45
+ test("falls back to the canonical name (loud failure, unchanged) when nothing runs", () => {
46
+ // No interpreter anywhere — must not throw, and must return python3 so the
47
+ // eventual spawn fails exactly as it did before this fix, never worse.
48
+ assert.deepEqual(
49
+ resolvePythonCommand("win32", () => false),
50
+ ["python3"],
51
+ );
52
+ assert.deepEqual(
53
+ resolvePythonCommand("linux", () => false),
54
+ ["python3"],
55
+ );
56
+ });
57
+
58
+ test("pythonInvocation prepends the resolved prefix ahead of caller args", () => {
59
+ assert.deepEqual(pythonInvocation(["-c", "import x"], ["python"]), {
60
+ cmd: "python",
61
+ args: ["-c", "import x"],
62
+ });
63
+ // The py launcher's -3 must stay ahead of the caller's own arguments.
64
+ assert.deepEqual(pythonInvocation(["-c", "import x"], ["py", "-3"]), {
65
+ cmd: "py",
66
+ args: ["-3", "-c", "import x"],
67
+ });
68
+ });
@@ -0,0 +1,143 @@
1
+ // sfx.mjs — sound effects for the media audio engine. Provider-gated (NOT a
2
+ // per-cue merge): the decision is made once, by whether HeyGen is configured —
3
+ // mirroring how TTS and BGM degrade.
4
+ //
5
+ // HeyGen credential present → retrieve EVERY cue from HeyGen's audio library
6
+ // (/v3/audio/sounds, type=sound_effects, min_score=0.4). The bundled
7
+ // library is NOT consulted.
8
+ // HeyGen credential absent → resolve cues against the bundled 21-file
9
+ // library (assets/sfx/manifest.json), copying matched files into the
10
+ // project. Offline, deterministic, free.
11
+ //
12
+ // A cue that matches nothing is skipped (recorded as an anomaly); SFX never
13
+ // blocks a render. Every cue sits at volume ~0.35, under voice + BGM.
14
+
15
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { downloadTo, searchSounds } from "./heygen.mjs";
18
+
19
+ const SFX_VOLUME = 0.35;
20
+ const slug = (s) =>
21
+ s
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "")
25
+ .slice(0, 40) || "x";
26
+ const r3 = (x) => Number(x.toFixed(3));
27
+
28
+ // cues: [{ id, name }] (id = the line/frame/scene the cue fires in). Returns
29
+ // { sfx: [{ id, name, file, source, offset_s, duration_s, volume }], anomalies }.
30
+ export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir }) {
31
+ const sfx = [];
32
+ const anomalies = [];
33
+ const destDir = join(hyperframesDir, "assets", "sfx");
34
+
35
+ // Dedupe identical (id,name) cues — the same effect named twice in one line
36
+ // downloads/copies once.
37
+ const seen = new Set();
38
+ const uniq = cues.filter((c) => {
39
+ const k = `${c.id}:${c.name}`;
40
+ if (seen.has(k)) return false;
41
+ seen.add(k);
42
+ return true;
43
+ });
44
+
45
+ if (heygenOK) {
46
+ for (const { id, name } of uniq) {
47
+ try {
48
+ // SFX hits score low (~0.5–0.67), below the API's default 0.7 which
49
+ // silently drops most named cues — floor to 0.4. (BGM/music score high
50
+ // and keep the default.)
51
+ const results = await searchSounds(name, "sound_effects", headers, {
52
+ limit: 3,
53
+ minScore: 0.4,
54
+ });
55
+ if (!results.length) {
56
+ anomalies.push(`sfx "${name}" (id ${id}): no HeyGen match — skipped`);
57
+ continue;
58
+ }
59
+ const top = results[0];
60
+ const file = `assets/sfx/${slug(name)}.mp3`;
61
+ await downloadTo(top.audio_url, join(hyperframesDir, file));
62
+ sfx.push({
63
+ id,
64
+ name,
65
+ file,
66
+ source: "heygen",
67
+ offset_s: 0,
68
+ duration_s: typeof top.duration === "number" ? r3(top.duration) : 1.0,
69
+ volume: SFX_VOLUME,
70
+ });
71
+ } catch (e) {
72
+ anomalies.push(`sfx "${name}" (id ${id}): retrieval failed — ${e.message}`);
73
+ }
74
+ }
75
+ return { sfx, anomalies };
76
+ }
77
+
78
+ // ── offline: bundled library ──
79
+ const manifestPath = join(sfxLibDir, "manifest.json");
80
+ if (!existsSync(manifestPath)) {
81
+ if (uniq.length)
82
+ anomalies.push(`no HeyGen credential and no SFX library at ${sfxLibDir} — all cues dropped`);
83
+ return { sfx, anomalies };
84
+ }
85
+ let manifest;
86
+ try {
87
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
88
+ } catch (e) {
89
+ anomalies.push(`SFX manifest parse failed (${e.message}) — all cues dropped`);
90
+ return { sfx, anomalies };
91
+ }
92
+ // Build lookups: by manifest key, by file basename, and by slug of either, so
93
+ // a cue can name "whoosh", "whoosh.mp3", or "ui click" (→ slug match).
94
+ const byKey = new Map();
95
+ for (const [key, entry] of Object.entries(manifest)) {
96
+ if (!entry?.file || !isFinite(entry.duration)) continue;
97
+ const rec = { key, file: entry.file, duration: entry.duration };
98
+ byKey.set(key, rec);
99
+ byKey.set(entry.file, rec);
100
+ byKey.set(slug(key), rec);
101
+ byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
102
+ }
103
+ mkdirSync(destDir, { recursive: true });
104
+ for (const { id, name } of uniq) {
105
+ const hit = byKey.get(name) ?? byKey.get(slug(name));
106
+ if (!hit) {
107
+ const known = [...new Set([...byKey.values()].map((v) => v.key))].slice(0, 8).join(", ");
108
+ anomalies.push(
109
+ `sfx "${name}" (id ${id}): not in bundled library — skipped (have: ${known}…)`,
110
+ );
111
+ continue;
112
+ }
113
+ const src = join(sfxLibDir, hit.file);
114
+ const destRel = `assets/sfx/${hit.file}`;
115
+ const dest = join(hyperframesDir, destRel);
116
+ // The bundled library may be incomplete: some installs of the skill ship
117
+ // manifest.json without the actual mp3s. Pushing an sfx entry that points at
118
+ // a file we never copied produces a dangling reference that silently drops
119
+ // downstream ("not on disk"). Surface it as a loud anomaly and skip the cue
120
+ // instead, so the audio_meta never references a missing file.
121
+ if (!existsSync(dest)) {
122
+ if (!existsSync(src)) {
123
+ anomalies.push(
124
+ `sfx "${name}" (id ${id}): bundled file ${hit.file} missing from the offline ` +
125
+ `library (${sfxLibDir}) — skipped. Reinstall the media-use skill to ` +
126
+ `restore assets/sfx/*.mp3, or configure a HeyGen credential for retrieval.`,
127
+ );
128
+ continue;
129
+ }
130
+ copyFileSync(src, dest);
131
+ }
132
+ sfx.push({
133
+ id,
134
+ name,
135
+ file: destRel,
136
+ source: "local",
137
+ offset_s: 0,
138
+ duration_s: r3(hit.duration),
139
+ volume: SFX_VOLUME,
140
+ });
141
+ }
142
+ return { sfx, anomalies };
143
+ }
@@ -0,0 +1,69 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { resolveSfx } from "./sfx.mjs";
7
+
8
+ // Offline (no HeyGen) SFX resolution: the bundled library may ship manifest.json
9
+ // without the actual mp3s. The old code copied only when the source existed but
10
+ // pushed the sfx entry unconditionally — producing a dangling reference that
11
+ // silently dropped downstream ("not on disk"). These tests lock in the loud
12
+ // behavior: a present file is copied + referenced; a missing file yields an
13
+ // anomaly and NO dangling entry.
14
+
15
+ async function withDirs(fn) {
16
+ const root = mkdtempSync(join(tmpdir(), "hf-sfx-"));
17
+ const libDir = join(root, "lib");
18
+ const projDir = join(root, "proj");
19
+ mkdirSync(libDir, { recursive: true });
20
+ mkdirSync(projDir, { recursive: true });
21
+ try {
22
+ // `await` is load-bearing: without it the finally cleanup runs before the
23
+ // async test body resolves, deleting the temp dir mid-assertion.
24
+ return await fn({ libDir, projDir });
25
+ } finally {
26
+ rmSync(root, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ test("offline: copies and references a present bundled file", async () => {
31
+ await withDirs(async ({ libDir, projDir }) => {
32
+ writeFileSync(
33
+ join(libDir, "manifest.json"),
34
+ JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
35
+ );
36
+ writeFileSync(join(libDir, "whoosh.mp3"), "ID3-fake-bytes");
37
+ const { sfx, anomalies } = await resolveSfx({
38
+ cues: [{ id: "s1", name: "whoosh" }],
39
+ heygenOK: false,
40
+ hyperframesDir: projDir,
41
+ sfxLibDir: libDir,
42
+ });
43
+ assert.equal(sfx.length, 1);
44
+ assert.equal(sfx[0].file, "assets/sfx/whoosh.mp3");
45
+ assert.equal(sfx[0].source, "local");
46
+ assert.ok(existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "mp3 copied into project");
47
+ assert.equal(anomalies.length, 0);
48
+ });
49
+ });
50
+
51
+ test("offline: a matched-but-missing bundled file yields an anomaly and NO dangling entry", async () => {
52
+ await withDirs(async ({ libDir, projDir }) => {
53
+ // Manifest names whoosh.mp3, but the mp3 was never shipped (the reported bug).
54
+ writeFileSync(
55
+ join(libDir, "manifest.json"),
56
+ JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
57
+ );
58
+ const { sfx, anomalies } = await resolveSfx({
59
+ cues: [{ id: "s1", name: "whoosh" }],
60
+ heygenOK: false,
61
+ hyperframesDir: projDir,
62
+ sfxLibDir: libDir,
63
+ });
64
+ assert.equal(sfx.length, 0, "no dangling entry for a file that was never copied");
65
+ assert.equal(anomalies.length, 1);
66
+ assert.match(anomalies[0], /missing from the offline library/);
67
+ assert.ok(!existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "nothing copied");
68
+ });
69
+ });
@@ -0,0 +1,379 @@
1
+ import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
2
+ // tts.mjs — multi-provider TTS for the media audio engine. The provider chain,
3
+ // auto-detected from env, is the one documented in ../SKILL.md:
4
+ //
5
+ // 1. HeyGen (Starfish) — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.heygen.
6
+ // Direct v3 REST (NOT `hyperframes tts`, which in the published build is
7
+ // Kokoro-only and silently ignores a HeyGen key). Returns word_timestamps
8
+ // in the same call, so no separate transcribe pass.
9
+ // 2. ElevenLabs — $ELEVENLABS_API_KEY + `pip install elevenlabs`. No
10
+ // word timings → caller chains transcribeWav().
11
+ // 3. Kokoro-82M (local) — always available, via the published `hyperframes tts`
12
+ // CLI. No word timings → caller chains transcribeWav().
13
+ //
14
+ // "HeyGen available" is decided by CREDENTIAL presence (heygenCredential), never
15
+ // by the CLI — see the note above.
16
+
17
+ import { spawn, spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs";
22
+ import { pythonInvocation } from "./python.mjs";
23
+
24
+ // ── provider detection ────────────────────────────────────────────────────────
25
+ export function heygenAvailable() {
26
+ return heygenCredential() !== null;
27
+ }
28
+ export function elevenlabsAvailable() {
29
+ if (!process.env.ELEVENLABS_API_KEY) return false;
30
+ const { cmd, args } = pythonInvocation(["-c", "import elevenlabs"]);
31
+ const r = spawnSync(cmd, args, {
32
+ stdio: "ignore",
33
+ });
34
+ return r.status === 0;
35
+ }
36
+
37
+ // First available provider wins; an explicit choice is honored (and validated).
38
+ export function pickProvider(userProvider) {
39
+ if (userProvider) {
40
+ if (!["heygen", "elevenlabs", "kokoro"].includes(userProvider))
41
+ throw new Error(`invalid provider "${userProvider}" (heygen | elevenlabs | kokoro)`);
42
+ if (userProvider === "heygen" && !heygenAvailable())
43
+ throw new Error(
44
+ "provider=heygen but no HeyGen credentials (set $HEYGEN_API_KEY or run `npx hyperframes auth login`)",
45
+ );
46
+ if (userProvider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY)
47
+ throw new Error("provider=elevenlabs but $ELEVENLABS_API_KEY is not set");
48
+ return userProvider;
49
+ }
50
+ return heygenAvailable() ? "heygen" : elevenlabsAvailable() ? "elevenlabs" : "kokoro";
51
+ }
52
+
53
+ // ── voice resolution ──────────────────────────────────────────────────────────
54
+ // HeyGen /v3/voices/speech only accepts STARFISH voice_ids; auto-pick the first
55
+ // English public starfish voice when none is pinned. ElevenLabs/Kokoro have
56
+ // their own defaults.
57
+ export async function resolveVoiceId({ provider, userVoice, lang = "en" }) {
58
+ if (userVoice) return userVoice;
59
+ if (provider === "elevenlabs") return "21m00Tcm4TlvDq8ikWAM"; // Rachel
60
+ if (provider === "kokoro") {
61
+ if (lang === "en") return "am_michael";
62
+ throw new Error("Kokoro non-English needs an explicit --voice (see references/tts.md)");
63
+ }
64
+ // heygen — pin a fixed English default so the choice is deterministic. The old
65
+ // "first English voice the API returns" drifts whenever HeyGen re-sorts the
66
+ // public catalog. Marcia (mature, low female). Override with --voice / request.voice.
67
+ if (lang === "en") return "05f19352e8f74b0392a8f411eba40de1"; // Marcia · English · female
68
+ // Non-English: no fixed default — fall back to the first matching catalog voice.
69
+ const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
70
+ headers: heygenAuthHeaders(),
71
+ });
72
+ const voices = payload.data ?? payload.voices ?? [];
73
+ const pick = voices.find((v) => v.language === "English") ?? voices[0];
74
+ if (!pick) throw new Error("no public starfish voice to default to — pass --voice");
75
+ return pick.voice_id;
76
+ }
77
+
78
+ // ── helpers ─────────────────────────────────────────────────────────────────
79
+ export function withWordIds(words) {
80
+ return (words ?? []).map((w, i) => ({
81
+ id: `w${i}`,
82
+ text: w.text,
83
+ start: w.start,
84
+ end: w.end,
85
+ }));
86
+ }
87
+
88
+ // `ffmpeg -i <file>` prints a `Duration: HH:MM:SS.ms` line to stderr even
89
+ // though it exits non-zero with no output requested. Parsing pulled out as
90
+ // a pure function so the ENOENT fallback below can be tested without
91
+ // depending on whether ffprobe/ffmpeg are actually installed on the
92
+ // machine running the tests.
93
+ export function parseFfmpegDurationBanner(stderrText) {
94
+ const match = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(stderrText ?? "");
95
+ if (!match) return NaN;
96
+ const [, hours, minutes, seconds] = match;
97
+ return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
98
+ }
99
+
100
+ // Some "essentials"-style ffmpeg distributions (common on Windows) ship
101
+ // ffmpeg.exe without ffprobe.exe. ffprobeDuration's caller (audio.mjs)
102
+ // otherwise reads a spurious NaN as "the WAV file is corrupt" and drops an
103
+ // already-successfully-synthesized TTS line, rather than "the tool for
104
+ // measuring it is missing".
105
+ function ffmpegDurationFallback(absPath) {
106
+ const r = spawnSync("ffmpeg", ["-i", absPath], { encoding: "utf8" });
107
+ return parseFfmpegDurationBanner(r.stderr);
108
+ }
109
+
110
+ export function ffprobeDuration(absPath) {
111
+ const r = spawnSync(
112
+ "ffprobe",
113
+ ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", "--", absPath],
114
+ { encoding: "utf8" },
115
+ );
116
+ if (r.error?.code === "ENOENT") return ffmpegDurationFallback(absPath);
117
+ if (r.status !== 0) return NaN;
118
+ return parseFloat(String(r.stdout).trim());
119
+ }
120
+
121
+ export function resolveNpxCliFromNpmExecPath(
122
+ npmExecPath = process.env.npm_execpath,
123
+ pathExists = existsSync,
124
+ ) {
125
+ if (!npmExecPath) return null;
126
+ const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
127
+ const npxCliPath =
128
+ fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js");
129
+ return pathExists(npxCliPath) ? npxCliPath : null;
130
+ }
131
+
132
+ export function resolveNpxCliPath(
133
+ npmExecPath = process.env.npm_execpath,
134
+ nodeExecPath = process.env.npm_node_execpath || process.execPath,
135
+ pathExists = existsSync,
136
+ ) {
137
+ const fromNpm = resolveNpxCliFromNpmExecPath(npmExecPath, pathExists);
138
+ if (fromNpm) return fromNpm;
139
+ const besideNode = join(dirname(nodeExecPath), "node_modules", "npm", "bin", "npx-cli.js");
140
+ return pathExists(besideNode) ? besideNode : null;
141
+ }
142
+
143
+ export function resolveSpawnCommand(
144
+ cmd,
145
+ args,
146
+ opts = {},
147
+ platform = process.platform,
148
+ env = process.env,
149
+ pathExists = existsSync,
150
+ ) {
151
+ if (cmd !== "npx" || platform !== "win32") {
152
+ return { cmd, args, opts: { stdio: "ignore", ...opts } };
153
+ }
154
+
155
+ // On Windows, npx resolves to npx.cmd, which Node cannot execute directly.
156
+ // Avoid `shell:true` and the .cmd shim entirely by invoking npm's JS CLI with
157
+ // node, preserving request-provided values as argv data instead of shell text.
158
+ const nodeExecPath = env.npm_node_execpath || process.execPath;
159
+ const npxCliPath = resolveNpxCliPath(env.npm_execpath, nodeExecPath, pathExists);
160
+ if (!npxCliPath) return null;
161
+ return {
162
+ cmd: nodeExecPath,
163
+ args: [npxCliPath, ...args.map((arg) => String(arg))],
164
+ opts: { stdio: "ignore", windowsHide: true, ...opts },
165
+ };
166
+ }
167
+
168
+ // `platform`/`spawnFn` params (default process.platform / the real spawn)
169
+ // exist so tests can exercise the win32 branch without mocking node:child_process
170
+ // (its ESM exports are non-configurable, so mock.method can't patch it).
171
+ // One-shot so a whole batch of TTS lines doesn't repeat the same diagnostic.
172
+ let _warnedNpxResolution = false;
173
+ /** Test-only: reset the one-shot npx-resolution warning latch. */
174
+ export function _resetNpxResolutionWarnForTests() {
175
+ _warnedNpxResolution = false;
176
+ }
177
+
178
+ export function spawnP(
179
+ cmd,
180
+ args,
181
+ opts = {},
182
+ platform = process.platform,
183
+ spawnFn = spawn,
184
+ env = process.env,
185
+ pathExists = existsSync,
186
+ ) {
187
+ const resolved = resolveSpawnCommand(cmd, args, opts, platform, env, pathExists);
188
+ if (!resolved) {
189
+ // resolveSpawnCommand only returns null for the npx-on-win32 case where
190
+ // neither npm's configured CLI nor the beside-node fallback exists. Without
191
+ // this, every call silently returns status:-1 and stdio:"ignore" hides why.
192
+ if (!_warnedNpxResolution) {
193
+ _warnedNpxResolution = true;
194
+ const reason = env.npm_execpath
195
+ ? `npm_execpath (${env.npm_execpath}) and the beside-node npm fallback could not be found`
196
+ : "npm_execpath is unset and the beside-node npm fallback could not be found";
197
+ console.error(
198
+ `[media-use] Cannot run "${cmd}" on Windows: ${reason}. ` +
199
+ `Every "${cmd}" call is being skipped. Install npm with Node, or run via ` +
200
+ `\`npx\`/\`npm run\` with a valid npm_execpath.`,
201
+ );
202
+ }
203
+ return Promise.resolve({ status: -1 });
204
+ }
205
+ return new Promise((resolve) => {
206
+ const p = spawnFn(resolved.cmd, resolved.args, resolved.opts);
207
+ p.on("exit", (code) => resolve({ status: code ?? -1 }));
208
+ p.on("error", () => resolve({ status: -1 }));
209
+ });
210
+ }
211
+
212
+ // mp3/whatever bytes → wav 44.1k mono at destWav (ffmpeg detects true format).
213
+ function transcodeToWav(bytes, destWav) {
214
+ const td = mkdtempSync(join(tmpdir(), "hf-tts-"));
215
+ const tmp = join(td, "a.mp3");
216
+ writeFileSync(tmp, bytes);
217
+ mkdirSync(dirname(destWav), { recursive: true });
218
+ const ff = spawnSync(
219
+ "ffmpeg",
220
+ ["-y", "-loglevel", "error", "-i", tmp, "-ar", "44100", "-ac", "1", destWav],
221
+ { stdio: "ignore" },
222
+ );
223
+ rmSync(td, { recursive: true, force: true });
224
+ return ff.status === 0 && existsSync(destWav);
225
+ }
226
+
227
+ const ELEVENLABS_PY = `
228
+ import os, sys
229
+ from elevenlabs.client import ElevenLabs
230
+ from elevenlabs import save
231
+ client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
232
+ text = open(sys.argv[1]).read()
233
+ audio = client.text_to_speech.convert(
234
+ text=text, voice_id=sys.argv[2],
235
+ model_id="eleven_multilingual_v2", output_format="mp3_44100_128",
236
+ )
237
+ save(audio, sys.argv[3])
238
+ `;
239
+
240
+ // ── synthesize one line ───────────────────────────────────────────────────────
241
+ // Writes wav at wavAbs. Returns { ok, words, error } — words is the raw
242
+ // [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro
243
+ // (caller must transcribeWav). Never throws; failures return { ok:false, error }
244
+ // where `error` states WHY (so the caller can surface it, not a bare "TTS failed").
245
+ export async function synthesizeOne({
246
+ provider,
247
+ text,
248
+ voiceId,
249
+ lang = "en",
250
+ speed = 1.0,
251
+ wavAbs,
252
+ hyperframesDir,
253
+ }) {
254
+ if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs });
255
+ if (provider === "elevenlabs") {
256
+ // The Python helper writes straight to wavAbs; unlike heygen (transcodeToWav)
257
+ // and kokoro (the `hyperframes tts` CLI), it does NOT create the parent dir,
258
+ // so on a fresh project (no assets/voice/ yet) the save fails and the line is
259
+ // silently dropped as "TTS failed - omitted". Create it first, like the other
260
+ // providers do. Guarded so a mkdir failure (EACCES/EROFS) returns
261
+ // { ok:false } like the rest of this branch rather than throwing (the
262
+ // function's contract is "never throws; failures return { ok:false }").
263
+ try {
264
+ mkdirSync(dirname(wavAbs), { recursive: true });
265
+ } catch {
266
+ return { ok: false, words: null };
267
+ }
268
+ const { cmd, args } = pythonInvocation([
269
+ "-c",
270
+ ELEVENLABS_PY,
271
+ writeTmpText(text),
272
+ voiceId,
273
+ wavAbs,
274
+ ]);
275
+ const r = await spawnP(cmd, args, {});
276
+ return synthResult(r, wavAbs, "elevenlabs (python)");
277
+ }
278
+ // kokoro — via the published CLI; --output is relative to the project dir.
279
+ const wavRel = relTo(hyperframesDir, wavAbs);
280
+ const args = ["hyperframes", "tts", writeTmpText(text), "--voice", voiceId, "--output", wavRel];
281
+ if (lang !== "en") args.push("--lang", lang);
282
+ const r = await spawnP("npx", args, { cwd: hyperframesDir });
283
+ return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)");
284
+ }
285
+
286
+ // Shape a spawn result into { ok, words, error }, naming why on failure so the
287
+ // caller surfaces it instead of a bare "TTS failed".
288
+ export function synthResult(r, wavAbs, label) {
289
+ if (r.status === 0 && existsSync(wavAbs)) return { ok: true, words: null };
290
+ const why =
291
+ r.status !== 0 ? `${label} exited with status ${r.status}` : `${label} produced no wav file`;
292
+ return { ok: false, words: null, error: why };
293
+ }
294
+
295
+ // `deps` is injectable for tests; production uses the real network/ffmpeg impls.
296
+ // Every failure path returns an `error` string so the caller can surface WHY a
297
+ // line was dropped instead of the bare "TTS failed" that hid the real cause
298
+ // (e.g. an HTTP 402 plan_upgrade_required thrown by heygenJSON was swallowed).
299
+ export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, deps = {}) {
300
+ const requestJSON = deps.heygenJSON ?? heygenJSON;
301
+ const authHeaders = deps.heygenAuthHeaders ?? heygenAuthHeaders;
302
+ const fetchImpl = deps.fetch ?? fetch;
303
+ const transcode = deps.transcodeToWav ?? transcodeToWav;
304
+ try {
305
+ const body = { text, voice_id: voiceId, speed };
306
+ if (lang !== "en") body.language = lang;
307
+ const payload = await requestJSON(`/voices/speech`, {
308
+ method: "POST",
309
+ headers: authHeaders(),
310
+ body,
311
+ });
312
+ const inner = payload.data ?? payload;
313
+ if (!inner.audio_url) {
314
+ return { ok: false, words: null, error: "HeyGen /voices/speech returned no audio_url" };
315
+ }
316
+ const res = await fetchMedia(inner.audio_url, { fetchImpl });
317
+ if (!res.ok) {
318
+ return { ok: false, words: null, error: `audio_url fetch failed: HTTP ${res.status}` };
319
+ }
320
+ const bytes = Buffer.from(await res.arrayBuffer());
321
+ // .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The
322
+ // engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3.
323
+ if (wavAbs.endsWith(".wav")) {
324
+ if (!transcode(bytes, wavAbs)) {
325
+ return {
326
+ ok: false,
327
+ words: null,
328
+ error: "wav transcode failed (ffmpeg)",
329
+ };
330
+ }
331
+ } else {
332
+ mkdirSync(dirname(wavAbs), { recursive: true });
333
+ writeFileSync(wavAbs, bytes);
334
+ }
335
+ const words = Array.isArray(inner.word_timestamps)
336
+ ? inner.word_timestamps
337
+ .filter((w) => w && typeof w.word === "string" && isFinite(w.start) && isFinite(w.end))
338
+ .filter((w) => !/^<.*>$/.test(w.word.trim())) // drop <start>/<end> sentinels
339
+ .map((w) => ({ text: w.word, start: w.start, end: w.end }))
340
+ : [];
341
+ return { ok: true, words };
342
+ } catch (e) {
343
+ return { ok: false, words: null, error: e?.message ? String(e.message) : String(e) };
344
+ }
345
+ }
346
+
347
+ // ElevenLabs/Kokoro have no word timings — run Whisper over the wav. Returns the
348
+ // flat [{id,text,start,end}] word array, or null. Each call uses a throwaway
349
+ // --dir so parallel scenes don't collide on transcript.json.
350
+ export async function transcribeWav({ wavRel, lang = "en", hyperframesDir }) {
351
+ const model = lang === "en" ? "small.en" : "small";
352
+ const td = mkdtempSync(join(tmpdir(), "hf-trans-"));
353
+ const args = ["hyperframes", "transcribe", wavRel, "--model", model, "--dir", td];
354
+ if (lang !== "en") args.push("--language", lang);
355
+ const r = await spawnP("npx", args, { cwd: hyperframesDir });
356
+ let words = null;
357
+ if (r.status === 0) {
358
+ const src = join(td, "transcript.json");
359
+ if (existsSync(src)) {
360
+ try {
361
+ const arr = JSON.parse(readFileSync(src, "utf8"));
362
+ if (Array.isArray(arr) && arr.length) words = arr;
363
+ } catch {}
364
+ }
365
+ }
366
+ rmSync(td, { recursive: true, force: true });
367
+ return words;
368
+ }
369
+
370
+ // ── tiny local utils ──────────────────────────────────────────────────────────
371
+ function writeTmpText(text) {
372
+ const td = mkdtempSync(join(tmpdir(), "hf-txt-"));
373
+ const p = join(td, "line.txt");
374
+ writeFileSync(p, text);
375
+ return p;
376
+ }
377
+ function relTo(base, abs) {
378
+ return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs;
379
+ }