dsh-hyperframes 0.10.1 → 0.11.0

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 (113) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.en.md +4 -3
  3. package/README.md +4 -3
  4. package/docs/WORKBENCH.md +6 -0
  5. package/lib/client.js +40 -2
  6. package/lib/media-templates.d.ts +1 -1
  7. package/lib/media-templates.js +1 -1
  8. package/package.json +2 -2
  9. package/plugin.json +1 -1
  10. package/skills/NOTICE.md +1 -1
  11. package/skills/UPSTREAM.json +93 -8
  12. package/skills/embedded-captions/SKILL.md +1 -1
  13. package/skills/faceless-explainer/SKILL.md +1 -1
  14. package/skills/general-video/SKILL.md +1 -1
  15. package/skills/hyperframes-animation/SKILL.md +1 -1
  16. package/skills/hyperframes-audio/SKILL.md +1 -1
  17. package/skills/hyperframes-core/SKILL.md +1 -1
  18. package/skills/hyperframes-creative/SKILL.md +1 -1
  19. package/skills/hyperframes-keyframes/SKILL.md +1 -1
  20. package/skills/hyperframes-studio/SKILL.md +1 -1
  21. package/skills/media-use/scripts/lib/adopt.mjs +128 -0
  22. package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  23. package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  24. package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  25. package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  26. package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  27. package/skills/media-use/scripts/lib/cache.mjs +162 -0
  28. package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
  29. package/skills/media-use/scripts/lib/candidates.mjs +91 -0
  30. package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  31. package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  32. package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  33. package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
  34. package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
  35. package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
  36. package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  37. package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  38. package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
  39. package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  40. package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
  41. package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
  42. package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  43. package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
  44. package/skills/media-use/scripts/lib/freeze.mjs +72 -0
  45. package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
  46. package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
  47. package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  48. package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
  49. package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
  50. package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  51. package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  52. package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  53. package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  54. package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  55. package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  56. package/skills/media-use/scripts/lib/local-models.mjs +318 -0
  57. package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  58. package/skills/media-use/scripts/lib/local-run.mjs +78 -0
  59. package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  60. package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
  61. package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
  62. package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  63. package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  64. package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  65. package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  66. package/skills/media-use/scripts/lib/main-module.mjs +13 -0
  67. package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
  68. package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
  69. package/skills/media-use/scripts/lib/match.mjs +47 -0
  70. package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
  71. package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
  72. package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  73. package/skills/media-use/scripts/lib/media-search.mjs +107 -0
  74. package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  75. package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  76. package/skills/media-use/scripts/lib/misses.mjs +49 -0
  77. package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
  78. package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
  79. package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  80. package/skills/media-use/scripts/lib/openverse.mjs +134 -0
  81. package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  82. package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  83. package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
  84. package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
  85. package/skills/media-use/scripts/lib/probe.mjs +46 -0
  86. package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
  87. package/skills/media-use/scripts/lib/providers.mjs +5 -0
  88. package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
  89. package/skills/media-use/scripts/lib/registry.mjs +214 -0
  90. package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  91. package/skills/media-use/scripts/lib/search.mjs +24 -0
  92. package/skills/media-use/scripts/lib/search.test.mjs +32 -0
  93. package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
  94. package/skills/media-use/scripts/lib/specs.mjs +132 -0
  95. package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  96. package/skills/media-use/scripts/lib/stats.mjs +117 -0
  97. package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  98. package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
  99. package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
  100. package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
  101. package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
  102. package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
  103. package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  104. package/skills/media-use/scripts/lib/usage.mjs +57 -0
  105. package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  106. package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  107. package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  108. package/skills/media-use/scripts/resolve.mjs +1360 -27
  109. package/skills/motion-graphics/SKILL.md +1 -1
  110. package/skills/music-to-video/SKILL.md +1 -1
  111. package/skills/pr-to-video/SKILL.md +1 -1
  112. package/skills/product-launch-video/SKILL.md +1 -1
  113. package/skills/talking-head-recut/SKILL.md +1 -1
