@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,951 +0,0 @@
1
- /**
2
- * Remotion rendering and compositing utilities.
3
- *
4
- * Uses `npx remotion` on-demand — Remotion is NOT a package dependency.
5
- * Scaffolds a temporary project, renders H264 MP4, and muxes audio separately.
6
- *
7
- * Strategy:
8
- * - Images/Videos are embedded NATIVELY inside the Remotion component using
9
- * <Img> / <Video> from Remotion (copied to public/).
10
- * - No transparent WebM rendering. No FFmpeg overlay compositing.
11
- * - Final output is always a standard H264 MP4.
12
- */
13
-
14
- import { writeFile, mkdir, rm, copyFile } from "node:fs/promises";
15
- import { existsSync } from "node:fs";
16
- import { join } from "node:path";
17
- import { tmpdir } from "node:os";
18
- import { execSafe } from "./exec-safe.js";
19
-
20
- // ── Types ──────────────────────────────────────────────────────────────────
21
-
22
- export interface RenderMotionOptions {
23
- /** Generated TSX component code */
24
- componentCode: string;
25
- /** Export name of the component */
26
- componentName: string;
27
- width: number;
28
- height: number;
29
- fps: number;
30
- durationInFrames: number;
31
- /** Output path for rendered video (.webm or .mp4) */
32
- outputPath: string;
33
- /** Render with transparent background (default: true) */
34
- transparent?: boolean;
35
- }
36
-
37
- export interface CompositeOptions {
38
- /** Base video to overlay on */
39
- baseVideo: string;
40
- /** Rendered overlay (transparent WebM) */
41
- overlayPath: string;
42
- /** Final composited output */
43
- outputPath: string;
44
- }
45
-
46
- export interface RenderResult {
47
- success: boolean;
48
- outputPath?: string;
49
- error?: string;
50
- }
51
-
52
- // ── Helpers ────────────────────────────────────────────────────────────────
53
-
54
- /**
55
- * Check that `npx remotion` is available. Returns an error message if not.
56
- */
57
- export async function ensureRemotionInstalled(): Promise<string | null> {
58
- try {
59
- await execSafe("npx", ["remotion", "--help"], { timeout: 30_000 });
60
- return null;
61
- } catch {
62
- return [
63
- "Remotion CLI not found. Install it with:",
64
- " npm install -g @remotion/cli",
65
- "Or ensure npx is available and can download @remotion/cli on demand.",
66
- ].join("\n");
67
- }
68
- }
69
-
70
- /**
71
- * Create a minimal Remotion project in a temp directory.
72
- * Returns the directory path.
73
- *
74
- * @param useMediaPackage - Include @remotion/media for <Video> support (default: false)
75
- */
76
- export async function scaffoldRemotionProject(
77
- componentCode: string,
78
- componentName: string,
79
- opts: {
80
- width: number;
81
- height: number;
82
- fps: number;
83
- durationInFrames: number;
84
- useMediaPackage?: boolean;
85
- },
86
- ): Promise<string> {
87
- const dir = join(tmpdir(), `vibe_motion_${Date.now()}`);
88
- await mkdir(dir, { recursive: true });
89
-
90
- // package.json — remotion + react deps
91
- const deps: Record<string, string> = {
92
- remotion: "^4.0.0",
93
- "@remotion/cli": "^4.0.0",
94
- react: "^18.0.0",
95
- "react-dom": "^18.0.0",
96
- "@types/react": "^18.0.0",
97
- };
98
-
99
- // @remotion/media is needed for the <Video> component (per Remotion docs)
100
- if (opts.useMediaPackage) {
101
- deps["@remotion/media"] = "^4.0.0";
102
- }
103
-
104
- const packageJson = {
105
- name: "vibe-motion-render",
106
- version: "1.0.0",
107
- private: true,
108
- dependencies: deps,
109
- };
110
- await writeFile(join(dir, "package.json"), JSON.stringify(packageJson, null, 2));
111
-
112
- // tsconfig.json — minimal config for TSX
113
- const tsconfig = {
114
- compilerOptions: {
115
- target: "ES2020",
116
- module: "ESNext",
117
- moduleResolution: "bundler",
118
- jsx: "react-jsx",
119
- strict: false,
120
- esModuleInterop: true,
121
- skipLibCheck: true,
122
- },
123
- };
124
- await writeFile(join(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2));
125
-
126
- // Component.tsx — the AI-generated (and optionally wrapped) component
127
- await writeFile(join(dir, "Component.tsx"), componentCode);
128
-
129
- // Root.tsx — Remotion entry point
130
- const rootCode = `import { registerRoot, Composition } from "remotion";
131
- import { ${componentName} } from "./Component";
132
-
133
- const Root = () => {
134
- return (
135
- <Composition
136
- id="${componentName}"
137
- component={${componentName}}
138
- durationInFrames={${opts.durationInFrames}}
139
- fps={${opts.fps}}
140
- width={${opts.width}}
141
- height={${opts.height}}
142
- />
143
- );
144
- };
145
-
146
- registerRoot(Root);
147
- `;
148
- await writeFile(join(dir, "Root.tsx"), rootCode);
149
-
150
- // Install deps
151
- if (!existsSync(join(dir, "node_modules"))) {
152
- // npm install needs to run in the scaffolded directory
153
- const { execFile } = await import("node:child_process");
154
- const { promisify } = await import("node:util");
155
- const execFileAsync = promisify(execFile);
156
- await execFileAsync("npm", ["install", "--prefer-offline", "--no-audit", "--no-fund"], {
157
- cwd: dir,
158
- timeout: 180_000,
159
- });
160
- }
161
-
162
- return dir;
163
- }
164
-
165
- // ── Standalone Motion Render ───────────────────────────────────────────────
166
-
167
- /**
168
- * Render a standalone Remotion composition to video (no base media).
169
- * When transparent: tries VP8 then VP9.
170
- * When opaque: renders H264 MP4.
171
- */
172
- export async function renderMotion(options: RenderMotionOptions): Promise<RenderResult> {
173
- const transparent = options.transparent !== false;
174
-
175
- const dir = await scaffoldRemotionProject(
176
- options.componentCode,
177
- options.componentName,
178
- {
179
- width: options.width,
180
- height: options.height,
181
- fps: options.fps,
182
- durationInFrames: options.durationInFrames,
183
- },
184
- );
185
-
186
- try {
187
- const entryPoint = join(dir, "Root.tsx");
188
-
189
- const { execFile } = await import("node:child_process");
190
- const { promisify } = await import("node:util");
191
- const execFileAsync = promisify(execFile);
192
-
193
- if (transparent) {
194
- const webmOut = options.outputPath.replace(/\.\w+$/, ".webm");
195
-
196
- try {
197
- await execFileAsync("npx", [
198
- "remotion", "render", entryPoint, options.componentName, webmOut,
199
- "--codec", "vp8", "--image-format", "png", "--pixel-format", "yuva420p",
200
- ], { cwd: dir, timeout: 300_000 });
201
- return { success: true, outputPath: webmOut };
202
- } catch {
203
- // VP8 failed, try VP9
204
- }
205
-
206
- try {
207
- await execFileAsync("npx", [
208
- "remotion", "render", entryPoint, options.componentName, webmOut,
209
- "--codec", "vp9", "--image-format", "png", "--pixel-format", "yuva420p",
210
- ], { cwd: dir, timeout: 300_000 });
211
- return { success: true, outputPath: webmOut };
212
- } catch (error) {
213
- const msg = error instanceof Error ? error.message : String(error);
214
- return { success: false, error: `Transparent render failed (VP8 & VP9): ${msg}` };
215
- }
216
- }
217
-
218
- // Non-transparent: H264 MP4
219
- const mp4Out = options.outputPath.replace(/\.\w+$/, ".mp4");
220
- await execFileAsync("npx", [
221
- "remotion", "render", entryPoint, options.componentName, mp4Out,
222
- "--codec", "h264", "--crf", "18",
223
- ], { cwd: dir, timeout: 300_000 });
224
- return { success: true, outputPath: mp4Out };
225
- } catch (error) {
226
- const msg = error instanceof Error ? error.message : String(error);
227
- return { success: false, error: `Remotion render failed: ${msg}` };
228
- } finally {
229
- await rm(dir, { recursive: true, force: true }).catch(() => {});
230
- }
231
- }
232
-
233
- // ── Pre-render code validator & auto-fixer ────────────────────────────────
234
-
235
- /**
236
- * Validates and auto-fixes common LLM-generated Remotion code bugs before
237
- * attempting to render. Returns fixed code and a list of applied fixes.
238
- *
239
- * Known patterns fixed:
240
- * 1. interpolate(x, [a,b], scalar, num) → interpolate(x, [a,b], [scalar, num])
241
- * Cause: outputRange must be an array, not a bare scalar.
242
- * 2. interpolate(x, [a,b], scalar) where scalar is a variable name
243
- * Cause: same — LLM passes a single number variable instead of [from, to].
244
- */
245
- export function validateAndFixMotionCode(code: string): { code: string; fixes: string[] } {
246
- const fixes: string[] = [];
247
- let fixed = code;
248
-
249
- // Pattern 1: interpolate(expr, [a, b], varName, numericLiteral)
250
- // where varName is a JS identifier and numericLiteral is a number
251
- // This is the exact bug seen in practice: interpolate(exitEase, [0, 1], barH, 0)
252
- const pattern1 = /interpolate\(([^,]+),\s*(\[[^\]]+\]),\s*([a-zA-Z_$][a-zA-Z0-9_$.]*),\s*(-?[\d.]+)\s*\)/g;
253
- fixed = fixed.replace(pattern1, (_match, val, inputRange, outVar, outNum) => {
254
- const fix = `interpolate(${val}, ${inputRange}, [${outVar}, ${outNum}])`;
255
- fixes.push(`Fixed scalar outputRange: interpolate(..., ${outVar}, ${outNum}) → [..., [${outVar}, ${outNum}]]`);
256
- return fix;
257
- });
258
-
259
- // Pattern 2: interpolate(expr, [a, b], singleIdentifier) — no options arg
260
- // e.g. interpolate(frame, [0, 30], progress) where progress is not an array
261
- // Heuristic: if the third arg is a plain identifier (not starting with [) and
262
- // there's no fourth arg, we can't safely auto-fix without knowing the intent,
263
- // so just log a warning in the fixes list for visibility.
264
- const pattern2 = /interpolate\(([^,]+),\s*(\[[^\]]+\]),\s*([a-zA-Z_$][a-zA-Z0-9_$.]*)\s*\)/g;
265
- let p2match;
266
- while ((p2match = pattern2.exec(fixed)) !== null) {
267
- // Only warn if the identifier doesn't look like an array variable name
268
- const varName = p2match[3];
269
- if (!varName.includes("[")) {
270
- fixes.push(`Warning: interpolate third arg "${varName}" may not be an array — verify outputRange is [from, to]`);
271
- }
272
- }
273
-
274
- return { code: fixed, fixes };
275
- }
276
-
277
- // ── Import injection helper ────────────────────────────────────────────────
278
-
279
- /**
280
- * Inject additional named imports into the existing `from 'remotion'`
281
- * import statement in the component code.
282
- * Avoids duplicate identifier errors when the component already imports
283
- * some of the same names (e.g. AbsoluteFill).
284
- */
285
- function injectRemotionImports(code: string, additions: string[]): string {
286
- return code.replace(
287
- /import\s*\{([^}]+)\}\s*from\s*['"]remotion['"]/,
288
- (match, imports) => {
289
- const existing = imports
290
- .split(",")
291
- .map((s: string) => s.trim())
292
- .filter(Boolean);
293
- const toAdd = additions.filter((a) => !existing.includes(a));
294
- if (toAdd.length === 0) return match;
295
- return `import { ${[...existing, ...toAdd].join(", ")} } from "remotion"`;
296
- },
297
- );
298
- }
299
-
300
- // ── Native Image Embed ─────────────────────────────────────────────────────
301
-
302
- /**
303
- * Wrap an overlay component to embed a static image as background.
304
- * Uses Remotion's <Img> component (required per Remotion docs — ensures
305
- * image is fully loaded before each frame renders).
306
- *
307
- * Injects Img and staticFile into the component's existing remotion import
308
- * to avoid duplicate identifier errors.
309
- */
310
- export function wrapComponentWithImage(
311
- componentCode: string,
312
- componentName: string,
313
- imageFileName: string,
314
- ): { code: string; name: string } {
315
- const wrappedName = "ImageComposite";
316
-
317
- // Inject Img and staticFile into the existing remotion import
318
- const modifiedCode = injectRemotionImports(componentCode, ["Img", "staticFile"]);
319
-
320
- const code = `${modifiedCode}
321
-
322
- export const ${wrappedName}: React.FC = () => {
323
- return (
324
- <AbsoluteFill>
325
- <Img
326
- src={staticFile("${imageFileName}")}
327
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
328
- />
329
- <AbsoluteFill>
330
- <${componentName} />
331
- </AbsoluteFill>
332
- </AbsoluteFill>
333
- );
334
- };
335
- `;
336
-
337
- return { code, name: wrappedName };
338
- }
339
-
340
- /**
341
- * Render a Remotion component that embeds a static image as background.
342
- * Copies image to public/, renders H264 MP4 directly — no transparency needed.
343
- */
344
- export async function renderWithEmbeddedImage(options: {
345
- componentCode: string;
346
- componentName: string;
347
- width: number;
348
- height: number;
349
- fps: number;
350
- durationInFrames: number;
351
- imagePath: string;
352
- imageFileName: string;
353
- outputPath: string;
354
- }): Promise<RenderResult> {
355
- const dir = await scaffoldRemotionProject(
356
- options.componentCode,
357
- options.componentName,
358
- {
359
- width: options.width,
360
- height: options.height,
361
- fps: options.fps,
362
- durationInFrames: options.durationInFrames,
363
- useMediaPackage: false,
364
- },
365
- );
366
-
367
- try {
368
- // Copy image to public/ so staticFile() can access it
369
- const publicDir = join(dir, "public");
370
- await mkdir(publicDir, { recursive: true });
371
- await copyFile(options.imagePath, join(publicDir, options.imageFileName));
372
-
373
- const entryPoint = join(dir, "Root.tsx");
374
- const mp4Out = options.outputPath.replace(/\.\w+$/, ".mp4");
375
-
376
- const { execFile: execFileImg } = await import("node:child_process");
377
- const { promisify: promisifyImg } = await import("node:util");
378
- const execFileAsyncImg = promisifyImg(execFileImg);
379
- await execFileAsyncImg("npx", [
380
- "remotion", "render", entryPoint, options.componentName, mp4Out,
381
- "--codec", "h264", "--crf", "18",
382
- ], { cwd: dir, timeout: 600_000, maxBuffer: 50 * 1024 * 1024 });
383
-
384
- return { success: true, outputPath: mp4Out };
385
- } catch (error) {
386
- const msg = error instanceof Error ? error.message : String(error);
387
- return { success: false, error: `Remotion image embed render failed: ${msg}` };
388
- } finally {
389
- await rm(dir, { recursive: true, force: true }).catch(() => {});
390
- }
391
- }
392
-
393
- // ── Native Video Embed ─────────────────────────────────────────────────────
394
-
395
- /**
396
- * Wrap an overlay component to embed a video as background.
397
- * Uses <Video> from @remotion/media (required per Remotion docs).
398
- * Video is muted — audio is muxed back via FFmpeg after rendering.
399
- *
400
- * Injects staticFile into the component's existing remotion import to avoid
401
- * duplicate identifier errors. Video is imported from @remotion/media
402
- * (different module — no conflict).
403
- */
404
- export function wrapComponentWithVideo(
405
- componentCode: string,
406
- componentName: string,
407
- videoFileName: string,
408
- ): { code: string; name: string } {
409
- const wrappedName = "VideoComposite";
410
-
411
- // Inject staticFile into the existing remotion import
412
- const modifiedCode = injectRemotionImports(componentCode, ["staticFile"]);
413
-
414
- // Prepend @remotion/media import (different module, no conflict)
415
- const code = `import { Video } from "@remotion/media";
416
- ${modifiedCode}
417
-
418
- export const ${wrappedName}: React.FC = () => {
419
- return (
420
- <AbsoluteFill>
421
- <Video
422
- src={staticFile("${videoFileName}")}
423
- style={{ width: "100%", height: "100%" }}
424
- muted
425
- />
426
- <AbsoluteFill>
427
- <${componentName} />
428
- </AbsoluteFill>
429
- </AbsoluteFill>
430
- );
431
- };
432
- `;
433
-
434
- return { code, name: wrappedName };
435
- }
436
-
437
- /**
438
- * Render a Remotion component that embeds the video directly.
439
- * Uses @remotion/media's <Video> component (official Remotion approach).
440
- * After rendering, muxes audio from the original video back into the output.
441
- */
442
- export async function renderWithEmbeddedVideo(options: {
443
- componentCode: string;
444
- componentName: string;
445
- width: number;
446
- height: number;
447
- fps: number;
448
- durationInFrames: number;
449
- videoPath: string;
450
- videoFileName: string;
451
- outputPath: string;
452
- }): Promise<RenderResult> {
453
- const dir = await scaffoldRemotionProject(
454
- options.componentCode,
455
- options.componentName,
456
- {
457
- width: options.width,
458
- height: options.height,
459
- fps: options.fps,
460
- durationInFrames: options.durationInFrames,
461
- useMediaPackage: true,
462
- },
463
- );
464
-
465
- try {
466
- // Copy video to public/ so staticFile() can access it
467
- const publicDir = join(dir, "public");
468
- await mkdir(publicDir, { recursive: true });
469
- await copyFile(options.videoPath, join(publicDir, options.videoFileName));
470
-
471
- const entryPoint = join(dir, "Root.tsx");
472
- const mp4VideoOnly = options.outputPath.replace(/\.\w+$/, "_video_only.mp4");
473
-
474
- // Render H264 (video-only, audio muted inside component)
475
- const { execFile: execFileVid } = await import("node:child_process");
476
- const { promisify: promisifyVid } = await import("node:util");
477
- const execFileAsyncVid = promisifyVid(execFileVid);
478
- await execFileAsyncVid("npx", [
479
- "remotion", "render", entryPoint, options.componentName, mp4VideoOnly,
480
- "--codec", "h264", "--crf", "18",
481
- ], { cwd: dir, timeout: 600_000, maxBuffer: 50 * 1024 * 1024 });
482
-
483
- // Mux: rendered video + original audio
484
- const mp4Out = options.outputPath.replace(/\.\w+$/, ".mp4");
485
- await execSafe("ffmpeg", [
486
- "-y", "-i", mp4VideoOnly, "-i", options.videoPath,
487
- "-map", "0:v:0", "-map", "1:a:0?", "-c:v", "copy", "-c:a", "copy", "-shortest", mp4Out,
488
- ], { timeout: 120_000 });
489
- await rm(mp4VideoOnly, { force: true }).catch(() => {});
490
-
491
- return { success: true, outputPath: mp4Out };
492
- } catch (error) {
493
- const msg = error instanceof Error ? error.message : String(error);
494
- return { success: false, error: `Remotion video embed render failed: ${msg}` };
495
- } finally {
496
- await rm(dir, { recursive: true, force: true }).catch(() => {});
497
- }
498
- }
499
-
500
- // ── Caption Component Generator ───────────────────────────────────────────
501
-
502
- export interface CaptionSegment {
503
- start: number;
504
- end: number;
505
- text: string;
506
- }
507
-
508
- export type CaptionStylePreset = "bold" | "minimal" | "outline" | "karaoke";
509
-
510
- export interface GenerateCaptionComponentOptions {
511
- segments: CaptionSegment[];
512
- style: CaptionStylePreset;
513
- fontSize: number;
514
- fontColor: string;
515
- position: "top" | "center" | "bottom";
516
- width: number;
517
- height: number;
518
- /** When set, embed the video inside the component (no transparency needed) */
519
- videoFileName?: string;
520
- }
521
-
522
- /**
523
- * Generate a Remotion TSX component that renders styled captions.
524
- * No LLM call — purely programmatic from SRT segments + style config.
525
- */
526
- export function generateCaptionComponent(options: GenerateCaptionComponentOptions): {
527
- code: string;
528
- name: string;
529
- } {
530
- const { segments, style, fontSize, fontColor, position, width, videoFileName } = options;
531
- const name = videoFileName ? "VideoCaptioned" : "CaptionOverlay";
532
-
533
- const segmentsJSON = JSON.stringify(
534
- segments.map((s) => ({ start: s.start, end: s.end, text: s.text })),
535
- );
536
-
537
- const styleMap: Record<CaptionStylePreset, string> = {
538
- bold: `
539
- fontWeight: "bold" as const,
540
- color: "${fontColor === "yellow" ? "#FFFF00" : "#FFFFFF"}",
541
- textShadow: "3px 3px 6px rgba(0,0,0,0.9), -1px -1px 3px rgba(0,0,0,0.7)",
542
- WebkitTextStroke: "1px rgba(0,0,0,0.5)",
543
- `,
544
- minimal: `
545
- fontWeight: "normal" as const,
546
- color: "#FFFFFF",
547
- textShadow: "1px 1px 3px rgba(0,0,0,0.5)",
548
- `,
549
- outline: `
550
- fontWeight: "bold" as const,
551
- color: "#FFFFFF",
552
- WebkitTextStroke: "2px #FF0000",
553
- textShadow: "none",
554
- `,
555
- karaoke: `
556
- fontWeight: "bold" as const,
557
- color: "#00FFFF",
558
- textShadow: "2px 2px 4px rgba(0,0,0,0.8), -1px -1px 2px rgba(0,0,0,0.6)",
559
- `,
560
- };
561
-
562
- const justifyContent =
563
- position === "top" ? "flex-start" : position === "center" ? "center" : "flex-end";
564
- const paddingDir = position === "top" ? "paddingTop" : position === "bottom" ? "paddingBottom" : "";
565
- const paddingVal = position === "center" ? "" : `${paddingDir}: 40,`;
566
-
567
- const videoImport = videoFileName ? `, staticFile` : "";
568
- const videoElement = videoFileName
569
- ? `<Video src={staticFile("${videoFileName}")} style={{ width: "100%", height: "100%" }} muted />`
570
- : "";
571
- const videoMediaImport = videoFileName
572
- ? `import { Video } from "@remotion/media";\n`
573
- : "";
574
-
575
- const code = `import { AbsoluteFill, useCurrentFrame, useVideoConfig${videoImport} } from "remotion";
576
- ${videoMediaImport}
577
- interface Segment {
578
- start: number;
579
- end: number;
580
- text: string;
581
- }
582
-
583
- const segments: Segment[] = ${segmentsJSON};
584
-
585
- export const ${name} = () => {
586
- const frame = useCurrentFrame();
587
- const { fps } = useVideoConfig();
588
- const currentTime = frame / fps;
589
-
590
- const activeSegment = segments.find(
591
- (s) => currentTime >= s.start && currentTime < s.end
592
- );
593
-
594
- return (
595
- <AbsoluteFill>
596
- ${videoElement}
597
- {activeSegment && (
598
- <AbsoluteFill
599
- style={{
600
- display: "flex",
601
- justifyContent: "${justifyContent}",
602
- alignItems: "center",
603
- ${paddingVal}
604
- }}
605
- >
606
- <div
607
- style={{
608
- fontSize: ${fontSize},
609
- fontFamily: "Arial, Helvetica, sans-serif",
610
- textAlign: "center" as const,
611
- maxWidth: "${Math.round(width * 0.9)}px",
612
- lineHeight: 1.3,
613
- padding: "8px 16px",
614
- ${styleMap[style]}
615
- }}
616
- >
617
- {activeSegment.text}
618
- </div>
619
- </AbsoluteFill>
620
- )}
621
- </AbsoluteFill>
622
- );
623
- };
624
- `;
625
-
626
- return { code, name };
627
- }
628
-
629
- // ── Animated Caption Component Generator ──────────────────────────────────
630
-
631
- export interface AnimatedCaptionWord {
632
- word: string;
633
- start: number;
634
- end: number;
635
- }
636
-
637
- export interface AnimatedCaptionGroup {
638
- words: AnimatedCaptionWord[];
639
- startTime: number;
640
- endTime: number;
641
- text: string;
642
- }
643
-
644
- export type AnimatedCaptionStylePreset = "highlight" | "bounce" | "pop-in" | "neon";
645
-
646
- export interface GenerateAnimatedCaptionComponentOptions {
647
- groups: AnimatedCaptionGroup[];
648
- style: AnimatedCaptionStylePreset;
649
- highlightColor: string;
650
- fontSize: number;
651
- position: "top" | "center" | "bottom";
652
- width: number;
653
- height: number;
654
- fps: number;
655
- videoFileName?: string;
656
- }
657
-
658
- /**
659
- * Generate a Remotion TSX component for word-level animated captions.
660
- * Each style creates different visual effects per word.
661
- */
662
- export function generateAnimatedCaptionComponent(options: GenerateAnimatedCaptionComponentOptions): {
663
- code: string;
664
- name: string;
665
- } {
666
- const { groups, style, highlightColor, fontSize, position, width, fps, videoFileName } = options;
667
- const name = videoFileName ? "VideoAnimatedCaption" : "AnimatedCaptionOverlay";
668
-
669
- const groupsJSON = JSON.stringify(
670
- groups.map((g) => ({
671
- words: g.words.map((w) => ({ word: w.word, start: w.start, end: w.end })),
672
- startTime: g.startTime,
673
- endTime: g.endTime,
674
- text: g.text,
675
- })),
676
- );
677
-
678
- const justifyContent =
679
- position === "top" ? "flex-start" : position === "center" ? "center" : "flex-end";
680
- const paddingDir = position === "top" ? "paddingTop" : position === "bottom" ? "paddingBottom" : "";
681
- const paddingVal = position === "center" ? "" : `${paddingDir}: 40,`;
682
-
683
- const videoImport = videoFileName ? `, staticFile` : "";
684
- const videoElement = videoFileName
685
- ? `<Video src={staticFile("${videoFileName}")} style={{ width: "100%", height: "100%" }} muted />`
686
- : "";
687
- const videoMediaImport = videoFileName
688
- ? `import { Video } from "@remotion/media";\n`
689
- : "";
690
-
691
- // Style-specific word rendering
692
- let wordRenderer: string;
693
-
694
- switch (style) {
695
- case "highlight":
696
- wordRenderer = `
697
- const isActive = currentTime >= w.start && currentTime < w.end;
698
- const bgOpacity = isActive ? 1 : 0;
699
- return (
700
- <span
701
- key={wi}
702
- style={{
703
- display: "inline-block",
704
- padding: "2px 6px",
705
- margin: "0 2px",
706
- borderRadius: 4,
707
- backgroundColor: isActive ? "${highlightColor}" : "transparent",
708
- color: isActive ? "#000000" : "#FFFFFF",
709
- transition: "background-color 0.1s",
710
- fontWeight: "bold",
711
- textShadow: isActive ? "none" : "2px 2px 4px rgba(0,0,0,0.8)",
712
- }}
713
- >
714
- {w.word}
715
- </span>
716
- );`;
717
- break;
718
-
719
- case "bounce":
720
- wordRenderer = `
721
- const isActive = currentTime >= w.start && currentTime < w.end;
722
- const entryFrame = w.start * ${fps};
723
- const progress = Math.min(1, Math.max(0, (frame - entryFrame) / 5));
724
- const springVal = isActive
725
- ? 1 + Math.sin(progress * Math.PI) * 0.15
726
- : 1;
727
- const translateY = isActive
728
- ? -Math.sin(progress * Math.PI) * 8
729
- : 0;
730
- return (
731
- <span
732
- key={wi}
733
- style={{
734
- display: "inline-block",
735
- margin: "0 3px",
736
- transform: \`scale(\${springVal}) translateY(\${translateY}px)\`,
737
- color: isActive ? "${highlightColor}" : "#FFFFFF",
738
- fontWeight: "bold",
739
- textShadow: "2px 2px 4px rgba(0,0,0,0.8)",
740
- }}
741
- >
742
- {w.word}
743
- </span>
744
- );`;
745
- break;
746
-
747
- case "pop-in":
748
- wordRenderer = `
749
- const entryFrame = w.start * ${fps};
750
- const scale = frame >= entryFrame
751
- ? Math.min(1, (frame - entryFrame) / 5)
752
- : 0;
753
- const isActive = currentTime >= w.start && currentTime < w.end;
754
- return (
755
- <span
756
- key={wi}
757
- style={{
758
- display: "inline-block",
759
- margin: "0 3px",
760
- transform: \`scale(\${scale})\`,
761
- opacity: scale,
762
- color: isActive ? "${highlightColor}" : "#FFFFFF",
763
- fontWeight: "bold",
764
- textShadow: "2px 2px 4px rgba(0,0,0,0.8)",
765
- }}
766
- >
767
- {w.word}
768
- </span>
769
- );`;
770
- break;
771
-
772
- case "neon":
773
- wordRenderer = `
774
- const isActive = currentTime >= w.start && currentTime < w.end;
775
- const pulse = isActive ? 0.8 + Math.sin(frame * 0.3) * 0.2 : 0.5;
776
- const glowSize = isActive ? 15 : 0;
777
- return (
778
- <span
779
- key={wi}
780
- style={{
781
- display: "inline-block",
782
- margin: "0 3px",
783
- color: isActive ? "${highlightColor}" : "#FFFFFF",
784
- fontWeight: "bold",
785
- opacity: isActive ? 1 : pulse,
786
- textShadow: isActive
787
- ? \`0 0 \${glowSize}px ${highlightColor}, 0 0 \${glowSize * 2}px ${highlightColor}, 0 0 \${glowSize * 3}px ${highlightColor}\`
788
- : "2px 2px 4px rgba(0,0,0,0.8)",
789
- }}
790
- >
791
- {w.word}
792
- </span>
793
- );`;
794
- break;
795
- }
796
-
797
- const code = `import { AbsoluteFill, useCurrentFrame, useVideoConfig${videoImport} } from "remotion";
798
- ${videoMediaImport}
799
- interface Word {
800
- word: string;
801
- start: number;
802
- end: number;
803
- }
804
-
805
- interface WordGroup {
806
- words: Word[];
807
- startTime: number;
808
- endTime: number;
809
- text: string;
810
- }
811
-
812
- const groups: WordGroup[] = ${groupsJSON};
813
-
814
- export const ${name} = () => {
815
- const frame = useCurrentFrame();
816
- const { fps } = useVideoConfig();
817
- const currentTime = frame / fps;
818
-
819
- const activeGroup = groups.find(
820
- (g) => currentTime >= g.startTime && currentTime < g.endTime
821
- );
822
-
823
- const renderWord = (w: Word, wi: number) => {
824
- ${wordRenderer}
825
- };
826
-
827
- return (
828
- <AbsoluteFill>
829
- ${videoElement}
830
- {activeGroup && (
831
- <AbsoluteFill
832
- style={{
833
- display: "flex",
834
- justifyContent: "${justifyContent}",
835
- alignItems: "center",
836
- ${paddingVal}
837
- }}
838
- >
839
- <div
840
- style={{
841
- fontSize: ${fontSize},
842
- fontFamily: "Arial, Helvetica, sans-serif",
843
- textAlign: "center" as const,
844
- maxWidth: "${Math.round(width * 0.9)}px",
845
- lineHeight: 1.5,
846
- padding: "8px 16px",
847
- display: "flex",
848
- flexWrap: "wrap" as const,
849
- justifyContent: "center",
850
- gap: "0px",
851
- }}
852
- >
853
- {activeGroup.words.map((w, wi) => renderWord(w, wi))}
854
- </div>
855
- </AbsoluteFill>
856
- )}
857
- </AbsoluteFill>
858
- );
859
- };
860
- `;
861
-
862
- return { code, name };
863
- }
864
-
865
- // ── Legacy composite helpers (kept for backward compat) ───────────────────
866
-
867
- /**
868
- * Composite a transparent overlay on top of a base video using FFmpeg.
869
- * @deprecated Use renderWithEmbeddedVideo() for new code.
870
- */
871
- export async function compositeOverlay(options: CompositeOptions): Promise<RenderResult> {
872
- try {
873
- await execSafe("ffmpeg", [
874
- "-y", "-i", options.baseVideo, "-i", options.overlayPath,
875
- "-filter_complex", "[0:v][1:v]overlay=0:0:shortest=1[out]",
876
- "-map", "[out]", "-map", "0:a?", "-c:a", "copy",
877
- "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
878
- options.outputPath,
879
- ], { timeout: 300_000 });
880
- return { success: true, outputPath: options.outputPath };
881
- } catch (error) {
882
- const msg = error instanceof Error ? error.message : String(error);
883
- return { success: false, error: `FFmpeg composite failed: ${msg}` };
884
- }
885
- }
886
-
887
- /**
888
- * Composite a transparent overlay on top of a static image using FFmpeg.
889
- * @deprecated Use renderWithEmbeddedImage() for new code.
890
- */
891
- export async function compositeWithImage(options: {
892
- baseImage: string;
893
- overlayPath: string;
894
- outputPath: string;
895
- durationSeconds: number;
896
- fps?: number;
897
- }): Promise<RenderResult> {
898
- try {
899
- const fps = options.fps || 30;
900
- await execSafe("ffmpeg", [
901
- "-y", "-loop", "1", "-framerate", String(fps), "-i", options.baseImage,
902
- "-i", options.overlayPath,
903
- "-filter_complex", "[0:v]scale=iw:ih[base];[base][1:v]overlay=0:0:shortest=1[out]",
904
- "-map", "[out]",
905
- "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
906
- "-t", String(options.durationSeconds),
907
- options.outputPath,
908
- ], { timeout: 300_000 });
909
- return { success: true, outputPath: options.outputPath };
910
- } catch (error) {
911
- const msg = error instanceof Error ? error.message : String(error);
912
- return { success: false, error: `FFmpeg image composite failed: ${msg}` };
913
- }
914
- }
915
-
916
- /**
917
- * Full pipeline: render motion graphic → composite onto base video.
918
- * @deprecated Use renderWithEmbeddedVideo() for new code.
919
- */
920
- export async function renderAndComposite(
921
- motionOpts: RenderMotionOptions,
922
- baseVideo?: string,
923
- finalOutput?: string,
924
- ): Promise<RenderResult> {
925
- const renderOpts = {
926
- ...motionOpts,
927
- transparent: !!baseVideo,
928
- outputPath: baseVideo
929
- ? motionOpts.outputPath.replace(/\.\w+$/, "_overlay.webm")
930
- : motionOpts.outputPath,
931
- };
932
-
933
- const renderResult = await renderMotion(renderOpts);
934
- if (!renderResult.success || !renderResult.outputPath) {
935
- return renderResult;
936
- }
937
-
938
- if (!baseVideo) {
939
- return renderResult;
940
- }
941
-
942
- const output = finalOutput || motionOpts.outputPath;
943
- const compositeResult = await compositeOverlay({
944
- baseVideo,
945
- overlayPath: renderResult.outputPath,
946
- outputPath: output,
947
- });
948
-
949
- await rm(renderResult.outputPath, { force: true }).catch(() => {});
950
- return compositeResult;
951
- }