From da19f9a69267fe5f706b97e13a21ecf6852b4e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 21:59:44 -0400 Subject: [PATCH] fix(media-use): explain missing bundled SFX (#2460) --- skills-manifest.json | 2 +- .../scripts/lib/bundled-sfx-provider.mjs | 76 ++++++++++++++---- .../scripts/lib/bundled-sfx-provider.test.mjs | 78 ++++++++++++++++++- skills/media-use/scripts/resolve.mjs | 34 ++++++-- skills/media-use/scripts/resolve.test.mjs | 28 ++++++- 5 files changed, 197 insertions(+), 21 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 4f64e96a9..6246a40ee 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "7a51f53cf615fe97", + "hash": "a842eab0c9d3a0c0", "files": 124 }, "motion-graphics": { diff --git a/skills/media-use/scripts/lib/bundled-sfx-provider.mjs b/skills/media-use/scripts/lib/bundled-sfx-provider.mjs index 7f11f420d..7011b45fc 100644 --- a/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +++ b/skills/media-use/scripts/lib/bundled-sfx-provider.mjs @@ -1,7 +1,63 @@ import { existsSync, readFileSync } from "node:fs"; import { extname, join } from "node:path"; -const LIB_DIR = join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"); +const LIB_DIR = + process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || + join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"); + +export const BUNDLED_SFX_RECOVERY_COMMAND = "npx hyperframes skills update media-use"; + +export class BundledSfxAssetsError extends Error { + constructor(health) { + super( + `bundled SFX assets are missing or incomplete (${health.detail}). Repair the installed media-use skill: ${health.fix}`, + ); + this.name = "BundledSfxAssetsError"; + this.code = health.code; + this.fix = health.fix; + } +} + +function unhealthy(detail) { + return { + ok: false, + code: "bundled_sfx_assets_missing", + detail, + fix: BUNDLED_SFX_RECOVERY_COMMAND, + }; +} + +export function inspectBundledSfxAssets(libraryDir = LIB_DIR) { + const manifestPath = join(libraryDir, "manifest.json"); + if (!existsSync(manifestPath)) return unhealthy(`manifest not found: ${manifestPath}`); + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + return unhealthy(`manifest is not valid JSON: ${manifestPath}`); + } + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return unhealthy(`manifest must contain an object: ${manifestPath}`); + } + + const entries = Object.entries(manifest); + if (entries.length === 0) return unhealthy(`manifest contains no SFX entries: ${manifestPath}`); + for (const [key, entry] of entries) { + if (!entry?.file || typeof entry.file !== "string") { + return unhealthy(`manifest entry "${key}" has no file`); + } + const assetPath = join(libraryDir, entry.file); + if (!existsSync(assetPath)) return unhealthy(`asset not found: ${assetPath}`); + } + + return { + ok: true, + count: entries.length, + detail: `${entries.length} bundled SFX asset${entries.length === 1 ? "" : "s"} available`, + fix: "", + }; +} const normalize = (value) => String(value) @@ -23,16 +79,11 @@ function score(intent, key, entry) { } 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; - } + async search(intent, ctx = {}) { + const libraryDir = ctx.libraryDir || LIB_DIR; + const health = inspectBundledSfxAssets(libraryDir); + if (!health.ok) throw new BundledSfxAssetsError(health); + const manifest = JSON.parse(readFileSync(join(libraryDir, "manifest.json"), "utf8")); const ranked = Object.entries(manifest) .map(([key, entry]) => ({ key, entry, score: score(intent, key, entry) })) @@ -41,8 +92,7 @@ export const bundledSfxProvider = { const best = ranked[0]; if (!best) return null; - const localPath = join(LIB_DIR, best.entry.file); - if (!existsSync(localPath)) return null; + const localPath = join(libraryDir, best.entry.file); return { localPath, ext: extensionForBundledSfxFile(best.entry.file), diff --git a/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs b/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs index 66b7f44f6..869fe896f 100644 --- a/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +++ b/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs @@ -1,9 +1,85 @@ import { strict as assert } from "node:assert"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; -import { extensionForBundledSfxFile } from "./bundled-sfx-provider.mjs"; +import { + BUNDLED_SFX_RECOVERY_COMMAND, + BundledSfxAssetsError, + bundledSfxProvider, + extensionForBundledSfxFile, + inspectBundledSfxAssets, +} from "./bundled-sfx-provider.mjs"; test("derives bundled SFX extension from the manifest filename", () => { assert.equal(extensionForBundledSfxFile("impact.wav"), ".wav"); assert.equal(extensionForBundledSfxFile("whoosh.ogg"), ".ogg"); assert.equal(extensionForBundledSfxFile("extensionless"), ".mp3"); }); + +test("reports an agent-friendly recovery when the bundled SFX manifest is absent", () => { + const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-missing-")); + try { + const health = inspectBundledSfxAssets(libraryDir); + assert.equal(health.ok, false); + assert.equal(health.code, "bundled_sfx_assets_missing"); + assert.match(health.detail, /manifest\.json/); + assert.match(health.fix, /hyperframes skills update media-use/); + assert.equal(health.fix, BUNDLED_SFX_RECOVERY_COMMAND); + } finally { + rmSync(libraryDir, { recursive: true, force: true }); + } +}); + +test("reports the exact missing file from an incomplete bundled SFX install", () => { + const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-incomplete-")); + try { + writeFileSync( + join(libraryDir, "manifest.json"), + JSON.stringify({ whoosh: { file: "whoosh.mp3", description: "transition" } }), + ); + const health = inspectBundledSfxAssets(libraryDir); + assert.equal(health.ok, false); + assert.equal(health.code, "bundled_sfx_assets_missing"); + assert.match(health.detail, /whoosh\.mp3/); + } finally { + rmSync(libraryDir, { recursive: true, force: true }); + } +}); + +test("bundled provider raises a typed install error instead of a generic catalog miss", async () => { + const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-provider-")); + try { + await assert.rejects( + () => bundledSfxProvider.search("whoosh", { libraryDir }), + (error) => { + assert.ok(error instanceof BundledSfxAssetsError); + assert.equal(error.code, "bundled_sfx_assets_missing"); + assert.match(error.message, /hyperframes skills update media-use/); + return true; + }, + ); + } finally { + rmSync(libraryDir, { recursive: true, force: true }); + } +}); + +test("accepts a complete bundled SFX library", () => { + const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-complete-")); + try { + mkdirSync(libraryDir, { recursive: true }); + writeFileSync( + join(libraryDir, "manifest.json"), + JSON.stringify({ whoosh: { file: "whoosh.mp3", description: "transition" } }), + ); + writeFileSync(join(libraryDir, "whoosh.mp3"), "audio"); + assert.deepEqual(inspectBundledSfxAssets(libraryDir), { + ok: true, + count: 1, + detail: "1 bundled SFX asset available", + fix: "", + }); + } finally { + rmSync(libraryDir, { recursive: true, force: true }); + } +}); diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index e234344fc..ab193f1dc 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -35,6 +35,10 @@ import { flushHeygenFailureTracking, versionLessThan, } from "./lib/heygen-cli.mjs"; +import { + BundledSfxAssetsError, + inspectBundledSfxAssets, +} from "./lib/bundled-sfx-provider.mjs"; const INGEST_TYPES = [...listTypes(), "video"]; @@ -339,9 +343,11 @@ async function run() { // 3. provider search — registry tries providers in order (heygen-CLI first) let searchResult = null; + let providerFailure = null; try { searchResult = await runCapability(type, "search", intent, ctx); - } catch { + } catch (error) { + providerFailure = error; // search failed, try generate } @@ -349,7 +355,8 @@ async function run() { if (!searchResult) { try { searchResult = await runCapability(type, "generate", intent, ctx); - } catch { + } catch (error) { + providerFailure ??= error; // generate failed too } } @@ -377,13 +384,23 @@ async function run() { // brand stays local: no frame.md/design.md -> upsell the HyperFrames design // flow rather than reporting a generic miss (B5). const msg = - type === "brand" + providerFailure instanceof BundledSfxAssetsError + ? providerFailure.message + : type === "brand" ? "no brand spec found — add a frame.md or design.md (colors/font/logo) to this project. Run the HyperFrames design flow to create one; brand tokens are read locally for deterministic rendering." : args.provider ? `provider "${args.provider}" could not resolve ${type}: "${intent}"${localOnly ? " (--local-only skips network providers; drop it or the --provider override)" : ""}` : `no provider could resolve ${type}: "${intent}"`; if (args.json) { - console.log(JSON.stringify({ ok: false, error: msg })); + console.log( + JSON.stringify({ + ok: false, + ...(providerFailure instanceof BundledSfxAssetsError + ? { code: providerFailure.code, fix: providerFailure.fix } + : {}), + error: msg, + }), + ); } else { console.error(`error: ${msg}`); } @@ -852,6 +869,13 @@ function heygenAuthCheck() { function runDoctor() { const checks = []; + const bundledSfx = inspectBundledSfxAssets(); + checks.push({ + name: "bundled SFX assets", + ok: bundledSfx.ok, + detail: bundledSfx.detail, + fix: bundledSfx.fix, + }); const heygenVersionProbe = runCommand("heygen", ["--version"]); const heygenOnPath = heygenVersionProbe.status === 0; const heygenVersionText = commandText(heygenVersionProbe); @@ -952,7 +976,7 @@ function runDoctor() { // missing and then break at the first probe call. const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH"); const ffprobe = checks.find((check) => check.name === "ffprobe on PATH"); - return { ok: !!ffmpeg?.ok && !!ffprobe?.ok, checks }; + return { ok: bundledSfx.ok && !!ffmpeg?.ok && !!ffprobe?.ok, checks }; } function printDoctor(checks) { diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index 0fbd8a7c6..ec5474e75 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -145,6 +145,29 @@ test("bundled SFX resolve without HeyGen on PATH", () => { cleanup(); }); +test("missing bundled SFX install returns a typed recovery command", () => { + setup(); + const missingLibrary = join(tmp, "missing-sfx-library"); + const result = spawnResolve( + ["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--local-only", "--json"], + { + env: { + HOME: tmp, + PATH: tmp, + HYPERFRAMES_MEDIA_USE_SFX_DIR: missingLibrary, + }, + }, + ); + assert.equal(result.status, 1, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.code, "bundled_sfx_assets_missing"); + assert.equal(parsed.fix, "npx hyperframes skills update media-use"); + assert.match(parsed.error, /bundled SFX assets are missing or incomplete/); + assert.match(parsed.error, /manifest not found/); + cleanup(); +}); + function writeFakeHeygen(body, exitCode = 0) { const binDir = join(tmp, "bin"); mkdirSync(binDir, { recursive: true }); @@ -514,6 +537,7 @@ test("--doctor --json reports dependency checks and top-level ok requires ffmpeg assert.ok(Array.isArray(parsed.checks)); const expected = [ + "bundled SFX assets", "heygen on PATH", "heygen version", "heygen authenticated", @@ -532,7 +556,9 @@ test("--doctor --json reports dependency checks and top-level ok requires ffmpeg const ffmpeg = byName.get("ffmpeg on PATH"); const ffprobe = byName.get("ffprobe on PATH"); - const strictOk = ffmpeg.ok && ffprobe.ok; + const bundledSfx = byName.get("bundled SFX assets"); + assert.match(bundledSfx.detail, /bundled SFX assets available/); + const strictOk = bundledSfx.ok && ffmpeg.ok && ffprobe.ok; assert.equal(parsed.ok, strictOk); assert.equal(result.status, strictOk ? 0 : 1); });