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,119 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+ import { rankMediaRows } from "./media-search.mjs";
4
+
5
+ const LIB_DIR =
6
+ process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR ||
7
+ [
8
+ join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"),
9
+ join(
10
+ import.meta.dirname,
11
+ "..",
12
+ "..",
13
+ "..",
14
+ "..",
15
+ "..",
16
+ "skills",
17
+ "media-use",
18
+ "audio",
19
+ "assets",
20
+ "sfx",
21
+ ),
22
+ ].find((candidate) => existsSync(candidate)) ||
23
+ join(import.meta.dirname, "..", "..", "audio", "assets", "sfx");
24
+
25
+ export const BUNDLED_SFX_RECOVERY_COMMAND = "npx hyperframes skills update media-use";
26
+
27
+ export class BundledSfxAssetsError extends Error {
28
+ constructor(health) {
29
+ super(
30
+ `bundled SFX assets are missing or incomplete (${health.detail}). Repair the installed media-use skill: ${health.fix}`,
31
+ );
32
+ this.name = "BundledSfxAssetsError";
33
+ this.code = health.code;
34
+ this.fix = health.fix;
35
+ }
36
+ }
37
+
38
+ function unhealthy(detail) {
39
+ return {
40
+ ok: false,
41
+ code: "bundled_sfx_assets_missing",
42
+ detail,
43
+ fix: BUNDLED_SFX_RECOVERY_COMMAND,
44
+ };
45
+ }
46
+
47
+ export function inspectBundledSfxAssets(libraryDir = LIB_DIR) {
48
+ const manifestPath = join(libraryDir, "manifest.json");
49
+ if (!existsSync(manifestPath)) return unhealthy(`manifest not found: ${manifestPath}`);
50
+
51
+ let manifest;
52
+ try {
53
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
54
+ } catch {
55
+ return unhealthy(`manifest is not valid JSON: ${manifestPath}`);
56
+ }
57
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
58
+ return unhealthy(`manifest must contain an object: ${manifestPath}`);
59
+ }
60
+
61
+ const entries = Object.entries(manifest);
62
+ if (entries.length === 0) return unhealthy(`manifest contains no SFX entries: ${manifestPath}`);
63
+ for (const [key, entry] of entries) {
64
+ if (!entry?.file || typeof entry.file !== "string") {
65
+ return unhealthy(`manifest entry "${key}" has no file`);
66
+ }
67
+ const assetPath = join(libraryDir, entry.file);
68
+ if (!existsSync(assetPath)) return unhealthy(`asset not found: ${assetPath}`);
69
+ }
70
+
71
+ return {
72
+ ok: true,
73
+ count: entries.length,
74
+ detail: `${entries.length} bundled SFX asset${entries.length === 1 ? "" : "s"} available`,
75
+ fix: "",
76
+ };
77
+ }
78
+
79
+ export function extensionForBundledSfxFile(filename) {
80
+ return extname(filename) || ".mp3";
81
+ }
82
+
83
+ export const bundledSfxProvider = {
84
+ async search(intent, ctx = {}) {
85
+ const libraryDir = ctx.libraryDir || LIB_DIR;
86
+ const health = inspectBundledSfxAssets(libraryDir);
87
+ if (!health.ok) throw new BundledSfxAssetsError(health);
88
+ const manifest = JSON.parse(readFileSync(join(libraryDir, "manifest.json"), "utf8"));
89
+
90
+ const rows = Object.entries(manifest).map(([key, entry]) => ({
91
+ id: key,
92
+ kind: "sfx",
93
+ title: key,
94
+ description: entry?.description || key,
95
+ tags: ["sfx"],
96
+ file: entry?.file || "",
97
+ duration: entry?.duration,
98
+ }));
99
+ const bestRow = rankMediaRows(intent, rows)[0];
100
+ const best =
101
+ bestRow && manifest[bestRow.id]?.file
102
+ ? { key: bestRow.id, entry: manifest[bestRow.id] }
103
+ : null;
104
+ if (!best) return null;
105
+
106
+ const localPath = join(libraryDir, best.entry.file);
107
+ return {
108
+ localPath,
109
+ ext: extensionForBundledSfxFile(best.entry.file),
110
+ source: "bundled",
111
+ metadata: {
112
+ description: best.entry.description || best.key,
113
+ duration: best.entry.duration ?? null,
114
+ provider: "bundled.sfx",
115
+ provenance: { library_key: best.key },
116
+ },
117
+ };
118
+ },
119
+ };
@@ -0,0 +1,139 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import {
7
+ BUNDLED_SFX_RECOVERY_COMMAND,
8
+ BundledSfxAssetsError,
9
+ bundledSfxProvider,
10
+ extensionForBundledSfxFile,
11
+ inspectBundledSfxAssets,
12
+ } from "./bundled-sfx-provider.mjs";
13
+ import { rankMediaRows } from "./media-search.mjs";
14
+
15
+ test("derives bundled SFX extension from the manifest filename", () => {
16
+ assert.equal(extensionForBundledSfxFile("impact.wav"), ".wav");
17
+ assert.equal(extensionForBundledSfxFile("whoosh.ogg"), ".ogg");
18
+ assert.equal(extensionForBundledSfxFile("extensionless"), ".mp3");
19
+ });
20
+
21
+ test("reports an agent-friendly recovery when the bundled SFX manifest is absent", () => {
22
+ const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-missing-"));
23
+ try {
24
+ const health = inspectBundledSfxAssets(libraryDir);
25
+ assert.equal(health.ok, false);
26
+ assert.equal(health.code, "bundled_sfx_assets_missing");
27
+ assert.match(health.detail, /manifest\.json/);
28
+ assert.match(health.fix, /hyperframes skills update media-use/);
29
+ assert.equal(health.fix, BUNDLED_SFX_RECOVERY_COMMAND);
30
+ } finally {
31
+ rmSync(libraryDir, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ test("reports the exact missing file from an incomplete bundled SFX install", () => {
36
+ const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-incomplete-"));
37
+ try {
38
+ writeFileSync(
39
+ join(libraryDir, "manifest.json"),
40
+ JSON.stringify({ whoosh: { file: "whoosh.mp3", description: "transition" } }),
41
+ );
42
+ const health = inspectBundledSfxAssets(libraryDir);
43
+ assert.equal(health.ok, false);
44
+ assert.equal(health.code, "bundled_sfx_assets_missing");
45
+ assert.match(health.detail, /whoosh\.mp3/);
46
+ } finally {
47
+ rmSync(libraryDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test("bundled provider raises a typed install error instead of a generic catalog miss", async () => {
52
+ const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-provider-"));
53
+ try {
54
+ await assert.rejects(
55
+ () => bundledSfxProvider.search("whoosh", { libraryDir }),
56
+ (error) => {
57
+ assert.ok(error instanceof BundledSfxAssetsError);
58
+ assert.equal(error.code, "bundled_sfx_assets_missing");
59
+ assert.match(error.message, /hyperframes skills update media-use/);
60
+ return true;
61
+ },
62
+ );
63
+ } finally {
64
+ rmSync(libraryDir, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ test("accepts a complete bundled SFX library", () => {
69
+ const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-complete-"));
70
+ try {
71
+ mkdirSync(libraryDir, { recursive: true });
72
+ writeFileSync(
73
+ join(libraryDir, "manifest.json"),
74
+ JSON.stringify({ whoosh: { file: "whoosh.mp3", description: "transition" } }),
75
+ );
76
+ writeFileSync(join(libraryDir, "whoosh.mp3"), "audio");
77
+ assert.deepEqual(inspectBundledSfxAssets(libraryDir), {
78
+ ok: true,
79
+ count: 1,
80
+ detail: "1 bundled SFX asset available",
81
+ fix: "",
82
+ });
83
+ } finally {
84
+ rmSync(libraryDir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test("prefers an exact key over a longer key with the same words", async () => {
89
+ const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-exact-key-"));
90
+ try {
91
+ writeFileSync(
92
+ join(libraryDir, "manifest.json"),
93
+ JSON.stringify({
94
+ "whoosh-cinematic": { file: "whoosh-cinematic.mp3", description: "long whoosh" },
95
+ whoosh: { file: "whoosh.mp3", description: "short whoosh" },
96
+ }),
97
+ );
98
+ writeFileSync(join(libraryDir, "whoosh-cinematic.mp3"), "cinematic audio");
99
+ writeFileSync(join(libraryDir, "whoosh.mp3"), "exact audio");
100
+
101
+ const result = await bundledSfxProvider.search("whoosh", { libraryDir });
102
+ assert.equal(result?.localPath, join(libraryDir, "whoosh.mp3"));
103
+ assert.equal(result?.metadata.provenance.library_key, "whoosh");
104
+
105
+ const stemmed = await bundledSfxProvider.search("whooshes", { libraryDir });
106
+ assert.equal(stemmed?.localPath, join(libraryDir, "whoosh.mp3"));
107
+ assert.equal(stemmed?.metadata.provenance.library_key, "whoosh");
108
+ } finally {
109
+ rmSync(libraryDir, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("literal ids outrank distinct ids with the same stem", () => {
114
+ const rows = [
115
+ {
116
+ id: "cats",
117
+ title: "Cats Fighting",
118
+ description: "fighting cats",
119
+ tags: ["yowl", "fight"],
120
+ kind: "sfx",
121
+ },
122
+ {
123
+ id: "cat",
124
+ title: "Cat",
125
+ description: "single cat",
126
+ tags: ["meow"],
127
+ kind: "sfx",
128
+ },
129
+ ];
130
+
131
+ assert.deepEqual(
132
+ rankMediaRows("cat", rows).map((row) => row.id),
133
+ ["cat", "cats"],
134
+ );
135
+ assert.deepEqual(
136
+ rankMediaRows("cats", rows).map((row) => row.id),
137
+ ["cats", "cat"],
138
+ );
139
+ });
@@ -0,0 +1,150 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
6
+
7
+ const SCHEMA_PREFIX = "mu-v1-";
8
+ const KEY_HEX_CHARS = 16;
9
+ const COMPLETE_SENTINEL = ".hf-complete";
10
+
11
+ export function globalMediaDir() {
12
+ return join(homedir(), ".media");
13
+ }
14
+
15
+ export function contentHash(filePath) {
16
+ const bytes = readFileSync(filePath);
17
+ return createHash("sha256").update(bytes).digest("hex");
18
+ }
19
+
20
+ function cacheEntryDir(rootDir, sha) {
21
+ return join(rootDir, SCHEMA_PREFIX + sha.slice(0, KEY_HEX_CHARS));
22
+ }
23
+
24
+ function isComplete(entryDir) {
25
+ return existsSync(join(entryDir, COMPLETE_SENTINEL));
26
+ }
27
+
28
+ function markComplete(entryDir) {
29
+ writeFileSync(join(entryDir, COMPLETE_SENTINEL), "", "utf8");
30
+ }
31
+
32
+ // The manifest helpers append their own ".media" to the dir they get, so the
33
+ // global manifest must be addressed by HOME, not by globalMediaDir() — passing
34
+ // the latter nested it at ~/.media/.media/manifest.jsonl, invisible to the
35
+ // Studio /api/assets/global route (which reads the documented flat path).
36
+ export function readGlobalManifest() {
37
+ return readManifest(homedir());
38
+ }
39
+
40
+ // Resolve a content-sha (full or unambiguous prefix) to a reusable global-cache
41
+ // record, for `resolve --reuse <sha>`. Returns null on no match, or
42
+ // { ambiguous: true, count } when a prefix matches multiple distinct entries.
43
+ // Completeness (the .hf-complete sentinel) is left to importFromCache so the
44
+ // caller can surface an "incomplete cache entry" error distinctly from a miss.
45
+ export function findGlobalBySha(shaPrefix) {
46
+ const p = String(shaPrefix || "")
47
+ .toLowerCase()
48
+ .trim();
49
+ if (!p) return null;
50
+ const matches = readGlobalManifest().filter(
51
+ (r) => r.reusable && typeof r.sha === "string" && r.sha.startsWith(p),
52
+ );
53
+ if (matches.length === 0) return null;
54
+ if (matches.length > 1) {
55
+ const exact = matches.find((r) => r.sha === p);
56
+ if (exact) return exact;
57
+ return { ambiguous: true, count: matches.length };
58
+ }
59
+ return matches[0];
60
+ }
61
+
62
+ function validateCacheHit(match) {
63
+ if (!match?.sha) return null;
64
+ return isComplete(cacheEntryDir(globalMediaDir(), match.sha)) ? match : null;
65
+ }
66
+
67
+ export function cacheGet(prompt, type) {
68
+ const key = normalizePrompt(prompt);
69
+ if (!key) return null;
70
+ return validateCacheHit(
71
+ readGlobalManifest().find(
72
+ (r) =>
73
+ r.reusable &&
74
+ normalizePrompt(r.provenance?.prompt) === key &&
75
+ (type == null || r.type === type),
76
+ ),
77
+ );
78
+ }
79
+
80
+ export function cacheGetByEntity(entity) {
81
+ const lower = entity.toLowerCase();
82
+ return validateCacheHit(
83
+ readGlobalManifest().find((r) => r.reusable && r.entity && r.entity.toLowerCase() === lower),
84
+ );
85
+ }
86
+
87
+ export function cachePut(filePath, record) {
88
+ const sha = contentHash(filePath);
89
+ // Idempotent: same content already promoted -> don't duplicate the global
90
+ // record. ponytail: skips usage_count bump; add it when the metric is needed.
91
+ const existing = readGlobalManifest().find((r) => r.sha === sha);
92
+ if (existing) return { sha, cached_path: existing.cached_path, deduped: true };
93
+
94
+ const dir = globalMediaDir();
95
+ const entryDir = cacheEntryDir(dir, sha);
96
+ mkdirSync(entryDir, { recursive: true });
97
+
98
+ const dest = join(entryDir, basename(filePath));
99
+ copyFileSync(filePath, dest);
100
+ markComplete(entryDir);
101
+
102
+ const globalRecord = {
103
+ ...record,
104
+ sha,
105
+ reusable: true,
106
+ cached_path: dest,
107
+ };
108
+ appendRecord(homedir(), globalRecord);
109
+ return { sha, cached_path: dest };
110
+ }
111
+
112
+ export function importFromCache(cacheRecord, projectDir, localId, localPath) {
113
+ const sha = cacheRecord.sha;
114
+ const entryDir = cacheEntryDir(globalMediaDir(), sha);
115
+ if (!isComplete(entryDir)) return null;
116
+
117
+ const cachedFile = cacheRecord.cached_path;
118
+ if (!cachedFile || !existsSync(cachedFile)) return null;
119
+
120
+ mkdirSync(join(projectDir, ".media"), { recursive: true });
121
+ const fullDest = join(projectDir, localPath);
122
+ mkdirSync(join(fullDest, ".."), { recursive: true });
123
+ copyFileSync(cachedFile, fullDest);
124
+
125
+ const projectRecord = {
126
+ ...cacheRecord,
127
+ id: localId,
128
+ path: localPath,
129
+ provenance: {
130
+ ...cacheRecord.provenance,
131
+ imported_from: sha,
132
+ },
133
+ };
134
+ delete projectRecord.sha;
135
+ delete projectRecord.reusable;
136
+ delete projectRecord.cached_path;
137
+
138
+ return projectRecord;
139
+ }
140
+
141
+ export function promote(projectDir, id) {
142
+ const records = readManifest(projectDir);
143
+ const record = records.find((r) => r.id === id);
144
+ if (!record) throw new Error(`asset not found in project manifest: ${id}`);
145
+
146
+ const filePath = join(projectDir, record.path);
147
+ if (!existsSync(filePath)) throw new Error(`asset file not found: ${filePath}`);
148
+
149
+ return cachePut(filePath, record);
150
+ }
@@ -0,0 +1,91 @@
1
+ // Reuse candidates: a side-effect-free view of assets already available to this
2
+ // project (its own manifest) and across every project (the global ~/.media
3
+ // cache), so the calling agent can judge semantic fit itself. No download, no
4
+ // provider, no mutation. The ranker only *surfaces* — it orders by lexical
5
+ // overlap but never filters a candidate out on zero overlap (that would
6
+ // pre-empt the agent's judgment); the agent does the semantic call.
7
+
8
+ import { readManifest } from "./manifest.mjs";
9
+ import { readGlobalManifest } from "./cache.mjs";
10
+ import { tokenOverlap, typesMatch } from "./match.mjs";
11
+
12
+ export const CANDIDATE_CAP = 8;
13
+
14
+ function shape(record, scope, intent) {
15
+ const description = record.description || record.provenance?.prompt || "";
16
+ const prompt = record.provenance?.prompt || null;
17
+ return {
18
+ id: record.id,
19
+ type: record.type,
20
+ scope,
21
+ description,
22
+ prompt,
23
+ provider: record.provenance?.provider || null,
24
+ duration: record.duration ?? null,
25
+ width: record.width ?? null,
26
+ height: record.height ?? null,
27
+ // Only global records carry a content sha — it is the stable reuse handle
28
+ // for `resolve --reuse <sha>`. Project assets are reused by referencing
29
+ // their path directly, so they need no handle.
30
+ sha: scope === "global" ? record.sha || null : null,
31
+ path: scope === "project" ? record.path : null,
32
+ score: intent ? tokenOverlap(intent, `${description} ${prompt || ""}`) : 0,
33
+ };
34
+ }
35
+
36
+ // Rank one scope: type-matched (icon<->image aware), newest-first within equal
37
+ // overlap, ordered by overlap desc. Returns the full ranked list (uncapped).
38
+ function rankScope(records, scope, type, intent) {
39
+ return records
40
+ .filter((r) => typesMatch(r.type, type))
41
+ .reverse() // manifest is append-order (oldest first); newest-first at equal score
42
+ .map((r) => shape(r, scope, intent))
43
+ .sort((a, b) => b.score - a.score); // Array.sort is stable → recency preserved
44
+ }
45
+
46
+ // List reuse candidates for `type`, capped per scope. Returns:
47
+ // candidates: capped project candidates followed by capped global candidates
48
+ // truncated: true if either scope had more than `cap`
49
+ // total: { project, global } counts before the cap (machine-readable)
50
+ // similar: count of candidates with lexical overlap > 0 (drives the nudge)
51
+ export function listCandidates({ projectDir, type, intent = "", cap = CANDIDATE_CAP }) {
52
+ const project = rankScope(readManifest(projectDir), "project", type, intent);
53
+ const global = rankScope(readGlobalManifest(), "global", type, intent);
54
+ const candidates = [...project.slice(0, cap), ...global.slice(0, cap)];
55
+ return {
56
+ candidates,
57
+ truncated: project.length > cap || global.length > cap,
58
+ total: { project: project.length, global: global.length },
59
+ similar: [...project, ...global].filter((c) => c.score > 0).length,
60
+ };
61
+ }
62
+
63
+ function meta(c) {
64
+ const parts = [];
65
+ if (c.duration != null) parts.push(`${c.duration}s`);
66
+ if (c.width && c.height) parts.push(`${c.width}x${c.height}`);
67
+ if (c.provider) parts.push(c.provider);
68
+ return parts.join(", ");
69
+ }
70
+
71
+ // Human-readable listing. The agent can read this directly; --json is for
72
+ // programmatic use. Reuse handle differs by scope: path for project, sha for
73
+ // global.
74
+ export function formatCandidates(candidates, { truncated, total } = {}) {
75
+ if (candidates.length === 0) return "no reuse candidates found (project or global cache)";
76
+ const lines = [`${candidates.length} reuse candidate${candidates.length === 1 ? "" : "s"}:`, ""];
77
+ for (const c of candidates) {
78
+ const handle =
79
+ c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path || `manifest:${c.id}`;
80
+ const m = meta(c);
81
+ lines.push(` [${c.scope}] ${c.description}${m ? ` (${m})` : ""}`);
82
+ lines.push(` ${handle}`);
83
+ }
84
+ if (truncated && total) {
85
+ lines.push("");
86
+ lines.push(
87
+ ` (showing top ${CANDIDATE_CAP} per scope; ${total.project} project / ${total.global} global total — refine --intent to narrow)`,
88
+ );
89
+ }
90
+ return lines.join("\n");
91
+ }
@@ -0,0 +1,155 @@
1
+ import { test } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./candidates.mjs";
7
+ import { findGlobalBySha } from "./cache.mjs";
8
+
9
+ // candidates + findGlobalBySha are offline (no heygen), so we can override HOME
10
+ // to a temp dir and seed a fake global ~/.media manifest deterministically.
11
+ function sandbox() {
12
+ const root = mkdtempSync(join(tmpdir(), "mu-cand-"));
13
+ const project = join(root, "proj");
14
+ const home = join(root, "home");
15
+ process.env.HOME = home;
16
+ return { root, project, home };
17
+ }
18
+ function seedManifest(dir, records) {
19
+ const md = join(dir, ".media");
20
+ mkdirSync(md, { recursive: true });
21
+ writeFileSync(md + "/manifest.jsonl", records.map((r) => JSON.stringify(r)).join("\n") + "\n");
22
+ }
23
+ function proj(id, type, description, prompt) {
24
+ return { id, type, path: `.media/audio/bgm/${id}.wav`, description, provenance: { prompt } };
25
+ }
26
+ function glob(id, type, description, prompt, sha) {
27
+ return {
28
+ id,
29
+ type,
30
+ sha,
31
+ reusable: true,
32
+ cached_path: `/x/${sha}/${id}.wav`,
33
+ description,
34
+ provenance: { prompt, provider: "heygen.audio.sounds" },
35
+ };
36
+ }
37
+
38
+ test("ranks project + global by overlap, tags scope", () => {
39
+ const { root, project, home } = sandbox();
40
+ try {
41
+ seedManifest(project, [proj("bgm_001", "bgm", "calm ambient piano", "calm ambient piano")]);
42
+ seedManifest(home, [
43
+ glob("bgm_009", "bgm", "energetic tech launch", "energetic tech launch", "a".repeat(64)),
44
+ glob("bgm_010", "bgm", "sad corporate piano", "sad corporate piano", "b".repeat(64)),
45
+ ]);
46
+ const { candidates } = listCandidates({
47
+ projectDir: project,
48
+ type: "bgm",
49
+ intent: "tech launch",
50
+ });
51
+ assert.equal(candidates[0].scope, "project"); // project listed first
52
+ const g = candidates.filter((c) => c.scope === "global");
53
+ assert.equal(g[0].description, "energetic tech launch"); // higher overlap ranks first
54
+ assert.ok(g[0].score >= g[1].score);
55
+ } finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("zero-overlap intent still lists candidates (no hard filter)", () => {
61
+ const { root, project, home } = sandbox();
62
+ try {
63
+ seedManifest(project, []);
64
+ seedManifest(home, [glob("bgm_009", "bgm", "driving synth", "driving synth", "c".repeat(64))]);
65
+ const { candidates, similar } = listCandidates({
66
+ projectDir: project,
67
+ type: "bgm",
68
+ intent: "totally unrelated words xyz",
69
+ });
70
+ assert.equal(candidates.length, 1, "listed despite zero overlap");
71
+ assert.equal(candidates[0].score, 0);
72
+ assert.equal(similar, 0, "similar counts only overlap>0");
73
+ } finally {
74
+ rmSync(root, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ test("caps per scope and reports truncation + totals", () => {
79
+ const { root, project, home } = sandbox();
80
+ try {
81
+ const many = Array.from({ length: CANDIDATE_CAP + 3 }, (_, i) =>
82
+ glob(`bgm_${i}`, "bgm", `track ${i}`, `track ${i}`, String(i).padStart(64, "0")),
83
+ );
84
+ seedManifest(project, []);
85
+ seedManifest(home, many);
86
+ const { candidates, truncated, total } = listCandidates({ projectDir: project, type: "bgm" });
87
+ assert.equal(candidates.length, CANDIDATE_CAP);
88
+ assert.equal(truncated, true);
89
+ assert.equal(total.global, CANDIDATE_CAP + 3);
90
+ } finally {
91
+ rmSync(root, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("honors icon<->image adjacency", () => {
96
+ const { root, project, home } = sandbox();
97
+ try {
98
+ seedManifest(project, []);
99
+ seedManifest(home, [glob("image_1", "image", "rocket logo", "rocket logo", "d".repeat(64))]);
100
+ const { candidates } = listCandidates({ projectDir: project, type: "icon", intent: "rocket" });
101
+ assert.equal(candidates.length, 1, "image asset surfaces for icon request");
102
+ } finally {
103
+ rmSync(root, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test("sha only on global, path only on project", () => {
108
+ const { root, project, home } = sandbox();
109
+ try {
110
+ seedManifest(project, [proj("bgm_001", "bgm", "x", "x")]);
111
+ seedManifest(home, [glob("bgm_009", "bgm", "y", "y", "e".repeat(64))]);
112
+ const { candidates } = listCandidates({ projectDir: project, type: "bgm" });
113
+ const p = candidates.find((c) => c.scope === "project");
114
+ const g = candidates.find((c) => c.scope === "global");
115
+ assert.ok(p.path && !p.sha);
116
+ assert.ok(g.sha && !g.path);
117
+ } finally {
118
+ rmSync(root, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test("findGlobalBySha resolves unique prefix, flags ambiguity, misses cleanly", () => {
123
+ const { root, project, home } = sandbox();
124
+ try {
125
+ seedManifest(project, []);
126
+ seedManifest(home, [
127
+ glob("bgm_1", "bgm", "a", "a", "abc" + "0".repeat(61)),
128
+ glob("bgm_2", "bgm", "b", "b", "abd" + "0".repeat(61)),
129
+ glob("bgm_3", "bgm", "c", "c", "fff" + "0".repeat(61)),
130
+ ]);
131
+ assert.equal(findGlobalBySha("fff").id, "bgm_3", "unique prefix resolves");
132
+ assert.deepEqual(
133
+ { ambiguous: findGlobalBySha("ab").ambiguous, count: findGlobalBySha("ab").count },
134
+ { ambiguous: true, count: 2 },
135
+ "ambiguous prefix flagged",
136
+ );
137
+ assert.equal(findGlobalBySha("zzz"), null, "miss returns null");
138
+ assert.equal(findGlobalBySha(""), null, "empty returns null");
139
+ } finally {
140
+ rmSync(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test("formatCandidates shows reuse handles by scope; empty message", () => {
145
+ const { candidates } = {
146
+ candidates: [
147
+ { scope: "project", description: "p", path: ".media/audio/bgm/bgm_001.wav" },
148
+ { scope: "global", description: "g", sha: "f".repeat(64) },
149
+ ],
150
+ };
151
+ const out = formatCandidates(candidates, {});
152
+ assert.match(out, /\.media\/audio\/bgm\/bgm_001\.wav/);
153
+ assert.match(out, /--reuse ffffffffffffffff/);
154
+ assert.match(formatCandidates([], {}), /no reuse candidates/);
155
+ });