simple-ffmpegjs 0.2.0 → 0.3.1
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 +706 -34
- package/package.json +3 -4
- package/src/core/constants.js +33 -0
- package/src/core/media_info.js +141 -66
- package/src/core/rotation.js +76 -7
- package/src/core/validation.js +758 -145
- package/src/ffmpeg/command_builder.js +33 -6
- package/src/ffmpeg/strings.js +52 -2
- package/src/ffmpeg/subtitle_builder.js +707 -0
- package/src/ffmpeg/text_passes.js +41 -5
- package/src/ffmpeg/text_renderer.js +165 -16
- package/src/ffmpeg/video_builder.js +3 -1
- package/src/ffmpeg/watermark_builder.js +411 -0
- package/src/loaders.js +81 -7
- package/src/simpleffmpeg.js +604 -62
- package/types/index.d.mts +266 -7
- package/types/index.d.ts +305 -9
- package/assets/example-thumbnail.jpg +0 -0
package/README.md
CHANGED
|
@@ -2,15 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/simple-ffmpegjs)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://nodejs.org)
|
|
6
6
|
|
|
7
7
|
A lightweight Node.js library for programmatic video composition using FFmpeg. Designed for data pipelines and automation workflows that need reliable video assembly without the complexity of a full editing suite.
|
|
8
8
|
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Why simple-ffmpeg?](#why-simple-ffmpeg)
|
|
12
|
+
- [Features](#features)
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Quick Start](#quick-start)
|
|
15
|
+
- [Pre-Validation (for AI Pipelines)](#pre-validation-for-ai-pipelines)
|
|
16
|
+
- [API Reference](#api-reference)
|
|
17
|
+
- [Constructor](#constructor)
|
|
18
|
+
- [Methods](#methods)
|
|
19
|
+
- [Clip Types](#clip-types)
|
|
20
|
+
- [Platform Presets](#platform-presets)
|
|
21
|
+
- [Watermarks](#watermarks)
|
|
22
|
+
- [Progress Information](#progress-information)
|
|
23
|
+
- [Error Handling](#error-handling)
|
|
24
|
+
- [Cancellation](#cancellation)
|
|
25
|
+
- [Gap Handling](#gap-handling)
|
|
26
|
+
- [Examples](#examples)
|
|
27
|
+
- [Transitions](#two-clips-with-transition)
|
|
28
|
+
- [Text Positioning](#text-positioning-with-offsets)
|
|
29
|
+
- [Word-by-Word Animation](#word-by-word-text-animation)
|
|
30
|
+
- [Ken Burns Slideshow](#image-slideshow-with-ken-burns)
|
|
31
|
+
- [Export Options](#high-quality-export-with-custom-settings)
|
|
32
|
+
- [Text Animations](#typewriter-text-effect)
|
|
33
|
+
- [Karaoke](#karaoke-text-effect)
|
|
34
|
+
- [Subtitles](#import-srtvtt-subtitles)
|
|
35
|
+
- [Timeline Behavior](#timeline-behavior)
|
|
36
|
+
- [Transition Compensation](#transition-compensation)
|
|
37
|
+
- [Auto-Batching](#auto-batching-for-complex-filter-graphs)
|
|
38
|
+
- [Testing](#testing)
|
|
39
|
+
- [Contributing](#contributing)
|
|
40
|
+
- [License](#license)
|
|
41
|
+
|
|
42
|
+
## Why simple-ffmpeg?
|
|
43
|
+
|
|
44
|
+
With `fluent-ffmpeg` no longer actively maintained, there's a need for a modern, well-supported alternative. simple-ffmpeg fills this gap with a declarative, config-driven API that's particularly well-suited for structured validation with error codes that makes it easy to build feedback loops where AI can generate configs, validate them, and iterate until successful
|
|
45
|
+
|
|
46
|
+
The library handles FFmpeg's complexity internally while exposing a clean interface that both humans and AI can work with effectively.
|
|
47
|
+
|
|
9
48
|
## Example Output
|
|
10
49
|
|
|
11
50
|
<p align="center">
|
|
12
|
-
<a href="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/wonders-
|
|
13
|
-
<img src="
|
|
51
|
+
<a href="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/wonders-showcase-1.mp4">
|
|
52
|
+
<img src="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/simple-ffmpeg/wonders-thumbnail-1.jpg" alt="Example video - click to watch" width="640">
|
|
14
53
|
</a>
|
|
15
54
|
</p>
|
|
16
55
|
|
|
@@ -21,10 +60,16 @@ _Click to watch a "Wonders of the World" video created with simple-ffmpeg — co
|
|
|
21
60
|
- **Video Concatenation** — Join multiple clips with optional xfade transitions
|
|
22
61
|
- **Audio Mixing** — Layer audio tracks, voiceovers, and background music
|
|
23
62
|
- **Text Overlays** — Static, word-by-word, and cumulative text with animations
|
|
63
|
+
- **Text Animations** — Typewriter, scale-in, pulse, fade effects
|
|
64
|
+
- **Karaoke Mode** — Word-by-word highlighting with customizable colors
|
|
65
|
+
- **Subtitle Import** — Load SRT, VTT, ASS/SSA subtitle files
|
|
66
|
+
- **Watermarks** — Text or image overlays with positioning and timing control
|
|
67
|
+
- **Platform Presets** — Quick configuration for TikTok, YouTube, Instagram, etc.
|
|
24
68
|
- **Image Support** — Ken Burns effects (zoom, pan) for static images
|
|
25
69
|
- **Progress Tracking** — Real-time export progress callbacks
|
|
26
70
|
- **Cancellation** — AbortController support for stopping exports
|
|
27
71
|
- **Gap Handling** — Optional black frame fill for timeline gaps
|
|
72
|
+
- **Auto-Batching** — Automatically splits complex filter graphs to avoid OS command limits
|
|
28
73
|
- **TypeScript Ready** — Full type definitions included
|
|
29
74
|
- **Zero Dependencies** — Only requires FFmpeg on your system
|
|
30
75
|
|
|
@@ -96,6 +141,55 @@ await project.export({
|
|
|
96
141
|
});
|
|
97
142
|
```
|
|
98
143
|
|
|
144
|
+
## Pre-Validation (for AI Pipelines)
|
|
145
|
+
|
|
146
|
+
Validate configurations before creating a project—ideal for AI feedback loops where you want to catch errors early and provide structured feedback:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
150
|
+
|
|
151
|
+
const clips = [
|
|
152
|
+
{ type: "video", url: "./intro.mp4", position: 0, end: 5 },
|
|
153
|
+
{ type: "text", text: "Hello", position: 1, end: 4 },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// Validate without creating a project
|
|
157
|
+
const result = SIMPLEFFMPEG.validate(clips, {
|
|
158
|
+
skipFileChecks: true, // Skip file existence checks (useful when files don't exist yet)
|
|
159
|
+
width: 1920, // Project dimensions (for Ken Burns size validation)
|
|
160
|
+
height: 1080,
|
|
161
|
+
strictKenBurns: false, // If true, undersized Ken Burns images error instead of warn (default: false)
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!result.valid) {
|
|
165
|
+
// Structured errors for programmatic handling
|
|
166
|
+
result.errors.forEach((err) => {
|
|
167
|
+
console.log(`[${err.code}] ${err.path}: ${err.message}`);
|
|
168
|
+
// e.g. [MISSING_REQUIRED] clips[0].url: URL is required for media clips
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Or get human-readable output
|
|
173
|
+
console.log(SIMPLEFFMPEG.formatValidationResult(result));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Validation Codes
|
|
177
|
+
|
|
178
|
+
Access error codes programmatically for custom handling:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const { ValidationCodes } = SIMPLEFFMPEG;
|
|
182
|
+
|
|
183
|
+
// Available codes:
|
|
184
|
+
// INVALID_TYPE, MISSING_REQUIRED, INVALID_VALUE, INVALID_RANGE,
|
|
185
|
+
// INVALID_TIMELINE, TIMELINE_GAP, FILE_NOT_FOUND, INVALID_FORMAT,
|
|
186
|
+
// INVALID_WORD_TIMING, OUTSIDE_BOUNDS
|
|
187
|
+
|
|
188
|
+
if (result.errors.some((e) => e.code === ValidationCodes.TIMELINE_GAP)) {
|
|
189
|
+
// Handle gap-specific logic
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
99
193
|
## API Reference
|
|
100
194
|
|
|
101
195
|
### Constructor
|
|
@@ -107,6 +201,7 @@ new SIMPLEFFMPEG(options?: {
|
|
|
107
201
|
fps?: number; // Frame rate (default: 30)
|
|
108
202
|
validationMode?: 'warn' | 'strict'; // Validation behavior (default: 'warn')
|
|
109
203
|
fillGaps?: 'none' | 'black'; // Gap handling (default: 'none')
|
|
204
|
+
preset?: string; // Platform preset (e.g., 'tiktok', 'youtube', 'instagram-post')
|
|
110
205
|
})
|
|
111
206
|
```
|
|
112
207
|
|
|
@@ -130,28 +225,30 @@ await project.export(options?: ExportOptions): Promise<string>
|
|
|
130
225
|
|
|
131
226
|
**Export Options:**
|
|
132
227
|
|
|
133
|
-
| Option
|
|
134
|
-
|
|
|
135
|
-
| `outputPath`
|
|
136
|
-
| `videoCodec`
|
|
137
|
-
| `crf`
|
|
138
|
-
| `preset`
|
|
139
|
-
| `videoBitrate`
|
|
140
|
-
| `audioCodec`
|
|
141
|
-
| `audioBitrate`
|
|
142
|
-
| `audioSampleRate`
|
|
143
|
-
| `hwaccel`
|
|
144
|
-
| `outputWidth`
|
|
145
|
-
| `outputHeight`
|
|
146
|
-
| `outputResolution`
|
|
147
|
-
| `audioOnly`
|
|
148
|
-
| `twoPass`
|
|
149
|
-
| `metadata`
|
|
150
|
-
| `thumbnail`
|
|
151
|
-
| `verbose`
|
|
152
|
-
| `saveCommand`
|
|
153
|
-
| `onProgress`
|
|
154
|
-
| `signal`
|
|
228
|
+
| Option | Type | Default | Description |
|
|
229
|
+
| ----------------------- | ------------- | ---------------- | -------------------------------------------------------------------------------- |
|
|
230
|
+
| `outputPath` | `string` | `'./output.mp4'` | Output file path |
|
|
231
|
+
| `videoCodec` | `string` | `'libx264'` | Video codec (`libx264`, `libx265`, `libvpx-vp9`, `prores_ks`, hardware encoders) |
|
|
232
|
+
| `crf` | `number` | `23` | Quality level (0-51, lower = better) |
|
|
233
|
+
| `preset` | `string` | `'medium'` | Encoding preset (`ultrafast` to `veryslow`) |
|
|
234
|
+
| `videoBitrate` | `string` | - | Target bitrate (e.g., `'5M'`). Overrides CRF. |
|
|
235
|
+
| `audioCodec` | `string` | `'aac'` | Audio codec (`aac`, `libmp3lame`, `libopus`, `flac`, `copy`) |
|
|
236
|
+
| `audioBitrate` | `string` | `'192k'` | Audio bitrate |
|
|
237
|
+
| `audioSampleRate` | `number` | `48000` | Audio sample rate in Hz |
|
|
238
|
+
| `hwaccel` | `string` | `'none'` | Hardware acceleration (`auto`, `videotoolbox`, `nvenc`, `vaapi`, `qsv`) |
|
|
239
|
+
| `outputWidth` | `number` | - | Scale output width |
|
|
240
|
+
| `outputHeight` | `number` | - | Scale output height |
|
|
241
|
+
| `outputResolution` | `string` | - | Resolution preset (`'720p'`, `'1080p'`, `'4k'`) |
|
|
242
|
+
| `audioOnly` | `boolean` | `false` | Export audio only (no video) |
|
|
243
|
+
| `twoPass` | `boolean` | `false` | Two-pass encoding for better quality |
|
|
244
|
+
| `metadata` | `object` | - | Embed metadata (title, artist, etc.) |
|
|
245
|
+
| `thumbnail` | `object` | - | Generate thumbnail image |
|
|
246
|
+
| `verbose` | `boolean` | `false` | Enable verbose logging |
|
|
247
|
+
| `saveCommand` | `string` | - | Save FFmpeg command to file |
|
|
248
|
+
| `onProgress` | `function` | - | Progress callback |
|
|
249
|
+
| `signal` | `AbortSignal` | - | Cancellation signal |
|
|
250
|
+
| `watermark` | `object` | - | Add watermark overlay (see Watermarks section) |
|
|
251
|
+
| `compensateTransitions` | `boolean` | `true` | Auto-adjust text timings for transition overlap (see below) |
|
|
155
252
|
|
|
156
253
|
#### `project.preview(options)`
|
|
157
254
|
|
|
@@ -209,11 +306,23 @@ All [xfade transitions](https://trac.ffmpeg.org/wiki/Xfade) are supported.
|
|
|
209
306
|
end?: number; // default: project duration
|
|
210
307
|
cutFrom?: number;
|
|
211
308
|
volume?: number; // default: 0.2
|
|
309
|
+
loop?: boolean; // Loop audio to fill video duration
|
|
212
310
|
}
|
|
213
311
|
```
|
|
214
312
|
|
|
215
313
|
Background music is mixed after transitions, so video crossfades won't affect its volume.
|
|
216
314
|
|
|
315
|
+
**Looping Music:**
|
|
316
|
+
|
|
317
|
+
If your music track is shorter than your video, enable looping:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
await project.load([
|
|
321
|
+
{ type: "video", url: "./video.mp4", position: 0, end: 120 },
|
|
322
|
+
{ type: "music", url: "./30s-track.mp3", volume: 0.3, loop: true },
|
|
323
|
+
]);
|
|
324
|
+
```
|
|
325
|
+
|
|
217
326
|
#### Image Clip
|
|
218
327
|
|
|
219
328
|
```ts
|
|
@@ -236,7 +345,7 @@ Background music is mixed after transitions, so video crossfades won't affect it
|
|
|
236
345
|
|
|
237
346
|
// Content
|
|
238
347
|
text?: string;
|
|
239
|
-
mode?: "static" | "word-replace" | "word-sequential";
|
|
348
|
+
mode?: "static" | "word-replace" | "word-sequential" | "karaoke";
|
|
240
349
|
words?: Array<{ text: string; start: number; end: number }>;
|
|
241
350
|
wordTimestamps?: number[];
|
|
242
351
|
|
|
@@ -251,21 +360,157 @@ Background music is mixed after transitions, so video crossfades won't affect it
|
|
|
251
360
|
shadowX?: number;
|
|
252
361
|
shadowY?: number;
|
|
253
362
|
|
|
254
|
-
// Positioning
|
|
363
|
+
// Positioning (omit x/y to center)
|
|
255
364
|
xPercent?: number; // Horizontal position as % (0 = left, 0.5 = center, 1 = right)
|
|
256
365
|
yPercent?: number; // Vertical position as % (0 = top, 0.5 = center, 1 = bottom)
|
|
257
366
|
x?: number; // Absolute X position in pixels
|
|
258
367
|
y?: number; // Absolute Y position in pixels
|
|
368
|
+
xOffset?: number; // Pixel offset added to X (works with any positioning method)
|
|
369
|
+
yOffset?: number; // Pixel offset added to Y (e.g., center + 50px below)
|
|
259
370
|
|
|
260
371
|
// Animation
|
|
261
372
|
animation?: {
|
|
262
|
-
type: "none" | "fade-in" | "fade-in-out" | "pop" | "pop-bounce"
|
|
373
|
+
type: "none" | "fade-in" | "fade-in-out" | "fade-out" | "pop" | "pop-bounce"
|
|
374
|
+
| "typewriter" | "scale-in" | "pulse";
|
|
263
375
|
in?: number; // Intro duration (seconds)
|
|
264
376
|
out?: number; // Outro duration (seconds)
|
|
377
|
+
speed?: number; // For typewriter (chars/sec) or pulse (pulses/sec)
|
|
378
|
+
intensity?: number; // For scale-in or pulse (size variation 0-1)
|
|
265
379
|
};
|
|
380
|
+
|
|
381
|
+
highlightColor?: string; // For karaoke mode (default: '#FFFF00')
|
|
382
|
+
highlightStyle?: "smooth" | "instant"; // 'smooth' = gradual fill, 'instant' = immediate change (default: 'smooth')
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
#### Subtitle Clip
|
|
387
|
+
|
|
388
|
+
Import external subtitle files (SRT, VTT, ASS/SSA):
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
{
|
|
392
|
+
type: "subtitle";
|
|
393
|
+
url: string; // Path to subtitle file
|
|
394
|
+
position?: number; // Time offset in seconds (default: 0)
|
|
395
|
+
|
|
396
|
+
// Styling (for SRT/VTT - ASS files use their own styles)
|
|
397
|
+
fontFamily?: string;
|
|
398
|
+
fontSize?: number;
|
|
399
|
+
fontColor?: string;
|
|
400
|
+
borderColor?: string;
|
|
401
|
+
borderWidth?: number;
|
|
402
|
+
opacity?: number;
|
|
266
403
|
}
|
|
267
404
|
```
|
|
268
405
|
|
|
406
|
+
### Platform Presets
|
|
407
|
+
|
|
408
|
+
Use platform presets to quickly configure optimal dimensions for social media:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
const project = new SIMPLEFFMPEG({ preset: "tiktok" });
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Available presets:
|
|
415
|
+
|
|
416
|
+
| Preset | Resolution | Aspect Ratio | Use Case |
|
|
417
|
+
| -------------------- | ----------- | ------------ | ----------------------- |
|
|
418
|
+
| `tiktok` | 1080 × 1920 | 9:16 | TikTok, vertical videos |
|
|
419
|
+
| `youtube-short` | 1080 × 1920 | 9:16 | YouTube Shorts |
|
|
420
|
+
| `instagram-reel` | 1080 × 1920 | 9:16 | Instagram Reels |
|
|
421
|
+
| `instagram-story` | 1080 × 1920 | 9:16 | Instagram Stories |
|
|
422
|
+
| `snapchat` | 1080 × 1920 | 9:16 | Snapchat |
|
|
423
|
+
| `instagram-post` | 1080 × 1080 | 1:1 | Instagram feed posts |
|
|
424
|
+
| `instagram-square` | 1080 × 1080 | 1:1 | Square format |
|
|
425
|
+
| `youtube` | 1920 × 1080 | 16:9 | YouTube standard |
|
|
426
|
+
| `twitter` | 1920 × 1080 | 16:9 | Twitter/X horizontal |
|
|
427
|
+
| `facebook` | 1920 × 1080 | 16:9 | Facebook horizontal |
|
|
428
|
+
| `landscape` | 1920 × 1080 | 16:9 | General landscape |
|
|
429
|
+
| `twitter-portrait` | 1080 × 1350 | 4:5 | Twitter portrait |
|
|
430
|
+
| `instagram-portrait` | 1080 × 1350 | 4:5 | Instagram portrait |
|
|
431
|
+
|
|
432
|
+
Override preset values with explicit options:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
const project = new SIMPLEFFMPEG({
|
|
436
|
+
preset: "tiktok",
|
|
437
|
+
fps: 60, // Override default 30fps
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Query available presets programmatically:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
SIMPLEFFMPEG.getPresetNames(); // ['tiktok', 'youtube-short', ...]
|
|
445
|
+
SIMPLEFFMPEG.getPresets(); // { tiktok: { width: 1080, height: 1920, fps: 30 }, ... }
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Watermarks
|
|
449
|
+
|
|
450
|
+
Add text or image watermarks to your videos:
|
|
451
|
+
|
|
452
|
+
**Text Watermark:**
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
await project.export({
|
|
456
|
+
outputPath: "./output.mp4",
|
|
457
|
+
watermark: {
|
|
458
|
+
type: "text",
|
|
459
|
+
text: "@myhandle",
|
|
460
|
+
position: "bottom-right", // 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'
|
|
461
|
+
fontSize: 24,
|
|
462
|
+
fontColor: "#FFFFFF",
|
|
463
|
+
opacity: 0.7,
|
|
464
|
+
margin: 20,
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**Image Watermark:**
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
await project.export({
|
|
473
|
+
outputPath: "./output.mp4",
|
|
474
|
+
watermark: {
|
|
475
|
+
type: "image",
|
|
476
|
+
url: "./logo.png",
|
|
477
|
+
position: "top-right",
|
|
478
|
+
opacity: 0.8,
|
|
479
|
+
scale: 0.5, // Scale to 50% of original size
|
|
480
|
+
margin: 15,
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Timed Watermark:**
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
await project.export({
|
|
489
|
+
outputPath: "./output.mp4",
|
|
490
|
+
watermark: {
|
|
491
|
+
type: "text",
|
|
492
|
+
text: "Limited Time!",
|
|
493
|
+
position: "top-left",
|
|
494
|
+
startTime: 5, // Appear at 5 seconds
|
|
495
|
+
endTime: 15, // Disappear at 15 seconds
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
**Custom Position:**
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
await project.export({
|
|
504
|
+
outputPath: "./output.mp4",
|
|
505
|
+
watermark: {
|
|
506
|
+
type: "text",
|
|
507
|
+
text: "Custom",
|
|
508
|
+
x: 100, // Exact X position in pixels
|
|
509
|
+
y: 50, // Exact Y position in pixels
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
269
514
|
### Progress Information
|
|
270
515
|
|
|
271
516
|
The `onProgress` callback receives:
|
|
@@ -282,18 +527,32 @@ The `onProgress` callback receives:
|
|
|
282
527
|
|
|
283
528
|
### Error Handling
|
|
284
529
|
|
|
285
|
-
The library
|
|
530
|
+
The library provides custom error classes for structured error handling:
|
|
286
531
|
|
|
287
|
-
|
|
288
|
-
|
|
532
|
+
| Error Class | When Thrown | Properties |
|
|
533
|
+
| ---------------------- | -------------------------- | --------------------------------------------------------------------------- |
|
|
534
|
+
| `ValidationError` | Invalid clip configuration | `errors[]`, `warnings[]` (structured issues with `code`, `path`, `message`) |
|
|
535
|
+
| `FFmpegError` | FFmpeg command fails | `stderr`, `command`, `exitCode` |
|
|
536
|
+
| `MediaNotFoundError` | File not found | `path` |
|
|
537
|
+
| `ExportCancelledError` | Export aborted | - |
|
|
289
538
|
|
|
539
|
+
```ts
|
|
290
540
|
try {
|
|
291
541
|
await project.export({ outputPath: "./out.mp4" });
|
|
292
542
|
} catch (error) {
|
|
293
543
|
if (error.name === "ValidationError") {
|
|
294
|
-
|
|
544
|
+
// Structured validation errors
|
|
545
|
+
error.errors.forEach((e) =>
|
|
546
|
+
console.error(`[${e.code}] ${e.path}: ${e.message}`)
|
|
547
|
+
);
|
|
548
|
+
error.warnings.forEach((w) =>
|
|
549
|
+
console.warn(`[${w.code}] ${w.path}: ${w.message}`)
|
|
550
|
+
);
|
|
295
551
|
} else if (error.name === "FFmpegError") {
|
|
296
552
|
console.error("FFmpeg failed:", error.stderr);
|
|
553
|
+
console.error("Command was:", error.command);
|
|
554
|
+
} else if (error.name === "MediaNotFoundError") {
|
|
555
|
+
console.error("File not found:", error.path);
|
|
297
556
|
} else if (error.name === "ExportCancelledError") {
|
|
298
557
|
console.log("Export was cancelled");
|
|
299
558
|
}
|
|
@@ -353,6 +612,36 @@ await project.load([
|
|
|
353
612
|
]);
|
|
354
613
|
```
|
|
355
614
|
|
|
615
|
+
### Text Positioning with Offsets
|
|
616
|
+
|
|
617
|
+
Text is centered by default. Use `xOffset` and `yOffset` to adjust position relative to any base:
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
await project.load([
|
|
621
|
+
{ type: "video", url: "./bg.mp4", position: 0, end: 10 },
|
|
622
|
+
// Title: centered, 100px above center
|
|
623
|
+
{
|
|
624
|
+
type: "text",
|
|
625
|
+
text: "Main Title",
|
|
626
|
+
position: 0,
|
|
627
|
+
end: 5,
|
|
628
|
+
fontSize: 72,
|
|
629
|
+
yOffset: -100,
|
|
630
|
+
},
|
|
631
|
+
// Subtitle: centered, 50px below center
|
|
632
|
+
{
|
|
633
|
+
type: "text",
|
|
634
|
+
text: "Subtitle here",
|
|
635
|
+
position: 0.5,
|
|
636
|
+
end: 5,
|
|
637
|
+
fontSize: 36,
|
|
638
|
+
yOffset: 50,
|
|
639
|
+
},
|
|
640
|
+
]);
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Offsets work with all positioning methods (`x`/`y` pixels, `xPercent`/`yPercent`, or default center).
|
|
644
|
+
|
|
356
645
|
### Word-by-Word Text Animation
|
|
357
646
|
|
|
358
647
|
```ts
|
|
@@ -401,6 +690,8 @@ await project.load([
|
|
|
401
690
|
]);
|
|
402
691
|
```
|
|
403
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
|
+
|
|
404
695
|
### Export with Progress Tracking
|
|
405
696
|
|
|
406
697
|
```ts
|
|
@@ -503,12 +794,382 @@ await project.export({
|
|
|
503
794
|
});
|
|
504
795
|
```
|
|
505
796
|
|
|
797
|
+
### Typewriter Text Effect
|
|
798
|
+
|
|
799
|
+
```ts
|
|
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
|
+
]);
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
### Pulsing Text Effect
|
|
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
|
+
```
|
|
837
|
+
|
|
838
|
+
### Karaoke Text Effect
|
|
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:
|
|
860
|
+
|
|
861
|
+
```ts
|
|
862
|
+
await project.load([
|
|
863
|
+
{ type: "video", url: "./music-video.mp4", position: 0, end: 10 },
|
|
864
|
+
{
|
|
865
|
+
type: "text",
|
|
866
|
+
mode: "karaoke",
|
|
867
|
+
text: "Never gonna give you up",
|
|
868
|
+
position: 0,
|
|
869
|
+
end: 5,
|
|
870
|
+
words: [
|
|
871
|
+
{ text: "Never", start: 0, end: 0.8 },
|
|
872
|
+
{ text: "gonna", start: 0.8, end: 1.4 },
|
|
873
|
+
{ text: "give", start: 1.4, end: 2.0 },
|
|
874
|
+
{ text: "you", start: 2.0, end: 2.5 },
|
|
875
|
+
{ text: "up", start: 2.5, end: 3.5 },
|
|
876
|
+
],
|
|
877
|
+
fontColor: "#FFFFFF",
|
|
878
|
+
highlightColor: "#00FF00",
|
|
879
|
+
fontSize: 52,
|
|
880
|
+
},
|
|
881
|
+
]);
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
With instant highlight (words change color immediately instead of gradual fill):
|
|
885
|
+
|
|
886
|
+
```ts
|
|
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
|
|
946
|
+
|
|
947
|
+
Add existing subtitle files to your video:
|
|
948
|
+
|
|
949
|
+
```ts
|
|
950
|
+
await project.load([
|
|
951
|
+
{ type: "video", url: "./video.mp4", position: 0, end: 60 },
|
|
952
|
+
{
|
|
953
|
+
type: "subtitle",
|
|
954
|
+
url: "./subtitles.srt", // or .vtt, .ass, .ssa
|
|
955
|
+
fontSize: 24,
|
|
956
|
+
fontColor: "#FFFFFF",
|
|
957
|
+
borderColor: "#000000",
|
|
958
|
+
},
|
|
959
|
+
]);
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
With time offset (shift subtitles forward):
|
|
963
|
+
|
|
964
|
+
```ts
|
|
965
|
+
await project.load([
|
|
966
|
+
{ type: "video", url: "./video.mp4", position: 0, end: 60 },
|
|
967
|
+
{
|
|
968
|
+
type: "subtitle",
|
|
969
|
+
url: "./subtitles.srt",
|
|
970
|
+
position: 2.5, // Delay subtitles by 2.5 seconds
|
|
971
|
+
},
|
|
972
|
+
]);
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
### Using Platform Presets
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
// Create a TikTok-optimized video
|
|
979
|
+
const tiktok = new SIMPLEFFMPEG({ preset: "tiktok" });
|
|
980
|
+
|
|
981
|
+
await tiktok.load([
|
|
982
|
+
{ type: "video", url: "./vertical.mp4", position: 0, end: 15 },
|
|
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
|
+
]);
|
|
994
|
+
|
|
995
|
+
await tiktok.export({
|
|
996
|
+
outputPath: "./tiktok-video.mp4",
|
|
997
|
+
watermark: {
|
|
998
|
+
type: "text",
|
|
999
|
+
text: "@myhandle",
|
|
1000
|
+
position: "bottom-right",
|
|
1001
|
+
opacity: 0.7,
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
```
|
|
1005
|
+
|
|
506
1006
|
## Timeline Behavior
|
|
507
1007
|
|
|
508
1008
|
- Clip timing uses `[position, end)` intervals in seconds
|
|
509
1009
|
- Transitions create overlaps that reduce total duration
|
|
510
1010
|
- Background music is mixed after video transitions (unaffected by crossfades)
|
|
511
|
-
|
|
1011
|
+
|
|
1012
|
+
### Transition Compensation
|
|
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)
|
|
1019
|
+
|
|
1020
|
+
With multiple transitions, this compounds—10 clips with 0.5s transitions each would be ~4.5 seconds shorter than the sum of clip durations.
|
|
1021
|
+
|
|
1022
|
+
**Automatic Compensation (default):**
|
|
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.
|
|
1025
|
+
|
|
1026
|
+
```ts
|
|
1027
|
+
// Text will appear at the correct visual position even with transitions
|
|
1028
|
+
await project.load([
|
|
1029
|
+
{ type: "video", url: "./a.mp4", position: 0, end: 10 },
|
|
1030
|
+
{
|
|
1031
|
+
type: "video",
|
|
1032
|
+
url: "./b.mp4",
|
|
1033
|
+
position: 10,
|
|
1034
|
+
end: 20,
|
|
1035
|
+
transition: { type: "fade", duration: 1 },
|
|
1036
|
+
},
|
|
1037
|
+
{ type: "text", text: "Appears at 15s visual", position: 15, end: 18 },
|
|
1038
|
+
]);
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
**Disabling Compensation:**
|
|
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
|
|
1053
|
+
|
|
1054
|
+
FFmpeg's `filter_complex` has platform-specific length limits (Windows ~32KB, macOS ~1MB, Linux ~2MB). When text animations like typewriter create many filter nodes, the command can exceed these limits.
|
|
1055
|
+
|
|
1056
|
+
**simple-ffmpeg automatically handles this:**
|
|
1057
|
+
|
|
1058
|
+
1. **Auto-detection**: Before running FFmpeg, the library checks if the filter graph exceeds a safe 100KB limit
|
|
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
|
|
1061
|
+
|
|
1062
|
+
This happens transparently—you don't need to configure anything. For very complex projects, you can tune it manually:
|
|
1063
|
+
|
|
1064
|
+
```js
|
|
1065
|
+
await project.export({
|
|
1066
|
+
outputPath: "./output.mp4",
|
|
1067
|
+
// Lower this if you have many complex text animations
|
|
1068
|
+
textMaxNodesPerPass: 30, // default: 75
|
|
1069
|
+
// Intermediate encoding settings (used between passes)
|
|
1070
|
+
intermediateVideoCodec: "libx264", // default
|
|
1071
|
+
intermediateCrf: 18, // default (high quality)
|
|
1072
|
+
intermediatePreset: "veryfast", // default (fast encoding)
|
|
1073
|
+
});
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
**When batching activates:**
|
|
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
|
+
```
|
|
1087
|
+
|
|
1088
|
+
## Real-World Usage Patterns
|
|
1089
|
+
|
|
1090
|
+
### Data Pipeline Example
|
|
1091
|
+
|
|
1092
|
+
Generate videos programmatically from structured data (JSON, database, API, CMS):
|
|
1093
|
+
|
|
1094
|
+
```js
|
|
1095
|
+
const SIMPLEFFMPEG = require("simple-ffmpegjs");
|
|
1096
|
+
|
|
1097
|
+
// Your data source - could be database records, API response, etc.
|
|
1098
|
+
const quotes = [
|
|
1099
|
+
{
|
|
1100
|
+
text: "The only way to do great work is to love what you do.",
|
|
1101
|
+
author: "Steve Jobs",
|
|
1102
|
+
},
|
|
1103
|
+
{ text: "Move fast and break things.", author: "Mark Zuckerberg" },
|
|
1104
|
+
];
|
|
1105
|
+
|
|
1106
|
+
async function generateQuoteVideo(quote, outputPath) {
|
|
1107
|
+
const clips = [
|
|
1108
|
+
{ type: "video", url: "./backgrounds/default.mp4", position: 0, end: 5 },
|
|
1109
|
+
{
|
|
1110
|
+
type: "text",
|
|
1111
|
+
text: `"${quote.text}"`,
|
|
1112
|
+
position: 0.5,
|
|
1113
|
+
end: 4,
|
|
1114
|
+
fontSize: 42,
|
|
1115
|
+
fontColor: "#FFFFFF",
|
|
1116
|
+
yPercent: 0.4,
|
|
1117
|
+
animation: { type: "fade-in", in: 0.3 },
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
type: "text",
|
|
1121
|
+
text: `— ${quote.author}`,
|
|
1122
|
+
position: 1.5,
|
|
1123
|
+
end: 4.5,
|
|
1124
|
+
fontSize: 28,
|
|
1125
|
+
fontColor: "#CCCCCC",
|
|
1126
|
+
yPercent: 0.6,
|
|
1127
|
+
animation: { type: "fade-in", in: 0.3 },
|
|
1128
|
+
},
|
|
1129
|
+
];
|
|
1130
|
+
|
|
1131
|
+
const project = new SIMPLEFFMPEG({ preset: "tiktok" });
|
|
1132
|
+
await project.load(clips);
|
|
1133
|
+
return project.export({ outputPath });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Batch process all quotes
|
|
1137
|
+
for (const [i, quote] of quotes.entries()) {
|
|
1138
|
+
await generateQuoteVideo(quote, `./output/quote-${i + 1}.mp4`);
|
|
1139
|
+
}
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
### AI Generation with Validation Loop
|
|
1143
|
+
|
|
1144
|
+
The structured validation with error codes makes it easy to build AI feedback loops:
|
|
1145
|
+
|
|
1146
|
+
```js
|
|
1147
|
+
const SIMPLEFFMPEG = require("simple-ffmpegjs");
|
|
1148
|
+
|
|
1149
|
+
async function generateVideoWithAI(prompt) {
|
|
1150
|
+
let config = await ai.generateVideoConfig(prompt);
|
|
1151
|
+
let result = SIMPLEFFMPEG.validate(config, { skipFileChecks: true });
|
|
1152
|
+
let retries = 0;
|
|
1153
|
+
|
|
1154
|
+
// Let AI fix its own mistakes
|
|
1155
|
+
while (!result.valid && retries < 3) {
|
|
1156
|
+
// Feed structured errors back to AI for correction
|
|
1157
|
+
config = await ai.fixConfig(config, result.errors);
|
|
1158
|
+
result = SIMPLEFFMPEG.validate(config, { skipFileChecks: true });
|
|
1159
|
+
retries++;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (!result.valid) {
|
|
1163
|
+
throw new Error("AI failed to generate valid config");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const project = new SIMPLEFFMPEG({ width: 1080, height: 1920 });
|
|
1167
|
+
await project.load(config);
|
|
1168
|
+
return project.export({ outputPath: "./output.mp4" });
|
|
1169
|
+
}
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
Each validation error includes a `code` (e.g., `INVALID_TIMELINE`, `MISSING_REQUIRED`) and `path` (e.g., `clips[2].position`) for precise AI feedback.
|
|
512
1173
|
|
|
513
1174
|
## Testing
|
|
514
1175
|
|
|
@@ -538,7 +1199,7 @@ For visual verification of output quality, run the examples script which generat
|
|
|
538
1199
|
node examples/run-examples.js
|
|
539
1200
|
```
|
|
540
1201
|
|
|
541
|
-
This creates
|
|
1202
|
+
This creates example videos in `examples/output/` covering:
|
|
542
1203
|
|
|
543
1204
|
- Basic video concatenation
|
|
544
1205
|
- Crossfade transitions
|
|
@@ -551,6 +1212,17 @@ This creates 12 example videos in `examples/output/` covering:
|
|
|
551
1212
|
- Metadata embedding
|
|
552
1213
|
- Thumbnail generation
|
|
553
1214
|
- Complex multi-track compositions
|
|
1215
|
+
- Word-by-word text
|
|
1216
|
+
- Platform presets (TikTok, YouTube, etc.)
|
|
1217
|
+
- Typewriter text animation
|
|
1218
|
+
- Scale-in text animation
|
|
1219
|
+
- Pulse text animation
|
|
1220
|
+
- Fade-out text animation
|
|
1221
|
+
- Text watermarks
|
|
1222
|
+
- Image watermarks
|
|
1223
|
+
- Timed watermarks
|
|
1224
|
+
- Karaoke text (word-by-word highlighting)
|
|
1225
|
+
- SRT/VTT subtitle import
|
|
554
1226
|
|
|
555
1227
|
View the outputs to confirm everything renders correctly:
|
|
556
1228
|
|