simple-ffmpegjs 0.2.0 → 0.3.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
@@ -2,15 +2,54 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/simple-ffmpegjs.svg)](https://www.npmjs.com/package/simple-ffmpegjs)
4
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)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](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-of-the-world.mp4">
13
- <img src="assets/example-thumbnail.jpg" alt="Example video - click to watch" width="640">
51
+ <a href="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/wonders-showcase.mp4">
52
+ <img src="https://7llpl63xkl8jovgt.public.blob.vercel-storage.com/wonders-thumbnail.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 | 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 |
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 exports custom error classes for better error handling:
530
+ The library provides custom error classes for structured error handling:
286
531
 
287
- ```ts
288
- import SIMPLEFFMPEG from "simple-ffmpegjs";
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
- console.error("Invalid clips:", error.errors);
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
- - Text with many nodes is automatically batched across multiple FFmpeg passes
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 12 example videos in `examples/output/` covering:
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