@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,601 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module ai-visual-fx
|
|
3
|
-
* @description Visual effects commands for the VibeFrame CLI.
|
|
4
|
-
*
|
|
5
|
-
* ## Commands: vibe ai grade, vibe ai text-overlay, vibe ai speed-ramp, vibe ai reframe, vibe ai style-transfer
|
|
6
|
-
* ## Dependencies: Whisper, Claude, FFmpeg
|
|
7
|
-
*
|
|
8
|
-
* Extracted from ai.ts as part of modularisation.
|
|
9
|
-
* ai.ts calls registerVisualFxCommands(aiCommand).
|
|
10
|
-
* @see MODELS.md for AI model configuration
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { type Command } from 'commander';
|
|
14
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
15
|
-
import { resolve } from 'node:path';
|
|
16
|
-
import { existsSync } from 'node:fs';
|
|
17
|
-
import chalk from 'chalk';
|
|
18
|
-
import ora from 'ora';
|
|
19
|
-
import {
|
|
20
|
-
WhisperProvider,
|
|
21
|
-
ClaudeProvider,
|
|
22
|
-
ReplicateProvider,
|
|
23
|
-
} from '@vibeframe/ai-providers';
|
|
24
|
-
import { getApiKey } from '../utils/api-key.js';
|
|
25
|
-
import { execSafe, commandExists } from '../utils/exec-safe.js';
|
|
26
|
-
import { formatTime, downloadVideo } from './ai-helpers.js';
|
|
27
|
-
import { applyTextOverlays, type TextOverlayStyle } from './ai-edit.js';
|
|
28
|
-
|
|
29
|
-
export function registerVisualFxCommands(ai: Command): void {
|
|
30
|
-
|
|
31
|
-
// ============================================================================
|
|
32
|
-
// Visual FX Commands
|
|
33
|
-
// ============================================================================
|
|
34
|
-
ai
|
|
35
|
-
.command("grade")
|
|
36
|
-
.description("Apply AI-generated color grading (Claude + FFmpeg)")
|
|
37
|
-
.argument("<video>", "Video file path")
|
|
38
|
-
.option("-s, --style <prompt>", "Style description (e.g., 'cinematic warm')")
|
|
39
|
-
.option("--preset <name>", "Built-in preset: film-noir, vintage, cinematic-warm, cool-tones, high-contrast, pastel, cyberpunk, horror")
|
|
40
|
-
.option("-o, --output <path>", "Output video file path")
|
|
41
|
-
.option("--analyze-only", "Show filter without applying")
|
|
42
|
-
.option("-k, --api-key <key>", "Anthropic API key (or set ANTHROPIC_API_KEY env)")
|
|
43
|
-
.action(async (videoPath: string, options) => {
|
|
44
|
-
try {
|
|
45
|
-
if (!options.style && !options.preset) {
|
|
46
|
-
console.error(chalk.red("Either --style or --preset is required"));
|
|
47
|
-
console.log(chalk.dim("Examples:"));
|
|
48
|
-
console.log(chalk.dim(' pnpm vibe ai grade video.mp4 --style "warm sunset"'));
|
|
49
|
-
console.log(chalk.dim(" pnpm vibe ai grade video.mp4 --preset cinematic-warm"));
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Check FFmpeg
|
|
54
|
-
if (!commandExists("ffmpeg")) {
|
|
55
|
-
console.error(chalk.red("FFmpeg not found. Please install FFmpeg."));
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const spinner = ora("Analyzing color grade...").start();
|
|
60
|
-
|
|
61
|
-
// Get API key if using style (not preset)
|
|
62
|
-
let gradeResult: { ffmpegFilter: string; description: string };
|
|
63
|
-
|
|
64
|
-
if (options.preset) {
|
|
65
|
-
const claude = new ClaudeProvider();
|
|
66
|
-
gradeResult = await claude.analyzeColorGrade("", options.preset);
|
|
67
|
-
} else {
|
|
68
|
-
const apiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic", options.apiKey);
|
|
69
|
-
const claude = new ClaudeProvider();
|
|
70
|
-
await claude.initialize({ apiKey: apiKey || undefined });
|
|
71
|
-
gradeResult = await claude.analyzeColorGrade(options.style);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
spinner.succeed(chalk.green("Color grade analyzed"));
|
|
75
|
-
console.log();
|
|
76
|
-
console.log(chalk.bold.cyan("Color Grade"));
|
|
77
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
78
|
-
console.log(`Style: ${options.preset || options.style}`);
|
|
79
|
-
console.log(`Description: ${gradeResult.description}`);
|
|
80
|
-
console.log();
|
|
81
|
-
console.log(chalk.dim("FFmpeg filter:"));
|
|
82
|
-
console.log(chalk.cyan(gradeResult.ffmpegFilter));
|
|
83
|
-
console.log();
|
|
84
|
-
|
|
85
|
-
if (options.analyzeOnly) {
|
|
86
|
-
console.log(chalk.dim("Use without --analyze-only to apply the grade."));
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const absPath = resolve(process.cwd(), videoPath);
|
|
91
|
-
const outputPath = options.output
|
|
92
|
-
? resolve(process.cwd(), options.output)
|
|
93
|
-
: absPath.replace(/(\.[^.]+)$/, "-graded$1");
|
|
94
|
-
|
|
95
|
-
spinner.start("Applying color grade...");
|
|
96
|
-
|
|
97
|
-
await execSafe("ffmpeg", ["-i", absPath, "-vf", gradeResult.ffmpegFilter, "-c:a", "copy", outputPath, "-y"], { timeout: 600000 });
|
|
98
|
-
|
|
99
|
-
spinner.succeed(chalk.green("Color grade applied"));
|
|
100
|
-
console.log(chalk.green(`Output: ${outputPath}`));
|
|
101
|
-
console.log();
|
|
102
|
-
} catch (error) {
|
|
103
|
-
console.error(chalk.red("Color grading failed"));
|
|
104
|
-
console.error(error);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// Text Overlay
|
|
110
|
-
ai
|
|
111
|
-
.command("text-overlay")
|
|
112
|
-
.description("Apply text overlays to video (FFmpeg drawtext)")
|
|
113
|
-
.argument("<video>", "Video file path")
|
|
114
|
-
.option("-t, --text <texts...>", "Text lines to overlay (repeat for multiple)")
|
|
115
|
-
.option("-s, --style <style>", "Overlay style: lower-third, center-bold, subtitle, minimal", "lower-third")
|
|
116
|
-
.option("--font-size <size>", "Font size in pixels (auto-calculated if omitted)")
|
|
117
|
-
.option("--font-color <color>", "Font color (default: white)", "white")
|
|
118
|
-
.option("--fade <seconds>", "Fade in/out duration in seconds", "0.3")
|
|
119
|
-
.option("--start <seconds>", "Start time in seconds", "0")
|
|
120
|
-
.option("--end <seconds>", "End time in seconds (default: video duration)")
|
|
121
|
-
.option("-o, --output <path>", "Output video file path")
|
|
122
|
-
.action(async (videoPath: string, options) => {
|
|
123
|
-
try {
|
|
124
|
-
if (!options.text || options.text.length === 0) {
|
|
125
|
-
console.error(chalk.red("At least one --text option is required"));
|
|
126
|
-
console.log(chalk.dim("Example:"));
|
|
127
|
-
console.log(chalk.dim(' pnpm vibe ai text-overlay video.mp4 -t "NEXUS AI" -t "Intelligence, Unleashed" --style center-bold'));
|
|
128
|
-
process.exit(1);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Check FFmpeg
|
|
132
|
-
if (!commandExists("ffmpeg")) {
|
|
133
|
-
console.error(chalk.red("FFmpeg not found. Please install FFmpeg."));
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const absPath = resolve(process.cwd(), videoPath);
|
|
138
|
-
const outputPath = options.output
|
|
139
|
-
? resolve(process.cwd(), options.output)
|
|
140
|
-
: absPath.replace(/(\.[^.]+)$/, "-overlay$1");
|
|
141
|
-
|
|
142
|
-
const spinner = ora("Applying text overlays...").start();
|
|
143
|
-
|
|
144
|
-
const result = await applyTextOverlays({
|
|
145
|
-
videoPath: absPath,
|
|
146
|
-
texts: options.text,
|
|
147
|
-
outputPath,
|
|
148
|
-
style: options.style as TextOverlayStyle,
|
|
149
|
-
fontSize: options.fontSize ? parseInt(options.fontSize) : undefined,
|
|
150
|
-
fontColor: options.fontColor,
|
|
151
|
-
fadeDuration: parseFloat(options.fade),
|
|
152
|
-
startTime: parseFloat(options.start),
|
|
153
|
-
endTime: options.end ? parseFloat(options.end) : undefined,
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
if (!result.success) {
|
|
157
|
-
spinner.fail(chalk.red(result.error || "Text overlay failed"));
|
|
158
|
-
process.exit(1);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
spinner.succeed(chalk.green("Text overlays applied"));
|
|
162
|
-
console.log();
|
|
163
|
-
console.log(chalk.bold.cyan("Text Overlay"));
|
|
164
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
165
|
-
console.log(`Style: ${options.style}`);
|
|
166
|
-
console.log(`Texts: ${options.text.join(", ")}`);
|
|
167
|
-
console.log(`Output: ${result.outputPath}`);
|
|
168
|
-
console.log();
|
|
169
|
-
} catch (error) {
|
|
170
|
-
console.error(chalk.red("Text overlay failed"));
|
|
171
|
-
console.error(error);
|
|
172
|
-
process.exit(1);
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// Speed Ramping
|
|
177
|
-
ai
|
|
178
|
-
.command("speed-ramp")
|
|
179
|
-
.description("Apply content-aware speed ramping (Whisper + Claude + FFmpeg)")
|
|
180
|
-
.argument("<video>", "Video file path")
|
|
181
|
-
.option("-o, --output <path>", "Output video file path")
|
|
182
|
-
.option("-s, --style <style>", "Style: dramatic, smooth, action", "dramatic")
|
|
183
|
-
.option("--min-speed <factor>", "Minimum speed factor", "0.25")
|
|
184
|
-
.option("--max-speed <factor>", "Maximum speed factor", "4.0")
|
|
185
|
-
.option("--analyze-only", "Show keyframes without applying")
|
|
186
|
-
.option("-l, --language <lang>", "Language code for transcription")
|
|
187
|
-
.option("-k, --api-key <key>", "Anthropic API key (or set ANTHROPIC_API_KEY env)")
|
|
188
|
-
.action(async (videoPath: string, options) => {
|
|
189
|
-
try {
|
|
190
|
-
// Check FFmpeg
|
|
191
|
-
if (!commandExists("ffmpeg")) {
|
|
192
|
-
console.error(chalk.red("FFmpeg not found. Please install FFmpeg."));
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const openaiApiKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
197
|
-
if (!openaiApiKey) {
|
|
198
|
-
console.error(chalk.red("OpenAI API key required for Whisper transcription. Set OPENAI_API_KEY in .env or run: vibe setup"));
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const claudeApiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic", options.apiKey);
|
|
203
|
-
if (!claudeApiKey) {
|
|
204
|
-
console.error(chalk.red("Anthropic API key required for speed analysis. Set ANTHROPIC_API_KEY in .env or run: vibe setup"));
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const absPath = resolve(process.cwd(), videoPath);
|
|
209
|
-
|
|
210
|
-
// Step 1: Check for audio stream
|
|
211
|
-
const spinner = ora("Extracting audio...").start();
|
|
212
|
-
|
|
213
|
-
const { stdout: speedRampProbe } = await execSafe("ffprobe", [
|
|
214
|
-
"-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "csv=p=0", absPath,
|
|
215
|
-
]);
|
|
216
|
-
if (!speedRampProbe.trim()) {
|
|
217
|
-
spinner.fail(chalk.yellow("Video has no audio track — cannot use Whisper transcription"));
|
|
218
|
-
console.log(chalk.yellow("\n⚠ This video has no audio stream."));
|
|
219
|
-
console.log(chalk.dim(" Speed ramping requires audio for content-aware analysis."));
|
|
220
|
-
console.log(chalk.dim(" Please use a video with an audio track.\n"));
|
|
221
|
-
process.exit(1);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const tempAudio = absPath.replace(/(\.[^.]+)$/, "-temp-audio.mp3");
|
|
225
|
-
|
|
226
|
-
await execSafe("ffmpeg", ["-i", absPath, "-vn", "-acodec", "libmp3lame", "-q:a", "2", tempAudio, "-y"]);
|
|
227
|
-
|
|
228
|
-
// Step 2: Transcribe
|
|
229
|
-
spinner.text = "Transcribing audio...";
|
|
230
|
-
|
|
231
|
-
const whisper = new WhisperProvider();
|
|
232
|
-
await whisper.initialize({ apiKey: openaiApiKey });
|
|
233
|
-
|
|
234
|
-
const audioBuffer = await readFile(tempAudio);
|
|
235
|
-
const audioBlob = new Blob([audioBuffer]);
|
|
236
|
-
const transcript = await whisper.transcribe(audioBlob, options.language);
|
|
237
|
-
|
|
238
|
-
if (!transcript.segments || transcript.segments.length === 0) {
|
|
239
|
-
spinner.fail(chalk.red("No transcript segments found"));
|
|
240
|
-
process.exit(1);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Step 3: Analyze with Claude
|
|
244
|
-
spinner.text = "Analyzing for speed ramping...";
|
|
245
|
-
|
|
246
|
-
const claude = new ClaudeProvider();
|
|
247
|
-
await claude.initialize({ apiKey: claudeApiKey });
|
|
248
|
-
|
|
249
|
-
const speedResult = await claude.analyzeForSpeedRamp(transcript.segments, {
|
|
250
|
-
style: options.style as "dramatic" | "smooth" | "action",
|
|
251
|
-
minSpeed: parseFloat(options.minSpeed),
|
|
252
|
-
maxSpeed: parseFloat(options.maxSpeed),
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// Clean up temp file
|
|
256
|
-
try {
|
|
257
|
-
const { unlink } = await import("node:fs/promises");
|
|
258
|
-
await unlink(tempAudio);
|
|
259
|
-
} catch { /* ignore cleanup errors */ }
|
|
260
|
-
|
|
261
|
-
spinner.succeed(chalk.green(`Found ${speedResult.keyframes.length} speed keyframes`));
|
|
262
|
-
|
|
263
|
-
console.log();
|
|
264
|
-
console.log(chalk.bold.cyan("Speed Ramp Keyframes"));
|
|
265
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
266
|
-
|
|
267
|
-
for (const kf of speedResult.keyframes) {
|
|
268
|
-
const speedColor = kf.speed < 1 ? chalk.blue : kf.speed > 1 ? chalk.yellow : chalk.white;
|
|
269
|
-
console.log(` ${formatTime(kf.time)} → ${speedColor(`${kf.speed.toFixed(2)}x`)} - ${kf.reason}`);
|
|
270
|
-
}
|
|
271
|
-
console.log();
|
|
272
|
-
|
|
273
|
-
if (options.analyzeOnly) {
|
|
274
|
-
console.log(chalk.dim("Use without --analyze-only to apply speed ramps."));
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (speedResult.keyframes.length < 2) {
|
|
279
|
-
console.log(chalk.yellow("Not enough keyframes for speed ramping."));
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
spinner.start("Applying speed ramps...");
|
|
284
|
-
|
|
285
|
-
// Build FFmpeg filter for speed ramping (segment-based)
|
|
286
|
-
const outputPath = options.output
|
|
287
|
-
? resolve(process.cwd(), options.output)
|
|
288
|
-
: absPath.replace(/(\.[^.]+)$/, "-ramped$1");
|
|
289
|
-
|
|
290
|
-
// For simplicity, we'll create segments and concatenate
|
|
291
|
-
// A full implementation would use complex filter expressions
|
|
292
|
-
// Here we use setpts with a simple approach
|
|
293
|
-
|
|
294
|
-
// For demo, apply average speed or first segment's speed
|
|
295
|
-
const avgSpeed = speedResult.keyframes.reduce((sum, kf) => sum + kf.speed, 0) / speedResult.keyframes.length;
|
|
296
|
-
|
|
297
|
-
// Use setpts for speed change (1/speed for setpts)
|
|
298
|
-
const setpts = `setpts=${(1 / avgSpeed).toFixed(3)}*PTS`;
|
|
299
|
-
const atempo = avgSpeed >= 0.5 && avgSpeed <= 2.0 ? `atempo=${avgSpeed.toFixed(3)}` : "";
|
|
300
|
-
|
|
301
|
-
if (atempo) {
|
|
302
|
-
await execSafe("ffmpeg", ["-i", absPath, "-filter_complex", `[0:v]${setpts}[v];[0:a]${atempo}[a]`, "-map", "[v]", "-map", "[a]", outputPath, "-y"], { timeout: 600000 });
|
|
303
|
-
} else {
|
|
304
|
-
await execSafe("ffmpeg", ["-i", absPath, "-vf", setpts, "-an", outputPath, "-y"], { timeout: 600000 });
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
spinner.succeed(chalk.green("Speed ramp applied"));
|
|
308
|
-
console.log(chalk.green(`Output: ${outputPath}`));
|
|
309
|
-
console.log(chalk.dim(`Average speed: ${avgSpeed.toFixed(2)}x`));
|
|
310
|
-
console.log();
|
|
311
|
-
} catch (error) {
|
|
312
|
-
console.error(chalk.red("Speed ramping failed"));
|
|
313
|
-
console.error(error);
|
|
314
|
-
process.exit(1);
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
// Auto Reframe
|
|
319
|
-
ai
|
|
320
|
-
.command("reframe")
|
|
321
|
-
.description("Auto-reframe video to different aspect ratio (Claude Vision + FFmpeg)")
|
|
322
|
-
.argument("<video>", "Video file path")
|
|
323
|
-
.option("-a, --aspect <ratio>", "Target aspect ratio: 9:16, 1:1, 4:5", "9:16")
|
|
324
|
-
.option("-f, --focus <mode>", "Focus mode: auto, face, center, action", "auto")
|
|
325
|
-
.option("-o, --output <path>", "Output video file path")
|
|
326
|
-
.option("--analyze-only", "Show crop regions without applying")
|
|
327
|
-
.option("--keyframes <path>", "Export keyframes to JSON file")
|
|
328
|
-
.option("-k, --api-key <key>", "Anthropic API key (or set ANTHROPIC_API_KEY env)")
|
|
329
|
-
.action(async (videoPath: string, options) => {
|
|
330
|
-
try {
|
|
331
|
-
// Check FFmpeg
|
|
332
|
-
if (!commandExists("ffmpeg")) {
|
|
333
|
-
console.error(chalk.red("FFmpeg not found. Please install FFmpeg."));
|
|
334
|
-
process.exit(1);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const absPath = resolve(process.cwd(), videoPath);
|
|
338
|
-
|
|
339
|
-
// Get video dimensions
|
|
340
|
-
const spinner = ora("Analyzing video...").start();
|
|
341
|
-
|
|
342
|
-
const { stdout: probeOut } = await execSafe("ffprobe", [
|
|
343
|
-
"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration", "-of", "csv=p=0", absPath,
|
|
344
|
-
]);
|
|
345
|
-
const [width, height, durationStr] = probeOut.trim().split(",");
|
|
346
|
-
const sourceWidth = parseInt(width);
|
|
347
|
-
const sourceHeight = parseInt(height);
|
|
348
|
-
const duration = parseFloat(durationStr);
|
|
349
|
-
|
|
350
|
-
spinner.text = "Extracting keyframes...";
|
|
351
|
-
|
|
352
|
-
// Extract keyframes every 2 seconds for analysis
|
|
353
|
-
const keyframeInterval = 2;
|
|
354
|
-
const numKeyframes = Math.ceil(duration / keyframeInterval);
|
|
355
|
-
const tempDir = `/tmp/vibe-reframe-${Date.now()}`;
|
|
356
|
-
const { mkdir: mkdirFs } = await import("node:fs/promises");
|
|
357
|
-
await mkdirFs(tempDir, { recursive: true });
|
|
358
|
-
|
|
359
|
-
await execSafe("ffmpeg", ["-i", absPath, "-vf", `fps=1/${keyframeInterval}`, "-frame_pts", "1", `${tempDir}/frame-%04d.jpg`, "-y"]);
|
|
360
|
-
|
|
361
|
-
// Get API key
|
|
362
|
-
const apiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic", options.apiKey);
|
|
363
|
-
const claude = new ClaudeProvider();
|
|
364
|
-
await claude.initialize({ apiKey: apiKey || undefined });
|
|
365
|
-
|
|
366
|
-
// Analyze keyframes
|
|
367
|
-
spinner.text = "Analyzing frames for subject tracking...";
|
|
368
|
-
|
|
369
|
-
const cropKeyframes: Array<{
|
|
370
|
-
time: number;
|
|
371
|
-
cropX: number;
|
|
372
|
-
cropY: number;
|
|
373
|
-
cropWidth: number;
|
|
374
|
-
cropHeight: number;
|
|
375
|
-
confidence: number;
|
|
376
|
-
subjectDescription: string;
|
|
377
|
-
}> = [];
|
|
378
|
-
|
|
379
|
-
for (let i = 1; i <= numKeyframes && i <= 30; i++) {
|
|
380
|
-
// Limit to 30 frames
|
|
381
|
-
const framePath = `${tempDir}/frame-${i.toString().padStart(4, "0")}.jpg`;
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
const frameBuffer = await readFile(framePath);
|
|
385
|
-
const frameBase64 = frameBuffer.toString("base64");
|
|
386
|
-
|
|
387
|
-
const result = await claude.analyzeFrameForReframe(frameBase64, options.aspect, {
|
|
388
|
-
focusMode: options.focus,
|
|
389
|
-
sourceWidth,
|
|
390
|
-
sourceHeight,
|
|
391
|
-
mimeType: "image/jpeg",
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
cropKeyframes.push({
|
|
395
|
-
time: (i - 1) * keyframeInterval,
|
|
396
|
-
...result,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
spinner.text = `Analyzing frames... ${i}/${Math.min(numKeyframes, 30)}`;
|
|
400
|
-
} catch (e) {
|
|
401
|
-
// Skip failed frames
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Small delay to avoid rate limiting
|
|
405
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Clean up temp files
|
|
409
|
-
try {
|
|
410
|
-
const { rm: rmFs } = await import("node:fs/promises");
|
|
411
|
-
await rmFs(tempDir, { recursive: true, force: true });
|
|
412
|
-
} catch { /* ignore cleanup errors */ }
|
|
413
|
-
|
|
414
|
-
spinner.succeed(chalk.green(`Analyzed ${cropKeyframes.length} keyframes`));
|
|
415
|
-
|
|
416
|
-
console.log();
|
|
417
|
-
console.log(chalk.bold.cyan("Reframe Analysis"));
|
|
418
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
419
|
-
console.log(`Source: ${sourceWidth}x${sourceHeight}`);
|
|
420
|
-
console.log(`Target: ${options.aspect}`);
|
|
421
|
-
console.log(`Focus: ${options.focus}`);
|
|
422
|
-
console.log();
|
|
423
|
-
|
|
424
|
-
if (cropKeyframes.length > 0) {
|
|
425
|
-
const avgConf = cropKeyframes.reduce((sum, kf) => sum + kf.confidence, 0) / cropKeyframes.length;
|
|
426
|
-
console.log(`Average confidence: ${(avgConf * 100).toFixed(0)}%`);
|
|
427
|
-
console.log();
|
|
428
|
-
console.log(chalk.dim("Sample keyframes:"));
|
|
429
|
-
for (const kf of cropKeyframes.slice(0, 5)) {
|
|
430
|
-
console.log(` ${formatTime(kf.time)} → crop=${kf.cropX},${kf.cropY} (${kf.subjectDescription})`);
|
|
431
|
-
}
|
|
432
|
-
if (cropKeyframes.length > 5) {
|
|
433
|
-
console.log(chalk.dim(` ... and ${cropKeyframes.length - 5} more`));
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
console.log();
|
|
437
|
-
|
|
438
|
-
// Export keyframes if requested
|
|
439
|
-
if (options.keyframes) {
|
|
440
|
-
const keyframesPath = resolve(process.cwd(), options.keyframes);
|
|
441
|
-
await writeFile(keyframesPath, JSON.stringify(cropKeyframes, null, 2));
|
|
442
|
-
console.log(chalk.green(`Keyframes saved to: ${keyframesPath}`));
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (options.analyzeOnly) {
|
|
446
|
-
console.log(chalk.dim("Use without --analyze-only to apply reframe."));
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Apply reframe using average crop position
|
|
451
|
-
const avgCropX = Math.round(cropKeyframes.reduce((sum, kf) => sum + kf.cropX, 0) / cropKeyframes.length);
|
|
452
|
-
const avgCropY = Math.round(cropKeyframes.reduce((sum, kf) => sum + kf.cropY, 0) / cropKeyframes.length);
|
|
453
|
-
const cropWidth = cropKeyframes[0]?.cropWidth || sourceWidth;
|
|
454
|
-
const cropHeight = cropKeyframes[0]?.cropHeight || sourceHeight;
|
|
455
|
-
|
|
456
|
-
const outputPath = options.output
|
|
457
|
-
? resolve(process.cwd(), options.output)
|
|
458
|
-
: absPath.replace(/(\.[^.]+)$/, `-${options.aspect.replace(":", "x")}$1`);
|
|
459
|
-
|
|
460
|
-
spinner.start("Applying reframe...");
|
|
461
|
-
|
|
462
|
-
await execSafe("ffmpeg", ["-i", absPath, "-vf", `crop=${cropWidth}:${cropHeight}:${avgCropX}:${avgCropY}`, "-c:a", "copy", outputPath, "-y"], { timeout: 600000 });
|
|
463
|
-
|
|
464
|
-
spinner.succeed(chalk.green("Reframe applied"));
|
|
465
|
-
console.log(chalk.green(`Output: ${outputPath}`));
|
|
466
|
-
console.log(chalk.dim(`Crop: ${cropWidth}x${cropHeight} at (${avgCropX}, ${avgCropY})`));
|
|
467
|
-
console.log();
|
|
468
|
-
} catch (error) {
|
|
469
|
-
console.error(chalk.red("Reframe failed"));
|
|
470
|
-
console.error(error);
|
|
471
|
-
process.exit(1);
|
|
472
|
-
}
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
// Style Transfer
|
|
476
|
-
ai
|
|
477
|
-
.command("style-transfer")
|
|
478
|
-
.description("Apply artistic style transfer to video (Replicate)")
|
|
479
|
-
.argument("<video>", "Video file path or URL")
|
|
480
|
-
.option("-s, --style <path/prompt>", "Style reference image path or text prompt")
|
|
481
|
-
.option("-o, --output <path>", "Output video file path")
|
|
482
|
-
.option("--strength <value>", "Transfer strength (0-1)", "0.5")
|
|
483
|
-
.option("--no-wait", "Start processing without waiting")
|
|
484
|
-
.option("-k, --api-key <key>", "Replicate API token (or set REPLICATE_API_TOKEN env)")
|
|
485
|
-
.action(async (videoPath: string, options) => {
|
|
486
|
-
try {
|
|
487
|
-
if (!options.style) {
|
|
488
|
-
console.error(chalk.red("Style required. Use --style <image-path> or --style <prompt>"));
|
|
489
|
-
process.exit(1);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const apiKey = await getApiKey("REPLICATE_API_TOKEN", "Replicate", options.apiKey);
|
|
493
|
-
if (!apiKey) {
|
|
494
|
-
console.error(chalk.red("Replicate API token required."));
|
|
495
|
-
console.error(chalk.dim("Set REPLICATE_API_TOKEN environment variable"));
|
|
496
|
-
process.exit(1);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const spinner = ora("Initializing style transfer...").start();
|
|
500
|
-
|
|
501
|
-
const replicate = new ReplicateProvider();
|
|
502
|
-
await replicate.initialize({ apiKey });
|
|
503
|
-
|
|
504
|
-
// Determine if style is an image path or text prompt
|
|
505
|
-
let styleRef: string | undefined;
|
|
506
|
-
let stylePrompt: string | undefined;
|
|
507
|
-
|
|
508
|
-
if (options.style.startsWith("http://") || options.style.startsWith("https://")) {
|
|
509
|
-
styleRef = options.style;
|
|
510
|
-
} else if (existsSync(resolve(process.cwd(), options.style))) {
|
|
511
|
-
// It's a local file - need to upload or base64
|
|
512
|
-
spinner.fail(chalk.yellow("Local style images must be URLs for Replicate."));
|
|
513
|
-
console.log(chalk.dim("Upload your style image to a URL and try again."));
|
|
514
|
-
process.exit(1);
|
|
515
|
-
} else {
|
|
516
|
-
// Treat as text prompt
|
|
517
|
-
stylePrompt = options.style;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Video must be URL
|
|
521
|
-
let videoUrl: string;
|
|
522
|
-
if (videoPath.startsWith("http://") || videoPath.startsWith("https://")) {
|
|
523
|
-
videoUrl = videoPath;
|
|
524
|
-
} else {
|
|
525
|
-
spinner.fail(chalk.yellow("Video must be a URL for Replicate processing."));
|
|
526
|
-
console.log(chalk.dim("Upload your video to a URL and try again."));
|
|
527
|
-
process.exit(1);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
spinner.text = "Starting style transfer...";
|
|
531
|
-
|
|
532
|
-
const result = await replicate.styleTransferVideo({
|
|
533
|
-
videoUrl,
|
|
534
|
-
styleRef,
|
|
535
|
-
stylePrompt,
|
|
536
|
-
strength: parseFloat(options.strength),
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
if (result.status === "failed") {
|
|
540
|
-
spinner.fail(chalk.red(result.error || "Style transfer failed"));
|
|
541
|
-
process.exit(1);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
console.log();
|
|
545
|
-
console.log(chalk.bold.cyan("Style Transfer Started"));
|
|
546
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
547
|
-
console.log(`Task ID: ${chalk.bold(result.id)}`);
|
|
548
|
-
console.log(`Style: ${stylePrompt || styleRef}`);
|
|
549
|
-
console.log(`Strength: ${options.strength}`);
|
|
550
|
-
|
|
551
|
-
if (!options.wait) {
|
|
552
|
-
spinner.succeed(chalk.green("Style transfer started"));
|
|
553
|
-
console.log();
|
|
554
|
-
console.log(chalk.dim("Check status with:"));
|
|
555
|
-
console.log(chalk.dim(` curl -s -H "Authorization: Bearer $REPLICATE_API_TOKEN" https://api.replicate.com/v1/predictions/${result.id}`));
|
|
556
|
-
console.log();
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
spinner.text = "Processing style transfer (this may take several minutes)...";
|
|
561
|
-
|
|
562
|
-
const finalResult = await replicate.waitForCompletion(
|
|
563
|
-
result.id,
|
|
564
|
-
(status) => {
|
|
565
|
-
spinner.text = `Processing... ${status.status}`;
|
|
566
|
-
},
|
|
567
|
-
600000
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
if (finalResult.status !== "completed") {
|
|
571
|
-
spinner.fail(chalk.red(finalResult.error || "Style transfer failed"));
|
|
572
|
-
process.exit(1);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
spinner.succeed(chalk.green("Style transfer complete"));
|
|
576
|
-
|
|
577
|
-
console.log();
|
|
578
|
-
if (finalResult.videoUrl) {
|
|
579
|
-
console.log(`Video URL: ${finalResult.videoUrl}`);
|
|
580
|
-
|
|
581
|
-
if (options.output) {
|
|
582
|
-
const downloadSpinner = ora("Downloading video...").start();
|
|
583
|
-
try {
|
|
584
|
-
const buffer = await downloadVideo(finalResult.videoUrl);
|
|
585
|
-
const outputPath = resolve(process.cwd(), options.output);
|
|
586
|
-
await writeFile(outputPath, buffer);
|
|
587
|
-
downloadSpinner.succeed(chalk.green(`Saved to: ${outputPath}`));
|
|
588
|
-
} catch (err) {
|
|
589
|
-
downloadSpinner.fail(chalk.red("Failed to download video"));
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
console.log();
|
|
594
|
-
} catch (error) {
|
|
595
|
-
console.error(chalk.red("Style transfer failed"));
|
|
596
|
-
console.error(error);
|
|
597
|
-
process.exit(1);
|
|
598
|
-
}
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
} // end registerVisualFxCommands
|