mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(media-use): explain missing bundled SFX (#2460)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user