simple-ffmpegjs 0.1.0 β 0.2.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 +1 -1
- package/README.md +453 -295
- package/assets/example-thumbnail.jpg +0 -0
- package/index.cjs +2 -0
- package/index.mjs +2 -0
- package/package.json +64 -22
- package/src/core/constants.js +39 -1
- package/src/core/errors.js +64 -0
- package/src/core/gaps.js +81 -0
- package/src/core/validation.js +30 -26
- package/src/ffmpeg/command_builder.js +168 -8
- package/src/ffmpeg/text_renderer.js +10 -4
- package/src/ffmpeg/video_builder.js +47 -2
- package/src/lib/utils.js +200 -1
- package/src/loaders.js +4 -4
- package/src/simpleffmpeg.js +493 -237
- package/types/index.d.mts +342 -0
- package/types/index.d.ts +399 -116
- package/index.js +0 -1
package/README.md
CHANGED
|
@@ -1,420 +1,578 @@
|
|
|
1
|
-
# simple-ffmpeg
|
|
1
|
+
# simple-ffmpeg
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/simple-ffmpegjs)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org)
|
|
4
6
|
|
|
5
|
-
|
|
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.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
## Example Output
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/wonders-of-the-world.mp4">
|
|
13
|
+
<img src="assets/example-thumbnail.jpg" alt="Example video - click to watch" width="640">
|
|
14
|
+
</a>
|
|
15
|
+
</p>
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
_Click to watch a "Wonders of the World" video created with simple-ffmpeg β combining multiple video clips with crossfade transitions, animated text overlays, and background music._
|
|
13
18
|
|
|
14
|
-
##
|
|
19
|
+
## Features
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
- **Video Concatenation** β Join multiple clips with optional xfade transitions
|
|
22
|
+
- **Audio Mixing** β Layer audio tracks, voiceovers, and background music
|
|
23
|
+
- **Text Overlays** β Static, word-by-word, and cumulative text with animations
|
|
24
|
+
- **Image Support** β Ken Burns effects (zoom, pan) for static images
|
|
25
|
+
- **Progress Tracking** β Real-time export progress callbacks
|
|
26
|
+
- **Cancellation** β AbortController support for stopping exports
|
|
27
|
+
- **Gap Handling** β Optional black frame fill for timeline gaps
|
|
28
|
+
- **TypeScript Ready** β Full type definitions included
|
|
29
|
+
- **Zero Dependencies** β Only requires FFmpeg on your system
|
|
17
30
|
|
|
18
|
-
|
|
19
|
-
- ποΈ Concatenate clips with optional transitions (xfade)
|
|
20
|
-
- π Mix multiple audio sources and add background music (not affected by transition fades)
|
|
21
|
-
- π Text overlays (static, word-by-word, cumulative) with opt-in animations (fade-in, pop, pop-bounce)
|
|
22
|
-
- π§° Safe defaults + guardrails (basic validation, media bounds clamping)
|
|
23
|
-
- π§± Scales to long scripts via optional multi-pass text batching
|
|
24
|
-
- π§© Ships TypeScript definitions without requiring TS
|
|
25
|
-
- πͺΆ No external libraries (other than FFmpeg), no bundled fonts; extremely lightweight
|
|
26
|
-
- π§βπ» Actively maintained; PRs and issues welcome
|
|
27
|
-
- πΌοΈ Image support with Ken Burns (zoom-in/out, pan-left/right/up/down)
|
|
28
|
-
|
|
29
|
-
## π¦ Install
|
|
31
|
+
## Installation
|
|
30
32
|
|
|
31
33
|
```bash
|
|
32
34
|
npm install simple-ffmpegjs
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Make sure you have ffmpeg installed on your system:
|
|
38
|
-
|
|
39
|
-
**Mac**: brew install ffmpeg
|
|
40
|
-
|
|
41
|
-
**Ubuntu/Debian**: apt-get install ffmpeg
|
|
42
|
-
|
|
43
|
-
**Windows**: Download from ffmpeg.org
|
|
44
|
-
|
|
45
|
-
Ensure `ffmpeg` and `ffprobe` are installed and available on your PATH.
|
|
37
|
+
### Prerequisites
|
|
46
38
|
|
|
47
|
-
|
|
39
|
+
FFmpeg must be installed and available in your PATH:
|
|
48
40
|
|
|
49
|
-
|
|
41
|
+
```bash
|
|
42
|
+
# macOS
|
|
43
|
+
brew install ffmpeg
|
|
50
44
|
|
|
51
|
-
Debian
|
|
45
|
+
# Ubuntu/Debian
|
|
46
|
+
apt-get install ffmpeg
|
|
52
47
|
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
# Windows
|
|
49
|
+
# Download from https://ffmpeg.org/download.html
|
|
55
50
|
```
|
|
56
51
|
|
|
57
|
-
Alpine:
|
|
52
|
+
For text overlays, ensure your FFmpeg build includes `libfreetype` and `fontconfig`. On minimal systems (Docker, Alpine), install a font package:
|
|
58
53
|
|
|
59
54
|
```bash
|
|
55
|
+
# Alpine
|
|
60
56
|
apk add --no-cache ffmpeg fontconfig ttf-dejavu
|
|
57
|
+
|
|
58
|
+
# Debian/Ubuntu
|
|
59
|
+
apt-get install -y ffmpeg fontconfig fonts-dejavu-core
|
|
61
60
|
```
|
|
62
61
|
|
|
63
|
-
##
|
|
62
|
+
## Quick Start
|
|
64
63
|
|
|
65
64
|
```js
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
(async () => {
|
|
69
|
-
const project = new SIMPLEFFMPEG({ width: 1080, height: 1920, fps: 30 });
|
|
70
|
-
|
|
71
|
-
await project.load([
|
|
72
|
-
{ type: "video", url: "./vids/a.mp4", position: 0, end: 5 },
|
|
73
|
-
{
|
|
74
|
-
type: "video",
|
|
75
|
-
url: "./vids/b.mp4",
|
|
76
|
-
position: 5,
|
|
77
|
-
end: 10,
|
|
78
|
-
transition: { type: "fade-in", duration: 0.5 },
|
|
79
|
-
},
|
|
80
|
-
{ type: "music", url: "./audio/bgm.wav", volume: 0.2 },
|
|
81
|
-
{
|
|
82
|
-
type: "text",
|
|
83
|
-
text: "Hello world",
|
|
84
|
-
position: 1,
|
|
85
|
-
end: 3,
|
|
86
|
-
fontColor: "white",
|
|
87
|
-
},
|
|
88
|
-
]);
|
|
89
|
-
|
|
90
|
-
await project.export({ outputPath: "./output.mp4" });
|
|
91
|
-
})();
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## π Examples
|
|
65
|
+
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
95
66
|
|
|
96
|
-
|
|
67
|
+
const project = new SIMPLEFFMPEG({
|
|
68
|
+
width: 1920,
|
|
69
|
+
height: 1080,
|
|
70
|
+
fps: 30,
|
|
71
|
+
});
|
|
97
72
|
|
|
98
|
-
```js
|
|
99
73
|
await project.load([
|
|
100
|
-
{ type: "video", url: "./
|
|
74
|
+
{ type: "video", url: "./intro.mp4", position: 0, end: 5 },
|
|
101
75
|
{
|
|
102
76
|
type: "video",
|
|
103
|
-
url: "./
|
|
77
|
+
url: "./main.mp4",
|
|
104
78
|
position: 5,
|
|
105
|
-
end:
|
|
106
|
-
transition: { type: "fade
|
|
79
|
+
end: 15,
|
|
80
|
+
transition: { type: "fade", duration: 0.5 },
|
|
107
81
|
},
|
|
108
|
-
{ type: "music", url: "./bgm.
|
|
109
|
-
]);
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
- π Static text (centered by default)
|
|
113
|
-
|
|
114
|
-
```js
|
|
115
|
-
await project.load([
|
|
116
|
-
{ type: "video", url: "./clip.mp4", position: 0, end: 5 },
|
|
82
|
+
{ type: "music", url: "./bgm.mp3", volume: 0.2 },
|
|
117
83
|
{
|
|
118
84
|
type: "text",
|
|
119
|
-
text: "
|
|
120
|
-
position:
|
|
121
|
-
end:
|
|
85
|
+
text: "Hello World",
|
|
86
|
+
position: 1,
|
|
87
|
+
end: 4,
|
|
122
88
|
fontColor: "white",
|
|
89
|
+
fontSize: 64,
|
|
123
90
|
},
|
|
124
91
|
]);
|
|
92
|
+
|
|
93
|
+
await project.export({
|
|
94
|
+
outputPath: "./output.mp4",
|
|
95
|
+
onProgress: ({ percent }) => console.log(`${percent}% complete`),
|
|
96
|
+
});
|
|
125
97
|
```
|
|
126
98
|
|
|
127
|
-
|
|
99
|
+
## API Reference
|
|
100
|
+
|
|
101
|
+
### Constructor
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
new SIMPLEFFMPEG(options?: {
|
|
105
|
+
width?: number; // Output width (default: 1920)
|
|
106
|
+
height?: number; // Output height (default: 1080)
|
|
107
|
+
fps?: number; // Frame rate (default: 30)
|
|
108
|
+
validationMode?: 'warn' | 'strict'; // Validation behavior (default: 'warn')
|
|
109
|
+
fillGaps?: 'none' | 'black'; // Gap handling (default: 'none')
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Methods
|
|
114
|
+
|
|
115
|
+
#### `project.load(clips)`
|
|
116
|
+
|
|
117
|
+
Load clip descriptors into the project. Validates the timeline and reads media metadata.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
await project.load(clips: Clip[]): Promise<void[]>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### `project.export(options)`
|
|
124
|
+
|
|
125
|
+
Build and execute the FFmpeg command to render the final video.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await project.export(options?: ExportOptions): Promise<string>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Export Options:**
|
|
132
|
+
|
|
133
|
+
| Option | Type | Default | Description |
|
|
134
|
+
| ------------------ | ------------- | ---------------- | -------------------------------------------------------------------------------- |
|
|
135
|
+
| `outputPath` | `string` | `'./output.mp4'` | Output file path |
|
|
136
|
+
| `videoCodec` | `string` | `'libx264'` | Video codec (`libx264`, `libx265`, `libvpx-vp9`, `prores_ks`, hardware encoders) |
|
|
137
|
+
| `crf` | `number` | `23` | Quality level (0-51, lower = better) |
|
|
138
|
+
| `preset` | `string` | `'medium'` | Encoding preset (`ultrafast` to `veryslow`) |
|
|
139
|
+
| `videoBitrate` | `string` | - | Target bitrate (e.g., `'5M'`). Overrides CRF. |
|
|
140
|
+
| `audioCodec` | `string` | `'aac'` | Audio codec (`aac`, `libmp3lame`, `libopus`, `flac`, `copy`) |
|
|
141
|
+
| `audioBitrate` | `string` | `'192k'` | Audio bitrate |
|
|
142
|
+
| `audioSampleRate` | `number` | `48000` | Audio sample rate in Hz |
|
|
143
|
+
| `hwaccel` | `string` | `'none'` | Hardware acceleration (`auto`, `videotoolbox`, `nvenc`, `vaapi`, `qsv`) |
|
|
144
|
+
| `outputWidth` | `number` | - | Scale output width |
|
|
145
|
+
| `outputHeight` | `number` | - | Scale output height |
|
|
146
|
+
| `outputResolution` | `string` | - | Resolution preset (`'720p'`, `'1080p'`, `'4k'`) |
|
|
147
|
+
| `audioOnly` | `boolean` | `false` | Export audio only (no video) |
|
|
148
|
+
| `twoPass` | `boolean` | `false` | Two-pass encoding for better quality |
|
|
149
|
+
| `metadata` | `object` | - | Embed metadata (title, artist, etc.) |
|
|
150
|
+
| `thumbnail` | `object` | - | Generate thumbnail image |
|
|
151
|
+
| `verbose` | `boolean` | `false` | Enable verbose logging |
|
|
152
|
+
| `saveCommand` | `string` | - | Save FFmpeg command to file |
|
|
153
|
+
| `onProgress` | `function` | - | Progress callback |
|
|
154
|
+
| `signal` | `AbortSignal` | - | Cancellation signal |
|
|
155
|
+
|
|
156
|
+
#### `project.preview(options)`
|
|
157
|
+
|
|
158
|
+
Get the FFmpeg command without executing it. Useful for debugging or dry runs.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
await project.preview(options?: ExportOptions): Promise<{
|
|
162
|
+
command: string; // Full FFmpeg command
|
|
163
|
+
filterComplex: string; // Filter graph
|
|
164
|
+
totalDuration: number; // Expected output duration
|
|
165
|
+
}>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Clip Types
|
|
169
|
+
|
|
170
|
+
#### Video Clip
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
{
|
|
174
|
+
type: "video";
|
|
175
|
+
url: string; // File path
|
|
176
|
+
position: number; // Timeline start (seconds)
|
|
177
|
+
end: number; // Timeline end (seconds)
|
|
178
|
+
cutFrom?: number; // Source offset (default: 0)
|
|
179
|
+
volume?: number; // Audio volume (default: 1)
|
|
180
|
+
transition?: {
|
|
181
|
+
type: string; // Any xfade transition (e.g., 'fade', 'wipeleft', 'dissolve')
|
|
182
|
+
duration: number; // Transition duration in seconds
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
All [xfade transitions](https://trac.ffmpeg.org/wiki/Xfade) are supported.
|
|
188
|
+
|
|
189
|
+
#### Audio Clip
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
{
|
|
193
|
+
type: "audio";
|
|
194
|
+
url: string;
|
|
195
|
+
position: number;
|
|
196
|
+
end: number;
|
|
197
|
+
cutFrom?: number;
|
|
198
|
+
volume?: number;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### Background Music
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
{
|
|
206
|
+
type: "music"; // or "backgroundAudio"
|
|
207
|
+
url: string;
|
|
208
|
+
position?: number; // default: 0
|
|
209
|
+
end?: number; // default: project duration
|
|
210
|
+
cutFrom?: number;
|
|
211
|
+
volume?: number; // default: 0.2
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Background music is mixed after transitions, so video crossfades won't affect its volume.
|
|
216
|
+
|
|
217
|
+
#### Image Clip
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
{
|
|
221
|
+
type: "image";
|
|
222
|
+
url: string;
|
|
223
|
+
position: number;
|
|
224
|
+
end: number;
|
|
225
|
+
kenBurns?: "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "pan-up" | "pan-down";
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### Text Clip
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
{
|
|
233
|
+
type: "text";
|
|
234
|
+
position: number;
|
|
235
|
+
end: number;
|
|
236
|
+
|
|
237
|
+
// Content
|
|
238
|
+
text?: string;
|
|
239
|
+
mode?: "static" | "word-replace" | "word-sequential";
|
|
240
|
+
words?: Array<{ text: string; start: number; end: number }>;
|
|
241
|
+
wordTimestamps?: number[];
|
|
242
|
+
|
|
243
|
+
// Styling
|
|
244
|
+
fontFile?: string; // Custom font file path
|
|
245
|
+
fontFamily?: string; // System font (default: 'Sans')
|
|
246
|
+
fontSize?: number; // default: 48
|
|
247
|
+
fontColor?: string; // default: '#FFFFFF'
|
|
248
|
+
borderColor?: string;
|
|
249
|
+
borderWidth?: number;
|
|
250
|
+
shadowColor?: string;
|
|
251
|
+
shadowX?: number;
|
|
252
|
+
shadowY?: number;
|
|
253
|
+
|
|
254
|
+
// Positioning
|
|
255
|
+
xPercent?: number; // Horizontal position as % (0 = left, 0.5 = center, 1 = right)
|
|
256
|
+
yPercent?: number; // Vertical position as % (0 = top, 0.5 = center, 1 = bottom)
|
|
257
|
+
x?: number; // Absolute X position in pixels
|
|
258
|
+
y?: number; // Absolute Y position in pixels
|
|
259
|
+
|
|
260
|
+
// Animation
|
|
261
|
+
animation?: {
|
|
262
|
+
type: "none" | "fade-in" | "fade-in-out" | "pop" | "pop-bounce";
|
|
263
|
+
in?: number; // Intro duration (seconds)
|
|
264
|
+
out?: number; // Outro duration (seconds)
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Progress Information
|
|
270
|
+
|
|
271
|
+
The `onProgress` callback receives:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
{
|
|
275
|
+
percent?: number; // 0-100
|
|
276
|
+
timeProcessed?: number; // Seconds processed
|
|
277
|
+
frame?: number; // Current frame
|
|
278
|
+
fps?: number; // Processing speed
|
|
279
|
+
speed?: number; // Multiplier (e.g., 2.0 = 2x realtime)
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Error Handling
|
|
284
|
+
|
|
285
|
+
The library exports custom error classes for better error handling:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import SIMPLEFFMPEG from "simple-ffmpegjs";
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await project.export({ outputPath: "./out.mp4" });
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error.name === "ValidationError") {
|
|
294
|
+
console.error("Invalid clips:", error.errors);
|
|
295
|
+
} else if (error.name === "FFmpegError") {
|
|
296
|
+
console.error("FFmpeg failed:", error.stderr);
|
|
297
|
+
} else if (error.name === "ExportCancelledError") {
|
|
298
|
+
console.log("Export was cancelled");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Cancellation
|
|
304
|
+
|
|
305
|
+
Use an `AbortController` to cancel an export in progress:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
|
|
310
|
+
// Cancel after 5 seconds
|
|
311
|
+
setTimeout(() => controller.abort(), 5000);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
await project.export({
|
|
315
|
+
outputPath: "./out.mp4",
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
});
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error.name === "ExportCancelledError") {
|
|
320
|
+
console.log("Cancelled");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Gap Handling
|
|
326
|
+
|
|
327
|
+
By default, timeline gaps (periods with no video/image content) throw a validation error. Enable automatic black frame fill:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
const project = new SIMPLEFFMPEG({
|
|
331
|
+
fillGaps: "black", // Fill gaps with black frames
|
|
332
|
+
});
|
|
128
333
|
|
|
129
|
-
```js
|
|
130
334
|
await project.load([
|
|
131
|
-
{
|
|
132
|
-
type: "text",
|
|
133
|
-
mode: "word-replace",
|
|
134
|
-
position: 2.0,
|
|
135
|
-
end: 4.0,
|
|
136
|
-
fontColor: "#00ffff",
|
|
137
|
-
centerX: 0,
|
|
138
|
-
centerY: -350,
|
|
139
|
-
animation: { type: "fade-in-out", in: 0.4, out: 0.4 },
|
|
140
|
-
words: [
|
|
141
|
-
{ text: "One", start: 2.0, end: 2.5 },
|
|
142
|
-
{ text: "Two", start: 2.5, end: 3.0 },
|
|
143
|
-
{ text: "Three", start: 3.0, end: 3.5 },
|
|
144
|
-
{ text: "Four", start: 3.5, end: 4.0 },
|
|
145
|
-
],
|
|
146
|
-
},
|
|
335
|
+
{ type: "video", url: "./clip.mp4", position: 2, end: 5 }, // Gap from 0-2s filled with black
|
|
147
336
|
]);
|
|
148
337
|
```
|
|
149
338
|
|
|
150
|
-
|
|
339
|
+
## Examples
|
|
151
340
|
|
|
152
|
-
|
|
341
|
+
### Two Clips with Transition
|
|
342
|
+
|
|
343
|
+
```ts
|
|
153
344
|
await project.load([
|
|
345
|
+
{ type: "video", url: "./a.mp4", position: 0, end: 5 },
|
|
154
346
|
{
|
|
155
|
-
type: "
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
fontSize: 64,
|
|
161
|
-
fontColor: "yellow",
|
|
162
|
-
centerX: 0,
|
|
163
|
-
centerY: -100,
|
|
164
|
-
animation: { type: "fade-in-out", in: 0.4, out: 0.4 },
|
|
165
|
-
wordTimestamps: [4.0, 4.5, 5.0, 5.5, 6.0],
|
|
347
|
+
type: "video",
|
|
348
|
+
url: "./b.mp4",
|
|
349
|
+
position: 5,
|
|
350
|
+
end: 10,
|
|
351
|
+
transition: { type: "fade", duration: 0.5 },
|
|
166
352
|
},
|
|
167
353
|
]);
|
|
168
354
|
```
|
|
169
355
|
|
|
170
|
-
-
|
|
356
|
+
### Word-by-Word Text Animation
|
|
171
357
|
|
|
172
|
-
```
|
|
358
|
+
```ts
|
|
173
359
|
await project.load([
|
|
174
|
-
{ type: "
|
|
360
|
+
{ type: "video", url: "./bg.mp4", position: 0, end: 10 },
|
|
361
|
+
{
|
|
362
|
+
type: "text",
|
|
363
|
+
mode: "word-replace",
|
|
364
|
+
text: "One Two Three Four",
|
|
365
|
+
position: 2,
|
|
366
|
+
end: 6,
|
|
367
|
+
wordTimestamps: [2, 3, 4, 5, 6],
|
|
368
|
+
animation: { type: "fade-in", in: 0.2 },
|
|
369
|
+
fontSize: 72,
|
|
370
|
+
fontColor: "white",
|
|
371
|
+
},
|
|
175
372
|
]);
|
|
176
373
|
```
|
|
177
374
|
|
|
178
|
-
|
|
375
|
+
### Image Slideshow with Ken Burns
|
|
179
376
|
|
|
180
|
-
```
|
|
377
|
+
```ts
|
|
181
378
|
await project.load([
|
|
182
|
-
// Zoom-in image (2s)
|
|
183
379
|
{
|
|
184
380
|
type: "image",
|
|
185
|
-
url: "./
|
|
186
|
-
position:
|
|
187
|
-
end:
|
|
381
|
+
url: "./photo1.jpg",
|
|
382
|
+
position: 0,
|
|
383
|
+
end: 3,
|
|
188
384
|
kenBurns: "zoom-in",
|
|
189
385
|
},
|
|
190
|
-
// Pan-right image (2s)
|
|
191
386
|
{
|
|
192
387
|
type: "image",
|
|
193
|
-
url: "./
|
|
194
|
-
position:
|
|
195
|
-
end:
|
|
388
|
+
url: "./photo2.jpg",
|
|
389
|
+
position: 3,
|
|
390
|
+
end: 6,
|
|
196
391
|
kenBurns: "pan-right",
|
|
197
392
|
},
|
|
393
|
+
{
|
|
394
|
+
type: "image",
|
|
395
|
+
url: "./photo3.jpg",
|
|
396
|
+
position: 6,
|
|
397
|
+
end: 9,
|
|
398
|
+
kenBurns: "zoom-out",
|
|
399
|
+
},
|
|
400
|
+
{ type: "music", url: "./music.mp3", volume: 0.3 },
|
|
198
401
|
]);
|
|
199
402
|
```
|
|
200
403
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
- Timeline uses clip `[position, end)`; transitions are overlaps that reduce total duration by their length
|
|
204
|
-
- Background music is mixed after other audio, so transition acrossfades donβt attenuate it
|
|
205
|
-
- Clip audio is timeline-aligned (absolute position) and mixed once; avoids early starts and gaps around transitions
|
|
206
|
-
- Text animations are opt-in (none by default)
|
|
207
|
-
- For big scripts, text rendering can be batched into multiple passes automatically
|
|
208
|
-
- Visual gaps are not allowed: if thereβs any gap with no video/image between clips (or at the very start), validation throws
|
|
209
|
-
|
|
210
|
-
## π API (at a glance)
|
|
211
|
-
|
|
212
|
-
- `new SIMPLEFFMPEG({ width?, height?, fps?, validationMode? })`
|
|
213
|
-
- `await project.load([...clips])` β video/audio/text/music descriptors
|
|
214
|
-
- `await project.export({ outputPath?, textMaxNodesPerPass? })`
|
|
215
|
-
|
|
216
|
-
Thatβs itβkeep it simple. See the examples above for common cases.
|
|
217
|
-
|
|
218
|
-
## π¬ API Details
|
|
219
|
-
|
|
220
|
-
### Constructor
|
|
404
|
+
### Export with Progress Tracking
|
|
221
405
|
|
|
222
406
|
```ts
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
407
|
+
await project.export({
|
|
408
|
+
outputPath: "./output.mp4",
|
|
409
|
+
onProgress: ({ percent, fps, speed }) => {
|
|
410
|
+
process.stdout.write(`\rRendering: ${percent}% (${fps} fps, ${speed}x)`);
|
|
411
|
+
},
|
|
228
412
|
});
|
|
229
413
|
```
|
|
230
414
|
|
|
231
|
-
###
|
|
232
|
-
|
|
233
|
-
Loads and pre-validates clips. Accepts an array of clip descriptors (video, audio, background music, text). Returns a Promise that resolves when inputs are prepared (e.g., metadata read, rotation handled later at export).
|
|
415
|
+
### High-Quality Export with Custom Settings
|
|
234
416
|
|
|
235
417
|
```ts
|
|
236
|
-
await project.
|
|
418
|
+
await project.export({
|
|
419
|
+
outputPath: "./output.mp4",
|
|
420
|
+
videoCodec: "libx265",
|
|
421
|
+
crf: 18, // Higher quality
|
|
422
|
+
preset: "slow", // Better compression
|
|
423
|
+
audioCodec: "libopus",
|
|
424
|
+
audioBitrate: "256k",
|
|
425
|
+
metadata: {
|
|
426
|
+
title: "My Video",
|
|
427
|
+
artist: "My Name",
|
|
428
|
+
date: "2024",
|
|
429
|
+
},
|
|
430
|
+
});
|
|
237
431
|
```
|
|
238
432
|
|
|
239
|
-
|
|
433
|
+
### Hardware-Accelerated Export (macOS)
|
|
240
434
|
|
|
241
435
|
```ts
|
|
242
|
-
|
|
436
|
+
await project.export({
|
|
437
|
+
outputPath: "./output.mp4",
|
|
438
|
+
hwaccel: "videotoolbox",
|
|
439
|
+
videoCodec: "h264_videotoolbox",
|
|
440
|
+
crf: 23,
|
|
441
|
+
});
|
|
243
442
|
```
|
|
244
443
|
|
|
245
|
-
|
|
444
|
+
### Two-Pass Encoding for Target File Size
|
|
246
445
|
|
|
247
446
|
```ts
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
volume?: number; // if the source has audio, default 1
|
|
255
|
-
transition?: {
|
|
256
|
-
// optional transition at the boundary before this clip
|
|
257
|
-
type: string; // e.g., 'fade', 'wipeleft', etc. (xfade transitions)
|
|
258
|
-
duration: number; // in seconds
|
|
259
|
-
};
|
|
260
|
-
}
|
|
447
|
+
await project.export({
|
|
448
|
+
outputPath: "./output.mp4",
|
|
449
|
+
twoPass: true,
|
|
450
|
+
videoBitrate: "5M", // Target bitrate
|
|
451
|
+
preset: "slow",
|
|
452
|
+
});
|
|
261
453
|
```
|
|
262
454
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
- All xfade transitions are supported you can see a list of them [here](https://trac.ffmpeg.org/wiki/Xfade)
|
|
266
|
-
- Each transition reduces total output duration by its duration (overlap semantics).
|
|
267
|
-
- Rotation metadata is handled automatically before export.
|
|
268
|
-
|
|
269
|
-
#### Audio clip (standalone)
|
|
455
|
+
### Scale Output Resolution
|
|
270
456
|
|
|
271
457
|
```ts
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
458
|
+
// Use resolution preset
|
|
459
|
+
await project.export({
|
|
460
|
+
outputPath: "./output-720p.mp4",
|
|
461
|
+
outputResolution: "720p",
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Or specify exact dimensions
|
|
465
|
+
await project.export({
|
|
466
|
+
outputPath: "./output-custom.mp4",
|
|
467
|
+
outputWidth: 1280,
|
|
468
|
+
outputHeight: 720,
|
|
469
|
+
});
|
|
280
470
|
```
|
|
281
471
|
|
|
282
|
-
|
|
472
|
+
### Audio-Only Export
|
|
283
473
|
|
|
284
474
|
```ts
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
volume?: number; // default 0.2
|
|
292
|
-
}
|
|
475
|
+
await project.export({
|
|
476
|
+
outputPath: "./audio.mp3",
|
|
477
|
+
audioOnly: true,
|
|
478
|
+
audioCodec: "libmp3lame",
|
|
479
|
+
audioBitrate: "320k",
|
|
480
|
+
});
|
|
293
481
|
```
|
|
294
482
|
|
|
295
|
-
|
|
483
|
+
### Generate Thumbnail
|
|
296
484
|
|
|
297
|
-
|
|
298
|
-
|
|
485
|
+
```ts
|
|
486
|
+
await project.export({
|
|
487
|
+
outputPath: "./output.mp4",
|
|
488
|
+
thumbnail: {
|
|
489
|
+
outputPath: "./thumbnail.jpg",
|
|
490
|
+
time: 5, // Capture at 5 seconds
|
|
491
|
+
width: 640,
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
```
|
|
299
495
|
|
|
300
|
-
|
|
496
|
+
### Debug Export Command
|
|
301
497
|
|
|
302
498
|
```ts
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
499
|
+
await project.export({
|
|
500
|
+
outputPath: "./output.mp4",
|
|
501
|
+
verbose: true, // Log export options
|
|
502
|
+
saveCommand: "./ffmpeg-command.txt", // Save command to file
|
|
503
|
+
});
|
|
504
|
+
```
|
|
308
505
|
|
|
309
|
-
|
|
310
|
-
text?: string; // used for 'static' and as source when auto-splitting words
|
|
311
|
-
mode?: "static" | "word-replace" | "word-sequential";
|
|
506
|
+
## Timeline Behavior
|
|
312
507
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
508
|
+
- Clip timing uses `[position, end)` intervals in seconds
|
|
509
|
+
- Transitions create overlaps that reduce total duration
|
|
510
|
+
- Background music is mixed after video transitions (unaffected by crossfades)
|
|
511
|
+
- Text with many nodes is automatically batched across multiple FFmpeg passes
|
|
316
512
|
|
|
317
|
-
|
|
318
|
-
fontFile?: string; // overrides fontFamily
|
|
319
|
-
fontFamily?: string; // default 'Sans' (fontconfig)
|
|
320
|
-
fontSize?: number; // default 48
|
|
321
|
-
fontColor?: string; // default '#FFFFFF'
|
|
513
|
+
## Testing
|
|
322
514
|
|
|
323
|
-
|
|
324
|
-
centerX?: number; // pixel offset from center (x)
|
|
325
|
-
centerY?: number; // pixel offset from center (y)
|
|
326
|
-
x?: number; // absolute x (left)
|
|
327
|
-
y?: number; // absolute y (top)
|
|
515
|
+
### Automated Tests
|
|
328
516
|
|
|
329
|
-
|
|
330
|
-
animation?: {
|
|
331
|
-
type: "none" | "fade-in" | "fade-in-out" | "pop" | "pop-bounce"; // default 'none'
|
|
332
|
-
in?: number; // seconds for intro phase (e.g., fade-in duration)
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
```
|
|
517
|
+
The library includes comprehensive unit and integration tests using Vitest:
|
|
336
518
|
|
|
337
|
-
|
|
519
|
+
```bash
|
|
520
|
+
# Run all tests
|
|
521
|
+
npm test
|
|
338
522
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
- Defaults to centered placement if no explicit `x/y` or `centerX/centerY` provided.
|
|
523
|
+
# Run unit tests only
|
|
524
|
+
npm run test:unit
|
|
342
525
|
|
|
343
|
-
|
|
526
|
+
# Run integration tests only
|
|
527
|
+
npm run test:integration
|
|
344
528
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
type: "image";
|
|
348
|
-
url: string;
|
|
349
|
-
position: number; // timeline start
|
|
350
|
-
end: number; // timeline end
|
|
351
|
-
kenBurns?:
|
|
352
|
-
| "zoom-in"
|
|
353
|
-
| "zoom-out"
|
|
354
|
-
| "pan-left"
|
|
355
|
-
| "pan-right"
|
|
356
|
-
| "pan-up"
|
|
357
|
-
| "pan-down";
|
|
358
|
-
}
|
|
529
|
+
# Run with watch mode
|
|
530
|
+
npm run test:watch
|
|
359
531
|
```
|
|
360
532
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
- Images are treated as video streams. Ken Burns uses `zoompan` internally with the correct frame count.
|
|
364
|
-
- For pan-only moves, a small base zoom is applied so thereβs room to pan across.
|
|
365
|
-
|
|
366
|
-
### project.export(options)
|
|
533
|
+
### Manual Verification
|
|
367
534
|
|
|
368
|
-
|
|
535
|
+
For visual verification of output quality, run the examples script which generates test media and demonstrates all major features:
|
|
369
536
|
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
outputPath?: string; // default './output.mp4'
|
|
373
|
-
textMaxNodesPerPass?: number; // default 75 (batch size for multi-pass text)
|
|
374
|
-
intermediateVideoCodec?: string; // default 'libx264' (for text passes)
|
|
375
|
-
intermediateCrf?: number; // default 18 (for text passes)
|
|
376
|
-
intermediatePreset?: string; // default 'veryfast' (for text passes)
|
|
377
|
-
}): Promise<string>;
|
|
537
|
+
```bash
|
|
538
|
+
node examples/run-examples.js
|
|
378
539
|
```
|
|
379
540
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
- If text overlay count exceeds `textMaxNodesPerPass`, text is rendered in multiple passes using temporary files; audio is copied between passes; final output is fast-start.
|
|
383
|
-
- Mapping: final video/audio streams are mapped based on what exists; if only audio or only video is present, mapping adapts accordingly.
|
|
541
|
+
This creates 12 example videos in `examples/output/` covering:
|
|
384
542
|
|
|
385
|
-
|
|
543
|
+
- Basic video concatenation
|
|
544
|
+
- Crossfade transitions
|
|
545
|
+
- Text overlays with animations
|
|
546
|
+
- Background music mixing
|
|
547
|
+
- Ken Burns effects on images
|
|
548
|
+
- Gap filling with black frames
|
|
549
|
+
- Quality settings (CRF, preset)
|
|
550
|
+
- Resolution scaling
|
|
551
|
+
- Metadata embedding
|
|
552
|
+
- Thumbnail generation
|
|
553
|
+
- Complex multi-track compositions
|
|
386
554
|
|
|
387
|
-
|
|
388
|
-
- For transitions, the overlap reduces the final output duration by the transition duration.
|
|
389
|
-
- Background music defaults to the visual timeline end (max `end` across video/image clips) and is mixed after other audio and acrossfades.
|
|
555
|
+
View the outputs to confirm everything renders correctly:
|
|
390
556
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
-
|
|
394
|
-
|
|
395
|
-
- `fade-in-out`: alpha 0 β 1 over `in` seconds, then 1 β 0 over `out` seconds approaching the end
|
|
396
|
-
- `pop`: font size scales from ~70% β 100% over `in` seconds
|
|
397
|
-
- `pop-bounce`: scales ~70% β 110% during `in`, then settles to 100%
|
|
557
|
+
```bash
|
|
558
|
+
open examples/output/ # macOS
|
|
559
|
+
xdg-open examples/output/ # Linux
|
|
560
|
+
```
|
|
398
561
|
|
|
399
|
-
|
|
562
|
+
## Contributing
|
|
400
563
|
|
|
401
|
-
|
|
564
|
+
Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request.
|
|
402
565
|
|
|
403
|
-
|
|
404
|
-
|
|
566
|
+
1. Fork the repository
|
|
567
|
+
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
568
|
+
3. Write tests for new functionality
|
|
569
|
+
4. Ensure all tests pass (`npm test`)
|
|
570
|
+
5. Submit a pull request
|
|
405
571
|
|
|
406
|
-
##
|
|
572
|
+
## Credits
|
|
407
573
|
|
|
408
|
-
|
|
409
|
-
- Additional text effects (typewriter, word-by-word fade-out variants, outlines/shadows presets)
|
|
410
|
-
- Image effects presets (Ken Burns paths presets, ease functions)
|
|
411
|
-
- Ken Burns upgrades: strength parameter, custom positioning, additional ease curves
|
|
412
|
-
- Optional audio transition coupling: tie clip audio fades to xfade boundaries
|
|
413
|
-
- Export options for different containers/codecs (HEVC, VP9/AV1, audio-only)
|
|
414
|
-
- Better error reporting with command dump helpers
|
|
415
|
-
- CLI wrapper for quick local use
|
|
416
|
-
- Performance: smarter batching and parallel intermediate renders
|
|
574
|
+
Inspired by [ezffmpeg](https://github.com/ezffmpeg/ezffmpeg) by John Chen.
|
|
417
575
|
|
|
418
|
-
##
|
|
576
|
+
## License
|
|
419
577
|
|
|
420
578
|
MIT
|