simple-ffmpegjs 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,216 @@ function createIssue(code, path, message, received = undefined) {
138
106
  return issue;
139
107
  }
140
108
 
109
+ const EFFECT_TYPES = [
110
+ "vignette",
111
+ "filmGrain",
112
+ "gaussianBlur",
113
+ "colorAdjust",
114
+ "sepia",
115
+ "blackAndWhite",
116
+ "sharpen",
117
+ "chromaticAberration",
118
+ "letterbox",
119
+ ];
120
+
121
+ function validateFiniteNumber(value, path, errors, opts = {}) {
122
+ const { min = null, max = null, minInclusive = true, maxInclusive = true } = opts;
123
+ if (typeof value !== "number" || !Number.isFinite(value)) {
124
+ errors.push(
125
+ createIssue(
126
+ ValidationCodes.INVALID_VALUE,
127
+ path,
128
+ "Must be a finite number",
129
+ value
130
+ )
131
+ );
132
+ return;
133
+ }
134
+ if (min != null) {
135
+ const failsMin = minInclusive ? value < min : value <= min;
136
+ if (failsMin) {
137
+ errors.push(
138
+ createIssue(
139
+ ValidationCodes.INVALID_RANGE,
140
+ path,
141
+ minInclusive ? `Must be >= ${min}` : `Must be > ${min}`,
142
+ value
143
+ )
144
+ );
145
+ return;
146
+ }
147
+ }
148
+ if (max != null) {
149
+ const failsMax = maxInclusive ? value > max : value >= max;
150
+ if (failsMax) {
151
+ errors.push(
152
+ createIssue(
153
+ ValidationCodes.INVALID_RANGE,
154
+ path,
155
+ maxInclusive ? `Must be <= ${max}` : `Must be < ${max}`,
156
+ value
157
+ )
158
+ );
159
+ }
160
+ }
161
+ }
162
+
163
+ function validateEffectClip(clip, path, errors) {
164
+ if (!EFFECT_TYPES.includes(clip.effect)) {
165
+ errors.push(
166
+ createIssue(
167
+ ValidationCodes.INVALID_VALUE,
168
+ `${path}.effect`,
169
+ `Invalid effect '${clip.effect}'. Expected: ${EFFECT_TYPES.join(", ")}`,
170
+ clip.effect
171
+ )
172
+ );
173
+ }
174
+
175
+ if (clip.fadeIn != null) {
176
+ validateFiniteNumber(clip.fadeIn, `${path}.fadeIn`, errors, { min: 0 });
177
+ }
178
+ if (clip.fadeOut != null) {
179
+ validateFiniteNumber(clip.fadeOut, `${path}.fadeOut`, errors, { min: 0 });
180
+ }
181
+ if (typeof clip.position === "number" && typeof clip.end === "number") {
182
+ const duration = clip.end - clip.position;
183
+ const fadeTotal = (clip.fadeIn || 0) + (clip.fadeOut || 0);
184
+ if (fadeTotal > duration + 1e-9) {
185
+ errors.push(
186
+ createIssue(
187
+ ValidationCodes.INVALID_TIMELINE,
188
+ `${path}`,
189
+ `fadeIn + fadeOut (${fadeTotal}) must be <= clip duration (${duration})`,
190
+ { fadeIn: clip.fadeIn || 0, fadeOut: clip.fadeOut || 0, duration }
191
+ )
192
+ );
193
+ }
194
+ }
195
+
196
+ if (
197
+ clip.params == null ||
198
+ typeof clip.params !== "object" ||
199
+ Array.isArray(clip.params)
200
+ ) {
201
+ errors.push(
202
+ createIssue(
203
+ ValidationCodes.MISSING_REQUIRED,
204
+ `${path}.params`,
205
+ "params is required and must be an object for effect clips",
206
+ clip.params
207
+ )
208
+ );
209
+ return;
210
+ }
211
+
212
+ const params = clip.params;
213
+ if (params.amount != null) {
214
+ validateFiniteNumber(params.amount, `${path}.params.amount`, errors, {
215
+ min: 0,
216
+ max: 1,
217
+ });
218
+ }
219
+
220
+ if (clip.effect === "vignette") {
221
+ if (params.angle != null) {
222
+ validateFiniteNumber(params.angle, `${path}.params.angle`, errors, {
223
+ min: 0,
224
+ max: 6.283185307179586,
225
+ });
226
+ }
227
+ } else if (clip.effect === "filmGrain") {
228
+ if (params.strength != null) {
229
+ validateFiniteNumber(params.strength, `${path}.params.strength`, errors, {
230
+ min: 0,
231
+ max: 1,
232
+ });
233
+ }
234
+ if (params.temporal != null && typeof params.temporal !== "boolean") {
235
+ errors.push(
236
+ createIssue(
237
+ ValidationCodes.INVALID_VALUE,
238
+ `${path}.params.temporal`,
239
+ "temporal must be a boolean",
240
+ params.temporal
241
+ )
242
+ );
243
+ }
244
+ } else if (clip.effect === "gaussianBlur") {
245
+ if (params.sigma != null) {
246
+ validateFiniteNumber(params.sigma, `${path}.params.sigma`, errors, {
247
+ min: 0,
248
+ max: 100,
249
+ });
250
+ }
251
+ } else if (clip.effect === "colorAdjust") {
252
+ if (params.brightness != null) {
253
+ validateFiniteNumber(params.brightness, `${path}.params.brightness`, errors, {
254
+ min: -1,
255
+ max: 1,
256
+ });
257
+ }
258
+ if (params.contrast != null) {
259
+ validateFiniteNumber(params.contrast, `${path}.params.contrast`, errors, {
260
+ min: 0,
261
+ max: 3,
262
+ });
263
+ }
264
+ if (params.saturation != null) {
265
+ validateFiniteNumber(params.saturation, `${path}.params.saturation`, errors, {
266
+ min: 0,
267
+ max: 3,
268
+ });
269
+ }
270
+ if (params.gamma != null) {
271
+ validateFiniteNumber(params.gamma, `${path}.params.gamma`, errors, {
272
+ min: 0.1,
273
+ max: 10,
274
+ });
275
+ }
276
+ } else if (clip.effect === "sepia") {
277
+ // sepia only uses amount (base param) — no extra params to validate
278
+ } else if (clip.effect === "blackAndWhite") {
279
+ if (params.contrast != null) {
280
+ validateFiniteNumber(params.contrast, `${path}.params.contrast`, errors, {
281
+ min: 0,
282
+ max: 3,
283
+ });
284
+ }
285
+ } else if (clip.effect === "sharpen") {
286
+ if (params.strength != null) {
287
+ validateFiniteNumber(params.strength, `${path}.params.strength`, errors, {
288
+ min: 0,
289
+ max: 3,
290
+ });
291
+ }
292
+ } else if (clip.effect === "chromaticAberration") {
293
+ if (params.shift != null) {
294
+ validateFiniteNumber(params.shift, `${path}.params.shift`, errors, {
295
+ min: 0,
296
+ max: 20,
297
+ });
298
+ }
299
+ } else if (clip.effect === "letterbox") {
300
+ if (params.size != null) {
301
+ validateFiniteNumber(params.size, `${path}.params.size`, errors, {
302
+ min: 0,
303
+ max: 0.5,
304
+ });
305
+ }
306
+ if (params.color != null && typeof params.color !== "string") {
307
+ errors.push(
308
+ createIssue(
309
+ ValidationCodes.INVALID_VALUE,
310
+ `${path}.params.color`,
311
+ "color must be a string",
312
+ params.color
313
+ )
314
+ );
315
+ }
316
+ }
317
+ }
318
+
141
319
  /**
142
320
  * Validate a single clip and return issues
143
321
  */