@@ -0,0 +1,95 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { findExistingAsset, adoptExistingAssets } from "./adopt.mjs";
6
+
7
+ let tmp;
8
+ function setup() {
9
+ tmp = mkdtempSync(join(tmpdir(), "mu-adopt-test-"));
10
+ }
11
+ function cleanup() {
12
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
13
+ }
14
+ function drop(rel) {
15
+ const full = join(tmp, "assets", rel);
16
+ mkdirSync(join(full, ".."), { recursive: true });
17
+ writeFileSync(full, "x");
18
+ }
19
+
20
+ function runTests() {
21
+ const tests = [];
22
+ const test = (name, fn) => tests.push({ name, fn });
23
+
24
+ test("does NOT false-match a short filename stem (who.mp3 vs 'whoosh')", () => {
25
+ setup();
26
+ drop("sfx/who.mp3");
27
+ assert.equal(findExistingAsset(tmp, "whoosh", "sfx"), null);
28
+ cleanup();
29
+ });
30
+
31
+ test("does NOT match a one-letter filename against any intent", () => {
32
+ setup();
33
+ drop("images/a.jpg");
34
+ assert.equal(findExistingAsset(tmp, "gradient tech background", "image"), null);
35
+ cleanup();
36
+ });
37
+
38
+ test("matches on a shared meaningful word", () => {
39
+ setup();
40
+ drop("images/hero-shot.jpg");
41
+ const hit = findExistingAsset(tmp, "hero image", "image");
42
+ assert.ok(hit);
43
+ assert.equal(hit.relativePath, "assets/images/hero-shot.jpg");
44
+ cleanup();
45
+ });
46
+
47
+ test("matches multi-word overlap", () => {
48
+ setup();
49
+ drop("images/gradient-tech-bg.jpg");
50
+ assert.ok(findExistingAsset(tmp, "gradient tech background", "image"));
51
+ cleanup();
52
+ });
53
+
54
+ test("a shared stopword alone does not match", () => {
55
+ setup();
56
+ drop("video/the-video.mp4");
57
+ assert.equal(findExistingAsset(tmp, "the rocket", null), null);
58
+ cleanup();
59
+ });
60
+
61
+ test("respects the type filter", () => {
62
+ setup();
63
+ drop("sfx/rocket.mp3");
64
+ assert.equal(findExistingAsset(tmp, "rocket", "image"), null, "wrong type is skipped");
65
+ assert.ok(findExistingAsset(tmp, "rocket", "sfx"), "right type matches");
66
+ cleanup();
67
+ });
68
+
69
+ test("adoptExistingAssets still imports every typed file (unaffected by match rule)", () => {
70
+ setup();
71
+ drop("sfx/who.mp3");
72
+ drop("images/a.jpg");
73
+ assert.equal(adoptExistingAssets(tmp).length, 2);
74
+ cleanup();
75
+ });
76
+
77
+ let passed = 0;
78
+ let failed = 0;
79
+ for (const { name, fn } of tests) {
80
+ try {
81
+ fn();
82
+ passed++;
83
+ console.log(` \x1b[32m✓\x1b[0m ${name}`);
84
+ } catch (err) {
85
+ failed++;
86
+ console.log(` \x1b[31m✗\x1b[0m ${name}`);
87
+ console.log(` ${err.message}`);
88
+ }
89
+ }
90
+ console.log(`\n${passed} passed, ${failed} failed`);
91
+ if (failed > 0) process.exit(1);
92
+ }
93
+
94
+ console.log("media-use · adopt / findExistingAsset tests\n");
95
+ runTests();
@@ -0,0 +1,20 @@
1
+ import { heygenSearch } from "./heygen-search.mjs";
2
+
3
+ export const bgmProvider = {
4
+ async search(intent) {
5
+ const results = heygenSearch("audio sounds list", intent, { type: "music" });
6
+ if (!results) return null;
7
+ const best = results[0];
8
+ return {
9
+ url: best.audio_url,
10
+ source: "search",
11
+ // ext derived from audio_url by resolve.mjs — catalog tracks are .mp3 or .wav
12
+ metadata: {
13
+ description: best.description || intent,
14
+ duration: best.duration || null,
15
+ provider: "heygen.audio.sounds",
16
+ provenance: { track_id: best.id, score: best.score, query: intent },
17
+ },
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,59 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function findDesignSpec(projectDir) {
5
+ for (const name of ["frame.md", "design.md", "DESIGN.md"]) {
6
+ const p = join(projectDir, name);
7
+ if (existsSync(p)) return { path: p, name };
8
+ }
9
+ return null;
10
+ }
11
+
12
+ function parseFrontmatter(content) {
13
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
14
+ if (!match) return null;
15
+ const yaml = match[1];
16
+ const tokens = {};
17
+ for (const line of yaml.split("\n")) {
18
+ const m = line.match(/^\s*(\w[\w-]*):\s*(.+)/);
19
+ if (m) tokens[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
20
+ }
21
+ return tokens;
22
+ }
23
+
24
+ function extractColors(tokens) {
25
+ const colors = [];
26
+ for (const [k, v] of Object.entries(tokens)) {
27
+ if (typeof v === "string" && /^#[0-9a-fA-F]{3,8}$/.test(v)) {
28
+ colors.push({ name: k, hex: v });
29
+ }
30
+ }
31
+ return colors;
32
+ }
33
+
34
+ export const brandProvider = {
35
+ async search(intent, { projectDir } = {}) {
36
+ if (!projectDir) return null;
37
+ const spec = findDesignSpec(projectDir);
38
+ if (!spec) return null;
39
+ const content = readFileSync(spec.path, "utf8");
40
+ const tokens = parseFrontmatter(content);
41
+ if (!tokens) return null;
42
+ const colors = extractColors(tokens);
43
+ return {
44
+ localPath: spec.path,
45
+ source: "local",
46
+ ext: ".md",
47
+ metadata: {
48
+ description: "Brand tokens from " + spec.name,
49
+ provider: "design_spec",
50
+ provenance: {
51
+ file: spec.name,
52
+ colors,
53
+ font: tokens.font || tokens.typography || null,
54
+ logo: tokens.logo || null,
55
+ },
56
+ },
57
+ };
58
+ },
59
+ };
@@ -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,162 @@
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ mkdirSync,
5
+ existsSync,
6
+ copyFileSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { join, basename } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
12
+ import { globalMediaDir, mediaHome } from "./media-home.mjs";
13
+
14
+ export { globalMediaDir };
15
+
16
+ const SCHEMA_PREFIX = "mu-v1-";
17
+ const KEY_HEX_CHARS = 16;
18
+ const COMPLETE_SENTINEL = ".hf-complete";
19
+
20
+ export function contentHash(filePath) {
21
+ const bytes = readFileSync(filePath);
22
+ return createHash("sha256").update(bytes).digest("hex");
23
+ }
24
+
25
+ function cacheEntryDir(rootDir, sha) {
26
+ return join(rootDir, SCHEMA_PREFIX + sha.slice(0, KEY_HEX_CHARS));
27
+ }
28
+
29
+ function isComplete(entryDir) {
30
+ return existsSync(join(entryDir, COMPLETE_SENTINEL));
31
+ }
32
+
33
+ function markComplete(entryDir) {
34
+ writeFileSync(join(entryDir, COMPLETE_SENTINEL), "", "utf8");
35
+ }
36
+
37
+ // The manifest helpers append their own ".media" to the dir they get, so the
38
+ // global manifest must be addressed by mediaHome(), not by globalMediaDir() — passing
39
+ // the latter nested it at ~/.media/.media/manifest.jsonl, invisible to the
40
+ // Studio /api/assets/global route (which reads the documented flat path).
41
+ export function readGlobalManifest() {
42
+ return readManifest(mediaHome());
43
+ }
44
+
45
+ // Resolve a content-sha (full or unambiguous prefix) to a reusable global-cache
46
+ // record, for `resolve --reuse <sha>`. Returns null on no match, or
47
+ // { ambiguous: true, count } when a prefix matches multiple distinct entries.
48
+ // Completeness (the .hf-complete sentinel) is left to importFromCache so the
49
+ // caller can surface an "incomplete cache entry" error distinctly from a miss.
50
+ export function findGlobalBySha(shaPrefix) {
51
+ const p = String(shaPrefix || "")
52
+ .toLowerCase()
53
+ .trim();
54
+ if (!p) return null;
55
+ const matchingRecords = readGlobalManifest().filter(
56
+ (r) => r.reusable && typeof r.sha === "string" && r.sha.startsWith(p),
57
+ );
58
+ const matches = Array.from(new Map(matchingRecords.map((r) => [r.sha, r])).values());
59
+ if (matches.length === 0) return null;
60
+ if (matches.length > 1) {
61
+ const exact = matches.find((r) => r.sha === p);
62
+ if (exact) return exact;
63
+ return { ambiguous: true, count: matches.length };
64
+ }
65
+ return matches[0];
66
+ }
67
+
68
+ function validateCacheHit(match) {
69
+ if (!match?.sha || !match.cached_path) return null;
70
+ if (!isComplete(cacheEntryDir(globalMediaDir(), match.sha))) return null;
71
+ try {
72
+ const file = statSync(match.cached_path);
73
+ return file.isFile() && file.size > 0 ? match : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ export function cacheGet(prompt, type) {
80
+ const key = normalizePrompt(prompt);
81
+ if (!key) return null;
82
+ return (
83
+ readGlobalManifest().find(
84
+ (r) =>
85
+ r.reusable &&
86
+ normalizePrompt(r.provenance?.prompt) === key &&
87
+ (type == null || r.type === type) &&
88
+ validateCacheHit(r),
89
+ ) ?? null
90
+ );
91
+ }
92
+
93
+ export function cacheGetByEntity(entity) {
94
+ const lower = entity.toLowerCase();
95
+ return (
96
+ readGlobalManifest().find(
97
+ (r) => r.reusable && r.entity && r.entity.toLowerCase() === lower && validateCacheHit(r),
98
+ ) ?? null
99
+ );
100
+ }
101
+
102
+ export function cachePut(filePath, record) {
103
+ const sha = contentHash(filePath);
104
+ // Idempotent: same content already promoted -> don't duplicate the global
105
+ // record. ponytail: skips usage_count bump; add it when the metric is needed.
106
+ const existing = readGlobalManifest().findLast((r) => r.sha === sha && validateCacheHit(r));
107
+ if (existing) return { sha, cached_path: existing.cached_path, deduped: true };
108
+
109
+ const dir = globalMediaDir();
110
+ const entryDir = cacheEntryDir(dir, sha);
111
+ mkdirSync(entryDir, { recursive: true });
112
+
113
+ const dest = join(entryDir, basename(filePath));
114
+ copyFileSync(filePath, dest);
115
+ markComplete(entryDir);
116
+
117
+ const globalRecord = {
118
+ ...record,
119
+ sha,
120
+ reusable: true,
121
+ cached_path: dest,
122
+ };
123
+ appendRecord(mediaHome(), globalRecord);
124
+ return { sha, cached_path: dest };
125
+ }
126
+
127
+ export function importFromCache(cacheRecord, projectDir, localId, localPath) {
128
+ if (!validateCacheHit(cacheRecord)) return null;
129
+ const sha = cacheRecord.sha;
130
+ const cachedFile = cacheRecord.cached_path;
131
+
132
+ mkdirSync(join(projectDir, ".media"), { recursive: true });
133
+ const fullDest = join(projectDir, localPath);
134
+ mkdirSync(join(fullDest, ".."), { recursive: true });
135
+ copyFileSync(cachedFile, fullDest);
136
+
137
+ const projectRecord = {
138
+ ...cacheRecord,
139
+ id: localId,
140
+ path: localPath,
141
+ provenance: {
142
+ ...cacheRecord.provenance,
143
+ imported_from: sha,
144
+ },
145
+ };
146
+ delete projectRecord.sha;
147
+ delete projectRecord.reusable;
148
+ delete projectRecord.cached_path;
149
+
150
+ return projectRecord;
151
+ }
152
+
153
+ export function promote(projectDir, id) {
154
+ const records = readManifest(projectDir);
155
+ const record = records.find((r) => r.id === id);
156
+ if (!record) throw new Error(`asset not found in project manifest: ${id}`);
157
+
158
+ const filePath = join(projectDir, record.path);
159
+ if (!existsSync(filePath)) throw new Error(`asset file not found: ${filePath}`);
160
+
161
+ return cachePut(filePath, record);
162
+ }