diff --git a/skills-manifest.json b/skills-manifest.json index 90856496d8..0ed23792e5 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -62,8 +62,8 @@ "files": 22 }, "product-launch-video": { - "hash": "158398dbfc4ab652", - "files": 20 + "hash": "fc14bdc4b20f90a4", + "files": 21 }, "remotion-to-hyperframes": { "hash": "aa599f027db4b994", diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 6c8d858fde..0925dacfdb 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -106,9 +106,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro Start audio after Step 3 approval. Run it in the background, then continue to Step 4. -**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice `. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference. +**Choose the narration provider and voice from the user's ask before invoking.** Pass the provider selected in Step 0 with `--provider ` (or set `HF_TTS_PROVIDER`). If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice `. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference. -`node /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice &` +`node /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --provider --voice &` The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and uses the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`. diff --git a/skills/product-launch-video/scripts/audio.mjs b/skills/product-launch-video/scripts/audio.mjs index 46a910b9b6..047b8a1ad0 100644 --- a/skills/product-launch-video/scripts/audio.mjs +++ b/skills/product-launch-video/scripts/audio.mjs @@ -124,6 +124,7 @@ function runGenerate(argv) { const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md"))); const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json"))); const userVoice = flag(argv, "voice", null); + const provider = flag(argv, "provider", process.env.HF_TTS_PROVIDER || "auto"); const speed = Number(flag(argv, "speed", "1.0")) || 1.0; if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); @@ -142,7 +143,7 @@ function runGenerate(argv) { // strict here (no wait-bgm step downstream). const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore"; const request = { - provider: "auto", + provider, speed, lines, bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" }, diff --git a/skills/product-launch-video/scripts/audio.test.mjs b/skills/product-launch-video/scripts/audio.test.mjs new file mode 100644 index 0000000000..8e0d6de7cf --- /dev/null +++ b/skills/product-launch-video/scripts/audio.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const script = new URL("./audio.mjs", import.meta.url).pathname; + +function runAudio({ args = [], env = {} } = {}) { + const dir = mkdtempSync(join(tmpdir(), "product-launch-audio-")); + const engine = join(dir, "engine.mjs"); + writeFileSync(join(dir, "STORYBOARD.md"), "message: Test\n"); + writeFileSync( + engine, + `import { readFileSync, writeFileSync } from "node:fs"; +const argv = process.argv.slice(2); +const flag = (name) => argv[argv.indexOf(name) + 1]; +const request = JSON.parse(readFileSync(flag("--request"), "utf8")); +writeFileSync(new URL("request.json", import.meta.url), JSON.stringify(request)); +writeFileSync(flag("--out"), JSON.stringify({ voices: [], bgm: null, sfx: [] })); +`, + ); + const result = spawnSync( + process.execPath, + [script, "--hyperframes", dir, "--storyboard", join(dir, "STORYBOARD.md"), ...args], + { encoding: "utf8", env: { ...process.env, HF_MEDIA_ENGINE: engine, ...env } }, + ); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(readFileSync(join(dir, "request.json"), "utf8")); +} + +test("passes --provider to the shared audio engine", () => { + assert.equal(runAudio({ args: ["--provider", "kokoro"] }).provider, "kokoro"); +}); + +test("uses HF_TTS_PROVIDER when --provider is omitted", () => { + assert.equal(runAudio({ env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, "elevenlabs"); +}); + +test("--provider takes precedence over HF_TTS_PROVIDER", () => { + assert.equal( + runAudio({ args: ["--provider", "kokoro"], env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, + "kokoro", + ); +});