@vibeframe/cli 0.48.5 → 0.49.1

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.
@@ -4,20 +4,14 @@
4
4
  * scene regeneration commands. Execute functions and helpers live in
5
5
  * ai-script-pipeline.ts; this file wires them up as Commander.js subcommands.
6
6
  */
7
- import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
8
- import { resolve, dirname, extname } from "node:path";
7
+ import { readFile, writeFile, stat } from "node:fs/promises";
8
+ import { resolve } from "node:path";
9
9
  import { existsSync } from "node:fs";
10
10
  import chalk from "chalk";
11
11
  import ora from "ora";
12
- import { GeminiProvider, OpenAIProvider, OpenAIImageProvider, ClaudeProvider, ElevenLabsProvider, KlingProvider, RunwayProvider, GrokProvider, } from "@vibeframe/ai-providers";
12
+ import { parse as yamlParse } from "yaml";
13
13
  import { getApiKey, loadEnv } from "../utils/api-key.js";
14
- import { getApiKeyFromConfig } from "../config/index.js";
15
- import { Project } from "../engine/index.js";
16
- import { getAudioDuration } from "../utils/audio.js";
17
- import { applyTextOverlays } from "./ai-edit.js";
18
- import { executeReview } from "./ai-review.js";
19
- import { DEFAULT_VIDEO_RETRIES, RETRY_DELAY_MS, sleep, uploadToImgbb, extendVideoToTarget, generateVideoWithRetryKling, generateVideoWithRetryRunway, executeScriptToVideo, executeRegenerateScene, } from "./ai-script-pipeline.js";
20
- import { downloadVideo } from "./ai-helpers.js";
14
+ import { DEFAULT_VIDEO_RETRIES, executeScriptToVideo, executeRegenerateScene, } from "./ai-script-pipeline.js";
21
15
  import { exitWithError, outputResult, authError, notFoundError, usageError, apiError, generalError } from "./output.js";
22
16
  import { validateOutputPath } from "./validate.js";
