diff --git a/skills-manifest.json b/skills-manifest.json index 9321089cb1..d60708921e 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,8 +46,8 @@ "files": 10 }, "media-use": { - "hash": "49aa5adae0800403", - "files": 122 + "hash": "facf3a0c03fb1bc5", + "files": 123 }, "motion-graphics": { "hash": "dafcdce07d221aa4", diff --git a/skills/media-use/scripts/lib/bundled-sfx-provider.mjs b/skills/media-use/scripts/lib/bundled-sfx-provider.mjs new file mode 100644 index 0000000000..e109808115 --- /dev/null +++ b/skills/media-use/scripts/lib/bundled-sfx-provider.mjs @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const LIB_DIR = join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"); + +const normalize = (value) => + String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + +function score(intent, key, entry) { + const query = normalize(intent); + const name = normalize(key); + if (query === name) return 100; + if (query.includes(name) || name.includes(query)) return 50; + const haystack = new Set(normalize(`${key} ${entry.description || ""}`).split(/\s+/)); + return query.split(/\s+/).filter((token) => token && haystack.has(token)).length; +} + +export const bundledSfxProvider = { + async search(intent) { + const manifestPath = join(LIB_DIR, "manifest.json"); + if (!existsSync(manifestPath)) return null; + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + return null; + } + + const ranked = Object.entries(manifest) + .map(([key, entry]) => ({ key, entry, score: score(intent, key, entry) })) + .filter(({ entry, score }) => entry?.file && score > 0) + .sort((a, b) => b.score - a.score || a.key.localeCompare(b.key)); + const best = ranked[0]; + if (!best) return null; + + const localPath = join(LIB_DIR, best.entry.file); + if (!existsSync(localPath)) return null; + return { + localPath, + ext: ".mp3", + source: "bundled", + metadata: { + description: best.entry.description || best.key, + duration: best.entry.duration ?? null, + provider: "bundled.sfx", + provenance: { library_key: best.key }, + }, + }; + }, +}; diff --git a/skills/media-use/scripts/lib/registry.mjs b/skills/media-use/scripts/lib/registry.mjs index 84df07ccf9..ef406fb2bc 100644 --- a/skills/media-use/scripts/lib/registry.mjs +++ b/skills/media-use/scripts/lib/registry.mjs @@ -22,6 +22,7 @@ import { bgmProvider } from "./bgm-provider.mjs"; import { sfxProvider } from "./sfx-provider.mjs"; +import { bundledSfxProvider } from "./bundled-sfx-provider.mjs"; import { imageProvider, iconProvider } from "./image-provider.mjs"; import { brandProvider } from "./brand-provider.mjs"; import { @@ -44,10 +45,13 @@ const A = (name, caps) => ({ name, ...caps }); // local, free const N = (name, caps) => ({ name, network: true, ...caps }); // remote, free const P = (name, caps) => ({ name, network: true, paid: true, ...caps }); // remote, paid -// heygen-CLI first (and currently only). All remote providers are skipped by --local-only. +// heygen-CLI first. All remote providers are skipped by --local-only. const REGISTRY = { bgm: [N("heygen.audio.sounds", { search: bgmProvider.search })], - sfx: [N("heygen.audio.sounds", { search: sfxProvider.search })], + sfx: [ + N("heygen.audio.sounds", { search: sfxProvider.search }), + A("bundled.sfx", { search: bundledSfxProvider.search }), + ], image: [ N("heygen.asset.search", { search: imageProvider.search }), // Catalog miss -> generate. Local first (best FLUX-class model the machine's diff --git a/skills/media-use/scripts/lib/registry.test.mjs b/skills/media-use/scripts/lib/registry.test.mjs index 6cd51ac430..4d570e5e76 100644 --- a/skills/media-use/scripts/lib/registry.test.mjs +++ b/skills/media-use/scripts/lib/registry.test.mjs @@ -21,7 +21,7 @@ test("heygen provider is first for every type it serves", () => { test("sanctioned providers only: heygen, local mflux/kokoro, codex, design spec, logo tiers", () => { const allowed = - /^heygen|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/; + /^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/; for (const t of listTypes()) { for (const p of getProviders(t)) { assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`); @@ -52,6 +52,15 @@ test("voice cascade: HeyGen TTS first, Kokoro remains the local fallback", () => assert.ok(!ps[1].paid, "local Kokoro is free"); }); +test("sfx cascade: HeyGen catalog first, bundled library remains the local fallback", () => { + const ps = getProviders("sfx"); + assert.equal(ps[0].name, "heygen.audio.sounds"); + assert.ok(ps[0].network, "HeyGen SFX catalog is network-only"); + assert.equal(ps[1].name, "bundled.sfx"); + assert.equal(typeof ps[1].search, "function"); + assert.ok(!ps[1].network, "bundled SFX remain available offline"); +}); + test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", async () => { const providers = [ { name: "heygen.asset.search", network: true, search: async () => null }, diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index fea0e3be8c..81e746af5e 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -130,6 +130,20 @@ function test(name, fn) { // --- manifest cache hit --- +test("bundled SFX resolve without HeyGen on PATH", () => { + setup(); + const result = spawnResolve( + ["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], + { env: { HOME: tmp, PATH: tmp } }, + ); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.provenance.provider, "bundled.sfx"); + assert.ok(existsSync(join(tmp, parsed.path))); + cleanup(); +}); + test("project manifest hit skips providers", () => { setup(); const record = makeRecord({ provenance: { prompt: "cached query", provider: "test" } });