simple-ffmpegjs 0.3.1 → 0.3.3
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/README.md +530 -442
- package/package.json +2 -2
- package/src/core/media_info.js +127 -90
- package/src/core/resolve.js +96 -0
- package/src/core/validation.js +43 -0
- package/src/lib/utils.js +1 -0
- package/src/loaders.js +13 -11
- package/src/schema/formatter.js +179 -0
- package/src/schema/index.js +118 -0
- package/src/schema/modules/audio.js +31 -0
- package/src/schema/modules/image.js +44 -0
- package/src/schema/modules/music.js +31 -0
- package/src/schema/modules/subtitle.js +37 -0
- package/src/schema/modules/text.js +122 -0
- package/src/schema/modules/video.js +93 -0
- package/src/simpleffmpeg.js +155 -3
- package/types/index.d.mts +144 -2
- package/types/index.d.ts +144 -2
package/README.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/simple-ffmpeg/zENiV5XBIET_cu11ZpOdE.png" alt="simple-ffmpeg" width="100%">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/simple-ffmpegjs"><img src="https://img.shields.io/npm/v/simple-ffmpegjs.svg" alt="npm version"></a>
|
|
7
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
8
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg" alt="Node.js"></a>
|
|
9
|
+
</p>
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
<p align="center">
|
|
12
|
+
A lightweight Node.js library for programmatic video composition using FFmpeg.<br>
|
|
13
|
+
Define your timeline as a simple array of clips, and the library handles the rest.
|
|
14
|
+
</p>
|
|
8
15
|
|
|
9
16
|
## Table of Contents
|
|
10
17
|
|
|
@@ -12,7 +19,8 @@ A lightweight Node.js library for programmatic video composition using FFmpeg. D
|
|
|
12
19
|
- [Features](#features)
|
|
13
20
|
- [Installation](#installation)
|
|
14
21
|
- [Quick Start](#quick-start)
|
|
15
|
-
- [Pre-Validation
|
|
22
|
+
- [Pre-Validation](#pre-validation)
|
|
23
|
+
- [Schema Export](#schema-export)
|
|
16
24
|
- [API Reference](#api-reference)
|
|
17
25
|
- [Constructor](#constructor)
|
|
18
26
|
- [Methods](#methods)
|
|
@@ -24,26 +32,26 @@ A lightweight Node.js library for programmatic video composition using FFmpeg. D
|
|
|
24
32
|
- [Cancellation](#cancellation)
|
|
25
33
|
- [Gap Handling](#gap-handling)
|
|
26
34
|
- [Examples](#examples)
|
|
27
|
-
- [Transitions](#
|
|
28
|
-
- [Text
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [Export
|
|
32
|
-
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
- [
|
|
36
|
-
- [
|
|
37
|
-
- [Auto-Batching](#auto-batching
|
|
35
|
+
- [Clips & Transitions](#clips--transitions)
|
|
36
|
+
- [Text & Animations](#text--animations)
|
|
37
|
+
- [Karaoke](#karaoke)
|
|
38
|
+
- [Subtitles](#subtitles)
|
|
39
|
+
- [Export Settings](#export-settings)
|
|
40
|
+
- [Real-World Usage Patterns](#real-world-usage-patterns)
|
|
41
|
+
- [Data Pipeline](#data-pipeline-example)
|
|
42
|
+
- [AI Video Pipeline](#ai-video-generation-pipeline-example)
|
|
43
|
+
- [Advanced](#advanced)
|
|
44
|
+
- [Timeline Behavior](#timeline-behavior)
|
|
45
|
+
- [Auto-Batching](#auto-batching)
|
|
38
46
|
- [Testing](#testing)
|
|
39
47
|
- [Contributing](#contributing)
|
|
40
48
|
- [License](#license)
|
|
41
49
|
|
|
42
50
|
## Why simple-ffmpeg?
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
FFmpeg is incredibly powerful, but its command-line interface is notoriously difficult to work with programmatically. Composing even a simple two-clip video with a crossfade requires navigating complex filter graphs, input mapping, and stream labeling. simple-ffmpeg abstracts all of that behind a declarative, config-driven API. You describe _what_ your video should look like, and the library figures out _how_ to build the FFmpeg command.
|
|
45
53
|
|
|
46
|
-
The
|
|
54
|
+
The entire timeline is expressed as a plain array of clip objects, making it straightforward to generate configurations from any data source: databases, APIs, templates, or AI models. Structured validation with machine-readable error codes means you can catch problems early and handle them programmatically, whether that's logging a warning, retrying with corrected input, or surfacing feedback to an end user.
|
|
47
55
|
|
|
48
56
|
## Example Output
|
|
49
57
|
|
|
@@ -70,6 +78,8 @@ _Click to watch a "Wonders of the World" video created with simple-ffmpeg — co
|
|
|
70
78
|
- **Cancellation** — AbortController support for stopping exports
|
|
71
79
|
- **Gap Handling** — Optional black frame fill for timeline gaps
|
|
72
80
|
- **Auto-Batching** — Automatically splits complex filter graphs to avoid OS command limits
|
|
81
|
+
- **Schema Export** — Generate a structured description of the clip format for documentation, code generation, or AI context
|
|
82
|
+
- **Pre-Validation** — Validate clip configurations before processing with structured, machine-readable error codes
|
|
73
83
|
- **TypeScript Ready** — Full type definitions included
|
|
74
84
|
- **Zero Dependencies** — Only requires FFmpeg on your system
|
|
75
85
|
|
|
@@ -109,41 +119,50 @@ apt-get install -y ffmpeg fontconfig fonts-dejavu-core
|
|
|
109
119
|
```js
|
|
110
120
|
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
111
121
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
height: 1080,
|
|
115
|
-
fps: 30,
|
|
116
|
-
});
|
|
122
|
+
// Use a platform preset — or set width/height/fps manually
|
|
123
|
+
const project = new SIMPLEFFMPEG({ preset: "youtube" });
|
|
117
124
|
|
|
118
125
|
await project.load([
|
|
119
|
-
|
|
126
|
+
// Two video clips with a crossfade transition between them
|
|
127
|
+
{ type: "video", url: "./opening-shot.mp4", position: 0, end: 6 },
|
|
120
128
|
{
|
|
121
129
|
type: "video",
|
|
122
|
-
url: "./
|
|
123
|
-
position: 5,
|
|
124
|
-
end:
|
|
130
|
+
url: "./highlights.mp4",
|
|
131
|
+
position: 5.5,
|
|
132
|
+
end: 18,
|
|
133
|
+
cutFrom: 3, // start 3s into the source file
|
|
125
134
|
transition: { type: "fade", duration: 0.5 },
|
|
126
135
|
},
|
|
127
|
-
|
|
136
|
+
|
|
137
|
+
// Title card with a pop animation
|
|
128
138
|
{
|
|
129
139
|
type: "text",
|
|
130
|
-
text: "
|
|
131
|
-
position:
|
|
140
|
+
text: "Summer Highlights 2025",
|
|
141
|
+
position: 0.5,
|
|
132
142
|
end: 4,
|
|
133
|
-
|
|
134
|
-
fontSize:
|
|
143
|
+
fontFile: "./fonts/Montserrat-Bold.ttf",
|
|
144
|
+
fontSize: 72,
|
|
145
|
+
fontColor: "#FFFFFF",
|
|
146
|
+
borderColor: "#000000",
|
|
147
|
+
borderWidth: 2,
|
|
148
|
+
xPercent: 0.5,
|
|
149
|
+
yPercent: 0.4,
|
|
150
|
+
animation: { type: "pop", in: 0.3 },
|
|
135
151
|
},
|
|
152
|
+
|
|
153
|
+
// Background music — loops to fill the whole video
|
|
154
|
+
{ type: "music", url: "./chill-beat.mp3", volume: 0.2, loop: true },
|
|
136
155
|
]);
|
|
137
156
|
|
|
138
157
|
await project.export({
|
|
139
|
-
outputPath: "./
|
|
158
|
+
outputPath: "./summer-highlights.mp4",
|
|
140
159
|
onProgress: ({ percent }) => console.log(`${percent}% complete`),
|
|
141
160
|
});
|
|
142
161
|
```
|
|
143
162
|
|
|
144
|
-
## Pre-Validation
|
|
163
|
+
## Pre-Validation
|
|
145
164
|
|
|
146
|
-
Validate configurations before creating a project
|
|
165
|
+
Validate clip configurations before creating a project. Useful for catching errors early in data pipelines, form-based editors, or any workflow where configurations are generated dynamically:
|
|
147
166
|
|
|
148
167
|
```js
|
|
149
168
|
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
@@ -155,7 +174,7 @@ const clips = [
|
|
|
155
174
|
|
|
156
175
|
// Validate without creating a project
|
|
157
176
|
const result = SIMPLEFFMPEG.validate(clips, {
|
|
158
|
-
skipFileChecks: true, // Skip file existence checks (useful when files
|
|
177
|
+
skipFileChecks: true, // Skip file existence checks (useful when files aren't on disk yet)
|
|
159
178
|
width: 1920, // Project dimensions (for Ken Burns size validation)
|
|
160
179
|
height: 1080,
|
|
161
180
|
strictKenBurns: false, // If true, undersized Ken Burns images error instead of warn (default: false)
|
|
@@ -190,6 +209,70 @@ if (result.errors.some((e) => e.code === ValidationCodes.TIMELINE_GAP)) {
|
|
|
190
209
|
}
|
|
191
210
|
```
|
|
192
211
|
|
|
212
|
+
## Schema Export
|
|
213
|
+
|
|
214
|
+
Export a structured, human-readable description of all clip types accepted by `load()`. The output is designed to serve as context for LLMs, documentation generators, code generation tools, or anything that needs to understand the library's clip format.
|
|
215
|
+
|
|
216
|
+
### Basic Usage
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
// Get the full schema (all clip types)
|
|
220
|
+
const schema = SIMPLEFFMPEG.getSchema();
|
|
221
|
+
console.log(schema);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The output is a formatted text document with type definitions, allowed values, usage notes, and examples for each clip type.
|
|
225
|
+
|
|
226
|
+
### Filtering Modules
|
|
227
|
+
|
|
228
|
+
The schema is broken into modules — one per clip type. You can include or exclude modules to control exactly what appears in the output:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
// Only include video and image clip types
|
|
232
|
+
const schema = SIMPLEFFMPEG.getSchema({ include: ["video", "image"] });
|
|
233
|
+
|
|
234
|
+
// Include everything except text and subtitle
|
|
235
|
+
const schema = SIMPLEFFMPEG.getSchema({ exclude: ["text", "subtitle"] });
|
|
236
|
+
|
|
237
|
+
// See all available module IDs
|
|
238
|
+
SIMPLEFFMPEG.getSchemaModules();
|
|
239
|
+
// ['video', 'audio', 'image', 'text', 'subtitle', 'music']
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Available modules:
|
|
243
|
+
|
|
244
|
+
| Module | Covers |
|
|
245
|
+
| ---------- | ----------------------------------------------------------- |
|
|
246
|
+
| `video` | Video clips, transitions, volume, trimming |
|
|
247
|
+
| `audio` | Standalone audio clips |
|
|
248
|
+
| `image` | Image clips, Ken Burns effects |
|
|
249
|
+
| `text` | Text overlays — all modes, animations, positioning, styling |
|
|
250
|
+
| `subtitle` | Subtitle file import (SRT, VTT, ASS, SSA) |
|
|
251
|
+
| `music` | Background music / background audio, looping |
|
|
252
|
+
|
|
253
|
+
### Custom Instructions
|
|
254
|
+
|
|
255
|
+
Embed your own instructions directly into the schema output. Top-level instructions appear at the beginning, and per-module instructions are placed inside the relevant section — formatted identically to the built-in notes:
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
const schema = SIMPLEFFMPEG.getSchema({
|
|
259
|
+
include: ["video", "image", "music"],
|
|
260
|
+
instructions: [
|
|
261
|
+
"You are creating short cooking tutorials for TikTok.",
|
|
262
|
+
"Keep all videos under 30 seconds.",
|
|
263
|
+
],
|
|
264
|
+
moduleInstructions: {
|
|
265
|
+
video: [
|
|
266
|
+
"Always use fade transitions at 0.5s.",
|
|
267
|
+
"Limit to 5 clips maximum.",
|
|
268
|
+
],
|
|
269
|
+
music: "Always include background music at volume 0.15.",
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Both `instructions` and `moduleInstructions` values accept a `string` or `string[]`. Per-module instructions for excluded modules are silently ignored.
|
|
275
|
+
|
|
193
276
|
## API Reference
|
|
194
277
|
|
|
195
278
|
### Constructor
|
|
@@ -215,6 +298,98 @@ Load clip descriptors into the project. Validates the timeline and reads media m
|
|
|
215
298
|
await project.load(clips: Clip[]): Promise<void[]>
|
|
216
299
|
```
|
|
217
300
|
|
|
301
|
+
#### `SIMPLEFFMPEG.getDuration(clips)`
|
|
302
|
+
|
|
303
|
+
Calculate the total visual timeline duration from a clips array. Handles `duration` and auto-sequencing shorthand, and subtracts transition overlaps. Pure function — no file I/O.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const clips = [
|
|
307
|
+
{ type: "video", url: "./a.mp4", duration: 5 },
|
|
308
|
+
{
|
|
309
|
+
type: "video",
|
|
310
|
+
url: "./b.mp4",
|
|
311
|
+
duration: 10,
|
|
312
|
+
transition: { type: "fade", duration: 0.5 },
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
SIMPLEFFMPEG.getDuration(clips); // 14.5
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Useful for computing text overlay timings or background music end times before calling `load()`.
|
|
319
|
+
|
|
320
|
+
**Duration and Auto-Sequencing:**
|
|
321
|
+
|
|
322
|
+
For video, image, and audio clips, you can use shorthand to avoid specifying explicit `position` and `end` values:
|
|
323
|
+
|
|
324
|
+
- **`duration`** — Use instead of `end`. The library computes `end = position + duration`. You cannot specify both `duration` and `end` on the same clip.
|
|
325
|
+
- **Omit `position`** — The clip is placed immediately after the previous clip on its track. Video and image clips share the visual track; audio clips have their own track. The first clip defaults to `position: 0`.
|
|
326
|
+
|
|
327
|
+
These can be combined:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
// Before: manual position/end for every clip
|
|
331
|
+
await project.load([
|
|
332
|
+
{ type: "video", url: "./a.mp4", position: 0, end: 5 },
|
|
333
|
+
{ type: "video", url: "./b.mp4", position: 5, end: 10 },
|
|
334
|
+
{ type: "video", url: "./c.mp4", position: 10, end: 18, cutFrom: 3 },
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
// After: auto-sequencing + duration
|
|
338
|
+
await project.load([
|
|
339
|
+
{ type: "video", url: "./a.mp4", duration: 5 },
|
|
340
|
+
{ type: "video", url: "./b.mp4", duration: 5 },
|
|
341
|
+
{ type: "video", url: "./c.mp4", duration: 8, cutFrom: 3 },
|
|
342
|
+
]);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
You can mix explicit and implicit positioning freely. Clips with explicit `position` are placed there; subsequent auto-sequenced clips follow from the last clip's end:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
await project.load([
|
|
349
|
+
{ type: "video", url: "./a.mp4", duration: 5 }, // position: 0, end: 5
|
|
350
|
+
{ type: "video", url: "./b.mp4", position: 10, end: 15 }, // explicit gap
|
|
351
|
+
{ type: "video", url: "./c.mp4", duration: 5 }, // position: 15, end: 20
|
|
352
|
+
]);
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Text clips always require an explicit `position` (they're overlays on specific moments). Background music and subtitle clips already have optional `position`/`end` with their own defaults.
|
|
356
|
+
|
|
357
|
+
#### `SIMPLEFFMPEG.probe(filePath)`
|
|
358
|
+
|
|
359
|
+
Probe a media file and return comprehensive metadata using ffprobe. Works with video, audio, and image files.
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
const info = await SIMPLEFFMPEG.probe("./video.mp4");
|
|
363
|
+
// {
|
|
364
|
+
// duration: 30.5, // seconds
|
|
365
|
+
// width: 1920, // pixels
|
|
366
|
+
// height: 1080, // pixels
|
|
367
|
+
// hasVideo: true,
|
|
368
|
+
// hasAudio: true,
|
|
369
|
+
// rotation: 0, // iPhone/mobile rotation
|
|
370
|
+
// videoCodec: "h264",
|
|
371
|
+
// audioCodec: "aac",
|
|
372
|
+
// format: "mov,mp4,m4a,3gp,3g2,mj2",
|
|
373
|
+
// fps: 30,
|
|
374
|
+
// size: 15728640, // bytes
|
|
375
|
+
// bitrate: 4125000, // bits/sec
|
|
376
|
+
// sampleRate: 48000, // Hz
|
|
377
|
+
// channels: 2 // stereo
|
|
378
|
+
// }
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Fields that don't apply to the file type are `null` (e.g. `width`/`height`/`videoCodec`/`fps` for audio-only files, `audioCodec`/`sampleRate`/`channels` for video-only files).
|
|
382
|
+
|
|
383
|
+
Throws `MediaNotFoundError` if the file cannot be found or probed.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
// Audio file
|
|
387
|
+
const audio = await SIMPLEFFMPEG.probe("./music.wav");
|
|
388
|
+
console.log(audio.hasVideo); // false
|
|
389
|
+
console.log(audio.duration); // 180.5
|
|
390
|
+
console.log(audio.sampleRate); // 44100
|
|
391
|
+
```
|
|
392
|
+
|
|
218
393
|
#### `project.export(options)`
|
|
219
394
|
|
|
220
395
|
Build and execute the FFmpeg command to render the final video.
|
|
@@ -270,8 +445,9 @@ await project.preview(options?: ExportOptions): Promise<{
|
|
|
270
445
|
{
|
|
271
446
|
type: "video";
|
|
272
447
|
url: string; // File path
|
|
273
|
-
position
|
|
274
|
-
end
|
|
448
|
+
position?: number; // Timeline start (seconds). Omit to auto-sequence after previous clip.
|
|
449
|
+
end?: number; // Timeline end (seconds). Use end OR duration, not both.
|
|
450
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
275
451
|
cutFrom?: number; // Source offset (default: 0)
|
|
276
452
|
volume?: number; // Audio volume (default: 1)
|
|
277
453
|
transition?: {
|
|
@@ -289,8 +465,9 @@ All [xfade transitions](https://trac.ffmpeg.org/wiki/Xfade) are supported.
|
|
|
289
465
|
{
|
|
290
466
|
type: "audio";
|
|
291
467
|
url: string;
|
|
292
|
-
position
|
|
293
|
-
end
|
|
468
|
+
position?: number; // Omit to auto-sequence after previous audio clip
|
|
469
|
+
end?: number; // Use end OR duration, not both
|
|
470
|
+
duration?: number; // Duration in seconds (alternative to end)
|
|
294
471
|
cutFrom?: number;
|
|
295
472
|
volume?: number;
|
|
296
473
|
}
|
|
@@ -329,8 +506,9 @@ await project.load([
|
|
|
329
506
|
{
|
|
330
507
|
type: "image";
|
|
331
508
|
url: string;
|
|
332
|
-
position
|
|
333
|
-
end
|
|
509
|
+
position?: number; // Omit to auto-sequence after previous video/image clip
|
|
510
|
+
end?: number; // Use end OR duration, not both
|
|
511
|
+
duration?: number; // Duration in seconds (alternative to end)
|
|
334
512
|
kenBurns?: "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "pan-up" | "pan-down";
|
|
335
513
|
}
|
|
336
514
|
```
|
|
@@ -341,7 +519,8 @@ await project.load([
|
|
|
341
519
|
{
|
|
342
520
|
type: "text";
|
|
343
521
|
position: number;
|
|
344
|
-
end
|
|
522
|
+
end?: number; // Use end OR duration, not both
|
|
523
|
+
duration?: number; // Duration in seconds (alternative to end)
|
|
345
524
|
|
|
346
525
|
// Content
|
|
347
526
|
text?: string;
|
|
@@ -518,6 +697,7 @@ The `onProgress` callback receives:
|
|
|
518
697
|
```ts
|
|
519
698
|
{
|
|
520
699
|
percent?: number; // 0-100
|
|
700
|
+
phase?: string; // "rendering" or "batching"
|
|
521
701
|
timeProcessed?: number; // Seconds processed
|
|
522
702
|
frame?: number; // Current frame
|
|
523
703
|
fps?: number; // Processing speed
|
|
@@ -525,6 +705,23 @@ The `onProgress` callback receives:
|
|
|
525
705
|
}
|
|
526
706
|
```
|
|
527
707
|
|
|
708
|
+
The `phase` field indicates what the export is doing:
|
|
709
|
+
|
|
710
|
+
- `"rendering"` — main video export (includes `percent`, `frame`, etc.)
|
|
711
|
+
- `"batching"` — text overlay passes are running (fired once when batching starts)
|
|
712
|
+
|
|
713
|
+
Use `phase` to update your UI when the export hits 100% but still has work to do:
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
onProgress: ({ percent, phase }) => {
|
|
717
|
+
if (phase === "batching") {
|
|
718
|
+
console.log("Applying text overlays...");
|
|
719
|
+
} else {
|
|
720
|
+
console.log(`${percent}%`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
```
|
|
724
|
+
|
|
528
725
|
### Error Handling
|
|
529
726
|
|
|
530
727
|
The library provides custom error classes for structured error handling:
|
|
@@ -597,9 +794,10 @@ await project.load([
|
|
|
597
794
|
|
|
598
795
|
## Examples
|
|
599
796
|
|
|
600
|
-
###
|
|
797
|
+
### Clips & Transitions
|
|
601
798
|
|
|
602
799
|
```ts
|
|
800
|
+
// Two clips with a crossfade
|
|
603
801
|
await project.load([
|
|
604
802
|
{ type: "video", url: "./a.mp4", position: 0, end: 5 },
|
|
605
803
|
{
|
|
@@ -612,9 +810,24 @@ await project.load([
|
|
|
612
810
|
]);
|
|
613
811
|
```
|
|
614
812
|
|
|
615
|
-
|
|
813
|
+
**Image slideshow with Ken Burns effects:**
|
|
616
814
|
|
|
617
|
-
|
|
815
|
+
```ts
|
|
816
|
+
await project.load([
|
|
817
|
+
{ type: "image", url: "./photo1.jpg", duration: 3, kenBurns: "zoom-in" },
|
|
818
|
+
{ type: "image", url: "./photo2.jpg", duration: 3, kenBurns: "pan-right" },
|
|
819
|
+
{ type: "image", url: "./photo3.jpg", duration: 3, kenBurns: "zoom-out" },
|
|
820
|
+
{ type: "music", url: "./music.mp3", volume: 0.3 },
|
|
821
|
+
]);
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
When `position` is omitted, clips are placed sequentially — each one starts where the previous one ended. `duration` is an alternative to `end`: the library computes `end = position + duration`. The explicit form (`position: 0, end: 3`) still works identically.
|
|
825
|
+
|
|
826
|
+
> **Note:** Ken Burns effects work best with images at least as large as your output resolution. Smaller images are automatically upscaled (with a validation warning). Use `strictKenBurns: true` in validation options to enforce size requirements instead.
|
|
827
|
+
|
|
828
|
+
### Text & Animations
|
|
829
|
+
|
|
830
|
+
Text is centered by default. Use `xPercent`/`yPercent` for percentage positioning, `x`/`y` for pixels, or `xOffset`/`yOffset` to nudge from any base:
|
|
618
831
|
|
|
619
832
|
```ts
|
|
620
833
|
await project.load([
|
|
@@ -640,223 +853,39 @@ await project.load([
|
|
|
640
853
|
]);
|
|
641
854
|
```
|
|
642
855
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
### Word-by-Word Text Animation
|
|
856
|
+
**Word-by-word replacement:**
|
|
646
857
|
|
|
647
858
|
```ts
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
fontColor: "white",
|
|
660
|
-
},
|
|
661
|
-
]);
|
|
662
|
-
```
|
|
663
|
-
|
|
664
|
-
### Image Slideshow with Ken Burns
|
|
665
|
-
|
|
666
|
-
```ts
|
|
667
|
-
await project.load([
|
|
668
|
-
{
|
|
669
|
-
type: "image",
|
|
670
|
-
url: "./photo1.jpg",
|
|
671
|
-
position: 0,
|
|
672
|
-
end: 3,
|
|
673
|
-
kenBurns: "zoom-in",
|
|
674
|
-
},
|
|
675
|
-
{
|
|
676
|
-
type: "image",
|
|
677
|
-
url: "./photo2.jpg",
|
|
678
|
-
position: 3,
|
|
679
|
-
end: 6,
|
|
680
|
-
kenBurns: "pan-right",
|
|
681
|
-
},
|
|
682
|
-
{
|
|
683
|
-
type: "image",
|
|
684
|
-
url: "./photo3.jpg",
|
|
685
|
-
position: 6,
|
|
686
|
-
end: 9,
|
|
687
|
-
kenBurns: "zoom-out",
|
|
688
|
-
},
|
|
689
|
-
{ type: "music", url: "./music.mp3", volume: 0.3 },
|
|
690
|
-
]);
|
|
691
|
-
```
|
|
692
|
-
|
|
693
|
-
> **Note:** Ken Burns effects work best with images at least as large as your output resolution. Smaller images are automatically upscaled (with a validation warning about potential quality loss). Use `strictKenBurns: true` in validation options to enforce size requirements instead.
|
|
694
|
-
|
|
695
|
-
### Export with Progress Tracking
|
|
696
|
-
|
|
697
|
-
```ts
|
|
698
|
-
await project.export({
|
|
699
|
-
outputPath: "./output.mp4",
|
|
700
|
-
onProgress: ({ percent, fps, speed }) => {
|
|
701
|
-
process.stdout.write(`\rRendering: ${percent}% (${fps} fps, ${speed}x)`);
|
|
702
|
-
},
|
|
703
|
-
});
|
|
704
|
-
```
|
|
705
|
-
|
|
706
|
-
### High-Quality Export with Custom Settings
|
|
707
|
-
|
|
708
|
-
```ts
|
|
709
|
-
await project.export({
|
|
710
|
-
outputPath: "./output.mp4",
|
|
711
|
-
videoCodec: "libx265",
|
|
712
|
-
crf: 18, // Higher quality
|
|
713
|
-
preset: "slow", // Better compression
|
|
714
|
-
audioCodec: "libopus",
|
|
715
|
-
audioBitrate: "256k",
|
|
716
|
-
metadata: {
|
|
717
|
-
title: "My Video",
|
|
718
|
-
artist: "My Name",
|
|
719
|
-
date: "2024",
|
|
720
|
-
},
|
|
721
|
-
});
|
|
722
|
-
```
|
|
723
|
-
|
|
724
|
-
### Hardware-Accelerated Export (macOS)
|
|
725
|
-
|
|
726
|
-
```ts
|
|
727
|
-
await project.export({
|
|
728
|
-
outputPath: "./output.mp4",
|
|
729
|
-
hwaccel: "videotoolbox",
|
|
730
|
-
videoCodec: "h264_videotoolbox",
|
|
731
|
-
crf: 23,
|
|
732
|
-
});
|
|
733
|
-
```
|
|
734
|
-
|
|
735
|
-
### Two-Pass Encoding for Target File Size
|
|
736
|
-
|
|
737
|
-
```ts
|
|
738
|
-
await project.export({
|
|
739
|
-
outputPath: "./output.mp4",
|
|
740
|
-
twoPass: true,
|
|
741
|
-
videoBitrate: "5M", // Target bitrate
|
|
742
|
-
preset: "slow",
|
|
743
|
-
});
|
|
744
|
-
```
|
|
745
|
-
|
|
746
|
-
### Scale Output Resolution
|
|
747
|
-
|
|
748
|
-
```ts
|
|
749
|
-
// Use resolution preset
|
|
750
|
-
await project.export({
|
|
751
|
-
outputPath: "./output-720p.mp4",
|
|
752
|
-
outputResolution: "720p",
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
// Or specify exact dimensions
|
|
756
|
-
await project.export({
|
|
757
|
-
outputPath: "./output-custom.mp4",
|
|
758
|
-
outputWidth: 1280,
|
|
759
|
-
outputHeight: 720,
|
|
760
|
-
});
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
### Audio-Only Export
|
|
764
|
-
|
|
765
|
-
```ts
|
|
766
|
-
await project.export({
|
|
767
|
-
outputPath: "./audio.mp3",
|
|
768
|
-
audioOnly: true,
|
|
769
|
-
audioCodec: "libmp3lame",
|
|
770
|
-
audioBitrate: "320k",
|
|
771
|
-
});
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
### Generate Thumbnail
|
|
775
|
-
|
|
776
|
-
```ts
|
|
777
|
-
await project.export({
|
|
778
|
-
outputPath: "./output.mp4",
|
|
779
|
-
thumbnail: {
|
|
780
|
-
outputPath: "./thumbnail.jpg",
|
|
781
|
-
time: 5, // Capture at 5 seconds
|
|
782
|
-
width: 640,
|
|
783
|
-
},
|
|
784
|
-
});
|
|
859
|
+
{
|
|
860
|
+
type: "text",
|
|
861
|
+
mode: "word-replace",
|
|
862
|
+
text: "One Two Three Four",
|
|
863
|
+
position: 2,
|
|
864
|
+
end: 6,
|
|
865
|
+
wordTimestamps: [2, 3, 4, 5, 6],
|
|
866
|
+
animation: { type: "fade-in", in: 0.2 },
|
|
867
|
+
fontSize: 72,
|
|
868
|
+
fontColor: "white",
|
|
869
|
+
}
|
|
785
870
|
```
|
|
786
871
|
|
|
787
|
-
|
|
872
|
+
**Typewriter, pulse, and other animations:**
|
|
788
873
|
|
|
789
874
|
```ts
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
saveCommand: "./ffmpeg-command.txt", // Save command to file
|
|
794
|
-
});
|
|
795
|
-
```
|
|
875
|
+
// Typewriter — letters appear one at a time
|
|
876
|
+
{ type: "text", text: "Appearing letter by letter...", position: 1, end: 4,
|
|
877
|
+
animation: { type: "typewriter", speed: 15 } }
|
|
796
878
|
|
|
797
|
-
|
|
879
|
+
// Pulse — rhythmic scaling
|
|
880
|
+
{ type: "text", text: "Pulsing...", position: 0.5, end: 4.5,
|
|
881
|
+
animation: { type: "pulse", speed: 2, intensity: 0.2 } }
|
|
798
882
|
|
|
799
|
-
|
|
800
|
-
await project.load([
|
|
801
|
-
{ type: "video", url: "./bg.mp4", position: 0, end: 5 },
|
|
802
|
-
{
|
|
803
|
-
type: "text",
|
|
804
|
-
text: "Appearing letter by letter...",
|
|
805
|
-
position: 1,
|
|
806
|
-
end: 4,
|
|
807
|
-
fontSize: 48,
|
|
808
|
-
fontColor: "white",
|
|
809
|
-
animation: {
|
|
810
|
-
type: "typewriter",
|
|
811
|
-
speed: 15, // 15 characters per second
|
|
812
|
-
},
|
|
813
|
-
},
|
|
814
|
-
]);
|
|
883
|
+
// Also available: fade-in, fade-out, fade-in-out, pop, pop-bounce, scale-in
|
|
815
884
|
```
|
|
816
885
|
|
|
817
|
-
###
|
|
818
|
-
|
|
819
|
-
```ts
|
|
820
|
-
await project.load([
|
|
821
|
-
{ type: "video", url: "./bg.mp4", position: 0, end: 5 },
|
|
822
|
-
{
|
|
823
|
-
type: "text",
|
|
824
|
-
text: "Pulsing...",
|
|
825
|
-
position: 0.5,
|
|
826
|
-
end: 4.5,
|
|
827
|
-
fontSize: 52,
|
|
828
|
-
fontColor: "cyan",
|
|
829
|
-
animation: {
|
|
830
|
-
type: "pulse",
|
|
831
|
-
speed: 2, // 2 pulses per second
|
|
832
|
-
intensity: 0.2, // 20% size variation
|
|
833
|
-
},
|
|
834
|
-
},
|
|
835
|
-
]);
|
|
836
|
-
```
|
|
886
|
+
### Karaoke
|
|
837
887
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
Create word-by-word highlighting like karaoke subtitles:
|
|
841
|
-
|
|
842
|
-
```ts
|
|
843
|
-
await project.load([
|
|
844
|
-
{ type: "video", url: "./music-video.mp4", position: 0, end: 10 },
|
|
845
|
-
{
|
|
846
|
-
type: "text",
|
|
847
|
-
mode: "karaoke",
|
|
848
|
-
text: "Never gonna give you up",
|
|
849
|
-
position: 2,
|
|
850
|
-
end: 6,
|
|
851
|
-
fontColor: "#FFFFFF",
|
|
852
|
-
highlightColor: "#FFFF00", // Words highlight to yellow
|
|
853
|
-
fontSize: 48,
|
|
854
|
-
yPercent: 0.85, // Position near bottom
|
|
855
|
-
},
|
|
856
|
-
]);
|
|
857
|
-
```
|
|
858
|
-
|
|
859
|
-
With precise word timings:
|
|
888
|
+
Word-by-word highlighting with customizable colors. Use `highlightStyle: "instant"` for immediate color changes instead of the default smooth fill:
|
|
860
889
|
|
|
861
890
|
```ts
|
|
862
891
|
await project.load([
|
|
@@ -877,74 +906,16 @@ await project.load([
|
|
|
877
906
|
fontColor: "#FFFFFF",
|
|
878
907
|
highlightColor: "#00FF00",
|
|
879
908
|
fontSize: 52,
|
|
909
|
+
yPercent: 0.85,
|
|
880
910
|
},
|
|
881
911
|
]);
|
|
882
912
|
```
|
|
883
913
|
|
|
884
|
-
|
|
914
|
+
For simple usage without explicit word timings, just provide `text` and `wordTimestamps` — the library will split on spaces. Multi-line karaoke is supported with `\n` in the text string or `lineBreak: true` in the words array.
|
|
885
915
|
|
|
886
|
-
|
|
887
|
-
await project.load([
|
|
888
|
-
{ type: "video", url: "./music-video.mp4", position: 0, end: 10 },
|
|
889
|
-
{
|
|
890
|
-
type: "text",
|
|
891
|
-
mode: "karaoke",
|
|
892
|
-
text: "Each word pops instantly",
|
|
893
|
-
position: 1,
|
|
894
|
-
end: 5,
|
|
895
|
-
fontColor: "#FFFFFF",
|
|
896
|
-
highlightColor: "#FF00FF",
|
|
897
|
-
highlightStyle: "instant", // Words change color immediately
|
|
898
|
-
fontSize: 48,
|
|
899
|
-
},
|
|
900
|
-
]);
|
|
901
|
-
```
|
|
902
|
-
|
|
903
|
-
Multi-line karaoke (use `\n` for line breaks):
|
|
904
|
-
|
|
905
|
-
```ts
|
|
906
|
-
await project.load([
|
|
907
|
-
{ type: "video", url: "./music-video.mp4", position: 0, end: 10 },
|
|
908
|
-
{
|
|
909
|
-
type: "text",
|
|
910
|
-
mode: "karaoke",
|
|
911
|
-
text: "First line of lyrics\nSecond line continues",
|
|
912
|
-
position: 0,
|
|
913
|
-
end: 6,
|
|
914
|
-
fontColor: "#FFFFFF",
|
|
915
|
-
highlightColor: "#FFFF00",
|
|
916
|
-
fontSize: 36,
|
|
917
|
-
yPercent: 0.8,
|
|
918
|
-
},
|
|
919
|
-
]);
|
|
920
|
-
```
|
|
921
|
-
|
|
922
|
-
Or with explicit line breaks in the words array:
|
|
923
|
-
|
|
924
|
-
```ts
|
|
925
|
-
await project.load([
|
|
926
|
-
{ type: "video", url: "./music-video.mp4", position: 0, end: 10 },
|
|
927
|
-
{
|
|
928
|
-
type: "text",
|
|
929
|
-
mode: "karaoke",
|
|
930
|
-
text: "Hello World Goodbye World",
|
|
931
|
-
position: 0,
|
|
932
|
-
end: 4,
|
|
933
|
-
words: [
|
|
934
|
-
{ text: "Hello", start: 0, end: 1 },
|
|
935
|
-
{ text: "World", start: 1, end: 2, lineBreak: true }, // Line break after this word
|
|
936
|
-
{ text: "Goodbye", start: 2, end: 3 },
|
|
937
|
-
{ text: "World", start: 3, end: 4 },
|
|
938
|
-
],
|
|
939
|
-
fontColor: "#FFFFFF",
|
|
940
|
-
highlightColor: "#00FF00",
|
|
941
|
-
},
|
|
942
|
-
]);
|
|
943
|
-
```
|
|
944
|
-
|
|
945
|
-
### Import SRT/VTT Subtitles
|
|
916
|
+
### Subtitles
|
|
946
917
|
|
|
947
|
-
|
|
918
|
+
Import external subtitle files (SRT, VTT, ASS/SSA):
|
|
948
919
|
|
|
949
920
|
```ts
|
|
950
921
|
await project.load([
|
|
@@ -959,72 +930,77 @@ await project.load([
|
|
|
959
930
|
]);
|
|
960
931
|
```
|
|
961
932
|
|
|
962
|
-
|
|
933
|
+
Use `position` to offset all subtitle timestamps forward (e.g., `position: 2.5` delays everything by 2.5s). ASS/SSA files use their own embedded styles — font options are for SRT/VTT imports.
|
|
934
|
+
|
|
935
|
+
### Export Settings
|
|
963
936
|
|
|
964
937
|
```ts
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
938
|
+
// High-quality H.265 with metadata
|
|
939
|
+
await project.export({
|
|
940
|
+
outputPath: "./output.mp4",
|
|
941
|
+
videoCodec: "libx265",
|
|
942
|
+
crf: 18,
|
|
943
|
+
preset: "slow",
|
|
944
|
+
audioCodec: "libopus",
|
|
945
|
+
audioBitrate: "256k",
|
|
946
|
+
metadata: { title: "My Video", artist: "My Name", date: "2025" },
|
|
947
|
+
});
|
|
974
948
|
|
|
975
|
-
|
|
949
|
+
// Hardware-accelerated (macOS)
|
|
950
|
+
await project.export({
|
|
951
|
+
outputPath: "./output.mp4",
|
|
952
|
+
hwaccel: "videotoolbox",
|
|
953
|
+
videoCodec: "h264_videotoolbox",
|
|
954
|
+
});
|
|
976
955
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
956
|
+
// Two-pass encoding for target file size
|
|
957
|
+
await project.export({
|
|
958
|
+
outputPath: "./output.mp4",
|
|
959
|
+
twoPass: true,
|
|
960
|
+
videoBitrate: "5M",
|
|
961
|
+
preset: "slow",
|
|
962
|
+
});
|
|
980
963
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
{
|
|
984
|
-
type: "text",
|
|
985
|
-
text: "Follow for more!",
|
|
986
|
-
position: 12,
|
|
987
|
-
end: 15,
|
|
988
|
-
fontSize: 48,
|
|
989
|
-
fontColor: "white",
|
|
990
|
-
yPercent: 0.8,
|
|
991
|
-
animation: { type: "pop-bounce", in: 0.3 },
|
|
992
|
-
},
|
|
993
|
-
]);
|
|
964
|
+
// Scale output resolution
|
|
965
|
+
await project.export({ outputPath: "./720p.mp4", outputResolution: "720p" });
|
|
994
966
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
967
|
+
// Audio-only export
|
|
968
|
+
await project.export({
|
|
969
|
+
outputPath: "./audio.mp3",
|
|
970
|
+
audioOnly: true,
|
|
971
|
+
audioCodec: "libmp3lame",
|
|
972
|
+
audioBitrate: "320k",
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// Generate thumbnail
|
|
976
|
+
await project.export({
|
|
977
|
+
outputPath: "./output.mp4",
|
|
978
|
+
thumbnail: { outputPath: "./thumb.jpg", time: 5, width: 640 },
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// Debug — save the FFmpeg command to a file
|
|
982
|
+
await project.export({
|
|
983
|
+
outputPath: "./output.mp4",
|
|
984
|
+
verbose: true,
|
|
985
|
+
saveCommand: "./ffmpeg-command.txt",
|
|
1003
986
|
});
|
|
1004
987
|
```
|
|
1005
988
|
|
|
1006
|
-
##
|
|
989
|
+
## Advanced
|
|
990
|
+
|
|
991
|
+
### Timeline Behavior
|
|
1007
992
|
|
|
1008
993
|
- Clip timing uses `[position, end)` intervals in seconds
|
|
1009
994
|
- Transitions create overlaps that reduce total duration
|
|
1010
995
|
- Background music is mixed after video transitions (unaffected by crossfades)
|
|
1011
996
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
FFmpeg's `xfade` transitions work by **overlapping** clips, which compresses the timeline. For example:
|
|
1015
|
-
|
|
1016
|
-
- Clip A: 0-10s
|
|
1017
|
-
- Clip B: 10-20s with 1s fade transition
|
|
1018
|
-
- **Actual output duration: 19s** (not 20s)
|
|
997
|
+
**Transition Compensation:**
|
|
1019
998
|
|
|
1020
|
-
|
|
999
|
+
FFmpeg's `xfade` transitions **overlap** clips, compressing the timeline. A 1s fade between two 10s clips produces 19s of output, not 20s. With multiple transitions this compounds.
|
|
1021
1000
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
By default, simple-ffmpeg automatically adjusts text and subtitle timings to compensate for this compression. When you position text at "15s", it appears at the visual 15s mark in the output video, regardless of how many transitions have occurred.
|
|
1001
|
+
By default, simple-ffmpeg automatically adjusts text and subtitle timings to compensate. When you position text at "15s", it appears at the visual 15s mark regardless of how many transitions preceded it:
|
|
1025
1002
|
|
|
1026
1003
|
```ts
|
|
1027
|
-
// Text will appear at the correct visual position even with transitions
|
|
1028
1004
|
await project.load([
|
|
1029
1005
|
{ type: "video", url: "./a.mp4", position: 0, end: 10 },
|
|
1030
1006
|
{
|
|
@@ -1038,138 +1014,250 @@ await project.load([
|
|
|
1038
1014
|
]);
|
|
1039
1015
|
```
|
|
1040
1016
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
If you need raw timeline positioning (e.g., you've pre-calculated offsets yourself):
|
|
1044
|
-
|
|
1045
|
-
```ts
|
|
1046
|
-
await project.export({
|
|
1047
|
-
outputPath: "./output.mp4",
|
|
1048
|
-
compensateTransitions: false, // Use raw timestamps
|
|
1049
|
-
});
|
|
1050
|
-
```
|
|
1051
|
-
|
|
1052
|
-
## Auto-Batching for Complex Filter Graphs
|
|
1017
|
+
Disable with `compensateTransitions: false` in export options if you've pre-calculated offsets yourself.
|
|
1053
1018
|
|
|
1054
|
-
|
|
1019
|
+
### Auto-Batching
|
|
1055
1020
|
|
|
1056
|
-
|
|
1021
|
+
FFmpeg's `filter_complex` has platform-specific length limits (Windows ~32KB, macOS ~1MB, Linux ~2MB). When text animations create many filter nodes, the command can exceed these limits.
|
|
1057
1022
|
|
|
1058
|
-
|
|
1059
|
-
2. **Smart batching**: If too long, text overlays are rendered in multiple passes with intermediate files
|
|
1060
|
-
3. **Optimal batch sizing**: Calculates the ideal number of nodes per pass based on actual filter complexity
|
|
1023
|
+
simple-ffmpeg handles this automatically — detecting oversized filter graphs and splitting text overlays into multiple rendering passes with intermediate files. No configuration needed.
|
|
1061
1024
|
|
|
1062
|
-
|
|
1025
|
+
For very complex projects, you can tune it:
|
|
1063
1026
|
|
|
1064
1027
|
```js
|
|
1065
1028
|
await project.export({
|
|
1066
|
-
outputPath: "./output.mp4",
|
|
1067
|
-
// Lower this if you have many complex text animations
|
|
1068
1029
|
textMaxNodesPerPass: 30, // default: 75
|
|
1069
|
-
// Intermediate encoding settings (used between passes)
|
|
1070
1030
|
intermediateVideoCodec: "libx264", // default
|
|
1071
1031
|
intermediateCrf: 18, // default (high quality)
|
|
1072
1032
|
intermediatePreset: "veryfast", // default (fast encoding)
|
|
1073
1033
|
});
|
|
1074
1034
|
```
|
|
1075
1035
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
- Typewriter animations with long text (creates one filter node per character)
|
|
1079
|
-
- Many simultaneous text overlays
|
|
1080
|
-
- Complex animation combinations
|
|
1081
|
-
|
|
1082
|
-
With `verbose: true`, you'll see when auto-batching kicks in:
|
|
1083
|
-
|
|
1084
|
-
```
|
|
1085
|
-
simple-ffmpeg: Auto-batching text (filter too long: 150000 > 100000). Using 35 nodes per pass.
|
|
1086
|
-
```
|
|
1036
|
+
Batching activates for typewriter animations with long text, many simultaneous text overlays, or complex animation combinations. With `verbose: true`, you'll see when it kicks in.
|
|
1087
1037
|
|
|
1088
1038
|
## Real-World Usage Patterns
|
|
1089
1039
|
|
|
1090
1040
|
### Data Pipeline Example
|
|
1091
1041
|
|
|
1092
|
-
Generate videos programmatically from structured data
|
|
1042
|
+
Generate videos programmatically from structured data — database records, API responses, CMS content, etc. This example creates property tour videos from real estate listings:
|
|
1093
1043
|
|
|
1094
1044
|
```js
|
|
1095
|
-
|
|
1045
|
+
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
1096
1046
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1047
|
+
const listings = await db.getActiveListings(); // your data source
|
|
1048
|
+
|
|
1049
|
+
async function generateListingVideo(listing, outputPath) {
|
|
1050
|
+
const photos = listing.photos; // ['kitchen.jpg', 'living-room.jpg', ...]
|
|
1051
|
+
const slideDuration = 4;
|
|
1052
|
+
|
|
1053
|
+
// Build an image slideshow from listing photos (auto-sequenced with crossfades)
|
|
1054
|
+
const transitionDuration = 0.5;
|
|
1055
|
+
const photoClips = photos.map((photo, i) => ({
|
|
1056
|
+
type: "image",
|
|
1057
|
+
url: photo,
|
|
1058
|
+
duration: slideDuration,
|
|
1059
|
+
kenBurns: i % 2 === 0 ? "zoom-in" : "pan-right",
|
|
1060
|
+
...(i > 0 && {
|
|
1061
|
+
transition: { type: "fade", duration: transitionDuration },
|
|
1062
|
+
}),
|
|
1063
|
+
}));
|
|
1064
|
+
|
|
1065
|
+
const totalDuration = SIMPLEFFMPEG.getDuration(photoClips);
|
|
1105
1066
|
|
|
1106
|
-
async function generateQuoteVideo(quote, outputPath) {
|
|
1107
1067
|
const clips = [
|
|
1108
|
-
|
|
1068
|
+
...photoClips,
|
|
1069
|
+
// Price banner
|
|
1109
1070
|
{
|
|
1110
1071
|
type: "text",
|
|
1111
|
-
text:
|
|
1072
|
+
text: listing.price,
|
|
1112
1073
|
position: 0.5,
|
|
1113
|
-
end:
|
|
1114
|
-
fontSize:
|
|
1074
|
+
end: totalDuration - 0.5,
|
|
1075
|
+
fontSize: 36,
|
|
1115
1076
|
fontColor: "#FFFFFF",
|
|
1116
|
-
|
|
1117
|
-
|
|
1077
|
+
backgroundColor: "#000000",
|
|
1078
|
+
backgroundOpacity: 0.6,
|
|
1079
|
+
padding: 12,
|
|
1080
|
+
xPercent: 0.5,
|
|
1081
|
+
yPercent: 0.1,
|
|
1118
1082
|
},
|
|
1083
|
+
// Address at the bottom
|
|
1119
1084
|
{
|
|
1120
1085
|
type: "text",
|
|
1121
|
-
text:
|
|
1122
|
-
position:
|
|
1123
|
-
end:
|
|
1086
|
+
text: listing.address,
|
|
1087
|
+
position: 0.5,
|
|
1088
|
+
end: totalDuration - 0.5,
|
|
1124
1089
|
fontSize: 28,
|
|
1125
|
-
fontColor: "#
|
|
1126
|
-
|
|
1127
|
-
|
|
1090
|
+
fontColor: "#FFFFFF",
|
|
1091
|
+
borderColor: "#000000",
|
|
1092
|
+
borderWidth: 2,
|
|
1093
|
+
xPercent: 0.5,
|
|
1094
|
+
yPercent: 0.9,
|
|
1128
1095
|
},
|
|
1096
|
+
{ type: "music", url: "./assets/ambient.mp3", volume: 0.15, loop: true },
|
|
1129
1097
|
];
|
|
1130
1098
|
|
|
1131
|
-
const project = new SIMPLEFFMPEG({ preset: "
|
|
1099
|
+
const project = new SIMPLEFFMPEG({ preset: "instagram-reel" });
|
|
1132
1100
|
await project.load(clips);
|
|
1133
1101
|
return project.export({ outputPath });
|
|
1134
1102
|
}
|
|
1135
1103
|
|
|
1136
|
-
// Batch
|
|
1137
|
-
for (const
|
|
1138
|
-
await
|
|
1104
|
+
// Batch generate videos for all listings
|
|
1105
|
+
for (const listing of listings) {
|
|
1106
|
+
await generateListingVideo(listing, `./output/${listing.id}.mp4`);
|
|
1139
1107
|
}
|
|
1140
1108
|
```
|
|
1141
1109
|
|
|
1142
|
-
### AI Generation
|
|
1110
|
+
### AI Video Generation Pipeline Example
|
|
1143
1111
|
|
|
1144
|
-
|
|
1112
|
+
Combine schema export, validation, and structured error codes to build a complete AI-driven video generation pipeline. The schema gives the model the exact specification it needs, and the validation loop lets it self-correct until the output is valid.
|
|
1145
1113
|
|
|
1146
1114
|
```js
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1115
|
+
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
1116
|
+
|
|
1117
|
+
// 1. Build the schema context for the AI
|
|
1118
|
+
// Only expose the clip types you want the AI to work with.
|
|
1119
|
+
// Developer-level config (codecs, resolution, etc.) stays out of the schema.
|
|
1120
|
+
|
|
1121
|
+
const schema = SIMPLEFFMPEG.getSchema({
|
|
1122
|
+
include: ["video", "image", "text", "music"],
|
|
1123
|
+
instructions: [
|
|
1124
|
+
"You are composing a short-form video for TikTok.",
|
|
1125
|
+
"Keep total duration under 30 seconds.",
|
|
1126
|
+
"Return ONLY valid JSON — an array of clip objects.",
|
|
1127
|
+
],
|
|
1128
|
+
moduleInstructions: {
|
|
1129
|
+
video: "Use fade transitions between clips. Keep each clip 3-6 seconds.",
|
|
1130
|
+
text: [
|
|
1131
|
+
"Add a title in the first 2 seconds with fontSize 72.",
|
|
1132
|
+
"Use white text with a black border for readability.",
|
|
1133
|
+
],
|
|
1134
|
+
music: "Always include looping background music at volume 0.15.",
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// 2. Send the schema + prompt to your LLM
|
|
1139
|
+
|
|
1140
|
+
async function askAI(systemPrompt, userPrompt) {
|
|
1141
|
+
// Replace with your LLM provider (OpenAI, Anthropic, etc.)
|
|
1142
|
+
const response = await llm.chat({
|
|
1143
|
+
messages: [
|
|
1144
|
+
{ role: "system", content: systemPrompt },
|
|
1145
|
+
{ role: "user", content: userPrompt },
|
|
1146
|
+
],
|
|
1147
|
+
});
|
|
1148
|
+
return JSON.parse(response.content);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// 3. Generate → Validate → Retry loop
|
|
1152
|
+
|
|
1153
|
+
async function generateVideo(userPrompt, media) {
|
|
1154
|
+
// Build the system prompt with schema + available media and their details.
|
|
1155
|
+
// Descriptions and durations help the AI make good creative decisions —
|
|
1156
|
+
// ordering clips logically, setting accurate position/end times, etc.
|
|
1157
|
+
const mediaList = media
|
|
1158
|
+
.map((m) => ` - ${m.file} (${m.duration}s) — ${m.description}`)
|
|
1159
|
+
.join("\n");
|
|
1160
|
+
|
|
1161
|
+
const systemPrompt = [
|
|
1162
|
+
"You are a video editor. Given the user's request and the available media,",
|
|
1163
|
+
"produce a clips array that follows this schema:\n",
|
|
1164
|
+
schema,
|
|
1165
|
+
"\nAvailable media (use these exact file paths):",
|
|
1166
|
+
mediaList,
|
|
1167
|
+
].join("\n");
|
|
1168
|
+
|
|
1169
|
+
const knownPaths = media.map((m) => m.file);
|
|
1170
|
+
|
|
1171
|
+
// First attempt
|
|
1172
|
+
let clips = await askAI(systemPrompt, userPrompt);
|
|
1173
|
+
let result = SIMPLEFFMPEG.validate(clips, { skipFileChecks: true });
|
|
1174
|
+
let attempts = 1;
|
|
1175
|
+
|
|
1176
|
+
// Self-correction loop: feed structured errors back to the AI
|
|
1177
|
+
while (!result.valid && attempts < 3) {
|
|
1178
|
+
const errorFeedback = result.errors
|
|
1179
|
+
.map((e) => `[${e.code}] ${e.path}: ${e.message}`)
|
|
1180
|
+
.join("\n");
|
|
1181
|
+
|
|
1182
|
+
clips = await askAI(
|
|
1183
|
+
systemPrompt,
|
|
1184
|
+
[
|
|
1185
|
+
`Your previous output had validation errors:\n${errorFeedback}`,
|
|
1186
|
+
`\nOriginal request: ${userPrompt}`,
|
|
1187
|
+
"\nPlease fix the errors and return the corrected clips array.",
|
|
1188
|
+
].join("\n")
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
result = SIMPLEFFMPEG.validate(clips, { skipFileChecks: true });
|
|
1192
|
+
attempts++;
|
|
1160
1193
|
}
|
|
1161
1194
|
|
|
1162
1195
|
if (!result.valid) {
|
|
1163
|
-
throw new Error(
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
`Failed to generate valid config after ${attempts} attempts:\n` +
|
|
1198
|
+
SIMPLEFFMPEG.formatValidationResult(result)
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// 4. Verify the AI only used known media paths
|
|
1203
|
+
// The structural loop (skipFileChecks: true) can't catch hallucinated paths.
|
|
1204
|
+
// You could also put this inside the retry loop to let the AI self-correct
|
|
1205
|
+
// bad paths — just append the unknown paths to the error feedback string.
|
|
1206
|
+
|
|
1207
|
+
const usedPaths = clips.filter((c) => c.url).map((c) => c.url);
|
|
1208
|
+
const unknownPaths = usedPaths.filter((p) => !knownPaths.includes(p));
|
|
1209
|
+
if (unknownPaths.length > 0) {
|
|
1210
|
+
throw new Error(`AI used unknown media paths: ${unknownPaths.join(", ")}`);
|
|
1164
1211
|
}
|
|
1165
1212
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1213
|
+
// 5. Build and export
|
|
1214
|
+
// load() will also throw MediaNotFoundError if any file is missing on disk.
|
|
1215
|
+
|
|
1216
|
+
const project = new SIMPLEFFMPEG({ preset: "tiktok" });
|
|
1217
|
+
await project.load(clips);
|
|
1218
|
+
|
|
1219
|
+
return project.export({
|
|
1220
|
+
outputPath: "./output.mp4",
|
|
1221
|
+
onProgress: ({ percent }) => console.log(`Rendering: ${percent}%`),
|
|
1222
|
+
});
|
|
1169
1223
|
}
|
|
1224
|
+
|
|
1225
|
+
// Usage
|
|
1226
|
+
|
|
1227
|
+
await generateVideo("Make a hype travel montage with upbeat text overlays", [
|
|
1228
|
+
{
|
|
1229
|
+
file: "clips/beach-drone.mp4",
|
|
1230
|
+
duration: 4,
|
|
1231
|
+
description:
|
|
1232
|
+
"Aerial drone shot of a tropical beach with people playing volleyball",
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
file: "clips/city-timelapse.mp4",
|
|
1236
|
+
duration: 8,
|
|
1237
|
+
description: "Timelapse of a city skyline transitioning from day to night",
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
file: "clips/sunset.mp4",
|
|
1241
|
+
duration: 6,
|
|
1242
|
+
description: "Golden hour sunset over the ocean with gentle waves",
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
file: "music/upbeat-track.mp3",
|
|
1246
|
+
duration: 120,
|
|
1247
|
+
description:
|
|
1248
|
+
"Upbeat electronic track with a strong beat, good for montages",
|
|
1249
|
+
},
|
|
1250
|
+
]);
|
|
1170
1251
|
```
|
|
1171
1252
|
|
|
1172
|
-
|
|
1253
|
+
The key parts of this pattern:
|
|
1254
|
+
|
|
1255
|
+
1. **`getSchema()`** gives the AI a precise specification of what it can produce, with only the clip types you've chosen to expose.
|
|
1256
|
+
2. **`instructions` / `moduleInstructions`** embed your creative constraints directly into the spec — the AI treats them the same as built-in rules.
|
|
1257
|
+
3. **Media descriptions** with durations and content details give the AI enough context to make good creative decisions — ordering clips logically, setting accurate timings, and choosing the right media for each part of the video.
|
|
1258
|
+
4. **`validate()`** with `skipFileChecks: true` checks structural correctness in the retry loop — types, timelines, required fields — without touching the filesystem.
|
|
1259
|
+
5. **The retry loop** lets the AI self-correct. Most validation failures resolve in one retry.
|
|
1260
|
+
6. **The path guard** catches hallucinated file paths before `load()` hits the filesystem. You can optionally move this check inside the retry loop to let the AI self-correct bad paths. `load()` itself will also throw `MediaNotFoundError` if a file is missing on disk.
|
|
1173
1261
|
|
|
1174
1262
|
## Testing
|
|
1175
1263
|
|