@vibeframe/cli 0.27.0 → 0.29.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/LICENSE +21 -0
- package/dist/agent/adapters/index.d.ts +1 -0
- package/dist/agent/adapters/index.d.ts.map +1 -1
- package/dist/agent/adapters/index.js +5 -0
- package/dist/agent/adapters/index.js.map +1 -1
- package/dist/agent/adapters/openrouter.d.ts +16 -0
- package/dist/agent/adapters/openrouter.d.ts.map +1 -0
- package/dist/agent/adapters/openrouter.js +100 -0
- package/dist/agent/adapters/openrouter.js.map +1 -0
- package/dist/agent/types.d.ts +1 -1
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +3 -1
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/setup.js +5 -2
- package/dist/commands/setup.js.map +1 -1
- package/dist/config/schema.d.ts +2 -1
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +2 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/index.js +0 -0
- package/package.json +16 -12
- package/.turbo/turbo-build.log +0 -4
- package/.turbo/turbo-lint.log +0 -21
- package/.turbo/turbo-test.log +0 -689
- package/src/agent/adapters/claude.ts +0 -143
- package/src/agent/adapters/gemini.ts +0 -159
- package/src/agent/adapters/index.ts +0 -61
- package/src/agent/adapters/ollama.ts +0 -231
- package/src/agent/adapters/openai.ts +0 -116
- package/src/agent/adapters/xai.ts +0 -119
- package/src/agent/index.ts +0 -251
- package/src/agent/memory/index.ts +0 -151
- package/src/agent/prompts/system.ts +0 -106
- package/src/agent/tools/ai-editing.ts +0 -845
- package/src/agent/tools/ai-generation.ts +0 -1073
- package/src/agent/tools/ai-pipeline.ts +0 -1055
- package/src/agent/tools/ai.ts +0 -21
- package/src/agent/tools/batch.ts +0 -429
- package/src/agent/tools/e2e.test.ts +0 -545
- package/src/agent/tools/export.ts +0 -184
- package/src/agent/tools/filesystem.ts +0 -237
- package/src/agent/tools/index.ts +0 -150
- package/src/agent/tools/integration.test.ts +0 -775
- package/src/agent/tools/media.ts +0 -697
- package/src/agent/tools/project.ts +0 -313
- package/src/agent/tools/timeline.ts +0 -951
- package/src/agent/types.ts +0 -68
- package/src/commands/agent.ts +0 -340
- package/src/commands/ai-analyze.ts +0 -429
- package/src/commands/ai-animated-caption.ts +0 -390
- package/src/commands/ai-audio.ts +0 -941
- package/src/commands/ai-broll.ts +0 -490
- package/src/commands/ai-edit-cli.ts +0 -658
- package/src/commands/ai-edit.ts +0 -1542
- package/src/commands/ai-fill-gaps.ts +0 -566
- package/src/commands/ai-helpers.ts +0 -65
- package/src/commands/ai-highlights.ts +0 -1303
- package/src/commands/ai-image.ts +0 -761
- package/src/commands/ai-motion.ts +0 -347
- package/src/commands/ai-narrate.ts +0 -451
- package/src/commands/ai-review.ts +0 -309
- package/src/commands/ai-script-pipeline-cli.ts +0 -1710
- package/src/commands/ai-script-pipeline.ts +0 -1365
- package/src/commands/ai-suggest-edit.ts +0 -264
- package/src/commands/ai-video-fx.ts +0 -445
- package/src/commands/ai-video.ts +0 -915
- package/src/commands/ai-viral.ts +0 -595
- package/src/commands/ai-visual-fx.ts +0 -601
- package/src/commands/ai.test.ts +0 -627
- package/src/commands/ai.ts +0 -307
- package/src/commands/analyze.ts +0 -282
- package/src/commands/audio.ts +0 -644
- package/src/commands/batch.test.ts +0 -279
- package/src/commands/batch.ts +0 -440
- package/src/commands/detect.ts +0 -329
- package/src/commands/doctor.ts +0 -237
- package/src/commands/edit-cmd.ts +0 -1014
- package/src/commands/export.ts +0 -918
- package/src/commands/generate.ts +0 -2146
- package/src/commands/media.ts +0 -177
- package/src/commands/output.ts +0 -142
- package/src/commands/pipeline.ts +0 -398
- package/src/commands/project.test.ts +0 -127
- package/src/commands/project.ts +0 -149
- package/src/commands/sanitize.ts +0 -60
- package/src/commands/schema.ts +0 -130
- package/src/commands/setup.ts +0 -509
- package/src/commands/timeline.test.ts +0 -499
- package/src/commands/timeline.ts +0 -529
- package/src/commands/validate.ts +0 -77
- package/src/config/config.test.ts +0 -197
- package/src/config/index.ts +0 -125
- package/src/config/schema.ts +0 -82
- package/src/engine/index.ts +0 -2
- package/src/engine/project.test.ts +0 -702
- package/src/engine/project.ts +0 -439
- package/src/index.ts +0 -146
- package/src/utils/api-key.test.ts +0 -41
- package/src/utils/api-key.ts +0 -247
- package/src/utils/audio.ts +0 -83
- package/src/utils/exec-safe.ts +0 -75
- package/src/utils/first-run.ts +0 -52
- package/src/utils/provider-resolver.ts +0 -56
- package/src/utils/remotion.ts +0 -951
- package/src/utils/subtitle.test.ts +0 -227
- package/src/utils/subtitle.ts +0 -169
- package/src/utils/tty.ts +0 -196
- package/tsconfig.json +0 -20
|
@@ -1,566 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module ai-fill-gaps
|
|
3
|
-
* @description Fill timeline gaps with AI-generated video (Kling image-to-video).
|
|
4
|
-
*
|
|
5
|
-
* ## Commands: vibe ai fill-gaps
|
|
6
|
-
* ## Dependencies: Kling
|
|
7
|
-
*
|
|
8
|
-
* Extracted from ai.ts as part of modularisation.
|
|
9
|
-
* ai.ts calls registerFillGapsCommand(aiCommand).
|
|
10
|
-
* @see MODELS.md for AI model configuration
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { type Command } from 'commander';
|
|
14
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
-
import { resolve, dirname } from 'node:path';
|
|
16
|
-
import { existsSync } from 'node:fs';
|
|
17
|
-
import chalk from 'chalk';
|
|
18
|
-
import ora from 'ora';
|
|
19
|
-
import { KlingProvider } from '@vibeframe/ai-providers';
|
|
20
|
-
import { Project, type ProjectFile } from '../engine/index.js';
|
|
21
|
-
import { getApiKey } from '../utils/api-key.js';
|
|
22
|
-
import { execSafe, ffprobeDuration } from '../utils/exec-safe.js';
|
|
23
|
-
import { formatTime, downloadVideo } from './ai-helpers.js';
|
|
24
|
-
|
|
25
|
-
// ── Helper functions (module-private) ────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Detect gaps in video timeline
|
|
29
|
-
*/
|
|
30
|
-
function detectVideoGaps(
|
|
31
|
-
videoClips: Array<{ startTime: number; duration: number }>,
|
|
32
|
-
totalDuration: number
|
|
33
|
-
): Array<{ start: number; end: number }> {
|
|
34
|
-
const gaps: Array<{ start: number; end: number }> = [];
|
|
35
|
-
const sortedClips = [...videoClips].sort((a, b) => a.startTime - b.startTime);
|
|
36
|
-
|
|
37
|
-
// Check for gap at the start
|
|
38
|
-
if (sortedClips.length > 0 && sortedClips[0].startTime > 0.001) {
|
|
39
|
-
gaps.push({ start: 0, end: sortedClips[0].startTime });
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Check for gaps between clips
|
|
43
|
-
for (let i = 0; i < sortedClips.length - 1; i++) {
|
|
44
|
-
const clipEnd = sortedClips[i].startTime + sortedClips[i].duration;
|
|
45
|
-
const nextStart = sortedClips[i + 1].startTime;
|
|
46
|
-
if (nextStart > clipEnd + 0.001) {
|
|
47
|
-
gaps.push({ start: clipEnd, end: nextStart });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Check for gap at the end
|
|
52
|
-
if (sortedClips.length > 0) {
|
|
53
|
-
const lastClip = sortedClips[sortedClips.length - 1];
|
|
54
|
-
const lastClipEnd = lastClip.startTime + lastClip.duration;
|
|
55
|
-
if (totalDuration > lastClipEnd + 0.001) {
|
|
56
|
-
gaps.push({ start: lastClipEnd, end: totalDuration });
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return gaps;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Analyze whether gaps can be filled by extending adjacent clips
|
|
65
|
-
*/
|
|
66
|
-
function analyzeGapFillability(
|
|
67
|
-
gaps: Array<{ start: number; end: number }>,
|
|
68
|
-
videoClips: Array<{ startTime: number; duration: number; sourceId: string; sourceStartOffset: number; sourceEndOffset: number }>,
|
|
69
|
-
sources: Array<{ id: string; url: string; type: string; duration: number }>
|
|
70
|
-
): Array<{
|
|
71
|
-
gap: { start: number; end: number };
|
|
72
|
-
canExtendBefore: number;
|
|
73
|
-
canExtendAfter: number;
|
|
74
|
-
remainingGap: number;
|
|
75
|
-
gapStart: number; // Where the unfillable gap starts
|
|
76
|
-
}> {
|
|
77
|
-
const sortedClips = [...videoClips].sort((a, b) => a.startTime - b.startTime);
|
|
78
|
-
|
|
79
|
-
return gaps.map((gap) => {
|
|
80
|
-
const gapDuration = gap.end - gap.start;
|
|
81
|
-
let canExtendBefore = 0;
|
|
82
|
-
let canExtendAfter = 0;
|
|
83
|
-
|
|
84
|
-
// Find clip BEFORE the gap (for extending forwards)
|
|
85
|
-
const clipBefore = sortedClips.find((c) =>
|
|
86
|
-
Math.abs(c.startTime + c.duration - gap.start) < 0.01
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
if (clipBefore) {
|
|
90
|
-
const source = sources.find((s) => s.id === clipBefore.sourceId);
|
|
91
|
-
if (source && source.type === "video") {
|
|
92
|
-
const usedEndInSource = clipBefore.sourceEndOffset;
|
|
93
|
-
canExtendBefore = Math.max(0, source.duration - usedEndInSource);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Find clip AFTER the gap (for extending backwards)
|
|
98
|
-
const clipAfter = sortedClips.find((c) =>
|
|
99
|
-
Math.abs(c.startTime - gap.end) < 0.01
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
if (clipAfter) {
|
|
103
|
-
const source = sources.find((s) => s.id === clipAfter.sourceId);
|
|
104
|
-
if (source && source.type === "video") {
|
|
105
|
-
canExtendAfter = Math.max(0, clipAfter.sourceStartOffset);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const totalExtendable = canExtendBefore + canExtendAfter;
|
|
110
|
-
const remainingGap = Math.max(0, gapDuration - totalExtendable);
|
|
111
|
-
|
|
112
|
-
// Calculate where the unfillable gap starts
|
|
113
|
-
// (after we extend the clip before as much as possible)
|
|
114
|
-
const gapStart = gap.start + Math.min(canExtendBefore, gapDuration);
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
gap,
|
|
118
|
-
canExtendBefore,
|
|
119
|
-
canExtendAfter,
|
|
120
|
-
remainingGap,
|
|
121
|
-
gapStart,
|
|
122
|
-
};
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// ── Command registration ─────────────────────────────────────────────────────
|
|
128
|
-
|
|
129
|
-
export function registerFillGapsCommand(aiCommand: Command): void {
|
|
130
|
-
// Fill Gaps command - AI video generation to fill timeline gaps
|
|
131
|
-
aiCommand
|
|
132
|
-
.command("fill-gaps")
|
|
133
|
-
.description("Fill timeline gaps with AI-generated video (Kling image-to-video)")
|
|
134
|
-
.argument("<project>", "Project file path")
|
|
135
|
-
.option("-p, --provider <provider>", "AI provider (kling)", "kling")
|
|
136
|
-
.option("-o, --output <path>", "Output project path (default: overwrite)")
|
|
137
|
-
.option("-d, --dir <path>", "Directory to save generated videos")
|
|
138
|
-
.option("--prompt <text>", "Custom prompt for video generation")
|
|
139
|
-
.option("--dry-run", "Show gaps without generating")
|
|
140
|
-
.option("-m, --mode <mode>", "Generation mode: std or pro (Kling)", "std")
|
|
141
|
-
.option("-r, --ratio <ratio>", "Aspect ratio: 16:9, 9:16, or 1:1", "16:9")
|
|
142
|
-
.action(async (projectPath: string, options) => {
|
|
143
|
-
try {
|
|
144
|
-
const spinner = ora("Loading project...").start();
|
|
145
|
-
|
|
146
|
-
// Load project
|
|
147
|
-
const filePath = resolve(process.cwd(), projectPath);
|
|
148
|
-
const content = await readFile(filePath, "utf-8");
|
|
149
|
-
const data: ProjectFile = JSON.parse(content);
|
|
150
|
-
const project = Project.fromJSON(data);
|
|
151
|
-
|
|
152
|
-
const clips = project.getClips().sort((a, b) => a.startTime - b.startTime);
|
|
153
|
-
const sources = project.getSources();
|
|
154
|
-
|
|
155
|
-
// Get video clips only
|
|
156
|
-
const videoClips = clips.filter((clip) => {
|
|
157
|
-
const source = sources.find((s) => s.id === clip.sourceId);
|
|
158
|
-
return source && (source.type === "video" || source.type === "image");
|
|
159
|
-
}).sort((a, b) => a.startTime - b.startTime);
|
|
160
|
-
|
|
161
|
-
if (videoClips.length === 0) {
|
|
162
|
-
spinner.fail(chalk.red("Project has no video clips"));
|
|
163
|
-
process.exit(1);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Determine total duration (use audio track if available)
|
|
167
|
-
const audioClips = clips.filter((clip) => {
|
|
168
|
-
const source = sources.find((s) => s.id === clip.sourceId);
|
|
169
|
-
return source && source.type === "audio";
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
let totalDuration: number;
|
|
173
|
-
if (audioClips.length > 0) {
|
|
174
|
-
totalDuration = Math.max(...audioClips.map((c) => c.startTime + c.duration));
|
|
175
|
-
} else {
|
|
176
|
-
totalDuration = Math.max(...videoClips.map((c) => c.startTime + c.duration));
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Detect gaps
|
|
180
|
-
spinner.text = "Detecting gaps...";
|
|
181
|
-
const gaps = detectVideoGaps(videoClips, totalDuration);
|
|
182
|
-
|
|
183
|
-
if (gaps.length === 0) {
|
|
184
|
-
spinner.succeed(chalk.green("No gaps found in timeline"));
|
|
185
|
-
process.exit(0);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Analyze which gaps can be filled by extending adjacent clips
|
|
189
|
-
const gapAnalysis = analyzeGapFillability(gaps, videoClips, sources);
|
|
190
|
-
|
|
191
|
-
spinner.succeed(chalk.green(`Found ${gaps.length} gap(s)`));
|
|
192
|
-
|
|
193
|
-
console.log();
|
|
194
|
-
console.log(chalk.bold.cyan("Timeline Gaps"));
|
|
195
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
196
|
-
|
|
197
|
-
const gapsNeedingAI: typeof gapAnalysis = [];
|
|
198
|
-
|
|
199
|
-
for (const analysis of gapAnalysis) {
|
|
200
|
-
const { gap, canExtendBefore, canExtendAfter, remainingGap } = analysis;
|
|
201
|
-
const gapDuration = gap.end - gap.start;
|
|
202
|
-
|
|
203
|
-
console.log();
|
|
204
|
-
console.log(chalk.yellow(`Gap: ${formatTime(gap.start)} - ${formatTime(gap.end)} (${gapDuration.toFixed(2)}s)`));
|
|
205
|
-
|
|
206
|
-
if (canExtendBefore > 0.01 || canExtendAfter > 0.01) {
|
|
207
|
-
const extendable = canExtendBefore + canExtendAfter;
|
|
208
|
-
console.log(chalk.dim(` Can extend from adjacent clips: ${extendable.toFixed(2)}s`));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (remainingGap > 0.01) {
|
|
212
|
-
console.log(chalk.red(` Needs AI generation: ${remainingGap.toFixed(2)}s`));
|
|
213
|
-
gapsNeedingAI.push(analysis);
|
|
214
|
-
} else {
|
|
215
|
-
console.log(chalk.green(` ✓ Can be filled by extending clips`));
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
console.log();
|
|
220
|
-
|
|
221
|
-
if (gapsNeedingAI.length === 0) {
|
|
222
|
-
console.log(chalk.green("All gaps can be filled by extending adjacent clips."));
|
|
223
|
-
console.log(chalk.dim("Run export with --gap-fill extend to apply."));
|
|
224
|
-
process.exit(0);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (options.dryRun) {
|
|
228
|
-
console.log(chalk.dim("Dry run - no videos generated"));
|
|
229
|
-
console.log();
|
|
230
|
-
console.log(chalk.bold(`${gapsNeedingAI.length} gap(s) need AI video generation:`));
|
|
231
|
-
for (const analysis of gapsNeedingAI) {
|
|
232
|
-
console.log(` - ${formatTime(analysis.gap.start)} - ${formatTime(analysis.gap.end)} (${analysis.remainingGap.toFixed(2)}s)`);
|
|
233
|
-
}
|
|
234
|
-
process.exit(0);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Get Kling API key
|
|
238
|
-
const apiKey = await getApiKey("KLING_API_KEY", "Kling", undefined);
|
|
239
|
-
if (!apiKey) {
|
|
240
|
-
console.error(chalk.red("Kling API key required for AI video generation. Set KLING_API_KEY in .env or run: vibe setup"));
|
|
241
|
-
console.error(chalk.dim("Format: ACCESS_KEY:SECRET_KEY"));
|
|
242
|
-
console.error(chalk.dim("Set KLING_API_KEY environment variable"));
|
|
243
|
-
process.exit(1);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const kling = new KlingProvider();
|
|
247
|
-
await kling.initialize({ apiKey });
|
|
248
|
-
|
|
249
|
-
if (!kling.isConfigured()) {
|
|
250
|
-
console.error(chalk.red("Invalid Kling API key format. Use ACCESS_KEY:SECRET_KEY"));
|
|
251
|
-
process.exit(1);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Determine output directory for generated videos
|
|
255
|
-
const projectDir = dirname(filePath);
|
|
256
|
-
const footageDir = options.dir
|
|
257
|
-
? resolve(process.cwd(), options.dir)
|
|
258
|
-
: resolve(projectDir, "footage");
|
|
259
|
-
|
|
260
|
-
// Create footage directory if needed
|
|
261
|
-
if (!existsSync(footageDir)) {
|
|
262
|
-
await mkdir(footageDir, { recursive: true });
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
console.log(chalk.bold.cyan("Generating AI Videos"));
|
|
266
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
267
|
-
|
|
268
|
-
let generatedCount = 0;
|
|
269
|
-
|
|
270
|
-
for (const analysis of gapsNeedingAI) {
|
|
271
|
-
const { gap, remainingGap, gapStart } = analysis;
|
|
272
|
-
|
|
273
|
-
console.log();
|
|
274
|
-
console.log(chalk.yellow(`Processing gap: ${formatTime(gap.start)} - ${formatTime(gap.end)}`));
|
|
275
|
-
|
|
276
|
-
// Find the clip before this gap to extract a frame
|
|
277
|
-
const clipBefore = videoClips.find((c) =>
|
|
278
|
-
Math.abs(c.startTime + c.duration - gap.start) < 0.1
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
if (!clipBefore) {
|
|
282
|
-
console.log(chalk.red(` No preceding clip found, skipping`));
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const sourceBefore = sources.find((s) => s.id === clipBefore.sourceId);
|
|
287
|
-
if (!sourceBefore || sourceBefore.type !== "video") {
|
|
288
|
-
console.log(chalk.red(` Preceding clip is not a video, skipping`));
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Extract last frame from preceding clip
|
|
293
|
-
spinner.start("Extracting frame from preceding clip...");
|
|
294
|
-
const frameOffset = clipBefore.sourceStartOffset + clipBefore.duration - 0.1; // 100ms before end
|
|
295
|
-
const framePath = resolve(footageDir, `frame-${gap.start.toFixed(2)}.png`);
|
|
296
|
-
|
|
297
|
-
try {
|
|
298
|
-
await execSafe("ffmpeg", ["-i", sourceBefore.url, "-ss", String(frameOffset), "-vframes", "1", "-f", "image2", "-y", framePath]);
|
|
299
|
-
} catch (err) {
|
|
300
|
-
spinner.fail(chalk.red("Failed to extract frame"));
|
|
301
|
-
console.error(err);
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
spinner.succeed("Frame extracted");
|
|
305
|
-
|
|
306
|
-
// Upload frame to imgbb to get URL (Kling v2.5/v2.6 requires URL, not base64)
|
|
307
|
-
spinner.start("Uploading frame to imgbb...");
|
|
308
|
-
const imgbbApiKey = await getApiKey("IMGBB_API_KEY", "imgbb", undefined);
|
|
309
|
-
if (!imgbbApiKey) {
|
|
310
|
-
spinner.fail(chalk.red("IMGBB_API_KEY required for image hosting"));
|
|
311
|
-
console.error(chalk.dim("Get a free API key at https://api.imgbb.com/"));
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const frameBuffer = await readFile(framePath);
|
|
316
|
-
const frameBase64 = frameBuffer.toString("base64");
|
|
317
|
-
|
|
318
|
-
let frameUrl: string;
|
|
319
|
-
try {
|
|
320
|
-
const formData = new FormData();
|
|
321
|
-
formData.append("key", imgbbApiKey);
|
|
322
|
-
formData.append("image", frameBase64);
|
|
323
|
-
|
|
324
|
-
const imgbbResponse = await fetch("https://api.imgbb.com/1/upload", {
|
|
325
|
-
method: "POST",
|
|
326
|
-
body: formData,
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
const imgbbData = await imgbbResponse.json() as { success: boolean; data?: { url: string }; error?: { message: string } };
|
|
330
|
-
if (!imgbbData.success || !imgbbData.data?.url) {
|
|
331
|
-
throw new Error(imgbbData.error?.message || "Upload failed");
|
|
332
|
-
}
|
|
333
|
-
frameUrl = imgbbData.data.url;
|
|
334
|
-
} catch (err) {
|
|
335
|
-
spinner.fail(chalk.red("Failed to upload frame to imgbb"));
|
|
336
|
-
console.error(err);
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
spinner.succeed(`Frame uploaded: ${frameUrl}`);
|
|
340
|
-
|
|
341
|
-
// Calculate how many seconds to generate
|
|
342
|
-
// Kling can generate 5 or 10 second videos
|
|
343
|
-
// For longer gaps, we may need multiple generations or video-extend
|
|
344
|
-
const targetDuration = remainingGap;
|
|
345
|
-
let generatedDuration = 0;
|
|
346
|
-
const generatedVideos: string[] = [];
|
|
347
|
-
|
|
348
|
-
// Generate initial video (up to 10 seconds)
|
|
349
|
-
const initialDuration = Math.min(10, targetDuration);
|
|
350
|
-
const klingDuration = initialDuration > 5 ? "10" : "5";
|
|
351
|
-
|
|
352
|
-
spinner.start(`Generating ${klingDuration}s video with Kling...`);
|
|
353
|
-
|
|
354
|
-
const prompt = options.prompt || "Continue the scene naturally with subtle motion";
|
|
355
|
-
|
|
356
|
-
const result = await kling.generateVideo(prompt, {
|
|
357
|
-
prompt,
|
|
358
|
-
referenceImage: frameUrl,
|
|
359
|
-
duration: parseInt(klingDuration) as 5 | 10,
|
|
360
|
-
aspectRatio: options.ratio as "16:9" | "9:16" | "1:1",
|
|
361
|
-
mode: options.mode as "std" | "pro",
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
if (result.status === "failed") {
|
|
365
|
-
spinner.fail(chalk.red(`Failed to start generation: ${result.error}`));
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
spinner.text = `Generating video (task: ${result.id})...`;
|
|
370
|
-
|
|
371
|
-
const finalResult = await kling.waitForCompletion(
|
|
372
|
-
result.id,
|
|
373
|
-
"image2video",
|
|
374
|
-
(status) => {
|
|
375
|
-
spinner.text = `Generating video... ${status.status}`;
|
|
376
|
-
},
|
|
377
|
-
600000
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
if (finalResult.status !== "completed" || !finalResult.videoUrl || !finalResult.videoId) {
|
|
381
|
-
spinner.fail(chalk.red(`Generation failed: ${finalResult.error || "Unknown error"}`));
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Download the generated video
|
|
386
|
-
const videoFileName = `gap-fill-${gap.start.toFixed(2)}-${gap.end.toFixed(2)}.mp4`;
|
|
387
|
-
const videoPath = resolve(footageDir, videoFileName);
|
|
388
|
-
|
|
389
|
-
spinner.text = "Downloading generated video...";
|
|
390
|
-
const currentVideoUrl = finalResult.videoUrl;
|
|
391
|
-
const response = await fetch(currentVideoUrl);
|
|
392
|
-
const videoBuffer = Buffer.from(await response.arrayBuffer());
|
|
393
|
-
await writeFile(videoPath, videoBuffer);
|
|
394
|
-
|
|
395
|
-
generatedDuration = finalResult.duration || parseInt(klingDuration);
|
|
396
|
-
generatedVideos.push(videoPath);
|
|
397
|
-
// videoId available via finalResult.videoId for future extend API usage
|
|
398
|
-
|
|
399
|
-
spinner.succeed(chalk.green(`Generated: ${videoFileName} (${generatedDuration}s)`));
|
|
400
|
-
|
|
401
|
-
// If we need more duration, generate additional videos using image-to-video
|
|
402
|
-
// (video-extend often fails, so we use a more reliable approach)
|
|
403
|
-
let segmentIndex = 1;
|
|
404
|
-
while (generatedDuration < targetDuration - 1) {
|
|
405
|
-
const remainingNeeded = targetDuration - generatedDuration;
|
|
406
|
-
const segmentDuration = remainingNeeded > 5 ? "10" : "5";
|
|
407
|
-
|
|
408
|
-
spinner.start(`Generating additional ${segmentDuration}s segment...`);
|
|
409
|
-
|
|
410
|
-
// Extract last frame from current video
|
|
411
|
-
const lastFramePath = resolve(footageDir, `frame-extend-${gap.start.toFixed(2)}-${segmentIndex}.png`);
|
|
412
|
-
try {
|
|
413
|
-
// Get video duration first
|
|
414
|
-
let videoDur: number;
|
|
415
|
-
try {
|
|
416
|
-
videoDur = await ffprobeDuration(videoPath);
|
|
417
|
-
} catch {
|
|
418
|
-
videoDur = generatedDuration;
|
|
419
|
-
}
|
|
420
|
-
const lastFrameTime = Math.max(0, videoDur - 0.1);
|
|
421
|
-
|
|
422
|
-
await execSafe("ffmpeg", ["-i", videoPath, "-ss", String(lastFrameTime), "-vframes", "1", "-f", "image2", "-y", lastFramePath]);
|
|
423
|
-
} catch (err) {
|
|
424
|
-
spinner.fail(chalk.yellow("Failed to extract frame for continuation"));
|
|
425
|
-
break;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Upload to imgbb
|
|
429
|
-
const extFrameBuffer = await readFile(lastFramePath);
|
|
430
|
-
const extFrameBase64 = extFrameBuffer.toString("base64");
|
|
431
|
-
|
|
432
|
-
let extFrameUrl: string;
|
|
433
|
-
try {
|
|
434
|
-
const formData = new FormData();
|
|
435
|
-
formData.append("key", imgbbApiKey);
|
|
436
|
-
formData.append("image", extFrameBase64);
|
|
437
|
-
|
|
438
|
-
const imgbbResp = await fetch("https://api.imgbb.com/1/upload", {
|
|
439
|
-
method: "POST",
|
|
440
|
-
body: formData,
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
const imgbbData = await imgbbResp.json() as { success: boolean; data?: { url: string }; error?: { message: string } };
|
|
444
|
-
if (!imgbbData.success || !imgbbData.data?.url) {
|
|
445
|
-
throw new Error(imgbbData.error?.message || "Upload failed");
|
|
446
|
-
}
|
|
447
|
-
extFrameUrl = imgbbData.data.url;
|
|
448
|
-
} catch (err) {
|
|
449
|
-
spinner.fail(chalk.yellow("Failed to upload continuation frame"));
|
|
450
|
-
break;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Generate next segment
|
|
454
|
-
const segResult = await kling.generateVideo(prompt, {
|
|
455
|
-
prompt,
|
|
456
|
-
referenceImage: extFrameUrl,
|
|
457
|
-
duration: parseInt(segmentDuration) as 5 | 10,
|
|
458
|
-
aspectRatio: options.ratio as "16:9" | "9:16" | "1:1",
|
|
459
|
-
mode: options.mode as "std" | "pro",
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
if (segResult.status === "failed") {
|
|
463
|
-
spinner.fail(chalk.yellow(`Segment generation failed: ${segResult.error}`));
|
|
464
|
-
break;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const segFinalResult = await kling.waitForCompletion(
|
|
468
|
-
segResult.id,
|
|
469
|
-
"image2video",
|
|
470
|
-
(status) => {
|
|
471
|
-
spinner.text = `Generating segment... ${status.status}`;
|
|
472
|
-
},
|
|
473
|
-
600000
|
|
474
|
-
);
|
|
475
|
-
|
|
476
|
-
if (segFinalResult.status !== "completed" || !segFinalResult.videoUrl) {
|
|
477
|
-
spinner.fail(chalk.yellow(`Segment generation failed: ${segFinalResult.error || "Unknown error"}`));
|
|
478
|
-
break;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Download new segment
|
|
482
|
-
const segVideoPath = resolve(footageDir, `gap-fill-${gap.start.toFixed(2)}-${gap.end.toFixed(2)}-seg${segmentIndex}.mp4`);
|
|
483
|
-
const segVideoBuffer = await downloadVideo(segFinalResult.videoUrl);
|
|
484
|
-
await writeFile(segVideoPath, segVideoBuffer);
|
|
485
|
-
|
|
486
|
-
// Concatenate videos
|
|
487
|
-
const concatListPath = resolve(footageDir, `concat-${gap.start.toFixed(2)}.txt`);
|
|
488
|
-
const concatList = generatedVideos.map(v => `file '${v}'`).join("\n") + `\nfile '${segVideoPath}'`;
|
|
489
|
-
await writeFile(concatListPath, concatList);
|
|
490
|
-
|
|
491
|
-
const concatOutputPath = resolve(footageDir, `gap-fill-${gap.start.toFixed(2)}-${gap.end.toFixed(2)}-merged.mp4`);
|
|
492
|
-
try {
|
|
493
|
-
await execSafe("ffmpeg", ["-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy", "-y", concatOutputPath]);
|
|
494
|
-
// Replace main video with concatenated version
|
|
495
|
-
const { rename: renameFs } = await import("node:fs/promises");
|
|
496
|
-
await renameFs(concatOutputPath, videoPath);
|
|
497
|
-
} catch (err) {
|
|
498
|
-
spinner.fail(chalk.yellow("Failed to concatenate videos"));
|
|
499
|
-
break;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
generatedVideos.push(segVideoPath);
|
|
503
|
-
generatedDuration += segFinalResult.duration || parseInt(segmentDuration);
|
|
504
|
-
segmentIndex++;
|
|
505
|
-
|
|
506
|
-
spinner.succeed(chalk.green(`Added segment, total: ${generatedDuration.toFixed(1)}s`));
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// Add the generated video to the project
|
|
510
|
-
const actualGapStart = gapStart;
|
|
511
|
-
const actualGapDuration = Math.min(remainingGap, generatedDuration);
|
|
512
|
-
|
|
513
|
-
// Get video info for source
|
|
514
|
-
let videoDuration = generatedDuration;
|
|
515
|
-
try {
|
|
516
|
-
videoDuration = await ffprobeDuration(videoPath);
|
|
517
|
-
} catch {
|
|
518
|
-
// Use estimated duration
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Add source
|
|
522
|
-
const newSource = project.addSource({
|
|
523
|
-
name: videoFileName,
|
|
524
|
-
type: "video",
|
|
525
|
-
url: videoPath,
|
|
526
|
-
duration: videoDuration,
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
// Add clip
|
|
530
|
-
project.addClip({
|
|
531
|
-
sourceId: newSource.id,
|
|
532
|
-
trackId: videoClips[0].trackId,
|
|
533
|
-
startTime: actualGapStart,
|
|
534
|
-
duration: actualGapDuration,
|
|
535
|
-
sourceStartOffset: 0,
|
|
536
|
-
sourceEndOffset: actualGapDuration,
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
generatedCount++;
|
|
540
|
-
console.log(chalk.green(` Added to timeline: ${formatTime(actualGapStart)} - ${formatTime(actualGapStart + actualGapDuration)}`));
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
console.log();
|
|
544
|
-
|
|
545
|
-
if (generatedCount > 0) {
|
|
546
|
-
// Save project
|
|
547
|
-
const outputPath = options.output
|
|
548
|
-
? resolve(process.cwd(), options.output)
|
|
549
|
-
: filePath;
|
|
550
|
-
|
|
551
|
-
await writeFile(outputPath, JSON.stringify(project.toJSON(), null, 2));
|
|
552
|
-
|
|
553
|
-
console.log(chalk.bold.green(`✔ Filled ${generatedCount} gap(s) with AI-generated video`));
|
|
554
|
-
console.log(chalk.dim(`Project saved: ${outputPath}`));
|
|
555
|
-
} else {
|
|
556
|
-
console.log(chalk.yellow("No gaps were filled"));
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
console.log();
|
|
560
|
-
} catch (error) {
|
|
561
|
-
console.error(chalk.red("Fill gaps failed"));
|
|
562
|
-
console.error(error);
|
|
563
|
-
process.exit(1);
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ai-helpers.ts — Shared utility functions used across AI commands.
|
|
3
|
-
*
|
|
4
|
-
* These were extracted from ai.ts to improve maintainability.
|
|
5
|
-
* ai.ts imports and re-uses these internally.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { Project } from "../engine/index.js";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Download a video from URL, handling Veo/Google API authentication.
|
|
12
|
-
* Uses x-goog-api-key header (not query param) for Google API URLs.
|
|
13
|
-
*
|
|
14
|
-
* @param url - Video URL to download
|
|
15
|
-
* @param apiKey - Google API key (caller should resolve from env/config)
|
|
16
|
-
*/
|
|
17
|
-
export async function downloadVideo(url: string, apiKey?: string): Promise<Buffer> {
|
|
18
|
-
const headers: Record<string, string> = {};
|
|
19
|
-
if (url.includes("generativelanguage.googleapis.com") && apiKey) {
|
|
20
|
-
headers["x-goog-api-key"] = apiKey;
|
|
21
|
-
}
|
|
22
|
-
const response = await fetch(url, { headers, redirect: "follow" });
|
|
23
|
-
if (!response.ok) {
|
|
24
|
-
throw new Error(`Download failed (${response.status}): ${response.statusText}`);
|
|
25
|
-
}
|
|
26
|
-
return Buffer.from(await response.arrayBuffer());
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Format a duration in seconds to m:ss.s display format */
|
|
30
|
-
export function formatTime(seconds: number): string {
|
|
31
|
-
const mins = Math.floor(seconds / 60);
|
|
32
|
-
const secs = (seconds % 60).toFixed(1);
|
|
33
|
-
return `${mins}:${secs.padStart(4, "0")}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Apply a single AI edit suggestion to a project */
|
|
37
|
-
export function applySuggestion(project: Project, suggestion: any): boolean {
|
|
38
|
-
const { type, clipIds, params } = suggestion;
|
|
39
|
-
|
|
40
|
-
if (clipIds.length === 0) return false;
|
|
41
|
-
const clipId = clipIds[0];
|
|
42
|
-
|
|
43
|
-
switch (type) {
|
|
44
|
-
case "trim":
|
|
45
|
-
if (params.newDuration) {
|
|
46
|
-
return project.trimClipEnd(clipId, params.newDuration);
|
|
47
|
-
}
|
|
48
|
-
break;
|
|
49
|
-
case "add-effect":
|
|
50
|
-
if (params.effectType) {
|
|
51
|
-
const effect = project.addEffect(clipId, {
|
|
52
|
-
type: params.effectType,
|
|
53
|
-
startTime: params.startTime || 0,
|
|
54
|
-
duration: params.duration || 1,
|
|
55
|
-
params: params.effectParams || {},
|
|
56
|
-
});
|
|
57
|
-
return effect !== null;
|
|
58
|
-
}
|
|
59
|
-
break;
|
|
60
|
-
case "delete":
|
|
61
|
-
return project.removeClip(clipId);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return false;
|
|
65
|
-
}
|