@vibeframe/cli 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @module _shared/tts-resolve
3
+ *
4
+ * One-stop factory for picking a TTS provider at runtime. Used by every
5
+ * scene-authoring path that turns text into narration audio (currently
6
+ * `executeSceneAdd`; `script-to-video --format scenes` follows in C5+).
7
+ *
8
+ * Routes between:
9
+ * - **ElevenLabs** — cloud, API-key gated, paid (~$0.02/scene). Default
10
+ * when `ELEVENLABS_API_KEY` is set.
11
+ * - **Kokoro** — local Apache 2.0 model, free, ~330MB first-call download.
12
+ * The fallback for key-less users.
13
+ *
14
+ * The router exposes a uniform `TtsCallable` shape so call sites don't have
15
+ * to branch on provider id. Both providers already return the same
16
+ * `{ success, audioBuffer, error?, characterCount? }` result.
17
+ */
18
+ import { ElevenLabsProvider, KokoroProvider } from "@vibeframe/ai-providers";
19
+ import { hasApiKey } from "../../utils/api-key.js";
20
+ import { getApiKey } from "../../utils/api-key.js";
21
+ /**
22
+ * Resolve a TTS provider preference into a callable + metadata.
23
+ *
24
+ * Resolution policy:
25
+ * - `"elevenlabs"`: requires `ELEVENLABS_API_KEY`. Throws an `ApiKeyError`-style
26
+ * error via {@link getApiKey} (consumed by `requireApiKey`/`exitWithError`).
27
+ * - `"kokoro"`: always available (local). No key check.
28
+ * - `"auto"` (or `undefined`): if `ELEVENLABS_API_KEY` is set, picks
29
+ * ElevenLabs; otherwise falls back to Kokoro.
30
+ */
31
+ export async function resolveTtsProvider(preferred = "auto") {
32
+ const choice = preferred === "auto"
33
+ ? (hasApiKey("ELEVENLABS_API_KEY") ? "elevenlabs" : "kokoro")
34
+ : preferred;
35
+ if (choice === "elevenlabs") {
36
+ return buildElevenLabs();
37
+ }
38
+ return buildKokoro();
39
+ }
40
+ async function buildElevenLabs() {
41
+ const key = await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs");
42
+ if (!key) {
43
+ throw new TtsKeyMissingError("elevenlabs");
44
+ }
45
+ const provider = new ElevenLabsProvider();
46
+ await provider.initialize({ apiKey: key });
47
+ const call = async (text, opts) => provider.textToSpeech(text, {
48
+ voiceId: opts?.voice,
49
+ speed: opts?.speed,
50
+ });
51
+ return { provider: "elevenlabs", audioExtension: "mp3", call };
52
+ }
53
+ async function buildKokoro() {
54
+ const provider = new KokoroProvider();
55
+ // No-op initialize — Kokoro doesn't take a config but we keep parity with
56
+ // the AIProvider lifecycle for future audit checks.
57
+ await provider.initialize({});
58
+ const call = async (text, opts) => provider.textToSpeech(text, {
59
+ voice: opts?.voice,
60
+ speed: opts?.speed,
61
+ onProgress: opts?.onProgress,
62
+ });
63
+ return { provider: "kokoro", audioExtension: "wav", call };
64
+ }
65
+ /**
66
+ * Thrown when the caller asked for a specific provider whose key/runtime is
67
+ * unavailable. Carries the provider id so the caller can format a clean
68
+ * `usageError`/`exitWithError`.
69
+ */
70
+ export class TtsKeyMissingError extends Error {
71
+ provider;
72
+ constructor(provider) {
73
+ super(provider === "elevenlabs"
74
+ ? "ElevenLabs API key required (ELEVENLABS_API_KEY). Run 'vibe setup', set ELEVENLABS_API_KEY in .env, or pass --tts kokoro for local synthesis."
75
+ : `Provider ${provider} is unavailable.`);
76
+ this.provider = provider;
77
+ this.name = "TtsKeyMissingError";
78
+ }
79
+ }
80
+ /** Validate a free-form `--tts <value>` against {@link TtsProviderName}. */
81
+ export function parseTtsProviderName(value) {
82
+ if (!value)
83
+ return "auto";
84
+ if (value === "auto" || value === "elevenlabs" || value === "kokoro") {
85
+ return value;
86
+ }
87
+ throw new Error(`Invalid --tts: ${value}. Valid: auto, elevenlabs, kokoro.`);
88
+ }
89
+ //# sourceMappingURL=tts-resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tts-resolve.js","sourceRoot":"","sources":["../../../src/commands/_shared/tts-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE7E,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AA0CnD;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,YAA6B,MAAM;IAEnC,MAAM,MAAM,GAAG,SAAS,KAAK,MAAM;QACjC,CAAC,CAAC,CAAC,SAAS,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,QAAQ,CAAC;QAC7D,CAAC,CAAC,SAAS,CAAC;IAEd,IAAI,MAAM,KAAK,YAAY,EAAE,CAAC;QAC5B,OAAO,eAAe,EAAE,CAAC;IAC3B,CAAC;IACD,OAAO,WAAW,EAAE,CAAC;AACvB,CAAC;AAED,KAAK,UAAU,eAAe;IAC5B,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,oBAAoB,EAAE,YAAY,CAAC,CAAC;IAChE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,kBAAkB,CAAC,YAAY,CAAC,CAAC;IAC7C,CAAC;IACD,MAAM,QAAQ,GAAG,IAAI,kBAAkB,EAAE,CAAC;IAC1C,MAAM,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IAC3C,MAAM,IAAI,GAAgB,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,CAC7C,QAAQ,CAAC,YAAY,CAAC,IAAI,EAAE;QAC1B,OAAO,EAAE,IAAI,EAAE,KAAK;QACpB,KAAK,EAAE,IAAI,EAAE,KAAK;KACnB,CAAC,CAAC;IACL,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACjE,CAAC;AAED,KAAK,UAAU,WAAW;IACxB,MAAM,QAAQ,GAAG,IAAI,cAAc,EAAE,CAAC;IACtC,0EAA0E;IAC1E,oDAAoD;IACpD,MAAM,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;IAC9B,MAAM,IAAI,GAAgB,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,CAC7C,QAAQ,CAAC,YAAY,CAAC,IAAI,EAAE;QAC1B,KAAK,EAAE,IAAI,EAAE,KAAK;QAClB,KAAK,EAAE,IAAI,EAAE,KAAK;QAClB,UAAU,EAAE,IAAI,EAAE,UAAU;KAC7B,CAAC,CAAC;IACL,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,cAAc,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AAC7D,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IACf;IAA5B,YAA4B,QAA6B;QACvD,KAAK,CACH,QAAQ,KAAK,YAAY;YACvB,CAAC,CAAC,+IAA+I;YACjJ,CAAC,CAAC,YAAY,QAAQ,kBAAkB,CAC3C,CAAC;QALwB,aAAQ,GAAR,QAAQ,CAAqB;QAMvD,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAED,4EAA4E;AAC5E,MAAM,UAAU,oBAAoB,CAAC,KAAyB;IAC5D,IAAI,CAAC,KAAK;QAAE,OAAO,MAAM,CAAC;IAC1B,IAAI,KAAK,KAAK,MAAM,IAAI,KAAK,KAAK,YAAY,IAAI,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,IAAI,KAAK,CACb,kBAAkB,KAAK,oCAAoC,CAC5D,CAAC;AACJ,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Unit tests for the TTS router. The actual provider classes are mocked at
3
+ * the module level so we can drive resolution outcomes deterministically.
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=tts-resolve.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tts-resolve.test.d.ts","sourceRoot":"","sources":["../../../src/commands/_shared/tts-resolve.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Unit tests for the TTS router. The actual provider classes are mocked at
3
+ * the module level so we can drive resolution outcomes deterministically.
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ // vi.mock factories are hoisted above imports — so any references must come
7
+ // from vi.hoisted (which is also hoisted) rather than module-scope variables.
8
+ const mocks = vi.hoisted(() => {
9
+ return {
10
+ elevenLabsTextToSpeech: vi.fn(),
11
+ elevenLabsInitialize: vi.fn(),
12
+ kokoroTextToSpeech: vi.fn(),
13
+ kokoroInitialize: vi.fn(),
14
+ hasApiKey: vi.fn(),
15
+ getApiKey: vi.fn(),
16
+ };
17
+ });
18
+ vi.mock("@vibeframe/ai-providers", () => ({
19
+ ElevenLabsProvider: class {
20
+ initialize = mocks.elevenLabsInitialize;
21
+ textToSpeech = mocks.elevenLabsTextToSpeech;
22
+ },
23
+ KokoroProvider: class {
24
+ initialize = mocks.kokoroInitialize;
25
+ textToSpeech = mocks.kokoroTextToSpeech;
26
+ },
27
+ }));
28
+ vi.mock("../../utils/api-key.js", () => ({
29
+ hasApiKey: (...args) => mocks.hasApiKey(...args),
30
+ getApiKey: (...args) => mocks.getApiKey(...args),
31
+ }));
32
+ const { elevenLabsTextToSpeech, elevenLabsInitialize, kokoroTextToSpeech, kokoroInitialize, hasApiKey, getApiKey, } = mocks;
33
+ import { parseTtsProviderName, resolveTtsProvider, TtsKeyMissingError, } from "./tts-resolve.js";
34
+ describe("parseTtsProviderName", () => {
35
+ it("defaults to auto when undefined or empty", () => {
36
+ expect(parseTtsProviderName(undefined)).toBe("auto");
37
+ expect(parseTtsProviderName("")).toBe("auto");
38
+ });
39
+ it("accepts the three valid values", () => {
40
+ expect(parseTtsProviderName("auto")).toBe("auto");
41
+ expect(parseTtsProviderName("elevenlabs")).toBe("elevenlabs");
42
+ expect(parseTtsProviderName("kokoro")).toBe("kokoro");
43
+ });
44
+ it("throws for unknown providers", () => {
45
+ expect(() => parseTtsProviderName("openai")).toThrow(/Invalid --tts/);
46
+ expect(() => parseTtsProviderName("KOKORO")).toThrow(/Invalid --tts/);
47
+ });
48
+ });
49
+ describe("resolveTtsProvider", () => {
50
+ beforeEach(() => {
51
+ elevenLabsTextToSpeech.mockReset();
52
+ elevenLabsInitialize.mockReset();
53
+ kokoroTextToSpeech.mockReset();
54
+ kokoroInitialize.mockReset();
55
+ hasApiKey.mockReset();
56
+ getApiKey.mockReset();
57
+ });
58
+ afterEach(() => {
59
+ vi.restoreAllMocks();
60
+ });
61
+ describe("auto", () => {
62
+ it("picks ElevenLabs when ELEVENLABS_API_KEY is set", async () => {
63
+ hasApiKey.mockReturnValue(true);
64
+ getApiKey.mockResolvedValue("sk-test");
65
+ const r = await resolveTtsProvider("auto");
66
+ expect(r.provider).toBe("elevenlabs");
67
+ expect(r.audioExtension).toBe("mp3");
68
+ expect(hasApiKey).toHaveBeenCalledWith("ELEVENLABS_API_KEY");
69
+ expect(elevenLabsInitialize).toHaveBeenCalledWith({ apiKey: "sk-test" });
70
+ });
71
+ it("falls back to Kokoro when ELEVENLABS_API_KEY is unset", async () => {
72
+ hasApiKey.mockReturnValue(false);
73
+ const r = await resolveTtsProvider("auto");
74
+ expect(r.provider).toBe("kokoro");
75
+ expect(r.audioExtension).toBe("wav");
76
+ expect(kokoroInitialize).toHaveBeenCalled();
77
+ expect(getApiKey).not.toHaveBeenCalled();
78
+ });
79
+ it("treats undefined preferred the same as auto", async () => {
80
+ hasApiKey.mockReturnValue(false);
81
+ const r = await resolveTtsProvider();
82
+ expect(r.provider).toBe("kokoro");
83
+ });
84
+ });
85
+ describe("explicit elevenlabs", () => {
86
+ it("uses ElevenLabs when key is present", async () => {
87
+ getApiKey.mockResolvedValue("sk-real");
88
+ const r = await resolveTtsProvider("elevenlabs");
89
+ expect(r.provider).toBe("elevenlabs");
90
+ expect(elevenLabsInitialize).toHaveBeenCalledWith({ apiKey: "sk-real" });
91
+ });
92
+ it("throws TtsKeyMissingError when key is absent", async () => {
93
+ getApiKey.mockResolvedValue(undefined);
94
+ await expect(resolveTtsProvider("elevenlabs")).rejects.toBeInstanceOf(TtsKeyMissingError);
95
+ expect(kokoroInitialize).not.toHaveBeenCalled();
96
+ });
97
+ });
98
+ describe("explicit kokoro", () => {
99
+ it("uses Kokoro without checking any key", async () => {
100
+ const r = await resolveTtsProvider("kokoro");
101
+ expect(r.provider).toBe("kokoro");
102
+ expect(r.audioExtension).toBe("wav");
103
+ expect(kokoroInitialize).toHaveBeenCalled();
104
+ expect(getApiKey).not.toHaveBeenCalled();
105
+ expect(hasApiKey).not.toHaveBeenCalled();
106
+ });
107
+ });
108
+ describe("call dispatch", () => {
109
+ it("forwards voice + speed to ElevenLabs as voiceId/speed", async () => {
110
+ hasApiKey.mockReturnValue(true);
111
+ getApiKey.mockResolvedValue("sk-test");
112
+ elevenLabsTextToSpeech.mockResolvedValue({ success: true, audioBuffer: Buffer.from("x") });
113
+ const r = await resolveTtsProvider("elevenlabs");
114
+ await r.call("Hello.", { voice: "rachel", speed: 1.1 });
115
+ expect(elevenLabsTextToSpeech).toHaveBeenCalledWith("Hello.", {
116
+ voiceId: "rachel",
117
+ speed: 1.1,
118
+ });
119
+ });
120
+ it("forwards voice + speed + onProgress to Kokoro", async () => {
121
+ kokoroTextToSpeech.mockResolvedValue({ success: true, audioBuffer: Buffer.from("y") });
122
+ const r = await resolveTtsProvider("kokoro");
123
+ const onProgress = vi.fn();
124
+ await r.call("Hello.", { voice: "af_heart", speed: 1.0, onProgress });
125
+ expect(kokoroTextToSpeech).toHaveBeenCalledWith("Hello.", {
126
+ voice: "af_heart",
127
+ speed: 1.0,
128
+ onProgress,
129
+ });
130
+ });
131
+ });
132
+ });
133
+ describe("TtsKeyMissingError", () => {
134
+ it("provides actionable elevenlabs message", () => {
135
+ const err = new TtsKeyMissingError("elevenlabs");
136
+ expect(err.message).toMatch(/ELEVENLABS_API_KEY/);
137
+ expect(err.message).toMatch(/--tts kokoro/);
138
+ expect(err.provider).toBe("elevenlabs");
139
+ });
140
+ it("is identifiable via instanceof", () => {
141
+ const err = new TtsKeyMissingError("elevenlabs");
142
+ expect(err).toBeInstanceOf(TtsKeyMissingError);
143
+ expect(err).toBeInstanceOf(Error);
144
+ expect(err.name).toBe("TtsKeyMissingError");
145
+ });
146
+ });
147
+ //# sourceMappingURL=tts-resolve.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tts-resolve.test.js","sourceRoot":"","sources":["../../../src/commands/_shared/tts-resolve.test.ts"],"names":[],"mappings":"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"}
@@ -14,6 +14,7 @@
14
14
  * - render [C4, this commit] — render scene project to MP4/WebM/MOV
