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.
- package/README.md +244 -137
- package/package.json +1 -1
- package/src/core/gaps.js +5 -31
- package/src/core/resolve.js +1 -1
- package/src/core/validation.js +339 -77
- package/src/ffmpeg/audio_builder.js +3 -1
- package/src/ffmpeg/bgm_builder.js +23 -5
- package/src/ffmpeg/effect_builder.js +220 -0
- package/src/ffmpeg/video_builder.js +20 -37
- package/src/lib/gradient.js +257 -0
- package/src/loaders.js +46 -1
- package/src/schema/formatter.js +2 -0
- package/src/schema/index.js +4 -0
- package/src/schema/modules/color.js +54 -0
- package/src/schema/modules/effect.js +124 -0
- package/src/simpleffmpeg.js +61 -92
- package/types/index.d.mts +110 -5
- package/types/index.d.ts +126 -5
package/src/core/validation.js
CHANGED
|
@@ -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"
|
|
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
|
-
//
|
|
900
|
-
if (clip.type === "
|
|
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
|
|
938
|
-
const { fillGaps = "none" } = options;
|
|
1208
|
+
function validateTimelineGaps(clips) {
|
|
939
1209
|
const errors = [];
|
|
940
1210
|
|
|
941
|
-
//
|
|
942
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
957
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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 [${
|
|
1254
|
+
`Gap in visual timeline [${gap.start.toFixed(3)}s, ${gap.end.toFixed(
|
|
989
1255
|
3
|
|
990
|
-
)}s] between clips[${
|
|
991
|
-
|
|
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.
|
|
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.
|
|
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}
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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=${
|