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.
@@ -1,192 +1,805 @@
1
1
  const fs = require("fs");
2
- const { ValidationError } = require("./errors");
3
2
 
4
- function validateClips(clips, validationMode = "warn", options = {}) {
5
- const { fillGaps = "none" } = options;
6
- const allowedTypes = new Set([
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
- const errors = [];
15
- const warnings = [];
54
+ "subtitle",
55
+ ];
16
56
 
17
- clips.forEach((clip, idx) => {
18
- if (!allowedTypes.has(clip.type)) {
19
- errors.push(`clip[${idx}]: invalid type '${clip.type}'`);
20
- return;
21
- }
22
-
23
- const requiresTimeline =
24
- clip.type === "video" ||
25
- clip.type === "audio" ||
26
- clip.type === "text" ||
27
- clip.type === "image";
28
- if (requiresTimeline) {
29
- if (typeof clip.position !== "number" || typeof clip.end !== "number") {
30
- errors.push(`clip[${idx}]: 'position' and 'end' must be numbers`);
31
- } else {
32
- if (clip.position < 0)
33
- errors.push(`clip[${idx}]: position must be >= 0`);
34
- if (clip.end <= clip.position)
35
- errors.push(`clip[${idx}]: end must be > position`);
36
- }
37
- } else {
38
- // music/backgroundAudio: allow missing position/end (defaults later)
39
- if (typeof clip.position === "number" && clip.position < 0) {
40
- errors.push(`clip[${idx}]: position must be >= 0`);
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
- if (
43
- typeof clip.end === "number" &&
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(`clip[${idx}]: end must be > position`);
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
- // Media clips
52
- if (
53
- clip.type === "video" ||
54
- clip.type === "audio" ||
55
- clip.type === "music" ||
56
- clip.type === "backgroundAudio" ||
57
- clip.type === "image"
58
- ) {
59
- if (typeof clip.url !== "string" || clip.url.length === 0) {
60
- errors.push(`clip[${idx}]: media 'url' is required`);
61
- } else {
62
- try {
63
- if (!fs.existsSync(clip.url)) {
64
- warnings.push(`clip[${idx}]: file not found at '${clip.url}'`);
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
- } catch (_) {}
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
- if (typeof clip.cutFrom === "number" && clip.cutFrom < 0) {
69
- errors.push(`clip[${idx}]: cutFrom must be >= 0`);
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
- (clip.type === "audio" ||
73
- clip.type === "music" ||
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(`clip[${idx}]: volume must be >= 0`);
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
- if (clip.type === "text") {
83
- // words windows
84
- if (Array.isArray(clip.words)) {
85
- clip.words.forEach((w, wi) => {
86
- if (
87
- typeof w.start !== "number" ||
88
- typeof w.end !== "number" ||
89
- typeof w.text !== "string"
90
- ) {
91
- errors.push(`clip[${idx}].words[${wi}]: invalid {text,start,end}`);
92
- return;
93
- }
94
- if (w.end <= w.start) {
95
- errors.push(`clip[${idx}].words[${wi}]: end must be > start`);
96
- }
97
- if (
98
- typeof clip.position === "number" &&
99
- typeof clip.end === "number"
100
- ) {
101
- if (w.start < clip.position || w.end > clip.end) {
102
- warnings.push(
103
- `clip[${idx}].words[${wi}]: window outside [position,end]`
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
- if (Array.isArray(clip.wordTimestamps)) {
110
- const ts = clip.wordTimestamps;
111
- for (let i = 1; i < ts.length; i++) {
112
- if (
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
- `clip[${idx}].wordTimestamps: not non-decreasing at index ${i}`
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
- if (clip.type === "image" && clip.kenBurns) {
134
- const kb = clip.kenBurns;
135
- const allowedKB = new Set([
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 type = typeof kb === "string" ? kb : kb.type;
144
- if (!allowedKB.has(type))
145
- errors.push(`clip[${idx}]: kenBurns '${type}' invalid`);
146
- }
147
- });
148
-
149
- // Visual timeline gap checks (video/image) - skip if fillGaps is enabled
150
- if (fillGaps === "none") {
151
- const visual = clips
152
- .map((c, i) => ({ c, i }))
153
- .filter(({ c }) => c.type === "video" || c.type === "image")
154
- .sort((a, b) => (a.c.position || 0) - (b.c.position || 0));
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
- // Middle gaps
168
- for (let i = 1; i < visual.length; i++) {
169
- const prev = visual[i - 1].c;
170
- const cur = visual[i].c;
171
- if ((cur.position || 0) - (prev.end || 0) > eps) {
172
- const start = prev.end || 0;
173
- const end = cur.position || 0;
174
- const msg = `visual gap [${start.toFixed(3)}, ${end.toFixed(
175
- 3
176
- )}) no video/image content between clips`;
177
- errors.push(msg);
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
- if (errors.length > 0) {
184
- const msg = `Validation failed:\n - ` + errors.join(`\n - `);
185
- throw new ValidationError(msg, { errors, warnings });
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
- if (validationMode === "warn" && warnings.length > 0) {
188
- warnings.forEach((w) => console.warn(w));
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 = { validateClips };
801
+ module.exports = {
802
+ validateConfig,
803
+ formatValidationResult,
804
+ ValidationCodes,
805
+ };