simple-ffmpegjs 0.2.0 → 0.3.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.
- package/README.md +706 -34
- package/package.json +3 -4
- package/src/core/constants.js +33 -0
- package/src/core/media_info.js +141 -66
- package/src/core/rotation.js +76 -7
- package/src/core/validation.js +758 -145
- package/src/ffmpeg/command_builder.js +17 -6
- package/src/ffmpeg/strings.js +52 -2
- package/src/ffmpeg/subtitle_builder.js +707 -0
- package/src/ffmpeg/text_passes.js +41 -5
- package/src/ffmpeg/text_renderer.js +149 -11
- package/src/ffmpeg/video_builder.js +3 -1
- package/src/ffmpeg/watermark_builder.js +411 -0
- package/src/loaders.js +81 -7
- package/src/simpleffmpeg.js +604 -62
- package/types/index.d.mts +266 -7
- package/types/index.d.ts +305 -9
- package/assets/example-thumbnail.jpg +0 -0
package/src/core/validation.js
CHANGED
|
@@ -1,192 +1,805 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
-
const { ValidationError } = require("./errors");
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Error/warning codes for programmatic handling
|
|
5
|
+
*/
|
|
6
|
+
const ValidationCodes = {
|
|
7
|
+
// Type errors
|
|
8
|
+
INVALID_TYPE: "INVALID_TYPE",
|
|
9
|
+
MISSING_REQUIRED: "MISSING_REQUIRED",
|
|
10
|
+
INVALID_VALUE: "INVALID_VALUE",
|
|
11
|
+
|
|
12
|
+
// Timeline errors
|
|
13
|
+
INVALID_RANGE: "INVALID_RANGE",
|
|
14
|
+
INVALID_TIMELINE: "INVALID_TIMELINE",
|
|
15
|
+
TIMELINE_GAP: "TIMELINE_GAP",
|
|
16
|
+
|
|
17
|
+
// File errors
|
|
18
|
+
FILE_NOT_FOUND: "FILE_NOT_FOUND",
|
|
19
|
+
INVALID_FORMAT: "INVALID_FORMAT",
|
|
20
|
+
|
|
21
|
+
// Word timing errors
|
|
22
|
+
INVALID_WORD_TIMING: "INVALID_WORD_TIMING",
|
|
23
|
+
OUTSIDE_BOUNDS: "OUTSIDE_BOUNDS",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a structured validation issue
|
|
28
|
+
*/
|
|
29
|
+
function createIssue(code, path, message, received = undefined) {
|
|
30
|
+
const issue = { code, path, message };
|
|
31
|
+
if (received !== undefined) {
|
|
32
|
+
issue.received = received;
|
|
33
|
+
}
|
|
34
|
+
return issue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate a single clip and return issues
|
|
39
|
+
*/
|
|
40
|
+
function validateClip(clip, index, options = {}) {
|
|
41
|
+
const { skipFileChecks = false } = options;
|
|
42
|
+
const errors = [];
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const path = `clips[${index}]`;
|
|
45
|
+
|
|
46
|
+
// Valid clip types
|
|
47
|
+
const validTypes = [
|
|
7
48
|
"video",
|
|
8
49
|
"audio",
|
|
9
50
|
"text",
|
|
10
51
|
"music",
|
|
11
52
|
"backgroundAudio",
|
|
12
53
|
"image",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const warnings = [];
|
|
54
|
+
"subtitle",
|
|
55
|
+
];
|
|
16
56
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
// Check type
|
|
58
|
+
if (!clip.type) {
|
|
59
|
+
errors.push(
|
|
60
|
+
createIssue(
|
|
61
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
62
|
+
`${path}.type`,
|
|
63
|
+
"Clip type is required",
|
|
64
|
+
undefined
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
return { errors, warnings }; // Can't validate further without type
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!validTypes.includes(clip.type)) {
|
|
71
|
+
errors.push(
|
|
72
|
+
createIssue(
|
|
73
|
+
ValidationCodes.INVALID_TYPE,
|
|
74
|
+
`${path}.type`,
|
|
75
|
+
`Invalid clip type '${clip.type}'. Expected: ${validTypes.join(", ")}`,
|
|
76
|
+
clip.type
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
return { errors, warnings }; // Can't validate further with invalid type
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Types that require position/end on timeline
|
|
83
|
+
const requiresTimeline = ["video", "audio", "text", "image"].includes(
|
|
84
|
+
clip.type
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (requiresTimeline) {
|
|
88
|
+
if (typeof clip.position !== "number") {
|
|
89
|
+
errors.push(
|
|
90
|
+
createIssue(
|
|
91
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
92
|
+
`${path}.position`,
|
|
93
|
+
"Position is required for this clip type",
|
|
94
|
+
clip.position
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
} else if (!Number.isFinite(clip.position)) {
|
|
98
|
+
errors.push(
|
|
99
|
+
createIssue(
|
|
100
|
+
ValidationCodes.INVALID_VALUE,
|
|
101
|
+
`${path}.position`,
|
|
102
|
+
"Position must be a finite number (not NaN or Infinity)",
|
|
103
|
+
clip.position
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
} else if (clip.position < 0) {
|
|
107
|
+
errors.push(
|
|
108
|
+
createIssue(
|
|
109
|
+
ValidationCodes.INVALID_RANGE,
|
|
110
|
+
`${path}.position`,
|
|
111
|
+
"Position must be >= 0",
|
|
112
|
+
clip.position
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof clip.end !== "number") {
|
|
118
|
+
errors.push(
|
|
119
|
+
createIssue(
|
|
120
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
121
|
+
`${path}.end`,
|
|
122
|
+
"End time is required for this clip type",
|
|
123
|
+
clip.end
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
} else if (!Number.isFinite(clip.end)) {
|
|
127
|
+
errors.push(
|
|
128
|
+
createIssue(
|
|
129
|
+
ValidationCodes.INVALID_VALUE,
|
|
130
|
+
`${path}.end`,
|
|
131
|
+
"End time must be a finite number (not NaN or Infinity)",
|
|
132
|
+
clip.end
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
} else if (Number.isFinite(clip.position) && clip.end <= clip.position) {
|
|
136
|
+
errors.push(
|
|
137
|
+
createIssue(
|
|
138
|
+
ValidationCodes.INVALID_TIMELINE,
|
|
139
|
+
`${path}.end`,
|
|
140
|
+
`End time (${clip.end}) must be greater than position (${clip.position})`,
|
|
141
|
+
clip.end
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// music/backgroundAudio/subtitle: position/end are optional
|
|
147
|
+
if (typeof clip.position === "number") {
|
|
148
|
+
if (!Number.isFinite(clip.position)) {
|
|
149
|
+
errors.push(
|
|
150
|
+
createIssue(
|
|
151
|
+
ValidationCodes.INVALID_VALUE,
|
|
152
|
+
`${path}.position`,
|
|
153
|
+
"Position must be a finite number (not NaN or Infinity)",
|
|
154
|
+
clip.position
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
} else if (clip.position < 0) {
|
|
158
|
+
errors.push(
|
|
159
|
+
createIssue(
|
|
160
|
+
ValidationCodes.INVALID_RANGE,
|
|
161
|
+
`${path}.position`,
|
|
162
|
+
"Position must be >= 0",
|
|
163
|
+
clip.position
|
|
164
|
+
)
|
|
165
|
+
);
|
|
41
166
|
}
|
|
42
|
-
|
|
43
|
-
|
|
167
|
+
}
|
|
168
|
+
if (typeof clip.end === "number") {
|
|
169
|
+
if (!Number.isFinite(clip.end)) {
|
|
170
|
+
errors.push(
|
|
171
|
+
createIssue(
|
|
172
|
+
ValidationCodes.INVALID_VALUE,
|
|
173
|
+
`${path}.end`,
|
|
174
|
+
"End time must be a finite number (not NaN or Infinity)",
|
|
175
|
+
clip.end
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
} else if (
|
|
44
179
|
typeof clip.position === "number" &&
|
|
180
|
+
Number.isFinite(clip.position) &&
|
|
45
181
|
clip.end <= clip.position
|
|
46
182
|
) {
|
|
47
|
-
errors.push(
|
|
183
|
+
errors.push(
|
|
184
|
+
createIssue(
|
|
185
|
+
ValidationCodes.INVALID_TIMELINE,
|
|
186
|
+
`${path}.end`,
|
|
187
|
+
`End time (${clip.end}) must be greater than position (${clip.position})`,
|
|
188
|
+
clip.end
|
|
189
|
+
)
|
|
190
|
+
);
|
|
48
191
|
}
|
|
49
192
|
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Media clips require URL
|
|
196
|
+
const mediaTypes = ["video", "audio", "music", "backgroundAudio", "image"];
|
|
197
|
+
if (mediaTypes.includes(clip.type)) {
|
|
198
|
+
if (typeof clip.url !== "string" || clip.url.length === 0) {
|
|
199
|
+
errors.push(
|
|
200
|
+
createIssue(
|
|
201
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
202
|
+
`${path}.url`,
|
|
203
|
+
"URL is required for media clips",
|
|
204
|
+
clip.url
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
} else if (!skipFileChecks) {
|
|
208
|
+
try {
|
|
209
|
+
if (!fs.existsSync(clip.url)) {
|
|
210
|
+
warnings.push(
|
|
211
|
+
createIssue(
|
|
212
|
+
ValidationCodes.FILE_NOT_FOUND,
|
|
213
|
+
`${path}.url`,
|
|
214
|
+
`File not found: '${clip.url}'`,
|
|
215
|
+
clip.url
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} catch (_) {}
|
|
220
|
+
}
|
|
50
221
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} else {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
222
|
+
if (typeof clip.cutFrom === "number") {
|
|
223
|
+
if (!Number.isFinite(clip.cutFrom)) {
|
|
224
|
+
errors.push(
|
|
225
|
+
createIssue(
|
|
226
|
+
ValidationCodes.INVALID_VALUE,
|
|
227
|
+
`${path}.cutFrom`,
|
|
228
|
+
"cutFrom must be a finite number (not NaN or Infinity)",
|
|
229
|
+
clip.cutFrom
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
} else if (clip.cutFrom < 0) {
|
|
233
|
+
errors.push(
|
|
234
|
+
createIssue(
|
|
235
|
+
ValidationCodes.INVALID_RANGE,
|
|
236
|
+
`${path}.cutFrom`,
|
|
237
|
+
"cutFrom must be >= 0",
|
|
238
|
+
clip.cutFrom
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Audio volume validation
|
|
245
|
+
const audioTypes = ["audio", "music", "backgroundAudio"];
|
|
246
|
+
if (audioTypes.includes(clip.type)) {
|
|
247
|
+
if (typeof clip.volume === "number") {
|
|
248
|
+
if (!Number.isFinite(clip.volume)) {
|
|
249
|
+
errors.push(
|
|
250
|
+
createIssue(
|
|
251
|
+
ValidationCodes.INVALID_VALUE,
|
|
252
|
+
`${path}.volume`,
|
|
253
|
+
"Volume must be a finite number (not NaN or Infinity)",
|
|
254
|
+
clip.volume
|
|
255
|
+
)
|
|
256
|
+
);
|
|
257
|
+
} else if (clip.volume < 0) {
|
|
258
|
+
errors.push(
|
|
259
|
+
createIssue(
|
|
260
|
+
ValidationCodes.INVALID_RANGE,
|
|
261
|
+
`${path}.volume`,
|
|
262
|
+
"Volume must be >= 0",
|
|
263
|
+
clip.volume
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Text clip validation
|
|
272
|
+
if (clip.type === "text") {
|
|
273
|
+
// Validate words array
|
|
274
|
+
if (Array.isArray(clip.words)) {
|
|
275
|
+
clip.words.forEach((w, wi) => {
|
|
276
|
+
const wordPath = `${path}.words[${wi}]`;
|
|
277
|
+
|
|
278
|
+
if (typeof w.text !== "string") {
|
|
279
|
+
errors.push(
|
|
280
|
+
createIssue(
|
|
281
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
282
|
+
`${wordPath}.text`,
|
|
283
|
+
"Word text is required",
|
|
284
|
+
w.text
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (typeof w.start !== "number") {
|
|
290
|
+
errors.push(
|
|
291
|
+
createIssue(
|
|
292
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
293
|
+
`${wordPath}.start`,
|
|
294
|
+
"Word start time is required",
|
|
295
|
+
w.start
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
} else if (!Number.isFinite(w.start)) {
|
|
299
|
+
errors.push(
|
|
300
|
+
createIssue(
|
|
301
|
+
ValidationCodes.INVALID_VALUE,
|
|
302
|
+
`${wordPath}.start`,
|
|
303
|
+
"Word start time must be a finite number (not NaN or Infinity)",
|
|
304
|
+
w.start
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (typeof w.end !== "number") {
|
|
310
|
+
errors.push(
|
|
311
|
+
createIssue(
|
|
312
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
313
|
+
`${wordPath}.end`,
|
|
314
|
+
"Word end time is required",
|
|
315
|
+
w.end
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
} else if (!Number.isFinite(w.end)) {
|
|
319
|
+
errors.push(
|
|
320
|
+
createIssue(
|
|
321
|
+
ValidationCodes.INVALID_VALUE,
|
|
322
|
+
`${wordPath}.end`,
|
|
323
|
+
"Word end time must be a finite number (not NaN or Infinity)",
|
|
324
|
+
w.end
|
|
325
|
+
)
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
Number.isFinite(w.start) &&
|
|
331
|
+
Number.isFinite(w.end) &&
|
|
332
|
+
w.end <= w.start
|
|
333
|
+
) {
|
|
334
|
+
errors.push(
|
|
335
|
+
createIssue(
|
|
336
|
+
ValidationCodes.INVALID_WORD_TIMING,
|
|
337
|
+
`${wordPath}.end`,
|
|
338
|
+
`Word end (${w.end}) must be greater than start (${w.start})`,
|
|
339
|
+
w.end
|
|
340
|
+
)
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Check if word is within clip bounds
|
|
345
|
+
if (
|
|
346
|
+
typeof w.start === "number" &&
|
|
347
|
+
typeof w.end === "number" &&
|
|
348
|
+
typeof clip.position === "number" &&
|
|
349
|
+
typeof clip.end === "number"
|
|
350
|
+
) {
|
|
351
|
+
if (w.start < clip.position || w.end > clip.end) {
|
|
352
|
+
warnings.push(
|
|
353
|
+
createIssue(
|
|
354
|
+
ValidationCodes.OUTSIDE_BOUNDS,
|
|
355
|
+
wordPath,
|
|
356
|
+
`Word timing [${w.start}, ${w.end}] outside clip bounds [${clip.position}, ${clip.end}]`,
|
|
357
|
+
{ start: w.start, end: w.end }
|
|
358
|
+
)
|
|
359
|
+
);
|
|
65
360
|
}
|
|
66
|
-
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Validate wordTimestamps
|
|
366
|
+
if (Array.isArray(clip.wordTimestamps)) {
|
|
367
|
+
const ts = clip.wordTimestamps;
|
|
368
|
+
for (let i = 1; i < ts.length; i++) {
|
|
369
|
+
if (typeof ts[i] !== "number" || typeof ts[i - 1] !== "number") {
|
|
370
|
+
warnings.push(
|
|
371
|
+
createIssue(
|
|
372
|
+
ValidationCodes.INVALID_VALUE,
|
|
373
|
+
`${path}.wordTimestamps[${i}]`,
|
|
374
|
+
"Word timestamps must be numbers",
|
|
375
|
+
ts[i]
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (ts[i] < ts[i - 1]) {
|
|
381
|
+
warnings.push(
|
|
382
|
+
createIssue(
|
|
383
|
+
ValidationCodes.INVALID_WORD_TIMING,
|
|
384
|
+
`${path}.wordTimestamps[${i}]`,
|
|
385
|
+
`Timestamps must be non-decreasing (${ts[i - 1]} -> ${ts[i]})`,
|
|
386
|
+
ts[i]
|
|
387
|
+
)
|
|
388
|
+
);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
67
391
|
}
|
|
68
|
-
|
|
69
|
-
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Validate fontFile
|
|
395
|
+
if (clip.fontFile && !skipFileChecks) {
|
|
396
|
+
try {
|
|
397
|
+
if (!fs.existsSync(clip.fontFile)) {
|
|
398
|
+
warnings.push(
|
|
399
|
+
createIssue(
|
|
400
|
+
ValidationCodes.FILE_NOT_FOUND,
|
|
401
|
+
`${path}.fontFile`,
|
|
402
|
+
`Font file not found: '${clip.fontFile}'. Will fall back to fontFamily.`,
|
|
403
|
+
clip.fontFile
|
|
404
|
+
)
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
} catch (_) {}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Warn about multiline text in non-karaoke modes (will be flattened to single line)
|
|
411
|
+
if (
|
|
412
|
+
clip.text &&
|
|
413
|
+
clip.mode !== "karaoke" &&
|
|
414
|
+
(clip.text.includes("\n") || clip.text.includes("\r"))
|
|
415
|
+
) {
|
|
416
|
+
warnings.push(
|
|
417
|
+
createIssue(
|
|
418
|
+
ValidationCodes.INVALID_VALUE,
|
|
419
|
+
`${path}.text`,
|
|
420
|
+
"Multiline text is only supported in karaoke mode. Newlines will be replaced with spaces.",
|
|
421
|
+
clip.text
|
|
422
|
+
)
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Validate text mode
|
|
427
|
+
const validModes = ["static", "word-replace", "word-sequential", "karaoke"];
|
|
428
|
+
if (clip.mode && !validModes.includes(clip.mode)) {
|
|
429
|
+
errors.push(
|
|
430
|
+
createIssue(
|
|
431
|
+
ValidationCodes.INVALID_VALUE,
|
|
432
|
+
`${path}.mode`,
|
|
433
|
+
`Invalid mode '${clip.mode}'. Expected: ${validModes.join(", ")}`,
|
|
434
|
+
clip.mode
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Validate karaoke-specific options
|
|
440
|
+
if (clip.mode === "karaoke") {
|
|
441
|
+
const validStyles = ["smooth", "instant"];
|
|
442
|
+
if (clip.highlightStyle && !validStyles.includes(clip.highlightStyle)) {
|
|
443
|
+
errors.push(
|
|
444
|
+
createIssue(
|
|
445
|
+
ValidationCodes.INVALID_VALUE,
|
|
446
|
+
`${path}.highlightStyle`,
|
|
447
|
+
`Invalid highlightStyle '${
|
|
448
|
+
clip.highlightStyle
|
|
449
|
+
}'. Expected: ${validStyles.join(", ")}`,
|
|
450
|
+
clip.highlightStyle
|
|
451
|
+
)
|
|
452
|
+
);
|
|
70
453
|
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Validate animation
|
|
457
|
+
if (clip.animation) {
|
|
458
|
+
const validAnimations = [
|
|
459
|
+
"none",
|
|
460
|
+
"fade-in",
|
|
461
|
+
"fade-out",
|
|
462
|
+
"fade-in-out",
|
|
463
|
+
"pop",
|
|
464
|
+
"pop-bounce",
|
|
465
|
+
"typewriter",
|
|
466
|
+
"scale-in",
|
|
467
|
+
"pulse",
|
|
468
|
+
];
|
|
71
469
|
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
clip.type === "backgroundAudio") &&
|
|
75
|
-
typeof clip.volume === "number" &&
|
|
76
|
-
clip.volume < 0
|
|
470
|
+
clip.animation.type &&
|
|
471
|
+
!validAnimations.includes(clip.animation.type)
|
|
77
472
|
) {
|
|
78
|
-
errors.push(
|
|
473
|
+
errors.push(
|
|
474
|
+
createIssue(
|
|
475
|
+
ValidationCodes.INVALID_VALUE,
|
|
476
|
+
`${path}.animation.type`,
|
|
477
|
+
`Invalid animation type '${
|
|
478
|
+
clip.animation.type
|
|
479
|
+
}'. Expected: ${validAnimations.join(", ")}`,
|
|
480
|
+
clip.animation.type
|
|
481
|
+
)
|
|
482
|
+
);
|
|
79
483
|
}
|
|
80
484
|
}
|
|
485
|
+
}
|
|
81
486
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
487
|
+
// Subtitle clip validation
|
|
488
|
+
if (clip.type === "subtitle") {
|
|
489
|
+
if (typeof clip.url !== "string" || clip.url.length === 0) {
|
|
490
|
+
errors.push(
|
|
491
|
+
createIssue(
|
|
492
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
493
|
+
`${path}.url`,
|
|
494
|
+
"URL is required for subtitle clips",
|
|
495
|
+
clip.url
|
|
496
|
+
)
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
// Check file extension
|
|
500
|
+
const ext = clip.url.split(".").pop().toLowerCase();
|
|
501
|
+
const validExts = ["srt", "vtt", "ass", "ssa"];
|
|
502
|
+
if (!validExts.includes(ext)) {
|
|
503
|
+
errors.push(
|
|
504
|
+
createIssue(
|
|
505
|
+
ValidationCodes.INVALID_FORMAT,
|
|
506
|
+
`${path}.url`,
|
|
507
|
+
`Unsupported subtitle format '.${ext}'. Expected: ${validExts
|
|
508
|
+
.map((e) => "." + e)
|
|
509
|
+
.join(", ")}`,
|
|
510
|
+
clip.url
|
|
511
|
+
)
|
|
512
|
+
);
|
|
108
513
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
typeof ts[i] === "number" &&
|
|
115
|
-
typeof ts[i - 1] === "number" &&
|
|
116
|
-
ts[i] >= ts[i - 1]
|
|
117
|
-
)
|
|
118
|
-
) {
|
|
514
|
+
|
|
515
|
+
// File existence check
|
|
516
|
+
if (!skipFileChecks) {
|
|
517
|
+
try {
|
|
518
|
+
if (!fs.existsSync(clip.url)) {
|
|
119
519
|
warnings.push(
|
|
120
|
-
|
|
520
|
+
createIssue(
|
|
521
|
+
ValidationCodes.FILE_NOT_FOUND,
|
|
522
|
+
`${path}.url`,
|
|
523
|
+
`Subtitle file not found: '${clip.url}'`,
|
|
524
|
+
clip.url
|
|
525
|
+
)
|
|
121
526
|
);
|
|
122
|
-
break;
|
|
123
527
|
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (clip.fontFile && !fs.existsSync(clip.fontFile)) {
|
|
127
|
-
warnings.push(
|
|
128
|
-
`clip[${idx}]: fontFile '${clip.fontFile}' not found; falling back to fontFamily`
|
|
129
|
-
);
|
|
528
|
+
} catch (_) {}
|
|
130
529
|
}
|
|
131
530
|
}
|
|
132
531
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
532
|
+
// Position offset validation
|
|
533
|
+
if (typeof clip.position === "number" && clip.position < 0) {
|
|
534
|
+
errors.push(
|
|
535
|
+
createIssue(
|
|
536
|
+
ValidationCodes.INVALID_RANGE,
|
|
537
|
+
`${path}.position`,
|
|
538
|
+
"Subtitle position offset must be >= 0",
|
|
539
|
+
clip.position
|
|
540
|
+
)
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Image clip validation
|
|
546
|
+
if (clip.type === "image") {
|
|
547
|
+
if (clip.kenBurns) {
|
|
548
|
+
const validKenBurns = [
|
|
136
549
|
"zoom-in",
|
|
137
550
|
"zoom-out",
|
|
138
551
|
"pan-left",
|
|
139
552
|
"pan-right",
|
|
140
553
|
"pan-up",
|
|
141
554
|
"pan-down",
|
|
142
|
-
]
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (visual.length > 0) {
|
|
157
|
-
const eps = 1e-3;
|
|
158
|
-
// Leading gap
|
|
159
|
-
if ((visual[0].c.position || 0) > eps) {
|
|
160
|
-
const start = 0;
|
|
161
|
-
const end = visual[0].c.position;
|
|
162
|
-
const msg = `visual gap [${start.toFixed(3)}, ${end.toFixed(
|
|
163
|
-
3
|
|
164
|
-
)}) — no video/image content at start`;
|
|
165
|
-
errors.push(msg);
|
|
555
|
+
];
|
|
556
|
+
const kbType =
|
|
557
|
+
typeof clip.kenBurns === "string" ? clip.kenBurns : clip.kenBurns.type;
|
|
558
|
+
if (kbType && !validKenBurns.includes(kbType)) {
|
|
559
|
+
errors.push(
|
|
560
|
+
createIssue(
|
|
561
|
+
ValidationCodes.INVALID_VALUE,
|
|
562
|
+
`${path}.kenBurns`,
|
|
563
|
+
`Invalid kenBurns effect '${kbType}'. Expected: ${validKenBurns.join(
|
|
564
|
+
", "
|
|
565
|
+
)}`,
|
|
566
|
+
kbType
|
|
567
|
+
)
|
|
568
|
+
);
|
|
166
569
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
570
|
+
|
|
571
|
+
// Check if image dimensions are provided and sufficient for project dimensions
|
|
572
|
+
// By default, undersized images are upscaled automatically (with a warning)
|
|
573
|
+
// Set strictKenBurns: true to make this an error instead
|
|
574
|
+
const projectWidth = options.width || 1920;
|
|
575
|
+
const projectHeight = options.height || 1080;
|
|
576
|
+
const strictKenBurns = options.strictKenBurns === true;
|
|
577
|
+
|
|
578
|
+
if (clip.width && clip.height) {
|
|
579
|
+
// If we know the image dimensions, check if they're large enough
|
|
580
|
+
if (clip.width < projectWidth || clip.height < projectHeight) {
|
|
581
|
+
const issue = createIssue(
|
|
582
|
+
ValidationCodes.INVALID_VALUE,
|
|
583
|
+
`${path}`,
|
|
584
|
+
strictKenBurns
|
|
585
|
+
? `Image dimensions (${clip.width}x${clip.height}) are smaller than project dimensions (${projectWidth}x${projectHeight}). Ken Burns effects require images at least as large as the output.`
|
|
586
|
+
: `Image (${clip.width}x${clip.height}) will be upscaled to ${projectWidth}x${projectHeight} for Ken Burns effect. Quality may be reduced.`,
|
|
587
|
+
{ width: clip.width, height: clip.height }
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
if (strictKenBurns) {
|
|
591
|
+
errors.push(issue);
|
|
592
|
+
} else {
|
|
593
|
+
warnings.push(issue);
|
|
594
|
+
}
|
|
178
595
|
}
|
|
596
|
+
} else if (!skipFileChecks && clip.url) {
|
|
597
|
+
// We could check file dimensions here, but that's expensive
|
|
598
|
+
// Instead, add a warning that dimensions should be verified
|
|
599
|
+
warnings.push(
|
|
600
|
+
createIssue(
|
|
601
|
+
ValidationCodes.INVALID_VALUE,
|
|
602
|
+
`${path}`,
|
|
603
|
+
`Ken Burns effect on image - ensure source image is at least ${projectWidth}x${projectHeight}px for best quality (smaller images will be upscaled).`,
|
|
604
|
+
clip.url
|
|
605
|
+
)
|
|
606
|
+
);
|
|
179
607
|
}
|
|
180
608
|
}
|
|
181
609
|
}
|
|
182
610
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
611
|
+
// Video transition validation
|
|
612
|
+
if (clip.type === "video" && clip.transition) {
|
|
613
|
+
if (typeof clip.transition.duration !== "number") {
|
|
614
|
+
errors.push(
|
|
615
|
+
createIssue(
|
|
616
|
+
ValidationCodes.INVALID_VALUE,
|
|
617
|
+
`${path}.transition.duration`,
|
|
618
|
+
"Transition duration must be a number",
|
|
619
|
+
clip.transition.duration
|
|
620
|
+
)
|
|
621
|
+
);
|
|
622
|
+
} else if (!Number.isFinite(clip.transition.duration)) {
|
|
623
|
+
errors.push(
|
|
624
|
+
createIssue(
|
|
625
|
+
ValidationCodes.INVALID_VALUE,
|
|
626
|
+
`${path}.transition.duration`,
|
|
627
|
+
"Transition duration must be a finite number (not NaN or Infinity)",
|
|
628
|
+
clip.transition.duration
|
|
629
|
+
)
|
|
630
|
+
);
|
|
631
|
+
} else if (clip.transition.duration <= 0) {
|
|
632
|
+
errors.push(
|
|
633
|
+
createIssue(
|
|
634
|
+
ValidationCodes.INVALID_VALUE,
|
|
635
|
+
`${path}.transition.duration`,
|
|
636
|
+
"Transition duration must be a positive number",
|
|
637
|
+
clip.transition.duration
|
|
638
|
+
)
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return { errors, warnings };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Validate timeline gaps (visual continuity)
|
|
648
|
+
*/
|
|
649
|
+
function validateTimelineGaps(clips, options = {}) {
|
|
650
|
+
const { fillGaps = "none" } = options;
|
|
651
|
+
const errors = [];
|
|
652
|
+
|
|
653
|
+
// Skip gap checking if fillGaps is enabled
|
|
654
|
+
if (fillGaps !== "none") {
|
|
655
|
+
return { errors, warnings: [] };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Get visual clips (video and image)
|
|
659
|
+
const visual = clips
|
|
660
|
+
.map((c, i) => ({ clip: c, index: i }))
|
|
661
|
+
.filter(({ clip }) => clip.type === "video" || clip.type === "image")
|
|
662
|
+
.filter(
|
|
663
|
+
({ clip }) =>
|
|
664
|
+
typeof clip.position === "number" && typeof clip.end === "number"
|
|
665
|
+
)
|
|
666
|
+
.sort((a, b) => a.clip.position - b.clip.position);
|
|
667
|
+
|
|
668
|
+
if (visual.length === 0) {
|
|
669
|
+
return { errors, warnings: [] };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const eps = 1e-3;
|
|
673
|
+
|
|
674
|
+
// Check for leading gap
|
|
675
|
+
if (visual[0].clip.position > eps) {
|
|
676
|
+
errors.push(
|
|
677
|
+
createIssue(
|
|
678
|
+
ValidationCodes.TIMELINE_GAP,
|
|
679
|
+
"timeline",
|
|
680
|
+
`Gap at start of timeline [0, ${visual[0].clip.position.toFixed(
|
|
681
|
+
3
|
|
682
|
+
)}s] - no video/image content. Use fillGaps: 'black' to auto-fill.`,
|
|
683
|
+
{ start: 0, end: visual[0].clip.position }
|
|
684
|
+
)
|
|
685
|
+
);
|
|
186
686
|
}
|
|
187
|
-
|
|
188
|
-
|
|
687
|
+
|
|
688
|
+
// Check for gaps between clips
|
|
689
|
+
for (let i = 1; i < visual.length; i++) {
|
|
690
|
+
const prev = visual[i - 1].clip;
|
|
691
|
+
const curr = visual[i].clip;
|
|
692
|
+
const gapStart = prev.end;
|
|
693
|
+
const gapEnd = curr.position;
|
|
694
|
+
|
|
695
|
+
if (gapEnd - gapStart > eps) {
|
|
696
|
+
errors.push(
|
|
697
|
+
createIssue(
|
|
698
|
+
ValidationCodes.TIMELINE_GAP,
|
|
699
|
+
"timeline",
|
|
700
|
+
`Gap in timeline [${gapStart.toFixed(3)}s, ${gapEnd.toFixed(
|
|
701
|
+
3
|
|
702
|
+
)}s] between clips[${visual[i - 1].index}] and clips[${
|
|
703
|
+
visual[i].index
|
|
704
|
+
}]. Use fillGaps: 'black' to auto-fill.`,
|
|
705
|
+
{ start: gapStart, end: gapEnd }
|
|
706
|
+
)
|
|
707
|
+
);
|
|
708
|
+
}
|
|
189
709
|
}
|
|
710
|
+
|
|
711
|
+
return { errors, warnings: [] };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Main validation function - validates clips and returns structured result
|
|
716
|
+
*
|
|
717
|
+
* @param {Array} clips - Array of clip objects to validate
|
|
718
|
+
* @param {Object} options - Validation options
|
|
719
|
+
* @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI validation)
|
|
720
|
+
* @param {string} options.fillGaps - Gap handling mode ('none' | 'black')
|
|
721
|
+
* @returns {Object} Validation result { valid, errors, warnings }
|
|
722
|
+
*/
|
|
723
|
+
function validateConfig(clips, options = {}) {
|
|
724
|
+
const allErrors = [];
|
|
725
|
+
const allWarnings = [];
|
|
726
|
+
|
|
727
|
+
// Check that clips is an array
|
|
728
|
+
if (!Array.isArray(clips)) {
|
|
729
|
+
allErrors.push(
|
|
730
|
+
createIssue(
|
|
731
|
+
ValidationCodes.INVALID_TYPE,
|
|
732
|
+
"clips",
|
|
733
|
+
"Clips must be an array",
|
|
734
|
+
typeof clips
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
return { valid: false, errors: allErrors, warnings: allWarnings };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Check that clips is not empty
|
|
741
|
+
if (clips.length === 0) {
|
|
742
|
+
allErrors.push(
|
|
743
|
+
createIssue(
|
|
744
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
745
|
+
"clips",
|
|
746
|
+
"At least one clip is required",
|
|
747
|
+
[]
|
|
748
|
+
)
|
|
749
|
+
);
|
|
750
|
+
return { valid: false, errors: allErrors, warnings: allWarnings };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Validate each clip
|
|
754
|
+
for (let i = 0; i < clips.length; i++) {
|
|
755
|
+
const { errors, warnings } = validateClip(clips[i], i, options);
|
|
756
|
+
allErrors.push(...errors);
|
|
757
|
+
allWarnings.push(...warnings);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Validate timeline gaps
|
|
761
|
+
const gapResult = validateTimelineGaps(clips, options);
|
|
762
|
+
allErrors.push(...gapResult.errors);
|
|
763
|
+
allWarnings.push(...gapResult.warnings);
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
valid: allErrors.length === 0,
|
|
767
|
+
errors: allErrors,
|
|
768
|
+
warnings: allWarnings,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Format validation result as human-readable string (for logging/display)
|
|
774
|
+
*/
|
|
775
|
+
function formatValidationResult(result) {
|
|
776
|
+
const lines = [];
|
|
777
|
+
|
|
778
|
+
if (result.valid) {
|
|
779
|
+
lines.push("✓ Validation passed");
|
|
780
|
+
} else {
|
|
781
|
+
lines.push("✗ Validation failed");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (result.errors.length > 0) {
|
|
785
|
+
lines.push(`\nErrors (${result.errors.length}):`);
|
|
786
|
+
result.errors.forEach((e) => {
|
|
787
|
+
lines.push(` [${e.code}] ${e.path}: ${e.message}`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (result.warnings.length > 0) {
|
|
792
|
+
lines.push(`\nWarnings (${result.warnings.length}):`);
|
|
793
|
+
result.warnings.forEach((w) => {
|
|
794
|
+
lines.push(` [${w.code}] ${w.path}: ${w.message}`);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return lines.join("\n");
|
|
190
799
|
}
|
|
191
800
|
|
|
192
|
-
module.exports = {
|
|
801
|
+
module.exports = {
|
|
802
|
+
validateConfig,
|
|
803
|
+
formatValidationResult,
|
|
804
|
+
ValidationCodes,
|
|
805
|
+
};
|