@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
package/src/utils/remotion.ts
DELETED
|
@@ -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
|
-
}
|