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,107 @@
1
+ const STOP = new Set(
2
+ (
3
+ "the a an and or of to in on at is are be it its for with that this as by from into " +
4
+ "one two must not no all over under across while when where which who whom whose they " +
5
+ "them their we our you your he she his her but if then than so such can may might will " +
6
+ "would should each other another same both few more most some any every"
7
+ ).split(" "),
8
+ );
9
+
10
+ const PLURAL_RULES = [
11
+ [/([^aeiou])ies$/, "$1y"],
12
+ [/(ch|sh|ss|x)es$/, "$1"],
13
+ [/(ss|us|is)$/, "$&"],
14
+ [/s$/, ""],
15
+ ];
16
+ const STRONG_FIELD_WEIGHT = 3;
17
+ const INFERRED_TOKEN_WEIGHT = 0.35;
18
+
19
+ function singularize(word) {
20
+ if (word.length <= 3) return word;
21
+ for (const [pattern, replacement] of PLURAL_RULES) {
22
+ if (pattern.test(word)) return word.replace(pattern, replacement);
23
+ }
24
+ return word;
25
+ }
26
+
27
+ function tokenize(text) {
28
+ const words =
29
+ String(text)
30
+ .toLowerCase()
31
+ .match(/[a-z]+/g) || [];
32
+ return words.filter((word) => word.length > 2 && !STOP.has(word)).map(singularize);
33
+ }
34
+
35
+ export function rankMediaRows(query, rows) {
36
+ const asked = tokenize(query);
37
+ const normalizedLiteralQuery = String(query).trim().toLowerCase();
38
+ const normalizedQuery = asked.join(" ");
39
+ const want = new Map(asked.map((token) => [token, 1]));
40
+ if (want.size === 0) return [];
41
+
42
+ const parsed = rows.map((row) => {
43
+ const strongTokens = new Set(tokenize(`${row.id} ${row.title}`));
44
+ const allTokens = new Set([
45
+ ...strongTokens,
46
+ ...tokenize(`${row.description} ${row.tags.join(" ")} ${row.kind}`),
47
+ ]);
48
+ return {
49
+ row,
50
+ strongTokens,
51
+ allTokens,
52
+ literalId: row.id.trim().toLowerCase() === normalizedLiteralQuery,
53
+ stemmedId: tokenize(row.id).join(" ") === normalizedQuery,
54
+ };
55
+ });
56
+ const vocabulary = new Set(parsed.flatMap(({ allTokens }) => [...allTokens]));
57
+ for (const token of asked) {
58
+ if (token.length < 6) continue;
59
+ for (let cut = 3; cut <= token.length - 3; cut++) {
60
+ const head = token.slice(0, cut);
61
+ const tail = token.slice(cut);
62
+ if (vocabulary.has(head) && vocabulary.has(tail)) {
63
+ want.set(head, INFERRED_TOKEN_WEIGHT);
64
+ want.set(tail, INFERRED_TOKEN_WEIGHT);
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ for (let index = 0; index < asked.length - 1; index++) {
70
+ const joined = `${asked[index]}${asked[index + 1]}`;
71
+ if (vocabulary.has(joined)) want.set(joined, INFERRED_TOKEN_WEIGHT);
72
+ }
73
+
74
+ const idf = new Map();
75
+ for (const token of want.keys()) {
76
+ const documentFrequency = parsed.filter(({ allTokens }) => allTokens.has(token)).length;
77
+ idf.set(token, Math.log((parsed.length + 1) / (documentFrequency + 1)) + 1);
78
+ }
79
+ return parsed
80
+ .map(({ row, strongTokens, allTokens, literalId, stemmedId }) => {
81
+ let score = 0;
82
+ for (const [token, asking] of want) {
83
+ const weight = (idf.get(token) || 1) * asking;
84
+ if (strongTokens.has(token)) score += STRONG_FIELD_WEIGHT * weight;
85
+ else if (allTokens.has(token)) score += weight;
86
+ }
87
+ return { row, score, literalId, stemmedId };
88
+ })
89
+ .filter(({ score }) => score > 0)
90
+ .sort(
91
+ (a, b) =>
92
+ Number(b.literalId) - Number(a.literalId) ||
93
+ Number(b.stemmedId) - Number(a.stemmedId) ||
94
+ b.score - a.score,
95
+ )
96
+ .map(({ row }) => row);
97
+ }
98
+
99
+ export async function rankMediaRowsWithVectors(query, rows, semanticRanking) {
100
+ const words = rankMediaRows(query, rows);
101
+ if (words.length > 0) return { rows: words, tier: "words" };
102
+ if (!semanticRanking) return { rows: [], tier: "words" };
103
+ const semantic = await semanticRanking(query, rows);
104
+ return semantic
105
+ ? { rows: semantic.map(({ row }) => row), tier: "on-device" }
106
+ : { rows: [], tier: "words" };
107
+ }
@@ -0,0 +1,128 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describeDownload, probeSpecs } from "./specs.mjs";
6
+ import { buildArgv, selectModelLadder } from "./local-models.mjs";
7
+
8
+ // Local image generation via mflux (FLUX-on-MLX), the Mac-native runner.
9
+ // Spec-gated: selectModelLadder("imagegen", specs) returns every FLUX-class
10
+ // model the machine's AVAILABLE RAM can actually run (medium FLUX-schnell
11
+ // --low-ram on ~24GB, up to Qwen-Image on 64GB+), best first. When nothing
12
+ // local fits, or no fitting tier can actually run here, this returns null so
13
+ // the registry falls through to the codex image upsell.
14
+ //
15
+ // The official FLUX repos are HF-gated, so the model entries point --path at
16
+ // non-gated community 4-bit re-uploads; the repo is resolved to a local snapshot
17
+ // (hf download, idempotent) because a bare repo id breaks mlx unflatten.
18
+
19
+ // Resolve an HF repo to its local snapshot dir. `hf download` is idempotent and
20
+ // prints the snapshot path as its last line.
21
+ function resolveSnapshot(repo, execFn, pathExists) {
22
+ const out = execFn("hf", ["download", repo], {
23
+ encoding: "utf8",
24
+ timeout: 1_800_000,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ });
27
+ const path = out?.trim().split(/\r?\n/).pop()?.trim();
28
+ return path && pathExists(path) ? path : null;
29
+ }
30
+
31
+ export async function mfluxImageGenerate(
32
+ intent,
33
+ ctx,
34
+ execFn = execFileSync,
35
+ pathExists = existsSync,
36
+ unlinkFn = unlinkSync,
37
+ ) {
38
+ const specs = ctx?.specs || probeSpecs();
39
+ const ladder = selectModelLadder("imagegen", specs, { preferTier: ctx?.preferTier });
40
+ if (!ladder.length) return null; // no local model fits -> codex upsell/fallback
41
+
42
+ // Each attempt mints its own timestamped output path, so a partial artifact
43
+ // from a failed tier is orphaned rather than overwritten - and a lower tier
44
+ // then succeeding hides it. Discard it before demoting. Best-effort: a
45
+ // partial we cannot remove must never mask the real failure.
46
+ const discardPartial = (path) => {
47
+ try {
48
+ if (pathExists(path)) unlinkFn(path);
49
+ } catch {
50
+ // nothing actionable: the generate failure below is the real story
51
+ }
52
+ };
53
+
54
+ // Best tier first, demoting past any tier that cannot run here (runner off
55
+ // PATH, a snapshot that won't download, an OOM) rather than failing local
56
+ // image gen outright. Every demotion is reported, so a quietly smaller model
57
+ // is never mistaken for the tier the machine nominally qualified for.
58
+ for (const model of ladder) {
59
+ const bin = model.invoke.trim().split(/\s+/)[0];
60
+ // Not installed? Surface the exact enable-command (before the model
61
+ // download) so the agent learns the free local path is available instead of
62
+ // silently taking the codex upsell.
63
+ try {
64
+ execFn("which", [bin], { stdio: ["ignore", "ignore", "ignore"] });
65
+ } catch {
66
+ console.error(
67
+ `media-use: local image gen not enabled (\`${bin}\` not on PATH). Install for free on-device FLUX: ${model.install}. Heads up: ${model.id} ${describeDownload(model.sizeMB)}.`,
68
+ );
69
+ continue;
70
+ }
71
+
72
+ const outPath = join(tmpdir(), `media-use-mflux-${process.pid}-${Date.now()}.png`);
73
+ const vars = {
74
+ prompt: intent,
75
+ w: ctx?.width || 512,
76
+ h: ctx?.height || 512,
77
+ seed: ctx?.seed ?? 42,
78
+ out: outPath,
79
+ };
80
+ if (model.repo && model.invoke.includes("{model_path}")) {
81
+ let snap = null;
82
+ let why = `could not resolve a local snapshot of ${model.repo}`;
83
+ try {
84
+ snap = resolveSnapshot(model.repo, execFn, pathExists);
85
+ } catch (err) {
86
+ why = `hf download failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`;
87
+ }
88
+ if (!snap) {
89
+ console.error(`media-use: local image gen (${model.id}): ${why}`);
90
+ continue;
91
+ }
92
+ vars.model_path = snap;
93
+ }
94
+
95
+ const argv = buildArgv(model.invoke, vars);
96
+ argv.shift(); // drop the bin (already validated)
97
+ try {
98
+ execFn(bin, argv, {
99
+ encoding: "utf8",
100
+ timeout: 1_800_000,
101
+ stdio: ["ignore", "pipe", "pipe"],
102
+ });
103
+ } catch (err) {
104
+ discardPartial(outPath);
105
+ console.error(
106
+ `media-use: local image gen (${model.id}) failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
107
+ );
108
+ continue;
109
+ }
110
+ if (!pathExists(outPath)) {
111
+ console.error(
112
+ `media-use: local image gen (${model.id}) exited cleanly but wrote no output file`,
113
+ );
114
+ continue;
115
+ }
116
+ return {
117
+ localPath: outPath,
118
+ ext: ".png",
119
+ source: "generated",
120
+ metadata: {
121
+ description: intent,
122
+ provider: `mflux.${model.id}`,
123
+ provenance: { model: model.id, tier: model.tier, prompt: intent },
124
+ },
125
+ };
126
+ }
127
+ return null;
128
+ }
@@ -0,0 +1,174 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mfluxImageGenerate } from "./mflux-provider.mjs";
4
+
5
+ // 40GB clears the 32GB klein tier and the 8GB schnell tier below it; the 64GB
6
+ // qwen tier stays out of reach. Two rungs is what makes demotion observable.
7
+ const bothTiersSpecs = { availableRamMB: 40000, gpu: { present: true } };
8
+ const SNAPSHOT = "/tmp/hf-snapshot";
9
+
10
+ // exec stub covering all three shells-out mflux does: the PATH probe, the
11
+ // idempotent `hf download`, and the generate itself.
12
+ function stubExec({ failGenerateFor = [], failWhichFor = [] } = {}) {
13
+ const calls = [];
14
+ const exec = (...call) => {
15
+ calls.push(call);
16
+ const [bin, argv] = call;
17
+ if (bin === "which") {
18
+ if (failWhichFor.includes(argv[0])) throw new Error("not found");
19
+ return "";
20
+ }
21
+ if (bin === "hf") return `Fetching 6 files...\n${SNAPSHOT}\n`;
22
+ if (failGenerateFor.some((id) => argv.join(" ").includes(id))) {
23
+ const err = new Error("exit 1");
24
+ err.stderr = "mlx.core.metal: out of memory";
25
+ throw err;
26
+ }
27
+ return "";
28
+ };
29
+ return { calls, exec };
30
+ }
31
+
32
+ const generateCalls = (calls) =>
33
+ calls.filter(([bin]) => bin !== "which" && bin !== "hf").map(([, argv]) => argv.join(" "));
34
+
35
+ test("no local model fits: falls through to the upsell without shelling out", async () => {
36
+ const { calls, exec } = stubExec();
37
+
38
+ const result = await mfluxImageGenerate(
39
+ "a red bicycle",
40
+ { specs: { availableRamMB: 100, gpu: { present: true } } },
41
+ exec,
42
+ () => true,
43
+ );
44
+
45
+ assert.equal(result, null);
46
+ assert.deepEqual(calls, []);
47
+ });
48
+
49
+ test("a top tier that cannot run demotes to the next fitting tier", async (t) => {
50
+ const errors = [];
51
+ t.mock.method(console, "error", (message) => errors.push(message));
52
+ const { calls, exec } = stubExec({ failGenerateFor: ["flux2-klein-4b"] });
53
+
54
+ const result = await mfluxImageGenerate(
55
+ "a red bicycle",
56
+ { specs: bothTiersSpecs },
57
+ exec,
58
+ () => true,
59
+ );
60
+
61
+ assert.ok(result, "the schnell tier still produced an image");
62
+ assert.equal(result.metadata.provider, "mflux.flux-schnell-mflux-q4");
63
+ const generated = generateCalls(calls);
64
+ assert.equal(generated.length, 2, "klein attempted first, then schnell");
65
+ assert.match(generated[0], /flux2-klein-4b/);
66
+ assert.match(generated[1], /--model schnell/);
67
+ assert.equal(errors.length, 1, "the demotion is reported, not silent");
68
+ assert.match(errors[0], /flux2-klein-mflux-q4\) failed/);
69
+ });
70
+
71
+ test("a snapshot that will not resolve demotes rather than failing outright", async (t) => {
72
+ const errors = [];
73
+ t.mock.method(console, "error", (message) => errors.push(message));
74
+ const calls = [];
75
+ const exec = (...call) => {
76
+ calls.push(call);
77
+ const [bin, argv] = call;
78
+ if (bin === "which") return "";
79
+ // klein's weights won't download; schnell's do
80
+ if (bin === "hf") {
81
+ if (argv[1].includes("FLUX.2-klein")) throw new Error("403 Forbidden");
82
+ return `${SNAPSHOT}\n`;
83
+ }
84
+ return "";
85
+ };
86
+
87
+ const result = await mfluxImageGenerate(
88
+ "a red bicycle",
89
+ { specs: bothTiersSpecs },
90
+ exec,
91
+ () => true,
92
+ );
93
+
94
+ assert.ok(result, "demoted past the ungettable weights");
95
+ assert.equal(result.metadata.provider, "mflux.flux-schnell-mflux-q4");
96
+ assert.equal(errors.length, 1);
97
+ assert.match(errors[0], /hf download failed/);
98
+ });
99
+
100
+ test("runner missing from PATH reports the install hint per tier and returns null", async (t) => {
101
+ const errors = [];
102
+ t.mock.method(console, "error", (message) => errors.push(message));
103
+ const { exec } = stubExec({ failWhichFor: ["mflux-generate"] });
104
+
105
+ const result = await mfluxImageGenerate(
106
+ "a red bicycle",
107
+ { specs: bothTiersSpecs },
108
+ exec,
109
+ () => true,
110
+ );
111
+
112
+ assert.equal(result, null);
113
+ assert.equal(errors.length, 2, "both fitting tiers reported");
114
+ assert.match(errors[0], /uv pip install mflux/);
115
+ // each hint states that tier's download cost before the user commits
116
+ assert.match(errors[0], /GB of weights to/);
117
+ assert.match(errors[1], /GB of weights to/);
118
+ });
119
+
120
+ // Same per-attempt temp path, same orphaning risk as the LTX provider: a
121
+ // partial png from a failed tier must not survive a lower tier succeeding.
122
+ const outputOf = (argv) => argv[argv.indexOf("--output") + 1];
123
+ const attemptedOutputs = (calls) =>
124
+ calls.filter(([bin]) => bin !== "which" && bin !== "hf").map(([, argv]) => outputOf(argv));
125
+
126
+ test("a failed attempt's partial output is discarded before demoting", async (t) => {
127
+ t.mock.method(console, "error", () => {});
128
+ const unlinked = [];
129
+ const { calls, exec } = stubExec({ failGenerateFor: ["flux2-klein-4b"] });
130
+
131
+ const result = await mfluxImageGenerate(
132
+ "a red bicycle",
133
+ { specs: bothTiersSpecs },
134
+ exec,
135
+ () => true,
136
+ (path) => unlinked.push(path),
137
+ );
138
+
139
+ assert.ok(result, "the schnell tier still produced an image");
140
+ assert.deepEqual(unlinked, [attemptedOutputs(calls)[0]], "the failed klein partial is removed");
141
+ });
142
+
143
+ test("every tier failing discards every partial, one per attempt", async (t) => {
144
+ t.mock.method(console, "error", () => {});
145
+ const unlinked = [];
146
+ const { calls, exec } = stubExec({ failGenerateFor: ["flux2-klein-4b", "schnell"] });
147
+
148
+ const result = await mfluxImageGenerate(
149
+ "a red bicycle",
150
+ { specs: bothTiersSpecs },
151
+ exec,
152
+ () => true,
153
+ (path) => unlinked.push(path),
154
+ );
155
+
156
+ assert.equal(result, null);
157
+ assert.deepEqual(unlinked, attemptedOutputs(calls), "nothing is left behind");
158
+ });
159
+
160
+ test("a successful generation is never discarded", async () => {
161
+ const unlinked = [];
162
+ const { exec } = stubExec();
163
+
164
+ const result = await mfluxImageGenerate(
165
+ "a red bicycle",
166
+ { specs: bothTiersSpecs },
167
+ exec,
168
+ () => true,
169
+ (path) => unlinked.push(path),
170
+ );
171
+
172
+ assert.ok(result);
173
+ assert.deepEqual(unlinked, [], "the returned artifact must survive");
174
+ });
@@ -0,0 +1,49 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const MISSES_FILE = "misses.jsonl";
6
+
7
+ function missesPath() {
8
+ return join(homedir(), ".media", MISSES_FILE);
9
+ }
10
+
11
+ export function recordMiss({ type, intent, provider_override, local_only }) {
12
+ try {
13
+ const dir = join(homedir(), ".media");
14
+ mkdirSync(dir, { recursive: true });
15
+ appendFileSync(
16
+ join(dir, MISSES_FILE),
17
+ JSON.stringify({
18
+ ts: new Date().toISOString(),
19
+ type,
20
+ intent,
21
+ provider_override: !!provider_override,
22
+ local_only: !!local_only,
23
+ }) + "\n",
24
+ );
25
+ } catch {
26
+ // local miss logging is best-effort; never surface into resolve
27
+ }
28
+ }
29
+
30
+ export function readMisses() {
31
+ const p = missesPath();
32
+ try {
33
+ if (!existsSync(p)) return [];
34
+ const raw = readFileSync(p, "utf8");
35
+ const records = [];
36
+ for (const line of raw.split(/\r?\n/)) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) continue;
39
+ try {
40
+ records.push(JSON.parse(trimmed));
41
+ } catch {
42
+ // skip malformed local lines, don't crash stats
43
+ }
44
+ }
45
+ return records;
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
@@ -0,0 +1,78 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { readMisses, recordMiss } from "./misses.mjs";
7
+
8
+ function sandbox() {
9
+ const root = mkdtempSync(join(tmpdir(), "mu-misses-"));
10
+ const home = join(root, "home");
11
+ mkdirSync(home, { recursive: true });
12
+ process.env.HOME = home;
13
+ return { root, home };
14
+ }
15
+
16
+ function restoreEnv(saved) {
17
+ for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
18
+ Object.assign(process.env, saved);
19
+ }
20
+
21
+ test("recordMiss appends a well-formed local miss", () => {
22
+ const savedEnv = { ...process.env };
23
+ const { root, home } = sandbox();
24
+ try {
25
+ recordMiss({ type: "bgm", intent: "moody synth pulse", provider_override: true });
26
+ const misses = readMisses();
27
+ assert.equal(misses.length, 1);
28
+ assert.equal(misses[0].type, "bgm");
29
+ assert.equal(misses[0].intent, "moody synth pulse");
30
+ assert.equal(misses[0].provider_override, true);
31
+ assert.equal(misses[0].local_only, false);
32
+ assert.ok(!Number.isNaN(Date.parse(misses[0].ts)));
33
+
34
+ const raw = readFileSync(join(home, ".media/misses.jsonl"), "utf8");
35
+ assert.match(raw, /moody synth pulse/);
36
+ } finally {
37
+ restoreEnv(savedEnv);
38
+ rmSync(root, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test("recordMiss swallows filesystem failures", () => {
43
+ const savedEnv = { ...process.env };
44
+ const { root, home } = sandbox();
45
+ try {
46
+ writeFileSync(join(home, ".media"), "not a directory");
47
+ assert.doesNotThrow(() =>
48
+ recordMiss({ type: "image", intent: "unwritable", local_only: true }),
49
+ );
50
+ assert.equal(existsSync(join(home, ".media/misses.jsonl")), false);
51
+ } finally {
52
+ restoreEnv(savedEnv);
53
+ rmSync(root, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("readMisses skips corrupt lines", () => {
58
+ const savedEnv = { ...process.env };
59
+ const { root, home } = sandbox();
60
+ try {
61
+ mkdirSync(join(home, ".media"), { recursive: true });
62
+ writeFileSync(
63
+ join(home, ".media/misses.jsonl"),
64
+ [
65
+ JSON.stringify({ ts: "2026-07-09T00:00:00.000Z", type: "bgm", intent: "one" }),
66
+ "{not json",
67
+ JSON.stringify({ ts: "2026-07-09T00:00:01.000Z", type: "sfx", intent: "two" }),
68
+ ].join("\n"),
69
+ );
70
+ assert.deepEqual(
71
+ readMisses().map((miss) => miss.intent),
72
+ ["one", "two"],
73
+ );
74
+ } finally {
75
+ restoreEnv(savedEnv);
76
+ rmSync(root, { recursive: true, force: true });
77
+ }
78
+ });
@@ -0,0 +1,31 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
3
+
4
+ // Sync-spawn analog of the audio engine's spawnP, for execFileSync call sites
5
+ // that must hard-fail (rather than fall through to another provider) when npx
6
+ // cannot be resolved. On Windows a bare "npx" is npx.cmd, which
7
+ // execFileSync/spawnSync cannot exec (spawnSync npx ENOENT) —
8
+ // resolveSpawnCommand reroutes it through node + npx-cli.js, no shell:true.
9
+ //
10
+ // `platform`/`env`/`pathExists` params (defaulting to the real values) exist
11
+ // so tests can exercise the win32 branch without mocking node:child_process
12
+ // (its ESM exports are non-configurable) — same idiom as spawnP and
13
+ // localTtsGenerate.
14
+ export function resolveNpxInvocation(
15
+ argv,
16
+ opts,
17
+ platform = process.platform,
18
+ env = process.env,
19
+ pathExists = existsSync,
20
+ ) {
21
+ const resolved = resolveSpawnCommand("npx", argv, opts, platform, env, pathExists);
22
+ if (!resolved) {
23
+ // npx-on-win32 with no resolvable npx-cli.js — same terminal condition
24
+ // spawnP warns about, surfaced as a throw for callers with no fallback.
25
+ throw new Error(
26
+ "cannot run npx on Windows: npm's npx-cli.js was not found " +
27
+ "(install npm with Node, or run via npx/npm run so npm_execpath is set)",
28
+ );
29
+ }
30
+ return resolved;
31
+ }
@@ -0,0 +1,50 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveNpxInvocation } from "./npx-sync.mjs";
4
+
5
+ // Coverage parity with tts-local-provider.test.mjs, for the whisper.cpp
6
+ // fallback's call-site shape (transcribe.mjs runWhisper): the same three
7
+ // branches, but with the hard-fail contract — no fallback provider exists,
8
+ // so an unresolvable npx must throw actionably instead of returning null.
9
+
10
+ const envWithNpxCli = {
11
+ npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js",
12
+ npm_node_execpath: "C:/Program Files/nodejs/node.exe",
13
+ };
14
+ const npxCliPath = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
15
+ const pathExists = (path) => path === npxCliPath;
16
+
17
+ const WHISPER_ARGV = ["hyperframes", "transcribe", "C:\\media\\input.wav", "--dir", "C:\\work"];
18
+ const WHISPER_OPTS = { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 };
19
+
20
+ test("win32: routes the whisper fallback through node + npx-cli, never bare npx", () => {
21
+ const resolved = resolveNpxInvocation(
22
+ WHISPER_ARGV,
23
+ WHISPER_OPTS,
24
+ "win32",
25
+ envWithNpxCli,
26
+ pathExists,
27
+ );
28
+
29
+ assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath);
30
+ assert.equal(resolved.args[0], npxCliPath);
31
+ assert.deepEqual(resolved.args.slice(1, 3), ["hyperframes", "transcribe"]);
32
+ // execFileSync options survive the rerouting (the timeout bounds the
33
+ // whisper build/model download; the pipes surface its errors).
34
+ assert.deepEqual(resolved.opts.stdio, ["ignore", "pipe", "pipe"]);
35
+ assert.equal(resolved.opts.timeout, 1_800_000);
36
+ });
37
+
38
+ test("win32 without a resolvable npx-cli: throws the actionable install hint", () => {
39
+ assert.throws(
40
+ () => resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "win32", {}, () => false),
41
+ /npx-cli\.js was not found/,
42
+ );
43
+ });
44
+
45
+ test("non-win32: spawns plain npx unchanged", () => {
46
+ const resolved = resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "darwin", {}, () => false);
47
+
48
+ assert.equal(resolved.cmd, "npx");
49
+ assert.deepEqual(resolved.args, WHISPER_ARGV);
50
+ });
@@ -0,0 +1,27 @@
1
+ // Merge Parakeet-MLX token timestamps into word timestamps.
2
+ //
3
+ // parakeet-mlx JSON emits SUB-WORD tokens (" H", "ello", ...) with per-token
4
+ // start/end. Captions + transcript-cut need WORD timestamps, so join tokens
5
+ // into words on the space boundary: a token whose text starts with a space
6
+ // (or the very first token) begins a new word; the rest append. Output matches
7
+ // the { words: [{ text, start, end }] } shape the rest of media-use consumes
8
+ // (see words.mjs / cutlist.mjs).
9
+
10
+ export function mergeTokensToWords(parakeet) {
11
+ const sentences = Array.isArray(parakeet?.sentences) ? parakeet.sentences : [];
12
+ const words = [];
13
+ for (const s of sentences) {
14
+ for (const t of s.tokens ?? []) {
15
+ const raw = typeof t.text === "string" ? t.text : "";
16
+ const startsWord = raw.startsWith(" ") || words.length === 0;
17
+ if (startsWord) {
18
+ words.push({ text: raw.trim(), start: t.start, end: t.end });
19
+ } else {
20
+ const w = words[words.length - 1];
21
+ w.text += raw;
22
+ w.end = t.end;
23
+ }
24
+ }
25
+ }
26
+ return { text: (parakeet?.text ?? "").trim(), words: words.filter((w) => w.text.length > 0) };
27
+ }