simple-ffmpegjs 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,14 +24,14 @@
24
24
  - [API Reference](#api-reference)
25
25
  - [Constructor](#constructor)
26
26
  - [Methods](#methods)
27
- - [Clip Types](#clip-types)
27
+ - [Auto-Sequencing & Duration Shorthand](#auto-sequencing--duration-shorthand)
28
+ - [Clip Types](#clip-types) — Video, Image, Color, Effect, Text, Subtitle, Audio, Background Music
28
29
  - [Platform Presets](#platform-presets)
29
30
  - [Watermarks](#watermarks)
30
31
  - [Progress Information](#progress-information)
31
32
  - [Logging](#logging)
32
33
  - [Error Handling](#error-handling)
33
34
  - [Cancellation](#cancellation)
34
- - [Color Clips](#color-clips)
35
35
  - [Examples](#examples)
36
36
  - [Clips & Transitions](#clips--transitions)
37
37
  - [Text & Animations](#text--animations)
@@ -66,19 +66,26 @@ _Click to watch a "Wonders of the World" video created with simple-ffmpeg — co
66
66
 
67
67
  ## Features
68
68
 
69
+ **Video & Images**
69
70
  - **Video Concatenation** — Join multiple clips with optional xfade transitions
71
+ - **Image Support** — Ken Burns effects (zoom, pan) for static images
72
+ - **Color Clips** — Flat colors and gradients (linear, radial) as first-class timeline clips with full transition support
73
+
74
+ **Audio**
70
75
  - **Audio Mixing** — Layer audio tracks, voiceovers, and background music
76
+
77
+ **Overlays & Effects**
71
78
  - **Text Overlays** — Static, word-by-word, and cumulative text with animations
72
79
  - **Text Animations** — Typewriter, scale-in, pulse, fade effects
73
80
  - **Karaoke Mode** — Word-by-word highlighting with customizable colors
74
81
  - **Subtitle Import** — Load SRT, VTT, ASS/SSA subtitle files
75
82
  - **Watermarks** — Text or image overlays with positioning and timing control
83
+ - **Effect Clips** — Timed overlay effects (vignette, film grain, blur, color adjust, sepia, black & white, sharpen, chromatic aberration, letterbox) with fade-in/out envelopes
84
+
85
+ **Developer Experience**
76
86
  - **Platform Presets** — Quick configuration for TikTok, YouTube, Instagram, etc.
77
- - **Image Support** — Ken Burns effects (zoom, pan) for static images
78
87
  - **Progress Tracking** — Real-time export progress callbacks
79
88
  - **Cancellation** — AbortController support for stopping exports
80
- - **Color Clips** — Flat colors and gradients (linear, radial) as first-class timeline clips with full transition support
81
- - **Effect Clips** — Timed overlay effects (vignette, film grain, gaussian blur, color adjustment) with fade-in/out envelopes
82
89
  - **Auto-Batching** — Automatically splits complex filter graphs to avoid OS command limits
83
90
  - **Schema Export** — Generate a structured description of the clip format for documentation, code generation, or AI context
84
91
  - **Pre-Validation** — Validate clip configurations before processing with structured, machine-readable error codes
@@ -249,7 +256,7 @@ Available modules:
249
256
  | `audio` | Standalone audio clips |
250
257
  | `image` | Image clips, Ken Burns effects |
251
258
  | `color` | Color clips — flat colors, linear/radial gradients |
252
- | `effect` | Overlay adjustment effects — vignette, grain, blur, grading |
259
+ | `effect` | Overlay adjustment effects — vignette, grain, blur, color adjust, sepia, B&W, sharpen, chromatic aberration, letterbox |
253
260
  | `text` | Text overlays — all modes, animations, positioning, styling |
254
261
  | `subtitle` | Subtitle file import (SRT, VTT, ASS, SSA) |
255
262
  | `music` | Background music / background audio, looping |
@@ -288,9 +295,27 @@ new SIMPLEFFMPEG(options?: {
288
295
  fps?: number; // Frame rate (default: 30)
289
296
  validationMode?: 'warn' | 'strict'; // Validation behavior (default: 'warn')
290
297
  preset?: string; // Platform preset (e.g., 'tiktok', 'youtube', 'instagram-post')
298
+ fontFile?: string; // Default font file for all text clips (individual clips can override)
291
299
  })
292
300
  ```
293
301
 
302
+ When `fontFile` is set at the project level, every text clip (including karaoke) inherits it automatically. You can still override it on any individual clip:
303
+
304
+ ```js
305
+ const project = new SIMPLEFFMPEG({
306
+ preset: "tiktok",
307
+ fontFile: "./fonts/Montserrat-Bold.ttf", // applies to all text clips
308
+ });
309
+
310
+ await project.load([
311
+ { type: "video", url: "intro.mp4", position: 0, end: 10 },
312
+ // Uses the global font
313
+ { type: "text", text: "Hello!", position: 1, end: 4, fontSize: 72 },
314
+ // Overrides with a different font
315
+ { type: "text", text: "Special", position: 5, end: 8, fontFile: "./fonts/Italic.otf" },
316
+ ]);
317
+ ```
318
+
294
319
  ### Methods
295
320
 
296
321
  #### `project.load(clips)`
@@ -320,43 +345,6 @@ SIMPLEFFMPEG.getDuration(clips); // 14.5
320
345
 
321
346
  Useful for computing text overlay timings or background music end times before calling `load()`.
322
347
 
323
- **Duration and Auto-Sequencing:**
324
-
325
- For video, image, and audio clips, you can use shorthand to avoid specifying explicit `position` and `end` values:
326
-
327
- - **`duration`** — Use instead of `end`. The library computes `end = position + duration`. You cannot specify both `duration` and `end` on the same clip.
328
- - **Omit `position`** — The clip is placed immediately after the previous clip on its track. Video and image clips share the visual track; audio clips have their own track. The first clip defaults to `position: 0`.
329
-
330
- These can be combined:
331
-
332
- ```ts
333
- // Before: manual position/end for every clip
334
- await project.load([
335
- { type: "video", url: "./a.mp4", position: 0, end: 5 },
336
- { type: "video", url: "./b.mp4", position: 5, end: 10 },
337
- { type: "video", url: "./c.mp4", position: 10, end: 18, cutFrom: 3 },
338
- ]);
339
-
340
- // After: auto-sequencing + duration
341
- await project.load([
342
- { type: "video", url: "./a.mp4", duration: 5 },
343
- { type: "video", url: "./b.mp4", duration: 5 },
344
- { type: "video", url: "./c.mp4", duration: 8, cutFrom: 3 },
345
- ]);
346
- ```
347
-
348
- You can mix explicit and implicit positioning freely. Clips with explicit `position` are placed there; subsequent auto-sequenced clips follow from the last clip's end:
349
-
350
- ```ts
351
- await project.load([
352
- { type: "video", url: "./a.mp4", duration: 5 }, // position: 0, end: 5
353
- { type: "video", url: "./b.mp4", position: 10, end: 15 }, // explicit gap
354
- { type: "video", url: "./c.mp4", duration: 5 }, // position: 15, end: 20
355
- ]);
356
- ```
357
-
358
- Text clips always require an explicit `position` (they're overlays on specific moments). Background music and subtitle clips already have optional `position`/`end` with their own defaults.
359
-
360
348
  #### `SIMPLEFFMPEG.probe(filePath)`
361
349
 
362
350
  Probe a media file and return comprehensive metadata using ffprobe. Works with video, audio, and image files.
@@ -482,6 +470,43 @@ await project.preview(options?: ExportOptions): Promise<{
482
470
  }>
483
471
  ```
484
472
 
473
+ ### Auto-Sequencing & Duration Shorthand
474
+
475
+ For video, image, and audio clips, you can use shorthand to avoid specifying explicit `position` and `end` values:
476
+
477
+ - **`duration`** — Use instead of `end`. The library computes `end = position + duration`. You cannot specify both `duration` and `end` on the same clip.
478
+ - **Omit `position`** — The clip is placed immediately after the previous clip on its track. Video and image clips share the visual track; audio clips have their own track. The first clip defaults to `position: 0`.
479
+
480
+ These can be combined:
481
+
482
+ ```ts
483
+ // Before: manual position/end for every clip
484
+ await project.load([
485
+ { type: "video", url: "./a.mp4", position: 0, end: 5 },
486
+ { type: "video", url: "./b.mp4", position: 5, end: 10 },
487
+ { type: "video", url: "./c.mp4", position: 10, end: 18, cutFrom: 3 },
488
+ ]);
489
+
490
+ // After: auto-sequencing + duration
491
+ await project.load([
492
+ { type: "video", url: "./a.mp4", duration: 5 },
493
+ { type: "video", url: "./b.mp4", duration: 5 },
494
+ { type: "video", url: "./c.mp4", duration: 8, cutFrom: 3 },
495
+ ]);
496
+ ```
497
+
498
+ You can mix explicit and implicit positioning freely. Clips with explicit `position` are placed there; subsequent auto-sequenced clips follow from the last clip's end:
499
+
500
+ ```ts
501
+ await project.load([
502
+ { type: "video", url: "./a.mp4", duration: 5 }, // position: 0, end: 5
503
+ { type: "video", url: "./b.mp4", position: 10, end: 15 }, // explicit gap
504
+ { type: "video", url: "./c.mp4", duration: 5 }, // position: 15, end: 20
505
+ ]);
506
+ ```
507
+
508
+ Text clips always require an explicit `position` (they're overlays on specific moments). Background music and subtitle clips already have optional `position`/`end` with their own defaults.
509
+
485
510
  ### Clip Types
486
511
 
487
512
  #### Video Clip
@@ -504,47 +529,6 @@ await project.preview(options?: ExportOptions): Promise<{
504
529
 
505
530
  All [xfade transitions](https://trac.ffmpeg.org/wiki/Xfade) are supported.
506
531
 
507
- #### Audio Clip
508
-
509
- ```ts
510
- {
511
- type: "audio";
512
- url: string;
513
- position?: number; // Omit to auto-sequence after previous audio clip
514
- end?: number; // Use end OR duration, not both
515
- duration?: number; // Duration in seconds (alternative to end)
516
- cutFrom?: number;
517
- volume?: number;
518
- }
519
- ```
520
-
521
- #### Background Music
522
-
523
- ```ts
524
- {
525
- type: "music"; // or "backgroundAudio"
526
- url: string;
527
- position?: number; // default: 0
528
- end?: number; // default: project duration
529
- cutFrom?: number;
530
- volume?: number; // default: 0.2
531
- loop?: boolean; // Loop audio to fill video duration
532
- }
533
- ```
534
-
535
- Background music is mixed after transitions, so video crossfades won't affect its volume.
536
-
537
- **Looping Music:**
538
-
539
- If your music track is shorter than your video, enable looping:
540
-
541
- ```ts
542
- await project.load([
543
- { type: "video", url: "./video.mp4", position: 0, end: 120 },
544
- { type: "music", url: "./30s-track.mp3", volume: 0.3, loop: true },
545
- ]);
546
- ```
547
-
548
532
  #### Image Clip
549
533
 
550
534
  ```ts
@@ -575,6 +559,8 @@ await project.load([
575
559
 
576
560
  #### Color Clip
577
561
 
562
+ Color clips add flat colors or gradients as first-class visual elements. They support transitions, text overlays, and all the same timeline features as video and image clips. Use them for intros, outros, title cards, or anywhere you need a background.
563
+
578
564
  ```ts
579
565
  {
580
566
  type: "color";
@@ -593,6 +579,73 @@ await project.load([
593
579
  }
594
580
  ```
595
581
 
582
+ `color` accepts any valid FFmpeg color name or hex code:
583
+
584
+ ```ts
585
+ { type: "color", color: "navy", position: 0, end: 3 }
586
+ { type: "color", color: "#1a1a2e", position: 0, end: 3 }
587
+ ```
588
+
589
+ **Gradients:**
590
+
591
+ ```ts
592
+ // Linear gradient (vertical by default)
593
+ {
594
+ type: "color",
595
+ color: { type: "linear-gradient", colors: ["#0a0a2e", "#4a148c"] },
596
+ position: 0,
597
+ end: 4,
598
+ }
599
+
600
+ // Horizontal linear gradient
601
+ {
602
+ type: "color",
603
+ color: { type: "linear-gradient", colors: ["#e74c3c", "#f1c40f", "#2ecc71"], direction: "horizontal" },
604
+ position: 0,
605
+ end: 4,
606
+ }
607
+
608
+ // Radial gradient
609
+ {
610
+ type: "color",
611
+ color: { type: "radial-gradient", colors: ["#ff8c00", "#1a0000"] },
612
+ position: 0,
613
+ end: 3,
614
+ }
615
+ ```
616
+
617
+ **With transitions:**
618
+
619
+ ```ts
620
+ await project.load([
621
+ { type: "color", color: "black", position: 0, end: 3 },
622
+ {
623
+ type: "video",
624
+ url: "./main.mp4",
625
+ position: 3,
626
+ end: 8,
627
+ transition: { type: "fade", duration: 0.5 },
628
+ },
629
+ {
630
+ type: "color",
631
+ color: { type: "radial-gradient", colors: ["#2c3e50", "#000000"] },
632
+ position: 8,
633
+ end: 11,
634
+ transition: { type: "fade", duration: 0.5 },
635
+ },
636
+ {
637
+ type: "text",
638
+ text: "The End",
639
+ position: 8.5,
640
+ end: 10.5,
641
+ fontSize: 64,
642
+ fontColor: "white",
643
+ },
644
+ ]);
645
+ ```
646
+
647
+ > **Note:** Timeline gaps (periods with no visual content) always produce a validation error. If a gap is intentional, fill it with a `type: "color"` clip or adjust your clip positions to close the gap.
648
+
596
649
  #### Effect Clip
597
650
 
598
651
  Effects are overlay adjustment layers. They apply to the already-composed video
@@ -601,34 +654,30 @@ for a time window, and can ramp in/out smoothly (instead of appearing instantly)
601
654
  ```ts
602
655
  {
603
656
  type: "effect";
604
- effect: "vignette" | "filmGrain" | "gaussianBlur" | "colorAdjust";
657
+ effect: EffectName; // See table below
605
658
  position: number; // Required timeline start (seconds)
606
659
  end?: number; // Use end OR duration, not both
607
660
  duration?: number; // Duration in seconds (alternative to end)
608
661
  fadeIn?: number; // Optional smooth ramp-in (seconds)
609
662
  fadeOut?: number; // Optional smooth ramp-out (seconds)
610
- easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out"; // default: "linear"
611
- params: {
612
- amount?: number; // Blend amount 0..1 (default: 1)
613
-
614
- // vignette
615
- angle?: number; // radians
616
-
617
- // filmGrain
618
- temporal?: boolean; // default: true
619
-
620
- // gaussianBlur
621
- sigma?: number;
622
-
623
- // colorAdjust
624
- brightness?: number; // -1..1
625
- contrast?: number; // 0..3
626
- saturation?: number; // 0..3
627
- gamma?: number; // 0.1..10
628
- };
663
+ params: EffectParams; // Effect-specific parameters (see table below)
629
664
  }
630
665
  ```
631
666
 
667
+ All effects accept `params.amount` (0-1, default 1) to control the blend intensity. Additional per-effect parameters:
668
+
669
+ | Effect | Description | Extra Params |
670
+ |---|---|---|
671
+ | `vignette` | Darkened edges | `angle`: radians (default: PI/5) |
672
+ | `filmGrain` | Noise overlay | `strength`: noise intensity 0-1 (default: 0.35), `temporal`: boolean (default: true) |
673
+ | `gaussianBlur` | Gaussian blur | `sigma`: blur radius (default derived from amount) |
674
+ | `colorAdjust` | Color grading | `brightness`: -1..1, `contrast`: 0..3, `saturation`: 0..3, `gamma`: 0.1..10 |
675
+ | `sepia` | Warm vintage tone | — |
676
+ | `blackAndWhite` | Desaturate to grayscale | `contrast`: boost 0-3 (default: 1) |
677
+ | `sharpen` | Sharpen detail | `strength`: unsharp amount 0-3 (default: 1) |
678
+ | `chromaticAberration` | RGB channel split | `shift`: pixel offset 0-20 (default: 4) |
679
+ | `letterbox` | Cinematic bars | `size`: bar height as fraction of frame 0-0.5 (default: 0.12), `color`: string (default: "black") |
680
+
632
681
  #### Text Clip
633
682
 
634
683
  ```ts
@@ -698,6 +747,47 @@ Import external subtitle files (SRT, VTT, ASS/SSA):
698
747
  }
699
748
  ```
700
749
 
750
+ #### Audio Clip
751
+
752
+ ```ts
753
+ {
754
+ type: "audio";
755
+ url: string;
756
+ position?: number; // Omit to auto-sequence after previous audio clip
757
+ end?: number; // Use end OR duration, not both
758
+ duration?: number; // Duration in seconds (alternative to end)
759
+ cutFrom?: number;
760
+ volume?: number;
761
+ }
762
+ ```
763
+
764
+ #### Background Music
765
+
766
+ ```ts
767
+ {
768
+ type: "music"; // or "backgroundAudio"
769
+ url: string;
770
+ position?: number; // default: 0
771
+ end?: number; // default: project duration
772
+ cutFrom?: number;
773
+ volume?: number; // default: 0.2
774
+ loop?: boolean; // Loop audio to fill video duration
775
+ }
776
+ ```
777
+
778
+ Background music is mixed after transitions, so video crossfades won't affect its volume.
779
+
780
+ **Looping Music:**
781
+
782
+ If your music track is shorter than your video, enable looping:
783
+
784
+ ```ts
785
+ await project.load([
786
+ { type: "video", url: "./video.mp4", position: 0, end: 120 },
787
+ { type: "music", url: "./30s-track.mp3", volume: 0.3, loop: true },
788
+ ]);
789
+ ```
790
+
701
791
  ### Platform Presets
702
792
 
703
793
  Use platform presets to quickly configure optimal dimensions for social media:
@@ -910,90 +1000,6 @@ try {
910
1000
  }
911
1001
  ```
912
1002
 
913
- ### Color Clips
914
-
915
- Color clips let you add flat colors or gradients as first-class visual elements in your timeline. They support transitions, text overlays, and all the same timeline features as video and image clips. Use them for intros, outros, title cards, or anywhere you need a background:
916
-
917
- **Flat color:**
918
-
919
- ```ts
920
- await project.load([
921
- // Black intro screen for 2 seconds
922
- { type: "color", color: "black", position: 0, end: 2 },
923
- // Video starts at 2s
924
- { type: "video", url: "./clip.mp4", position: 2, end: 7 },
925
- ]);
926
- ```
927
-
928
- `color` accepts any valid FFmpeg color name or hex code:
929
-
930
- ```ts
931
- { type: "color", color: "navy", position: 0, end: 3 }
932
- { type: "color", color: "#1a1a2e", position: 0, end: 3 }
933
- ```
934
-
935
- **Gradients:**
936
-
937
- ```ts
938
- // Linear gradient (vertical by default)
939
- {
940
- type: "color",
941
- color: { type: "linear-gradient", colors: ["#0a0a2e", "#4a148c"] },
942
- position: 0,
943
- end: 4,
944
- }
945
-
946
- // Horizontal linear gradient
947
- {
948
- type: "color",
949
- color: { type: "linear-gradient", colors: ["#e74c3c", "#f1c40f", "#2ecc71"], direction: "horizontal" },
950
- position: 0,
951
- end: 4,
952
- }
953
-
954
- // Radial gradient
955
- {
956
- type: "color",
957
- color: { type: "radial-gradient", colors: ["#ff8c00", "#1a0000"] },
958
- position: 0,
959
- end: 3,
960
- }
961
- ```
962
-
963
- **With transitions:**
964
-
965
- Color clips support the same `transition` property as video and image clips:
966
-
967
- ```ts
968
- await project.load([
969
- { type: "color", color: "black", position: 0, end: 3 },
970
- {
971
- type: "video",
972
- url: "./main.mp4",
973
- position: 3,
974
- end: 8,
975
- transition: { type: "fade", duration: 0.5 },
976
- },
977
- {
978
- type: "color",
979
- color: { type: "radial-gradient", colors: ["#2c3e50", "#000000"] },
980
- position: 8,
981
- end: 11,
982
- transition: { type: "fade", duration: 0.5 },
983
- },
984
- {
985
- type: "text",
986
- text: "The End",
987
- position: 8.5,
988
- end: 10.5,
989
- fontSize: 64,
990
- fontColor: "white",
991
- },
992
- ]);
993
- ```
994
-
995
- > **Note:** Timeline gaps (periods with no visual content) always produce a validation error. If a gap is intentional, fill it with a `type: "color"` clip or adjust your clip positions to close the gap.
996
-
997
1003
  ## Examples
998
1004
 
999
1005
  ### Clips & Transitions
@@ -1055,7 +1061,7 @@ await project.load([
1055
1061
  ]);
1056
1062
  ```
1057
1063
 
1058
- When `position` is omitted, clips are placed sequentially — each one starts where the previous one ended. `duration` is an alternative to `end`: the library computes `end = position + duration`. The explicit form (`position: 0, end: 3`) still works identically.
1064
+ When `position` is omitted, clips are placed sequentially — see [Auto-Sequencing & Duration Shorthand](#auto-sequencing--duration-shorthand) for details.
1059
1065
 
1060
1066
  > **Note:** Ken Burns effects work best with images at least as large as your output resolution. Smaller images are automatically upscaled (with a validation warning). Use `strictKenBurns: true` in validation options to enforce size requirements instead.
1061
1067
  > If you pass `width`/`height`, they override probed dimensions (useful for remote or generated images).
@@ -1533,7 +1539,7 @@ Available demo scripts (can also be run individually):
1533
1539
  | Script | What it tests |
1534
1540
  | ------------------------------- | -------------------------------------------------------------------------------------- |
1535
1541
  | `demo-color-clips.js` | Flat colors, linear/radial gradients, transitions, full composition with color clips |
1536
- | `demo-effects-pack-1.js` | Timed overlay effects (vignette, grain, blur, color adjustment) with smooth ramps |
1542
+ | `demo-effects.js` | Timed overlay effects (all 9 effects) with smooth fade ramps |
1537
1543
  | `demo-transitions.js` | Fade, wipe, slide, dissolve, fadeblack/white, short/long durations, image transitions |
1538
1544
  | `demo-text-and-animations.js` | Positioning, fade, pop, pop-bounce, typewriter, scale-in, pulse, styling, word-replace |
1539
1545
  | `demo-ken-burns.js` | All 6 presets, smart anchors, custom diagonal, slideshow with transitions |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-ffmpegjs",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Declarative video composition for Node.js — define clips, transitions, text, and audio as simple objects, and let FFmpeg handle the rest.",
5
5
  "author": "Brayden Blackwell <braydenblackwell21@gmail.com> (https://github.com/Fats403)",
6
6
  "license": "MIT",
@@ -106,8 +106,17 @@ function createIssue(code, path, message, received = undefined) {
106
106
  return issue;
107
107
  }
108
108
 
109
- const EFFECT_TYPES = ["vignette", "filmGrain", "gaussianBlur", "colorAdjust"];
110
- const EFFECT_EASING = ["linear", "ease-in", "ease-out", "ease-in-out"];
109
+ const EFFECT_TYPES = [
110
+ "vignette",
111
+ "filmGrain",
112
+ "gaussianBlur",
113
+ "colorAdjust",
114
+ "sepia",
115
+ "blackAndWhite",
116
+ "sharpen",
117
+ "chromaticAberration",
118
+ "letterbox",
119
+ ];
111
120
 
112
121
  function validateFiniteNumber(value, path, errors, opts = {}) {
113
122
  const { min = null, max = null, minInclusive = true, maxInclusive = true } = opts;
@@ -169,17 +178,6 @@ function validateEffectClip(clip, path, errors) {
169
178
  if (clip.fadeOut != null) {
170
179
  validateFiniteNumber(clip.fadeOut, `${path}.fadeOut`, errors, { min: 0 });
171
180
  }
172
- if (clip.easing != null && !EFFECT_EASING.includes(clip.easing)) {
173
- errors.push(
174
- createIssue(
175
- ValidationCodes.INVALID_VALUE,
176
- `${path}.easing`,
177
- `Invalid easing '${clip.easing}'. Expected: ${EFFECT_EASING.join(", ")}`,
178
- clip.easing
179
- )
180
- );
181
- }
182
-
183
181
  if (typeof clip.position === "number" && typeof clip.end === "number") {
184
182
  const duration = clip.end - clip.position;
185
183
  const fadeTotal = (clip.fadeIn || 0) + (clip.fadeOut || 0);
@@ -227,6 +225,12 @@ function validateEffectClip(clip, path, errors) {
227
225
  });
228
226
  }
229
227
  } else if (clip.effect === "filmGrain") {
228
+ if (params.strength != null) {
229
+ validateFiniteNumber(params.strength, `${path}.params.strength`, errors, {
230
+ min: 0,
231
+ max: 1,
232
+ });
233
+ }
230
234
  if (params.temporal != null && typeof params.temporal !== "boolean") {
231
235
  errors.push(
232
236
  createIssue(
@@ -269,6 +273,46 @@ function validateEffectClip(clip, path, errors) {
269
273
  max: 10,
270
274
  });
271
275
  }
276
+ } else if (clip.effect === "sepia") {
277
+ // sepia only uses amount (base param) — no extra params to validate
278
+ } else if (clip.effect === "blackAndWhite") {
279
+ if (params.contrast != null) {
280
+ validateFiniteNumber(params.contrast, `${path}.params.contrast`, errors, {
281
+ min: 0,
282
+ max: 3,
283
+ });
284
+ }
285
+ } else if (clip.effect === "sharpen") {
286
+ if (params.strength != null) {
287
+ validateFiniteNumber(params.strength, `${path}.params.strength`, errors, {
288
+ min: 0,
289
+ max: 3,
290
+ });
291
+ }
292
+ } else if (clip.effect === "chromaticAberration") {
293
+ if (params.shift != null) {
294
+ validateFiniteNumber(params.shift, `${path}.params.shift`, errors, {
295
+ min: 0,
296
+ max: 20,
297
+ });
298
+ }
299
+ } else if (clip.effect === "letterbox") {
300
+ if (params.size != null) {
301
+ validateFiniteNumber(params.size, `${path}.params.size`, errors, {
302
+ min: 0,
303
+ max: 0.5,
304
+ });
305
+ }
306
+ if (params.color != null && typeof params.color !== "string") {
307
+ errors.push(
308
+ createIssue(
309
+ ValidationCodes.INVALID_VALUE,
310
+ `${path}.params.color`,
311
+ "color must be a string",
312
+ params.color
313
+ )
314
+ );
315
+ }
272
316
  }
273
317
  }
274
318
 
@@ -43,15 +43,31 @@ function buildBackgroundMusicMix(
43
43
  const adelay = effectivePosition * 1000;
44
44
  const trimEnd = effectiveCutFrom + (effectiveEnd - effectivePosition);
45
45
  const outLabel = `[bg${i}]`;
46
- filter += `[${inputIndex}:a]volume=${effectiveVolume},atrim=start=${effectiveCutFrom}:end=${trimEnd},adelay=${adelay}|${adelay},asetpts=PTS-STARTPTS${outLabel};`;
46
+ filter += `[${inputIndex}:a]volume=${effectiveVolume},atrim=start=${effectiveCutFrom}:end=${trimEnd},asetpts=PTS-STARTPTS,adelay=${adelay}|${adelay}${outLabel};`;
47
47
  bgLabels.push(outLabel);
48
48
  });
49
49
 
50
50
  if (bgLabels.length > 0) {
51
51
  if (existingAudioLabel) {
52
- filter += `${existingAudioLabel}${bgLabels.join("")}amix=inputs=${
53
- bgLabels.length + 1
54
- }:duration=longest[finalaudio];`;
52
+ // Generate a silence anchor from time 0 so that amix starts producing
53
+ // output immediately. Without this, amix waits for ALL inputs to have
54
+ // frames before outputting — if the existing audio (e.g. delayed video
55
+ // audio) starts later on the timeline, background music is silenced
56
+ // until that point.
57
+ const anchorDur = Math.max(projectDuration, visualEnd || 0, 0.1);
58
+ const padLabel = "[_bgmpad]";
59
+ filter += `anullsrc=cl=stereo,atrim=end=${anchorDur}${padLabel};`;
60
+
61
+ // Use normalize=0 with explicit weights so the silence anchor
62
+ // contributes no audio energy while preserving the same volume
63
+ // balance as a direct amix of the real inputs.
64
+ const realCount = bgLabels.length + 1; // bgm tracks + existing audio
65
+ const w = (1 / realCount).toFixed(6);
66
+ const weights = ["0", ...Array(realCount).fill(w)].join(" ");
67
+
68
+ filter += `${padLabel}${existingAudioLabel}${bgLabels.join("")}amix=inputs=${
69
+ bgLabels.length + 2
70
+ }:duration=longest:weights='${weights}':normalize=0[finalaudio];`;
55
71
  return { filter, finalAudioLabel: "[finalaudio]", hasAudio: true };
56
72
  }
57
73
  filter += `${bgLabels.join("")}amix=inputs=${
@@ -24,11 +24,14 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
24
24
  }
25
25
 
26
26
  if (effectClip.effect === "filmGrain") {
27
- const grainStrength = clamp(
28
- (typeof params.amount === "number" ? params.amount : 0.35) * 100,
27
+ // `strength` controls noise intensity (0-1, default 0.35).
28
+ // `amount` is used purely for overlay blend alpha.
29
+ const strength = clamp(
30
+ typeof params.strength === "number" ? params.strength : 0.35,
29
31
  0,
30
- 100
32
+ 1
31
33
  );
34
+ const grainStrength = strength * 100;
32
35
  const flags = params.temporal === false ? "u" : "t+u";
33
36
  return {
34
37
  filter: `${inputLabel}noise=alls=${formatNumber(
@@ -53,24 +56,103 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
53
56
  };
54
57
  }
55
58
 
56
- // colorAdjust
57
- const brightness =
58
- typeof params.brightness === "number" ? params.brightness : 0;
59
- const contrast = typeof params.contrast === "number" ? params.contrast : 1;
60
- const saturation =
61
- typeof params.saturation === "number" ? params.saturation : 1;
62
- const gamma = typeof params.gamma === "number" ? params.gamma : 1;
63
-
64
- return {
65
- filter:
66
- `${inputLabel}eq=` +
67
- `brightness=${formatNumber(brightness, 4)}:` +
68
- `contrast=${formatNumber(contrast, 4)}:` +
69
- `saturation=${formatNumber(saturation, 4)}:` +
70
- `gamma=${formatNumber(gamma, 4)}` +
71
- `${outputLabel};`,
72
- amount,
73
- };
59
+ if (effectClip.effect === "colorAdjust") {
60
+ const brightness =
61
+ typeof params.brightness === "number" ? params.brightness : 0;
62
+ const contrast =
63
+ typeof params.contrast === "number" ? params.contrast : 1;
64
+ const saturation =
65
+ typeof params.saturation === "number" ? params.saturation : 1;
66
+ const gamma = typeof params.gamma === "number" ? params.gamma : 1;
67
+
68
+ return {
69
+ filter:
70
+ `${inputLabel}eq=` +
71
+ `brightness=${formatNumber(brightness, 4)}:` +
72
+ `contrast=${formatNumber(contrast, 4)}:` +
73
+ `saturation=${formatNumber(saturation, 4)}:` +
74
+ `gamma=${formatNumber(gamma, 4)}` +
75
+ `${outputLabel};`,
76
+ amount,
77
+ };
78
+ }
79
+
80
+ if (effectClip.effect === "sepia") {
81
+ // Classic sepia tone via color channel mixer matrix
82
+ return {
83
+ filter:
84
+ `${inputLabel}colorchannelmixer=` +
85
+ `.393:.769:.189:0:` +
86
+ `.349:.686:.168:0:` +
87
+ `.272:.534:.131:0` +
88
+ `${outputLabel};`,
89
+ amount,
90
+ };
91
+ }
92
+
93
+ if (effectClip.effect === "blackAndWhite") {
94
+ const contrast =
95
+ typeof params.contrast === "number" ? params.contrast : 1;
96
+ let chain = `${inputLabel}hue=s=0`;
97
+ if (contrast !== 1) {
98
+ chain += `,eq=contrast=${formatNumber(contrast, 4)}`;
99
+ }
100
+ return {
101
+ filter: `${chain}${outputLabel};`,
102
+ amount,
103
+ };
104
+ }
105
+
106
+ if (effectClip.effect === "sharpen") {
107
+ // strength controls the unsharp amount (0-3, default 1.0), 5x5 luma matrix
108
+ const strength = clamp(
109
+ typeof params.strength === "number" ? params.strength : 1.0,
110
+ 0,
111
+ 3
112
+ );
113
+ return {
114
+ filter: `${inputLabel}unsharp=5:5:${formatNumber(strength, 4)}${outputLabel};`,
115
+ amount,
116
+ };
117
+ }
118
+
119
+ if (effectClip.effect === "chromaticAberration") {
120
+ // shift is horizontal pixel offset for red/blue channels (default 4, range 0-20)
121
+ const shift = clamp(
122
+ typeof params.shift === "number" ? params.shift : 4,
123
+ 0,
124
+ 20
125
+ );
126
+ const shiftInt = Math.round(shift);
127
+ return {
128
+ filter: `${inputLabel}rgbashift=rh=${shiftInt}:bh=${-shiftInt}${outputLabel};`,
129
+ amount,
130
+ };
131
+ }
132
+
133
+ if (effectClip.effect === "letterbox") {
134
+ // size is bar height as fraction of frame height (default 0.12, range 0-0.5)
135
+ const size = clamp(
136
+ typeof params.size === "number" ? params.size : 0.12,
137
+ 0,
138
+ 0.5
139
+ );
140
+ const color = typeof params.color === "string" ? params.color : "black";
141
+ const barExpr = `round(ih*${formatNumber(size, 4)})`;
142
+ return {
143
+ filter:
144
+ `${inputLabel}` +
145
+ `drawbox=y=0:w=iw:h='${barExpr}':color=${color}:t=fill,` +
146
+ `drawbox=y='ih-${barExpr}':w=iw:h='${barExpr}':color=${color}:t=fill` +
147
+ `${outputLabel};`,
148
+ amount,
149
+ };
150
+ }
151
+
152
+ // Unknown effect — guard against silent fallthrough
153
+ throw new Error(
154
+ `Unknown effect type '${effectClip.effect}' in buildProcessedEffectFilter`
155
+ );
74
156
  }
75
157
 
76
158
  function buildEffectFilters(effectClips, inputLabel) {
package/src/loaders.js CHANGED
@@ -154,7 +154,7 @@ async function loadBackgroundAudio(project, clipObj) {
154
154
  function loadText(project, clipObj) {
155
155
  const clip = {
156
156
  ...clipObj,
157
- fontFile: clipObj.fontFile || null,
157
+ fontFile: clipObj.fontFile || project.options.fontFile || null,
158
158
  fontFamily: clipObj.fontFamily || C.DEFAULT_FONT_FAMILY,
159
159
  fontSize: clipObj.fontSize || C.DEFAULT_FONT_SIZE,
160
160
  fontColor: clipObj.fontColor || C.DEFAULT_FONT_COLOR,
@@ -180,7 +180,6 @@ function loadEffect(project, clipObj) {
180
180
  ...clipObj,
181
181
  fadeIn: typeof clipObj.fadeIn === "number" ? clipObj.fadeIn : 0,
182
182
  fadeOut: typeof clipObj.fadeOut === "number" ? clipObj.fadeOut : 0,
183
- easing: clipObj.easing || "linear",
184
183
  params: clipObj.params || {},
185
184
  };
186
185
  project.effectClips.push(clip);
@@ -5,23 +5,38 @@ module.exports = {
5
5
  "Overlay adjustment clips that apply timed visual effects to the composed video (they do not create visual content by themselves).",
6
6
  schema: `{
7
7
  type: "effect"; // Required: clip type identifier
8
- effect: "vignette" | "filmGrain" | "gaussianBlur" | "colorAdjust"; // Required: effect kind
8
+ effect: "vignette" | "filmGrain" | "gaussianBlur" | "colorAdjust"
9
+ | "sepia" | "blackAndWhite" | "sharpen" | "chromaticAberration"
10
+ | "letterbox"; // Required: effect kind
9
11
  position: number; // Required: start time on timeline (seconds)
10
12
  end?: number; // End time on timeline (seconds). Use end OR duration, not both.
11
13
  duration?: number; // Duration in seconds (alternative to end). end = position + duration.
12
14
  fadeIn?: number; // Optional: seconds to ramp in from 0 to full intensity
13
15
  fadeOut?: number; // Optional: seconds to ramp out from full intensity to 0
14
- easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out"; // Optional envelope easing (default: "linear")
15
16
  params: EffectParams; // Required: effect-specific parameters
16
17
  }`,
17
18
  enums: {
18
- EffectType: ["vignette", "filmGrain", "gaussianBlur", "colorAdjust"],
19
- EffectEasing: ["linear", "ease-in", "ease-out", "ease-in-out"],
19
+ EffectType: [
20
+ "vignette",
21
+ "filmGrain",
22
+ "gaussianBlur",
23
+ "colorAdjust",
24
+ "sepia",
25
+ "blackAndWhite",
26
+ "sharpen",
27
+ "chromaticAberration",
28
+ "letterbox",
29
+ ],
20
30
  VignetteParams: `{ amount?: number; angle?: number; }`,
21
- FilmGrainParams: `{ amount?: number; temporal?: boolean; }`,
31
+ FilmGrainParams: `{ amount?: number; strength?: number; temporal?: boolean; }`,
22
32
  GaussianBlurParams: `{ amount?: number; sigma?: number; }`,
23
33
  ColorAdjustParams:
24
34
  `{ amount?: number; brightness?: number; contrast?: number; saturation?: number; gamma?: number; }`,
35
+ SepiaParams: `{ amount?: number; }`,
36
+ BlackAndWhiteParams: `{ amount?: number; contrast?: number; }`,
37
+ SharpenParams: `{ amount?: number; strength?: number; }`,
38
+ ChromaticAberrationParams: `{ amount?: number; shift?: number; }`,
39
+ LetterboxParams: `{ amount?: number; size?: number; color?: string; }`,
25
40
  },
26
41
  examples: [
27
42
  {
@@ -36,7 +51,7 @@ module.exports = {
36
51
  }`,
37
52
  },
38
53
  {
39
- label: "Film grain only during the middle section",
54
+ label: "Film grain with independent strength and blend",
40
55
  code: `{
41
56
  type: "effect",
42
57
  effect: "filmGrain",
@@ -44,8 +59,7 @@ module.exports = {
44
59
  end: 8,
45
60
  fadeIn: 0.4,
46
61
  fadeOut: 0.6,
47
- easing: "ease-in-out",
48
- params: { amount: 0.45, temporal: true }
62
+ params: { amount: 0.8, strength: 0.35, temporal: true }
49
63
  }`,
50
64
  },
51
65
  {
@@ -63,6 +77,38 @@ module.exports = {
63
77
  gamma: 1.04,
64
78
  brightness: -0.02
65
79
  }
80
+ }`,
81
+ },
82
+ {
83
+ label: "Warm sepia tone",
84
+ code: `{
85
+ type: "effect",
86
+ effect: "sepia",
87
+ position: 0,
88
+ duration: 5,
89
+ fadeIn: 0.5,
90
+ params: { amount: 0.85 }
91
+ }`,
92
+ },
93
+ {
94
+ label: "Black and white with contrast boost",
95
+ code: `{
96
+ type: "effect",
97
+ effect: "blackAndWhite",
98
+ position: 2,
99
+ end: 6,
100
+ params: { amount: 1, contrast: 1.3 }
101
+ }`,
102
+ },
103
+ {
104
+ label: "Cinematic letterbox bars",
105
+ code: `{
106
+ type: "effect",
107
+ effect: "letterbox",
108
+ position: 0,
109
+ duration: 8,
110
+ fadeIn: 0.8,
111
+ params: { size: 0.12 }
66
112
  }`,
67
113
  },
68
114
  ],
@@ -71,7 +117,8 @@ module.exports = {
71
117
  "Effects do not satisfy visual timeline continuity checks and do not fill gaps.",
72
118
  "Use duration instead of end to specify length: end = position + duration. Cannot use both.",
73
119
  "position is required for effect clips (no auto-sequencing).",
74
- "fadeIn/fadeOut are optional envelope controls that avoid abrupt on/off changes.",
120
+ "fadeIn/fadeOut are optional linear envelope controls that avoid abrupt on/off changes.",
75
121
  "params.amount is a normalized blend amount from 0 to 1 (default: 1).",
122
+ "filmGrain: use params.strength (0-1) for noise intensity, params.amount for blend alpha.",
76
123
  ],
77
124
  };
@@ -56,6 +56,7 @@ class SIMPLEFFMPEG {
56
56
  * @param {number} options.fps - Frames per second (default: 30)
57
57
  * @param {string} options.preset - Platform preset ('tiktok', 'youtube', 'instagram-post', etc.)
58
58
  * @param {string} options.validationMode - Validation behavior: 'warn' or 'strict' (default: 'warn')
59
+ * @param {string} options.fontFile - Default font file path (.ttf, .otf) applied to all text clips unless overridden per-clip
59
60
  *
60
61
  * @example
61
62
  * const project = new SIMPLEFFMPEG({ preset: 'tiktok' });
@@ -87,6 +88,7 @@ class SIMPLEFFMPEG {
87
88
  height: options.height || presetConfig.height || C.DEFAULT_HEIGHT,
88
89
  validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
89
90
  preset: options.preset || null,
91
+ fontFile: options.fontFile || null,
90
92
  };
91
93
  this.videoOrAudioClips = [];
92
94
  this.textClips = [];
package/types/index.d.mts CHANGED
@@ -246,8 +246,16 @@ declare namespace SIMPLEFFMPEG {
246
246
  transition?: { type: string; duration: number };
247
247
  }
248
248
 
249
- type EffectName = "vignette" | "filmGrain" | "gaussianBlur" | "colorAdjust";
250
- type EffectEasing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
249
+ type EffectName =
250
+ | "vignette"
251
+ | "filmGrain"
252
+ | "gaussianBlur"
253
+ | "colorAdjust"
254
+ | "sepia"
255
+ | "blackAndWhite"
256
+ | "sharpen"
257
+ | "chromaticAberration"
258
+ | "letterbox";
251
259
 
252
260
  interface EffectParamsBase {
253
261
  amount?: number;
@@ -258,6 +266,7 @@ declare namespace SIMPLEFFMPEG {
258
266
  }
259
267
 
260
268
  interface FilmGrainEffectParams extends EffectParamsBase {
269
+ strength?: number;
261
270
  temporal?: boolean;
262
271
  }
263
272
 
@@ -272,11 +281,35 @@ declare namespace SIMPLEFFMPEG {
272
281
  gamma?: number;
273
282
  }
274
283
 
284
+ interface SepiaEffectParams extends EffectParamsBase {}
285
+
286
+ interface BlackAndWhiteEffectParams extends EffectParamsBase {
287
+ contrast?: number;
288
+ }
289
+
290
+ interface SharpenEffectParams extends EffectParamsBase {
291
+ strength?: number;
292
+ }
293
+
294
+ interface ChromaticAberrationEffectParams extends EffectParamsBase {
295
+ shift?: number;
296
+ }
297
+
298
+ interface LetterboxEffectParams extends EffectParamsBase {
299
+ size?: number;
300
+ color?: string;
301
+ }
302
+
275
303
  type EffectParams =
276
304
  | VignetteEffectParams
277
305
  | FilmGrainEffectParams
278
306
  | GaussianBlurEffectParams
279
- | ColorAdjustEffectParams;
307
+ | ColorAdjustEffectParams
308
+ | SepiaEffectParams
309
+ | BlackAndWhiteEffectParams
310
+ | SharpenEffectParams
311
+ | ChromaticAberrationEffectParams
312
+ | LetterboxEffectParams;
280
313
 
281
314
  /** Effect clip — timed overlay adjustment layer over composed video */
282
315
  interface EffectClip {
@@ -287,7 +320,6 @@ declare namespace SIMPLEFFMPEG {
287
320
  duration?: number;
288
321
  fadeIn?: number;
289
322
  fadeOut?: number;
290
- easing?: EffectEasing;
291
323
  params: EffectParams;
292
324
  }
293
325
 
@@ -389,6 +421,8 @@ declare namespace SIMPLEFFMPEG {
389
421
  height?: number;
390
422
  /** Validation mode: 'warn' logs warnings, 'strict' throws on warnings (default: 'warn') */
391
423
  validationMode?: "warn" | "strict";
424
+ /** Default font file path (.ttf, .otf) applied to all text clips. Individual clips can override this with their own fontFile. */
425
+ fontFile?: string;
392
426
  }
393
427
 
394
428
  /** Log entry passed to onLog callback */
package/types/index.d.ts CHANGED
@@ -246,8 +246,16 @@ declare namespace SIMPLEFFMPEG {
246
246
  transition?: { type: string; duration: number };
247
247
  }
248
248
 
249
- type EffectName = "vignette" | "filmGrain" | "gaussianBlur" | "colorAdjust";
250
- type EffectEasing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
249
+ type EffectName =
250
+ | "vignette"
251
+ | "filmGrain"
252
+ | "gaussianBlur"
253
+ | "colorAdjust"
254
+ | "sepia"
255
+ | "blackAndWhite"
256
+ | "sharpen"
257
+ | "chromaticAberration"
258
+ | "letterbox";
251
259
 
252
260
  interface EffectParamsBase {
253
261
  /** Base blend amount from 0 to 1 (default: 1) */
@@ -260,6 +268,8 @@ declare namespace SIMPLEFFMPEG {
260
268
  }
261
269
 
262
270
  interface FilmGrainEffectParams extends EffectParamsBase {
271
+ /** Noise intensity 0-1 (default: 0.35). Independent from blend amount. */
272
+ strength?: number;
263
273
  /** Temporal grain changes every frame (default: true) */
264
274
  temporal?: boolean;
265
275
  }
@@ -276,11 +286,40 @@ declare namespace SIMPLEFFMPEG {
276
286
  gamma?: number;
277
287
  }
278
288
 
289
+ interface SepiaEffectParams extends EffectParamsBase {}
290
+
291
+ interface BlackAndWhiteEffectParams extends EffectParamsBase {
292
+ /** Optional contrast boost (default: 1, range 0-3) */
293
+ contrast?: number;
294
+ }
295
+
296
+ interface SharpenEffectParams extends EffectParamsBase {
297
+ /** Unsharp amount (default: 1.0, range 0-3) */
298
+ strength?: number;
299
+ }
300
+
301
+ interface ChromaticAberrationEffectParams extends EffectParamsBase {
302
+ /** Horizontal pixel offset for R/B channels (default: 4, range 0-20) */
303
+ shift?: number;
304
+ }
305
+
306
+ interface LetterboxEffectParams extends EffectParamsBase {
307
+ /** Bar height as fraction of frame height (default: 0.12, range 0-0.5) */
308
+ size?: number;
309
+ /** Bar color (default: "black") */
310
+ color?: string;
311
+ }
312
+
279
313
  type EffectParams =
280
314
  | VignetteEffectParams
281
315
  | FilmGrainEffectParams
282
316
  | GaussianBlurEffectParams
283
- | ColorAdjustEffectParams;
317
+ | ColorAdjustEffectParams
318
+ | SepiaEffectParams
319
+ | BlackAndWhiteEffectParams
320
+ | SharpenEffectParams
321
+ | ChromaticAberrationEffectParams
322
+ | LetterboxEffectParams;
284
323
 
285
324
  /** Effect clip — timed overlay adjustment layer over composed video */
286
325
  interface EffectClip {
@@ -296,8 +335,6 @@ declare namespace SIMPLEFFMPEG {
296
335
  fadeIn?: number;
297
336
  /** Ramp-out duration in seconds */
298
337
  fadeOut?: number;
299
- /** Envelope easing for fade ramps */
300
- easing?: EffectEasing;
301
338
  /** Effect-specific params */
302
339
  params: EffectParams;
303
340
  }
@@ -400,6 +437,8 @@ declare namespace SIMPLEFFMPEG {
400
437
  height?: number;
401
438
  /** Validation mode: 'warn' logs warnings, 'strict' throws on warnings (default: 'warn') */
402
439
  validationMode?: "warn" | "strict";
440
+ /** Default font file path (.ttf, .otf) applied to all text clips. Individual clips can override this with their own fontFile. */
441
+ fontFile?: string;
403
442
  }
404
443
 
405
444
  /** Log entry passed to onLog callback */