feat(media-use): source sound effects from Openverse with the bundled library offline (#5753)

* feat(media-use): source sound effects from Openverse with the bundled library offline

* docs(media-use): name the resolve credits file without a dead path

* style(skills): restore the slideshow harness formatting the pinned formatter expects

* fix(media-use): keep Openverse credit text to one safe line and read credits without a race

* fix(media-use): Openverse sound effects record as the engine's, so a rerun writes no copy

* fix(media-use): two sound effect resolves at once each keep their credit line

* fix(media-use): the standalone media-use copy takes the same credit lock

* fix(media-use): a sound effect credit is always one safe line with a provider link

writeSfxCredit cleans the credit and source link itself, since the CLI writes
credits from a saved record. Backticks, brackets, angle brackets and backslashes
are stripped, and a source link must be https on an Openverse audio provider host
(Freesound, Jamendo, Wikimedia Commons) or it is left out.
This commit is contained in:
Miguel Ángel
2026-10-18 04:35:08 +00:00
committed by GitHub
parent deeb8d437c
commit 49671b73dd
34 changed files with 1175 additions and 320 deletions
@@ -102,19 +102,16 @@ const pendingFailureTracking = new Set();
// API, move this state into a per-resolve context before reusing that path.
let pendingRemediation = null;
const TOOL_WORDS = { bgm: "music", sfx: "sound-effect" };
/** A music or sound-effect resolve miss after the heygen CLI was missing or too old: what is missing, the host
* app's own tool, the fix. Other types keep the generic miss. */
/** A music resolve miss after the heygen CLI was missing or too old: what is missing, the host app's own
* tool, the fix. Other types keep the generic miss. */
export function heygenMiss(type, { code }) {
const tool = TOOL_WORDS[type];
if (!tool) return null;
if (type !== "bgm") return null;
const outdated = code === "outdated";
const state = outdated ? `older than v${HEYGEN_MIN_VERSION}` : "not installed";
return {
code: outdated ? "heygen_cli_outdated" : "heygen_cli_missing",
fix: outdated ? HEYGEN_UPDATE_COMMAND : HEYGEN_INSTALL_COMMAND,
error: `${type} needs the heygen CLI, which is ${state}: use your host app's own ${tool} tool if it has one, or ${outdated ? "update" : "install"} the CLI.`,
error: `${type} needs the heygen CLI, which is ${state}: use your host app's own music tool if it has one, or ${outdated ? "update" : "install"} the CLI.`,
};
}
@@ -301,13 +301,13 @@ test("flushHeygenFailureTracking resolves immediately when nothing is pending",
await flushHeygenFailureTracking();
});
test("only a music or sound-effect miss names the heygen CLI and the host app's own tool", () => {
for (const type of ["image", "icon", "voice", "video"])
test("only a music miss names the heygen CLI and the host app's own tool", () => {
for (const type of ["sfx", "image", "icon", "voice", "video"])
assert.equal(heygenMiss(type, { code: "not_found" }), null, type);
assert.equal(heygenMiss("bgm", { code: "not_found" }).code, "heygen_cli_missing");
const sfx = heygenMiss("sfx", { code: "outdated" });
assert.equal(sfx.code, "heygen_cli_outdated");
assert.match(sfx.error, /host app's own sound-effect tool/);
const bgm = heygenMiss("bgm", { code: "outdated" });
assert.equal(bgm.code, "heygen_cli_outdated");
assert.match(bgm.error, /host app's own music tool/);
});
test("a CLI that rejects --headers without printing a version is outdated", () => {
@@ -11,6 +11,7 @@ export function allocateId(
type: string,
ext: string,
): Omit<Reservation, "fullPath">;
export function withMediaLock<T>(projectDir: string, fn: () => T): T;
export function withReservedFileSync<T>(
projectDir: string,
type: string,
+9 -6
View File
@@ -107,8 +107,7 @@ export function recordInPlace(
description: description || basename(path),
...(duration != null && { duration: Math.round(duration * 10) / 10 }),
};
mkdirSync(mediaDir(projectDir), { recursive: true });
return withLock(mediaDir(projectDir), () => {
return withMediaLock(projectDir, () => {
const latest = latestRecordFor(projectDir, path);
const same = ["source", "description", "duration"].every(
(key) => latest?.[key] === fields[key],
@@ -203,6 +202,12 @@ function withLock(dir, fn) {
}
}
/** Runs `fn` holding the project's `.media/.lock`, so concurrent resolves take turns on what it guards. */
export function withMediaLock(projectDir, fn) {
mkdirSync(mediaDir(projectDir), { recursive: true });
return withLock(mediaDir(projectDir), fn);
}
// Atomically allocate the next free id for `type` AND reserve it on disk, so a
// slow download/copy between allocation and appendRecord can't let a concurrent
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
@@ -210,10 +215,8 @@ function withLock(dir, fn) {
// O_EXCL-create an empty marker under core's atomic temp name, which Studio's
// project history skips. Returns { id, localPath, markerPath }.
export function allocateId(projectDir, type, ext) {
mkdirSync(mediaDir(projectDir), { recursive: true });
const typeDir = typeDirPath(projectDir, type);
mkdirSync(typeDir, { recursive: true });
return withLock(mediaDir(projectDir), () => {
mkdirSync(typeDirPath(projectDir, type), { recursive: true });
return withMediaLock(projectDir, () => {
const id = nextFreeId(projectDir, type);
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
// Same shape as core atomicTempPath: an empty file at the final name would be committed.
@@ -0,0 +1,134 @@
// Sound effects from Openverse (https://api.openverse.org): free, no key.
// Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall
// back to the bundled library when it is unavailable.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/";
// The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives.
export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]);
const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]);
const MAX_SFX_MS = 15000;
const IDEAL_SFX_MS = 4000;
const BY_PENALTY = 2;
const SEARCH_TIMEOUT_MS = 10000;
// Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded.
const oneLine = (text) =>
String(text ?? "")
.replace(/[\p{Cc}`[\]<>\\]+/gu, " ")
.trim()
.slice(0, 300);
// Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed.
const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]);
function httpsUrl(url, hosts) {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname))
? oneLine(parsed.href)
: "";
} catch {
return "";
}
}
/** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */
export class OpenverseUnavailableError extends Error {
constructor(message) {
super(message);
this.name = "OpenverseUnavailableError";
}
}
function usable(result) {
return (
OPENVERSE_SFX_LICENSES.has(result?.license) &&
PLAYABLE_FILETYPES.has(result.filetype) &&
typeof result.url === "string" &&
result.url.startsWith("https://") &&
Number.isFinite(result.duration) &&
result.duration > 0 &&
result.duration <= MAX_SFX_MS
);
}
// Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY.
function penalty(result, position) {
const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000;
return position + longBy + (result.license === "by" ? BY_PENALTY : 0);
}
/** Best usable sound in Openverse's relevance-ordered results, or null. */
export function pickOpenverseSfx(results) {
let best = null;
results.forEach((result, position) => {
if (!usable(result)) return;
const score = penalty(result, position);
if (!best || score < best.score) best = { result, score };
});
if (!best) return null;
const r = best.result;
return {
id: r.id,
url: r.url,
ext: `.${r.filetype}`,
title: oneLine(r.title),
creator: oneLine(r.creator),
license: r.license,
licenseUrl: httpsUrl(r.license_url),
sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS),
credit: oneLine(r.attribution),
durationS: Math.round(r.duration) / 1000,
};
}
/** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */
export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) {
const params = new URLSearchParams({
q: query,
page_size: "20",
license: [...OPENVERSE_SFX_LICENSES].join(","),
});
let response;
try {
response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
});
} catch (error) {
throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`);
}
if (response.status === 429)
throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)");
if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`);
let body;
try {
body = await response.json();
} catch {
throw new OpenverseUnavailableError("Openverse returned non-JSON");
}
return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []);
}
/** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */
export function writeSfxCredit(creditsPath, file, sound) {
if (sound.license !== "by") return;
// The sound may come from a saved record, not this run's pick: clean it again here.
const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS);
const source = sourceUrl ? ` Source: ${sourceUrl}` : "";
const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`;
let prior = "";
try {
prior = readFileSync(creditsPath, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const kept = prior
.split("\n")
.filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`));
const header =
"# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n";
mkdirSync(dirname(creditsPath), { recursive: true });
writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`);
}
@@ -0,0 +1,219 @@
import { strict as assert } from "node:assert";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import {
OpenverseUnavailableError,
pickOpenverseSfx,
searchOpenverseSfx,
writeSfxCredit,
} from "./openverse.mjs";
const result = (overrides = {}) => ({
id: "ov-1",
title: "Deep Whoosh",
creator: "Kinoton",
license: "cc0",
license_url: "https://creativecommons.org/publicdomain/zero/1.0/",
foreign_landing_url: "https://freesound.org/people/Kinoton/sounds/351256",
url: "https://cdn.freesound.org/previews/351/351256_2247456-hq.mp3",
filetype: "mp3",
duration: 1200,
attribution: '"Deep Whoosh" by Kinoton is marked with CC0 1.0.',
...overrides,
});
test("only CC0 and CC BY sounds are ever picked", () => {
for (const license of ["by-nc", "by-sa", "by-nd", "by-nc-sa", "by-nc-nd", "pdm", "sampling+"])
assert.equal(pickOpenverseSfx([result({ license })]), null, license);
assert.equal(pickOpenverseSfx([result({ license: "by" })]).license, "by");
assert.equal(pickOpenverseSfx([result({ license: "cc0" })]).license, "cc0");
});
test("sounds too long for a cue, or not playable, are skipped", () => {
assert.equal(pickOpenverseSfx([result({ duration: 15001 })]), null);
assert.equal(pickOpenverseSfx([result({ duration: null })]), null);
assert.equal(pickOpenverseSfx([result({ filetype: "aiff" })]), null);
assert.equal(pickOpenverseSfx([result({ url: "http://insecure.test/a.mp3" })]), null);
});
test("ranking keeps relevance order, then prefers short clips and CC0 over CC BY", () => {
const pick = (results) => pickOpenverseSfx(results).id;
assert.equal(pick([result({ id: "first" }), result({ id: "second" })]), "first");
assert.equal(pick([result({ id: "long", duration: 12000 }), result({ id: "short" })]), "short");
assert.equal(pick([result({ id: "by", license: "by" }), result({ id: "cc0" })]), "cc0");
assert.equal(
pick([
result({ id: "by", license: "by" }),
...Array(3).fill(result({ license: "by-nc" })),
result({ id: "cc0" }),
]),
"by",
"a much more relevant CC BY sound still wins",
);
});
test("a pick carries what the project needs to play and credit it", () => {
assert.deepEqual(pickOpenverseSfx([result({ license: "by", filetype: "wav", duration: 450 })]), {
id: "ov-1",
url: "https://cdn.freesound.org/previews/351/351256_2247456-hq.mp3",
ext: ".wav",
title: "Deep Whoosh",
creator: "Kinoton",
license: "by",
licenseUrl: "https://creativecommons.org/publicdomain/zero/1.0/",
sourceUrl: "https://freesound.org/people/Kinoton/sounds/351256",
credit: '"Deep Whoosh" by Kinoton is marked with CC0 1.0.',
durationS: 0.45,
});
});
test("Openverse text is kept to one bounded line, and only https links survive", () => {
const pick = pickOpenverseSfx([
result({
license: "by",
title: "Pop\n# Heading",
attribution: `"Pop" by Ana.\n- \`assets/sfx/other.mp3\`: forged credit\r\u0007${"x".repeat(400)}`,
foreign_landing_url: "javascript:alert(1)",
license_url: "http://creativecommons.org/licenses/by/4.0/",
}),
]);
assert.equal(pick.title, "Pop # Heading");
assert.equal(pick.credit.length, 300);
assert.ok(!/\p{Cc}/u.test(pick.credit), "no line breaks or control characters");
assert.equal(pick.sourceUrl, "");
assert.equal(pick.licenseUrl, "");
});
test("search asks Openverse for JSON and only the allowed licences", async () => {
const calls = [];
const sound = await searchOpenverseSfx("ui click", {
fetchImpl: async (url, init) => {
calls.push({ url: new URL(url), init });
return Response.json({ results: [result({ license: "by-nc" }), result({ id: "ok" })] });
},
});
assert.equal(sound.id, "ok", "the licence field is checked even on a filtered response");
assert.equal(calls.length, 1);
assert.equal(calls[0].url.origin + calls[0].url.pathname, "https://api.openverse.org/v1/audio/");
assert.equal(calls[0].url.searchParams.get("q"), "ui click");
assert.equal(calls[0].url.searchParams.get("license"), "cc0,by");
assert.equal(calls[0].init.headers.Accept, "application/json");
});
test("no usable result is a miss, not an error", async () => {
const fetchImpl = async () => Response.json({ results: [] });
assert.equal(await searchOpenverseSfx("nothing", { fetchImpl }), null);
});
test("rate limits, server errors, bad bodies and network failures make Openverse unavailable", async () => {
const failures = [
[async () => new Response("slow down", { status: 429 }), /rate limit/],
[async () => new Response("down", { status: 503 }), /HTTP 503/],
[async () => new Response("<html>"), /non-JSON/],
[
async () => {
throw new TypeError("fetch failed");
},
/unreachable \(fetch failed\)/,
],
];
for (const [fetchImpl, message] of failures)
await assert.rejects(searchOpenverseSfx("whoosh", { fetchImpl }), (error) => {
assert.ok(error instanceof OpenverseUnavailableError);
assert.match(error.message, message);
return true;
});
});
test("credits list each CC BY file once and leave CC0 out", () => {
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
try {
const path = join(dir, "assets", "sfx", "CREDITS.md");
const by = {
license: "by",
credit: '"Pop" by Ana is licensed under CC BY 4.0.',
sourceUrl: "https://freesound.org/s/1",
};
writeSfxCredit(path, "assets/sfx/zap.mp3", { ...by, license: "cc0" });
assert.throws(() => readFileSync(path), /ENOENT/);
writeSfxCredit(path, "assets/sfx/pop.mp3", by);
writeSfxCredit(path, "assets/sfx/pop.mp3", {
...by,
credit: '"Pop 2" by Ana is licensed under CC BY 4.0.',
});
writeSfxCredit(path, "assets/sfx/ding.mp3", by);
const lines = readFileSync(path, "utf8")
.split("\n")
.filter((l) => l.startsWith("- "));
assert.deepEqual(lines, [
'- `assets/sfx/pop.mp3`: "Pop 2" by Ana is licensed under CC BY 4.0. Source: https://freesound.org/s/1',
'- `assets/sfx/ding.mp3`: "Pop" by Ana is licensed under CC BY 4.0. Source: https://freesound.org/s/1',
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a credit with no https source link is written without one", () => {
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
try {
const path = join(dir, "CREDITS.md");
writeSfxCredit(path, "assets/sfx/pop.mp3", { license: "by", credit: "c.", sourceUrl: "" });
assert.match(readFileSync(path, "utf8"), /\n- `assets\/sfx\/pop\.mp3`: c\.\n$/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a hostile credit is written as one safe line, with no link off Openverse's providers", () => {
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
try {
const path = join(dir, "CREDITS.md");
const hostile = `"Pop" by Ana.\n- \`assets/sfx/x.mp3\`: forged [click](https://evil.test) <img src=x> \\${"x".repeat(5000)}`;
for (const sourceUrl of [
"javascript:alert(1)",
"https://evil.test/a",
"https://freesound.org/s/1\n- forged",
]) {
writeSfxCredit(path, "assets/sfx/pop.mp3", { license: "by", credit: hostile, sourceUrl });
const lines = readFileSync(path, "utf8")
.split("\n")
.filter((l) => l.startsWith("- "));
assert.equal(lines.length, 1, sourceUrl);
assert.ok(
lines[0].startsWith('- `assets/sfx/pop.mp3`: "Pop" by Ana. - '),
lines[0].slice(0, 80),
);
assert.ok(
!/[\p{Cc}`[\]<>\\]/u.test(lines[0].slice("- `assets/sfx/pop.mp3`:".length)),
sourceUrl,
);
assert.ok(lines[0].length < 400, "bounded");
assert.doesNotMatch(lines[0], /javascript:|evil\.test\/a/);
}
assert.match(readFileSync(path, "utf8"), /Source: https:\/\/freesound\.org\/s\/1-%20forged\n$/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("earlier credit lines survive a new credit", () => {
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
try {
const path = join(dir, "CREDITS.md");
writeFileSync(path, "# Sound effect credits\n\n- `assets/sfx/old.mp3`: kept\n");
writeSfxCredit(path, "assets/sfx/new.mp3", {
license: "by",
credit: "c.",
sourceUrl: "https://freesound.org/s/2",
});
assert.match(
readFileSync(path, "utf8"),
/- `assets\/sfx\/old\.mp3`: kept\n- `assets\/sfx\/new\.mp3`: c\. Source: https:\/\/freesound\.org\/s\/2\n$/,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+4 -2
View File
@@ -10,6 +10,8 @@
// own auth. Providers, by type:
// - heygen CLI: catalog + TTS, first for every type it serves (OAuth free
// allowance first, then the user's HeyGen billing path)
// - Openverse: free, keyless sound effects (CC0 / CC BY), with the bundled
// library as the offline fallback
// - mflux: local FLUX-class image gen, spec-selected to the machine's RAM
// (free, private, offline once cached)
// - codex CLI: image gen on the user's ChatGPT sub — the better-quality upsell
@@ -21,7 +23,7 @@
// (e.g. "make an image with codex").
import { bgmProvider } from "./bgm-provider.mjs";
import { sfxProvider } from "./sfx-provider.mjs";
import { OPENVERSE_SFX_PROVIDER, 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";
@@ -46,8 +48,8 @@ const P = (name, caps) => ({ name, network: true, paid: true, ...caps }); // rem
const REGISTRY = {
bgm: [N("heygen.audio.sounds", { search: bgmProvider.search })],
sfx: [
N(OPENVERSE_SFX_PROVIDER, { search: sfxProvider.search }),
A("bundled.sfx", { search: bundledSfxProvider.search }),
N("heygen.audio.sounds", { search: sfxProvider.search }),
],
image: [
N("heygen.asset.search", { search: imageProvider.search }),
@@ -32,18 +32,18 @@ test("listTypes exposes the v2 media types", () => {
}
});
test("heygen provider is first for every type it serves except bundled sfx", () => {
test("heygen provider is first for every type it serves", () => {
for (const t of ["bgm", "image", "icon"]) {
const first = getProviders(t)[0];
assert.ok(first, `no enabled provider for ${t}`);
assert.match(first.name, /^heygen/, `${t} first provider is ${first.name}`);
}
assert.equal(getProviders("sfx")[0].name, "bundled.sfx");
assert.ok(!getProviders("sfx").some((p) => p.name.startsWith("heygen")), "sfx never asks HeyGen");
});
test("sanctioned providers only: heygen, local mflux/kokoro/ltx, codex, design spec, logo tiers", () => {
test("sanctioned providers only: heygen, Openverse, local mflux/kokoro/ltx, codex, design spec, logo tiers", () => {
const allowed =
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^ltx\.local$|^codex\.image_gen$|^design_spec$|^thesvg$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
/^heygen|^openverse\.audio$|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^ltx\.local$|^codex\.image_gen$|^design_spec$|^thesvg$|^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}`);
@@ -85,13 +85,14 @@ test("video cascade: HeyGen first, LTX local fallback, generate-only", async ()
assert.equal(await runCapability("video", "search", "x", {}), null);
});
test("sfx cascade: bundled library first, HeyGen catalog remains the network fallback", () => {
test("sfx cascade: Openverse first, the bundled library is the offline fallback", () => {
const ps = getProviders("sfx");
assert.equal(ps[0].name, "bundled.sfx");
assert.equal(typeof ps[0].search, "function");
assert.ok(!ps[0].network, "bundled SFX remain available offline");
assert.equal(ps[1].name, "heygen.audio.sounds");
assert.ok(ps[1].network, "HeyGen SFX catalog is network-only");
assert.deepEqual(
ps.map((p) => p.name),
["openverse.audio", "bundled.sfx"],
);
assert.ok(ps[0].network && !ps[0].paid, "Openverse is free and network-only");
assert.ok(!ps[1].network, "bundled SFX remain available offline");
});
test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", async () => {
@@ -201,9 +202,8 @@ test("providerTierFor reports the registry's own A/N/P declaration", () => {
});
test("providerTierFor agrees across every type that declares the same name", () => {
// heygen.audio.sounds serves both bgm and sfx; heygen.asset.search serves both
// image and icon. A name whose tier depended on the media type would make the
// telemetry property meaningless.
// heygen.asset.search serves both image and icon. A name whose tier depended
// on the media type would make the telemetry property meaningless.
const byName = new Map();
for (const type of listTypes()) {
for (const name of providerNamesFor(type)) {
+23 -13
View File
@@ -1,22 +1,32 @@
import { heygenSearch } from "./heygen-search.mjs";
import { OpenverseUnavailableError, searchOpenverseSfx } from "./openverse.mjs";
export const OPENVERSE_SFX_PROVIDER = "openverse.audio";
export const sfxProvider = {
async search(intent) {
const results = heygenSearch("audio sounds list", intent, {
type: "sound_effects",
minScore: 0.4,
});
if (!results) return null;
const best = results[0];
let sound;
try {
sound = await searchOpenverseSfx(intent);
} catch (error) {
if (!(error instanceof OpenverseUnavailableError)) throw error;
console.error(`media-use: ${error.message}; using the bundled sound effects`);
return null;
}
if (!sound) return null;
return {
url: best.audio_url,
url: sound.url,
ext: sound.ext,
source: "search",
// ext derived from audio_url by resolve.mjs — catalog SFX are .mp3 or .wav
metadata: {
description: best.description || best.name || intent,
duration: best.duration || null,
provider: "heygen.audio.sounds",
provenance: { track_id: best.id, score: best.score, query: intent },
description: sound.title || intent,
duration: sound.durationS,
provider: OPENVERSE_SFX_PROVIDER,
provenance: {
openverse_id: sound.id,
license: sound.license,
credit: sound.credit,
source_url: sound.sourceUrl,
},
},
};
},
+45 -55
View File
@@ -12,6 +12,8 @@ import {
findByPrompt,
findByEntity,
nextId,
typeDirPath,
withMediaLock,
withReservedFile,
withReservedFileSync,
} from "./lib/manifest.mjs";
@@ -55,6 +57,8 @@ import {
versionLessThan,
} from "./lib/heygen-cli.mjs";
import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs";
import { OPENVERSE_SFX_PROVIDER } from "./lib/sfx-provider.mjs";
import { writeSfxCredit } from "./lib/openverse.mjs";
import {
fetchMediaVectors,
mediaVectorRows,
@@ -441,57 +445,43 @@ async function run() {
return resolveColor(type, intent, { projectDir });
}
// SFX search is bundled, local-index, then HeyGen.
// SFX search is Openverse, the bundled library, then the local media index.
let searchResult = null;
let providerFailure = null;
let localIndexFailure = null;
try {
if (type === "sfx" && !args.provider) {
searchResult = await runCapability(type, "search", intent, {
...ctx,
provider: "bundled.sfx",
});
if (!searchResult && !localOnly) {
const registry =
process.env.HYPERFRAMES_REGISTRY ||
"https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry";
try {
await fetchMediaVectors(registry);
const ranked = await rankMediaRowsWithVectors(
intent,
mediaVectorRows().filter((row) => row.kind === "sfx"),
);
const row = ranked.rows[0];
const candidates = row
? [resolve(row.file), join(import.meta.dirname, "..", "..", "..", row.file)]
: [];
const localPath = candidates.find((candidate) => existsSync(candidate));
if (row && localPath) {
searchResult = {
localPath,
ext: extname(localPath),
source: "local-index",
metadata: {
description: row.description,
duration: row.duration ?? null,
provider: "catalog.local",
provenance: { library_key: row.id, tier: ranked.tier },
},
};
}
} catch (error) {
localIndexFailure = error;
searchResult = await runCapability(type, "search", intent, ctx);
if (!searchResult && type === "sfx" && !args.provider && !localOnly) {
const registry =
process.env.HYPERFRAMES_REGISTRY ||
"https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry";
try {
await fetchMediaVectors(registry);
const ranked = await rankMediaRowsWithVectors(
intent,
mediaVectorRows().filter((row) => row.kind === "sfx"),
);
const row = ranked.rows[0];
const candidates = row
? [resolve(row.file), join(import.meta.dirname, "..", "..", "..", row.file)]
: [];
const localPath = candidates.find((candidate) => existsSync(candidate));
if (row && localPath) {
searchResult = {
localPath,
ext: extname(localPath),
source: "local-index",
metadata: {
description: row.description,
duration: row.duration ?? null,
provider: "catalog.local",
provenance: { library_key: row.id, tier: ranked.tier },
},
};
}
} catch (error) {
localIndexFailure = error;
}
if (!searchResult) searchResult = await runCapability(type, "search", intent, ctx);
// Keep HeyGen remediation diagnostics while bundled remains authoritative.
else if (!localOnly)
await runCapability(type, "search", intent, {
...ctx,
provider: "heygen.audio.sounds",
});
} else {
searchResult = await runCapability(type, "search", intent, ctx);
}
} catch (error) {
providerFailure = error;
@@ -602,16 +592,6 @@ async function run() {
},
};
const heygenRemediation = consumeHeygenRemediation();
if (
searchResult.metadata?.provider === "bundled.sfx" &&
!localOnly &&
!args.provider &&
heygenRemediation
) {
record.advisory = heygenRemediation;
}
appendRecord(projectDir, record);
regenerateIndex(projectDir);
// Promote fetched assets into the global cache for reuse.
@@ -1304,6 +1284,16 @@ async function reuseGlobal(shaArg) {
}
async function result(record, source) {
const { provider, license, credit, source_url: sourceUrl } = record.provenance ?? {};
// Under the media lock: two resolves at once each read, then rewrite, the whole credits file.
if (provider === OPENVERSE_SFX_PROVIDER)
withMediaLock(projectDir, () =>
writeSfxCredit(join(typeDirPath(projectDir, "sfx"), "CREDITS.md"), record.path, {
license,
credit,
sourceUrl,
}),
);
// Non-PII usage event: which media type, how it resolved, which provider won.
// Never the intent text or paths. Awaited so a short-lived run flushes it.
await track("media_use_resolve", {
+117 -40
View File
@@ -16,11 +16,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
import { appendRecord, findByPrompt, readManifest } from "./lib/manifest.mjs";
import { regenerateIndex } from "./lib/index-gen.mjs";
import { getProvider } from "./lib/providers.mjs";
import {
HEYGEN_INSTALL_COMMAND,
HEYGEN_NOT_FOUND_MESSAGE,
HEYGEN_UPDATE_COMMAND,
} from "./lib/heygen-cli.mjs";
import { HEYGEN_INSTALL_COMMAND, HEYGEN_UPDATE_COMMAND } from "./lib/heygen-cli.mjs";
import { freezeLocalFile } from "./lib/freeze.mjs";
import { cachePut, cacheGet, importFromCache } from "./lib/cache.mjs";
import { validateCubeFile } from "./lib/cube-validate.mjs";
@@ -78,8 +74,8 @@ function runResolve(args, opts = {}) {
}
function spawnResolve(args, opts = {}) {
const { env, ...rest } = opts;
return spawnSync(process.execPath, [RESOLVE_CLI, ...args], {
const { env, nodeArgs = [], ...rest } = opts;
return spawnSync(process.execPath, [...nodeArgs, RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
@@ -88,9 +84,9 @@ function spawnResolve(args, opts = {}) {
}
function spawnResolveAsync(args, opts = {}) {
const { env, ...rest } = opts;
const { env, nodeArgs = [], ...rest } = opts;
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [RESOLVE_CLI, ...args], {
const child = spawn(process.execPath, [...nodeArgs, RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
stdio: ["ignore", "pipe", "pipe"],
@@ -107,6 +103,31 @@ function spawnResolveAsync(args, opts = {}) {
});
}
const OPENVERSE_BY_WHOOSH = {
id: "ov-whoosh",
title: "Swoosh",
creator: "Ana",
license: "by",
foreign_landing_url: "https://freesound.org/s/1",
url: "https://cdn.openverse.test/swoosh.mp3",
filetype: "mp3",
duration: 820,
attribution: '"Swoosh" by Ana is licensed under CC BY 4.0.',
};
// Node args that stand Openverse in for the spawned resolve: `results` answers every search, 429 rate-limits it.
function openverseImport(results) {
const stub = `const real = globalThis.fetch;
globalThis.fetch = async (url, init) => {
const u = String(url);
if (u.startsWith("https://api.openverse.org/"))
return ${results === 429 ? 'new Response("", { status: 429 })' : `Response.json({ results: ${JSON.stringify(results)} })`};
if (u.startsWith("https://cdn.openverse.test/")) return new Response("ID3 openverse bytes");
return real(url, init);
};`;
return [`--import=data:text/javascript,${encodeURIComponent(stub)}`];
}
function makeFrame(dir, name, color) {
const out = join(dir, name);
execFileSync(
@@ -160,18 +181,82 @@ function test(name, fn) {
// --- manifest cache hit ---
test("bundled SFX resolve without HeyGen on PATH", () => {
test("SFX resolve from Openverse with the licence on the record and a credit", () => {
setup();
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
env: { HOME: tmp, PATH: tmp },
nodeArgs: openverseImport([OPENVERSE_BY_WHOOSH]),
});
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.equal(parsed.advisory?.message, HEYGEN_NOT_FOUND_MESSAGE);
assert.equal(parsed.advisory.message.includes("| bash"), false);
assert.ok(existsSync(join(tmp, parsed.path)));
assert.equal(parsed.provenance.provider, "openverse.audio");
assert.equal(parsed.provenance.license, "by");
assert.equal(parsed.provenance.credit, OPENVERSE_BY_WHOOSH.attribution);
assert.equal(parsed.advisory, undefined);
assert.equal(readFileSync(join(tmp, parsed.path), "utf8"), "ID3 openverse bytes");
assert.equal(
readFileSync(join(tmp, ".media/audio/sfx/CREDITS.md"), "utf8").split("\n").at(-2),
`- \`${parsed.path}\`: ${OPENVERSE_BY_WHOOSH.attribution} Source: ${OPENVERSE_BY_WHOOSH.foreign_landing_url}`,
);
cleanup();
});
// Node args that hold each resolve just after its credits read until two have read (2 s at most), and give each
// sound its own bytes.
function creditRaceImport(barrier) {
const stub = `import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const read = fs.readFileSync;
fs.readFileSync = (path, ...rest) => {
if (!String(path).endsWith("CREDITS.md")) return read(path, ...rest);
let text, error;
try {
text = read(path, ...rest);
} catch (e) {
error = e;
}
fs.writeFileSync(${JSON.stringify(barrier)} + "/" + process.pid, "");
const until = Date.now() + 2000;
while (fs.readdirSync(${JSON.stringify(barrier)}).length < 2 && Date.now() < until)
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
if (error) throw error;
return text;
};
syncBuiltinESMExports();
const prior = globalThis.fetch;
globalThis.fetch = async (url, init) =>
String(url).startsWith("https://cdn.openverse.test/") ? new Response("ID3 " + url) : prior(url, init);`;
return [`--import=data:text/javascript,${encodeURIComponent(stub)}`];
}
test("two SFX resolves at once each keep their Openverse credit line", async () => {
setup();
const barrier = join(tmp, "credit-barrier");
mkdirSync(barrier);
const runs = await Promise.all(
["swoosh", "thud"].map((intent) =>
spawnResolveAsync(["--type", "sfx", "--intent", intent, "--project", tmp, "--json"], {
env: { HOME: tmp, PATH: tmp },
nodeArgs: [
...openverseImport([
{
...OPENVERSE_BY_WHOOSH,
id: `ov-${intent}`,
url: `https://cdn.openverse.test/${intent}.mp3`,
},
]),
...creditRaceImport(barrier),
],
}),
),
);
for (const run of runs) assert.equal(run.status, 0, run.stderr);
const credited = readFileSync(join(tmp, ".media/audio/sfx/CREDITS.md"), "utf8")
.split("\n")
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(3, line.indexOf("`:")));
assert.deepEqual(credited.sort(), runs.map((run) => JSON.parse(run.stdout).path).sort());
cleanup();
});
@@ -207,24 +292,28 @@ function writeFakeHeygen(body, exitCode = 0) {
return binDir;
}
test("bundled SFX advises update when the HeyGen CLI is outdated", () => {
test("an Openverse rate limit resolves SFX from the bundled library", () => {
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 },
env: { HOME: tmp, PATH: tmp },
nodeArgs: openverseImport(429),
});
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/);
assert.match(
result.stderr,
/Openverse rate limit reached \(HTTP 429\); using the bundled sound effects/,
);
assert.equal(existsSync(join(tmp, ".media/audio/sfx/CREDITS.md")), false);
cleanup();
});
test("bundled SFX does not advise installation after a healthy catalog miss", () => {
test("a sound Openverse has no licensed match for comes from the bundled library", () => {
setup();
const binDir = writeFakeHeygen(`echo '{"data":[]}'`);
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
env: { HOME: tmp, PATH: binDir },
env: { HOME: tmp, PATH: tmp },
nodeArgs: openverseImport([{ ...OPENVERSE_BY_WHOOSH, license: "by-nc" }]),
});
assert.equal(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
@@ -233,12 +322,12 @@ test("bundled SFX does not advise installation after a healthy catalog miss", ()
cleanup();
});
test("explicit local bundled SFX resolution does not advise installation", () => {
test("explicit local bundled SFX resolution never asks Openverse", () => {
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 } },
{ env: { HOME: tmp, PATH: tmp }, nodeArgs: openverseImport([OPENVERSE_BY_WHOOSH]) },
);
assert.equal(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
@@ -266,7 +355,7 @@ test("music without the HeyGen CLI fails naming it and the host app's own tool",
cleanup();
});
test("a sound effect nothing bundled matches fails the same way without the HeyGen CLI", async () => {
test("a sound effect nothing matches is a plain miss that never names the HeyGen CLI", async () => {
setup();
// The local media index answers 404, so the miss never depends on the network.
const server = createServer((_req, res) => {
@@ -283,12 +372,13 @@ test("a sound effect nothing bundled matches fails the same way without the HeyG
PATH: tmp,
HYPERFRAMES_REGISTRY: `http://127.0.0.1:${server.address().port}`,
},
nodeArgs: openverseImport([]),
},
);
assert.equal(result.status, 1, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.code, "heygen_cli_missing");
assert.match(parsed.error, /host app's own sound-effect tool/);
assert.equal(parsed.code, undefined);
assert.equal(parsed.error, 'no provider could resolve sfx: "dog barking"');
} finally {
server.close();
cleanup();
@@ -311,20 +401,6 @@ test("music with an outdated HeyGen CLI fails naming the update", () => {
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 the CLI from https:\/\/developers\.heygen\.com\/cli/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" } });
@@ -1403,6 +1479,7 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept
for (const [provider, type, expected] of [
["heygen.tts", "voice", "network_paid"],
["heygen.audio.sounds", "bgm", "network_free"],
["openverse.audio", "sfx", "network_free"],
["bundled.sfx", "sfx", "local"],
]) {
test(`a resolve won by ${provider} sends provider_tier: ${expected}`, async () => {
+4
View File
@@ -9,6 +9,10 @@ export const skillModuleCopies = [
"packages/cli/src/media-use/lib/media-fetch.mjs",
["skills/media-use/scripts/lib/media-fetch.mjs"],
],
[
"packages/cli/src/media-use/lib/openverse.mjs",
["skills/media-use/audio/scripts/lib/openverse.mjs"],
],
[
"skills/media-use/audio/scripts/lib/bgm-volume.mjs",
workflows.map((skill) => `skills/${skill}/scripts/lib/bgm-volume.mjs`),
+6 -6
View File
@@ -6,7 +6,7 @@
"files": 145
},
"faceless-explainer": {
"hash": "395653774d035d3a",
"hash": "47a0783444abc45a",
"files": 29
},
"figma": {
@@ -54,8 +54,8 @@
"files": 1
},
"media-use": {
"hash": "c4c3c723aa04bb7d",
"files": 112
"hash": "2816313a2f72e636",
"files": 113
},
"motion-graphics": {
"hash": "7e4af0881390ac2e",
@@ -66,11 +66,11 @@
"files": 169
},
"pr-to-video": {
"hash": "5e69368e66a27179",
"hash": "c9a397a3d1ccbbfe",
"files": 35
},
"product-launch-video": {
"hash": "14d03406b4fc6904",
"hash": "a705b9232e91f51f",
"files": 37
},
"remotion-to-hyperframes": {
@@ -78,7 +78,7 @@
"files": 77
},
"slideshow": {
"hash": "efc3c0be622fa9a1",
"hash": "873bc459cc9c200b",
"files": 2
},
"talking-head-recut": {
+1 -1
View File
@@ -7,7 +7,7 @@ description: "Turn arbitrary text — an article, notes, a topic, a brief — in
> **First, keep this skill fresh — confirm with the user before running:** `npx hyperframes skills update faceless-explainer`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them.
> **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. When the host app provides its own music or sound-effect tools, use those for music and sound effects.
> **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/images from the HeyGen catalog, SFX from Openverse, and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. When the host app provides its own music or sound-effect tools, use those for music and sound effects.
# Faceless Explainer to HyperFrames
@@ -117,6 +117,7 @@ function toProductLaunchMeta(neutral) {
offset_s: s.offset_s ?? 0,
duration_s: s.duration_s ?? 1,
volume: s.volume ?? 0.35,
...(s.license && { license: s.license, credit: s.credit }),
}));
return { bgm, bgm_pending: bgmPending, voices, sfx };
}
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: media-use
description: Agent Media OS for a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, voice, color grade, or LUT into a frozen local file or paste-ready block + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Also use for vague feedback that real footage looks dark, flat, boring, should feel retro/camcorder/print/ASCII, needs privacy, or needs a media reveal. When the host app provides its own music or sound-effect tools, use those for music and sound effects; `resolve --type bgm|sfx` needs the heygen CLI. When `HEYGEN_API_BASE` is set, HeyGen calls go through that host with no CLI sign-in.
description: Agent Media OS for a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, voice, color grade, or LUT into a frozen local file or paste-ready block + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Also use for vague feedback that real footage looks dark, flat, boring, should feel retro/camcorder/print/ASCII, needs privacy, or needs a media reveal. When the host app provides its own music or sound-effect tools, use those for music and sound effects; `resolve --type bgm` needs the heygen CLI, while `--type sfx` needs no account (Openverse, then a bundled library). When `HEYGEN_API_BASE` is set, HeyGen calls go through that host with no CLI sign-in.
---
**Plugin installs:** Before setup or freshness commands, follow [plugin execution rules](../hyperframes/references/plugin-installation.md) when this skill is inside a HyperFrames plugin. Standalone installs keep the update instructions below.
@@ -13,7 +13,7 @@ Only when `HEYGEN_API_BASE` is set in your environment (a host app set it and pa
First run otherwise (no `HEYGEN_API_BASE`), when you will use HeyGen media (catalog search, TTS, avatar video): install and sign in to the `heygen` CLI (the free-usage path), then verify with `npx hyperframes media-use resolve --doctor`. Setup and providers: `references/setup-providers.md`.
Music and sound effects inside a host app: when the app you run in gives you its own music or sound-effect tools, use those. Without `HEYGEN_API_BASE`, `resolve --type bgm` and `--type sfx` search the HeyGen catalog through the `heygen` CLI; without it they fail and say so (`sfx` still answers from its bundled library).
Music and sound effects inside a host app: when the app you run in gives you its own music or sound-effect tools, use those. Without `HEYGEN_API_BASE`, `resolve --type bgm` searches the HeyGen catalog through the `heygen` CLI and fails without it, saying so. `resolve --type sfx` searches Openverse (CC0 and CC BY only, no account) and falls back to its bundled library offline; credit CC BY sounds as `audio/references/sfx.md` says.
Without `HEYGEN_API_BASE`, before generating a voiceover or an avatar video, tell the person: signing in to the heygen CLI with OAuth (`heygen auth login --oauth`) gives a free allowance for TTS voiceover and avatar videos, while an API key bills API credits.
@@ -28,7 +28,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`. All search no
| Type | One-line intent |
| ------- | -------------------------------------------------------------------------------- |
| `bgm` | background music (HeyGen catalog via the `heygen` CLI, 10k+ tracks) |
| `sfx` | sound effects (bundled 19-file library + catalog via the `heygen` CLI) |
| `sfx` | sound effects (Openverse CC0 / CC BY, bundled 21-file library offline) |
| `image` | photos, backgrounds (HeyGen asset search, 75k+ vectors) |
| `icon` | icons, symbols (transparent) |
| `logo` | official brand marks (theSVG → GitHub avatar → favicon; never redrawn) |
@@ -4,13 +4,13 @@
Run `npx hyperframes auth status` to see what's configured and which engines a workflow will use (see the skill's **Preflight** section). Keys resolve in this order — **first match wins**:
| Provider | Resolution order (first non-empty wins) | Local deps when used |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| **HeyGen** (TTS + BGM/SFX retrieval) | `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; `$HEYGEN_CONFIG_DIR` overrides the dir; written by `hyperframes auth login`) | none (REST) |
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
| Provider | Resolution order (first non-empty wins) | Local deps when used |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; `$HEYGEN_CONFIG_DIR` overrides the dir; written by `hyperframes auth login`) | none (REST) |
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both are tagged with `X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance for HeyGen TTS (10 min/month); API keys follow the normal API billing path. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.
+19 -13
View File
@@ -1,35 +1,41 @@
# Sound effects (SFX)
Named sound effects, produced by the shared audio engine (`scripts/audio.mjs` → `scripts/lib/sfx.mjs`). **Provider-gated** by the engine's one switch — whether a HeyGen credential is present, decided once (not per cue):
Named sound effects, produced by the shared audio engine (`scripts/audio.mjs` → `scripts/lib/sfx.mjs`). No account or key is needed:
- **HeyGen credential present → retrieve every cue** from HeyGen's audio library (`/v3/audio/sounds`, `type=sound_effects`, `min_score=0.4`). Search-and-download, **not** generation. The bundled library is NOT consulted.
- **No credential → the bundled 21-file library** (`assets/sfx/` + `manifest.json`): match each cue name, copy the matched file into the project. Offline, deterministic, free.
- **Openverse first, per cue.** Each cue is searched on [Openverse](https://openverse.org)'s free audio API (most sound effects come from Freesound). Search-and-download, **not** generation.
- **The bundled 21-file library is the fallback** (`assets/sfx/` + `manifest.json`): a cue Openverse has no usable match for, and every cue once Openverse is offline or rate limited, is matched against it and the file copied into the project.
There is no `npx hyperframes sfx` command. SFX is never generated — it is retrieved (online) or taken from the bundled library (offline).
There is no `npx hyperframes sfx` command. SFX is never generated — it is retrieved from Openverse (online) or taken from the bundled library (offline). `npx hyperframes media-use resolve --type sfx` follows the same order (`--local-only` skips Openverse).
## Licences
Only **CC0** and **CC BY** sounds are used: never NonCommercial, ShareAlike or NoDerivatives. Each CC BY sound is credited in the project's `assets/sfx/CREDITS.md` (title, creator, licence, source link; for `resolve`, in a `CREDITS.md` beside the sound files it saves) and its `audio_meta.sfx[]` entry carries `license` and `credit`. Tell the person to include those credits wherever the video is published.
## Cues — request → meta
Each line names the effects it wants: `lines[].sfx: ["whoosh", "ui click"]`. The engine flattens these into cues, resolves them per the switch, dedupes identical `(id, name)` pairs (the same effect named twice downloads/copies once), and writes `audio_meta.sfx[]`:
Each line names the effects it wants: `lines[].sfx: ["whoosh", "ui click"]`. The engine flattens these into cues, resolves each, dedupes identical `(id, name)` pairs (the same effect named twice downloads/copies once), and writes `audio_meta.sfx[]`:
```jsonc
{
"id": "3", // joins the cue to the caller's model (frame / scene / segment)
"name": "whoosh",
"file": "assets/sfx/whoosh.mp3", // downloaded or copied, relative to project root
"source": "heygen" | "local", // which route resolved it
"source": "openverse" | "local", // which route resolved it
"offset_s": 0, // delay from the line's start
"duration_s": 0.57,
"volume": 0.35 // SFX sit UNDER voice + BGM
"volume": 0.35, // SFX sit UNDER voice + BGM
"license": "by", // Openverse only: "cc0" or "by"
"credit": "\"Swoosh\" by Ana is licensed under CC BY 4.0. …" // Openverse only
}
```
A cue that matches nothing is **skipped** (recorded as an anomaly); SFX never blocks a render. Neither route replaces a file of yours already at the output name: the cue gets the next free name (`whoosh-2.mp3`), reported as an anomaly, and `file` carries the real path.
## HeyGen retrieval (credentialed)
## Openverse
`searchSounds(name, "sound_effects", { limit: 3, minScore: 0.4 })` → top hit → `assets/sfx/<slug>.mp3`. Results are ranked by `score` (each carries a presigned `audio_url`, `duration`, `description`). The floor is **0.4** because good SFX hits score ~0.5–0.67 — below the API's default `0.7`, which would silently drop most named cues (only whoosh/swoosh-family clears 0.7). `duration_s` comes from the result (else 1.0). Name effects concretely (`glass shatter`, not `dramatic sound`); a vague query returns a poor match.
One search per distinct effect name (20 results, `license=cc0,by`; each result's licence is checked again). The pick keeps Openverse's relevance order, prefers clips that fit a cue (15 s at most; past ~4 s ranks lower) and CC0 over CC BY when they are close. The file lands at `assets/sfx/<slug>.<ext>` and `duration_s` comes from the result. Name effects concretely (`glass shatter`, not `dramatic sound`); a vague query returns a poor match. Anonymous use allows 20 searches a minute and 200 a day: past that, the rest of the run uses the bundled library.
## Bundled library (no credential)
## Bundled library (fallback)
21 curated files in `assets/sfx/`, indexed by `manifest.json` — `{ file, duration, description }` per key (e.g. `whoosh`, `pop`, `click`, `chime`, `riser`, `impact-bass-1`, `glitch-1`, `typing`, …). A cue name resolves by **manifest key, file basename, or slug**, so `whoosh`, `whoosh.mp3`, or `"ui click"` (→ slug) all match. Matched files are copied into the project's `assets/sfx/`; `duration_s` comes from the manifest, so timing is known **offline** — e.g. `riser` is 10.03s, so trigger it at `climax − 10.03s`. The manifest's `description` field carries placement hints per effect; read `assets/sfx/manifest.json` for the full set and usage.
@@ -37,6 +43,6 @@ A cue that matches nothing is **skipped** (recorded as an anomaly); SFX never bl
- **Volume ~0.35.** SFX must sit under narration and BGM, not fight them.
- **No match → skip, don't fail.** A missing effect logs an anomaly and moves on; never a render blocker.
- **Retrieval (credentialed) or bundled library (offline) — never generation.** You search HeyGen by text, or match a name against the 21-file manifest.
- **One asset per distinct name.** Reuse across lines is deduped to a single download/copy, many cues.
- **The switch is global, not per cue.** With a credential, retrieval handles even the long tail (effects not in the 21); without one, only the 21 bundled names resolve.
- **Retrieval (Openverse) or bundled library (offline) — never generation.**
- **One asset per distinct name.** Reuse across lines is deduped to a single search and download, many cues.
- **Credit CC BY sounds.** `CREDITS.md` lists them; keep it with the project.
+9 -9
View File
@@ -6,12 +6,12 @@
//
// node <MEDIA_DIR>/scripts/audio.mjs --request ./audio_request.json --hyperframes . --out ./audio_meta.json
//
// The three capabilities degrade on ONE switch — whether HeyGen is configured
// (credential present, NOT the CLI). This mirrors the table in ../SKILL.md:
// TTS and BGM degrade on ONE switch — whether HeyGen is configured (credential
// present, NOT the CLI). SFX needs no credential. This mirrors ../SKILL.md:
//
// TTS : HeyGen REST → ElevenLabs → Kokoro (CLI)
// BGM : HeyGen retrieve → (no credential) Lyria/MusicGen generate
// SFX : HeyGen retrieve → (no credential) bundled 19-file library
// SFX : Openverse (CC0 / CC BY) → (offline, rate limited, no match) bundled library
//
// ── audio_request.json (input) ────────────────────────────────────────────────
// {
@@ -31,7 +31,7 @@
// bgm: { path, volume, mode, query?, duration_s? } | null,
// bgm_pending, bgm_provider, bgm_pid, bgm_log, bgm_mode, bgm_target_duration_s, …,
// voices: [ { id, path, duration_s, words: [{id,text,start,end}] } ],
// sfx: [ { id, name, file, source, offset_s, duration_s, volume } ],
// sfx: [ { id, name, file, source, offset_s, duration_s, volume, license?, credit? } ],
// total_duration_s }
//
// --only tts,bgm,sfx runs a subset and MERGES into an existing --out (so a
@@ -52,7 +52,7 @@ import {
withWordIds,
} from "./lib/tts.mjs";
import { generateBgmDetached, inferBgmPrompt, retrieveBgm } from "./lib/bgm.mjs";
import { resolveSfx } from "./lib/sfx.mjs";
import { BUNDLED_SFX_DIR, resolveSfx } from "./lib/sfx.mjs";
import { mapWithConcurrency } from "./lib/concurrency.mjs";
import { openAudioMeta } from "./lib/audio-meta.mjs";
import { recordInManifest, voicePaths, writtenAssets } from "./lib/media-record.mjs";
@@ -83,7 +83,7 @@ const ttsConcurrency = Math.max(1, Number(process.env.HYPERFRAMES_TTS_CONCURRENC
const hyperframesDir = resolve(flag("hyperframes", "."));
const requestPath = resolve(flag("request", join(hyperframesDir, "audio_request.json")));
const outPath = resolve(flag("out", join(hyperframesDir, "audio_meta.json")));
const sfxLibDir = resolve(flag("sfx-lib", join(HERE, "..", "assets", "sfx")));
const sfxLibDir = resolve(flag("sfx-lib", BUNDLED_SFX_DIR));
const lyriaRecipe = resolve(flag("lyria-recipe", join(HERE, "lyria-recipe.py")));
const onlyArg = flag("only", "tts,bgm,sfx");
const only = new Set(
@@ -275,12 +275,12 @@ if (only.has("sfx")) {
.map((name) => ({ id: String(l.id), name: String(name).trim() }))
.filter((c) => c.name),
);
const headers = heygenOK && cues.length ? heygenAuthHeaders() : null;
const res = await resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir });
const res = await resolveSfx({ cues, hyperframesDir, sfxLibDir });
sfx = res.sfx;
anomalies.push(...res.anomalies);
const openverse = sfx.filter((s) => s.source === "openverse").length;
console.error(
`· sfx: ${sfx.length} cue(s) resolved (${heygenOK ? "heygen retrieval" : "bundled library"})`,
`· sfx: ${sfx.length} cue(s) resolved (${openverse} Openverse, ${sfx.length - openverse} bundled library)`,
);
}
+17 -10
View File
@@ -10,7 +10,11 @@ import { resolveSfx } from "./lib/sfx.mjs";
// Proves the relocated engine (skills/media-use/audio/) still resolves its
// bundled SFX library from the moved location — the path most likely to break
// on a subtree move. Offline (heygenOK:false), no network.
// on a subtree move. Openverse is unreachable unless a test serves it.
const offline = async () => {
throw new TypeError("fetch failed");
};
globalThis.fetch = offline;
const HERE = dirname(fileURLToPath(import.meta.url));
const sfxLibDir = join(HERE, "..", "assets", "sfx"); // same offset the engine uses
@@ -21,7 +25,6 @@ test("bundled SFX library resolves from the relocated path", async () => {
try {
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: dir,
sfxLibDir,
});
@@ -39,7 +42,6 @@ test("an unknown cue is reported, not fatal", async () => {
try {
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "1", name: "definitely-not-a-real-sfx" }],
heygenOK: false,
hyperframesDir: dir,
sfxLibDir,
});
@@ -59,7 +61,6 @@ test("a person's own file under a bundled name survives, and the cue gets the ne
const resolve = () =>
resolveSfx({
cues: [{ id: "1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: dir,
sfxLibDir,
});
@@ -89,18 +90,24 @@ test("two effects never share a file when one's name is taken by the person, run
try {
mkdirSync(join(dir, "assets", "sfx"), { recursive: true });
writeFileSync(join(dir, "assets", "sfx", "glitch.mp3"), "the person's own glitch");
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const query = new URL(url).searchParams.get("query");
const query = new URL(url).searchParams.get("q");
if (query)
return Response.json({ data: [{ audio_url: `https://sound.test/${query}`, score: 0.6 }] });
return Response.json({
results: [
{
license: "cc0",
filetype: "mp3",
duration: 500,
url: `https://sound.test/${encodeURIComponent(query)}`,
},
],
});
return new Response(`bytes of ${new URL(url).pathname}`);
};
const run = async (names) => {
const { sfx } = await resolveSfx({
cues: names.map((name, index) => ({ id: String(index), name })),
heygenOK: true,
headers: {},
hyperframesDir: dir,
sfxLibDir,
});
@@ -119,7 +126,7 @@ test("two effects never share a file when one's name is taken by the person, run
"assets/sfx/glitch-2.mp3",
]);
} finally {
globalThis.fetch = realFetch;
globalThis.fetch = offline;
}
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -43,7 +43,12 @@ export function voicePaths(hyperframesDir, lines, anomalies) {
return paths;
}
const SFX_SOURCES = { heygen: "search", local: "bundled" };
// A cue's source as the manifest records it, and who provided it. A source missing here records as the person's
// file, so every rerun would write a numbered copy beside it.
const SFX_SOURCES = {
openverse: { source: "search", provider: "openverse.audio" },
local: { source: "bundled", provider: "bundled.sfx" },
};
// The files one engine run wrote, each with how it was made, for the project's media manifest.
export function writtenAssets({ only, lines, voices, ttsProvider, bgm, bgmFields, sfx }) {
@@ -73,8 +78,7 @@ export function writtenAssets({ only, lines, voices, ttsProvider, bgm, bgmFields
}
if (only.has("sfx")) {
for (const cue of new Map(sfx.map((entry) => [entry.file, entry])).values()) {
const source = SFX_SOURCES[cue.source];
const provider = source === "search" ? "heygen" : "bundled.sfx";
const { source, provider } = SFX_SOURCES[cue.source];
assets.push({
path: cue.file,
type: "sfx",
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findByPrompt, readManifest } from "../../../scripts/lib/manifest.mjs";
@@ -97,7 +97,7 @@ test("music and sound effects are marked by where they came from", () => {
sfx: [
{ file: "assets/sfx/whoosh.mp3", name: "whoosh", source: "local" },
{ file: "assets/sfx/whoosh.mp3", name: "whoosh", source: "local" },
{ file: "assets/sfx/glass.mp3", name: "glass", source: "heygen" },
{ file: "assets/sfx/glass.mp3", name: "glass", source: "openverse" },
],
});
@@ -106,11 +106,30 @@ test("music and sound effects are marked by where they came from", () => {
[
["assets/bgm/track.mp3", "search", undefined],
["assets/sfx/whoosh.mp3", "bundled", "bundled.sfx"],
["assets/sfx/glass.mp3", "search", "heygen"],
["assets/sfx/glass.mp3", "search", "openverse.audio"],
],
);
});
test("a rerun of an Openverse sound effect writes over its own file, not a numbered copy", (t) => {
const dir = project(t);
mkdirSync(join(dir, "assets/sfx"), { recursive: true });
const anomalies = [];
for (let run = 0; run < 3; run++) {
const file = agentWritePath(dir, "assets/sfx/door-knock.mp3", { anomalies });
writeFileSync(join(dir, file), "knock");
const sfx = [{ file, name: "door knock", source: "openverse", duration_s: 1 }];
recordInManifest(
dir,
writtenAssets({ only: new Set(["sfx"]), lines: [], voices: [], sfx, ...noBgm }),
);
}
assert.deepEqual(readdirSync(join(dir, "assets/sfx")), ["door-knock.mp3"]);
assert.deepEqual(anomalies, []);
assert.equal(readManifest(dir).length, 1);
});
test("music made locally is marked generated once it is ready, not while pending", () => {
const written = (bgm_pending) =>
writtenAssets({
@@ -0,0 +1,134 @@
// Sound effects from Openverse (https://api.openverse.org): free, no key.
// Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall
// back to the bundled library when it is unavailable.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/";
// The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives.
export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]);
const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]);
const MAX_SFX_MS = 15000;
const IDEAL_SFX_MS = 4000;
const BY_PENALTY = 2;
const SEARCH_TIMEOUT_MS = 10000;
// Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded.
const oneLine = (text) =>
String(text ?? "")
.replace(/[\p{Cc}`[\]<>\\]+/gu, " ")
.trim()
.slice(0, 300);
// Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed.
const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]);
function httpsUrl(url, hosts) {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname))
? oneLine(parsed.href)
: "";
} catch {
return "";
}
}
/** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */
export class OpenverseUnavailableError extends Error {
constructor(message) {
super(message);
this.name = "OpenverseUnavailableError";
}
}
function usable(result) {
return (
OPENVERSE_SFX_LICENSES.has(result?.license) &&
PLAYABLE_FILETYPES.has(result.filetype) &&
typeof result.url === "string" &&
result.url.startsWith("https://") &&
Number.isFinite(result.duration) &&
result.duration > 0 &&
result.duration <= MAX_SFX_MS
);
}
// Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY.
function penalty(result, position) {
const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000;
return position + longBy + (result.license === "by" ? BY_PENALTY : 0);
}
/** Best usable sound in Openverse's relevance-ordered results, or null. */
export function pickOpenverseSfx(results) {
let best = null;
results.forEach((result, position) => {
if (!usable(result)) return;
const score = penalty(result, position);
if (!best || score < best.score) best = { result, score };
});
if (!best) return null;
const r = best.result;
return {
id: r.id,
url: r.url,
ext: `.${r.filetype}`,
title: oneLine(r.title),
creator: oneLine(r.creator),
license: r.license,
licenseUrl: httpsUrl(r.license_url),
sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS),
credit: oneLine(r.attribution),
durationS: Math.round(r.duration) / 1000,
};
}
/** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */
export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) {
const params = new URLSearchParams({
q: query,
page_size: "20",
license: [...OPENVERSE_SFX_LICENSES].join(","),
});
let response;
try {
response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
});
} catch (error) {
throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`);
}
if (response.status === 429)
throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)");
if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`);
let body;
try {
body = await response.json();
} catch {
throw new OpenverseUnavailableError("Openverse returned non-JSON");
}
return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []);
}
/** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */
export function writeSfxCredit(creditsPath, file, sound) {
if (sound.license !== "by") return;
// The sound may come from a saved record, not this run's pick: clean it again here.
const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS);
const source = sourceUrl ? ` Source: ${sourceUrl}` : "";
const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`;
let prior = "";
try {
prior = readFileSync(creditsPath, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const kept = prior
.split("\n")
.filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`));
const header =
"# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n";
mkdirSync(dirname(creditsPath), { recursive: true });
writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`);
}
+120 -89
View File
@@ -1,23 +1,26 @@
// sfx.mjs — sound effects for the media audio engine. Provider-gated (NOT a
// per-cue merge): the decision is made once, by whether HeyGen is configured —
// mirroring how TTS and BGM degrade.
//
// HeyGen credential present → retrieve EVERY cue from HeyGen's audio library
// (/v3/audio/sounds, type=sound_effects, min_score=0.4). The bundled
// library is NOT consulted.
// HeyGen credential absent → resolve cues against the bundled 21-file
// library (assets/sfx/manifest.json), copying matched files into the
// project. Offline, deterministic, free.
// sfx.mjs — sound effects for the media audio engine. Each cue is searched on
// Openverse (CC0 / CC BY only, see openverse.mjs). A cue Openverse cannot answer,
// and every cue after Openverse is unreachable or rate limited, resolves against
// the bundled 21-file library (assets/sfx/manifest.json) instead.
//
// A cue that matches nothing is skipped (recorded as an anomaly); SFX never
// blocks a render. Every cue sits at volume ~0.35, under voice + BGM.
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { downloadTo, searchSounds } from "./heygen.mjs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { downloadTo } from "./heygen.mjs";
import { OpenverseUnavailableError, searchOpenverseSfx, writeSfxCredit } from "./openverse.mjs";
import { latestRecordFor } from "../../../scripts/lib/manifest.mjs";
import { agentWritePath } from "./media-record.mjs";
export const BUNDLED_SFX_DIR = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"assets",
"sfx",
);
const SFX_VOLUME = 0.35;
const slug = (s) =>
s
@@ -27,14 +30,42 @@ const slug = (s) =>
.slice(0, 40) || "x";
const r3 = (x) => Number(x.toFixed(3));
// Lookups by manifest key, file basename, and slug of either, so a cue can name
// "whoosh", "whoosh.mp3", or "ui click" (→ slug match). { error } when unusable.
function loadBundledLibrary(sfxLibDir) {
const manifestPath = join(sfxLibDir, "manifest.json");
if (!existsSync(manifestPath)) return { error: `no bundled SFX library at ${sfxLibDir}` };
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch (e) {
return { error: `bundled SFX manifest parse failed (${e.message})` };
}
const byKey = new Map();
for (const [key, entry] of Object.entries(manifest)) {
if (!entry?.file || !isFinite(entry.duration)) continue;
const rec = { key, file: entry.file, duration: entry.duration };
byKey.set(key, rec);
byKey.set(entry.file, rec);
byKey.set(slug(key), rec);
byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
}
return { byKey };
}
// cues: [{ id, name }] (id = the line/frame/scene the cue fires in). Returns
// { sfx: [{ id, name, file, source, offset_s, duration_s, volume }], anomalies }.
export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir }) {
// { sfx: [{ id, name, file, source, offset_s, duration_s, volume, license?, credit? }], anomalies }.
export async function resolveSfx({ cues, hyperframesDir, sfxLibDir = BUNDLED_SFX_DIR }) {
const sfx = [];
const anomalies = [];
const destDir = join(hyperframesDir, "assets", "sfx");
// Each effect's file this run: one effect named twice gets one file, and two effects never share one.
const fileFor = new Map();
const taken = () => new Set(fileFor.values());
// One Openverse sound per effect name this run, so a repeated cue costs no second request.
const openverseFor = new Map();
let openverseDown = false;
let library;
// Dedupe identical (id,name) cues — the same effect named twice in one line
// downloads/copies once.
@@ -46,112 +77,82 @@ export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxL
return true;
});
if (heygenOK) {
for (const { id, name } of uniq) {
async function fromOpenverse({ id, name }) {
const key = `openverse:${slug(name)}`;
let entry = openverseFor.get(key);
if (!entry) {
const sound = await searchOpenverseSfx(name);
if (!sound) return null;
const file = agentWritePath(hyperframesDir, `assets/sfx/${slug(name)}${sound.ext}`, {
anomalies,
taken: taken(),
});
try {
// SFX hits score low (~0.5–0.67), below the API's default 0.7 which
// silently drops most named cues — floor to 0.4. (BGM/music score high
// and keep the default.)
const results = await searchSounds(name, "sound_effects", headers, {
limit: 3,
minScore: 0.4,
});
if (!results.length) {
anomalies.push(`sfx "${name}" (id ${id}): no HeyGen match — skipped`);
continue;
}
const top = results[0];
const file =
fileFor.get(slug(name)) ??
agentWritePath(hyperframesDir, `assets/sfx/${slug(name)}.mp3`, {
anomalies,
taken: new Set(fileFor.values()),
});
await downloadTo(top.audio_url, join(hyperframesDir, file));
fileFor.set(slug(name), file);
sfx.push({
id,
name,
file,
source: "heygen",
offset_s: 0,
duration_s: typeof top.duration === "number" ? r3(top.duration) : 1.0,
volume: SFX_VOLUME,
});
await downloadTo(sound.url, join(hyperframesDir, file));
} catch (e) {
anomalies.push(`sfx "${name}" (id ${id}): retrieval failed — ${e.message}`);
anomalies.push(`sfx "${name}" (id ${id}): Openverse download failed — ${e.message}`);
return null;
}
fileFor.set(key, file);
writeSfxCredit(join(destDir, "CREDITS.md"), file, sound);
entry = {
file,
source: "openverse",
duration_s: r3(sound.durationS),
license: sound.license,
credit: sound.credit,
};
openverseFor.set(key, entry);
}
return { sfx, anomalies };
return { id, name, ...entry, offset_s: 0, volume: SFX_VOLUME };
}
// ── offline: bundled library ──
const manifestPath = join(sfxLibDir, "manifest.json");
if (!existsSync(manifestPath)) {
if (uniq.length)
anomalies.push(`no HeyGen credential and no SFX library at ${sfxLibDir} — all cues dropped`);
return { sfx, anomalies };
}
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch (e) {
anomalies.push(`SFX manifest parse failed (${e.message}) — all cues dropped`);
return { sfx, anomalies };
}
// Build lookups: by manifest key, by file basename, and by slug of either, so
// a cue can name "whoosh", "whoosh.mp3", or "ui click" (→ slug match).
const byKey = new Map();
for (const [key, entry] of Object.entries(manifest)) {
if (!entry?.file || !isFinite(entry.duration)) continue;
const rec = { key, file: entry.file, duration: entry.duration };
byKey.set(key, rec);
byKey.set(entry.file, rec);
byKey.set(slug(key), rec);
byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
}
mkdirSync(destDir, { recursive: true });
for (const { id, name } of uniq) {
function fromBundled({ id, name }) {
library ??= loadBundledLibrary(sfxLibDir);
if (library.error) {
anomalies.push(`sfx "${name}" (id ${id}): ${library.error} — skipped`);
return null;
}
const { byKey } = library;
const hit = byKey.get(name) ?? byKey.get(slug(name));
if (!hit) {
const known = [...new Set([...byKey.values()].map((v) => v.key))].slice(0, 8).join(", ");
anomalies.push(
`sfx "${name}" (id ${id}): not in bundled library — skipped (have: ${known}…)`,
);
continue;
return null;
}
mkdirSync(destDir, { recursive: true });
const src = join(sfxLibDir, hit.file);
const library = existsSync(src) ? readFileSync(src) : null;
const bytes = existsSync(src) ? readFileSync(src) : null;
const isLibraryCopy = (rel) =>
library &&
bytes &&
existsSync(join(hyperframesDir, rel)) &&
readFileSync(join(hyperframesDir, rel)).equals(library);
readFileSync(join(hyperframesDir, rel)).equals(bytes);
const destRel =
fileFor.get(hit.file) ??
agentWritePath(hyperframesDir, `assets/sfx/${hit.file}`, {
anomalies,
taken: new Set(fileFor.values()),
taken: taken(),
// An unrecorded copy of the library file is one an engine run made before the manifest had it.
reusable: (rel) => !latestRecordFor(hyperframesDir, rel) && isLibraryCopy(rel),
});
const dest = join(hyperframesDir, destRel);
// The bundled library may be incomplete: some installs of the skill ship
// manifest.json without the actual mp3s. Pushing an sfx entry that points at
// a file we never copied produces a dangling reference that silently drops
// downstream ("not on disk"). Surface it as a loud anomaly and skip the cue
// instead, so the audio_meta never references a missing file.
if (library) {
// Some installs of the skill ship manifest.json without the mp3s. An entry
// pointing at a file we never copied would silently drop downstream, so
// skip the cue with a loud anomaly instead.
if (bytes) {
if (!isLibraryCopy(destRel)) copyFileSync(src, dest);
} else if (!existsSync(dest)) {
anomalies.push(
`sfx "${name}" (id ${id}): bundled file ${hit.file} missing from the offline ` +
`library (${sfxLibDir}) — skipped. Reinstall the media-use skill to ` +
`restore assets/sfx/*.mp3, or configure a HeyGen credential for retrieval.`,
`restore assets/sfx/*.mp3.`,
);
continue;
return null;
} // else the engine's earlier copy at dest stands in for the file this install lacks
fileFor.set(hit.file, destRel);
sfx.push({
return {
id,
name,
file: destRel,
@@ -159,7 +160,37 @@ export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxL
offset_s: 0,
duration_s: r3(hit.duration),
volume: SFX_VOLUME,
});
};
}
for (const cue of uniq) {
let entry = null;
if (!openverseDown) {
try {
entry = await fromOpenverse(cue);
} catch (e) {
if (!(e instanceof OpenverseUnavailableError)) throw e;
openverseDown = true;
anomalies.push(`${e.message}: the remaining SFX cues use the bundled library`);
}
}
entry ??= fromBundled(cue);
if (entry) sfx.push(entry);
}
return { sfx, anomalies };
}
/**
* One sound effect for `query`, saved under the project's assets/sfx/ (the entry point for hosts).
* { sound: { file, source, duration_s, license?, credit? } | null, anomalies }.
*/
export async function findSoundEffect(query, { hyperframesDir, sfxLibDir = BUNDLED_SFX_DIR }) {
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "1", name: query }],
hyperframesDir,
sfxLibDir,
});
if (!sfx.length) return { sound: null, anomalies };
const { file, source, duration_s, license, credit } = sfx[0];
return { sound: { file, source, duration_s, ...(license && { license, credit }) }, anomalies };
}
+222 -12
View File
@@ -4,9 +4,18 @@ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync
import { join } from "node:path";
import { tmpdir } from "node:os";
import { appendRecord } from "../../../scripts/lib/manifest.mjs";
import { resolveSfx } from "./sfx.mjs";
import { BUNDLED_SFX_DIR, findSoundEffect, resolveSfx } from "./sfx.mjs";
// Offline (no HeyGen) SFX resolution: the bundled library may ship manifest.json
// No real network: each test serves Openverse's answers; by default it is unreachable.
const offline = async () => {
throw new TypeError("fetch failed");
};
let serve = offline;
const UNREACHABLE =
"Openverse unreachable (fetch failed): the remaining SFX cues use the bundled library";
globalThis.fetch = (url, init) => serve(String(url), init);
// Offline SFX resolution: the bundled library may ship manifest.json
// without the actual mp3s. The old code copied only when the source existed but
// pushed the sfx entry unconditionally — producing a dangling reference that
// silently dropped downstream ("not on disk"). These tests lock in the loud
@@ -37,7 +46,6 @@ test("offline: copies and references a present bundled file", async () => {
writeFileSync(join(libDir, "whoosh.mp3"), "ID3-fake-bytes");
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: projDir,
sfxLibDir: libDir,
});
@@ -45,7 +53,7 @@ test("offline: copies and references a present bundled file", async () => {
assert.equal(sfx[0].file, "assets/sfx/whoosh.mp3");
assert.equal(sfx[0].source, "local");
assert.ok(existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "mp3 copied into project");
assert.equal(anomalies.length, 0);
assert.deepEqual(anomalies, [UNREACHABLE]);
});
});
@@ -58,13 +66,12 @@ test("offline: a matched-but-missing bundled file yields an anomaly and NO dangl
);
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: projDir,
sfxLibDir: libDir,
});
assert.equal(sfx.length, 0, "no dangling entry for a file that was never copied");
assert.equal(anomalies.length, 1);
assert.match(anomalies[0], /missing from the offline library/);
assert.equal(anomalies.length, 2);
assert.match(anomalies[1], /missing from the offline library/);
assert.ok(!existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "nothing copied");
});
});
@@ -78,10 +85,9 @@ const whooshLibrary = (libDir, withFile) => {
};
const record = (projDir, source) =>
appendRecord(projDir, { id: "sfx_001", type: "sfx", path: "assets/sfx/whoosh.mp3", source });
const offline = (libDir, projDir) =>
const bundledOnly = (libDir, projDir) =>
resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: projDir,
sfxLibDir: libDir,
});
@@ -93,13 +99,13 @@ test("offline: the engine's earlier copy stands in for a library file this insta
writeFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "engine copy");
record(projDir, "bundled");
const { sfx, anomalies } = await offline(libDir, projDir);
const { sfx, anomalies } = await bundledOnly(libDir, projDir);
assert.deepEqual(
sfx.map(({ file }) => file),
["assets/sfx/whoosh.mp3"],
);
assert.deepEqual(anomalies, []);
assert.deepEqual(anomalies, [UNREACHABLE]);
});
});
@@ -110,7 +116,7 @@ test("offline: a person's file with the library's bytes stays theirs once record
writeFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "ID3-library-bytes");
record(projDir, "existing");
const { sfx } = await offline(libDir, projDir);
const { sfx } = await bundledOnly(libDir, projDir);
assert.deepEqual(
sfx.map(({ file }) => file),
@@ -119,3 +125,207 @@ test("offline: a person's file with the library's bytes stays theirs once record
assert.equal(readFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "utf8"), "ID3-library-bytes");
});
});
const libraryWith = (libDir, names) => {
const manifest = {};
for (const name of names) {
manifest[name] = { file: `${name}.mp3`, duration: 0.5 };
writeFileSync(join(libDir, `${name}.mp3`), `library ${name}`);
}
writeFileSync(join(libDir, "manifest.json"), JSON.stringify(manifest));
};
const sound = (overrides = {}) => ({
id: "ov-1",
title: "Swoosh",
creator: "Ana",
license: "by",
foreign_landing_url: "https://freesound.org/s/1",
url: "https://cdn.freesound.test/swoosh.mp3",
filetype: "mp3",
duration: 820,
attribution: '"Swoosh" by Ana is licensed under CC BY 4.0.',
...overrides,
});
// Openverse stand-in: `answer(query)` gives a search response; any other URL is the sound file.
function openverse(answer) {
const searches = [];
serve = async (url) => {
const query = new URL(url).searchParams.get("q");
if (!url.startsWith("https://api.openverse.org/")) return new Response(`bytes of ${url}`);
searches.push(query);
return answer(query);
};
return searches;
}
const run = (libDir, projDir, names) =>
resolveSfx({
cues: names.map((name, index) => ({ id: String(index + 1), name })),
hyperframesDir: projDir,
sfxLibDir: libDir,
});
test("openverse: each cue gets the best licensed sound, with its licence and credit", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["whoosh"]);
openverse(() => Response.json({ results: [sound()] }));
try {
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh"]);
assert.deepEqual(sfx, [
{
id: "1",
name: "whoosh",
file: "assets/sfx/whoosh.mp3",
source: "openverse",
duration_s: 0.82,
license: "by",
credit: '"Swoosh" by Ana is licensed under CC BY 4.0.',
offset_s: 0,
volume: 0.35,
},
]);
assert.deepEqual(anomalies, []);
assert.equal(
readFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "utf8"),
"bytes of https://cdn.freesound.test/swoosh.mp3",
);
assert.match(
readFileSync(join(projDir, "assets/sfx/CREDITS.md"), "utf8"),
/- `assets\/sfx\/whoosh\.mp3`: "Swoosh" by Ana is licensed under CC BY 4\.0\. Source: https:\/\/freesound\.org\/s\/1/,
);
} finally {
serve = offline;
}
});
});
test("openverse: a rate limit sends this and every later cue to the bundled library, asking once", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["whoosh", "pop"]);
const searches = openverse(() => new Response("slow down", { status: 429 }));
try {
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh", "pop"]);
assert.deepEqual(searches, ["whoosh"]);
assert.deepEqual(
sfx.map(({ file, source }) => [file, source]),
[
["assets/sfx/whoosh.mp3", "local"],
["assets/sfx/pop.mp3", "local"],
],
);
assert.equal(readFileSync(join(projDir, "assets/sfx/pop.mp3"), "utf8"), "library pop");
assert.deepEqual(anomalies, [
"Openverse rate limit reached (HTTP 429): the remaining SFX cues use the bundled library",
]);
} finally {
serve = offline;
}
});
});
test("openverse: a cue with no licensed match falls back to the bundled library alone", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["pop"]);
const searches = openverse((query) =>
Response.json({ results: query === "pop" ? [sound({ license: "by-nc" })] : [sound()] }),
);
try {
const { sfx } = await run(libDir, projDir, ["pop", "swoosh"]);
assert.deepEqual(searches, ["pop", "swoosh"]);
assert.deepEqual(
sfx.map(({ file, source }) => [file, source]),
[
["assets/sfx/pop.mp3", "local"],
["assets/sfx/swoosh.mp3", "openverse"],
],
);
} finally {
serve = offline;
}
});
});
test("openverse: a failed download falls back to the bundled file with an anomaly", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["whoosh"]);
serve = async (url) =>
url.startsWith("https://api.openverse.org/")
? Response.json({ results: [sound()] })
: new Response("gone", { status: 404 });
try {
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh"]);
assert.deepEqual(
sfx.map(({ file, source }) => [file, source]),
[["assets/sfx/whoosh.mp3", "local"]],
);
assert.match(anomalies[0], /Openverse download failed — download HTTP 404/);
} finally {
serve = offline;
}
});
});
test("openverse: one effect named in two lines is searched and downloaded once", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, []);
const searches = openverse(() => Response.json({ results: [sound({ license: "cc0" })] }));
try {
const { sfx } = await run(libDir, projDir, ["whoosh", "whoosh"]);
assert.deepEqual(searches, ["whoosh"]);
assert.deepEqual(
sfx.map(({ id, file }) => [id, file]),
[
["1", "assets/sfx/whoosh.mp3"],
["2", "assets/sfx/whoosh.mp3"],
],
);
assert.equal(
existsSync(join(projDir, "assets/sfx/CREDITS.md")),
false,
"CC0 needs no credit",
);
} finally {
serve = offline;
}
});
});
test("findSoundEffect: a host gets the saved file, licence, credit and duration for one query", async () => {
await withDirs(async ({ projDir }) => {
const searches = openverse(() => Response.json({ results: [sound()] }));
try {
assert.deepEqual(await findSoundEffect("whoosh", { hyperframesDir: projDir }), {
sound: {
file: "assets/sfx/whoosh.mp3",
source: "openverse",
duration_s: 0.82,
license: "by",
credit: '"Swoosh" by Ana is licensed under CC BY 4.0.',
},
anomalies: [],
});
assert.deepEqual(searches, ["whoosh"]);
} finally {
serve = offline;
}
});
});
test("findSoundEffect: offline, the skill's own bundled library answers", async () => {
await withDirs(async ({ projDir }) => {
const { sound, anomalies } = await findSoundEffect("whoosh", { hyperframesDir: projDir });
assert.deepEqual(sound, {
file: "assets/sfx/whoosh.mp3",
source: "local",
duration_s: sound.duration_s,
});
assert.deepEqual(
readFileSync(join(projDir, sound.file)),
readFileSync(join(BUNDLED_SFX_DIR, "whoosh.mp3")),
);
assert.deepEqual(anomalies, [UNREACHABLE]);
assert.equal(
(await findSoundEffect("no such sound at all", { hyperframesDir: projDir })).sound,
null,
);
});
});
+1 -1
View File
@@ -12,7 +12,7 @@ node <SKILL_DIR>/audio/scripts/audio.mjs --request ./audio_request.json --out ./
- **Request** `{ provider?, voice?, lang?, speed?, tts_model?, style?, lines: [{ id, text, style?, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`.
- **Gemini narration** is opt-in with `provider: "gemini"` and an API key or service-account credentials. `tts_model` defaults to `gemini-3.8-flash-tts`; 3.8 Flash-Lite, 3.1 Flash Preview, and 2.5 Pro/Flash Preview TTS are also supported. `style` directs delivery; a line's style overrides the request's style. CLI overrides: `--tts-model`, `--style`. See `audio/references/tts.md` for a complete request. The existing automatic provider order is unchanged.
- **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`.
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `npx hyperframes media-use resolve --doctor` before assuming retrieval or TTS will work.
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music retrieval; SFX come from Openverse with no account (`audio/references/sfx.md`). Local/provider-specific generators are explicit alternatives where installed; run `npx hyperframes media-use resolve --doctor` before assuming retrieval or TTS will work.
- If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render.
Single-shot helpers: `audio/scripts/heygen-tts.mjs` (one voice file). Transcription / background removal / captions use the `hyperframes` CLI (`transcribe`, `remove-background`), see the per-topic guides in `audio/references/` (`tts.md`, `bgm.md`, `sfx.md`, `transcribe.md`, `remove-background.md`, `captions/`).
+1 -1
View File
@@ -11,7 +11,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
| Type | What it finds | Provider / cascade |
| ------- | -------------------------------- | ------------------------------------------------------------ |
| `bgm` | Background music | HeyGen audio catalog (10k+ tracks) |
| `sfx` | Sound effects | Bundled 19-file library + HeyGen catalog |
| `sfx` | Sound effects | Openverse (CC0 / CC BY), then the bundled 21-file library |
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
| `logo` | Official brand marks | theSVG → GitHub org avatar → domain favicon |
+13 -12
View File
@@ -9,7 +9,7 @@ heygen update # free usage needs the OAuth-capable CLI (v0.3.0+)
heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits
```
This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with:
This unlocks the FREE path for bgm/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with:
```bash
npx hyperframes media-use resolve --doctor
@@ -41,7 +41,8 @@ in `references/operations.md`).
| Type | Provider / path |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| bgm/sfx | heygen catalog free-usage path; a host app's own music and sound tools come first |
| bgm | heygen catalog free-usage path; a host app's own music tools come first |
| sfx | Openverse (CC0 / CC BY, no account), bundled library offline; a host app's own sound tools come first |
| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell |
| voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
| icon | heygen asset search free-usage path |
@@ -67,21 +68,21 @@ HeyGen-only types, that means no fresh resolve.
## CLI tools used (what to run, and how to enable each)
`resolve` auto-cascades; each provider shells one CLI. HeyGen is the
free-usage path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar
free-usage path for bgm/image/icon catalog search, TTS (voice), and avatar
video, so those capabilities need `heygen` installed and authenticated. Local
tools are OPT-IN alternatives where they exist; install one to unlock its free,
private, on-device path instead of or ahead of HeyGen for that type. Only
`ffmpeg`/`ffprobe` are strictly required for the tool to run at all.
| Tool | Serves | Install |
| ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
| `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) |
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
| `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
| `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
| Tool | Serves | Install |
| ------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
| `heygen` | catalog (bgm/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) |
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
| `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
| `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
The RAM-graded local-model shortlist + exact per-tier install/invoke lives in
the CLI's bundled media-use engine, not in the standalone skill. Without a tool on PATH, its provider
+9 -6
View File
@@ -107,8 +107,7 @@ export function recordInPlace(
description: description || basename(path),
...(duration != null && { duration: Math.round(duration * 10) / 10 }),
};
mkdirSync(mediaDir(projectDir), { recursive: true });
return withLock(mediaDir(projectDir), () => {
return withMediaLock(projectDir, () => {
const latest = latestRecordFor(projectDir, path);
const same = ["source", "description", "duration"].every(
(key) => latest?.[key] === fields[key],
@@ -203,6 +202,12 @@ function withLock(dir, fn) {
}
}
/** Runs `fn` holding the project's `.media/.lock`, so concurrent resolves take turns on what it guards. */
export function withMediaLock(projectDir, fn) {
mkdirSync(mediaDir(projectDir), { recursive: true });
return withLock(mediaDir(projectDir), fn);
}
// Atomically allocate the next free id for `type` AND reserve it on disk, so a
// slow download/copy between allocation and appendRecord can't let a concurrent
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
@@ -210,10 +215,8 @@ function withLock(dir, fn) {
// O_EXCL-create an empty marker under core's atomic temp name, which Studio's
// project history skips. Returns { id, localPath, markerPath }.
export function allocateId(projectDir, type, ext) {
mkdirSync(mediaDir(projectDir), { recursive: true });
const typeDir = typeDirPath(projectDir, type);
mkdirSync(typeDir, { recursive: true });
return withLock(mediaDir(projectDir), () => {
mkdirSync(typeDirPath(projectDir, type), { recursive: true });
return withMediaLock(projectDir, () => {
const id = nextFreeId(projectDir, type);
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
// Same shape as core atomicTempPath: an empty file at the final name would be committed.
+1 -1
View File
@@ -7,7 +7,7 @@ description: "Turn a GitHub pull request (a PR URL, owner/repo#N, or 'this PR' i
> **First, keep this skill fresh — confirm with the user before running:** `npx hyperframes skills update pr-to-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them.
> **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. When the host app provides its own music or sound-effect tools, use those for music and sound effects.
> **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/images from the HeyGen catalog, SFX from Openverse, and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. When the host app provides its own music or sound-effect tools, use those for music and sound effects.
# PR to HyperFrames
+1
View File
@@ -117,6 +117,7 @@ function toProductLaunchMeta(neutral) {
offset_s: s.offset_s ?? 0,
duration_s: s.duration_s ?? 1,
volume: s.volume ?? 0.35,
...(s.license && { license: s.license, credit: s.credit }),
}));
return { bgm, bgm_pending: bgmPending, voices, sfx };
}
+1 -1
View File
@@ -7,7 +7,7 @@ description: "Turn a product or marketing URL, pasted script, or brief into a pr
> **First, keep this skill fresh — confirm with the user before running:** `npx hyperframes skills update product-launch-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them.
> **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. When the host app provides its own music or sound-effect tools, use those for music and sound effects.
> **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/images from the HeyGen catalog, SFX from Openverse, and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. When the host app provides its own music or sound-effect tools, use those for music and sound effects.
> **figma source**: If the source is a figma.com URL, run `/figma` first — asset export, brand tokens, and components/storyboard reconstruction if needed — then build this workflow from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import.
@@ -115,6 +115,7 @@ function toProductLaunchMeta(neutral) {
offset_s: s.offset_s ?? 0,
duration_s: s.duration_s ?? 1,
volume: s.volume ?? 0.35,
...(s.license && { license: s.license, credit: s.credit }),
}));
return { bgm, bgm_pending: bgmPending, voices, sfx };
}
@@ -875,7 +875,7 @@ Do NOT add a mute button inside the composition. The `#sfx-mute` coral button pa
</script>
```
**Sourcing SFX files:** use the HeyGen MCP `search_audio_sounds` tool with `type=sound_effects` and keywords like "whoosh", "click", "transition". Download the results to a local `sfx/` directory next to `demo.html` and reference them by relative path. Do not fetch SFX at render time — the HyperFrames determinism rule forbids runtime network requests; pre-download and commit them.
**Sourcing SFX files:** resolve each one with `npx hyperframes media-use resolve --type sfx --intent "<whoosh | click | transition>" --project .` (Openverse CC0 / CC BY, bundled library offline; CC BY credits land in a `CREDITS.md` beside the resolved files), copy the files to a local `sfx/` directory next to `demo.html` and reference them by relative path. Do not fetch SFX at render time — the HyperFrames determinism rule forbids runtime network requests; pre-download and commit them.
---