23
17
  export function registerScriptPipelineCommands(aiCommand) {
@@ -78,63 +72,44 @@ export function registerScriptPipelineCommands(aiCommand) {
78
72
  }
79
73
  // Load environment variables from .env file
80
74
  loadEnv();
81
- // Get storyboard provider API key
75
+ // Pre-check API keys so we surface friendly exit codes (AUTH instead
76
+ // of API_ERROR) before executeScriptToVideo's internal re-check fires.
82
77
  const storyboardProvider = (options.storyboardProvider || "claude");
83
- let storyboardApiKey;
84
- if (storyboardProvider === "openai") {
85
- storyboardApiKey = (await getApiKey("OPENAI_API_KEY", "OpenAI")) ?? undefined;
86
- if (!storyboardApiKey) {
87
- exitWithError(authError("OPENAI_API_KEY", "OpenAI"));
88
- }
89
- }
90
- else if (storyboardProvider === "gemini") {
91
- storyboardApiKey = (await getApiKey("GOOGLE_API_KEY", "Google")) ?? undefined;
92
- if (!storyboardApiKey) {
93
- exitWithError(authError("GOOGLE_API_KEY", "Google"));
94
- }
95
- }
96
- else if (storyboardProvider === "claude") {
97
- storyboardApiKey = (await getApiKey("ANTHROPIC_API_KEY", "Anthropic")) ?? undefined;
98
- if (!storyboardApiKey) {
99
- exitWithError(authError("ANTHROPIC_API_KEY", "Anthropic"));
78
+ const storyboardKeyMap = {
79
+ claude: { envVar: "ANTHROPIC_API_KEY", name: "Anthropic" },
80
+ openai: { envVar: "OPENAI_API_KEY", name: "OpenAI" },
81
+ gemini: { envVar: "GOOGLE_API_KEY", name: "Google" },
82
+ };
83
+ {
84
+ const info = storyboardKeyMap[storyboardProvider];
85
+ if (!info) {
86
+ exitWithError(usageError(`Unknown storyboard provider: ${storyboardProvider}`, "Use claude, openai, or gemini"));
100
87
  }
101
- }
102
- else {
103
- exitWithError(usageError(`Unknown storyboard provider: ${storyboardProvider}`, "Use claude, openai, or gemini"));
104
- }
105
- // Get image provider API key
106
- let imageApiKey;
107
- const imageProvider = options.imageProvider || "openai";
108
- if (imageProvider === "openai" || imageProvider === "dalle") {
109
- imageApiKey = (await getApiKey("OPENAI_API_KEY", "OpenAI")) ?? undefined;
110
- if (!imageApiKey) {
111
- exitWithError(authError("OPENAI_API_KEY", "OpenAI"));
88
+ if (!(await getApiKey(info.envVar, info.name))) {
89
+ exitWithError(authError(info.envVar, info.name));
112
90
  }
113
91
  }
114
- else if (imageProvider === "gemini") {
115
- imageApiKey = (await getApiKey("GOOGLE_API_KEY", "Google")) ?? undefined;
116
- if (!imageApiKey) {
117
- exitWithError(authError("GOOGLE_API_KEY", "Google"));
92
+ const imageProvider = (options.imageProvider || "openai");
93
+ const imageKeyMap = {
94
+ openai: { envVar: "OPENAI_API_KEY", name: "OpenAI" },
95
+ dalle: { envVar: "OPENAI_API_KEY", name: "OpenAI" },
96
+ gemini: { envVar: "GOOGLE_API_KEY", name: "Google" },
97
+ grok: { envVar: "XAI_API_KEY", name: "xAI" },
98
+ };
99
+ {
100
+ const info = imageKeyMap[imageProvider];
101
+ if (!info) {
102
+ exitWithError(usageError(`Unknown image provider: ${imageProvider}`, "Use openai, gemini, or grok"));
118
103
  }
119
- }
120
- else if (imageProvider === "grok") {
121
- imageApiKey = (await getApiKey("XAI_API_KEY", "xAI")) ?? undefined;
122
- if (!imageApiKey) {
123
- exitWithError(authError("XAI_API_KEY", "xAI"));
104
+ if (!(await getApiKey(info.envVar, info.name))) {
105
+ exitWithError(authError(info.envVar, info.name));
124
106
  }
125
107
  }
126
- else {
127
- exitWithError(usageError(`Unknown image provider: ${imageProvider}`, "Use openai, gemini, or grok"));
128
- }
129
- let elevenlabsApiKey;
130
108
  if (options.voiceover !== false) {
131
- const key = await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs");
132
- if (!key) {
109
+ if (!(await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs"))) {
133
110
  exitWithError(authError("ELEVENLABS_API_KEY", "ElevenLabs"));
134
111
  }
135
- elevenlabsApiKey = key;
136
112
  }
137
- let videoApiKey;
138
113
  if (!options.imagesOnly) {
139
114
  const generatorKeyMap = {
140
115
  grok: { envVar: "XAI_API_KEY", name: "xAI" },
@@ -147,11 +122,9 @@ export function registerScriptPipelineCommands(aiCommand) {
147
122
  if (!genInfo) {
148
123
  exitWithError(usageError(`Invalid generator: ${generator}`, `Available: ${Object.keys(generatorKeyMap).join(", ")}`));
149
124
  }
150
- const key = await getApiKey(genInfo.envVar, genInfo.name);
151
- if (!key) {
125
+ if (!(await getApiKey(genInfo.envVar, genInfo.name))) {
152
126
  exitWithError(authError(genInfo.envVar, genInfo.name));
153
127
  }
154
- videoApiKey = key;
155
128
  }
156
129
  // Read script content
157
130
  let scriptContent = script;
@@ -159,67 +132,25 @@ export function registerScriptPipelineCommands(aiCommand) {
159
132
  const filePath = resolve(process.cwd(), script);
160
133
  scriptContent = await readFile(filePath, "utf-8");
161
134
  }
162
- // Grok and Veo route through executeScriptToVideo (fully supports all 4 providers).
163
- // Kling and Runway still use the inline path below pending full dedup (#38 follow-up).
164
- const delegatedGenerators = new Set(["grok", "veo"]);
165
- if (!options.imagesOnly && delegatedGenerators.has(options.generator)) {
166
- const pipelineSpinner = ora(`🎬 Running script-to-video with ${options.generator}...`).start();
167
- const result = await executeScriptToVideo({
168
- script: scriptContent,
169
- outputDir: options.outputDir,
170
- duration: options.duration ? parseFloat(options.duration) : undefined,
171
- voice: options.voice,
172
- generator: options.generator,
173
- imageProvider: options.imageProvider,
174
- aspectRatio: options.aspectRatio,
175
- imagesOnly: options.imagesOnly,
176
- noVoiceover: options.voiceover === false,
177
- retries: parseInt(options.retries) || DEFAULT_VIDEO_RETRIES,
178
- creativity: options.creativity,
179
- storyboardProvider: options.storyboardProvider,
180
- noTextOverlay: options.textOverlay === false,
181
- textStyle: options.textStyle,
182
- review: options.review,
183
- reviewAutoApply: options.reviewAutoApply,
184
- onProgress: (msg) => { pipelineSpinner.text = msg; },
185
- });
186
- if (!result.success) {
187
- pipelineSpinner.fail(chalk.red(result.error || "Script-to-Video failed"));
188
- exitWithError(apiError(result.error || "Script-to-Video failed", true));
189
- }
190
- pipelineSpinner.succeed(chalk.green(`Generated ${result.scenes} scene(s) → ${result.projectPath}`));
191
- outputResult({
192
- success: true,
193
- command: "pipeline script-to-video",
194
- result: {
195
- projectPath: result.projectPath,
196
- outputDir: result.outputDir,
197
- scenes: result.scenes,
198
- totalDuration: result.totalDuration,
199
- images: result.images?.length ?? 0,
200
- videos: result.videos?.length ?? 0,
201
- failedScenes: result.failedScenes ?? [],
202
- },
203
- });
204
- return;
205
- }
206
- // Determine output directory for assets
207
- // If -o looks like a directory and --output-dir is not explicitly set, use -o directory for assets
135
+ // Resolve -o / --output-dir semantics (identical to the old inline path):
136
+ // -o foo/ → outputDir = foo, project = foo/project.vibe.json
137
+ // -o foo.vibe.json → outputDir = default, project = foo.vibe.json
138
+ // -o foo → outputDir = foo, project = foo/project.vibe.json
208
139
  let effectiveOutputDir = options.outputDir;
209
140
  const outputLooksLikeDirectory = options.output.endsWith("/") ||
210
141
  (!options.output.endsWith(".json") && !options.output.endsWith(".vibe.json"));
211
142
  if (outputLooksLikeDirectory && options.outputDir === "script-video-output") {
212
- // User specified a directory for -o but didn't set --output-dir, use -o directory for assets
213
143
  effectiveOutputDir = options.output;
214
144
  }
215
- // Create output directory
216
- const outputDir = resolve(process.cwd(), effectiveOutputDir);
217
- if (!existsSync(outputDir)) {
218
- await mkdir(outputDir, { recursive: true });
145
+ let projectFilePath = resolve(process.cwd(), options.output);
146
+ if (outputLooksLikeDirectory) {
147
+ projectFilePath = resolve(projectFilePath, "project.vibe.json");
148
+ }
149
+ else if (existsSync(projectFilePath) && (await stat(projectFilePath)).isDirectory()) {
150
+ projectFilePath = resolve(projectFilePath, "project.vibe.json");
219
151
  }
220
- // Validate creativity level
221
- const creativity = options.creativity?.toLowerCase();
222
- if (creativity && creativity !== "low" && creativity !== "high") {
152
+ const creativity = (options.creativity ?? "low").toLowerCase();
153
+ if (creativity !== "low" && creativity !== "high") {
223
154
  exitWithError(usageError("Invalid creativity level.", "Use 'low' or 'high'."));
224
155
  }
225
156
  console.log();
@@ -229,852 +160,86 @@ export function registerScriptPipelineCommands(aiCommand) {
229
160
  console.log(chalk.yellow("🎨 High creativity mode: Generating varied, unexpected scenes"));
230
161
  }
231
162
  console.log();
232
- // Step 1: Generate storyboard
233
- const providerLabel = storyboardProvider.charAt(0).toUpperCase() + storyboardProvider.slice(1);
234
- const storyboardSpinnerText = creativity === "high"
235
- ? `Analyzing script with ${providerLabel} (high creativity)...`
236
- : `Analyzing script with ${providerLabel}...`;
237
- const storyboardSpinner = ora(storyboardSpinnerText).start();
238
- let segments;
239
- const creativityOpts = { creativity: creativity };
240
- const durationOpt = options.duration ? parseFloat(options.duration) : undefined;
241
- if (storyboardProvider === "openai") {
242
- const openai = new OpenAIProvider();
243
- await openai.initialize({ apiKey: storyboardApiKey });
244
- segments = await openai.analyzeContent(scriptContent, durationOpt, creativityOpts);
245
- }
246
- else if (storyboardProvider === "gemini") {
247
- const gemini = new GeminiProvider();
248
- await gemini.initialize({ apiKey: storyboardApiKey });
249
- segments = await gemini.analyzeContent(scriptContent, durationOpt, creativityOpts);
250
- }
251
- else {
252
- const claude = new ClaudeProvider();
253
- await claude.initialize({ apiKey: storyboardApiKey });
254
- segments = await claude.analyzeContent(scriptContent, durationOpt, creativityOpts);
255
- }
256
- if (segments.length === 0) {
257
- storyboardSpinner.fail("Failed to generate storyboard");
258
- exitWithError(apiError("Failed to generate storyboard (check API key and error above)", true));
259
- }
260
- let totalDuration = segments.reduce((sum, seg) => sum + seg.duration, 0);
261
- storyboardSpinner.succeed(chalk.green(`Generated ${segments.length} scenes (total: ${totalDuration}s)`));
262
- // Save storyboard
263
- const storyboardPath = resolve(outputDir, "storyboard.json");
264
- await writeFile(storyboardPath, JSON.stringify(segments, null, 2), "utf-8");
265
- console.log(chalk.dim(` → Saved: ${storyboardPath}`));
266
- console.log();
267
- // Step 2: Generate per-scene voiceovers with ElevenLabs
268
- const perSceneTTS = [];
269
- const failedNarrations = [];
270
- if (options.voiceover !== false && elevenlabsApiKey) {
271
- const ttsSpinner = ora("🎙️ Generating voiceovers with ElevenLabs...").start();
272
- const elevenlabs = new ElevenLabsProvider();
273
- await elevenlabs.initialize({ apiKey: elevenlabsApiKey });
274
- let totalCharacters = 0;
275
- for (let i = 0; i < segments.length; i++) {
276
- const segment = segments[i];
277
- const narrationText = segment.narration || segment.description;
278
- if (!narrationText)
279
- continue;
280
- // Pre-TTS word count validation
281
- const wordCount = narrationText.split(/\s+/).filter(Boolean).length;
282
- const maxWords = segment.duration > 5 ? 24 : 12;
283
- if (wordCount > maxWords * 1.3) {
284
- console.log(chalk.yellow(` ⚠ Scene ${i + 1} narration too long: ${wordCount} words (max ~${maxWords} for ${segment.duration}s). Trimming may cause rushed speech.`));
285
- }
286
- ttsSpinner.text = `🎙️ Generating narration ${i + 1}/${segments.length}...`;
287
- let ttsResult = await elevenlabs.textToSpeech(narrationText, {
288
- voiceId: options.voice,
289
- });
290
- if (!ttsResult.success || !ttsResult.audioBuffer) {
291
- const errorMsg = ttsResult.error || "Unknown error";
292
- failedNarrations.push({ sceneNum: i + 1, error: errorMsg });
293
- ttsSpinner.text = `🎙️ Generating narration ${i + 1}/${segments.length}... (failed)`;
294
- console.log(chalk.yellow(`\n ⚠ Narration ${i + 1} failed: ${errorMsg}`));
295
- continue;
296
- }
297
- const audioPath = resolve(outputDir, `narration-${i + 1}.mp3`);
298
- await writeFile(audioPath, ttsResult.audioBuffer);
299
- // Get actual audio duration using ffprobe
300
- let actualDuration = await getAudioDuration(audioPath);
301
- // Auto speed-adjust if narration exceeds video bracket (5s or 10s)
302
- const videoBracket = segment.duration > 5 ? 10 : 5;
303
- const overageRatio = actualDuration / videoBracket;
304
- if (overageRatio > 1.0 && overageRatio <= 1.35) {
305
- // Narration exceeds bracket by 0-35% — regenerate at faster speed
306
- const adjustedSpeed = Math.min(1.35, parseFloat(overageRatio.toFixed(2)));
307
- ttsSpinner.text = `🎙️ Narration ${i + 1}: adjusting speed to ${adjustedSpeed}x...`;
308
- const speedResult = await elevenlabs.textToSpeech(narrationText, {
309
- voiceId: options.voice,
310
- speed: adjustedSpeed,
311
- });
312
- if (speedResult.success && speedResult.audioBuffer) {
313
- await writeFile(audioPath, speedResult.audioBuffer);
314
- actualDuration = await getAudioDuration(audioPath);
315
- ttsResult = speedResult;
316
- console.log(chalk.dim(` → Speed-adjusted narration ${i + 1}: ${adjustedSpeed}x → ${actualDuration.toFixed(1)}s`));
317
- }
318
- }
319
- else if (overageRatio > 1.35) {
320
- // Narration way too long — warn user
321
- console.log(chalk.yellow(` ⚠ Narration ${i + 1} is ${((overageRatio - 1) * 100).toFixed(0)}% over target (${actualDuration.toFixed(1)}s vs ${videoBracket}s bracket)`));
322
- }
323
- // Update segment duration to match actual narration length
324
- segment.duration = actualDuration;
325
- perSceneTTS.push({ path: audioPath, duration: actualDuration, segmentIndex: i });
326
- totalCharacters += ttsResult.characterCount || 0;
327
- console.log(chalk.dim(` → Saved: ${audioPath} (${actualDuration.toFixed(1)}s)`));
328
- }
329
- // Recalculate startTime for all segments based on updated durations
330
- let currentTime = 0;
331
- for (const segment of segments) {
332
- segment.startTime = currentTime;
333
- currentTime += segment.duration;
334
- }
335
- // Update total duration
336
- totalDuration = segments.reduce((sum, seg) => sum + seg.duration, 0);
337
- // Show success with failed count if any
338
- if (failedNarrations.length > 0) {
339
- ttsSpinner.warn(chalk.yellow(`Generated ${perSceneTTS.length}/${segments.length} narrations (${failedNarrations.length} failed)`));
340
- }
341
- else {
342
- ttsSpinner.succeed(chalk.green(`Generated ${perSceneTTS.length}/${segments.length} narrations (${totalCharacters} chars, ${totalDuration.toFixed(1)}s total)`));
343
- }
344
- // Re-save storyboard with updated durations
345
- await writeFile(storyboardPath, JSON.stringify(segments, null, 2), "utf-8");
346
- console.log(chalk.dim(` → Updated storyboard: ${storyboardPath}`));
347
- console.log();
348
- }
349
- // Step 3: Generate images with selected provider
350
- const providerNames = {
351
- openai: "OpenAI GPT Image 1.5",
352
- dalle: "OpenAI GPT Image 1.5", // backward compatibility
353
- gemini: "Gemini",
354
- grok: "xAI Grok",
355
- };
356
- const imageSpinner = ora(`🎨 Generating visuals with ${providerNames[imageProvider]}...`).start();
357
- // Determine image size/aspect ratio based on provider
358
- const dalleImageSizes = {
359
- "16:9": "1536x1024",
360
- "9:16": "1024x1536",
361
- "1:1": "1024x1024",
362
- };
363
- const imagePaths = [];
364
- // Store first scene image for style continuity
365
- let firstSceneImage;
366
- // Initialize the selected provider
367
- let openaiImageInstance;
368
- let geminiInstance;
369
- let grokInstance;
370
- if (imageProvider === "openai" || imageProvider === "dalle") {
371
- openaiImageInstance = new OpenAIImageProvider();
372
- await openaiImageInstance.initialize({ apiKey: imageApiKey });
373
- }
374
- else if (imageProvider === "gemini") {
375
- geminiInstance = new GeminiProvider();
376
- await geminiInstance.initialize({ apiKey: imageApiKey });
377
- }
378
- else if (imageProvider === "grok") {
379
- grokInstance = new GrokProvider();
380
- await grokInstance.initialize({ apiKey: imageApiKey });
381
- }
382
- // Get character description from first segment (should be same across all)
383
- const characterDescription = segments[0]?.characterDescription;
384
- for (let i = 0; i < segments.length; i++) {
385
- const segment = segments[i];
386
- imageSpinner.text = `🎨 Generating image ${i + 1}/${segments.length}: ${segment.description.slice(0, 30)}...`;
387
- // Build comprehensive image prompt with character description
388
- let imagePrompt = segment.visuals;
389
- // Add character description to ensure consistency
390
- if (characterDescription) {
391
- imagePrompt = `CHARACTER (must match exactly): ${characterDescription}. SCENE: ${imagePrompt}`;
392
- }
393
- // Add visual style
394
- if (segment.visualStyle) {
395
- imagePrompt = `${imagePrompt}. STYLE: ${segment.visualStyle}`;
396
- }
397
- // For scenes after the first, add extra continuity instruction (OpenAI)
398
- // Gemini uses editImage with reference instead
399
- if (i > 0 && firstSceneImage && imageProvider !== "gemini") {
400
- imagePrompt = `${imagePrompt}. CRITICAL: The character must look IDENTICAL to the first scene - same face, hair, clothing, accessories.`;
401
- }
402
- try {
403
- let imageBuffer;
404
- let imageUrl;
405
- let imageError;
406
- if ((imageProvider === "openai" || imageProvider === "dalle") && openaiImageInstance) {
407
- const imageResult = await openaiImageInstance.generateImage(imagePrompt, {
408
- size: dalleImageSizes[options.aspectRatio] || "1536x1024",
409
- quality: "standard",
410
- });
411
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
412
- // GPT Image 1.5 returns base64, DALL-E 3 returns URL
413
- const img = imageResult.images[0];
414
- if (img.base64) {
415
- imageBuffer = Buffer.from(img.base64, "base64");
416
- }
417
- else if (img.url) {
418
- imageUrl = img.url;
419
- }
420
- }
421
- else {
422
- imageError = imageResult.error;
423
- }
424
- }
425
- else if (imageProvider === "gemini" && geminiInstance) {
426
- // Gemini: use editImage with first scene reference for subsequent scenes
427
- if (i > 0 && firstSceneImage) {
428
- // Use editImage to maintain style continuity with first scene
429
- const editPrompt = `Create a new scene for a video: ${imagePrompt}. IMPORTANT: Maintain the exact same character appearance, clothing, environment style, color palette, and art style as the reference image.`;
430
- const imageResult = await geminiInstance.editImage([firstSceneImage], editPrompt, {
431
- aspectRatio: options.aspectRatio,
432
- });
433
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
434
- const img = imageResult.images[0];
435
- if (img.base64) {
436
- imageBuffer = Buffer.from(img.base64, "base64");
437
- }
438
- }
439
- else {
440
- imageError = imageResult.error;
441
- }
442
- }
443
- else {
444
- // First scene: use regular generateImage
445
- const imageResult = await geminiInstance.generateImage(imagePrompt, {
446
- aspectRatio: options.aspectRatio,
447
- });
448
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
449
- const img = imageResult.images[0];
450
- if (img.base64) {
451
- imageBuffer = Buffer.from(img.base64, "base64");
452
- }
453
- }
454
- else {
455
- imageError = imageResult.error;
456
- }
457
- }
458
- }
459
- else if (imageProvider === "grok" && grokInstance) {
460
- const imageResult = await grokInstance.generateImage(imagePrompt, {
461
- aspectRatio: options.aspectRatio || "16:9",
462
- });
463
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
464
- const img = imageResult.images[0];
465
- if (img.base64) {
466
- imageBuffer = Buffer.from(img.base64, "base64");
467
- }
468
- else if (img.url) {
469
- imageUrl = img.url;
470
- }
471
- }
472
- else {
473
- imageError = imageResult.error;
474
- }
475
- }
476
- // Save the image
477
- const imagePath = resolve(outputDir, `scene-${i + 1}.png`);
478
- if (imageBuffer) {
479
- await writeFile(imagePath, imageBuffer);
480
- imagePaths.push(imagePath);
481
- // Store first successful image for style continuity
482
- if (!firstSceneImage) {
483
- firstSceneImage = imageBuffer;
484
- }
485
- }
486
- else if (imageUrl) {
487
- const response = await fetch(imageUrl);
488
- const buffer = Buffer.from(await response.arrayBuffer());
489
- await writeFile(imagePath, buffer);
490
- imagePaths.push(imagePath);
491
- // Store first successful image for style continuity
492
- if (!firstSceneImage) {
493
- firstSceneImage = buffer;
494
- }
495
- }
496
- else {
497
- const errorMsg = imageError || "Unknown error";
498
- console.log(chalk.yellow(`\n ⚠ Failed to generate image for scene ${i + 1}: ${errorMsg}`));
499
- imagePaths.push("");
500
- }
501
- }
502
- catch (err) {
503
- console.log(chalk.yellow(`\n ⚠ Error generating image for scene ${i + 1}: ${err}`));
504
- imagePaths.push("");
505
- }
506
- // Small delay to avoid rate limiting
507
- if (i < segments.length - 1) {
508
- await new Promise((r) => setTimeout(r, 500));
509
- }
510
- }
511
- const successfulImages = imagePaths.filter((p) => p !== "").length;
512
- imageSpinner.succeed(chalk.green(`Generated ${successfulImages}/${segments.length} images with ${providerNames[imageProvider]}`));
513
- console.log();
514
- // Step 4: Generate videos (if not images-only)
515
- const videoPaths = [];
516
- const failedScenes = []; // Track failed scenes for summary
517
- const maxRetries = parseInt(options.retries) || DEFAULT_VIDEO_RETRIES;
518
- if (!options.imagesOnly && videoApiKey) {
519
- // Guard already enforced above (before storyboard/image/narration);
520
- // reaching here means generator ∈ {kling, runway}.
521
- const providerLabel = options.generator === "kling" ? "Kling" : "Runway";
522
- const videoSpinner = ora(`🎬 Generating videos with ${providerLabel}...`).start();
523
- if (options.generator === "kling") {
524
- const kling = new KlingProvider();
525
- await kling.initialize({ apiKey: videoApiKey });
526
- if (!kling.isConfigured()) {
527
- videoSpinner.fail("Invalid Kling API key format");
528
- exitWithError(authError("KLING_API_KEY", "Kling"));
529
- }
530
- // Check for ImgBB API key for image-to-video support (from config or env)
531
- const imgbbApiKey = await getApiKeyFromConfig("imgbb") || process.env.IMGBB_API_KEY;
532
- const useImageToVideo = !!imgbbApiKey;
533
- if (useImageToVideo) {
534
- videoSpinner.text = `🎬 Uploading images to ImgBB for image-to-video...`;
535
- }
536
- // Upload images to ImgBB if API key is available (for Kling v2.x image-to-video)
537
- const imageUrls = [];
538
- if (useImageToVideo) {
539
- for (let i = 0; i < imagePaths.length; i++) {
540
- if (imagePaths[i] && imagePaths[i] !== "") {
541
- try {
542
- const imageBuffer = await readFile(imagePaths[i]);
543
- const uploadResult = await uploadToImgbb(imageBuffer, imgbbApiKey);
544
- if (uploadResult.success && uploadResult.url) {
545
- imageUrls[i] = uploadResult.url;
546
- }
547
- else {
548
- console.log(chalk.yellow(`\n ⚠ Failed to upload image ${i + 1}: ${uploadResult.error}`));
549
- imageUrls[i] = undefined;
550
- }
551
- }
552
- catch {
553
- imageUrls[i] = undefined;
554
- }
555
- }
556
- else {
557
- imageUrls[i] = undefined;
558
- }
559
- }
560
- const uploadedCount = imageUrls.filter((u) => u).length;
561
- if (uploadedCount > 0) {
562
- videoSpinner.text = `🎬 Uploaded ${uploadedCount}/${imagePaths.length} images to ImgBB`;
563
- }
564
- }
565
- // Sequential mode: generate one video at a time (slower but more reliable)
566
- if (options.sequential) {
567
- for (let i = 0; i < segments.length; i++) {
568
- const segment = segments[i];
569
- videoSpinner.text = `🎬 Scene ${i + 1}/${segments.length}: Starting...`;
570
- const videoDuration = (segment.duration > 5 ? 10 : 5);
571
- const referenceImage = imageUrls[i];
572
- let completed = false;
573
- for (let attempt = 0; attempt <= maxRetries && !completed; attempt++) {
574
- const result = await generateVideoWithRetryKling(kling, segment, {
575
- duration: videoDuration,
576
- aspectRatio: options.aspectRatio,
577
- referenceImage,
578
- }, 0, // Handle retries at this level
579
- (msg) => {
580
- videoSpinner.text = `🎬 Scene ${i + 1}/${segments.length}: ${msg}`;
581
- });
582
- if (!result) {
583
- if (attempt < maxRetries) {
584
- videoSpinner.text = `🎬 Scene ${i + 1}: Submit failed, retry ${attempt + 1}/${maxRetries}...`;
585
- await sleep(RETRY_DELAY_MS);
586
- continue;
587
- }
588
- console.log(chalk.yellow(`\n ⚠ Failed to start video generation for scene ${i + 1}`));
589
- videoPaths[i] = "";
590
- failedScenes.push(i + 1);
591
- break;
592
- }
593
- try {
594
- const waitResult = await kling.waitForCompletion(result.taskId, result.type, (status) => {
595
- videoSpinner.text = `🎬 Scene ${i + 1}/${segments.length}: ${status.status}...`;
596
- }, 600000);
597
- if (waitResult.status === "completed" && waitResult.videoUrl) {
598
- const videoPath = resolve(outputDir, `scene-${i + 1}.mp4`);
599
- const buffer = await downloadVideo(waitResult.videoUrl, videoApiKey);
600
- await writeFile(videoPath, buffer);
601
- // Extend video to match narration duration if needed
602
- await extendVideoToTarget(videoPath, segment.duration, outputDir, `Scene ${i + 1}`, {
603
- kling,
604
- videoId: waitResult.videoId,
605
- onProgress: (msg) => { videoSpinner.text = `🎬 ${msg}`; },
606
- });
607
- videoPaths[i] = videoPath;
608
- completed = true;
609
- console.log(chalk.green(`\n ✓ Scene ${i + 1} completed`));
610
- }
611
- else if (attempt < maxRetries) {
612
- videoSpinner.text = `🎬 Scene ${i + 1}: Failed, retry ${attempt + 1}/${maxRetries}...`;
613
- await sleep(RETRY_DELAY_MS);
614
- }
615
- else {
616
- videoPaths[i] = "";
617
- failedScenes.push(i + 1);
618
- }
619
- }
620
- catch (err) {
621
- if (attempt < maxRetries) {
622
- videoSpinner.text = `🎬 Scene ${i + 1}: Error, retry ${attempt + 1}/${maxRetries}...`;
623
- await sleep(RETRY_DELAY_MS);
624
- }
625
- else {
626
- console.log(chalk.yellow(`\n ⚠ Error for scene ${i + 1}: ${err}`));
627
- videoPaths[i] = "";
628
- failedScenes.push(i + 1);
629
- }
630
- }
631
- }
632
- }
633
- }
634
- else {
635
- // Parallel mode (default): batch-based submission respecting concurrency limit
636
- const concurrency = Math.max(1, parseInt(options.concurrency) || 3);
637
- for (let batchStart = 0; batchStart < segments.length; batchStart += concurrency) {
638
- const batchEnd = Math.min(batchStart + concurrency, segments.length);
639
- const batchNum = Math.floor(batchStart / concurrency) + 1;
640
- const totalBatches = Math.ceil(segments.length / concurrency);
641
- if (totalBatches > 1) {
642
- videoSpinner.text = `🎬 Batch ${batchNum}/${totalBatches}: submitting scenes ${batchStart + 1}-${batchEnd}...`;
643
- }
644
- // Phase 1: Submit batch
645
- const tasks = [];
646
- for (let i = batchStart; i < batchEnd; i++) {
647
- const segment = segments[i];
648
- videoSpinner.text = `🎬 Submitting video task ${i + 1}/${segments.length}...`;
649
- const videoDuration = (segment.duration > 5 ? 10 : 5);
650
- const referenceImage = imageUrls[i];
651
- const result = await generateVideoWithRetryKling(kling, segment, {
652
- duration: videoDuration,
653
- aspectRatio: options.aspectRatio,
654
- referenceImage,
655
- }, maxRetries, (msg) => {
656
- videoSpinner.text = `🎬 Scene ${i + 1}: ${msg}`;
657
- });
658
- if (result) {
659
- tasks.push({ taskId: result.taskId, index: i, segment, type: result.type });
660
- if (!videoPaths[i])
661
- videoPaths[i] = "";
662
- }
663
- else {
664
- console.log(chalk.yellow(`\n ⚠ Failed to start video generation for scene ${i + 1} (after ${maxRetries} retries)`));
665
- videoPaths[i] = "";
666
- failedScenes.push(i + 1);
667
- }
668
- }
669
- // Phase 2: Wait for batch completion
670
- videoSpinner.text = `🎬 Waiting for batch ${batchNum} (${tasks.length} video${tasks.length > 1 ? "s" : ""})...`;
671
- for (const task of tasks) {
672
- let completed = false;
673
- let currentTaskId = task.taskId;
674
- let currentType = task.type;
675
- for (let attempt = 0; attempt <= maxRetries && !completed; attempt++) {
676
- try {
677
- const result = await kling.waitForCompletion(currentTaskId, currentType, (status) => {
678
- videoSpinner.text = `🎬 Scene ${task.index + 1}: ${status.status}...`;
679
- }, 600000);
680
- if (result.status === "completed" && result.videoUrl) {
681
- const videoPath = resolve(outputDir, `scene-${task.index + 1}.mp4`);
682
- const buffer = await downloadVideo(result.videoUrl, videoApiKey);
683
- await writeFile(videoPath, buffer);
684
- // Extend video to match narration duration if needed
685
- await extendVideoToTarget(videoPath, task.segment.duration, outputDir, `Scene ${task.index + 1}`, {
686
- kling,
687
- videoId: result.videoId,
688
- onProgress: (msg) => { videoSpinner.text = `🎬 ${msg}`; },
689
- });
690
- videoPaths[task.index] = videoPath;
691
- completed = true;
692
- }
693
- else if (attempt < maxRetries) {
694
- videoSpinner.text = `🎬 Scene ${task.index + 1}: Retry ${attempt + 1}/${maxRetries}...`;
695
- await sleep(RETRY_DELAY_MS);
696
- const videoDuration = (task.segment.duration > 5 ? 10 : 5);
697
- const retryReferenceImage = imageUrls[task.index];
698
- const retryResult = await generateVideoWithRetryKling(kling, task.segment, {
699
- duration: videoDuration,
700
- aspectRatio: options.aspectRatio,
701
- referenceImage: retryReferenceImage,
702
- }, 0);
703
- if (retryResult) {
704
- currentTaskId = retryResult.taskId;
705
- currentType = retryResult.type;
706
- }
707
- else {
708
- videoPaths[task.index] = "";
709
- failedScenes.push(task.index + 1);
710
- completed = true;
711
- }
712
- }
713
- else {
714
- videoPaths[task.index] = "";
715
- failedScenes.push(task.index + 1);
716
- }
717
- }
718
- catch (err) {
719
- if (attempt >= maxRetries) {
720
- console.log(chalk.yellow(`\n ⚠ Error completing video for scene ${task.index + 1}: ${err}`));
721
- videoPaths[task.index] = "";
722
- failedScenes.push(task.index + 1);
723
- }
724
- else {
725
- videoSpinner.text = `🎬 Scene ${task.index + 1}: Error, retry ${attempt + 1}/${maxRetries}...`;
726
- await sleep(RETRY_DELAY_MS);
727
- }
728
- }
729
- }
730
- }
731
- if (totalBatches > 1 && batchEnd < segments.length) {
732
- console.log(chalk.dim(` → Batch ${batchNum}/${totalBatches} complete`));
733
- }
734
- }
735
- }
736
- }
737
- else {
738
- // Runway
739
- const runway = new RunwayProvider();
740
- await runway.initialize({ apiKey: videoApiKey });
741
- // Submit all video generation tasks with retry logic
742
- const tasks = [];
743
- for (let i = 0; i < segments.length; i++) {
744
- if (!imagePaths[i]) {
745
- videoPaths.push("");
746
- continue;
747
- }
748
- const segment = segments[i];
749
- videoSpinner.text = `🎬 Submitting video task ${i + 1}/${segments.length}...`;
750
- const imageBuffer = await readFile(imagePaths[i]);
751
- const ext = extname(imagePaths[i]).toLowerCase().slice(1);
752
- const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
753
- const referenceImage = `data:${mimeType};base64,${imageBuffer.toString("base64")}`;
754
- // Use 10s video if narration > 5s to avoid video ending before narration
755
- const videoDuration = (segment.duration > 5 ? 10 : 5);
756
- const result = await generateVideoWithRetryRunway(runway, segment, referenceImage, {
757
- duration: videoDuration,
758
- aspectRatio: options.aspectRatio === "1:1" ? "16:9" : options.aspectRatio,
759
- }, maxRetries, (msg) => {
760
- videoSpinner.text = `🎬 Scene ${i + 1}: ${msg}`;
761
- });
762
- if (result) {
763
- tasks.push({ taskId: result.taskId, index: i, imagePath: imagePaths[i], referenceImage, segment });
764
- }
765
- else {
766
- console.log(chalk.yellow(`\n ⚠ Failed to start video generation for scene ${i + 1} (after ${maxRetries} retries)`));
767
- videoPaths[i] = "";
768
- failedScenes.push(i + 1);
769
- }
770
- }
771
- // Wait for all tasks to complete with retry logic
772
- videoSpinner.text = `🎬 Waiting for ${tasks.length} video(s) to complete...`;
773
- for (const task of tasks) {
774
- let completed = false;
775
- let currentTaskId = task.taskId;
776
- for (let attempt = 0; attempt <= maxRetries && !completed; attempt++) {
777
- try {
778
- const result = await runway.waitForCompletion(currentTaskId, (status) => {
779
- const progress = status.progress !== undefined ? `${status.progress}%` : status.status;
780
- videoSpinner.text = `🎬 Scene ${task.index + 1}: ${progress}...`;
781
- }, 300000 // 5 minute timeout per video
782
- );
783
- if (result.status === "completed" && result.videoUrl) {
784
- const videoPath = resolve(outputDir, `scene-${task.index + 1}.mp4`);
785
- const buffer = await downloadVideo(result.videoUrl, videoApiKey);
786
- await writeFile(videoPath, buffer);
787
- // Extend video to match narration duration if needed
788
- await extendVideoToTarget(videoPath, task.segment.duration, outputDir, `Scene ${task.index + 1}`, {
789
- onProgress: (msg) => { videoSpinner.text = `🎬 ${msg}`; },
790
- });
791
- videoPaths[task.index] = videoPath;
792
- completed = true;
793
- }
794
- else if (attempt < maxRetries) {
795
- // Resubmit task on failure
796
- videoSpinner.text = `🎬 Scene ${task.index + 1}: Retry ${attempt + 1}/${maxRetries}...`;
797
- await sleep(RETRY_DELAY_MS);
798
- const videoDuration = (task.segment.duration > 5 ? 10 : 5);
799
- const retryResult = await generateVideoWithRetryRunway(runway, task.segment, task.referenceImage, {
800
- duration: videoDuration,
801
- aspectRatio: options.aspectRatio === "1:1" ? "16:9" : options.aspectRatio,
802
- }, 0, // No nested retries
803
- (msg) => {
804
- videoSpinner.text = `🎬 Scene ${task.index + 1}: ${msg}`;
805
- });
806
- if (retryResult) {
807
- currentTaskId = retryResult.taskId;
808
- }
809
- else {
810
- videoPaths[task.index] = "";
811
- failedScenes.push(task.index + 1);
812
- completed = true; // Exit retry loop
813
- }
814
- }
815
- else {
816
- videoPaths[task.index] = "";
817
- failedScenes.push(task.index + 1);
818
- }
819
- }
820
- catch (err) {
821
- if (attempt >= maxRetries) {
822
- console.log(chalk.yellow(`\n ⚠ Error completing video for scene ${task.index + 1}: ${err}`));
823
- videoPaths[task.index] = "";
824
- failedScenes.push(task.index + 1);
825
- }
826
- else {
827
- videoSpinner.text = `🎬 Scene ${task.index + 1}: Error, retry ${attempt + 1}/${maxRetries}...`;
828
- await sleep(RETRY_DELAY_MS);
829
- }
830
- }
831
- }
832
- }
833
- }
834
- const successfulVideos = videoPaths.filter((p) => p && p !== "").length;
835
- videoSpinner.succeed(chalk.green(`Generated ${successfulVideos}/${segments.length} videos`));
836
- console.log();
837
- }
838
- // Step 4.5: Apply text overlays (if segments have textOverlays)
839
- if (options.textOverlay !== false) {
840
- const overlaySegments = segments.filter((s, i) => s.textOverlays && s.textOverlays.length > 0 && videoPaths[i] && videoPaths[i] !== "");
841
- if (overlaySegments.length > 0) {
842
- const overlaySpinner = ora(`Applying text overlays to ${overlaySegments.length} scene(s)...`).start();
843
- let overlayCount = 0;
844
- for (let i = 0; i < segments.length; i++) {
845
- const segment = segments[i];
846
- if (segment.textOverlays && segment.textOverlays.length > 0 && videoPaths[i] && videoPaths[i] !== "") {
847
- try {
848
- const overlayOutput = videoPaths[i].replace(/(\.[^.]+)$/, "-overlay$1");
849
- const overlayResult = await applyTextOverlays({
850
- videoPath: videoPaths[i],
851
- texts: segment.textOverlays,
852
- outputPath: overlayOutput,
853
- style: options.textStyle || "lower-third",
854
- });
855
- if (overlayResult.success && overlayResult.outputPath) {
856
- videoPaths[i] = overlayResult.outputPath;
857
- overlayCount++;
858
- }
859
- }
860
- catch {
861
- // Silent fallback: keep original video
862
- }
863
- }
864
- }
865
- overlaySpinner.succeed(chalk.green(`Applied text overlays to ${overlayCount} scene(s)`));
866
- console.log();
867
- }
868
- }
869
- // Step 5: Assemble project
870
- const assembleSpinner = ora("Assembling project...").start();
871
- const project = new Project("Script-to-Video Output");
872
- project.setAspectRatio(options.aspectRatio);
873
- // Clear default tracks and create new ones
874
- const defaultTracks = project.getTracks();
875
- for (const track of defaultTracks) {
876
- project.removeTrack(track.id);
877
- }
878
- const videoTrack = project.addTrack({
879
- name: "Video",
880
- type: "video",
881
- order: 1,
882
- isMuted: false,
883
- isLocked: false,
884
- isVisible: true,
885
- });
886
- const audioTrack = project.addTrack({
887
- name: "Audio",
888
- type: "audio",
889
- order: 0,
890
- isMuted: false,
891
- isLocked: false,
892
- isVisible: true,
163
+ const pipelineSpinner = ora(`🎬 Running script-to-video with ${options.generator}...`).start();
164
+ const result = await executeScriptToVideo({
165
+ script: scriptContent,
166
+ outputDir: effectiveOutputDir,
167
+ projectFilePath,
168
+ duration: options.duration ? parseFloat(options.duration) : undefined,
169
+ voice: options.voice,
170
+ generator: options.generator,
171
+ imageProvider: options.imageProvider,
172
+ aspectRatio: options.aspectRatio,
173
+ imagesOnly: options.imagesOnly,
174
+ noVoiceover: options.voiceover === false,
175
+ retries: parseInt(options.retries) || DEFAULT_VIDEO_RETRIES,
176
+ creativity: creativity,
177
+ storyboardProvider: options.storyboardProvider,
178
+ noTextOverlay: options.textOverlay === false,
179
+ textStyle: options.textStyle,
180
+ review: options.review,
181
+ reviewAutoApply: options.reviewAutoApply,
182
+ onProgress: (msg) => { pipelineSpinner.text = msg; },
893
183
  });
894
- // Add per-scene narration sources and clips
895
- for (const tts of perSceneTTS) {
896
- const segment = segments[tts.segmentIndex];
897
- const audioSource = project.addSource({
898
- name: `Narration ${tts.segmentIndex + 1}`,
899
- url: tts.path,
900
- type: "audio",
901
- duration: tts.duration,
902
- });
903
- project.addClip({
904
- sourceId: audioSource.id,
905
- trackId: audioTrack.id,
906
- startTime: segment.startTime,
907
- duration: tts.duration,
908
- sourceStartOffset: 0,
909
- sourceEndOffset: tts.duration,
910
- });
911
- }
912
- // Add video/image sources and clips
913
- let currentTime = 0;
914
- const videoClipIds = [];
915
- const fadeDuration = 0.3; // Fade duration in seconds for smooth transitions
916
- for (let i = 0; i < segments.length; i++) {
917
- const segment = segments[i];
918
- const hasVideo = videoPaths[i] && videoPaths[i] !== "";
919
- const hasImage = imagePaths[i] && imagePaths[i] !== "";
920
- if (!hasVideo && !hasImage) {
921
- // Skip if no visual asset
922
- currentTime += segment.duration;
923
- continue;
924
- }
925
- const assetPath = hasVideo ? videoPaths[i] : imagePaths[i];
926
- const mediaType = hasVideo ? "video" : "image";
927
- const source = project.addSource({
928
- name: `Scene ${i + 1}`,
929
- url: assetPath,
930
- type: mediaType,
931
- duration: segment.duration,
932
- });
933
- const clip = project.addClip({
934
- sourceId: source.id,
935
- trackId: videoTrack.id,
936
- startTime: currentTime,
937
- duration: segment.duration,
938
- sourceStartOffset: 0,
939
- sourceEndOffset: segment.duration,
940
- });
941
- videoClipIds.push(clip.id);
942
- currentTime += segment.duration;
943
- }
944
- // Add fade effects to video clips for smoother scene transitions
945
- for (let i = 0; i < videoClipIds.length; i++) {
946
- const clipId = videoClipIds[i];
947
- const clip = project.getClips().find(c => c.id === clipId);
948
- if (!clip)
949
- continue;
950
- // Add fadeIn effect (except for first clip)
951
- if (i > 0) {
952
- project.addEffect(clipId, {
953
- type: "fadeIn",
954
- startTime: 0,
955
- duration: fadeDuration,
956
- params: {},
957
- });
958
- }
959
- // Add fadeOut effect (except for last clip)
960
- if (i < videoClipIds.length - 1) {
961
- project.addEffect(clipId, {
962
- type: "fadeOut",
963
- startTime: clip.duration - fadeDuration,
964
- duration: fadeDuration,
965
- params: {},
966
- });
967
- }
968
- }
969
- // Save project file
970
- let outputPath = resolve(process.cwd(), options.output);
971
- // Detect if output looks like a directory (ends with / or no .json extension)
972
- const looksLikeDirectory = options.output.endsWith("/") ||
973
- (!options.output.endsWith(".json") &&
974
- !options.output.endsWith(".vibe.json"));
975
- if (looksLikeDirectory) {
976
- // Create directory if it doesn't exist
977
- if (!existsSync(outputPath)) {
978
- await mkdir(outputPath, { recursive: true });
979
- }
980
- outputPath = resolve(outputPath, "project.vibe.json");
981
- }
982
- else if (existsSync(outputPath) &&
983
- (await stat(outputPath)).isDirectory()) {
984
- // Existing directory without trailing slash
985
- outputPath = resolve(outputPath, "project.vibe.json");
986
- }
987
- else {
988
- // File path - ensure parent directory exists
989
- const parentDir = dirname(outputPath);
990
- if (!existsSync(parentDir)) {
991
- await mkdir(parentDir, { recursive: true });
992
- }
993
- }
994
- await writeFile(outputPath, JSON.stringify(project.toJSON(), null, 2), "utf-8");
995
- assembleSpinner.succeed(chalk.green("Project assembled"));
996
- // Step 6: AI Review (optional)
997
- if (options.review) {
998
- const reviewSpinner = ora("Reviewing video with Gemini AI...").start();
999
- try {
1000
- const reviewTarget = videoPaths.find((p) => p && p !== "");
1001
- if (reviewTarget) {
1002
- const storyboardFile = resolve(effectiveOutputDir, "storyboard.json");
1003
- const reviewResult = await executeReview({
1004
- videoPath: reviewTarget,
1005
- storyboardPath: existsSync(storyboardFile) ? storyboardFile : undefined,
1006
- autoApply: options.reviewAutoApply,
1007
- model: "flash",
1008
- });
1009
- if (reviewResult.success && reviewResult.feedback) {
1010
- reviewSpinner.succeed(chalk.green(`AI Review: ${reviewResult.feedback.overallScore}/10`));
1011
- if (reviewResult.appliedFixes && reviewResult.appliedFixes.length > 0) {
1012
- for (const fix of reviewResult.appliedFixes) {
1013
- console.log(chalk.green(` + ${fix}`));
1014
- }
1015
- }
1016
- if (reviewResult.feedback.recommendations.length > 0) {
1017
- for (const rec of reviewResult.feedback.recommendations) {
1018
- console.log(chalk.dim(` * ${rec}`));
1019
- }
1020
- }
1021
- }
1022
- else {
1023
- reviewSpinner.warn(chalk.yellow("AI review completed but no actionable feedback"));
1024
- }
1025
- }
1026
- else {
1027
- reviewSpinner.warn(chalk.yellow("No videos available for review"));
1028
- }
1029
- }
1030
- catch {
1031
- reviewSpinner.warn(chalk.yellow("AI review skipped (non-critical error)"));
1032
- }
1033
- console.log();
1034
- }
1035
- // Final summary
184
+ if (!result.success) {
185
+ pipelineSpinner.fail(chalk.red(result.error || "Script-to-Video failed"));
186
+ exitWithError(apiError(result.error || "Script-to-Video failed", true));
187
+ }
188
+ pipelineSpinner.succeed(chalk.green(`Generated ${result.scenes} scene(s) → ${result.projectPath}`));
189
+ // Final summary (presentational; keeps parity with the pre-thin-wrap CLI).
190
+ const narrationCount = (result.narrationEntries ?? []).filter((e) => e.path).length;
191
+ const failedNarrationNums = result.failedNarrations ?? [];
192
+ const failedSceneNums = [...new Set(result.failedScenes ?? [])].sort((a, b) => a - b);
193
+ const imageCount = result.images?.length ?? 0;
194
+ const videoCount = result.videos?.length ?? 0;
1036
195
  console.log();
1037
196
  console.log(chalk.bold.green("Script-to-Video complete!"));
1038
197
  console.log(chalk.dim("─".repeat(60)));
1039
198
  console.log();
1040
- console.log(` 📄 Project: ${chalk.cyan(outputPath)}`);
1041
- console.log(` 🎬 Scenes: ${segments.length}`);
1042
- console.log(` ⏱️ Duration: ${totalDuration}s`);
199
+ console.log(` 📄 Project: ${chalk.cyan(result.projectPath)}`);
200
+ console.log(` 🎬 Scenes: ${result.scenes}`);
201
+ console.log(` ⏱️ Duration: ${result.totalDuration ?? 0}s`);
1043
202
  console.log(` 📁 Assets: ${effectiveOutputDir}/`);
1044
- if (perSceneTTS.length > 0 || failedNarrations.length > 0) {
1045
- const narrationInfo = `${perSceneTTS.length}/${segments.length}`;
1046
- if (failedNarrations.length > 0) {
1047
- const failedSceneNums = failedNarrations.map((f) => f.sceneNum).join(", ");
1048
- console.log(` 🎙️ Narrations: ${narrationInfo} narration-*.mp3`);
1049
- console.log(chalk.yellow(` ⚠ Failed: scene ${failedSceneNums}`));
1050
- }
1051
- else {
1052
- console.log(` 🎙️ Narrations: ${perSceneTTS.length} narration-*.mp3`);
203
+ if (narrationCount > 0 || failedNarrationNums.length > 0) {
204
+ console.log(` 🎙️ Narrations: ${narrationCount}/${result.scenes} narration-*.mp3`);
205
+ if (failedNarrationNums.length > 0) {
206
+ console.log(chalk.yellow(` ⚠ Failed: scene ${failedNarrationNums.join(", ")}`));
1053
207
  }
1054
208
  }
1055
- console.log(` 🖼️ Images: ${successfulImages} scene-*.png`);
209
+ console.log(` 🖼️ Images: ${imageCount} scene-*.png`);
1056
210
  if (!options.imagesOnly) {
1057
- const videoCount = videoPaths.filter((p) => p && p !== "").length;
1058
- console.log(` 🎥 Videos: ${videoCount}/${segments.length} scene-*.mp4`);
1059
- if (failedScenes.length > 0) {
1060
- const uniqueFailedScenes = [...new Set(failedScenes)].sort((a, b) => a - b);
1061
- console.log(chalk.yellow(` ⚠ Failed: scene ${uniqueFailedScenes.join(", ")} (fallback to image)`));
211
+ console.log(` 🎥 Videos: ${videoCount}/${result.scenes} scene-*.mp4`);
212
+ if (failedSceneNums.length > 0) {
213
+ console.log(chalk.yellow(` ⚠ Failed: scene ${failedSceneNums.join(", ")} (fallback to image)`));
1062
214
  }
1063
215
  }
1064
216
  console.log();
1065
217
  console.log(chalk.dim("Next steps:"));
1066
218
  console.log(chalk.dim(` vibe project info ${options.output}`));
1067
219
  console.log(chalk.dim(` vibe export ${options.output} -o final.mp4`));
1068
- // Show regeneration hint if there were failures
1069
- if (!options.imagesOnly && failedScenes.length > 0) {
1070
- const uniqueFailedScenes = [...new Set(failedScenes)].sort((a, b) => a - b);
220
+ if (!options.imagesOnly && failedSceneNums.length > 0) {
1071
221
  console.log();
1072
222
  console.log(chalk.dim("💡 To regenerate failed scenes:"));
1073
- for (const sceneNum of uniqueFailedScenes) {
223
+ for (const sceneNum of failedSceneNums) {
1074
224
  console.log(chalk.dim(` vibe ai regenerate-scene ${effectiveOutputDir}/ --scene ${sceneNum} --video-only`));
1075
225
  }
1076
226
  }
1077
227
  console.log();
228
+ // JSON shape is byte-identical to the pre-thin-wrap delegation block;
229
+ // agent callers depend on these exact fields and counts.
230
+ outputResult({
231
+ success: true,
232
+ command: "pipeline script-to-video",
233
+ result: {
234
+ projectPath: result.projectPath,
235
+ outputDir: result.outputDir,
236
+ scenes: result.scenes,
237
+ totalDuration: result.totalDuration,
238
+ images: result.images?.length ?? 0,
239
+ videos: result.videos?.length ?? 0,
240
+ failedScenes: result.failedScenes ?? [],
241
+ },
242
+ });
1078
243
  }
1079
244
  catch (error) {
1080
245
  exitWithError(generalError(error instanceof Error ? error.message : "Script-to-Video failed"));
@@ -1099,17 +264,25 @@ export function registerScriptPipelineCommands(aiCommand) {
1099
264
  .action(async (projectDir, options) => {
1100
265
  try {
1101
266
  const outputDir = resolve(process.cwd(), projectDir);
1102
- const storyboardPath = resolve(outputDir, "storyboard.json");
1103
267
  const projectPath = resolve(outputDir, "project.vibe.json");
1104
- // Validate project directory
1105
268
  if (!existsSync(outputDir)) {
1106
269
  exitWithError(notFoundError(outputDir));
1107
270
  }
1108
- if (!existsSync(storyboardPath)) {
1109
- exitWithError(notFoundError(storyboardPath));
1110
- }
1111
- // Parse scene number(s) - supports "3" or "3,4,5"
1112
- const sceneNums = options.scene.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n >= 1);
271
+ // Storyboard: prefer YAML (current executeScriptToVideo output), fall back
272
+ // to JSON (pre-0.48.6 inline kling/runway output). Track source format so
273
+ // the CLI's project-file update can re-read it after the library has
274
+ // rewritten segment durations.
275
+ const yamlPath = resolve(outputDir, "storyboard.yaml");
276
+ const jsonPath = resolve(outputDir, "storyboard.json");
277
+ const storyboardPath = existsSync(yamlPath) ? yamlPath : existsSync(jsonPath) ? jsonPath : null;
278
+ const storyboardIsYaml = storyboardPath === yamlPath;
279
+ if (!storyboardPath) {
280
+ exitWithError(notFoundError(`${outputDir}/storyboard.{yaml,json}`));
281
+ }
282
+ const sceneNums = options.scene
283
+ .split(",")
284
+ .map((s) => parseInt(s.trim()))
285
+ .filter((n) => !isNaN(n) && n >= 1);
1113
286
  if (sceneNums.length === 0) {
1114
287
  exitWithError(usageError("Scene number must be a positive integer (1-based)", "e.g., --scene 3 or --scene 3,4,5"));
1115
288
  }
@@ -1132,50 +305,35 @@ export function registerScriptPipelineCommands(aiCommand) {
1132
305
  });
1133
306
  return;
1134
307
  }
1135
- // Load storyboard
1136
- const storyboardContent = await readFile(storyboardPath, "utf-8");
1137
- const segments = JSON.parse(storyboardContent);
1138
- // Validate all scene numbers
308
+ // Validate scene numbers against the on-disk storyboard so we fail before
309
+ // any API calls. executeRegenerateScene validates too, but a usage exit
310
+ // code is friendlier than API_ERROR here.
311
+ const content = await readFile(storyboardPath, "utf-8");
312
+ const segments = storyboardIsYaml
313
+ ? yamlParse(content).scenes
314
+ : JSON.parse(content);
1139
315
  for (const sceneNum of sceneNums) {
1140
316
  if (sceneNum > segments.length) {
1141
317
  exitWithError(usageError(`Scene ${sceneNum} does not exist. Storyboard has ${segments.length} scenes.`));
1142
318
  }
1143
319
  }
1144
- // Determine what to regenerate
1145
320
  const regenerateVideo = options.videoOnly || (!options.narrationOnly && !options.imageOnly);
1146
321
  const regenerateNarration = options.narrationOnly || (!options.videoOnly && !options.imageOnly);
1147
322
  const regenerateImage = options.imageOnly || (!options.videoOnly && !options.narrationOnly);
1148
- console.log();
1149
- console.log(chalk.bold.cyan(`🔄 Regenerating Scene${sceneNums.length > 1 ? "s" : ""} ${sceneNums.join(", ")}`));
1150
- console.log(chalk.dim("─".repeat(60)));
1151
- console.log();
1152
- console.log(` 📁 Project: ${outputDir}`);
1153
- console.log(` 🎬 Scenes: ${sceneNums.join(", ")} of ${segments.length}`);
1154
- console.log();
1155
- // Get required API keys (once, before processing scenes)
1156
- let imageApiKey;
1157
- let videoApiKey;
1158
- let elevenlabsApiKey;
323
+ // API-key pre-check for friendly AUTH exit codes (library re-checks).
1159
324
  if (regenerateImage) {
1160
- const imageProvider = options.imageProvider || "openai";
1161
- if (imageProvider === "openai" || imageProvider === "dalle") {
1162
- imageApiKey = (await getApiKey("OPENAI_API_KEY", "OpenAI")) ?? undefined;
1163
- if (!imageApiKey) {
1164
- exitWithError(authError("OPENAI_API_KEY", "OpenAI"));
1165
- }
1166
- }
1167
- else if (imageProvider === "gemini") {
1168
- imageApiKey = (await getApiKey("GOOGLE_API_KEY", "Google")) ?? undefined;
1169
- if (!imageApiKey) {
1170
- exitWithError(authError("GOOGLE_API_KEY", "Google"));
1171
- }
1172
- }
1173
- else if (imageProvider === "grok") {
1174
- imageApiKey = (await getApiKey("XAI_API_KEY", "xAI")) ?? undefined;
1175
- if (!imageApiKey) {
1176
- exitWithError(authError("XAI_API_KEY", "xAI"));
1177
- }
1178
- }
325
+ const provider = (options.imageProvider || "openai");
326
+ const keyMap = {
327
+ openai: { envVar: "OPENAI_API_KEY", name: "OpenAI" },
328
+ dalle: { envVar: "OPENAI_API_KEY", name: "OpenAI" },
329
+ gemini: { envVar: "GOOGLE_API_KEY", name: "Google" },
330
+ grok: { envVar: "XAI_API_KEY", name: "xAI" },
331
+ };
332
+ const info = keyMap[provider];
333
+ if (!info)
334
+ exitWithError(usageError(`Unknown image provider: ${provider}`));
335
+ if (!(await getApiKey(info.envVar, info.name)))
336
+ exitWithError(authError(info.envVar, info.name));
1179
337
  }
1180
338
  if (regenerateVideo) {
1181
339
  const generatorKeyMap = {
@@ -1185,405 +343,102 @@ export function registerScriptPipelineCommands(aiCommand) {
1185
343
  veo: { envVar: "GOOGLE_API_KEY", name: "Google" },
1186
344
  };
1187
345
  const generator = options.generator || "grok";
1188
- const genInfo = generatorKeyMap[generator];
1189
- if (!genInfo) {
346
+ const info = generatorKeyMap[generator];
347
+ if (!info)
1190
348
  exitWithError(usageError(`Invalid generator: ${generator}`, `Available: ${Object.keys(generatorKeyMap).join(", ")}`));
1191
- }
1192
- const key = await getApiKey(genInfo.envVar, genInfo.name);
1193
- if (!key) {
1194
- exitWithError(authError(genInfo.envVar, genInfo.name));
1195
- }
1196
- videoApiKey = key;
349
+ if (!(await getApiKey(info.envVar, info.name)))
350
+ exitWithError(authError(info.envVar, info.name));
1197
351
  }
1198
352
  if (regenerateNarration) {
1199
- const key = await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs");
1200
- if (!key) {
353
+ if (!(await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs"))) {
1201
354
  exitWithError(authError("ELEVENLABS_API_KEY", "ElevenLabs"));
1202
355
  }
1203
- elevenlabsApiKey = key;
1204
356
  }
1205
- // Process each scene
1206
- for (const sceneNum of sceneNums) {
1207
- const segment = segments[sceneNum - 1];
1208
- console.log(chalk.cyan(`\n── Scene ${sceneNum} ──`));
1209
- console.log(chalk.dim(` ${segment.description.slice(0, 50)}...`));
1210
- // Step 1: Regenerate narration if needed
1211
- const narrationPath = resolve(outputDir, `narration-${sceneNum}.mp3`);
1212
- let narrationDuration = segment.duration;
1213
- if (regenerateNarration && elevenlabsApiKey) {
1214
- const ttsSpinner = ora(`🎙️ Regenerating narration for scene ${sceneNum}...`).start();
1215
- const elevenlabs = new ElevenLabsProvider();
1216
- await elevenlabs.initialize({ apiKey: elevenlabsApiKey });
1217
- const narrationText = segment.narration || segment.description;
1218
- const ttsResult = await elevenlabs.textToSpeech(narrationText, {
1219
- voiceId: options.voice,
1220
- });
1221
- if (!ttsResult.success || !ttsResult.audioBuffer) {
1222
- ttsSpinner.fail(`Failed to generate narration: ${ttsResult.error || "Unknown error"}`);
1223
- exitWithError(apiError(`Failed to generate narration: ${ttsResult.error || "Unknown error"}`, true));
1224
- }
1225
- await writeFile(narrationPath, ttsResult.audioBuffer);
1226
- narrationDuration = await getAudioDuration(narrationPath);
1227
- // Update segment duration in storyboard
1228
- segment.duration = narrationDuration;
1229
- ttsSpinner.succeed(chalk.green(`Generated narration (${narrationDuration.toFixed(1)}s)`));
1230
- }
1231
- // Step 2: Regenerate image if needed
1232
- const imagePath = resolve(outputDir, `scene-${sceneNum}.png`);
1233
- if (regenerateImage && imageApiKey) {
1234
- const imageSpinner = ora(`🎨 Regenerating image for scene ${sceneNum}...`).start();
1235
- const imageProvider = options.imageProvider || "gemini";
1236
- // Build prompt with character description for consistency
1237
- const characterDesc = segment.characterDescription || segments[0]?.characterDescription;
1238
- let imagePrompt = segment.visualStyle
1239
- ? `${segment.visuals}. Style: ${segment.visualStyle}`
1240
- : segment.visuals;
1241
- // Add character description to prompt if available
1242
- if (characterDesc) {
1243
- imagePrompt = `${imagePrompt}\n\nIMPORTANT - Character appearance must match exactly: ${characterDesc}`;
1244
- }
1245
- // Check if we should use reference-based generation for character consistency
1246
- const refSceneNum = options.referenceScene ? parseInt(options.referenceScene) : null;
1247
- let referenceImageBuffer;
1248
- if (refSceneNum && refSceneNum >= 1 && refSceneNum <= segments.length && refSceneNum !== sceneNum) {
1249
- const refImagePath = resolve(outputDir, `scene-${refSceneNum}.png`);
1250
- if (existsSync(refImagePath)) {
1251
- referenceImageBuffer = await readFile(refImagePath);
1252
- imageSpinner.text = `🎨 Regenerating image for scene ${sceneNum} (using scene ${refSceneNum} as reference)...`;
1253
- }
1254
- }
1255
- else if (!refSceneNum) {
1256
- // Auto-detect: use the first available scene image as reference
1257
- for (let i = 1; i <= segments.length; i++) {
1258
- if (i !== sceneNum) {
1259
- const otherImagePath = resolve(outputDir, `scene-${i}.png`);
1260
- if (existsSync(otherImagePath)) {
1261
- referenceImageBuffer = await readFile(otherImagePath);
1262
- imageSpinner.text = `🎨 Regenerating image for scene ${sceneNum} (using scene ${i} as reference)...`;
1263
- break;
1264
- }
1265
- }
1266
- }
1267
- }
1268
- // Determine image size/aspect ratio based on provider
1269
- const dalleImageSizes = {
1270
- "16:9": "1536x1024",
1271
- "9:16": "1024x1536",
1272
- "1:1": "1024x1024",
1273
- };
1274
- let imageBuffer;
1275
- let imageUrl;
1276
- let imageError;
1277
- if (imageProvider === "openai" || imageProvider === "dalle") {
1278
- const openaiImage = new OpenAIImageProvider();
1279
- await openaiImage.initialize({ apiKey: imageApiKey });
1280
- const imageResult = await openaiImage.generateImage(imagePrompt, {
1281
- size: dalleImageSizes[options.aspectRatio] || "1536x1024",
1282
- quality: "standard",
1283
- });
1284
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
1285
- imageUrl = imageResult.images[0].url;
1286
- }
1287
- else {
1288
- imageError = imageResult.error;
1289
- }
1290
- }
1291
- else if (imageProvider === "gemini") {
1292
- const gemini = new GeminiProvider();
1293
- await gemini.initialize({ apiKey: imageApiKey });
1294
- // Use editImage with reference for character consistency
1295
- if (referenceImageBuffer) {
1296
- // Extract the main action from the scene description (take first action if multiple)
1297
- const simplifiedVisuals = segment.visuals.split(/[,.]/).find((part) => part.includes("standing") || part.includes("sitting") || part.includes("walking") ||
1298
- part.includes("lying") || part.includes("reaching") || part.includes("looking") ||
1299
- part.includes("working") || part.includes("coding") || part.includes("typing")) || segment.visuals.split(".")[0];
1300
- const editPrompt = `Generate a new image showing the SAME SINGLE person from the reference image in a new scene.
1301
-
1302
- REFERENCE: Look at the person in the reference image - their face, hair, build, and overall appearance.
1303
-
1304
- NEW SCENE: ${simplifiedVisuals}
1305
-
1306
- CRITICAL RULES:
1307
- 1. Show ONLY ONE person - the exact same individual from the reference image
1308
- 2. The person must have the IDENTICAL face, hair style, and body type
1309
- 3. Do NOT show multiple people or duplicate the character
1310
- 4. Create a single moment in time, one pose, one action
1311
- 5. Match the art style and quality of the reference image
1312
-
1313
- Generate the single-person scene image now.`;
1314
- const imageResult = await gemini.editImage([referenceImageBuffer], editPrompt, {
1315
- aspectRatio: options.aspectRatio,
1316
- });
1317
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
1318
- const img = imageResult.images[0];
1319
- if (img.base64) {
1320
- imageBuffer = Buffer.from(img.base64, "base64");
1321
- }
1322
- }
1323
- else {
1324
- imageError = imageResult.error;
1325
- }
1326
- }
1327
- else {
1328
- // No reference image, use regular generation
1329
- const imageResult = await gemini.generateImage(imagePrompt, {
1330
- aspectRatio: options.aspectRatio,
1331
- });
1332
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
1333
- const img = imageResult.images[0];
1334
- if (img.base64) {
1335
- imageBuffer = Buffer.from(img.base64, "base64");
1336
- }
1337
- }
1338
- else {
1339
- imageError = imageResult.error;
1340
- }
1341
- }
1342
- }
1343
- else if (imageProvider === "grok") {
1344
- const { GrokProvider } = await import("@vibeframe/ai-providers");
1345
- const grok = new GrokProvider();
1346
- await grok.initialize({ apiKey: imageApiKey });
1347
- const imageResult = await grok.generateImage(imagePrompt, {
1348
- aspectRatio: options.aspectRatio || "16:9",
1349
- });
1350
- if (imageResult.success && imageResult.images && imageResult.images.length > 0) {
1351
- const img = imageResult.images[0];
1352
- if (img.base64) {
1353
- imageBuffer = Buffer.from(img.base64, "base64");
1354
- }
1355
- else if (img.url) {
1356
- imageUrl = img.url;
1357
- }
1358
- }
1359
- else {
1360
- imageError = imageResult.error;
1361
- }
1362
- }
1363
- if (imageBuffer) {
1364
- await writeFile(imagePath, imageBuffer);
1365
- imageSpinner.succeed(chalk.green("Generated image"));
1366
- }
1367
- else if (imageUrl) {
1368
- const response = await fetch(imageUrl);
1369
- const buffer = Buffer.from(await response.arrayBuffer());
1370
- await writeFile(imagePath, buffer);
1371
- imageSpinner.succeed(chalk.green("Generated image"));
1372
- }
1373
- else {
1374
- const errorMsg = imageError || "Unknown error";
1375
- imageSpinner.fail(`Failed to generate image: ${errorMsg}`);
1376
- exitWithError(apiError(`Failed to generate image: ${errorMsg}`, true));
1377
- }
1378
- }
1379
- // Step 3: Regenerate video if needed
1380
- const videoPath = resolve(outputDir, `scene-${sceneNum}.mp4`);
1381
- if (regenerateVideo && videoApiKey) {
1382
- const videoSpinner = ora(`🎬 Regenerating video for scene ${sceneNum}...`).start();
1383
- // Check if image exists
1384
- if (!existsSync(imagePath)) {
1385
- videoSpinner.fail(`Reference image not found: ${imagePath}`);
1386
- exitWithError(notFoundError(imagePath));
1387
- }
1388
- const imageBuffer = await readFile(imagePath);
1389
- const ext = extname(imagePath).toLowerCase().slice(1);
1390
- const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
1391
- const referenceImage = `data:${mimeType};base64,${imageBuffer.toString("base64")}`;
1392
- const videoDuration = (segment.duration > 5 ? 10 : 5);
1393
- const maxRetries = parseInt(options.retries) || DEFAULT_VIDEO_RETRIES;
1394
- let videoGenerated = false;
1395
- // Grok and Veo: delegate to executeRegenerateScene (library function supports all 4
1396
- // since v0.48.5). Kling and Runway continue through the inline path below pending
1397
- // full dedup (#46).
1398
- if (options.generator === "grok" || options.generator === "veo") {
1399
- videoSpinner.text = `🎬 Regenerating scene ${sceneNum} video with ${options.generator}...`;
1400
- const delResult = await executeRegenerateScene({
1401
- projectDir,
1402
- scenes: [sceneNum],
1403
- videoOnly: true,
1404
- generator: options.generator,
1405
- aspectRatio: options.aspectRatio,
1406
- retries: maxRetries,
1407
- });
1408
- if (delResult.success && delResult.regeneratedScenes.includes(sceneNum)) {
1409
- videoGenerated = true;
1410
- }
1411
- else if (delResult.error) {
1412
- videoSpinner.fail(delResult.error);
1413
- exitWithError(apiError(delResult.error, true));
1414
- }
1415
- }
1416
- else if (options.generator === "kling") {
1417
- const kling = new KlingProvider();
1418
- await kling.initialize({ apiKey: videoApiKey });
1419
- if (!kling.isConfigured()) {
1420
- videoSpinner.fail("Invalid Kling API key format");
1421
- exitWithError(authError("KLING_API_KEY", "Kling"));
1422
- }
1423
- // Try to use image-to-video if ImgBB API key is available
1424
- const imgbbApiKey = await getApiKeyFromConfig("imgbb") || process.env.IMGBB_API_KEY;
1425
- let imageUrl;
1426
- if (imgbbApiKey) {
1427
- videoSpinner.text = `🎬 Uploading image to ImgBB...`;
1428
- const uploadResult = await uploadToImgbb(imageBuffer, imgbbApiKey);
1429
- if (uploadResult.success && uploadResult.url) {
1430
- imageUrl = uploadResult.url;
1431
- videoSpinner.text = `🎬 Starting image-to-video generation...`;
1432
- }
1433
- else {
1434
- console.log(chalk.yellow(`\n ⚠ ImgBB upload failed, falling back to text-to-video`));
1435
- }
1436
- }
1437
- const result = await generateVideoWithRetryKling(kling, segment, {
1438
- duration: videoDuration,
1439
- aspectRatio: options.aspectRatio,
1440
- referenceImage: imageUrl, // Use uploaded URL for image-to-video
1441
- }, maxRetries);
1442
- if (result) {
1443
- videoSpinner.text = `🎬 Waiting for video to complete...`;
1444
- for (let attempt = 0; attempt <= maxRetries && !videoGenerated; attempt++) {
1445
- try {
1446
- const waitResult = await kling.waitForCompletion(result.taskId, result.type, (status) => {
1447
- videoSpinner.text = `🎬 Scene ${sceneNum}: ${status.status}...`;
1448
- }, 600000);
1449
- if (waitResult.status === "completed" && waitResult.videoUrl) {
1450
- const buffer = await downloadVideo(waitResult.videoUrl, videoApiKey);
1451
- await writeFile(videoPath, buffer);
1452
- // Extend video to match narration duration if needed
1453
- await extendVideoToTarget(videoPath, segment.duration, outputDir, `Scene ${sceneNum}`, {
1454
- kling,
1455
- videoId: waitResult.videoId,
1456
- onProgress: (msg) => { videoSpinner.text = `🎬 ${msg}`; },
1457
- });
1458
- videoGenerated = true;
1459
- }
1460
- else if (attempt < maxRetries) {
1461
- videoSpinner.text = `🎬 Scene ${sceneNum}: Retry ${attempt + 1}/${maxRetries}...`;
1462
- await sleep(RETRY_DELAY_MS);
1463
- }
1464
- }
1465
- catch (err) {
1466
- if (attempt >= maxRetries) {
1467
- throw err;
1468
- }
1469
- videoSpinner.text = `🎬 Scene ${sceneNum}: Error, retry ${attempt + 1}/${maxRetries}...`;
1470
- await sleep(RETRY_DELAY_MS);
1471
- }
1472
- }
1473
- }
1474
- }
1475
- else {
1476
- // Runway
1477
- const runway = new RunwayProvider();
1478
- await runway.initialize({ apiKey: videoApiKey });
1479
- const result = await generateVideoWithRetryRunway(runway, segment, referenceImage, {
1480
- duration: videoDuration,
1481
- aspectRatio: options.aspectRatio === "1:1" ? "16:9" : options.aspectRatio,
1482
- }, maxRetries, (msg) => {
1483
- videoSpinner.text = `🎬 Scene ${sceneNum}: ${msg}`;
1484
- });
1485
- if (result) {
1486
- videoSpinner.text = `🎬 Waiting for video to complete...`;
1487
- for (let attempt = 0; attempt <= maxRetries && !videoGenerated; attempt++) {
1488
- try {
1489
- const waitResult = await runway.waitForCompletion(result.taskId, (status) => {
1490
- const progress = status.progress !== undefined ? `${status.progress}%` : status.status;
1491
- videoSpinner.text = `🎬 Scene ${sceneNum}: ${progress}...`;
1492
- }, 300000);
1493
- if (waitResult.status === "completed" && waitResult.videoUrl) {
1494
- const buffer = await downloadVideo(waitResult.videoUrl, videoApiKey);
1495
- await writeFile(videoPath, buffer);
1496
- // Extend video to match narration duration if needed (Runway - no Kling extend)
1497
- await extendVideoToTarget(videoPath, segment.duration, outputDir, `Scene ${sceneNum}`, {
1498
- onProgress: (msg) => { videoSpinner.text = `🎬 ${msg}`; },
1499
- });
1500
- videoGenerated = true;
1501
- }
1502
- else if (attempt < maxRetries) {
1503
- videoSpinner.text = `🎬 Scene ${sceneNum}: Retry ${attempt + 1}/${maxRetries}...`;
1504
- await sleep(RETRY_DELAY_MS);
1505
- }
1506
- }
1507
- catch (err) {
1508
- if (attempt >= maxRetries) {
1509
- throw err;
1510
- }
1511
- videoSpinner.text = `🎬 Scene ${sceneNum}: Error, retry ${attempt + 1}/${maxRetries}...`;
1512
- await sleep(RETRY_DELAY_MS);
1513
- }
1514
- }
1515
- }
1516
- }
1517
- if (videoGenerated) {
1518
- videoSpinner.succeed(chalk.green("Generated video"));
1519
- }
1520
- else {
1521
- videoSpinner.fail("Failed to generate video after all retries");
1522
- exitWithError(apiError("Failed to generate video after all retries", true));
1523
- }
1524
- }
1525
- // Step 4: Recalculate startTime for ALL segments and re-save storyboard
1526
- {
1527
- let currentTime = 0;
1528
- for (const seg of segments) {
1529
- seg.startTime = currentTime;
1530
- currentTime += seg.duration;
1531
- }
1532
- await writeFile(storyboardPath, JSON.stringify(segments, null, 2), "utf-8");
1533
- console.log(chalk.dim(` → Updated storyboard: ${storyboardPath}`));
1534
- }
1535
- // Step 5: Update project.vibe.json if it exists — update ALL clips' startTime/duration
1536
- if (existsSync(projectPath)) {
1537
- const updateSpinner = ora("📦 Updating project file...").start();
1538
- try {
1539
- const projectContent = await readFile(projectPath, "utf-8");
1540
- const projectData = JSON.parse(projectContent);
1541
- // Find and update the source for this scene
1542
- const sceneName = `Scene ${sceneNum}`;
1543
- const narrationName = `Narration ${sceneNum}`;
1544
- // Update video/image source
1545
- const videoSource = projectData.state.sources.find((s) => s.name === sceneName);
357
+ console.log();
358
+ console.log(chalk.bold.cyan(`🔄 Regenerating Scene${sceneNums.length > 1 ? "s" : ""} ${sceneNums.join(", ")}`));
359
+ console.log(chalk.dim("─".repeat(60)));
360
+ console.log();
361
+ console.log(` 📁 Project: ${outputDir}`);
362
+ console.log(` 🎬 Scenes: ${sceneNums.join(", ")} of ${segments.length}`);
363
+ console.log();
364
+ const spinner = ora("Regenerating...").start();
365
+ const result = await executeRegenerateScene({
366
+ projectDir,
367
+ scenes: sceneNums,
368
+ videoOnly: options.videoOnly,
369
+ narrationOnly: options.narrationOnly,
370
+ imageOnly: options.imageOnly,
371
+ generator: options.generator,
372
+ // `dalle` is a CLI alias for OpenAI image generation; the library only
373
+ // knows `openai`.
374
+ imageProvider: (options.imageProvider === "dalle" ? "openai" : options.imageProvider),
375
+ voice: options.voice,
376
+ aspectRatio: options.aspectRatio,
377
+ retries: parseInt(options.retries) || DEFAULT_VIDEO_RETRIES,
378
+ referenceScene: options.referenceScene ? parseInt(options.referenceScene) : undefined,
379
+ onProgress: (msg) => { spinner.text = msg; },
380
+ });
381
+ if (!result.success) {
382
+ spinner.fail(chalk.red(result.error || "Scene regeneration failed"));
383
+ exitWithError(apiError(result.error || "Scene regeneration failed", true));
384
+ }
385
+ spinner.succeed(chalk.green(`Regenerated ${result.regeneratedScenes.length} scene(s)`));
386
+ // Sync project.vibe.json clip durations with the updated storyboard.
387
+ // The library rewrites segment.duration when narration changes; we
388
+ // re-read it here so every clip's startTime/duration line up.
389
+ if (existsSync(projectPath) && result.regeneratedScenes.length > 0) {
390
+ const updateSpinner = ora("📦 Updating project file...").start();
391
+ try {
392
+ const updatedContent = await readFile(storyboardPath, "utf-8");
393
+ const updatedSegments = storyboardIsYaml
394
+ ? yamlParse(updatedContent).scenes
395
+ : JSON.parse(updatedContent);
396
+ const projectContent = await readFile(projectPath, "utf-8");
397
+ const projectData = JSON.parse(projectContent);
398
+ for (const sceneNum of result.regeneratedScenes) {
399
+ const segment = updatedSegments[sceneNum - 1];
400
+ if (!segment)
401
+ continue;
402
+ const videoPath = resolve(outputDir, `scene-${sceneNum}.mp4`);
403
+ const imagePath = resolve(outputDir, `scene-${sceneNum}.png`);
404
+ const videoSource = projectData.state.sources.find((s) => s.name === `Scene ${sceneNum}`);
1546
405
  if (videoSource) {
1547
406
  const hasVideo = existsSync(videoPath);
1548
407
  videoSource.url = hasVideo ? videoPath : imagePath;
1549
408
  videoSource.type = hasVideo ? "video" : "image";
1550
409
  videoSource.duration = segment.duration;
1551
410
  }
1552
- // Update narration source
1553
- const narrationSource = projectData.state.sources.find((s) => s.name === narrationName);
1554
- if (narrationSource && regenerateNarration) {
1555
- narrationSource.duration = narrationDuration;
1556
- }
1557
- // Update ALL clips' startTime and duration based on recalculated segments
1558
- for (const clip of projectData.state.clips) {
1559
- const source = projectData.state.sources.find((s) => s.id === clip.sourceId);
1560
- if (!source)
1561
- continue;
1562
- // Match source name to segment (e.g., "Scene 1" → segment 0, "Narration 2" → segment 1)
1563
- const sceneMatch = source.name.match(/^Scene (\d+)$/);
1564
- const narrationMatch = source.name.match(/^Narration (\d+)$/);
1565
- const segIdx = sceneMatch ? parseInt(sceneMatch[1]) - 1 : narrationMatch ? parseInt(narrationMatch[1]) - 1 : -1;
1566
- if (segIdx >= 0 && segIdx < segments.length) {
1567
- const seg = segments[segIdx];
1568
- clip.startTime = seg.startTime;
1569
- clip.duration = seg.duration;
1570
- clip.sourceEndOffset = seg.duration;
1571
- // Also update the source duration to match segment
1572
- source.duration = seg.duration;
411
+ if (regenerateNarration) {
412
+ const narrationSource = projectData.state.sources.find((s) => s.name === `Narration ${sceneNum}`);
413
+ if (narrationSource) {
414
+ narrationSource.duration = segment.duration;
1573
415
  }
1574
416
  }
1575
- await writeFile(projectPath, JSON.stringify(projectData, null, 2), "utf-8");
1576
- updateSpinner.succeed(chalk.green("Updated project file (all clips synced)"));
1577
417
  }
1578
- catch (err) {
1579
- updateSpinner.warn(chalk.yellow(`Could not update project file: ${err}`));
418
+ for (const clip of projectData.state.clips) {
419
+ const source = projectData.state.sources.find((s) => s.id === clip.sourceId);
420
+ if (!source)
421
+ continue;
422
+ const sceneMatch = source.name.match(/^Scene (\d+)$/);
423
+ const narrationMatch = source.name.match(/^Narration (\d+)$/);
424
+ const segIdx = sceneMatch ? parseInt(sceneMatch[1]) - 1 : narrationMatch ? parseInt(narrationMatch[1]) - 1 : -1;
425
+ if (segIdx >= 0 && segIdx < updatedSegments.length) {
426
+ const seg = updatedSegments[segIdx];
427
+ clip.startTime = seg.startTime;
428
+ clip.duration = seg.duration;
429
+ clip.sourceEndOffset = seg.duration;
430
+ source.duration = seg.duration;
431
+ }
1580
432
  }
433
+ await writeFile(projectPath, JSON.stringify(projectData, null, 2), "utf-8");
434
+ updateSpinner.succeed(chalk.green("Updated project file (all clips synced)"));
1581
435
  }
1582
- console.log(chalk.green(` ✓ Scene ${sceneNum} done`));
1583
- } // End of for loop over sceneNums
1584
- // Final summary
436
+ catch (err) {
437
+ updateSpinner.warn(chalk.yellow(`Could not update project file: ${err}`));
438
+ }
439
+ }
1585
440
  console.log();
1586
- console.log(chalk.bold.green(`✅ ${sceneNums.length} scene${sceneNums.length > 1 ? "s" : ""} regenerated successfully!`));
441
+ console.log(chalk.bold.green(`✅ ${result.regeneratedScenes.length} scene${result.regeneratedScenes.length !== 1 ? "s" : ""} regenerated successfully!`));
1587
442
  console.log(chalk.dim("─".repeat(60)));
1588
443
  console.log();
1589
444
  console.log(chalk.dim("Next steps:"));