simple-ffmpegjs 0.3.4 → 0.3.6
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 +113 -48
- package/package.json +1 -1
- package/src/core/gaps.js +27 -1
- package/src/core/validation.js +250 -2
- package/src/ffmpeg/audio_builder.js +12 -3
- package/src/ffmpeg/video_builder.js +219 -57
- package/src/ffmpeg/watermark_builder.js +13 -0
- package/src/loaders.js +9 -2
- package/src/schema/modules/image.js +24 -6
- package/src/simpleffmpeg.js +102 -8
- package/types/index.d.mts +32 -11
- package/types/index.d.ts +32 -11
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ _Click to watch a "Wonders of the World" video created with simple-ffmpeg — co
|
|
|
77
77
|
- **Image Support** — Ken Burns effects (zoom, pan) for static images
|
|
78
78
|
- **Progress Tracking** — Real-time export progress callbacks
|
|
79
79
|
- **Cancellation** — AbortController support for stopping exports
|
|
80
|
-
- **Gap Handling** —
|
|
80
|
+
- **Gap Handling** — Auto-fill timeline gaps with any color (including trailing gaps for text-on-background endings)
|
|
81
81
|
- **Auto-Batching** — Automatically splits complex filter graphs to avoid OS command limits
|
|
82
82
|
- **Schema Export** — Generate a structured description of the clip format for documentation, code generation, or AI context
|
|
83
83
|
- **Pre-Validation** — Validate clip configurations before processing with structured, machine-readable error codes
|
|
@@ -284,7 +284,7 @@ new SIMPLEFFMPEG(options?: {
|
|
|
284
284
|
height?: number; // Output height (default: 1080)
|
|
285
285
|
fps?: number; // Frame rate (default: 30)
|
|
286
286
|
validationMode?: 'warn' | 'strict'; // Validation behavior (default: 'warn')
|
|
287
|
-
fillGaps?:
|
|
287
|
+
fillGaps?: boolean | string; // Gap handling: true/"black", any FFmpeg color, or "none"/false (default: "none")
|
|
288
288
|
preset?: string; // Platform preset (e.g., 'tiktok', 'youtube', 'instagram-post')
|
|
289
289
|
})
|
|
290
290
|
```
|
|
@@ -406,13 +406,13 @@ await SIMPLEFFMPEG.snapshot("./video.mp4", {
|
|
|
406
406
|
|
|
407
407
|
**Snapshot Options:**
|
|
408
408
|
|
|
409
|
-
| Option | Type | Default | Description
|
|
410
|
-
| ------------ | -------- | ------- |
|
|
411
|
-
| `outputPath` | `string` | - | **Required.** Output image path (extension determines format)
|
|
412
|
-
| `time` | `number` | `0` | Time in seconds to capture the frame at
|
|
413
|
-
| `width` | `number` | - | Output width in pixels (maintains aspect ratio if height omitted)
|
|
414
|
-
| `height` | `number` | - | Output height in pixels (maintains aspect ratio if width omitted)
|
|
415
|
-
| `quality` | `number` | `2` | JPEG quality 1-31, lower is better (only applies to `.jpg`/`.jpeg` output)
|
|
409
|
+
| Option | Type | Default | Description |
|
|
410
|
+
| ------------ | -------- | ------- | -------------------------------------------------------------------------- |
|
|
411
|
+
| `outputPath` | `string` | - | **Required.** Output image path (extension determines format) |
|
|
412
|
+
| `time` | `number` | `0` | Time in seconds to capture the frame at |
|
|
413
|
+
| `width` | `number` | - | Output width in pixels (maintains aspect ratio if height omitted) |
|
|
414
|
+
| `height` | `number` | - | Output height in pixels (maintains aspect ratio if width omitted) |
|
|
415
|
+
| `quality` | `number` | `2` | JPEG quality 1-31, lower is better (only applies to `.jpg`/`.jpeg` output) |
|
|
416
416
|
|
|
417
417
|
**Supported formats:** `.jpg` / `.jpeg`, `.png`, `.webp`, `.bmp`, `.tiff`
|
|
418
418
|
|
|
@@ -552,7 +552,22 @@ await project.load([
|
|
|
552
552
|
position?: number; // Omit to auto-sequence after previous video/image clip
|
|
553
553
|
end?: number; // Use end OR duration, not both
|
|
554
554
|
duration?: number; // Duration in seconds (alternative to end)
|
|
555
|
-
|
|
555
|
+
width?: number; // Optional: source image width (skip probe / override)
|
|
556
|
+
height?: number; // Optional: source image height (skip probe / override)
|
|
557
|
+
kenBurns?:
|
|
558
|
+
| "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "pan-up" | "pan-down"
|
|
559
|
+
| "smart" | "custom"
|
|
560
|
+
| {
|
|
561
|
+
type?: "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "pan-up" | "pan-down" | "smart" | "custom";
|
|
562
|
+
startZoom?: number;
|
|
563
|
+
endZoom?: number;
|
|
564
|
+
startX?: number; // 0 = left, 1 = right
|
|
565
|
+
startY?: number; // 0 = top, 1 = bottom
|
|
566
|
+
endX?: number;
|
|
567
|
+
endY?: number;
|
|
568
|
+
anchor?: "top" | "bottom" | "left" | "right";
|
|
569
|
+
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
570
|
+
};
|
|
556
571
|
}
|
|
557
572
|
```
|
|
558
573
|
|
|
@@ -762,7 +777,7 @@ onProgress: ({ percent, phase }) => {
|
|
|
762
777
|
} else {
|
|
763
778
|
console.log(`${percent}%`);
|
|
764
779
|
}
|
|
765
|
-
}
|
|
780
|
+
};
|
|
766
781
|
```
|
|
767
782
|
|
|
768
783
|
### Logging
|
|
@@ -798,10 +813,10 @@ try {
|
|
|
798
813
|
if (error.name === "ValidationError") {
|
|
799
814
|
// Structured validation errors
|
|
800
815
|
error.errors.forEach((e) =>
|
|
801
|
-
console.error(`[${e.code}] ${e.path}: ${e.message}`)
|
|
816
|
+
console.error(`[${e.code}] ${e.path}: ${e.message}`),
|
|
802
817
|
);
|
|
803
818
|
error.warnings.forEach((w) =>
|
|
804
|
-
console.warn(`[${w.code}] ${w.path}: ${w.message}`)
|
|
819
|
+
console.warn(`[${w.code}] ${w.path}: ${w.message}`),
|
|
805
820
|
);
|
|
806
821
|
} else if (error.name === "FFmpegError") {
|
|
807
822
|
// Structured details for bug reports (last 50 lines of stderr, command, exitCode)
|
|
@@ -839,7 +854,7 @@ try {
|
|
|
839
854
|
|
|
840
855
|
### Gap Handling
|
|
841
856
|
|
|
842
|
-
By default, timeline gaps (periods with no video/image content)
|
|
857
|
+
By default, timeline gaps (periods with no video/image content) produce a validation warning. Enable automatic gap filling to insert solid-color frames wherever there's no visual media — leading gaps, middle gaps, and trailing gaps are all handled:
|
|
843
858
|
|
|
844
859
|
```ts
|
|
845
860
|
const project = new SIMPLEFFMPEG({
|
|
@@ -847,7 +862,34 @@ const project = new SIMPLEFFMPEG({
|
|
|
847
862
|
});
|
|
848
863
|
|
|
849
864
|
await project.load([
|
|
850
|
-
{ type: "video", url: "./clip.mp4", position: 2, end: 5 }, //
|
|
865
|
+
{ type: "video", url: "./clip.mp4", position: 2, end: 5 }, // Leading gap from 0-2s filled with black
|
|
866
|
+
]);
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
`fillGaps` accepts any valid FFmpeg color — named colors, hex codes, or `true` as shorthand for `"black"`:
|
|
870
|
+
|
|
871
|
+
```ts
|
|
872
|
+
// Custom color fill
|
|
873
|
+
const project = new SIMPLEFFMPEG({ fillGaps: "#0a0a2e" }); // dark navy
|
|
874
|
+
const project = new SIMPLEFFMPEG({ fillGaps: "navy" }); // named color
|
|
875
|
+
const project = new SIMPLEFFMPEG({ fillGaps: true }); // same as "black"
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
All three gap types are supported:
|
|
879
|
+
|
|
880
|
+
- **Leading gaps** — no visual media at the start of the timeline (e.g. video starts at 2s, gap from 0-2s)
|
|
881
|
+
- **Middle gaps** — periods between visual clips with no media
|
|
882
|
+
- **Trailing gaps** — text or audio extends past the last visual clip, so the video is extended with the fill color
|
|
883
|
+
|
|
884
|
+
Trailing gaps are useful for ending a video with text on a solid background:
|
|
885
|
+
|
|
886
|
+
```ts
|
|
887
|
+
const project = new SIMPLEFFMPEG({ fillGaps: "#1a1a2e" });
|
|
888
|
+
|
|
889
|
+
await project.load([
|
|
890
|
+
{ type: "video", url: "./clip.mp4", position: 0, end: 5 },
|
|
891
|
+
// Text extends 5 seconds past the video — dark blue fill from 5-10s
|
|
892
|
+
{ type: "text", text: "The End", position: 4, end: 10, fontSize: 64, fontColor: "white" },
|
|
851
893
|
]);
|
|
852
894
|
```
|
|
853
895
|
|
|
@@ -880,9 +922,43 @@ await project.load([
|
|
|
880
922
|
]);
|
|
881
923
|
```
|
|
882
924
|
|
|
925
|
+
**Custom Ken Burns (smart anchor + explicit endpoints):**
|
|
926
|
+
|
|
927
|
+
```ts
|
|
928
|
+
await project.load([
|
|
929
|
+
{
|
|
930
|
+
type: "image",
|
|
931
|
+
url: "./portrait.jpg",
|
|
932
|
+
duration: 5,
|
|
933
|
+
kenBurns: {
|
|
934
|
+
type: "smart",
|
|
935
|
+
anchor: "bottom",
|
|
936
|
+
startZoom: 1.05,
|
|
937
|
+
endZoom: 1.2,
|
|
938
|
+
easing: "ease-in-out",
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
type: "image",
|
|
943
|
+
url: "./wide.jpg",
|
|
944
|
+
duration: 4,
|
|
945
|
+
kenBurns: {
|
|
946
|
+
type: "custom",
|
|
947
|
+
startX: 0.15,
|
|
948
|
+
startY: 0.7,
|
|
949
|
+
endX: 0.85,
|
|
950
|
+
endY: 0.2,
|
|
951
|
+
easing: "ease-in-out",
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
]);
|
|
955
|
+
```
|
|
956
|
+
|
|
883
957
|
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.
|
|
884
958
|
|
|
885
959
|
> **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.
|
|
960
|
+
> If you pass `width`/`height`, they override probed dimensions (useful for remote or generated images).
|
|
961
|
+
> `smart` mode uses source vs output aspect (when known) to choose pan direction.
|
|
886
962
|
|
|
887
963
|
### Text & Animations
|
|
888
964
|
|
|
@@ -1244,7 +1320,7 @@ async function generateVideo(userPrompt, media) {
|
|
|
1244
1320
|
`Your previous output had validation errors:\n${errorFeedback}`,
|
|
1245
1321
|
`\nOriginal request: ${userPrompt}`,
|
|
1246
1322
|
"\nPlease fix the errors and return the corrected clips array.",
|
|
1247
|
-
].join("\n")
|
|
1323
|
+
].join("\n"),
|
|
1248
1324
|
);
|
|
1249
1325
|
|
|
1250
1326
|
result = SIMPLEFFMPEG.validate(clips, { skipFileChecks: true });
|
|
@@ -1254,7 +1330,7 @@ async function generateVideo(userPrompt, media) {
|
|
|
1254
1330
|
if (!result.valid) {
|
|
1255
1331
|
throw new Error(
|
|
1256
1332
|
`Failed to generate valid config after ${attempts} attempts:\n` +
|
|
1257
|
-
SIMPLEFFMPEG.formatValidationResult(result)
|
|
1333
|
+
SIMPLEFFMPEG.formatValidationResult(result),
|
|
1258
1334
|
);
|
|
1259
1335
|
}
|
|
1260
1336
|
|
|
@@ -1340,43 +1416,31 @@ npm run test:watch
|
|
|
1340
1416
|
|
|
1341
1417
|
### Manual Verification
|
|
1342
1418
|
|
|
1343
|
-
For visual verification
|
|
1419
|
+
For visual verification, run the demo suite to generate sample videos covering all major features. Each demo outputs to its own subfolder under `examples/output/` and includes annotated expected timelines so you know exactly what to look for:
|
|
1344
1420
|
|
|
1345
1421
|
```bash
|
|
1422
|
+
# Run all demos (timeline, transitions, text, Ken Burns, audio, watermarks, karaoke, torture test)
|
|
1346
1423
|
node examples/run-examples.js
|
|
1424
|
+
|
|
1425
|
+
# Run a specific demo by name (partial match)
|
|
1426
|
+
node examples/run-examples.js transitions
|
|
1427
|
+
node examples/run-examples.js torture ken
|
|
1347
1428
|
```
|
|
1348
1429
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
- Basic video concatenation
|
|
1352
|
-
- Crossfade transitions
|
|
1353
|
-
- Text overlays with animations
|
|
1354
|
-
- Background music mixing
|
|
1355
|
-
- Ken Burns effects on images
|
|
1356
|
-
- Gap filling with black frames
|
|
1357
|
-
- Quality settings (CRF, preset)
|
|
1358
|
-
- Resolution scaling
|
|
1359
|
-
- Metadata embedding
|
|
1360
|
-
- Thumbnail generation
|
|
1361
|
-
- Complex multi-track compositions
|
|
1362
|
-
- Word-by-word text
|
|
1363
|
-
- Platform presets (TikTok, YouTube, etc.)
|
|
1364
|
-
- Typewriter text animation
|
|
1365
|
-
- Scale-in text animation
|
|
1366
|
-
- Pulse text animation
|
|
1367
|
-
- Fade-out text animation
|
|
1368
|
-
- Text watermarks
|
|
1369
|
-
- Image watermarks
|
|
1370
|
-
- Timed watermarks
|
|
1371
|
-
- Karaoke text (word-by-word highlighting)
|
|
1372
|
-
- SRT/VTT subtitle import
|
|
1373
|
-
|
|
1374
|
-
View the outputs to confirm everything renders correctly:
|
|
1430
|
+
Available demo scripts (can also be run individually):
|
|
1375
1431
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1432
|
+
| Script | What it tests |
|
|
1433
|
+
| --- | --- |
|
|
1434
|
+
| `demo-timeline-and-gaps.js` | Leading/middle/trailing gaps, custom fill colors, `fillGaps: true` shorthand |
|
|
1435
|
+
| `demo-transitions.js` | Fade, wipe, slide, dissolve, fadeblack/white, short/long durations, image transitions |
|
|
1436
|
+
| `demo-text-and-animations.js` | Positioning, fade, pop, pop-bounce, typewriter, scale-in, pulse, styling, word-replace |
|
|
1437
|
+
| `demo-ken-burns.js` | All 6 presets, smart anchors, custom diagonal, slideshow with transitions |
|
|
1438
|
+
| `demo-audio-mixing.js` | Volume levels, background music, standalone audio, loop, multi-source mix |
|
|
1439
|
+
| `demo-watermarks.js` | Text/image watermarks, all positions, timed appearance, styled over transitions |
|
|
1440
|
+
| `demo-karaoke-and-subtitles.js` | Smooth/instant karaoke, word timestamps, multiline, SRT, VTT, mixed text+karaoke |
|
|
1441
|
+
| `demo-torture-test.js` | Kitchen sink, many clips+gaps+transitions, 6 simultaneous text animations, edge cases |
|
|
1442
|
+
|
|
1443
|
+
Each script header contains a `WHAT TO CHECK` section describing the expected visual output at every timestamp, making it easy to spot regressions.
|
|
1380
1444
|
|
|
1381
1445
|
## Contributing
|
|
1382
1446
|
|
|
@@ -1395,3 +1459,4 @@ Inspired by [ezffmpeg](https://github.com/ezffmpeg/ezffmpeg) by John Chen.
|
|
|
1395
1459
|
## License
|
|
1396
1460
|
|
|
1397
1461
|
MIT
|
|
1462
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simple-ffmpegjs",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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/gaps.js
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* @param {Array<{type: string, position: number, end: number}>} clips - Array of clips
|
|
6
6
|
* @param {Object} options - Options
|
|
7
7
|
* @param {number} options.epsilon - Tolerance for gap detection (default 0.001)
|
|
8
|
+
* @param {number} [options.timelineEnd] - Desired end of the timeline; if set and greater
|
|
9
|
+
* than the last visual clip's end, a trailing gap is appended.
|
|
8
10
|
* @returns {Array<{start: number, end: number, duration: number}>} Array of gaps
|
|
9
11
|
*/
|
|
10
12
|
function detectVisualGaps(clips, options = {}) {
|
|
11
|
-
const { epsilon = 1e-3 } = options;
|
|
13
|
+
const { epsilon = 1e-3, timelineEnd } = options;
|
|
12
14
|
|
|
13
15
|
// Filter to only visual clips (video/image) and sort by position
|
|
14
16
|
const visual = clips
|
|
@@ -22,6 +24,18 @@ function detectVisualGaps(clips, options = {}) {
|
|
|
22
24
|
const gaps = [];
|
|
23
25
|
|
|
24
26
|
if (visual.length === 0) {
|
|
27
|
+
// If no visual clips but a timeline end is specified, the entire range is a gap
|
|
28
|
+
if (
|
|
29
|
+
typeof timelineEnd === "number" &&
|
|
30
|
+
Number.isFinite(timelineEnd) &&
|
|
31
|
+
timelineEnd > epsilon
|
|
32
|
+
) {
|
|
33
|
+
gaps.push({
|
|
34
|
+
start: 0,
|
|
35
|
+
end: timelineEnd,
|
|
36
|
+
duration: timelineEnd,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
25
39
|
return gaps;
|
|
26
40
|
}
|
|
27
41
|
|
|
@@ -48,6 +62,18 @@ function detectVisualGaps(clips, options = {}) {
|
|
|
48
62
|
}
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
// Check for trailing gap (gap at the end after last clip)
|
|
66
|
+
if (typeof timelineEnd === "number" && Number.isFinite(timelineEnd)) {
|
|
67
|
+
const lastEnd = visual[visual.length - 1].end;
|
|
68
|
+
if (timelineEnd - lastEnd > epsilon) {
|
|
69
|
+
gaps.push({
|
|
70
|
+
start: lastEnd,
|
|
71
|
+
end: timelineEnd,
|
|
72
|
+
duration: timelineEnd - lastEnd,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
return gaps;
|
|
52
78
|
}
|
|
53
79
|
|
package/src/core/validation.js
CHANGED
|
@@ -1,5 +1,109 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
|
|
3
|
+
// ========================================================================
|
|
4
|
+
// FFmpeg named colors (X11/CSS color names accepted by libavutil)
|
|
5
|
+
// This list is extremely stable — identical across FFmpeg versions.
|
|
6
|
+
// Reference: https://ffmpeg.org/ffmpeg-utils.html#Color
|
|
7
|
+
// ========================================================================
|
|
8
|
+
const FFMPEG_NAMED_COLORS = new Set([
|
|
9
|
+
"aliceblue", "antiquewhite", "aqua", "aquamarine", "azure",
|
|
10
|
+
"beige", "bisque", "black", "blanchedalmond", "blue",
|
|
11
|
+
"blueviolet", "brown", "burlywood", "cadetblue", "chartreuse",
|
|
12
|
+
"chocolate", "coral", "cornflowerblue", "cornsilk", "crimson",
|
|
13
|
+
"cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray",
|
|
14
|
+
"darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen",
|
|
15
|
+
"darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen",
|
|
16
|
+
"darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet",
|
|
17
|
+
"deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue",
|
|
18
|
+
"firebrick", "floralwhite", "forestgreen", "fuchsia", "gainsboro",
|
|
19
|
+
"ghostwhite", "gold", "goldenrod", "gray", "green",
|
|
20
|
+
"greenyellow", "grey", "honeydew", "hotpink", "indianred",
|
|
21
|
+
"indigo", "ivory", "khaki", "lavender", "lavenderblush",
|
|
22
|
+
"lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan",
|
|
23
|
+
"lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink",
|
|
24
|
+
"lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey",
|
|
25
|
+
"lightsteelblue", "lightyellow", "lime", "limegreen", "linen",
|
|
26
|
+
"magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid",
|
|
27
|
+
"mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise",
|
|
28
|
+
"mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin",
|
|
29
|
+
"navajowhite", "navy", "oldlace", "olive", "olivedrab",
|
|
30
|
+
"orange", "orangered", "orchid", "palegoldenrod", "palegreen",
|
|
31
|
+
"paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru",
|
|
32
|
+
"pink", "plum", "powderblue", "purple", "red",
|
|
33
|
+
"rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown",
|
|
34
|
+
"seagreen", "seashell", "sienna", "silver", "skyblue",
|
|
35
|
+
"slateblue", "slategray", "slategrey", "snow", "springgreen",
|
|
36
|
+
"steelblue", "tan", "teal", "thistle", "tomato",
|
|
37
|
+
"turquoise", "violet", "wheat", "white", "whitesmoke",
|
|
38
|
+
"yellow", "yellowgreen",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Hex patterns accepted by FFmpeg: #RGB, #RRGGBB, #RRGGBBAA, 0xRRGGBB, 0xRRGGBBAA
|
|
42
|
+
const HEX_COLOR_RE = /^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|0x[0-9a-fA-F]{6}|0x[0-9a-fA-F]{8})$/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check whether a string is a valid FFmpeg color value.
|
|
46
|
+
*
|
|
47
|
+
* Accepted formats:
|
|
48
|
+
* - Named colors (case-insensitive): "black", "Red", "DarkSlateGray", …
|
|
49
|
+
* - Hex: #RGB, #RRGGBB, #RRGGBBAA, 0xRRGGBB, 0xRRGGBBAA
|
|
50
|
+
* - Special keyword: "random"
|
|
51
|
+
* - Any of the above with an @alpha suffix: "white@0.5", "#FF0000@0.8"
|
|
52
|
+
*
|
|
53
|
+
* @param {string} value
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isValidFFmpegColor(value) {
|
|
57
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
58
|
+
|
|
59
|
+
// Strip optional @alpha suffix (e.g. "white@0.5", "#FF0000@0.8")
|
|
60
|
+
let color = value;
|
|
61
|
+
const atIdx = value.indexOf("@");
|
|
62
|
+
if (atIdx > 0) {
|
|
63
|
+
const alphaPart = value.slice(atIdx + 1);
|
|
64
|
+
const alpha = Number(alphaPart);
|
|
65
|
+
if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return false;
|
|
66
|
+
color = value.slice(0, atIdx);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (color === "random") return true;
|
|
70
|
+
if (HEX_COLOR_RE.test(color)) return true;
|
|
71
|
+
return FFMPEG_NAMED_COLORS.has(color.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalise a fillGaps option value to either "none" (disabled) or a
|
|
76
|
+
* valid FFmpeg color string.
|
|
77
|
+
*
|
|
78
|
+
* Accepted inputs:
|
|
79
|
+
* - false / "none" / "off" / undefined → "none"
|
|
80
|
+
* - true → "black"
|
|
81
|
+
* - "black", "red", "#FF0000", … → the color string (validated)
|
|
82
|
+
*
|
|
83
|
+
* @param {*} value - Raw fillGaps option value
|
|
84
|
+
* @returns {{ color: string|null, error: string|null }}
|
|
85
|
+
* color is the normalised value ("none" when disabled), error is a
|
|
86
|
+
* human-readable message when the value is invalid.
|
|
87
|
+
*/
|
|
88
|
+
function normalizeFillGaps(value) {
|
|
89
|
+
if (value === undefined || value === null || value === false || value === "none" || value === "off") {
|
|
90
|
+
return { color: "none", error: null };
|
|
91
|
+
}
|
|
92
|
+
if (value === true) {
|
|
93
|
+
return { color: "black", error: null };
|
|
94
|
+
}
|
|
95
|
+
if (typeof value !== "string") {
|
|
96
|
+
return { color: null, error: `fillGaps must be a string color value, boolean, or "none" — got ${typeof value}` };
|
|
97
|
+
}
|
|
98
|
+
if (!isValidFFmpegColor(value)) {
|
|
99
|
+
return {
|
|
100
|
+
color: null,
|
|
101
|
+
error: `fillGaps color "${value}" is not a recognised FFmpeg color. Use a named color (e.g. "black", "red", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { color: value, error: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
3
107
|
/**
|
|
4
108
|
* Error/warning codes for programmatic handling
|
|
5
109
|
*/
|
|
@@ -525,6 +629,29 @@ function validateClip(clip, index, options = {}) {
|
|
|
525
629
|
);
|
|
526
630
|
}
|
|
527
631
|
}
|
|
632
|
+
|
|
633
|
+
// Validate text clip color properties
|
|
634
|
+
const textColorProps = [
|
|
635
|
+
"fontColor",
|
|
636
|
+
"borderColor",
|
|
637
|
+
"shadowColor",
|
|
638
|
+
"backgroundColor",
|
|
639
|
+
"highlightColor",
|
|
640
|
+
];
|
|
641
|
+
for (const prop of textColorProps) {
|
|
642
|
+
if (clip[prop] != null && typeof clip[prop] === "string") {
|
|
643
|
+
if (!isValidFFmpegColor(clip[prop])) {
|
|
644
|
+
warnings.push(
|
|
645
|
+
createIssue(
|
|
646
|
+
ValidationCodes.INVALID_VALUE,
|
|
647
|
+
`${path}.${prop}`,
|
|
648
|
+
`Invalid color "${clip[prop]}". Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5").`,
|
|
649
|
+
clip[prop]
|
|
650
|
+
)
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
528
655
|
}
|
|
529
656
|
|
|
530
657
|
// Subtitle clip validation
|
|
@@ -583,6 +710,23 @@ function validateClip(clip, index, options = {}) {
|
|
|
583
710
|
)
|
|
584
711
|
);
|
|
585
712
|
}
|
|
713
|
+
|
|
714
|
+
// Validate subtitle color properties
|
|
715
|
+
const subtitleColorProps = ["fontColor", "borderColor"];
|
|
716
|
+
for (const prop of subtitleColorProps) {
|
|
717
|
+
if (clip[prop] != null && typeof clip[prop] === "string") {
|
|
718
|
+
if (!isValidFFmpegColor(clip[prop])) {
|
|
719
|
+
warnings.push(
|
|
720
|
+
createIssue(
|
|
721
|
+
ValidationCodes.INVALID_VALUE,
|
|
722
|
+
`${path}.${prop}`,
|
|
723
|
+
`Invalid color "${clip[prop]}". Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5").`,
|
|
724
|
+
clip[prop]
|
|
725
|
+
)
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
586
730
|
}
|
|
587
731
|
|
|
588
732
|
// Image clip validation
|
|
@@ -595,6 +739,8 @@ function validateClip(clip, index, options = {}) {
|
|
|
595
739
|
"pan-right",
|
|
596
740
|
"pan-up",
|
|
597
741
|
"pan-down",
|
|
742
|
+
"smart",
|
|
743
|
+
"custom",
|
|
598
744
|
];
|
|
599
745
|
const kbType =
|
|
600
746
|
typeof clip.kenBurns === "string" ? clip.kenBurns : clip.kenBurns.type;
|
|
@@ -611,6 +757,105 @@ function validateClip(clip, index, options = {}) {
|
|
|
611
757
|
);
|
|
612
758
|
}
|
|
613
759
|
|
|
760
|
+
if (typeof clip.kenBurns === "object") {
|
|
761
|
+
const {
|
|
762
|
+
anchor,
|
|
763
|
+
easing,
|
|
764
|
+
startZoom,
|
|
765
|
+
endZoom,
|
|
766
|
+
startX,
|
|
767
|
+
startY,
|
|
768
|
+
endX,
|
|
769
|
+
endY,
|
|
770
|
+
} =
|
|
771
|
+
clip.kenBurns;
|
|
772
|
+
if (anchor !== undefined) {
|
|
773
|
+
const validAnchors = ["top", "bottom", "left", "right"];
|
|
774
|
+
if (!validAnchors.includes(anchor)) {
|
|
775
|
+
errors.push(
|
|
776
|
+
createIssue(
|
|
777
|
+
ValidationCodes.INVALID_VALUE,
|
|
778
|
+
`${path}.kenBurns.anchor`,
|
|
779
|
+
`Invalid kenBurns anchor '${anchor}'. Expected: ${validAnchors.join(
|
|
780
|
+
", "
|
|
781
|
+
)}`,
|
|
782
|
+
anchor
|
|
783
|
+
)
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (easing !== undefined) {
|
|
789
|
+
const validEasing = ["linear", "ease-in", "ease-out", "ease-in-out"];
|
|
790
|
+
if (!validEasing.includes(easing)) {
|
|
791
|
+
errors.push(
|
|
792
|
+
createIssue(
|
|
793
|
+
ValidationCodes.INVALID_VALUE,
|
|
794
|
+
`${path}.kenBurns.easing`,
|
|
795
|
+
`Invalid kenBurns easing '${easing}'. Expected: ${validEasing.join(
|
|
796
|
+
", "
|
|
797
|
+
)}`,
|
|
798
|
+
easing
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const numericFields = [
|
|
805
|
+
["startZoom", startZoom],
|
|
806
|
+
["endZoom", endZoom],
|
|
807
|
+
["startX", startX],
|
|
808
|
+
["startY", startY],
|
|
809
|
+
["endX", endX],
|
|
810
|
+
["endY", endY],
|
|
811
|
+
];
|
|
812
|
+
|
|
813
|
+
numericFields.forEach(([field, value]) => {
|
|
814
|
+
if (value === undefined) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
818
|
+
errors.push(
|
|
819
|
+
createIssue(
|
|
820
|
+
ValidationCodes.INVALID_TYPE,
|
|
821
|
+
`${path}.kenBurns.${field}`,
|
|
822
|
+
`kenBurns.${field} must be a finite number`,
|
|
823
|
+
value
|
|
824
|
+
)
|
|
825
|
+
);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if ((field === "startZoom" || field === "endZoom") && value <= 0) {
|
|
830
|
+
errors.push(
|
|
831
|
+
createIssue(
|
|
832
|
+
ValidationCodes.INVALID_RANGE,
|
|
833
|
+
`${path}.kenBurns.${field}`,
|
|
834
|
+
`kenBurns.${field} must be > 0`,
|
|
835
|
+
value
|
|
836
|
+
)
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (
|
|
841
|
+
(field === "startX" ||
|
|
842
|
+
field === "startY" ||
|
|
843
|
+
field === "endX" ||
|
|
844
|
+
field === "endY") &&
|
|
845
|
+
(value < 0 || value > 1)
|
|
846
|
+
) {
|
|
847
|
+
errors.push(
|
|
848
|
+
createIssue(
|
|
849
|
+
ValidationCodes.INVALID_RANGE,
|
|
850
|
+
`${path}.kenBurns.${field}`,
|
|
851
|
+
`kenBurns.${field} must be between 0 and 1`,
|
|
852
|
+
value
|
|
853
|
+
)
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
614
859
|
// Check if image dimensions are provided and sufficient for project dimensions
|
|
615
860
|
// By default, undersized images are upscaled automatically (with a warning)
|
|
616
861
|
// Set strictKenBurns: true to make this an error instead
|
|
@@ -722,7 +967,7 @@ function validateTimelineGaps(clips, options = {}) {
|
|
|
722
967
|
"timeline",
|
|
723
968
|
`Gap at start of timeline [0, ${visual[0].clip.position.toFixed(
|
|
724
969
|
3
|
|
725
|
-
)}s] - no video/image content. Use fillGaps
|
|
970
|
+
)}s] - no video/image content. Use fillGaps option (e.g. 'black') to auto-fill.`,
|
|
726
971
|
{ start: 0, end: visual[0].clip.position }
|
|
727
972
|
)
|
|
728
973
|
);
|
|
@@ -744,7 +989,7 @@ function validateTimelineGaps(clips, options = {}) {
|
|
|
744
989
|
3
|
|
745
990
|
)}s] between clips[${visual[i - 1].index}] and clips[${
|
|
746
991
|
visual[i].index
|
|
747
|
-
}]. Use fillGaps
|
|
992
|
+
}]. Use fillGaps option (e.g. 'black') to auto-fill.`,
|
|
748
993
|
{ start: gapStart, end: gapEnd }
|
|
749
994
|
)
|
|
750
995
|
);
|
|
@@ -845,4 +1090,7 @@ module.exports = {
|
|
|
845
1090
|
validateConfig,
|
|
846
1091
|
formatValidationResult,
|
|
847
1092
|
ValidationCodes,
|
|
1093
|
+
isValidFFmpegColor,
|
|
1094
|
+
normalizeFillGaps,
|
|
1095
|
+
FFMPEG_NAMED_COLORS,
|
|
848
1096
|
};
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Build audio filter chain for video clips.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} project - The SIMPLEFFMPEG project instance
|
|
5
|
+
* @param {Array} videoClips - Array of video clip objects
|
|
6
|
+
* @param {Map} [transitionOffsets] - Map of clip -> cumulative transition offset in seconds
|
|
7
|
+
*/
|
|
8
|
+
function buildAudioForVideoClips(project, videoClips, transitionOffsets) {
|
|
2
9
|
let audioFilter = "";
|
|
3
10
|
const labels = [];
|
|
4
11
|
|
|
@@ -15,9 +22,11 @@ function buildAudioForVideoClips(project, videoClips) {
|
|
|
15
22
|
: requestedDuration;
|
|
16
23
|
const clipDuration = Math.max(0, Math.min(requestedDuration, maxAvailable));
|
|
17
24
|
|
|
18
|
-
const
|
|
25
|
+
const offset = transitionOffsets ? (transitionOffsets.get(clip) || 0) : 0;
|
|
26
|
+
const adelayMs = Math.round(Math.max(0, (clip.position || 0) - offset) * 1000);
|
|
27
|
+
const vol = clip.volume != null ? clip.volume : 1;
|
|
19
28
|
const out = `[va${inputIndex}]`;
|
|
20
|
-
audioFilter += `[${inputIndex}:a]atrim=start=${clip.cutFrom}:duration=${clipDuration},asetpts=PTS-STARTPTS,adelay=${adelayMs}|${adelayMs}${out};`;
|
|
29
|
+
audioFilter += `[${inputIndex}:a]volume=${vol},atrim=start=${clip.cutFrom}:duration=${clipDuration},asetpts=PTS-STARTPTS,adelay=${adelayMs}|${adelayMs}${out};`;
|
|
21
30
|
labels.push(out);
|
|
22
31
|
});
|
|
23
32
|
|