mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(skills): product-launch-video + consolidate motion knowledge into hyperframes-animation
- Add the product-launch-video skill: shot-sequence architecture where each
visual frame is a time-coded shot sequence picked from a blueprint menu and
paced to the voiceover (anti-PowerPoint). Includes the frame-worker sub-agent,
story/visual/motion-design references, and audio/captions/transitions/
stage-assets/assemble-index scripts.
- Consolidate motion knowledge in hyperframes-animation as the single source of
truth: promote the updated atomic rules (31 -> 36) and rename product-launch-
video's archetypes into hyperframes-animation blueprints (13 -> 15, replacing
the old set). product-launch-video, faceless-explainer, and pr-to-video now
reference them via ../hyperframes-animation/{rules-index,blueprints-index}.md
and the rules/blueprints dirs. Fixes the discrete-text-sequence broken links;
blueprints no longer ship per-id runnable examples, so example references in
the consumers were dropped.
- Default HeyGen TTS voice to Marcia (deterministic; was the API's first English
voice, which drifts on catalog re-sort). Override with --voice.
- assemble-index pre-assembly frame guards: auto-repair a sub-comp root missing
canvas dims; hard-fail on <video>/<audio> inside a sub-comp; hard-fail on a
timed non-root element missing class="clip" or overlapping same-track clips.
- Lint/CLI: lint media inside sub-compositions as an error; stop false-positive
caption layout/lint findings; contrast/layout-audit skip elements hidden by an
invisible ancestor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): clear CodeQL alerts in assemble-index.mjs
- script/style blanking regex now matches closing tags with trailing
whitespace (</script >, </style >) — js/bad-tag-filter (high).
- drop the existsSync precheck before reading/repairing a frame file; read
directly and handle ENOENT, removing the check->write TOCTOU window —
js/file-system-race (high).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
9.7 KiB
JavaScript
221 lines
9.7 KiB
JavaScript
// tts.mjs — multi-provider TTS for the media audio engine. The provider chain,
|
|
// auto-detected from env, is the one documented in ../SKILL.md:
|
|
//
|
|
// 1. HeyGen (Starfish) — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.heygen.
|
|
// Direct v3 REST (NOT `hyperframes tts`, which in the published build is
|
|
// Kokoro-only and silently ignores a HeyGen key). Returns word_timestamps
|
|
// in the same call, so no separate transcribe pass.
|
|
// 2. ElevenLabs — $ELEVENLABS_API_KEY + `pip install elevenlabs`. No
|
|
// word timings → caller chains transcribeWav().
|
|
// 3. Kokoro-82M (local) — always available, via the published `hyperframes tts`
|
|
// CLI. No word timings → caller chains transcribeWav().
|
|
//
|
|
// "HeyGen available" is decided by CREDENTIAL presence (heygenCredential), never
|
|
// by the CLI — see the note above.
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs";
|
|
|
|
// ── provider detection ────────────────────────────────────────────────────────
|
|
export function heygenAvailable() {
|
|
return heygenCredential() !== null;
|
|
}
|
|
export function elevenlabsAvailable() {
|
|
if (!process.env.ELEVENLABS_API_KEY) return false;
|
|
const r = spawnSync("python3", ["-c", "import elevenlabs"], { stdio: "ignore" });
|
|
return r.status === 0;
|
|
}
|
|
|
|
// First available provider wins; an explicit choice is honored (and validated).
|
|
export function pickProvider(userProvider) {
|
|
if (userProvider) {
|
|
if (!["heygen", "elevenlabs", "kokoro"].includes(userProvider))
|
|
throw new Error(`invalid provider "${userProvider}" (heygen | elevenlabs | kokoro)`);
|
|
if (userProvider === "heygen" && !heygenAvailable())
|
|
throw new Error(
|
|
"provider=heygen but no HeyGen credentials (set $HEYGEN_API_KEY or run `npx hyperframes auth login`)",
|
|
);
|
|
if (userProvider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY)
|
|
throw new Error("provider=elevenlabs but $ELEVENLABS_API_KEY is not set");
|
|
return userProvider;
|
|
}
|
|
return heygenAvailable() ? "heygen" : elevenlabsAvailable() ? "elevenlabs" : "kokoro";
|
|
}
|
|
|
|
// ── voice resolution ──────────────────────────────────────────────────────────
|
|
// HeyGen /v3/voices/speech only accepts STARFISH voice_ids; auto-pick the first
|
|
// English public starfish voice when none is pinned. ElevenLabs/Kokoro have
|
|
// their own defaults.
|
|
export async function resolveVoiceId({ provider, userVoice, lang = "en" }) {
|
|
if (userVoice) return userVoice;
|
|
if (provider === "elevenlabs") return "21m00Tcm4TlvDq8ikWAM"; // Rachel
|
|
if (provider === "kokoro") {
|
|
if (lang === "en") return "am_michael";
|
|
throw new Error("Kokoro non-English needs an explicit --voice (see references/tts.md)");
|
|
}
|
|
// heygen — pin a fixed English default so the choice is deterministic. The old
|
|
// "first English voice the API returns" drifts whenever HeyGen re-sorts the
|
|
// public catalog. Marcia (mature, low female). Override with --voice / request.voice.
|
|
if (lang === "en") return "05f19352e8f74b0392a8f411eba40de1"; // Marcia · English · female
|
|
// Non-English: no fixed default — fall back to the first matching catalog voice.
|
|
const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
|
|
headers: heygenAuthHeaders(),
|
|
});
|
|
const voices = payload.data ?? payload.voices ?? [];
|
|
const pick = voices.find((v) => v.language === "English") ?? voices[0];
|
|
if (!pick) throw new Error("no public starfish voice to default to — pass --voice");
|
|
return pick.voice_id;
|
|
}
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
export function withWordIds(words) {
|
|
return (words ?? []).map((w, i) => ({ id: `w${i}`, text: w.text, start: w.start, end: w.end }));
|
|
}
|
|
|
|
export function ffprobeDuration(absPath) {
|
|
const r = spawnSync(
|
|
"ffprobe",
|
|
["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", absPath],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (r.status !== 0) return NaN;
|
|
return parseFloat(String(r.stdout).trim());
|
|
}
|
|
|
|
function spawnP(cmd, args, opts) {
|
|
return new Promise((resolve) => {
|
|
const p = spawn(cmd, args, { stdio: "ignore", ...opts });
|
|
p.on("exit", (code) => resolve({ status: code ?? -1 }));
|
|
p.on("error", () => resolve({ status: -1 }));
|
|
});
|
|
}
|
|
|
|
// mp3/whatever bytes → wav 44.1k mono at destWav (ffmpeg detects true format).
|
|
function transcodeToWav(bytes, destWav) {
|
|
const td = mkdtempSync(join(tmpdir(), "hf-tts-"));
|
|
const tmp = join(td, "a.mp3");
|
|
writeFileSync(tmp, bytes);
|
|
mkdirSync(dirname(destWav), { recursive: true });
|
|
const ff = spawnSync(
|
|
"ffmpeg",
|
|
["-y", "-loglevel", "error", "-i", tmp, "-ar", "44100", "-ac", "1", destWav],
|
|
{ stdio: "ignore" },
|
|
);
|
|
rmSync(td, { recursive: true, force: true });
|
|
return ff.status === 0 && existsSync(destWav);
|
|
}
|
|
|
|
const ELEVENLABS_PY = `
|
|
import os, sys
|
|
from elevenlabs.client import ElevenLabs
|
|
from elevenlabs import save
|
|
client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
|
|
text = open(sys.argv[1]).read()
|
|
audio = client.text_to_speech.convert(
|
|
text=text, voice_id=sys.argv[2],
|
|
model_id="eleven_multilingual_v2", output_format="mp3_44100_128",
|
|
)
|
|
save(audio, sys.argv[3])
|
|
`;
|
|
|
|
// ── synthesize one line ───────────────────────────────────────────────────────
|
|
// Writes wav at wavAbs. Returns { ok, words } — words is the raw
|
|
// [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro
|
|
// (caller must transcribeWav). Never throws; failures return { ok:false }.
|
|
export async function synthesizeOne({
|
|
provider,
|
|
text,
|
|
voiceId,
|
|
lang = "en",
|
|
speed = 1.0,
|
|
wavAbs,
|
|
hyperframesDir,
|
|
}) {
|
|
if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs });
|
|
if (provider === "elevenlabs") {
|
|
const r = await spawnP(
|
|
"python3",
|
|
["-c", ELEVENLABS_PY, writeTmpText(text), voiceId, wavAbs],
|
|
{},
|
|
);
|
|
return { ok: r.status === 0 && existsSync(wavAbs), words: null };
|
|
}
|
|
// kokoro — via the published CLI; --output is relative to the project dir.
|
|
const wavRel = relTo(hyperframesDir, wavAbs);
|
|
const args = ["hyperframes", "tts", writeTmpText(text), "--voice", voiceId, "--output", wavRel];
|
|
if (lang !== "en") args.push("--lang", lang);
|
|
const r = await spawnP("npx", args, { cwd: hyperframesDir });
|
|
return { ok: r.status === 0 && existsSync(wavAbs), words: null };
|
|
}
|
|
|
|
async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) {
|
|
try {
|
|
const body = { text, voice_id: voiceId, speed };
|
|
if (lang !== "en") body.language = lang;
|
|
const payload = await heygenJSON(`/voices/speech`, {
|
|
method: "POST",
|
|
headers: heygenAuthHeaders(),
|
|
body,
|
|
});
|
|
const inner = payload.data ?? payload;
|
|
if (!inner.audio_url) return { ok: false, words: null };
|
|
const res = await fetch(inner.audio_url);
|
|
if (!res.ok) return { ok: false, words: null };
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
// .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The
|
|
// engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3.
|
|
if (wavAbs.endsWith(".wav")) {
|
|
if (!transcodeToWav(bytes, wavAbs)) return { ok: false, words: null };
|
|
} else {
|
|
mkdirSync(dirname(wavAbs), { recursive: true });
|
|
writeFileSync(wavAbs, bytes);
|
|
}
|
|
const words = Array.isArray(inner.word_timestamps)
|
|
? inner.word_timestamps
|
|
.filter((w) => w && typeof w.word === "string" && isFinite(w.start) && isFinite(w.end))
|
|
.filter((w) => !/^<.*>$/.test(w.word.trim())) // drop <start>/<end> sentinels
|
|
.map((w) => ({ text: w.word, start: w.start, end: w.end }))
|
|
: [];
|
|
return { ok: true, words };
|
|
} catch {
|
|
return { ok: false, words: null };
|
|
}
|
|
}
|
|
|
|
// ElevenLabs/Kokoro have no word timings — run Whisper over the wav. Returns the
|
|
// flat [{id,text,start,end}] word array, or null. Each call uses a throwaway
|
|
// --dir so parallel scenes don't collide on transcript.json.
|
|
export async function transcribeWav({ wavRel, lang = "en", hyperframesDir }) {
|
|
const model = lang === "en" ? "small.en" : "small";
|
|
const td = mkdtempSync(join(tmpdir(), "hf-trans-"));
|
|
const args = ["hyperframes", "transcribe", wavRel, "--model", model, "--dir", td];
|
|
if (lang !== "en") args.push("--language", lang);
|
|
const r = await spawnP("npx", args, { cwd: hyperframesDir });
|
|
let words = null;
|
|
if (r.status === 0) {
|
|
const src = join(td, "transcript.json");
|
|
if (existsSync(src)) {
|
|
try {
|
|
const arr = JSON.parse(readFileSync(src, "utf8"));
|
|
if (Array.isArray(arr) && arr.length) words = arr;
|
|
} catch {}
|
|
}
|
|
}
|
|
rmSync(td, { recursive: true, force: true });
|
|
return words;
|
|
}
|
|
|
|
// ── tiny local utils ──────────────────────────────────────────────────────────
|
|
function writeTmpText(text) {
|
|
const td = mkdtempSync(join(tmpdir(), "hf-txt-"));
|
|
const p = join(td, "line.txt");
|
|
writeFileSync(p, text);
|
|
return p;
|
|
}
|
|
function relTo(base, abs) {
|
|
return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs;
|
|
}
|