@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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agent/adapters/index.d.ts +1 -0
  3. package/dist/agent/adapters/index.d.ts.map +1 -1
  4. package/dist/agent/adapters/index.js +5 -0
  5. package/dist/agent/adapters/index.js.map +1 -1
  6. package/dist/agent/adapters/openrouter.d.ts +16 -0
  7. package/dist/agent/adapters/openrouter.d.ts.map +1 -0
  8. package/dist/agent/adapters/openrouter.js +100 -0
  9. package/dist/agent/adapters/openrouter.js.map +1 -0
  10. package/dist/agent/types.d.ts +1 -1
  11. package/dist/agent/types.d.ts.map +1 -1
  12. package/dist/commands/agent.d.ts.map +1 -1
  13. package/dist/commands/agent.js +3 -1
  14. package/dist/commands/agent.js.map +1 -1
  15. package/dist/commands/setup.js +5 -2
  16. package/dist/commands/setup.js.map +1 -1
  17. package/dist/config/schema.d.ts +2 -1
  18. package/dist/config/schema.d.ts.map +1 -1
  19. package/dist/config/schema.js +2 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/index.js +0 -0
  22. package/package.json +16 -12
  23. package/.turbo/turbo-build.log +0 -4
  24. package/.turbo/turbo-lint.log +0 -21
  25. package/.turbo/turbo-test.log +0 -689
  26. package/src/agent/adapters/claude.ts +0 -143
  27. package/src/agent/adapters/gemini.ts +0 -159
  28. package/src/agent/adapters/index.ts +0 -61
  29. package/src/agent/adapters/ollama.ts +0 -231
  30. package/src/agent/adapters/openai.ts +0 -116
  31. package/src/agent/adapters/xai.ts +0 -119
  32. package/src/agent/index.ts +0 -251
  33. package/src/agent/memory/index.ts +0 -151
  34. package/src/agent/prompts/system.ts +0 -106
  35. package/src/agent/tools/ai-editing.ts +0 -845
  36. package/src/agent/tools/ai-generation.ts +0 -1073
  37. package/src/agent/tools/ai-pipeline.ts +0 -1055
  38. package/src/agent/tools/ai.ts +0 -21
  39. package/src/agent/tools/batch.ts +0 -429
  40. package/src/agent/tools/e2e.test.ts +0 -545
  41. package/src/agent/tools/export.ts +0 -184
  42. package/src/agent/tools/filesystem.ts +0 -237
  43. package/src/agent/tools/index.ts +0 -150
  44. package/src/agent/tools/integration.test.ts +0 -775
  45. package/src/agent/tools/media.ts +0 -697
  46. package/src/agent/tools/project.ts +0 -313
  47. package/src/agent/tools/timeline.ts +0 -951
  48. package/src/agent/types.ts +0 -68
  49. package/src/commands/agent.ts +0 -340
  50. package/src/commands/ai-analyze.ts +0 -429
  51. package/src/commands/ai-animated-caption.ts +0 -390
  52. package/src/commands/ai-audio.ts +0 -941
  53. package/src/commands/ai-broll.ts +0 -490
  54. package/src/commands/ai-edit-cli.ts +0 -658
  55. package/src/commands/ai-edit.ts +0 -1542
  56. package/src/commands/ai-fill-gaps.ts +0 -566
  57. package/src/commands/ai-helpers.ts +0 -65
  58. package/src/commands/ai-highlights.ts +0 -1303
  59. package/src/commands/ai-image.ts +0 -761
  60. package/src/commands/ai-motion.ts +0 -347
  61. package/src/commands/ai-narrate.ts +0 -451
  62. package/src/commands/ai-review.ts +0 -309
  63. package/src/commands/ai-script-pipeline-cli.ts +0 -1710
  64. package/src/commands/ai-script-pipeline.ts +0 -1365
  65. package/src/commands/ai-suggest-edit.ts +0 -264
  66. package/src/commands/ai-video-fx.ts +0 -445
  67. package/src/commands/ai-video.ts +0 -915
  68. package/src/commands/ai-viral.ts +0 -595
  69. package/src/commands/ai-visual-fx.ts +0 -601
  70. package/src/commands/ai.test.ts +0 -627
  71. package/src/commands/ai.ts +0 -307
  72. package/src/commands/analyze.ts +0 -282
  73. package/src/commands/audio.ts +0 -644
  74. package/src/commands/batch.test.ts +0 -279
  75. package/src/commands/batch.ts +0 -440
  76. package/src/commands/detect.ts +0 -329
  77. package/src/commands/doctor.ts +0 -237
  78. package/src/commands/edit-cmd.ts +0 -1014
  79. package/src/commands/export.ts +0 -918
  80. package/src/commands/generate.ts +0 -2146
  81. package/src/commands/media.ts +0 -177
  82. package/src/commands/output.ts +0 -142
  83. package/src/commands/pipeline.ts +0 -398
  84. package/src/commands/project.test.ts +0 -127
  85. package/src/commands/project.ts +0 -149
  86. package/src/commands/sanitize.ts +0 -60
  87. package/src/commands/schema.ts +0 -130
  88. package/src/commands/setup.ts +0 -509
  89. package/src/commands/timeline.test.ts +0 -499
  90. package/src/commands/timeline.ts +0 -529
  91. package/src/commands/validate.ts +0 -77
  92. package/src/config/config.test.ts +0 -197
  93. package/src/config/index.ts +0 -125
  94. package/src/config/schema.ts +0 -82
  95. package/src/engine/index.ts +0 -2
  96. package/src/engine/project.test.ts +0 -702
  97. package/src/engine/project.ts +0 -439
  98. package/src/index.ts +0 -146
  99. package/src/utils/api-key.test.ts +0 -41
  100. package/src/utils/api-key.ts +0 -247
  101. package/src/utils/audio.ts +0 -83
  102. package/src/utils/exec-safe.ts +0 -75
  103. package/src/utils/first-run.ts +0 -52
  104. package/src/utils/provider-resolver.ts +0 -56
  105. package/src/utils/remotion.ts +0 -951
  106. package/src/utils/subtitle.test.ts +0 -227
  107. package/src/utils/subtitle.ts +0 -169
  108. package/src/utils/tty.ts +0 -196
  109. 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