simple-ffmpegjs 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  const fs = require("fs");
2
+ const { detectVisualGaps } = require("./gaps");
2
3
 
3
4
  // ========================================================================
4
5
  // FFmpeg named colors (X11/CSS color names accepted by libavutil)
@@ -71,39 +72,6 @@ function isValidFFmpegColor(value) {
71
72
  return FFMPEG_NAMED_COLORS.has(color.toLowerCase());
72
73
  }
73
74
 
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
-
107
75
  /**
108
76
  * Error/warning codes for programmatic handling
109
77
  */
@@ -138,6 +106,172 @@ function createIssue(code, path, message, received = undefined) {
138
106
  return issue;
139
107
  }
140
108
 
109
+ const EFFECT_TYPES = ["vignette", "filmGrain", "gaussianBlur", "colorAdjust"];
110
+ const EFFECT_EASING = ["linear", "ease-in", "ease-out", "ease-in-out"];
111
+
112
+ function validateFiniteNumber(value, path, errors, opts = {}) {
113
+ const { min = null, max = null, minInclusive = true, maxInclusive = true } = opts;
114
+ if (typeof value !== "number" || !Number.isFinite(value)) {
115
+ errors.push(
116
+ createIssue(
117
+ ValidationCodes.INVALID_VALUE,
118
+ path,
119
+ "Must be a finite number",
120
+ value
121
+ )
122
+ );
123
+ return;
124
+ }
125
+ if (min != null) {
126
+ const failsMin = minInclusive ? value < min : value <= min;
127
+ if (failsMin) {
128
+ errors.push(
129
+ createIssue(
130
+ ValidationCodes.INVALID_RANGE,
131
+ path,
132
+ minInclusive ? `Must be >= ${min}` : `Must be > ${min}`,
133
+ value
134
+ )
135
+ );
136
+ return;
137
+ }
138
+ }
139
+ if (max != null) {
140
+ const failsMax = maxInclusive ? value > max : value >= max;
141
+ if (failsMax) {
142
+ errors.push(
143
+ createIssue(
144
+ ValidationCodes.INVALID_RANGE,
145
+ path,
146
+ maxInclusive ? `Must be <= ${max}` : `Must be < ${max}`,
147
+ value
148
+ )
149
+ );
150
+ }
151
+ }
152
+ }
153
+
154
+ function validateEffectClip(clip, path, errors) {
155
+ if (!EFFECT_TYPES.includes(clip.effect)) {
156
+ errors.push(
157
+ createIssue(
158
+ ValidationCodes.INVALID_VALUE,
159
+ `${path}.effect`,
160
+ `Invalid effect '${clip.effect}'. Expected: ${EFFECT_TYPES.join(", ")}`,
161
+ clip.effect
162
+ )
163
+ );
164
+ }
165
+
166
+ if (clip.fadeIn != null) {
167
+ validateFiniteNumber(clip.fadeIn, `${path}.fadeIn`, errors, { min: 0 });
168
+ }
169
+ if (clip.fadeOut != null) {
170
+ validateFiniteNumber(clip.fadeOut, `${path}.fadeOut`, errors, { min: 0 });
171
+ }
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
+ if (typeof clip.position === "number" && typeof clip.end === "number") {
184
+ const duration = clip.end - clip.position;
185
+ const fadeTotal = (clip.fadeIn || 0) + (clip.fadeOut || 0);
186
+ if (fadeTotal > duration + 1e-9) {
187
+ errors.push(
188
+ createIssue(
189
+ ValidationCodes.INVALID_TIMELINE,
190
+ `${path}`,
191
+ `fadeIn + fadeOut (${fadeTotal}) must be <= clip duration (${duration})`,
192
+ { fadeIn: clip.fadeIn || 0, fadeOut: clip.fadeOut || 0, duration }
193
+ )
194
+ );
195
+ }
196
+ }
197
+
198
+ if (
199
+ clip.params == null ||
200
+ typeof clip.params !== "object" ||
201
+ Array.isArray(clip.params)
202
+ ) {
203
+ errors.push(
204
+ createIssue(
205
+ ValidationCodes.MISSING_REQUIRED,
206
+ `${path}.params`,
207
+ "params is required and must be an object for effect clips",
208
+ clip.params
209
+ )
210
+ );
211
+ return;
212
+ }
213
+
214
+ const params = clip.params;
215
+ if (params.amount != null) {
216
+ validateFiniteNumber(params.amount, `${path}.params.amount`, errors, {
217
+ min: 0,
218
+ max: 1,
219
+ });
220
+ }
221
+
222
+ if (clip.effect === "vignette") {
223
+ if (params.angle != null) {
224
+ validateFiniteNumber(params.angle, `${path}.params.angle`, errors, {
225
+ min: 0,
226
+ max: 6.283185307179586,
227
+ });
228
+ }
229
+ } else if (clip.effect === "filmGrain") {
230
+ if (params.temporal != null && typeof params.temporal !== "boolean") {
231
+ errors.push(
232
+ createIssue(
233
+ ValidationCodes.INVALID_VALUE,
234
+ `${path}.params.temporal`,
235
+ "temporal must be a boolean",
236
+ params.temporal
237
+ )
238
+ );
239
+ }
240
+ } else if (clip.effect === "gaussianBlur") {
241
+ if (params.sigma != null) {
242
+ validateFiniteNumber(params.sigma, `${path}.params.sigma`, errors, {
243
+ min: 0,
244
+ max: 100,
245
+ });
246
+ }
247
+ } else if (clip.effect === "colorAdjust") {
248
+ if (params.brightness != null) {
249
+ validateFiniteNumber(params.brightness, `${path}.params.brightness`, errors, {
250
+ min: -1,
251
+ max: 1,
252
+ });
253
+ }
254
+ if (params.contrast != null) {
255
+ validateFiniteNumber(params.contrast, `${path}.params.contrast`, errors, {
256
+ min: 0,
257
+ max: 3,
258
+ });
259
+ }
260
+ if (params.saturation != null) {
261
+ validateFiniteNumber(params.saturation, `${path}.params.saturation`, errors, {
262
+ min: 0,
263
+ max: 3,
264
+ });
265
+ }
266
+ if (params.gamma != null) {
267
+ validateFiniteNumber(params.gamma, `${path}.params.gamma`, errors, {
268
+ min: 0.1,
269
+ max: 10,
270
+ });
271
+ }
272
+ }
273
+ }
274
+
141
275
  /**
142
276
  * Validate a single clip and return issues
143
277
  */