15
15
  */
16
16
  import { Command } from "commander";
17
+ import { type TtsProviderName } from "./_shared/tts-resolve.js";
17
18
  import { type ScenePreset } from "./_shared/scene-html-emit.js";
18
19
  export declare const sceneCommand: Command;
19
20
  export interface SceneAddOptions {
@@ -21,6 +22,14 @@ export interface SceneAddOptions {
21
22
  preset: ScenePreset;
22
23
  /** Narration text. If the value is an existing file path, its content is used. */
23
24
  narration?: string;
25
+ /**
26
+ * Path to an existing narration audio file (.wav/.mp3). When provided,
27
+ * skips TTS entirely and copies the file into the project's `assets/`.
28
+ * Compatible with `npx hyperframes tts`, `say`, or any other external
29
+ * synthesis tool. The transcript is still generated unless
30
+ * `skipTranscribe` is set.
31
+ */
32
+ narrationFile?: string;
24
33
  /** Explicit duration in seconds. Overrides narration-derived duration. */
25
34
  duration?: number;
26
35
  /** Image generation prompt. */
@@ -33,12 +42,21 @@ export interface SceneAddOptions {
33
42
  insertInto?: string;
34
43
  /** "gemini" | "openai". */
35
44
  imageProvider?: string;
36
- /** ElevenLabs voice id/name. */
45
+ /** TTS provider preference. Defaults to `"auto"` (ElevenLabs if key set, else Kokoro). */
46
+ tts?: TtsProviderName;
47
+ /** Voice id (ElevenLabs name/id, or Kokoro id like `af_heart`). */
37
48
  voice?: string;
38
49
  /** When true, skip TTS even if narration is provided. */
39
50
  skipAudio?: boolean;
40
51
  /** When true, skip image generation even if visuals is provided. */
41
52
  skipImage?: boolean;
53
+ /**
54
+ * When true, skip the Whisper word-level transcribe step that would
55
+ * otherwise emit `assets/transcript-<id>.json`.
56
+ */
57
+ skipTranscribe?: boolean;
58
+ /** BCP-47 language hint forwarded to Whisper (e.g. `"en"`, `"ko"`). */
59
+ transcribeLanguage?: string;
42
60
  /** Overwrite existing compositions/scene-<id>.html. */
43
61
  force?: boolean;
44
62
  /** Progress sink (CLI spinner / agent stream). */
@@ -54,6 +72,14 @@ export interface SceneAddResult {
54
72
  rootPath: string;
55
73
  audioPath?: string;
56
74
  imagePath?: string;
75
+ /**
76
+ * Project-relative path to `assets/transcript-<id>.json` when Whisper
77
+ * word-level transcribe ran. `undefined` when skipped or when the audio
78
+ * source produced no narration text to transcribe.
79
+ */
80
+ transcriptPath?: string;
81
+ /** Number of word entries emitted into the transcript JSON. */
82
+ transcriptWordCount?: number;
57
83
  error?: string;
58
84
  }
59
85
  export declare function executeSceneAdd(opts: SceneAddOptions): Promise<SceneAddResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"scene.d.ts","sourceRoot":"","sources":["../../src/commands/scene.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC,OAAO,EAOL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AA8CtC,eAAO,MAAM,YAAY,SAkByC,CAAC;AAoLnE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,CAAC;IACpB,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2BAA2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gCAAgC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uDAAuD;IACvD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,kDAAkD;IAClD,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAwCD,wBAAsB,eAAe,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAuKpF"}
1
+ {"version":3,"file":"scene.d.ts","sourceRoot":"","sources":["../../src/commands/scene.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,0BAA0B,CAAC;AAOlC,OAAO,EAOL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AA8CtC,eAAO,MAAM,YAAY,SAkByC,CAAC;AAoMnE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,CAAC;IACpB,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2BAA2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0FAA0F;IAC1F,GAAG,CAAC,EAAE,eAAe,CAAC;IACtB,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,uDAAuD;IACvD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,kDAAkD;IAClD,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+DAA+D;IAC/D,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAwCD,wBAAsB,eAAe,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAuPpF"}
@@ -15,12 +15,13 @@
15
15
  */
16
16
  import { Command } from "commander";
17
17
  import { basename, resolve, relative, dirname } from "node:path";
18
- import { mkdir, readFile, writeFile, access } from "node:fs/promises";
18
+ import { mkdir, readFile, writeFile, access, copyFile } from "node:fs/promises";
19
19
  import { existsSync } from "node:fs";
20
20
  import chalk from "chalk";
21
21
  import ora from "ora";
22
22
  import { parse as yamlParse } from "yaml";
23
- import { ElevenLabsProvider, GeminiProvider, OpenAIImageProvider, } from "@vibeframe/ai-providers";
23
+ import { GeminiProvider, OpenAIImageProvider, WhisperProvider, } from "@vibeframe/ai-providers";
24
+ import { resolveTtsProvider, TtsKeyMissingError, parseTtsProviderName, } from "./_shared/tts-resolve.js";
24
25
  import { scaffoldSceneProject, aspectToDims, } from "./_shared/scene-project.js";
25
26
  import { emitSceneHtml, insertClipIntoRoot, nextSceneStart, readRootDims, slugifySceneName, SCENE_PRESETS, } from "./_shared/scene-html-emit.js";
26
27
  import { runProjectLint, rootExists, } from "./_shared/scene-lint.js";
@@ -136,6 +137,7 @@ sceneCommand
136
137
  .argument("<name>", "Scene name (slugified into the composition id)")
137
138
  .option("--style <preset>", `Style preset: ${SCENE_PRESETS.join(", ")}`, "simple")
138
139
  .option("--narration <text>", "Narration text (or path to a .txt file). Drives TTS + scene duration.")
140
+ .option("--narration-file <path>", "Existing narration audio file (.wav/.mp3). Skips TTS — useful with hyperframes tts, Mac say, or other external tools.")
139
141
  .option("-d, --duration <sec>", "Explicit scene duration in seconds (overrides narration audio)")
140
142
  .option("--visuals <prompt>", "Image prompt — generates assets/scene-<id>.png via the configured image provider")
141
143
  .option("--headline <text>", "Visible headline (defaults to the humanised scene name)")
@@ -143,9 +145,12 @@ sceneCommand
143
145
  .option("--insert-into <path>", "Root composition file to update", "index.html")
144
146
  .option("--project <dir>", "Project directory", ".")
145
147
  .option("--image-provider <name>", "Image provider: gemini, openai", "gemini")
146
- .option("--voice <id>", "ElevenLabs voice id or name")
148
+ .option("--tts <provider>", "TTS provider: auto, elevenlabs, kokoro (default auto — picks ElevenLabs when key set, else Kokoro local)", "auto")
149
+ .option("--voice <id>", "Voice id (ElevenLabs name/id, or Kokoro id like af_heart, am_michael)")
147
150
  .option("--no-audio", "Skip TTS even when --narration is provided (useful for tests/agent dry runs)")
148
151
  .option("--no-image", "Skip image generation even when --visuals is provided")
152
+ .option("--no-transcribe", "Skip Whisper word-level transcribe step (no transcript-<id>.json emitted)")
153
+ .option("--transcribe-language <code>", "BCP-47 language code passed to Whisper (e.g. en, ko)")
149
154
  .option("--force", "Overwrite an existing compositions/scene-<id>.html")
150
155
  .option("--dry-run", "Preview parameters without writing files or calling APIs")
151
156
  .action(async (name, options) => {
@@ -153,6 +158,13 @@ sceneCommand
153
158
  options.style = validatePreset(options.style);
154
159
  if (options.duration !== undefined)
155
160
  options.duration = validateDuration(options.duration);
161
+ let tts;
162
+ try {
163
+ tts = parseTtsProviderName(options.tts);
164
+ }
165
+ catch (error) {
166
+ exitWithError(usageError(error instanceof Error ? error.message : String(error)));
167
+ }
156
168
  if (options.dryRun) {
157
169
  const id = slugifySceneName(name);
158
170
  outputResult({
@@ -170,6 +182,7 @@ sceneCommand
170
182
  project: options.project,
171
183
  insertInto: options.insertInto,
172
184
  imageProvider: options.imageProvider,
185
+ tts,
173
186
  audio: options.audio, // commander sets `audio: false` when --no-audio is passed
174
187
  image: options.image,
175
188
  },
@@ -182,6 +195,7 @@ sceneCommand
182
195
  name,
183
196
  preset: options.style,
184
197
  narration: options.narration,
198
+ narrationFile: options.narrationFile,
185
199
  duration: options.duration,
186
200
  visuals: options.visuals,
187
201
  headline: options.headline,
@@ -189,9 +203,12 @@ sceneCommand
189
203
  projectDir: options.project,
190
204
  insertInto: options.insertInto,
191
205
  imageProvider: options.imageProvider,
206
+ tts,
192
207
  voice: options.voice,
193
208
  skipAudio: options.audio === false,
194
209
  skipImage: options.image === false,
210
+ skipTranscribe: options.transcribe === false,
211
+ transcribeLanguage: options.transcribeLanguage,
195
212
  force: !!options.force,
196
213
  onProgress: (msg) => {
197
214
  if (spinner)
@@ -218,6 +235,8 @@ sceneCommand
218
235
  console.log(chalk.green(" +"), result.audioPath);
219
236
  if (result.imagePath)
220
237
  console.log(chalk.green(" +"), result.imagePath);
238
+ if (result.transcriptPath)
239
+ console.log(chalk.green(" +"), result.transcriptPath);
221
240
  console.log(chalk.yellow(" ~"), result.rootPath, chalk.dim("(updated)"));
222
241
  console.log();
223
242
  console.log(chalk.bold.cyan("Composition"));
@@ -325,19 +344,53 @@ export async function executeSceneAdd(opts) {
325
344
  let audioRelPath;
326
345
  let audioAbsPath;
327
346
  let narrationDuration;
328
- if (narrationText && !opts.skipAudio) {
329
- const elevenlabsKey = await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs");
330
- if (!elevenlabsKey) {
331
- return errResult("ElevenLabs API key required for --narration. Set ELEVENLABS_API_KEY, run 'vibe setup', or pass --no-audio.");
347
+ if (opts.narrationFile && !opts.skipAudio) {
348
+ // External wav/mp3: skip TTS, copy file into the project's assets/ dir.
349
+ const sourceAbs = resolve(opts.narrationFile);
350
+ if (!(await pathExists(sourceAbs))) {
351
+ return errResult(`Narration file not found: ${sourceAbs}`);
352
+ }
353
+ const ext = (sourceAbs.match(/\.([a-z0-9]+)$/i)?.[1] ?? "wav").toLowerCase();
354
+ if (ext !== "wav" && ext !== "mp3") {
355
+ return errResult(`Unsupported narration file extension: .${ext}. Use .wav or .mp3.`);
356
+ }
357
+ audioRelPath = `assets/narration-${id}.${ext}`;
358
+ audioAbsPath = resolve(projectDir, audioRelPath);
359
+ await mkdir(dirname(audioAbsPath), { recursive: true });
360
+ await copyFile(sourceAbs, audioAbsPath);
361
+ try {
362
+ narrationDuration = await getAudioDuration(audioAbsPath);
363
+ }
364
+ catch {
365
+ narrationDuration = undefined;
332
366
  }
333
- opts.onProgress?.("Generating narration with ElevenLabs...");
334
- const elevenlabs = new ElevenLabsProvider();
335
- await elevenlabs.initialize({ apiKey: elevenlabsKey });
336
- const tts = await elevenlabs.textToSpeech(narrationText, { voiceId: opts.voice });
367
+ }
368
+ else if (narrationText && !opts.skipAudio) {
369
+ let resolution;
370
+ try {
371
+ resolution = await resolveTtsProvider(opts.tts ?? "auto");
372
+ }
373
+ catch (error) {
374
+ if (error instanceof TtsKeyMissingError) {
375
+ return errResult(error.message);
376
+ }
377
+ throw error;
378
+ }
379
+ opts.onProgress?.(resolution.provider === "kokoro"
380
+ ? "Generating narration with Kokoro (local — first run downloads ~330MB)..."
381
+ : "Generating narration with ElevenLabs...");
382
+ const tts = await resolution.call(narrationText, {
383
+ voice: opts.voice,
384
+ onProgress: (event) => {
385
+ if (event.status === "progress" && typeof event.progress === "number") {
386
+ opts.onProgress?.(`Kokoro model: ${event.file ?? ""} ${Math.round(event.progress)}%`);
387
+ }
388
+ },
389
+ });
337
390
  if (!tts.success || !tts.audioBuffer) {
338
- return errResult(`ElevenLabs TTS failed: ${tts.error ?? "unknown error"}`);
391
+ return errResult(`${resolution.provider} TTS failed: ${tts.error ?? "unknown error"}`);
339
392
  }
340
- audioRelPath = `assets/narration-${id}.mp3`;
393
+ audioRelPath = `assets/narration-${id}.${resolution.audioExtension}`;
341
394
  audioAbsPath = resolve(projectDir, audioRelPath);
342
395
  await mkdir(dirname(audioAbsPath), { recursive: true });
343
396
  await writeFile(audioAbsPath, tts.audioBuffer);
@@ -348,6 +401,47 @@ export async function executeSceneAdd(opts) {
348
401
  narrationDuration = undefined;
349
402
  }
350
403
  }
404
+ // -- Whisper word-level transcribe --------------------------------------
405
+ // Auto-runs whenever we have audio + an OpenAI key. The transcript JSON
406
+ // mirrors the Hyperframes shape so a future emitSceneHtml (C5) can drive
407
+ // GSAP word-sync from it. Failure is non-fatal — narration still plays,
408
+ // we just lose word-level animation timing.
409
+ let transcriptRelPath;
410
+ let transcriptWordCount;
411
+ let transcriptWords;
412
+ if (audioAbsPath && !opts.skipTranscribe) {
413
+ const whisperKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
414
+ if (!whisperKey) {
415
+ opts.onProgress?.("Skipping transcribe (OPENAI_API_KEY not set — narration plays but word-sync unavailable)");
416
+ }
417
+ else {
418
+ opts.onProgress?.("Transcribing narration (Whisper word-level)...");
419
+ try {
420
+ const whisper = new WhisperProvider();
421
+ await whisper.initialize({ apiKey: whisperKey });
422
+ const audioBytes = await readFile(audioAbsPath);
423
+ const audioBlob = new Blob([new Uint8Array(audioBytes)]);
424
+ const transcript = await whisper.transcribe(audioBlob, undefined, {
425
+ granularity: "word",
426
+ language: opts.transcribeLanguage,
427
+ });
428
+ if (transcript.status === "completed" && transcript.words?.length) {
429
+ transcriptRelPath = `assets/transcript-${id}.json`;
430
+ const transcriptAbs = resolve(projectDir, transcriptRelPath);
431
+ await writeFile(transcriptAbs, JSON.stringify(transcript.words, null, 2), "utf-8");
432
+ transcriptWordCount = transcript.words.length;
433
+ transcriptWords = transcript.words.map((w) => ({ text: w.text, start: w.start, end: w.end }));
434
+ }
435
+ else if (transcript.status === "failed") {
436
+ opts.onProgress?.(`Transcribe failed: ${transcript.error ?? "unknown error"}`);
437
+ }
438
+ }
439
+ catch (error) {
440
+ const msg = error instanceof Error ? error.message : String(error);
441
+ opts.onProgress?.(`Transcribe failed: ${msg}`);
442
+ }
443
+ }
444
+ }
351
445
  // -- Asset generation: backdrop image -----------------------------------
352
446
  let imageRelPath;
353
447
  let imageAbsPath;
@@ -424,6 +518,7 @@ export async function executeSceneAdd(opts) {
424
518
  kicker: opts.kicker,
425
519
  imagePath: imageRelPath,
426
520
  audioPath: audioRelPath,
521
+ transcript: transcriptWords,
427
522
  });
428
523
  await mkdir(dirname(scenePath), { recursive: true });
429
524
  await writeFile(scenePath, sceneHtml, "utf-8");
@@ -432,6 +527,7 @@ export async function executeSceneAdd(opts) {
432
527
  const start = nextSceneStart(rootHtmlBefore);
433
528
  const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration });
434
529
  await writeFile(rootPath, updated, "utf-8");
530
+ const transcriptAbsPath = transcriptRelPath ? resolve(projectDir, transcriptRelPath) : undefined;
435
531
  return {
436
532
  success: true,
437
533
  id,
@@ -442,6 +538,8 @@ export async function executeSceneAdd(opts) {
442
538
  rootPath: relative(process.cwd(), rootPath) || rootPath,
443
539
  audioPath: audioAbsPath ? (relative(process.cwd(), audioAbsPath) || audioAbsPath) : undefined,
444
540
  imagePath: imageAbsPath ? (relative(process.cwd(), imageAbsPath) || imageAbsPath) : undefined,
541
+ transcriptPath: transcriptAbsPath ? (relative(process.cwd(), transcriptAbsPath) || transcriptAbsPath) : undefined,
542
+ transcriptWordCount,
445
543
  };
446
544
  }
447
545
  // ---------------------------------------------------------------------------