mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* feat(hyperframes-creative): add frame-preset library Add a library of ready-made visual frame presets (claude, biennale-yellow, blockframe, blue-professional, bold-poster, broadside, capsule, cartesian, cobalt-grid, coral, creative-mode, daisy-days, editorial-forest, …), each with a FRAME.md spec, a frame-showcase.html, and a per-preset caption-skin.html. Registered in the creative design-spec so workflows can remix a preset onto brand tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hyperframes-media): shared TTS/BGM/SFX audio engine Add a shared audio engine under hyperframes-media (scripts/audio.mjs + lib/ tts.mjs, bgm.mjs, sfx.mjs, heygen.mjs) plus a bundled SFX pack and manifest. Workflows resolve this engine by path (../../hyperframes-media/scripts/ audio.mjs) for text-to-speech, background music, and sound effects, so audio is authored once and reused across skills instead of duplicated per workflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(skills): gate render on user review; refresh router, core, general-video - hyperframes-cli: render is now user-gated — preview opens Studio (the timeline editor where the user can hand-edit anything, not just watch); never auto-render once checks pass, pause at preview and render only after approval. - hyperframes (router): tighten the entry SKILL.md description + routing. - hyperframes-core: rewrite SKILL.md and add script-format.md + storyboard-format.md references for the script-driven authoring architecture. - general-video: tidy the fallback-workflow description and routing table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(hyperframes-creative): reformat frame-preset showcase HTML Run the HTML formatter over the frame-showcase.html files (indentation, self-closing void tags, one CSS declaration per line). Formatting only — no content or markup changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hyperframes-media): correct wait-bgm field mapping and guard credential parse Two correctness fixes from review (#1632): - wait-bgm.mjs read audioMeta.bgm_path / audioMeta.bgm_enabled, but audio.mjs writes the path nested as bgm.path and the flag as bgm_pending. The detached generate path (Lyria/MusicGen) therefore always saw an empty path and exited status: disabled, silently dropping the music track even while generation was running. Read audioMeta.bgm?.path and gate on bgm_pending. - heygenCredential() had an unguarded JSON.parse despite documenting that it never throws — a malformed ~/.heygen credentials file crashed the engine at startup instead of degrading to no-credential. Wrap the parse and return null. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(hyperframes): add router tag to entry skill metadata Fold the router metadata tag into the foundation rewrite of the entry SKILL.md. This file is owned by this PR (the full router rewrite); keeping the tag tweak here — instead of a separate edit on the pre-rewrite version in another PR — avoids a guaranteed merge conflict between the two. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.2 KiB
JavaScript
Executable File
122 lines
4.2 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Self-contained HeyGen TTS — single text in → one wav (+ optional words JSON)
|
|
// out. A thin CLI over lib/tts.mjs (the same code the audio engine uses), so the
|
|
// HeyGen REST call, starfish voice pick, mp3→wav transcode, and word-timestamp
|
|
// filtering live in exactly one place. Bypasses the `hyperframes` CLI, which in
|
|
// the published build is Kokoro-only.
|
|
//
|
|
// Usage:
|
|
// node heygen-tts.mjs "Text to speak" -o narration.wav [--words narration.words.json]
|
|
// node heygen-tts.mjs ./script.txt -o narration.wav --words narration.words.json
|
|
// node heygen-tts.mjs "Bonjour" -o fr.wav --lang fr --voice <id>
|
|
// node heygen-tts.mjs --list # list starfish voices and exit
|
|
//
|
|
// Flags: -o/--output (.wav → ffmpeg transcode; .mp3 → raw bytes), --words,
|
|
// --voice (starfish id), --speed, --lang, --list.
|
|
// Requires: $HEYGEN_API_KEY (or ~/.heygen) and ffmpeg for .wav output.
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { heygenAuthHeaders, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs";
|
|
import { ffprobeDuration, resolveVoiceId, synthesizeOne, withWordIds } from "./lib/tts.mjs";
|
|
|
|
const argv = process.argv.slice(2);
|
|
function flag(name, def) {
|
|
const i = argv.indexOf(`--${name}`);
|
|
if (i < 0) return def;
|
|
if (i + 1 >= argv.length) return true;
|
|
const v = argv[i + 1];
|
|
return v.startsWith("--") ? true : v;
|
|
}
|
|
const die = (m) => {
|
|
console.error(`✗ heygen-tts: ${m}`);
|
|
process.exit(1);
|
|
};
|
|
|
|
// First arg that isn't a flag or the -o value is the text / .txt path.
|
|
const positional = (() => {
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a.startsWith("--")) {
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith("--")) i++;
|
|
continue;
|
|
}
|
|
if (a === "-o") {
|
|
i++;
|
|
continue;
|
|
}
|
|
return a;
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
const output = resolve(
|
|
(typeof flag("output") === "string" && flag("output")) ||
|
|
(argv.includes("-o") && argv[argv.indexOf("-o") + 1]) ||
|
|
"narration.wav",
|
|
);
|
|
const wordsPath = typeof flag("words") === "string" ? resolve(flag("words")) : null;
|
|
const userVoice = typeof flag("voice") === "string" ? flag("voice") : null;
|
|
const speedRaw = typeof flag("speed") === "string" ? Number(flag("speed")) : 1.0;
|
|
const speed = isFinite(speedRaw) && speedRaw > 0 ? speedRaw : 1.0;
|
|
const lang = typeof flag("lang") === "string" ? flag("lang") : "en";
|
|
const listOnly = flag("list") === true;
|
|
|
|
loadEnvFromDir(process.cwd());
|
|
let authHeaders;
|
|
try {
|
|
authHeaders = heygenAuthHeaders();
|
|
} catch (e) {
|
|
die(e.message);
|
|
}
|
|
|
|
// ---------- --list ----------
|
|
if (listOnly) {
|
|
const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
|
|
headers: authHeaders,
|
|
});
|
|
for (const v of payload.data ?? payload.voices ?? []) {
|
|
console.log(`${v.voice_id}\t${v.name}\t${v.language ?? ""}`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// ---------- resolve text + voice ----------
|
|
if (!positional) die("no text given. Pass a string or a .txt path, or use --list.");
|
|
const text =
|
|
positional.endsWith(".txt") && existsSync(resolve(positional))
|
|
? readFileSync(resolve(positional), "utf8").trim()
|
|
: positional;
|
|
if (!text) die("input text is empty");
|
|
|
|
const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
|
|
if (!userVoice) console.error(`· using voice ${voiceId}`);
|
|
|
|
// ---------- synthesize (shared engine code) ----------
|
|
const { ok, words } = await synthesizeOne({
|
|
provider: "heygen",
|
|
text,
|
|
voiceId,
|
|
lang,
|
|
speed,
|
|
wavAbs: output,
|
|
hyperframesDir: process.cwd(),
|
|
});
|
|
if (!ok) die("synthesis failed (HeyGen request/transcode error)");
|
|
|
|
let wordCount = 0;
|
|
if (wordsPath) {
|
|
if (words && words.length) {
|
|
mkdirSync(dirname(wordsPath), { recursive: true });
|
|
writeFileSync(wordsPath, JSON.stringify(withWordIds(words), null, 2));
|
|
wordCount = words.length;
|
|
} else {
|
|
console.error("⚠ no word_timestamps in response — run `hyperframes transcribe` instead");
|
|
}
|
|
}
|
|
|
|
const dur = ffprobeDuration(output);
|
|
const durStr = isFinite(dur) ? ` (${dur.toFixed(2)}s)` : "";
|
|
console.log(`✓ ${output}${durStr}${wordCount ? ` · ${wordsPath} (${wordCount} words)` : ""}`);
|