@@ -156,6 +290,8 @@ function validateClip(clip, index, options = {}) {
156
290
  "backgroundAudio",
157
291
  "image",
158
292
  "subtitle",
293
+ "color",
294
+ "effect",
159
295
  ];
160
296
 
161
297
  // Check type
@@ -227,7 +363,7 @@ function validateClip(clip, index, options = {}) {
227
363
  }
228
364
 
229
365
  // Types that require position/end on timeline
230
- const requiresTimeline = ["video", "audio", "text", "image"].includes(
366
+ const requiresTimeline = ["video", "audio", "text", "image", "color", "effect"].includes(
231
367
  clip.type
232
368
  );
233
369
 
@@ -743,7 +879,9 @@ function validateClip(clip, index, options = {}) {
743
879
  "custom",
744
880
  ];
745
881
  const kbType =
746
- typeof clip.kenBurns === "string" ? clip.kenBurns : clip.kenBurns.type;
882
+ typeof clip.kenBurns === "string"
883
+ ? clip.kenBurns
884
+ : clip.kenBurns.type;
747
885
  if (kbType && !validKenBurns.includes(kbType)) {
748
886
  errors.push(
749
887
  createIssue(
@@ -896,8 +1034,95 @@ function validateClip(clip, index, options = {}) {
896
1034
  }
897
1035
  }
898
1036
 
899
- // Video transition validation
900
- if (clip.type === "video" && clip.transition) {
1037
+ // Color clip validation
1038
+ if (clip.type === "color") {
1039
+ if (clip.color == null) {
1040
+ errors.push(
1041
+ createIssue(
1042
+ ValidationCodes.MISSING_REQUIRED,
1043
+ `${path}.color`,
1044
+ "Color is required for color clips",
1045
+ clip.color
1046
+ )
1047
+ );
1048
+ } else if (typeof clip.color === "string") {
1049
+ if (!isValidFFmpegColor(clip.color)) {
1050
+ errors.push(
1051
+ createIssue(
1052
+ ValidationCodes.INVALID_VALUE,
1053
+ `${path}.color`,
1054
+ `Invalid color "${clip.color}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
1055
+ clip.color
1056
+ )
1057
+ );
1058
+ }
1059
+ } else if (typeof clip.color === "object" && clip.color !== null) {
1060
+ const validGradientTypes = ["linear-gradient", "radial-gradient"];
1061
+ if (!clip.color.type || !validGradientTypes.includes(clip.color.type)) {
1062
+ errors.push(
1063
+ createIssue(
1064
+ ValidationCodes.INVALID_VALUE,
1065
+ `${path}.color.type`,
1066
+ `Invalid gradient type '${clip.color.type}'. Expected: ${validGradientTypes.join(", ")}`,
1067
+ clip.color.type
1068
+ )
1069
+ );
1070
+ }
1071
+ if (!Array.isArray(clip.color.colors) || clip.color.colors.length < 2) {
1072
+ errors.push(
1073
+ createIssue(
1074
+ ValidationCodes.INVALID_VALUE,
1075
+ `${path}.color.colors`,
1076
+ "Gradient colors must be an array of at least 2 color strings",
1077
+ clip.color.colors
1078
+ )
1079
+ );
1080
+ } else {
1081
+ clip.color.colors.forEach((c, ci) => {
1082
+ if (typeof c !== "string" || !isValidFFmpegColor(c)) {
1083
+ errors.push(
1084
+ createIssue(
1085
+ ValidationCodes.INVALID_VALUE,
1086
+ `${path}.color.colors[${ci}]`,
1087
+ `Invalid gradient color "${c}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB), or "random".`,
1088
+ c
1089
+ )
1090
+ );
1091
+ }
1092
+ });
1093
+ }
1094
+ if (clip.color.direction != null) {
1095
+ const validDirections = ["vertical", "horizontal"];
1096
+ if (typeof clip.color.direction !== "number" && !validDirections.includes(clip.color.direction)) {
1097
+ errors.push(
1098
+ createIssue(
1099
+ ValidationCodes.INVALID_VALUE,
1100
+ `${path}.color.direction`,
1101
+ `Invalid gradient direction '${clip.color.direction}'. Expected: "vertical", "horizontal", or a number (angle in degrees)`,
1102
+ clip.color.direction
1103
+ )
1104
+ );
1105
+ }
1106
+ }
1107
+ } else {
1108
+ errors.push(
1109
+ createIssue(
1110
+ ValidationCodes.INVALID_VALUE,
1111
+ `${path}.color`,
1112
+ "Color must be a string (flat color) or an object (gradient spec)",
1113
+ clip.color
1114
+ )
1115
+ );
1116
+ }
1117
+ }
1118
+
1119
+ if (clip.type === "effect") {
1120
+ validateEffectClip(clip, path, errors);
1121
+ }
1122
+
1123
+ // Visual clip transition validation (video, image, color)
1124
+ const visualTypes = ["video", "image", "color"];
1125
+ if (visualTypes.includes(clip.type) && clip.transition) {
901
1126
  if (typeof clip.transition.duration !== "number") {
902
1127
  errors.push(
903
1128
  createIssue(
@@ -932,65 +1157,60 @@ function validateClip(clip, index, options = {}) {
932
1157
  }
933
1158
 
934
1159
  /**
935
- * Validate timeline gaps (visual continuity)
1160
+ * Validate timeline gaps (visual continuity).
1161
+ * Uses detectVisualGaps() from gaps.js as the single source of truth
1162
+ * for gap detection logic.
936
1163
  */
937
- function validateTimelineGaps(clips, options = {}) {
938
- const { fillGaps = "none" } = options;
1164
+ function validateTimelineGaps(clips) {
939
1165
  const errors = [];
940
1166
 
941
- // Skip gap checking if fillGaps is enabled
942
- if (fillGaps !== "none") {
1167
+ // Build clip objects with original indices for error messages
1168
+ const indexed = clips.map((c, i) => ({ ...c, _origIndex: i }));
1169
+ const gaps = detectVisualGaps(indexed);
1170
+
1171
+ if (gaps.length === 0) {
943
1172
  return { errors, warnings: [] };
944
1173
  }
945
1174
 
946
- // Get visual clips (video and image)
1175
+ // Build a sorted visual clip list so we can reference neighbours in messages
947
1176
  const visual = clips
948
1177
  .map((c, i) => ({ clip: c, index: i }))
949
- .filter(({ clip }) => clip.type === "video" || clip.type === "image")
1178
+ .filter(({ clip }) => clip.type === "video" || clip.type === "image" || clip.type === "color")
950
1179
  .filter(
951
1180
  ({ clip }) =>
952
1181
  typeof clip.position === "number" && typeof clip.end === "number"
953
1182
  )
954
1183
  .sort((a, b) => a.clip.position - b.clip.position);
955
1184
 
956
- if (visual.length === 0) {
957
- return { errors, warnings: [] };
958
- }
959
-
960
- const eps = 1e-3;
1185
+ for (const gap of gaps) {
1186
+ const isLeading = gap.start === 0 || (visual.length > 0 && gap.end <= visual[0].clip.position + 1e-3);
961
1187
 
962
- // Check for leading gap
963
- if (visual[0].clip.position > eps) {
964
- errors.push(
965
- createIssue(
966
- ValidationCodes.TIMELINE_GAP,
967
- "timeline",
968
- `Gap at start of timeline [0, ${visual[0].clip.position.toFixed(
969
- 3
970
- )}s] - no video/image content. Use fillGaps option (e.g. 'black') to auto-fill.`,
971
- { start: 0, end: visual[0].clip.position }
972
- )
973
- );
974
- }
975
-
976
- // Check for gaps between clips
977
- for (let i = 1; i < visual.length; i++) {
978
- const prev = visual[i - 1].clip;
979
- const curr = visual[i].clip;
980
- const gapStart = prev.end;
981
- const gapEnd = curr.position;
1188
+ if (isLeading && gap.start < 1e-3) {
1189
+ errors.push(
1190
+ createIssue(
1191
+ ValidationCodes.TIMELINE_GAP,
1192
+ "timeline",
1193
+ `Gap at start of visual timeline [0, ${gap.end.toFixed(
1194
+ 3
1195
+ )}s]. If intentional, fill it with a { type: "color" } clip. Otherwise, start your first clip at position 0.`,
1196
+ { start: gap.start, end: gap.end }
1197
+ )
1198
+ );
1199
+ } else {
1200
+ // Find the surrounding clip indices for a helpful message
1201
+ const before = visual.filter(v => v.clip.end <= gap.start + 1e-3);
1202
+ const after = visual.filter(v => v.clip.position >= gap.end - 1e-3);
1203
+ const prevIdx = before.length > 0 ? before[before.length - 1].index : "?";
1204
+ const nextIdx = after.length > 0 ? after[0].index : "?";
982
1205
 
983
- if (gapEnd - gapStart > eps) {
984
1206
  errors.push(
985
1207
  createIssue(
986
1208
  ValidationCodes.TIMELINE_GAP,
987
1209
  "timeline",
988
- `Gap in timeline [${gapStart.toFixed(3)}s, ${gapEnd.toFixed(
1210
+ `Gap in visual timeline [${gap.start.toFixed(3)}s, ${gap.end.toFixed(
989
1211
  3
990
- )}s] between clips[${visual[i - 1].index}] and clips[${
991
- visual[i].index
992
- }]. Use fillGaps option (e.g. 'black') to auto-fill.`,
993
- { start: gapStart, end: gapEnd }
1212
+ )}s] between clips[${prevIdx}] and clips[${nextIdx}]. If intentional, fill it with a { type: "color" } clip. Otherwise, adjust clip positions to remove the gap.`,
1213
+ { start: gap.start, end: gap.end }
994
1214
  )
995
1215
  );
996
1216
  }
@@ -1005,7 +1225,6 @@ function validateTimelineGaps(clips, options = {}) {
1005
1225
  * @param {Array} clips - Array of clip objects to validate
1006
1226
  * @param {Object} options - Validation options
1007
1227
  * @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI validation)
1008
- * @param {string} options.fillGaps - Gap handling mode ('none' | 'black')
1009
1228
  * @returns {Object} Validation result { valid, errors, warnings }
1010
1229
  */
1011
1230
  function validateConfig(clips, options = {}) {
@@ -1091,6 +1310,5 @@ module.exports = {
1091
1310
  formatValidationResult,
1092
1311
  ValidationCodes,
1093
1312
  isValidFFmpegColor,
1094
- normalizeFillGaps,
1095
1313
  FFMPEG_NAMED_COLORS,
1096
1314
  };
@@ -11,7 +11,9 @@ function buildAudioForVideoClips(project, videoClips, transitionOffsets) {
11
11
 
12
12
  videoClips.forEach((clip) => {
13
13
  if (!clip.hasAudio) return;
14
- const inputIndex = project.videoOrAudioClips.indexOf(clip);
14
+ const inputIndex = project._inputIndexMap
15
+ ? project._inputIndexMap.get(clip)
16
+ : project.videoOrAudioClips.indexOf(clip);
15
17
  const requestedDuration = Math.max(
16
18
  0,
17
19
  (clip.end || 0) - (clip.position || 0)
@@ -29,7 +29,9 @@ function buildBackgroundMusicMix(
29
29
  let filter = "";
30
30
  const bgLabels = [];
31
31
  backgroundClips.forEach((clip, i) => {
32
- const inputIndex = project.videoOrAudioClips.indexOf(clip);
32
+ const inputIndex = project._inputIndexMap
33
+ ? project._inputIndexMap.get(clip)
34
+ : project.videoOrAudioClips.indexOf(clip);
33
35
  const effectivePosition =
34
36
  typeof clip.position === "number" ? clip.position : 0;
35
37
  const effectiveEnd =
@@ -0,0 +1,138 @@
1
+ function formatNumber(value, decimals = 6) {
2
+ return Number(value.toFixed(decimals)).toString();
3
+ }
4
+
5
+ function clamp(value, min, max) {
6
+ return Math.min(max, Math.max(min, value));
7
+ }
8
+
9
+ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
10
+ const params = effectClip.params || {};
11
+ const amount = clamp(
12
+ typeof params.amount === "number" ? params.amount : 1,
13
+ 0,
14
+ 1
15
+ );
16
+
17
+ if (effectClip.effect === "vignette") {
18
+ const angle =
19
+ typeof params.angle === "number" ? params.angle : Math.PI / 5;
20
+ return {
21
+ filter: `${inputLabel}vignette=angle=${formatNumber(angle)}:eval=frame${outputLabel};`,
22
+ amount,
23
+ };
24
+ }
25
+
26
+ if (effectClip.effect === "filmGrain") {
27
+ const grainStrength = clamp(
28
+ (typeof params.amount === "number" ? params.amount : 0.35) * 100,
29
+ 0,
30
+ 100
31
+ );
32
+ const flags = params.temporal === false ? "u" : "t+u";
33
+ return {
34
+ filter: `${inputLabel}noise=alls=${formatNumber(
35
+ grainStrength,
36
+ 3
37
+ )}:allf=${flags}${outputLabel};`,
38
+ amount,
39
+ };
40
+ }
41
+
42
+ if (effectClip.effect === "gaussianBlur") {
43
+ const sigma = clamp(
44
+ typeof params.sigma === "number"
45
+ ? params.sigma
46
+ : (typeof params.amount === "number" ? params.amount : 0.5) * 20,
47
+ 0,
48
+ 100
49
+ );
50
+ return {
51
+ filter: `${inputLabel}gblur=sigma=${formatNumber(sigma, 4)}${outputLabel};`,
52
+ amount,
53
+ };
54
+ }
55
+
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
+ };
74
+ }
75
+
76
+ function buildEffectFilters(effectClips, inputLabel) {
77
+ if (!Array.isArray(effectClips) || effectClips.length === 0) {
78
+ return { filter: "", finalVideoLabel: inputLabel };
79
+ }
80
+
81
+ const ordered = [...effectClips]
82
+ .filter((c) => typeof c.position === "number" && typeof c.end === "number")
83
+ .sort((a, b) => {
84
+ if (a.position !== b.position) return a.position - b.position;
85
+ return a.end - b.end;
86
+ });
87
+
88
+ if (ordered.length === 0) {
89
+ return { filter: "", finalVideoLabel: inputLabel };
90
+ }
91
+
92
+ let filter = "";
93
+ let currentLabel = inputLabel;
94
+
95
+ ordered.forEach((clip, i) => {
96
+ const baseLabel = `[fxbase${i}]`;
97
+ const procSrcLabel = `[fxsrc${i}]`;
98
+ const fxLabel = `[fxraw${i}]`;
99
+ const fxAlphaLabel = `[fxa${i}]`;
100
+ const outLabel = `[fxout${i}]`;
101
+
102
+ // Split current frame stream into base + processed branches so compositing
103
+ // is deterministic and frame-aligned across all effects.
104
+ filter += `${currentLabel}split=2${baseLabel}${procSrcLabel};`;
105
+
106
+ const { filter: fxFilter, amount } = buildProcessedEffectFilter(
107
+ clip,
108
+ procSrcLabel,
109
+ fxLabel
110
+ );
111
+ filter += fxFilter;
112
+
113
+ const start = formatNumber(clip.position || 0, 4);
114
+ const end = formatNumber(clip.end || 0, 4);
115
+ const fadeIn = Math.max(0, clip.fadeIn || 0);
116
+ const fadeOut = Math.max(0, clip.fadeOut || 0);
117
+ const fadeOutStart = Math.max(clip.position || 0, (clip.end || 0) - fadeOut);
118
+
119
+ let alphaChain = `format=rgba,colorchannelmixer=aa=${formatNumber(amount, 4)}`;
120
+ if (fadeIn > 0) {
121
+ alphaChain += `,fade=t=in:st=${start}:d=${formatNumber(fadeIn, 4)}:alpha=1`;
122
+ }
123
+ if (fadeOut > 0) {
124
+ alphaChain += `,fade=t=out:st=${formatNumber(
125
+ fadeOutStart,
126
+ 4
127
+ )}:d=${formatNumber(fadeOut, 4)}:alpha=1`;
128
+ }
129
+
130
+ filter += `${fxLabel}${alphaChain}${fxAlphaLabel};`;
131
+ filter += `${baseLabel}${fxAlphaLabel}overlay=shortest=1:eof_action=pass:enable='between(t,${start},${end})'${outLabel};`;
132
+ currentLabel = outLabel;
133
+ });
134
+
135
+ return { filter, finalVideoLabel: currentLabel };
136
+ }
137
+
138
+ module.exports = { buildEffectFilters };