hyperframes 0.8.57 → 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 +5300 -3428
  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-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +577 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +22537 -21600
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +3 -3
@@ -0,0 +1,257 @@
1
+ // bgm.mjs — background music for the media audio engine. Two routes, gated the
2
+ // same way as TTS/SFX:
3
+ //
4
+ // retrieve (default when HeyGen is configured) — search HeyGen's music library
5
+ // by mood, download the top track. Synchronous. assets/bgm/track.mp3.
6
+ // generate (the alternative; the automatic choice when HeyGen is absent) —
7
+ // Lyria (cloud, $GEMINI_API_KEY/$GOOGLE_API_KEY + google-genai) preferred,
8
+ // else local MusicGen (facebook/musicgen-small via transformers). Spawned
9
+ // DETACHED so the engine can return while audio renders; the caller marks
10
+ // bgm_pending and runs wait-bgm.mjs before assembling. assets/bgm/track.wav.
11
+ //
12
+ // Missing/failed BGM never blocks a render.
13
+
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, openSync, closeSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { downloadTo, searchSounds } from "./heygen.mjs";
18
+ import { pythonInvocation } from "./python.mjs";
19
+
20
+ const r3 = (x) => Number(x.toFixed(3));
21
+ const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
22
+
23
+ // Default BGM level. Under narration music is a bed that must stay under the
24
+ // voice — 0.12 linear ≈ -18 dB. A silent film (no voice) has no voice to duck
25
+ // beneath, so BGM sits forward at 0.9. Callers may override per composition.
26
+ export const BGM_BED_VOLUME = 0.12;
27
+ export const BGM_SILENT_VOLUME = 0.9;
28
+ export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
29
+
30
+ const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"];
31
+ const BGM_PY_PROBE =
32
+ "import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration";
33
+ const LYRIA_PY_DEPS = ["google-genai", "python-dotenv"];
34
+ const LYRIA_PY_PROBE = "import google.genai";
35
+
36
+ function pyOk(probe) {
37
+ const { cmd, args } = pythonInvocation(["-c", probe]);
38
+ return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
39
+ }
40
+ // `python -m pip`, not a bare `pip` binary: a Homebrew/system Python often
41
+ // exposes only `python3`/`pip3` on PATH, so a plain `pip` spawn silently
42
+ // no-ops (ENOENT) and the documented "auto-installed on demand" path never
43
+ // actually installs. `-m pip` also guarantees the packages land in the SAME
44
+ // interpreter pyOk() probes — a bare `pip`/`pip3` could resolve to a
45
+ // different Python installation than `python3` if more than one is on PATH.
46
+ function pipInstall(deps) {
47
+ const { cmd, args } = pythonInvocation(["-m", "pip", "install", "-q", ...deps]);
48
+ return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
49
+ }
50
+
51
+ // ── retrieval (HeyGen music library) ──────────────────────────────────────────
52
+ export async function retrieveBgm({ query, headers, hyperframesDir, hasVoice }) {
53
+ const q = query || "calm cinematic underscore";
54
+ const results = await searchSounds(q, "music", headers, { limit: 5 });
55
+ if (!results.length) return null;
56
+ const top = results[0];
57
+ const rel = "assets/bgm/track.mp3";
58
+ await downloadTo(top.audio_url, join(hyperframesDir, rel));
59
+ return {
60
+ path: rel,
61
+ volume: bgmDefaultVolume(hasVoice),
62
+ query: q,
63
+ mode: "retrieve",
64
+ duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
65
+ };
66
+ }
67
+
68
+ // ── mood inference (for the generate path's prompt) ──────────────────────────
69
+ // Industry base → archetype shape → emotional-arc tiebreaker. Exported so a
70
+ // workflow adapter can build a rich prompt from its own narrative metadata; the
71
+ // engine also calls it when generate has only a plain mood query.
72
+ export function inferBgmPrompt({ blob = "", archetype = "", arc = "", userPrompt = "" } = {}) {
73
+ if (userPrompt) return userPrompt;
74
+ const b = String(blob).toLowerCase();
75
+ let base;
76
+ let bpm;
77
+ if (/\b(crypto|nft|web3|defi|token|blockchain|exchange|wallet|dao)\b/.test(b)) {
78
+ base = "atmospheric electronic, deep bass, futuristic synths, restrained percussion";
79
+ bpm = 100;
80
+ } else if (/\b(finance|fintech|bank|payment|invest|wealth|insurance|treasury)\b/.test(b)) {
81
+ base = "calm cinematic, soft strings, subtle piano, restrained percussion";
82
+ bpm = 92;
83
+ } else if (/\b(creative|agency|design|studio|art|brand|marketing|content)\b/.test(b)) {
84
+ base = "playful electronic, warm pads, light percussion";
85
+ bpm = 115;
86
+ } else {
87
+ base = "uplifting corporate tech, bright modern piano with synth pads";
88
+ bpm = 108;
89
+ }
90
+ const at = String(archetype).toLowerCase();
91
+ const ar = String(arc).toLowerCase();
92
+ if (/\bpas\b|pain.agitate|pain.+solve/.test(at))
93
+ return `${base}, starts with subtle tension then builds to resolution, BPM ${bpm}, transitions from MINOR to MAJOR`;
94
+ if (/\bbab\b|before.after|future.pac|vision/.test(at))
95
+ return `${base}, cinematic and aspirational, steady build with rising energy, BPM ${bpm}, MAJOR`;
96
+ if (/cascade|feature.benefit/.test(at))
97
+ return `${base}, energetic and driving, consistent momentum, BPM ${Math.min(bpm + 10, 128)}, MAJOR`;
98
+ if (/demo.loop|question.+answer/.test(at))
99
+ return `${base}, clean and focused, minimal arrangement, BPM ${Math.max(bpm - 8, 88)}`;
100
+ if (/frustrat|anxiety|overwhelm|tension/.test(ar) && /relief|excite|triumph/.test(ar))
101
+ return `${base}, builds from understated tension to uplifting resolution, BPM ${bpm}, MINOR to MAJOR`;
102
+ if (/excit|awe|power|triumph/.test(ar))
103
+ return `${base}, energetic and confident, BPM ${bpm}, MAJOR`;
104
+ if (/trust|ease|clarity|reassur/.test(ar))
105
+ return `${base}, warm and reassuring, BPM ${Math.max(bpm - 5, 85)}`;
106
+ return `${base}, BPM ${bpm}, MAJOR`;
107
+ }
108
+
109
+ // ── generation (Lyria → MusicGen, detached) ──────────────────────────────────
110
+ // Returns a bgmMeta the caller folds into audio_meta:
111
+ // { path, mode, volume, provider, pid, log, target_duration_s, seed_duration_s,
112
+ // loop_count, pending:true } on success, or { disabled:true, reason }.
113
+ export function generateBgmDetached({
114
+ prompt,
115
+ durationS,
116
+ hyperframesDir,
117
+ lyriaRecipe,
118
+ seedSeconds = 28,
119
+ hasVoice,
120
+ }) {
121
+ const rel = "assets/bgm/track.wav";
122
+ const abs = join(hyperframesDir, rel);
123
+ mkdirSync(join(hyperframesDir, "assets", "bgm"), { recursive: true });
124
+ const log = join(hyperframesDir, "assets", "bgm", `bgm-${Date.now()}.log`);
125
+ const targetS = Math.max(1, durationS);
126
+ const baseMeta = { path: rel, mode: null, volume: bgmDefaultVolume(hasVoice), pending: true };
127
+
128
+ const lyriaConfigured = !!lyriaKey() && !!lyriaRecipe && existsSync(lyriaRecipe);
129
+
130
+ // Make a backend runnable: prefer Lyria when configured (install google-genai
131
+ // on demand), else ensure local MusicGen deps. Installs are synchronous here —
132
+ // generation itself is detached, so the engine still returns promptly.
133
+ if (lyriaConfigured && !pyOk(LYRIA_PY_PROBE)) pipInstall(LYRIA_PY_DEPS);
134
+ const useLyria = lyriaConfigured && pyOk(LYRIA_PY_PROBE);
135
+ if (!useLyria && !pyOk(BGM_PY_PROBE)) pipInstall(BGM_PY_DEPS);
136
+
137
+ const fd = openSync(log, "w");
138
+ if (useLyria) {
139
+ const { cmd, args } = pythonInvocation([
140
+ lyriaRecipe,
141
+ "--output",
142
+ abs,
143
+ "--duration",
144
+ String(targetS),
145
+ "--prompt",
146
+ prompt,
147
+ ]);
148
+ const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
149
+ proc.unref();
150
+ closeSync(fd);
151
+ return {
152
+ ...baseMeta,
153
+ mode: "detached-single",
154
+ provider: "lyria",
155
+ pid: proc.pid,
156
+ log,
157
+ target_duration_s: r3(targetS),
158
+ };
159
+ }
160
+
161
+ if (pyOk(BGM_PY_PROBE)) {
162
+ const seedS = Math.min(Math.max(seedSeconds, 10), 30);
163
+ const loops = targetS > seedS ? Math.ceil(targetS / seedS) : 1;
164
+ const script = musicgenScript({ prompt, abs, targetS, seedS });
165
+ const { cmd, args } = pythonInvocation(["-c", script]);
166
+ const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
167
+ proc.unref();
168
+ closeSync(fd);
169
+ return {
170
+ ...baseMeta,
171
+ mode: targetS > seedS ? "detached-seed-loop" : "detached-seed-trim",
172
+ provider: "musicgen",
173
+ pid: proc.pid,
174
+ log,
175
+ target_duration_s: r3(targetS),
176
+ seed_duration_s: seedS,
177
+ loop_count: loops,
178
+ };
179
+ }
180
+
181
+ closeSync(fd);
182
+ return {
183
+ disabled: true,
184
+ reason: lyriaConfigured
185
+ ? `Lyria configured but google-genai uninstallable, and local MusicGen unavailable (pip install ${BGM_PY_DEPS.join(" ")})`
186
+ : `no Lyria key/recipe and local MusicGen deps unavailable (pip install ${BGM_PY_DEPS.join(" ")})`,
187
+ };
188
+ }
189
+
190
+ // Inline MusicGen: generate ONE seed clip (≤30s to stay under the decoder's
191
+ // positional limit), then trim it down or crossfade-loop it up to the target.
192
+ function musicgenScript({ prompt, abs, targetS, seedS }) {
193
+ return `
194
+ import math, os, sys, traceback
195
+ from pathlib import Path
196
+ import numpy as np
197
+ import soundfile as sf
198
+ from transformers import MusicgenForConditionalGeneration, AutoProcessor
199
+
200
+ prompt = ${JSON.stringify(prompt)}
201
+ out_path = ${JSON.stringify(abs)}
202
+ target_s = float(${targetS.toFixed(3)})
203
+ seed_s = float(${seedS.toFixed(3)})
204
+ token_rate = 50
205
+ crossfade_s = 0.3
206
+
207
+ def apply_fade(arr, sr, fade_in_s=0.08, fade_out_s=0.5):
208
+ n_in = min(int(round(fade_in_s * sr)), arr.shape[0] // 2)
209
+ n_out = min(int(round(fade_out_s * sr)), arr.shape[0] // 2)
210
+ if n_in > 1: arr[:n_in] *= np.linspace(0.0, 1.0, n_in, dtype="float32")
211
+ if n_out > 1: arr[-n_out:] *= np.linspace(1.0, 0.0, n_out, dtype="float32")
212
+ return arr
213
+
214
+ def loop_crossfade(seed, target_len, xf):
215
+ if seed.shape[0] >= target_len: return seed[:target_len]
216
+ xf = min(xf, seed.shape[0] // 2)
217
+ if xf < 1:
218
+ reps = int(math.ceil(target_len / seed.shape[0]))
219
+ return np.tile(seed, reps)[:target_len]
220
+ t = np.linspace(0.0, 1.0, xf, dtype="float32")
221
+ fade_out = np.cos(t * (math.pi / 2)); fade_in = np.sin(t * (math.pi / 2))
222
+ out = seed.copy()
223
+ while out.shape[0] < target_len:
224
+ tail = out[-xf:] * fade_out; head = seed[:xf] * fade_in
225
+ out = np.concatenate([out[:-xf], tail + head, seed[xf:]])
226
+ return out[:target_len]
227
+
228
+ try:
229
+ Path(os.path.dirname(out_path)).mkdir(parents=True, exist_ok=True)
230
+ processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
231
+ model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
232
+ model.eval()
233
+ sr = int(model.config.audio_encoder.sampling_rate)
234
+ gen_s = min(seed_s, target_s)
235
+ tokens = max(1, int(math.ceil(gen_s * token_rate)))
236
+ print(f"[musicgen] seed dur={gen_s:.2f}s tokens={tokens}", flush=True)
237
+ inputs = processor(text=[prompt], padding=True, return_tensors="pt")
238
+ audio = model.generate(**inputs, max_new_tokens=tokens)
239
+ seed = audio[0, 0].detach().cpu().numpy().astype("float32")
240
+ peak = float(np.max(np.abs(seed)))
241
+ if peak > 1e-6: seed = seed * (0.89 / peak)
242
+ want = max(1, int(round(target_s * sr)))
243
+ if seed.shape[0] >= want:
244
+ final = seed[:want].copy()
245
+ else:
246
+ final = loop_crossfade(seed, want, int(round(crossfade_s * sr)))
247
+ if final.shape[0] < want: final = np.pad(final, (0, want - final.shape[0]))
248
+ else: final = final[:want]
249
+ final = apply_fade(final, sr)
250
+ peak = float(np.max(np.abs(final)))
251
+ if peak > 1.0: final = final / peak
252
+ sf.write(out_path, final, sr)
253
+ print(f"[musicgen] wrote {out_path} samples={final.shape[0]} sr={sr}", flush=True)
254
+ except Exception:
255
+ traceback.print_exc(); sys.exit(1)
256
+ `;
257
+ }
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm.mjs";
4
+
5
+ // Regression: narrated pipelines used to ship BGM at 0.8 (≈ -2 dB), ~16 dB
6
+ // hotter than a music bed under a voice should be. The default under narration
7
+ // must be a proper bed (≈ -18 dB); a silent film keeps the louder default.
8
+
9
+ const dbfs = (linear) => 20 * Math.log10(linear);
10
+
11
+ test("BGM under narration is a bed near -18 dB", () => {
12
+ assert.equal(bgmDefaultVolume(true), BGM_BED_VOLUME);
13
+ assert.equal(BGM_BED_VOLUME, 0.12);
14
+ const db = dbfs(BGM_BED_VOLUME);
15
+ assert.ok(db < -17 && db > -19, `bed should be ≈ -18 dB, got ${db.toFixed(1)} dB`);
16
+ });
17
+
18
+ test("a silent film (no voice) keeps BGM forward", () => {
19
+ assert.equal(bgmDefaultVolume(false), BGM_SILENT_VOLUME);
20
+ assert.equal(BGM_SILENT_VOLUME, 0.9);
21
+ });
22
+
23
+ test("the narrated default is well below the voice (≈ 0 dBFS)", () => {
24
+ // Voice sits at data-volume="1" (0 dBFS); the bed must be ~16+ dB under it.
25
+ const separation = dbfs(1) - dbfs(bgmDefaultVolume(true));
26
+ assert.ok(
27
+ separation >= 16,
28
+ `bed should sit ≥16 dB under the voice, got ${separation.toFixed(1)} dB`,
29
+ );
30
+ });
@@ -0,0 +1,14 @@
1
+ // mapWithConcurrency — run `fn` over `items` with at most `limit` in flight at
2
+ // once. Preserves input order in the result array regardless of completion order.
3
+ export async function mapWithConcurrency(items, limit, fn) {
4
+ const results = new Array(items.length);
5
+ let next = 0;
6
+ async function worker() {
7
+ while (next < items.length) {
8
+ const i = next++;
9
+ results[i] = await fn(items[i], i);
10
+ }
11
+ }
12
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
13
+ return results;
14
+ }
@@ -0,0 +1,41 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mapWithConcurrency } from "./concurrency.mjs";
4
+
5
+ // Regression: audio.mjs used a bare Promise.all(lines.map(synthLine)) to
6
+ // synthesize every TTS line at once, spawning one Kokoro/whisper model load
7
+ // per line concurrently. Two independent reports of this overwhelming a
8
+ // machine (OOM, and cold-start contention causing spurious failures).
9
+ // mapWithConcurrency is the extracted cap; test it in isolation since
10
+ // audio.mjs itself is a script (runs CLI/exit side effects on import).
11
+ test("processes every item and preserves input order regardless of completion order", async () => {
12
+ const order = [5, 1, 3, 2, 4];
13
+ const results = await mapWithConcurrency(order, 2, async (n) => {
14
+ await new Promise((r) => setTimeout(r, n));
15
+ return n * 10;
16
+ });
17
+ assert.deepEqual(results, [50, 10, 30, 20, 40]);
18
+ });
19
+
20
+ test("never runs more than `limit` at once", async () => {
21
+ let inFlight = 0;
22
+ let maxInFlight = 0;
23
+ const items = Array.from({ length: 10 }, (_, i) => i);
24
+ await mapWithConcurrency(items, 3, async () => {
25
+ inFlight++;
26
+ maxInFlight = Math.max(maxInFlight, inFlight);
27
+ await new Promise((r) => setTimeout(r, 5));
28
+ inFlight--;
29
+ });
30
+ assert.equal(maxInFlight, 3);
31
+ });
32
+
33
+ test("limit larger than the item count runs everything without hanging", async () => {
34
+ const results = await mapWithConcurrency([1, 2], 10, async (n) => n * 2);
35
+ assert.deepEqual(results, [2, 4]);
36
+ });
37
+
38
+ test("empty input resolves to an empty array", async () => {
39
+ const results = await mapWithConcurrency([], 4, async (n) => n);
40
+ assert.deepEqual(results, []);
41
+ });
@@ -0,0 +1,156 @@
1
+ import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
2
+ // heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
3
+ // pipeline. The credential resolver matches the hyperframes CLI auth: first
4
+ // usable source wins — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
5
+ // credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
6
+ // overrides the dir). Vendored so the skill ships standalone. Pure node.
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join, resolve } from "node:path";
11
+
12
+ export const HEYGEN_BASE = "https://api.heygen.com/v3";
13
+ export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
14
+ // Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
15
+ // the backend can isolate media-use consumption from other free TTS / avatar video.
16
+ // Unconditional — a paying user's media-use call is still media-use — unlike the
17
+ // OAuth-only cli-source header above, which also gates the free allowance.
18
+ export const HEYGEN_CLIENT_SOURCE_HEADERS = { "X-HeyGen-Client-Source": "media-use" };
19
+
20
+ // Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
21
+ export function loadEnvFromDir(startDir) {
22
+ let dir = resolve(startDir);
23
+ for (let i = 0; i < 5; i++) {
24
+ const envPath = join(dir, ".env");
25
+ if (existsSync(envPath)) {
26
+ for (const raw of readFileSync(envPath, "utf8").split("\n")) {
27
+ let line = raw.trim();
28
+ if (!line || line.startsWith("#")) continue;
29
+ if (line.startsWith("export ")) line = line.slice(7).trim();
30
+ const eq = line.indexOf("=");
31
+ if (eq < 1) continue;
32
+ const key = line.slice(0, eq).trim();
33
+ let val = line.slice(eq + 1).trim();
34
+ if (val.startsWith('"') || val.startsWith("'")) {
35
+ const q = val[0];
36
+ const end = val.indexOf(q, 1);
37
+ val = end > 0 ? val.slice(1, end) : val.slice(1);
38
+ }
39
+ if (!(key in process.env)) process.env[key] = val;
40
+ }
41
+ return;
42
+ }
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ }
48
+
49
+ // → { headers } | { expired: true } | null. Never throws.
50
+ export function heygenCredential() {
51
+ const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
52
+ if (envKey) return { headers: { "X-Api-Key": envKey } };
53
+
54
+ const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
55
+ if (!existsSync(file)) return null;
56
+ const raw = readFileSync(file, "utf8").trim();
57
+ if (!raw) return null;
58
+ if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
59
+
60
+ // A malformed credentials file (partial write / wrong shape) must degrade to
61
+ // "no credential", not crash the engine at startup — this function never throws.
62
+ let cred;
63
+ try {
64
+ cred = JSON.parse(raw);
65
+ } catch {
66
+ return null;
67
+ }
68
+ const oauth = cred.oauth;
69
+ if (oauth?.access_token) {
70
+ const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
71
+ if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
72
+ if (!cred.api_key) return { expired: true };
73
+ }
74
+ if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
75
+ return null;
76
+ }
77
+
78
+ // → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
79
+ // makes internally, exposed on its own so callers that only need to *tag* the
80
+ // auth path (telemetry) don't have to parse headers back apart. Never throws:
81
+ // no credential (or an expired one) is just `null`, same as a fresh resolve
82
+ // with nothing to tag.
83
+ export function heygenAuthMethod() {
84
+ const cred = heygenCredential();
85
+ if (!cred?.headers) return null;
86
+ return "Authorization" in cred.headers ? "oauth" : "api_key";
87
+ }
88
+
89
+ // → auth headers object, or throw with a fix hint.
90
+ export function heygenAuthHeaders() {
91
+ const cred = heygenCredential();
92
+ if (cred?.headers) {
93
+ // Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
94
+ // grant the free allowance for OAuth requests and ignores it for API-key
95
+ // (X-Api-Key) traffic, where it's dead metadata.
96
+ const isOauth = "Authorization" in cred.headers;
97
+ return isOauth
98
+ ? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS, ...HEYGEN_CLIENT_SOURCE_HEADERS }
99
+ : { ...cred.headers, ...HEYGEN_CLIENT_SOURCE_HEADERS };
100
+ }
101
+ if (cred?.expired)
102
+ throw new Error(
103
+ "HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
104
+ );
105
+ throw new Error(
106
+ "no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
107
+ );
108
+ }
109
+
110
+ // Authed JSON request against the v3 API; throws on a non-OK status.
111
+ export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
112
+ const opts = { method, headers: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } };
113
+ if (body !== undefined) {
114
+ opts.headers["Content-Type"] = "application/json";
115
+ opts.body = JSON.stringify(body);
116
+ }
117
+ const res = await fetch(`${HEYGEN_BASE}${path}`, opts);
118
+ if (!res.ok) {
119
+ const detail = await res.text().catch(() => "");
120
+ throw new Error(
121
+ `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`,
122
+ );
123
+ }
124
+ return res.json();
125
+ }
126
+
127
+ // Download a (presigned) URL to destPath; returns byte length.
128
+ export async function downloadTo(url, destPath) {
129
+ const res = await fetchMedia(url);
130
+ if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
131
+ const bytes = Buffer.from(await res.arrayBuffer());
132
+ mkdirSync(dirname(destPath), { recursive: true });
133
+ writeFileSync(destPath, bytes);
134
+ return bytes.length;
135
+ }
136
+
137
+ // Retrieval search over HeyGen's audio catalog (NOT generation). type =
138
+ // "music" | "sound_effects". Returns the ranked results array (best first); each
139
+ // item has a presigned `audio_url` (+ `duration`, `description`, `name`, `score`).
140
+ // `query` is required (≥1 char, empty → HTTP 400) and `limit` is capped at 50.
141
+ // `minScore`: omit to use the server default (0.7). That default is TOO HIGH for
142
+ // sound_effects — good SFX hits score ~0.5–0.67, so callers wanting SFX should
143
+ // pass a lower floor (~0.4); music scores high and is fine at the default.
144
+ export async function searchSounds(query, type, headers, { limit = 5, minScore } = {}) {
145
+ const params = new URLSearchParams({ query, type, limit: String(limit) });
146
+ if (minScore != null) params.set("min_score", String(minScore));
147
+ const payload = await heygenJSON(`/audio/sounds?${params.toString()}`, { headers });
148
+ // `data` comes back as a ranked array (best first). Older responses keyed it by
149
+ // numeric index ("0","1",…); normalize both shapes to an array (empty → []).
150
+ const data = payload?.data ?? payload;
151
+ if (Array.isArray(data)) return data;
152
+ if (data && typeof data === "object") return Object.values(data);
153
+ throw new Error(
154
+ `unexpected /audio/sounds shape — top keys: ${Object.keys(payload ?? {}).join(", ")}`,
155
+ );
156
+ }
@@ -0,0 +1,103 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { heygenAuthHeaders, heygenAuthMethod } from "./heygen.mjs";
7
+
8
+ function withCleanHeygenEnv(fn) {
9
+ const previousApiKey = process.env.HEYGEN_API_KEY;
10
+ const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY;
11
+ const previousConfigDir = process.env.HEYGEN_CONFIG_DIR;
12
+ try {
13
+ delete process.env.HEYGEN_API_KEY;
14
+ delete process.env.HYPERFRAMES_API_KEY;
15
+ delete process.env.HEYGEN_CONFIG_DIR;
16
+ return fn();
17
+ } finally {
18
+ if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY;
19
+ else process.env.HEYGEN_API_KEY = previousApiKey;
20
+ if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY;
21
+ else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey;
22
+ if (previousConfigDir === undefined) delete process.env.HEYGEN_CONFIG_DIR;
23
+ else process.env.HEYGEN_CONFIG_DIR = previousConfigDir;
24
+ }
25
+ }
26
+
27
+ test("heygenAuthHeaders does not tag API-key requests as CLI traffic, but still carries the media-use tool tag", () => {
28
+ withCleanHeygenEnv(() => {
29
+ process.env.HEYGEN_API_KEY = "hg_test";
30
+ // API-key requests use normal billing; the backend ignores the cli-source
31
+ // header for them, so it's not sent. The tool-attribution header IS sent on
32
+ // every media-use call (any auth type) so the backend can isolate media-use.
33
+ assert.deepEqual(heygenAuthHeaders(), {
34
+ "X-Api-Key": "hg_test",
35
+ "X-HeyGen-Client-Source": "media-use",
36
+ });
37
+ });
38
+ });
39
+
40
+ test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-use tool tag", () => {
41
+ withCleanHeygenEnv(() => {
42
+ const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
43
+ try {
44
+ process.env.HEYGEN_CONFIG_DIR = dir;
45
+ writeFileSync(
46
+ join(dir, "credentials"),
47
+ JSON.stringify({
48
+ oauth: {
49
+ access_token: "at_test",
50
+ expires_at: "2099-01-01T00:00:00Z",
51
+ },
52
+ }),
53
+ );
54
+ assert.deepEqual(heygenAuthHeaders(), {
55
+ Authorization: "Bearer at_test",
56
+ "X-HeyGen-Source": "cli",
57
+ "X-HeyGen-Client-Source": "media-use",
58
+ });
59
+ } finally {
60
+ rmSync(dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
64
+
65
+ test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
66
+ withCleanHeygenEnv(() => {
67
+ process.env.HEYGEN_API_KEY = "hg_test";
68
+ assert.equal(heygenAuthMethod(), "api_key");
69
+ });
70
+ });
71
+
72
+ test("heygenAuthMethod returns oauth for a live OAuth credential", () => {
73
+ withCleanHeygenEnv(() => {
74
+ const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
75
+ try {
76
+ process.env.HEYGEN_CONFIG_DIR = dir;
77
+ writeFileSync(
78
+ join(dir, "credentials"),
79
+ JSON.stringify({
80
+ oauth: {
81
+ access_token: "at_test",
82
+ expires_at: "2099-01-01T00:00:00Z",
83
+ },
84
+ }),
85
+ );
86
+ assert.equal(heygenAuthMethod(), "oauth");
87
+ } finally {
88
+ rmSync(dir, { recursive: true, force: true });
89
+ }
90
+ });
91
+ });
92
+
93
+ test("heygenAuthMethod returns null with no credential at all", () => {
94
+ withCleanHeygenEnv(() => {
95
+ const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
96
+ try {
97
+ process.env.HEYGEN_CONFIG_DIR = dir; // no credentials file written
98
+ assert.equal(heygenAuthMethod(), null);
99
+ } finally {
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ });
103
+ });
@@ -0,0 +1,63 @@
1
+ // python.mjs — resolve which Python 3 executable to spawn, per platform.
2
+ //
3
+ // The audio engine (tts.mjs, bgm.mjs) shells out to `python3` for ElevenLabs
4
+ // TTS and the local Lyria/MusicGen BGM paths. `python3` is the right name on
5
+ // macOS/Linux, but on Windows the python.org installer only creates
6
+ // `python.exe` plus the `py` launcher — there is no `python3.exe` (only the
7
+ // Microsoft Store build adds one). So a bare `spawn("python3", …)` ENOENTs on a
8
+ // standard Windows Python install, silently disabling every Python-backed audio
9
+ // feature until the user hand-creates a `python3.exe` shim (reported twice).
10
+ //
11
+ // Resolve once, per process: probe the platform's candidates in order and take
12
+ // the first that actually runs. `py` is the launcher, so it needs a `-3` arg to
13
+ // select Python 3 — hence candidates are argv PREFIXES, not bare names.
14
+
15
+ import { spawnSync } from "node:child_process";
16
+
17
+ function defaultProbe(cmd, args) {
18
+ try {
19
+ return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Pick the argv prefix that launches Python 3 on this platform.
27
+ * Returns e.g. `["python3"]`, `["python"]`, or `["py", "-3"]`.
28
+ *
29
+ * Pure except for `probe` (which runs `<cmd> … --version`); both `platform`
30
+ * and `probe` are injectable so every branch is unit-testable without spawning.
31
+ * If nothing probes OK, falls back to the canonical name for the platform so
32
+ * the eventual spawn fails loudly exactly as it did before — never worse.
33
+ */
34
+ export function resolvePythonCommand(platform = process.platform, probe = defaultProbe) {
35
+ const candidates =
36
+ platform === "win32" ? [["python3"], ["python"], ["py", "-3"]] : [["python3"], ["python"]];
37
+ for (const prefix of candidates) {
38
+ if (probe(prefix[0], [...prefix.slice(1), "--version"])) return prefix;
39
+ }
40
+ return candidates[0];
41
+ }
42
+
43
+ let cached = null;
44
+
45
+ /** Cached `resolvePythonCommand()` — probing spawns, so resolve at most once. */
46
+ export function pythonCommand() {
47
+ if (!cached) cached = resolvePythonCommand();
48
+ return cached;
49
+ }
50
+
51
+ /**
52
+ * Build a `{ cmd, args }` for running Python 3 with `extraArgs`, using the
53
+ * resolved (or supplied) prefix. Keeps the launcher's `-3` (and any future
54
+ * prefix args) ahead of the caller's own arguments.
55
+ */
56
+ export function pythonInvocation(extraArgs, prefix = pythonCommand()) {
57
+ return { cmd: prefix[0], args: [...prefix.slice(1), ...extraArgs] };
58
+ }
59
+
60
+ /** Test-only: clear the cached resolution so a test can re-probe. */
61
+ export function _resetPythonCommandCacheForTests() {
62
+ cached = null;
63
+ }