mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
The workflow skills, the CLI reference, and the guides described a vendor's services: a separate account to sign into, a credential file beside the project, a music library and asset search that are not ours, and preview media on a CDN we do not run. Auth is now one Hanzo IAM token in $HANZO_API_KEY, read from Hanzo KMS — one credential for voice, music, sound effects, images, video, the shared catalog, and cloud rendering. The credential guide, the cloud-render pages, and the CLI auth reference all say the same thing, because there is now only one thing to say. Docs previews resolve from the docs site's own /images/ path, produced by our own generator. They are not committed (docs/images/ is ignored), so a deploy must run `bun run generate:catalog-previews` first — the same gap as before, minus a CDN we cannot publish to. Element grounding calls zen-vl through api.hanzo.ai rather than a third-party vision endpoint, and the LUT library builds every look from params, so no look needs a host at all. Command examples read `npx @hanzo/frame` / `hanzo frame` throughout. Three navigation entries left dangling by the rebrand now resolve. The changelog and the weekly archive keep their entries as written and say plainly that pre-port entries describe the upstream product.
228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// audio.mjs — the shared Frames audio engine. ONE implementation of TTS +
|
|
// BGM + SFX for every video workflow (product-launch, general-video, pr-to-video,
|
|
// …). Workflows do NOT vendor a copy: they write a neutral `audio_request.json`
|
|
// (a tiny per-workflow adapter maps their storyboard/scenes into it) and call:
|
|
//
|
|
// node <MEDIA_DIR>/scripts/audio.mjs --request ./audio_request.json --frames . --out ./audio_meta.json
|
|
//
|
|
// All three capabilities go through api.hanzo.ai — one credential, one host:
|
|
//
|
|
// TTS : POST /v1/audio/speech, then /v1/audio/transcriptions for word timings
|
|
// BGM : media/bgm/ in our object store, else POST /v1/audio/music
|
|
// SFX : the 21 bundled files, else media/sfx/, else POST /v1/audio/foley
|
|
//
|
|
// ── audio_request.json (input) ────────────────────────────────────────────────
|
|
// {
|
|
// "lang": "en", "speed": 1.0,
|
|
// "voice": null, // a voice id the speech service accepts
|
|
// "lines": [ // one TTS unit each; id joins back to the caller's model
|
|
// { "id": "01", "text": "...", "sfx": ["whoosh", "ui click"] }
|
|
// ],
|
|
// "bgm": { "mode": "catalog", // catalog|compose|none (override: --bgm-mode / --no-bgm)
|
|
// "query": "calm cinematic underscore", // mood, and the catalog query
|
|
// "prompt": null, // full prompt for compose (else inferred)
|
|
// "blob": "...", "archetype": "...", "arc": "..." } // optional mood-inference hints
|
|
// }
|
|
//
|
|
// ── audio_meta.json (output, id-keyed) ───────────────────────────────────────
|
|
// { voice_id,
|
|
// bgm: { path, volume, mode, query?, duration_s? } | null, bgm_mode,
|
|
// voices: [ { id, path, duration_s, words: [{id,text,start,end}] } ],
|
|
// sfx: [ { id, name, file, source, offset_s, duration_s, volume } ],
|
|
// total_duration_s }
|
|
//
|
|
// --only tts,bgm,sfx runs a subset and MERGES into an existing --out (so a
|
|
// workflow can do TTS+BGM early, then SFX later once cues exist).
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { align, ffprobeDuration, ready, synthesize, withWordIds } from "./lib/tts.mjs";
|
|
import { fromCatalog, fromPrompt, inferBgmPrompt } from "./lib/bgm.mjs";
|
|
import { resolveSfx } from "./lib/sfx.mjs";
|
|
import { mapWithConcurrency } from "./lib/concurrency.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const argv = process.argv.slice(2);
|
|
const flag = (name, def) => {
|
|
const i = argv.indexOf(`--${name}`);
|
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
|
|
};
|
|
const has = (name) => argv.includes(`--${name}`);
|
|
const die = (m) => {
|
|
console.error(`✗ audio engine: ${m}`);
|
|
process.exit(1);
|
|
};
|
|
const r3 = (x) => Number(x.toFixed(3));
|
|
|
|
// Each line is two requests (speech, then alignment), so an unbounded
|
|
// Promise.all over a long script opens two connections per line at once and
|
|
// gets throttled — the whole batch then fails together rather than degrading.
|
|
// mapWithConcurrency caps how many run at once: still parallel, just bounded.
|
|
const ttsConcurrency = Math.max(1, Number(process.env.FRAMES_TTS_CONCURRENCY) || 4);
|
|
|
|
const framesDir = resolve(flag("frames", "."));
|
|
const requestPath = resolve(flag("request", join(framesDir, "audio_request.json")));
|
|
const outPath = resolve(flag("out", join(framesDir, "audio_meta.json")));
|
|
const sfxLibDir = resolve(flag("sfx-lib", join(HERE, "..", "assets", "sfx")));
|
|
const onlyArg = flag("only", "tts,bgm,sfx");
|
|
const only = new Set(
|
|
onlyArg
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
);
|
|
const bgmModeOverride = flag("bgm-mode", null);
|
|
const noBgm = has("no-bgm");
|
|
const voiceOverride = flag("voice", null);
|
|
const speedOverride = flag("speed", null);
|
|
const langOverride = flag("lang", null);
|
|
|
|
if (!existsSync(requestPath)) die(`audio_request.json not found at ${requestPath}`);
|
|
let request;
|
|
try {
|
|
request = JSON.parse(readFileSync(requestPath, "utf8"));
|
|
} catch (e) {
|
|
die(`audio_request.json parse: ${e.message}`);
|
|
}
|
|
const lines = Array.isArray(request.lines) ? request.lines : [];
|
|
const lang = langOverride || request.lang || "en";
|
|
const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0;
|
|
|
|
// ── credential (the single switch) ────────────────────────────────────────────
|
|
const online = ready();
|
|
if (!online)
|
|
console.error(
|
|
"· no HANZO_API_KEY — voice and generated audio are unavailable; bundled SFX still resolve",
|
|
);
|
|
|
|
// ── merge base: preserve sections not selected by --only ──────────────────────
|
|
const prev = existsSync(outPath) ? JSON.parse(readFileSync(outPath, "utf8")) : {};
|
|
const anomalies = [];
|
|
|
|
// ── TTS ───────────────────────────────────────────────────────────────────────
|
|
let voices = prev.voices ?? [];
|
|
let voiceId = prev.voice_id ?? null;
|
|
if (only.has("tts") && lines.length) {
|
|
if (!online) die("voice needs a Hanzo credential — export HANZO_API_KEY");
|
|
// No voice id means the speech service picks its own default. Pass one to
|
|
// choose; there is no second catalog here to keep in step with theirs.
|
|
voiceId = voiceOverride || request.voice || null;
|
|
console.error(`· tts: ${voiceId ?? "service default"} voice · ${lines.length} line(s)`);
|
|
const synthLine = async (line) => {
|
|
const id = String(line.id);
|
|
const text = String(line.text ?? "").trim();
|
|
if (!text) {
|
|
anomalies.push(`line ${id}: empty text — skipped`);
|
|
return null;
|
|
}
|
|
const rel = `assets/voice/${id}.wav`;
|
|
const abs = join(framesDir, rel);
|
|
const { ok, error } = await synthesize({ text, voice: voiceId, lang, speed, out: abs });
|
|
if (!ok) {
|
|
anomalies.push(`line ${id}: TTS failed — omitted${error ? ` (${error})` : ""}`);
|
|
return null;
|
|
}
|
|
const dur = ffprobeDuration(abs);
|
|
if (!isFinite(dur) || dur <= 0) {
|
|
anomalies.push(`line ${id}: bad voice duration — omitted`);
|
|
return null;
|
|
}
|
|
const words = await align({ file: abs, lang });
|
|
if (!words) anomalies.push(`line ${id}: no word timings returned — captions fall back to line timing`);
|
|
return { id, path: rel, duration_s: r3(dur), words: withWordIds(words) };
|
|
};
|
|
const results = await mapWithConcurrency(lines, ttsConcurrency, synthLine);
|
|
voices = results.filter(Boolean);
|
|
for (const v of voices)
|
|
console.error(` voice ${v.id}: ${v.path} (${v.duration_s}s, ${v.words.length} words)`);
|
|
}
|
|
const hasVoice = voices.length > 0;
|
|
const totalDuration = r3(voices.reduce((a, v) => a + (v.duration_s || 0), 0));
|
|
|
|
// ── BGM ─────────────────────────────────────────────────────────────────────
|
|
let bgm = prev.bgm ?? null;
|
|
let bgmMode = prev.bgm_mode ?? null;
|
|
if (only.has("bgm")) {
|
|
bgm = null;
|
|
bgmMode = null;
|
|
// An EXPLICIT mode (flag or request.bgm.mode) is honored as written: asking
|
|
// for the catalog and getting a generated track instead is a surprise the
|
|
// caller cannot see in the output. Only the unset default falls through.
|
|
const explicitMode = bgmModeOverride || request.bgm?.mode || null;
|
|
const mode = noBgm ? "none" : explicitMode || "auto";
|
|
const query = request.bgm?.query;
|
|
|
|
if (mode === "none" || !online) {
|
|
if (mode !== "none") anomalies.push("bgm: needs a Hanzo credential — skipped");
|
|
console.error(`· bgm: disabled`);
|
|
} else {
|
|
try {
|
|
const hit = mode === "compose" ? null : await fromCatalog({ query, framesDir, hasVoice });
|
|
if (hit?.path) {
|
|
bgm = hit;
|
|
bgmMode = "catalog";
|
|
console.error(` bgm: ${bgm.path} (catalog "${bgm.query}")`);
|
|
} else if (mode === "catalog") {
|
|
anomalies.push(
|
|
hit?.empty
|
|
? `bgm: the catalog holds no music yet (${hit.prefix} is empty) — skipped`
|
|
: `bgm: no catalog match for "${query ?? ""}" — skipped`,
|
|
);
|
|
} else {
|
|
if (hit?.empty)
|
|
anomalies.push(`bgm: the catalog holds no music yet (${hit.prefix}) — composing instead`);
|
|
const prompt = inferBgmPrompt({
|
|
userPrompt: request.bgm?.prompt,
|
|
blob: request.bgm?.blob || query,
|
|
archetype: request.bgm?.archetype,
|
|
arc: request.bgm?.arc,
|
|
});
|
|
bgm = await fromPrompt({ prompt, durationS: totalDuration || 30, framesDir, hasVoice });
|
|
bgmMode = "compose";
|
|
console.error(` bgm: ${bgm.path} (composed)`);
|
|
}
|
|
} catch (e) {
|
|
anomalies.push(`bgm failed: ${e.message} — skipped`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── SFX ─────────────────────────────────────────────────────────────────────
|
|
let sfx = prev.sfx ?? [];
|
|
if (only.has("sfx")) {
|
|
const cues = lines.flatMap((l) =>
|
|
(Array.isArray(l.sfx) ? l.sfx : [])
|
|
.map((name) => ({ id: String(l.id), name: String(name).trim() }))
|
|
.filter((c) => c.name),
|
|
);
|
|
const res = await resolveSfx({ cues, framesDir, sfxLibDir });
|
|
sfx = res.sfx;
|
|
anomalies.push(...res.anomalies);
|
|
console.error(`· sfx: ${sfx.length} cue(s) resolved`);
|
|
}
|
|
|
|
// ── write audio_meta.json ─────────────────────────────────────────────────────
|
|
const meta = {
|
|
voice_id: voiceId,
|
|
bgm,
|
|
bgm_mode: bgmMode,
|
|
voices,
|
|
sfx,
|
|
total_duration_s: totalDuration,
|
|
};
|
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
writeFileSync(outPath, JSON.stringify(meta, null, 2));
|
|
|
|
console.log(`✓ audio engine → ${outPath}`);
|
|
console.log(` ran: ${[...only].join(",")}`);
|
|
console.log(
|
|
` voices: ${voices.length} · bgm: ${bgm ? bgmMode : "none"} · sfx: ${sfx.length}`,
|
|
);
|
|
console.log(` total voice duration: ${totalDuration}s`);
|
|
if (anomalies.length) {
|
|
console.log(`\nanomalies (non-fatal):`);
|
|
for (const a of anomalies) console.log(` - ${a}`);
|
|
}
|