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