@@ -156,6 +334,8 @@ function validateClip(clip, index, options = {}) {
156
334
  "backgroundAudio",
157
335
  "image",
158
336
  "subtitle",
337
+ "color",
338
+ "effect",
159
339
  ];
160
340
 
161
341
  // Check type
@@ -227,7 +407,7 @@ function validateClip(clip, index, options = {}) {
227
407
  }
228
408
 
229
409
  // Types that require position/end on timeline
230
- const requiresTimeline = ["video", "audio", "text", "image"].includes(
410
+ const requiresTimeline = ["video", "audio", "text", "image", "color", "effect"].includes(
231
411
  clip.type
232
412
  );
233
413
 
@@ -743,7 +923,9 @@ function validateClip(clip, index, options = {}) {
743
923
  "custom",
744
924
  ];
745
925
  const kbType =
746
- typeof clip.kenBurns === "string" ? clip.kenBurns : clip.kenBurns.type;
926
+ typeof clip.kenBurns === "string"
927
+ ? clip.kenBurns
928
+ : clip.kenBurns.type;
747
929
  if (kbType && !validKenBurns.includes(kbType)) {
748
930
  errors.push(
749
931
  createIssue(
@@ -896,8 +1078,95 @@ function validateClip(clip, index, options = {}) {
896
1078
  }
897
1079
  }
898
1080
 
899
- // Video transition validation
900
- if (clip.type === "video" && clip.transition) {
1081
+ // Color clip validation
1082
+ if (clip.type === "color") {
1083
+ if (clip.color == null) {
1084
+ errors.push(
1085
+ createIssue(
1086
+ ValidationCodes.MISSING_REQUIRED,
1087
+ `${path}.color`,
1088
+ "Color is required for color clips",
1089
+ clip.color
1090
+ )
1091
+ );
1092
+ } else if (typeof clip.color === "string") {
1093
+ if (!isValidFFmpegColor(clip.color)) {
1094
+ errors.push(
1095
+ createIssue(
1096
+ ValidationCodes.INVALID_VALUE,
1097
+ `${path}.color`,
1098
+ `Invalid color "${clip.color}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
1099
+ clip.color
1100
+ )
1101
+ );
1102
+ }
1103
+ } else if (typeof clip.color === "object" && clip.color !== null) {
1104
+ const validGradientTypes = ["linear-gradient", "radial-gradient"];
1105
+ if (!clip.color.type || !validGradientTypes.includes(clip.color.type)) {
1106
+ errors.push(
1107
+ createIssue(
1108
+ ValidationCodes.INVALID_VALUE,
1109
+ `${path}.color.type`,
1110
+ `Invalid gradient type '${clip.color.type}'. Expected: ${validGradientTypes.join(", ")}`,
1111
+ clip.color.type
1112
+ )
1113
+ );
1114
+ }
1115
+ if (!Array.isArray(clip.color.colors) || clip.color.colors.length < 2) {
1116
+ errors.push(
1117
+ createIssue(
1118
+ ValidationCodes.INVALID_VALUE,
1119
+ `${path}.color.colors`,
1120
+ "Gradient colors must be an array of at least 2 color strings",
1121
+ clip.color.colors
1122
+ )
1123
+ );
1124
+ } else {
1125
+ clip.color.colors.forEach((c, ci) => {
1126
+ if (typeof c !== "string" || !isValidFFmpegColor(c)) {
1127
+ errors.push(
1128
+ createIssue(
1129
+ ValidationCodes.INVALID_VALUE,
1130
+ `${path}.color.colors[${ci}]`,
1131
+ `Invalid gradient color "${c}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB), or "random".`,
1132
+ c
1133
+ )
1134
+ );
1135
+ }
1136
+ });
1137
+ }
1138
+ if (clip.color.direction != null) {
1139
+ const validDirections = ["vertical", "horizontal"];
1140
+ if (typeof clip.color.direction !== "number" && !validDirections.includes(clip.color.direction)) {
1141
+ errors.push(
1142
+ createIssue(
1143
+ ValidationCodes.INVALID_VALUE,
1144
+ `${path}.color.direction`,
1145
+ `Invalid gradient direction '${clip.color.direction}'. Expected: "vertical", "horizontal", or a number (angle in degrees)`,
1146
+ clip.color.direction
1147
+ )
1148
+ );
1149
+ }
1150
+ }
1151
+ } else {
1152
+ errors.push(
1153
+ createIssue(
1154
+ ValidationCodes.INVALID_VALUE,
1155
+ `${path}.color`,
1156
+ "Color must be a string (flat color) or an object (gradient spec)",
1157
+ clip.color
1158
+ )
1159
+ );
1160
+ }
1161
+ }
1162
+
1163
+ if (clip.type === "effect") {
1164
+ validateEffectClip(clip, path, errors);
1165
+ }
1166
+
1167
+ // Visual clip transition validation (video, image, color)
1168
+ const visualTypes = ["video", "image", "color"];
1169
+ if (visualTypes.includes(clip.type) && clip.transition) {
901
1170
  if (typeof clip.transition.duration !== "number") {
902
1171
  errors.push(
903
1172
  createIssue(
@@ -932,65 +1201,60 @@ function validateClip(clip, index, options = {}) {
932
1201
  }
933
1202
 
934
1203
  /**
935
- * Validate timeline gaps (visual continuity)
1204
+ * Validate timeline gaps (visual continuity).
1205
+ * Uses detectVisualGaps() from gaps.js as the single source of truth
1206
+ * for gap detection logic.
936
1207
  */
937
- function validateTimelineGaps(clips, options = {}) {
938
- const { fillGaps = "none" } = options;
1208
+ function validateTimelineGaps(clips) {
939
1209
  const errors = [];
940
1210
 
941
- // Skip gap checking if fillGaps is enabled
942
- if (fillGaps !== "none") {
1211
+ // Build clip objects with original indices for error messages
1212
+ const indexed = clips.map((c, i) => ({ ...c, _origIndex: i }));
1213
+ const gaps = detectVisualGaps(indexed);
1214
+
1215
+ if (gaps.length === 0) {
943
1216
  return { errors, warnings: [] };
944
1217
  }
945
1218
 
946
- // Get visual clips (video and image)
1219
+ // Build a sorted visual clip list so we can reference neighbours in messages
947
1220
  const visual = clips
948
1221
  .map((c, i) => ({ clip: c, index: i }))
949
- .filter(({ clip }) => clip.type === "video" || clip.type === "image")
1222
+ .filter(({ clip }) => clip.type === "video" || clip.type === "image" || clip.type === "color")
950
1223
  .filter(
951
1224
  ({ clip }) =>
952
1225
  typeof clip.position === "number" && typeof clip.end === "number"
953
1226
  )
954
1227
  .sort((a, b) => a.clip.position - b.clip.position);
955
1228
 
956
- if (visual.length === 0) {
957
- return { errors, warnings: [] };
958
- }
959
-
960
- const eps = 1e-3;
961
-
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
- }
1229
+ for (const gap of gaps) {
1230
+ const isLeading = gap.start === 0 || (visual.length > 0 && gap.end <= visual[0].clip.position + 1e-3);
975
1231
 
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;
1232
+ if (isLeading && gap.start < 1e-3) {
1233
+ errors.push(
1234
+ createIssue(
1235
+ ValidationCodes.TIMELINE_GAP,
1236
+ "timeline",
1237
+ `Gap at start of visual timeline [0, ${gap.end.toFixed(
1238
+ 3
1239
+ )}s]. If intentional, fill it with a { type: "color" } clip. Otherwise, start your first clip at position 0.`,
1240
+ { start: gap.start, end: gap.end }
1241
+ )
1242
+ );
1243
+ } else {
1244
+ // Find the surrounding clip indices for a helpful message
1245
+ const before = visual.filter(v => v.clip.end <= gap.start + 1e-3);
1246
+ const after = visual.filter(v => v.clip.position >= gap.end - 1e-3);
1247
+ const prevIdx = before.length > 0 ? before[before.length - 1].index : "?";
1248
+ const nextIdx = after.length > 0 ? after[0].index : "?";
982
1249
 
983
- if (gapEnd - gapStart > eps) {
984
1250
  errors.push(
985
1251
  createIssue(
986
1252
  ValidationCodes.TIMELINE_GAP,
987
1253
  "timeline",
988
- `Gap in timeline [${gapStart.toFixed(3)}s, ${gapEnd.toFixed(
1254
+ `Gap in visual timeline [${gap.start.toFixed(3)}s, ${gap.end.toFixed(
989
1255
  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 }
1256
+ )}s] between clips[${prevIdx}] and clips[${nextIdx}]. If intentional, fill it with a { type: "color" } clip. Otherwise, adjust clip positions to remove the gap.`,
1257
+ { start: gap.start, end: gap.end }
994
1258
  )
995
1259
  );
996
1260
  }
@@ -1005,7 +1269,6 @@ function validateTimelineGaps(clips, options = {}) {
1005
1269
  * @param {Array} clips - Array of clip objects to validate
1006
1270
  * @param {Object} options - Validation options
1007
1271
  * @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI validation)
1008
- * @param {string} options.fillGaps - Gap handling mode ('none' | 'black')
1009
1272
  * @returns {Object} Validation result { valid, errors, warnings }
1010
1273
  */
1011
1274
  function validateConfig(clips, options = {}) {
@@ -1091,6 +1354,5 @@ module.exports = {
1091
1354
  formatValidationResult,
1092
1355
  ValidationCodes,
1093
1356
  isValidFFmpegColor,
1094
- normalizeFillGaps,
1095
1357
  FFMPEG_NAMED_COLORS,
1096
1358
  };
@@ -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 =
@@ -41,15 +43,31 @@ function buildBackgroundMusicMix(
41
43
  const adelay = effectivePosition * 1000;
42
44
  const trimEnd = effectiveCutFrom + (effectiveEnd - effectivePosition);
43
45
  const outLabel = `[bg${i}]`;
44
- filter += `[${inputIndex}:a]volume=${effectiveVolume},atrim=start=${effectiveCutFrom}:end=${trimEnd},adelay=${adelay}|${adelay},asetpts=PTS-STARTPTS${outLabel};`;
46
+ filter += `[${inputIndex}:a]volume=${effectiveVolume},atrim=start=${effectiveCutFrom}:end=${trimEnd},asetpts=PTS-STARTPTS,adelay=${adelay}|${adelay}${outLabel};`;
45
47
  bgLabels.push(outLabel);
46
48
  });
47
49
 
48
50
  if (bgLabels.length > 0) {
49
51
  if (existingAudioLabel) {
50
- filter += `${existingAudioLabel}${bgLabels.join("")}amix=inputs=${
51
- bgLabels.length + 1
52
- }:duration=longest[finalaudio];`;
52
+ // Generate a silence anchor from time 0 so that amix starts producing
53
+ // output immediately. Without this, amix waits for ALL inputs to have
54
+ // frames before outputting — if the existing audio (e.g. delayed video
55
+ // audio) starts later on the timeline, background music is silenced
56
+ // until that point.
57
+ const anchorDur = Math.max(projectDuration, visualEnd || 0, 0.1);
58
+ const padLabel = "[_bgmpad]";
59
+ filter += `anullsrc=cl=stereo,atrim=end=${anchorDur}${padLabel};`;
60
+
61
+ // Use normalize=0 with explicit weights so the silence anchor
62
+ // contributes no audio energy while preserving the same volume
63
+ // balance as a direct amix of the real inputs.
64
+ const realCount = bgLabels.length + 1; // bgm tracks + existing audio
65
+ const w = (1 / realCount).toFixed(6);
66
+ const weights = ["0", ...Array(realCount).fill(w)].join(" ");
67
+
68
+ filter += `${padLabel}${existingAudioLabel}${bgLabels.join("")}amix=inputs=${
69
+ bgLabels.length + 2
70
+ }:duration=longest:weights='${weights}':normalize=0[finalaudio];`;
53
71
  return { filter, finalAudioLabel: "[finalaudio]", hasAudio: true };
54
72
  }
55
73
  filter += `${bgLabels.join("")}amix=inputs=${