simple-ffmpegjs 0.4.0 → 0.4.2
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 +196 -190
- package/package.json +1 -1
- package/src/core/validation.js +116 -13
- package/src/ffmpeg/bgm_builder.js +20 -4
- package/src/ffmpeg/command_builder.js +53 -0
- package/src/ffmpeg/effect_builder.js +131 -22
- package/src/loaders.js +1 -2
- package/src/schema/modules/effect.js +56 -9
- package/src/simpleffmpeg.js +9 -0
- package/types/index.d.mts +38 -4
- package/types/index.d.ts +44 -5
package/README.md
CHANGED
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
- [API Reference](#api-reference)
|
|
25
25
|
- [Constructor](#constructor)
|
|
26
26
|
- [Methods](#methods)
|
|
27
|
-
- [
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
package/src/core/validation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const nodePath = require("path");
|
|
2
3
|
const { detectVisualGaps } = require("./gaps");
|
|
3
4
|
|
|
4
5
|
// ========================================================================
|
|
@@ -106,8 +107,73 @@ function createIssue(code, path, message, received = undefined) {
|
|
|
106
107
|
return issue;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
const EFFECT_TYPES = [
|
|
110
|
-
|
|
110
|
+
const EFFECT_TYPES = [
|
|
111
|
+
"vignette",
|
|
112
|
+
"filmGrain",
|
|
113
|
+
"gaussianBlur",
|
|
114
|
+
"colorAdjust",
|
|
115
|
+
"sepia",
|
|
116
|
+
"blackAndWhite",
|
|
117
|
+
"sharpen",
|
|
118
|
+
"chromaticAberration",
|
|
119
|
+
"letterbox",
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const VIDEO_EXTENSIONS = new Set([
|
|
123
|
+
".mp4",
|
|
124
|
+
".mov",
|
|
125
|
+
".m4v",
|
|
126
|
+
".mkv",
|
|
127
|
+
".webm",
|
|
128
|
+
".avi",
|
|
129
|
+
".flv",
|
|
130
|
+
".wmv",
|
|
131
|
+
".mpg",
|
|
132
|
+
".mpeg",
|
|
133
|
+
".m2ts",
|
|
134
|
+
".mts",
|
|
135
|
+
".ts",
|
|
136
|
+
".3gp",
|
|
137
|
+
".ogv",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
141
|
+
".jpg",
|
|
142
|
+
".jpeg",
|
|
143
|
+
".png",
|
|
144
|
+
".webp",
|
|
145
|
+
".bmp",
|
|
146
|
+
".tif",
|
|
147
|
+
".tiff",
|
|
148
|
+
".gif",
|
|
149
|
+
".avif",
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
function validateMediaUrlExtension(clip, clipPath, errors) {
|
|
153
|
+
if (typeof clip.url !== "string" || clip.url.length === 0) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (clip.type !== "video" && clip.type !== "image") {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ext = nodePath.extname(clip.url).toLowerCase();
|
|
162
|
+
const expectedExts = clip.type === "video" ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
|
|
163
|
+
const expectedLabel = clip.type === "video" ? "video" : "image";
|
|
164
|
+
const oppositeLabel = clip.type === "video" ? "image" : "video";
|
|
165
|
+
|
|
166
|
+
if (!ext || !expectedExts.has(ext)) {
|
|
167
|
+
errors.push(
|
|
168
|
+
createIssue(
|
|
169
|
+
ValidationCodes.INVALID_FORMAT,
|
|
170
|
+
`${clipPath}.url`,
|
|
171
|
+
`URL extension '${ext || "(none)"}' does not match clip type '${clip.type}'. Expected a ${expectedLabel} file extension, not ${oppositeLabel}.`,
|
|
172
|
+
clip.url
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
111
177
|
|
|
112
178
|
function validateFiniteNumber(value, path, errors, opts = {}) {
|
|
113
179
|
const { min = null, max = null, minInclusive = true, maxInclusive = true } = opts;
|
|
@@ -169,17 +235,6 @@ function validateEffectClip(clip, path, errors) {
|
|
|
169
235
|
if (clip.fadeOut != null) {
|
|
170
236
|
validateFiniteNumber(clip.fadeOut, `${path}.fadeOut`, errors, { min: 0 });
|
|
171
237
|
}
|
|
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
238
|
if (typeof clip.position === "number" && typeof clip.end === "number") {
|
|
184
239
|
const duration = clip.end - clip.position;
|
|
185
240
|
const fadeTotal = (clip.fadeIn || 0) + (clip.fadeOut || 0);
|
|
@@ -227,6 +282,12 @@ function validateEffectClip(clip, path, errors) {
|
|
|
227
282
|
});
|
|
228
283
|
}
|
|
229
284
|
} else if (clip.effect === "filmGrain") {
|
|
285
|
+
if (params.strength != null) {
|
|
286
|
+
validateFiniteNumber(params.strength, `${path}.params.strength`, errors, {
|
|
287
|
+
min: 0,
|
|
288
|
+
max: 1,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
230
291
|
if (params.temporal != null && typeof params.temporal !== "boolean") {
|
|
231
292
|
errors.push(
|
|
232
293
|
createIssue(
|
|
@@ -269,6 +330,46 @@ function validateEffectClip(clip, path, errors) {
|
|
|
269
330
|
max: 10,
|
|
270
331
|
});
|
|
271
332
|
}
|
|
333
|
+
} else if (clip.effect === "sepia") {
|
|
334
|
+
// sepia only uses amount (base param) — no extra params to validate
|
|
335
|
+
} else if (clip.effect === "blackAndWhite") {
|
|
336
|
+
if (params.contrast != null) {
|
|
337
|
+
validateFiniteNumber(params.contrast, `${path}.params.contrast`, errors, {
|
|
338
|
+
min: 0,
|
|
339
|
+
max: 3,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
} else if (clip.effect === "sharpen") {
|
|
343
|
+
if (params.strength != null) {
|
|
344
|
+
validateFiniteNumber(params.strength, `${path}.params.strength`, errors, {
|
|
345
|
+
min: 0,
|
|
346
|
+
max: 3,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
} else if (clip.effect === "chromaticAberration") {
|
|
350
|
+
if (params.shift != null) {
|
|
351
|
+
validateFiniteNumber(params.shift, `${path}.params.shift`, errors, {
|
|
352
|
+
min: 0,
|
|
353
|
+
max: 20,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
} else if (clip.effect === "letterbox") {
|
|
357
|
+
if (params.size != null) {
|
|
358
|
+
validateFiniteNumber(params.size, `${path}.params.size`, errors, {
|
|
359
|
+
min: 0,
|
|
360
|
+
max: 0.5,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (params.color != null && typeof params.color !== "string") {
|
|
364
|
+
errors.push(
|
|
365
|
+
createIssue(
|
|
366
|
+
ValidationCodes.INVALID_VALUE,
|
|
367
|
+
`${path}.params.color`,
|
|
368
|
+
"color must be a string",
|
|
369
|
+
params.color
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
}
|
|
272
373
|
}
|
|
273
374
|
}
|
|
274
375
|
|
|
@@ -502,6 +603,8 @@ function validateClip(clip, index, options = {}) {
|
|
|
502
603
|
} catch (_) {}
|
|
503
604
|
}
|
|
504
605
|
|
|
606
|
+
validateMediaUrlExtension(clip, path, errors);
|
|
607
|
+
|
|
505
608
|
if (typeof clip.cutFrom === "number") {
|
|
506
609
|
if (!Number.isFinite(clip.cutFrom)) {
|
|
507
610
|
errors.push(
|
|
@@ -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}
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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=${
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const os = require("os");
|
|
2
2
|
const { escapeFilePath } = require("./strings");
|
|
3
|
+
const { SimpleffmpegError } = require("../core/errors");
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Get the null device path for the current platform
|
|
@@ -258,10 +259,62 @@ function escapeMetadata(value) {
|
|
|
258
259
|
.replace(/\n/g, "\\n");
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Sanitize a filter_complex string before passing it to FFmpeg.
|
|
264
|
+
*
|
|
265
|
+
* Guards against:
|
|
266
|
+
* - Trailing semicolons that create empty filter chains (some FFmpeg builds
|
|
267
|
+
* reject these with "No such filter: ''").
|
|
268
|
+
* - Double (or more) semicolons that produce empty chains between real ones.
|
|
269
|
+
* - Completely empty filter names between pad labels, e.g. "[a][b]" with no
|
|
270
|
+
* filter name — detected and surfaced as a descriptive error.
|
|
271
|
+
*/
|
|
272
|
+
function sanitizeFilterComplex(fc) {
|
|
273
|
+
if (!fc || typeof fc !== "string") return fc;
|
|
274
|
+
|
|
275
|
+
// Collapse runs of semicolons (;;; → ;) that would produce empty chains
|
|
276
|
+
let sanitized = fc.replace(/;{2,}/g, ";");
|
|
277
|
+
|
|
278
|
+
// Strip leading/trailing semicolons
|
|
279
|
+
sanitized = sanitized.replace(/^;+/, "").replace(/;+$/, "");
|
|
280
|
+
|
|
281
|
+
// Detect empty filter names: a closing ']' immediately followed by an
|
|
282
|
+
// opening '[' with no filter name in between (at a chain boundary).
|
|
283
|
+
// Valid patterns like "[a][b]xfade=..." have a filter name after the
|
|
284
|
+
// second label. An empty name looks like "[a][b];" or "[a][b]," or
|
|
285
|
+
// "[a];[b]" at the very start of a chain.
|
|
286
|
+
//
|
|
287
|
+
// We check for the pattern: ';' followed by optional whitespace then '['
|
|
288
|
+
// where the preceding chain segment has no filter name.
|
|
289
|
+
// Also check for label sequences with no filter: "][" not followed by an
|
|
290
|
+
// alphanumeric filter name within the same chain segment.
|
|
291
|
+
const chains = sanitized.split(";");
|
|
292
|
+
for (let i = 0; i < chains.length; i++) {
|
|
293
|
+
const chain = chains[i].trim();
|
|
294
|
+
if (!chain) continue;
|
|
295
|
+
|
|
296
|
+
// Remove all pad labels to see if there's an actual filter name left
|
|
297
|
+
const withoutLabels = chain.replace(/\[[^\]]*\]/g, "").trim();
|
|
298
|
+
// After removing labels, what's left should start with a filter name
|
|
299
|
+
// (alphabetic). If it's empty or starts with ',' or '=' that means
|
|
300
|
+
// a filter name is missing.
|
|
301
|
+
if (withoutLabels.length === 0) {
|
|
302
|
+
throw new SimpleffmpegError(
|
|
303
|
+
`Empty filter name detected in filter_complex chain segment ${i}: "${chain}". ` +
|
|
304
|
+
`This usually means an effect or transition is not producing a valid FFmpeg filter. ` +
|
|
305
|
+
`Full filter_complex (truncated): "${sanitized.slice(0, 500)}..."`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return sanitized;
|
|
311
|
+
}
|
|
312
|
+
|
|
261
313
|
module.exports = {
|
|
262
314
|
buildMainCommand,
|
|
263
315
|
buildTextBatchCommand,
|
|
264
316
|
buildThumbnailCommand,
|
|
265
317
|
buildSnapshotCommand,
|
|
266
318
|
escapeMetadata,
|
|
319
|
+
sanitizeFilterComplex,
|
|
267
320
|
};
|
|
@@ -24,11 +24,14 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
if (effectClip.effect === "filmGrain") {
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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,116 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
53
56
|
};
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extract the FFmpeg filter name from a filter segment string.
|
|
160
|
+
* Expected format: "[inputLabel]filterName=params[outputLabel];"
|
|
161
|
+
* Returns the filter name or an empty string if it can't be found.
|
|
162
|
+
*/
|
|
163
|
+
function extractFilterName(filterSegment) {
|
|
164
|
+
// Strip leading pad labels like [fxsrc0]
|
|
165
|
+
const withoutLabels = filterSegment.replace(/\[[^\]]*\]/g, "");
|
|
166
|
+
// The filter name is the first word before '=' or ',' or ';' or end-of-string
|
|
167
|
+
const match = withoutLabels.match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
168
|
+
return match ? match[1] : "";
|
|
74
169
|
}
|
|
75
170
|
|
|
76
171
|
function buildEffectFilters(effectClips, inputLabel) {
|
|
@@ -108,6 +203,20 @@ function buildEffectFilters(effectClips, inputLabel) {
|
|
|
108
203
|
procSrcLabel,
|
|
109
204
|
fxLabel
|
|
110
205
|
);
|
|
206
|
+
|
|
207
|
+
// Safeguard: verify the filter builder produced a non-empty filter name.
|
|
208
|
+
// If any effect resolves to an empty filter (e.g. unsupported on the
|
|
209
|
+
// running FFmpeg version), FFmpeg would receive '' as a filter name and
|
|
210
|
+
// fail with "No such filter: ''".
|
|
211
|
+
const filterName = extractFilterName(fxFilter);
|
|
212
|
+
if (!filterName) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Effect '${clip.effect}' produced an empty filter name. ` +
|
|
215
|
+
`This usually means the effect is not supported by the current FFmpeg version. ` +
|
|
216
|
+
`Generated filter segment: ${JSON.stringify(fxFilter)}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
111
220
|
filter += fxFilter;
|
|
112
221
|
|
|
113
222
|
const start = formatNumber(clip.position || 0, 4);
|
|
@@ -135,4 +244,4 @@ function buildEffectFilters(effectClips, inputLabel) {
|
|
|
135
244
|
return { filter, finalVideoLabel: currentLabel };
|
|
136
245
|
}
|
|
137
246
|
|
|
138
|
-
module.exports = { buildEffectFilters };
|
|
247
|
+
module.exports = { buildEffectFilters, extractFilterName };
|
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"
|
|
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: [
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/src/simpleffmpeg.js
CHANGED
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
buildMainCommand,
|
|
31
31
|
buildThumbnailCommand,
|
|
32
32
|
buildSnapshotCommand,
|
|
33
|
+
sanitizeFilterComplex,
|
|
33
34
|
} = require("./ffmpeg/command_builder");
|
|
34
35
|
const { runTextPasses } = require("./ffmpeg/text_passes");
|
|
35
36
|
const { formatBytes, runFFmpeg } = require("./lib/utils");
|
|
@@ -56,6 +57,7 @@ class SIMPLEFFMPEG {
|
|
|
56
57
|
* @param {number} options.fps - Frames per second (default: 30)
|
|
57
58
|
* @param {string} options.preset - Platform preset ('tiktok', 'youtube', 'instagram-post', etc.)
|
|
58
59
|
* @param {string} options.validationMode - Validation behavior: 'warn' or 'strict' (default: 'warn')
|
|
60
|
+
* @param {string} options.fontFile - Default font file path (.ttf, .otf) applied to all text clips unless overridden per-clip
|
|
59
61
|
*
|
|
60
62
|
* @example
|
|
61
63
|
* const project = new SIMPLEFFMPEG({ preset: 'tiktok' });
|
|
@@ -87,6 +89,7 @@ class SIMPLEFFMPEG {
|
|
|
87
89
|
height: options.height || presetConfig.height || C.DEFAULT_HEIGHT,
|
|
88
90
|
validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
|
|
89
91
|
preset: options.preset || null,
|
|
92
|
+
fontFile: options.fontFile || null,
|
|
90
93
|
};
|
|
91
94
|
this.videoOrAudioClips = [];
|
|
92
95
|
this.textClips = [];
|
|
@@ -828,6 +831,12 @@ class SIMPLEFFMPEG {
|
|
|
828
831
|
}
|
|
829
832
|
}
|
|
830
833
|
|
|
834
|
+
// Sanitize the filter complex string before passing to FFmpeg.
|
|
835
|
+
// Remove trailing semicolons (which create empty filter chains on some
|
|
836
|
+
// FFmpeg builds) and collapse double semicolons that could result from
|
|
837
|
+
// concatenating builder outputs where one returned an empty string.
|
|
838
|
+
filterComplex = sanitizeFilterComplex(filterComplex);
|
|
839
|
+
|
|
831
840
|
// Build command
|
|
832
841
|
const command = buildMainCommand({
|
|
833
842
|
inputs: this._getInputStreams() + watermarkInputString,
|
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 =
|
|
250
|
-
|
|
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 =
|
|
250
|
-
|
|
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 */
|