mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(media-use): fall back to bundled SFX (#2257)
* fix(media-use): fall back to bundled SFX * chore(skills): refresh media-use manifest * docs(media-use): design CLI fallback advisory * docs(media-use): plan CLI fallback advisory * fix(media-use): surface HeyGen CLI fallback guidance * fix(media-use): derive bundled SFX extension
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, 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();
|
||||
|
||||
export function extensionForBundledSfxFile(filename) {
|
||||
return extname(filename) || ".mp3";
|
||||
}
|
||||
|
||||
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: extensionForBundledSfxFile(best.entry.file),
|
||||
source: "bundled",
|
||||
metadata: {
|
||||
description: best.entry.description || best.key,
|
||||
duration: best.entry.duration ?? null,
|
||||
provider: "bundled.sfx",
|
||||
provenance: { library_key: best.key },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { extensionForBundledSfxFile } 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");
|
||||
});
|
||||
@@ -89,9 +89,22 @@ function classifyHeygenErrorResult(err) {
|
||||
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
|
||||
// track() already documents, just reachable from a sync call site.
|
||||
const pendingFailureTracking = new Set();
|
||||
// resolve.mjs is a single-shot CLI (one resolve per process), so one shared
|
||||
// consume-once slot is sufficient. If resolve becomes an in-process/concurrent
|
||||
// API, move this state into a per-resolve context before reusing that path.
|
||||
let pendingRemediation = null;
|
||||
|
||||
export function consumeHeygenRemediation() {
|
||||
const remediation = pendingRemediation;
|
||||
pendingRemediation = null;
|
||||
return remediation;
|
||||
}
|
||||
|
||||
export function reportHeygenFailure(err, context, trackEvent = track) {
|
||||
const { code, message } = classifyHeygenErrorResult(err);
|
||||
if (code === "not_found" || code === "outdated") {
|
||||
pendingRemediation = { code, message };
|
||||
}
|
||||
if (ACTIONABLE_MESSAGES.has(message)) {
|
||||
console.error(message);
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { test } from "node:test";
|
||||
import {
|
||||
classifyHeygenError,
|
||||
classifyHeygenErrorCode,
|
||||
consumeHeygenRemediation,
|
||||
flushHeygenFailureTracking,
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
HEYGEN_NOT_FOUND_MESSAGE,
|
||||
@@ -149,6 +150,39 @@ test("tracks not-found failures without changing actionable output", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("records missing and outdated CLI remediation once", () => {
|
||||
consumeHeygenRemediation();
|
||||
captureFailureReport({ code: "ENOENT" }, "heygen audio sounds list", () => {});
|
||||
assert.deepEqual(consumeHeygenRemediation(), {
|
||||
code: "not_found",
|
||||
message: HEYGEN_NOT_FOUND_MESSAGE,
|
||||
});
|
||||
assert.equal(consumeHeygenRemediation(), null);
|
||||
|
||||
captureFailureReport(
|
||||
{ stderr: Buffer.from("heygen v0.1.5 does not support --headers") },
|
||||
"heygen audio sounds list",
|
||||
() => {},
|
||||
);
|
||||
assert.deepEqual(consumeHeygenRemediation(), {
|
||||
code: "outdated",
|
||||
message: HEYGEN_OUTDATED_MESSAGE,
|
||||
});
|
||||
assert.equal(consumeHeygenRemediation(), null);
|
||||
});
|
||||
|
||||
test("does not record non-install remediation", () => {
|
||||
consumeHeygenRemediation();
|
||||
for (const error of [
|
||||
{ stderr: Buffer.from("HTTP 401 Unauthorized") },
|
||||
{ stderr: Buffer.from("quota exhausted") },
|
||||
{ stderr: Buffer.from("provider unavailable") },
|
||||
]) {
|
||||
captureFailureReport(error, "heygen audio sounds list", () => {});
|
||||
assert.equal(consumeHeygenRemediation(), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("tracks generic failures without including raw detail", () => {
|
||||
const trackingCalls = [];
|
||||
const stderrCalls = captureFailureReport(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
HEYGEN_INSTALL_COMMAND,
|
||||
HEYGEN_MIN_VERSION,
|
||||
HEYGEN_UPDATE_COMMAND,
|
||||
consumeHeygenRemediation,
|
||||
firstSemver,
|
||||
flushHeygenFailureTracking,
|
||||
versionLessThan,
|
||||
@@ -429,6 +430,16 @@ async function run() {
|
||||
},
|
||||
};
|
||||
|
||||
const heygenRemediation = consumeHeygenRemediation();
|
||||
if (
|
||||
searchResult.metadata?.provider === "bundled.sfx" &&
|
||||
!localOnly &&
|
||||
!args.provider &&
|
||||
heygenRemediation
|
||||
) {
|
||||
record.advisory = heygenRemediation;
|
||||
}
|
||||
|
||||
appendRecord(projectDir, record);
|
||||
regenerateIndex(projectDir);
|
||||
// Auto-promote: surface every fetched asset in the global cache so it's
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
chmodSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -130,6 +131,81 @@ 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.match(parsed.advisory?.message ?? "", /Install: curl -fsSL/);
|
||||
assert.ok(existsSync(join(tmp, parsed.path)));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function writeFakeHeygen(body, exitCode = 0) {
|
||||
const binDir = join(tmp, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const command = join(binDir, "heygen");
|
||||
writeFileSync(command, `#!/bin/sh\n${body}\nexit ${exitCode}\n`);
|
||||
chmodSync(command, 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
test("bundled SFX advises update when the HeyGen CLI is outdated", () => {
|
||||
setup();
|
||||
const binDir = writeFakeHeygen('echo "heygen v0.1.5 does not support --headers" >&2', 1);
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
|
||||
env: { HOME: tmp, PATH: binDir },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.match(parsed.advisory?.message ?? "", /heygen update/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("bundled SFX does not advise installation after a healthy catalog miss", () => {
|
||||
setup();
|
||||
const binDir = writeFakeHeygen(`echo '{"data":[]}'`);
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
|
||||
env: { HOME: tmp, PATH: binDir },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.equal(parsed.advisory, undefined);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("explicit local bundled SFX resolution does not advise installation", () => {
|
||||
for (const extraArgs of [["--local-only"], ["--provider", "bundled.sfx"]]) {
|
||||
setup();
|
||||
const result = spawnResolve(
|
||||
["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json", ...extraArgs],
|
||||
{ env: { HOME: tmp, PATH: tmp } },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.equal(parsed.advisory, undefined);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("human bundled fallback prints the install hint once", () => {
|
||||
setup();
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp], {
|
||||
env: { HOME: tmp, PATH: tmp },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr.match(/Install: curl -fsSL/g)?.length, 1);
|
||||
assert.match(result.stdout, /resolved sfx_001/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("project manifest hit skips providers", () => {
|
||||
setup();
|
||||
const record = makeRecord({ provenance: { prompt: "cached query", provider: "test" } });
|
||||
|
||||
Reference in New Issue
Block a user