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/README.md CHANGED
@@ -1,420 +1,578 @@
1
- # simple-ffmpeg 🎬
1
+ # simple-ffmpeg
2
2
 
3
- Simple lightweight Node.js helper around FFmpeg for quick video composition, transitions, audio mixing, and animated text overlays.
3
+ [![npm version](https://img.shields.io/npm/v/simple-ffmpegjs.svg)](https://www.npmjs.com/package/simple-ffmpegjs)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D16-brightgreen.svg)](https://nodejs.org)
4
6
 
5
- ## πŸ™Œ Credits
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
- Huge shoutout to the original inspiration for this library:
9
+ ## Example Output
8
10
 
9
- - John Chen (coldshower): https://github.com/coldshower
10
- - ezffmpeg: https://github.com/ezffmpeg/ezffmpeg
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
- This project builds on those ideas and extends them with a few opinionated defaults and features to make common video tasks easier.
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
- ## ✨ Why this project
19
+ ## Features
15
20
 
16
- Built for data pipelines: a tiny helper around FFmpeg that makes common edits trivialβ€”clip concatenation with transitions, flexible text overlays, images with Ken Burns effects, and reliable audio mixingβ€”without hiding FFmpeg. It favors safe defaults, scales to long scripts, and stays dependency‑free.
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
- - βœ… Simple API for building FFmpeg filter graphs
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
- ## βš™οΈ Requirements
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
- For text overlays with `drawtext`, use an FFmpeg build that includes libfreetype and fontconfig. Make sure a system font is present so `font=Sans` resolves, or provide `fontFile`. Minimal containers often lack fonts, so install one explicitly.
39
+ FFmpeg must be installed and available in your PATH:
48
40
 
49
- ### Examples:
41
+ ```bash
42
+ # macOS
43
+ brew install ffmpeg
50
44
 
51
- Debian/Ubuntu:
45
+ # Ubuntu/Debian
46
+ apt-get install ffmpeg
52
47
 
53
- ```bash
54
- apt-get update && apt-get install -y ffmpeg fontconfig fonts-dejavu-core
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
- ## πŸš€ Quick start
62
+ ## Quick Start
64
63
 
65
64
  ```js
66
- const SIMPLEFFMPEG = require("simple-ffmpegjs");
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
- - 🎞️ Two clips + fade transition + background music
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: "./a.mp4", position: 0, end: 5 },
74
+ { type: "video", url: "./intro.mp4", position: 0, end: 5 },
101
75
  {
102
76
  type: "video",
103
- url: "./b.mp4",
77
+ url: "./main.mp4",
104
78
  position: 5,
105
- end: 10,
106
- transition: { type: "fade-in", duration: 0.4 },
79
+ end: 15,
80
+ transition: { type: "fade", duration: 0.5 },
107
81
  },
108
- { type: "music", url: "./bgm.wav", volume: 0.18 },
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: "Static Title",
120
- position: 0.5,
121
- end: 2.5,
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
- - πŸ”€ Word-by-word replacement with fade-in
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
- - πŸ”  Word-by-word (auto) with pop-bounce
339
+ ## Examples
151
340
 
152
- ```js
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: "text",
156
- mode: "word-replace",
157
- text: "Alpha Beta Gamma Delta",
158
- position: 4.0,
159
- end: 6.0,
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
- - 🎧 Standalone audio overlay
356
+ ### Word-by-Word Text Animation
171
357
 
172
- ```js
358
+ ```ts
173
359
  await project.load([
174
- { type: "audio", url: "./vo.mp3", position: 0, end: 10, volume: 1 },
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
- - πŸ–ΌοΈ Images with Ken Burns (zoom + pan)
375
+ ### Image Slideshow with Ken Burns
179
376
 
180
- ```js
377
+ ```ts
181
378
  await project.load([
182
- // Zoom-in image (2s)
183
379
  {
184
380
  type: "image",
185
- url: "./img.png",
186
- position: 10,
187
- end: 12,
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: "./img.png",
194
- position: 12,
195
- end: 14,
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
- ## 🧠 Behavior (in short)
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
- new SIMPLEFFMPEG(options?: {
224
- fps?: number; // default 30
225
- width?: number; // default 1920
226
- height?: number; // default 1080
227
- validationMode?: 'warn' | 'strict'; // default 'warn'
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
- ### project.load(clips)
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.load(clips: Clip[]);
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
- #### Clip union
433
+ ### Hardware-Accelerated Export (macOS)
240
434
 
241
435
  ```ts
242
- type Clip = VideoClip | AudioClip | BackgroundMusicClip | ImageClip | TextClip;
436
+ await project.export({
437
+ outputPath: "./output.mp4",
438
+ hwaccel: "videotoolbox",
439
+ videoCodec: "h264_videotoolbox",
440
+ crf: 23,
441
+ });
243
442
  ```
244
443
 
245
- #### Video clip
444
+ ### Two-Pass Encoding for Target File Size
246
445
 
247
446
  ```ts
248
- interface VideoClip {
249
- type: "video";
250
- url: string; // input video file path/URL
251
- position: number; // timeline start (seconds)
252
- end: number; // timeline end (seconds)
253
- cutFrom?: number; // source offset (seconds), default 0
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
- Notes:
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
- interface AudioClip {
273
- type: "audio";
274
- url: string;
275
- position: number; // timeline start
276
- end: number; // timeline end
277
- cutFrom?: number; // default 0
278
- volume?: number; // default 1
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
- #### Background music clip
472
+ ### Audio-Only Export
283
473
 
284
474
  ```ts
285
- interface BackgroundMusicClip {
286
- type: "music" | "backgroundAudio";
287
- url: string;
288
- position?: number; // default 0
289
- end?: number; // default project duration (video timeline)
290
- cutFrom?: number; // default 0
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
- Notes:
483
+ ### Generate Thumbnail
296
484
 
297
- - Mixed after other audio and after acrossfades, so transition fades do not attenuate the background music.
298
- - If no videos exist, `end` defaults to the max provided among BGM clips.
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
- #### Text clip
496
+ ### Debug Export Command
301
497
 
302
498
  ```ts
303
- interface TextClip {
304
- type: "text";
305
- // Time window
306
- position: number; // start on timeline
307
- end: number; // end on timeline
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
- // Content & modes
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
- // Word timing (choose one form)
314
- words?: Array<{ text: string; start: number; end: number }>; // explicit per-word timing (absolute seconds)
315
- wordTimestamps?: number[]; // timestamps to split `text` by whitespace; N or N+1 entries
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
- // Font & styling
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
- // Positioning (center by default)
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
- // Animation (opt-in)
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
- Notes:
519
+ ```bash
520
+ # Run all tests
521
+ npm test
338
522
 
339
- - If both `words` and `wordTimestamps` are provided, `words` takes precedence.
340
- - For `wordTimestamps` with a single array: provide either per-word start times (end inferred by next start), or N+1 edge times; whitespace in `text` defines words.
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
- #### Image clip
526
+ # Run integration tests only
527
+ npm run test:integration
344
528
 
345
- ```ts
346
- interface ImageClip {
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
- Notes:
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
- Builds and runs the FFmpeg command. Returns the final `outputPath`.
535
+ For visual verification of output quality, run the examples script which generates test media and demonstrates all major features:
369
536
 
370
- ```ts
371
- await project.export(options?: {
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
- Behavior:
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
- ### Timeline semantics
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
- - Each clip contributes `[position, end)` to the timeline.
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
- ### Animations
392
-
393
- - `none` (default): plain text, no animation
394
- - `fade-in`: alpha 0 β†’ 1 over `in` seconds (e.g., 0.25–0.4)
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
- Tip: small `in` values (0.2–0.4s) feel snappy for word-by-word displays.
562
+ ## Contributing
400
563
 
401
- ## 🀝 Contributing
564
+ Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request.
402
565
 
403
- - PRs and issues welcome
404
- - Actively being worked on; I’ll review new contributions and iterate
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
- ## πŸ—ΊοΈ Roadmap
572
+ ## Credits
407
573
 
408
- - Visual gap handling (opt-in fillers): optional `fillVisualGaps: 'none' | 'black'` if requested
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
- ## πŸ“œ License
576
+ ## License
419
577
 
420
578
  MIT