simple-ffmpegjs 0.5.2 → 0.5.4

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.
@@ -169,8 +169,8 @@ function validateMediaUrlExtension(clip, clipPath, errors) {
169
169
  ValidationCodes.INVALID_FORMAT,
170
170
  `${clipPath}.url`,
171
171
  `URL extension '${ext || "(none)"}' does not match clip type '${clip.type}'. Expected a ${expectedLabel} file extension, not ${oppositeLabel}.`,
172
- clip.url
173
- )
172
+ clip.url,
173
+ ),
174
174
  );
175
175
  }
176
176
  }
@@ -183,8 +183,8 @@ function validateFiniteNumber(value, path, errors, opts = {}) {
183
183
  ValidationCodes.INVALID_VALUE,
184
184
  path,
185
185
  "Must be a finite number",
186
- value
187
- )
186
+ value,
187
+ ),
188
188
  );
189
189
  return;
190
190
  }
@@ -196,8 +196,8 @@ function validateFiniteNumber(value, path, errors, opts = {}) {
196
196
  ValidationCodes.INVALID_RANGE,
197
197
  path,
198
198
  minInclusive ? `Must be >= ${min}` : `Must be > ${min}`,
199
- value
200
- )
199
+ value,
200
+ ),
201
201
  );
202
202
  return;
203
203
  }
@@ -210,8 +210,8 @@ function validateFiniteNumber(value, path, errors, opts = {}) {
210
210
  ValidationCodes.INVALID_RANGE,
211
211
  path,
212
212
  maxInclusive ? `Must be <= ${max}` : `Must be < ${max}`,
213
- value
214
- )
213
+ value,
214
+ ),
215
215
  );
216
216
  }
217
217
  }
@@ -224,8 +224,8 @@ function validateEffectClip(clip, path, errors) {
224
224
  ValidationCodes.INVALID_VALUE,
225
225
  `${path}.effect`,
226
226
  `Invalid effect '${clip.effect}'. Expected: ${EFFECT_TYPES.join(", ")}`,
227
- clip.effect
228
- )
227
+ clip.effect,
228
+ ),
229
229
  );
230
230
  }
231
231
 
@@ -244,8 +244,8 @@ function validateEffectClip(clip, path, errors) {
244
244
  ValidationCodes.INVALID_TIMELINE,
245
245
  `${path}`,
246
246
  `fadeIn + fadeOut (${fadeTotal}) must be <= clip duration (${duration})`,
247
- { fadeIn: clip.fadeIn || 0, fadeOut: clip.fadeOut || 0, duration }
248
- )
247
+ { fadeIn: clip.fadeIn || 0, fadeOut: clip.fadeOut || 0, duration },
248
+ ),
249
249
  );
250
250
  }
251
251
  }
@@ -260,8 +260,8 @@ function validateEffectClip(clip, path, errors) {
260
260
  ValidationCodes.MISSING_REQUIRED,
261
261
  `${path}.params`,
262
262
  "params is required and must be an object for effect clips",
263
- clip.params
264
- )
263
+ clip.params,
264
+ ),
265
265
  );
266
266
  return;
267
267
  }
@@ -294,8 +294,8 @@ function validateEffectClip(clip, path, errors) {
294
294
  ValidationCodes.INVALID_VALUE,
295
295
  `${path}.params.temporal`,
296
296
  "temporal must be a boolean",
297
- params.temporal
298
- )
297
+ params.temporal,
298
+ ),
299
299
  );
300
300
  }
301
301
  } else if (clip.effect === "gaussianBlur") {
@@ -360,15 +360,26 @@ function validateEffectClip(clip, path, errors) {
360
360
  max: 0.5,
361
361
  });
362
362
  }
363
- if (params.color != null && typeof params.color !== "string") {
364
- errors.push(
365
- createIssue(
366
- ValidationCodes.INVALID_VALUE,
367
- `${path}.params.color`,
368
- "color must be a string",
369
- params.color
370
- )
371
- );
363
+ if (params.color != null) {
364
+ if (typeof params.color !== "string") {
365
+ errors.push(
366
+ createIssue(
367
+ ValidationCodes.INVALID_VALUE,
368
+ `${path}.params.color`,
369
+ "color must be a string",
370
+ params.color,
371
+ ),
372
+ );
373
+ } else if (!isValidFFmpegColor(params.color)) {
374
+ errors.push(
375
+ createIssue(
376
+ ValidationCodes.INVALID_VALUE,
377
+ `${path}.params.color`,
378
+ `invalid color "${params.color}". Use a named color (e.g. "black"), hex (#RRGGBB), or color@alpha format.`,
379
+ params.color,
380
+ ),
381
+ );
382
+ }
372
383
  }
373
384
  }
374
385
  }
@@ -402,8 +413,8 @@ function validateClip(clip, index, options = {}) {
402
413
  ValidationCodes.MISSING_REQUIRED,
403
414
  `${path}.type`,
404
415
  "Clip type is required",
405
- undefined
406
- )
416
+ undefined,
417
+ ),
407
418
  );
408
419
  return { errors, warnings }; // Can't validate further without type
409
420
  }
@@ -414,8 +425,8 @@ function validateClip(clip, index, options = {}) {
414
425
  ValidationCodes.INVALID_TYPE,
415
426
  `${path}.type`,
416
427
  `Invalid clip type '${clip.type}'. Expected: ${validTypes.join(", ")}`,
417
- clip.type
418
- )
428
+ clip.type,
429
+ ),
419
430
  );
420
431
  return { errors, warnings }; // Can't validate further with invalid type
421
432
  }
@@ -428,8 +439,8 @@ function validateClip(clip, index, options = {}) {
428
439
  ValidationCodes.INVALID_VALUE,
429
440
  `${path}.duration`,
430
441
  "Duration must be a number",
431
- clip.duration
432
- )
442
+ clip.duration,
443
+ ),
433
444
  );
434
445
  } else if (!Number.isFinite(clip.duration)) {
435
446
  errors.push(
@@ -437,8 +448,8 @@ function validateClip(clip, index, options = {}) {
437
448
  ValidationCodes.INVALID_VALUE,
438
449
  `${path}.duration`,
439
450
  "Duration must be a finite number (not NaN or Infinity)",
440
- clip.duration
441
- )
451
+ clip.duration,
452
+ ),
442
453
  );
443
454
  } else if (clip.duration <= 0) {
444
455
  errors.push(
@@ -446,8 +457,8 @@ function validateClip(clip, index, options = {}) {
446
457
  ValidationCodes.INVALID_RANGE,
447
458
  `${path}.duration`,
448
459
  "Duration must be greater than 0",
449
- clip.duration
450
- )
460
+ clip.duration,
461
+ ),
451
462
  );
452
463
  }
453
464
  // Conflict check: duration + end both set
@@ -457,26 +468,51 @@ function validateClip(clip, index, options = {}) {
457
468
  ValidationCodes.INVALID_VALUE,
458
469
  `${path}`,
459
470
  "Cannot specify both 'duration' and 'end'. Use one or the other.",
460
- { duration: clip.duration, end: clip.end }
461
- )
471
+ { duration: clip.duration, end: clip.end },
472
+ ),
462
473
  );
463
474
  }
464
475
  }
465
476
 
466
- // Types that require position/end on timeline
477
+ // fullDuration validation
478
+ const fullDurationTypes = ["effect", "text"];
479
+ if (clip.fullDuration != null) {
480
+ if (clip.fullDuration !== true) {
481
+ errors.push(
482
+ createIssue(
483
+ ValidationCodes.INVALID_VALUE,
484
+ `${path}.fullDuration`,
485
+ "fullDuration must be true when specified",
486
+ clip.fullDuration,
487
+ ),
488
+ );
489
+ } else if (!fullDurationTypes.includes(clip.type)) {
490
+ errors.push(
491
+ createIssue(
492
+ ValidationCodes.INVALID_VALUE,
493
+ `${path}.fullDuration`,
494
+ `fullDuration is only supported on ${fullDurationTypes.join(", ")} clips`,
495
+ clip.type,
496
+ ),
497
+ );
498
+ }
499
+ }
500
+
501
+ // Types that require position/end on timeline (unless fullDuration is set)
502
+ const hasFullDuration = clip.fullDuration === true && fullDurationTypes.includes(clip.type);
467
503
  const requiresTimeline = ["video", "audio", "text", "image", "color", "effect"].includes(
468
- clip.type
504
+ clip.type,
469
505
  );
470
506
 
471
- if (requiresTimeline) {
507
+ if (requiresTimeline && !hasFullDuration) {
472
508
  if (typeof clip.position !== "number") {
473
509
  errors.push(
474
510
  createIssue(
475
511
  ValidationCodes.MISSING_REQUIRED,
476
512
  `${path}.position`,
477
513
  "Position is required for this clip type",
478
- clip.position
479
- )
514
+ clip.position,
515
+ ),
480
516
  );
481
517
  } else if (!Number.isFinite(clip.position)) {
482
518
  errors.push(
@@ -484,8 +520,8 @@ function validateClip(clip, index, options = {}) {
484
520
  ValidationCodes.INVALID_VALUE,
485
521
  `${path}.position`,
486
522
  "Position must be a finite number (not NaN or Infinity)",
487
- clip.position
488
- )
523
+ clip.position,
524
+ ),
489
525
  );
490
526
  } else if (clip.position < 0) {
491
527
  errors.push(
@@ -493,8 +529,8 @@ function validateClip(clip, index, options = {}) {
493
529
  ValidationCodes.INVALID_RANGE,
494
530
  `${path}.position`,
495
531
  "Position must be >= 0",
496
- clip.position
497
- )
532
+ clip.position,
533
+ ),
498
534
  );
499
535
  }
500
536
 
@@ -504,8 +540,8 @@ function validateClip(clip, index, options = {}) {
504
540
  ValidationCodes.MISSING_REQUIRED,
505
541
  `${path}.end`,
506
542
  "End time is required for this clip type",
507
- clip.end
508
- )
543
+ clip.end,
544
+ ),
509
545
  );
510
546
  } else if (!Number.isFinite(clip.end)) {
511
547
  errors.push(
@@ -513,8 +549,8 @@ function validateClip(clip, index, options = {}) {
513
549
  ValidationCodes.INVALID_VALUE,
514
550
  `${path}.end`,
515
551
  "End time must be a finite number (not NaN or Infinity)",
516
- clip.end
517
- )
552
+ clip.end,
553
+ ),
518
554
  );
519
555
  } else if (Number.isFinite(clip.position) && clip.end <= clip.position) {
520
556
  errors.push(
@@ -522,8 +558,8 @@ function validateClip(clip, index, options = {}) {
522
558
  ValidationCodes.INVALID_TIMELINE,
523
559
  `${path}.end`,
524
560
  `End time (${clip.end}) must be greater than position (${clip.position})`,
525
- clip.end
526
- )
561
+ clip.end,
562
+ ),
527
563
  );
528
564
  }
529
565
  } else {
@@ -535,8 +571,8 @@ function validateClip(clip, index, options = {}) {
535
571
  ValidationCodes.INVALID_VALUE,
536
572
  `${path}.position`,
537
573
  "Position must be a finite number (not NaN or Infinity)",
538
- clip.position
539
- )
574
+ clip.position,
575
+ ),
540
576
  );
541
577
  } else if (clip.position < 0) {
542
578
  errors.push(
@@ -544,8 +580,8 @@ function validateClip(clip, index, options = {}) {
544
580
  ValidationCodes.INVALID_RANGE,
545
581
  `${path}.position`,
546
582
  "Position must be >= 0",
547
- clip.position
548
- )
583
+ clip.position,
584
+ ),
549
585
  );
550
586
  }
551
587
  }
@@ -556,8 +592,8 @@ function validateClip(clip, index, options = {}) {
556
592
  ValidationCodes.INVALID_VALUE,
557
593
  `${path}.end`,
558
594
  "End time must be a finite number (not NaN or Infinity)",
559
- clip.end
560
- )
595
+ clip.end,
596
+ ),
561
597
  );
562
598
  } else if (
563
599
  typeof clip.position === "number" &&
@@ -569,8 +605,8 @@ function validateClip(clip, index, options = {}) {
569
605
  ValidationCodes.INVALID_TIMELINE,
570
606
  `${path}.end`,
571
607
  `End time (${clip.end}) must be greater than position (${clip.position})`,
572
- clip.end
573
- )
608
+ clip.end,
609
+ ),
574
610
  );
575
611
  }
576
612
  }
@@ -585,8 +621,8 @@ function validateClip(clip, index, options = {}) {
585
621
  ValidationCodes.MISSING_REQUIRED,
586
622
  `${path}.url`,
587
623
  "URL is required for media clips",
588
- clip.url
589
- )
624
+ clip.url,
625
+ ),
590
626
  );
591
627
  } else if (!skipFileChecks) {
592
628
  try {
@@ -596,8 +632,8 @@ function validateClip(clip, index, options = {}) {
596
632
  ValidationCodes.FILE_NOT_FOUND,
597
633
  `${path}.url`,
598
634
  `File not found: '${clip.url}'`,
599
- clip.url
600
- )
635
+ clip.url,
636
+ ),
601
637
  );
602
638
  }
603
639
  } catch (_) {}
@@ -612,8 +648,8 @@ function validateClip(clip, index, options = {}) {
612
648
  ValidationCodes.INVALID_VALUE,
613
649
  `${path}.cutFrom`,
614
650
  "cutFrom must be a finite number (not NaN or Infinity)",
615
- clip.cutFrom
616
- )
651
+ clip.cutFrom,
652
+ ),
617
653
  );
618
654
  } else if (clip.cutFrom < 0) {
619
655
  errors.push(
@@ -621,8 +657,8 @@ function validateClip(clip, index, options = {}) {
621
657
  ValidationCodes.INVALID_RANGE,
622
658
  `${path}.cutFrom`,
623
659
  "cutFrom must be >= 0",
624
- clip.cutFrom
625
- )
660
+ clip.cutFrom,
661
+ ),
626
662
  );
627
663
  }
628
664
  }
@@ -637,8 +673,8 @@ function validateClip(clip, index, options = {}) {
637
673
  ValidationCodes.INVALID_VALUE,
638
674
  `${path}.volume`,
639
675
  "Volume must be a finite number (not NaN or Infinity)",
640
- clip.volume
641
- )
676
+ clip.volume,
677
+ ),
642
678
  );
643
679
  } else if (clip.volume < 0) {
644
680
  errors.push(
@@ -646,8 +682,8 @@ function validateClip(clip, index, options = {}) {
646
682
  ValidationCodes.INVALID_RANGE,
647
683
  `${path}.volume`,
648
684
  "Volume must be >= 0",
649
- clip.volume
650
- )
685
+ clip.volume,
686
+ ),
651
687
  );
652
688
  }
653
689
  }
@@ -667,8 +703,8 @@ function validateClip(clip, index, options = {}) {
667
703
  ValidationCodes.MISSING_REQUIRED,
668
704
  `${wordPath}.text`,
669
705
  "Word text is required",
670
- w.text
671
- )
706
+ w.text,
707
+ ),
672
708
  );
673
709
  }
674
710
 
@@ -678,8 +714,8 @@ function validateClip(clip, index, options = {}) {
678
714
  ValidationCodes.MISSING_REQUIRED,
679
715
  `${wordPath}.start`,
680
716
  "Word start time is required",
681
- w.start
682
- )
717
+ w.start,
718
+ ),
683
719
  );
684
720
  } else if (!Number.isFinite(w.start)) {
685
721
  errors.push(
@@ -687,8 +723,8 @@ function validateClip(clip, index, options = {}) {
687
723
  ValidationCodes.INVALID_VALUE,
688
724
  `${wordPath}.start`,
689
725
  "Word start time must be a finite number (not NaN or Infinity)",
690
- w.start
691
- )
726
+ w.start,
727
+ ),
692
728
  );
693
729
  }
694
730
 
@@ -698,8 +734,8 @@ function validateClip(clip, index, options = {}) {
698
734
  ValidationCodes.MISSING_REQUIRED,
699
735
  `${wordPath}.end`,
700
736
  "Word end time is required",
701
- w.end
702
- )
737
+ w.end,
738
+ ),
703
739
  );
704
740
  } else if (!Number.isFinite(w.end)) {
705
741
  errors.push(
@@ -707,8 +743,8 @@ function validateClip(clip, index, options = {}) {
707
743
  ValidationCodes.INVALID_VALUE,
708
744
  `${wordPath}.end`,
709
745
  "Word end time must be a finite number (not NaN or Infinity)",
710
- w.end
711
- )
746
+ w.end,
747
+ ),
712
748
  );
713
749
  }
714
750
 
@@ -722,26 +758,32 @@ function validateClip(clip, index, options = {}) {
722
758
  ValidationCodes.INVALID_WORD_TIMING,
723
759
  `${wordPath}.end`,
724
760
  `Word end (${w.end}) must be greater than start (${w.start})`,
725
- w.end
726
- )
761
+ w.end,
762
+ ),
727
763
  );
728
764
  }
729
765
 
730
766
  // Check if word is within clip bounds
767
+ // Words can use absolute timings [clip.position, clip.end]
768
+ // or relative timings [0, clipDuration]. Accept either.
731
769
  if (
732
770
  typeof w.start === "number" &&
733
771
  typeof w.end === "number" &&
734
772
  typeof clip.position === "number" &&
735
773
  typeof clip.end === "number"
736
774
  ) {
737
- if (w.start < clip.position || w.end > clip.end) {
775
+ const clipDuration = clip.end - clip.position;
776
+ const inAbsolute =
777
+ w.start >= clip.position && w.end <= clip.end;
778
+ const inRelative = w.start >= 0 && w.end <= clipDuration;
779
+ if (!inAbsolute && !inRelative) {
738
780
  warnings.push(
739
781
  createIssue(
740
782
  ValidationCodes.OUTSIDE_BOUNDS,
741
783
  wordPath,
742
- `Word timing [${w.start}, ${w.end}] outside clip bounds [${clip.position}, ${clip.end}]`,
743
- { start: w.start, end: w.end }
744
- )
784
+ `Word timing [${w.start}, ${w.end}] outside clip bounds [${clip.position}, ${clip.end}] (duration: ${clipDuration}s)`,
785
+ { start: w.start, end: w.end },
786
+ ),
745
787
  );
746
788
  }
747
789
  }
@@ -758,8 +800,8 @@ function validateClip(clip, index, options = {}) {
758
800
  ValidationCodes.INVALID_VALUE,
759
801
  `${path}.wordTimestamps[${i}]`,
760
802
  "Word timestamps must be numbers",
761
- ts[i]
762
- )
803
+ ts[i],
804
+ ),
763
805
  );
764
806
  break;
765
807
  }
@@ -769,8 +811,8 @@ function validateClip(clip, index, options = {}) {
769
811
  ValidationCodes.INVALID_WORD_TIMING,
770
812
  `${path}.wordTimestamps[${i}]`,
771
813
  `Timestamps must be non-decreasing (${ts[i - 1]} -> ${ts[i]})`,
772
- ts[i]
773
- )
814
+ ts[i],
815
+ ),
774
816
  );
775
817
  break;
776
818
  }
@@ -786,8 +828,8 @@ function validateClip(clip, index, options = {}) {
786
828
  ValidationCodes.FILE_NOT_FOUND,
787
829
  `${path}.fontFile`,
788
830
  `Font file not found: '${clip.fontFile}'. Will fall back to fontFamily.`,
789
- clip.fontFile
790
- )
831
+ clip.fontFile,
832
+ ),
791
833
  );
792
834
  }
793
835
  } catch (_) {}
@@ -804,8 +846,8 @@ function validateClip(clip, index, options = {}) {
804
846
  ValidationCodes.INVALID_VALUE,
805
847
  `${path}.text`,
806
848
  "Multiline text is only supported in karaoke mode. Newlines will be replaced with spaces.",
807
- clip.text
808
- )
849
+ clip.text,
850
+ ),
809
851
  );
810
852
  }
811
853
 
@@ -817,8 +859,8 @@ function validateClip(clip, index, options = {}) {
817
859
  ValidationCodes.INVALID_VALUE,
818
860
  `${path}.mode`,
819
861
  `Invalid mode '${clip.mode}'. Expected: ${validModes.join(", ")}`,
820
- clip.mode
821
- )
862
+ clip.mode,
863
+ ),
822
864
  );
823
865
  }
824
866
 
@@ -833,8 +875,8 @@ function validateClip(clip, index, options = {}) {
833
875
  `Invalid highlightStyle '${
834
876
  clip.highlightStyle
835
877
  }'. Expected: ${validStyles.join(", ")}`,
836
- clip.highlightStyle
837
- )
878
+ clip.highlightStyle,
879
+ ),
838
880
  );
839
881
  }
840
882
  }
@@ -863,8 +905,8 @@ function validateClip(clip, index, options = {}) {
863
905
  `Invalid animation type '${
864
906
  clip.animation.type
865
907
  }'. Expected: ${validAnimations.join(", ")}`,
866
- clip.animation.type
867
- )
908
+ clip.animation.type,
909
+ ),
868
910
  );
869
911
  }
870
912
  }
@@ -885,8 +927,8 @@ function validateClip(clip, index, options = {}) {
885
927
  ValidationCodes.INVALID_VALUE,
886
928
  `${path}.${prop}`,
887
929
  `Invalid color "${clip[prop]}". Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5").`,
888
- clip[prop]
889
- )
930
+ clip[prop],
931
+ ),
890
932
  );
891
933
  }
892
934
  }
@@ -901,8 +943,8 @@ function validateClip(clip, index, options = {}) {
901
943
  ValidationCodes.MISSING_REQUIRED,
902
944
  `${path}.url`,
903
945
  "URL is required for subtitle clips",
904
- clip.url
905
- )
946
+ clip.url,
947
+ ),
906
948
  );
907
949
  } else {
908
950
  // Check file extension
@@ -916,8 +958,8 @@ function validateClip(clip, index, options = {}) {
916
958
  `Unsupported subtitle format '.${ext}'. Expected: ${validExts
917
959
  .map((e) => "." + e)
918
960
  .join(", ")}`,
919
- clip.url
920
- )
961
+ clip.url,
962
+ ),
921
963
  );
922
964
  }
923
965
 
@@ -930,8 +972,8 @@ function validateClip(clip, index, options = {}) {
930
972
  ValidationCodes.FILE_NOT_FOUND,
931
973
  `${path}.url`,
932
974
  `Subtitle file not found: '${clip.url}'`,
933
- clip.url
934
- )
975
+ clip.url,
976
+ ),
935
977
  );
936
978
  }
937
979
  } catch (_) {}
@@ -945,8 +987,8 @@ function validateClip(clip, index, options = {}) {
945
987
  ValidationCodes.INVALID_RANGE,
946
988
  `${path}.position`,
947
989
  "Subtitle position offset must be >= 0",
948
- clip.position
949
- )
990
+ clip.position,
991
+ ),
950
992
  );
951
993
  }
952
994
 
@@ -960,8 +1002,8 @@ function validateClip(clip, index, options = {}) {
960
1002
  ValidationCodes.INVALID_VALUE,
961
1003
  `${path}.${prop}`,
962
1004
  `Invalid color "${clip[prop]}". Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5").`,
963
- clip[prop]
964
- )
1005
+ clip[prop],
1006
+ ),
965
1007
  );
966
1008
  }
967
1009
  }
@@ -978,8 +1020,8 @@ function validateClip(clip, index, options = {}) {
978
1020
  ValidationCodes.INVALID_VALUE,
979
1021
  `${path}.imageFit`,
980
1022
  `Invalid imageFit '${clip.imageFit}'. Expected: ${validImageFit.join(", ")}`,
981
- clip.imageFit
982
- )
1023
+ clip.imageFit,
1024
+ ),
983
1025
  );
984
1026
  }
985
1027
  }
@@ -991,8 +1033,8 @@ function validateClip(clip, index, options = {}) {
991
1033
  ValidationCodes.INVALID_TYPE,
992
1034
  `${path}.blurIntensity`,
993
1035
  `blurIntensity must be a finite number`,
994
- clip.blurIntensity
995
- )
1036
+ clip.blurIntensity,
1037
+ ),
996
1038
  );
997
1039
  } else if (clip.blurIntensity <= 0) {
998
1040
  errors.push(
@@ -1000,8 +1042,8 @@ function validateClip(clip, index, options = {}) {
1000
1042
  ValidationCodes.INVALID_RANGE,
1001
1043
  `${path}.blurIntensity`,
1002
1044
  `blurIntensity must be > 0`,
1003
- clip.blurIntensity
1004
- )
1045
+ clip.blurIntensity,
1046
+ ),
1005
1047
  );
1006
1048
  }
1007
1049
  }
@@ -1027,10 +1069,10 @@ function validateClip(clip, index, options = {}) {
1027
1069
  ValidationCodes.INVALID_VALUE,
1028
1070
  `${path}.kenBurns`,
1029
1071
  `Invalid kenBurns effect '${kbType}'. Expected: ${validKenBurns.join(
1030
- ", "
1072
+ ", ",
1031
1073
  )}`,
1032
- kbType
1033
- )
1074
+ kbType,
1075
+ ),
1034
1076
  );
1035
1077
  }
1036
1078
 
@@ -1054,10 +1096,10 @@ function validateClip(clip, index, options = {}) {
1054
1096
  ValidationCodes.INVALID_VALUE,
1055
1097
  `${path}.kenBurns.anchor`,
1056
1098
  `Invalid kenBurns anchor '${anchor}'. Expected: ${validAnchors.join(
1057
- ", "
1099
+ ", ",
1058
1100
  )}`,
1059
- anchor
1060
- )
1101
+ anchor,
1102
+ ),
1061
1103
  );
1062
1104
  }
1063
1105
  }
@@ -1070,10 +1112,10 @@ function validateClip(clip, index, options = {}) {
1070
1112
  ValidationCodes.INVALID_VALUE,
1071
1113
  `${path}.kenBurns.easing`,
1072
1114
  `Invalid kenBurns easing '${easing}'. Expected: ${validEasing.join(
1073
- ", "
1115
+ ", ",
1074
1116
  )}`,
1075
- easing
1076
- )
1117
+ easing,
1118
+ ),
1077
1119
  );
1078
1120
  }
1079
1121
  }
@@ -1097,8 +1139,8 @@ function validateClip(clip, index, options = {}) {
1097
1139
  ValidationCodes.INVALID_TYPE,
1098
1140
  `${path}.kenBurns.${field}`,
1099
1141
  `kenBurns.${field} must be a finite number`,
1100
- value
1101
- )
1142
+ value,
1143
+ ),
1102
1144
  );
1103
1145
  return;
1104
1146
  }
@@ -1109,8 +1151,8 @@ function validateClip(clip, index, options = {}) {
1109
1151
  ValidationCodes.INVALID_RANGE,
1110
1152
  `${path}.kenBurns.${field}`,
1111
1153
  `kenBurns.${field} must be > 0`,
1112
- value
1113
- )
1154
+ value,
1155
+ ),
1114
1156
  );
1115
1157
  }
1116
1158
 
@@ -1119,15 +1161,15 @@ function validateClip(clip, index, options = {}) {
1119
1161
  field === "startY" ||
1120
1162
  field === "endX" ||
1121
1163
  field === "endY") &&
1122
- (value < 0 || value > 1)
1164
+ (value < 0 || value > 1)
1123
1165
  ) {
1124
1166
  errors.push(
1125
1167
  createIssue(
1126
1168
  ValidationCodes.INVALID_RANGE,
1127
1169
  `${path}.kenBurns.${field}`,
1128
1170
  `kenBurns.${field} must be between 0 and 1`,
1129
- value
1130
- )
1171
+ value,
1172
+ ),
1131
1173
  );
1132
1174
  }
1133
1175
  });
@@ -1149,7 +1191,7 @@ function validateClip(clip, index, options = {}) {
1149
1191
  strictKenBurns
1150
1192
  ? `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.`
1151
1193
  : `Image (${clip.width}x${clip.height}) will be upscaled to ${projectWidth}x${projectHeight} for Ken Burns effect. Quality may be reduced.`,
1152
- { width: clip.width, height: clip.height }
1194
+ { width: clip.width, height: clip.height },
1153
1195
  );
1154
1196
 
1155
1197
  if (strictKenBurns) {
@@ -1166,8 +1208,8 @@ function validateClip(clip, index, options = {}) {
1166
1208
  ValidationCodes.INVALID_VALUE,
1167
1209
  `${path}`,
1168
1210
  `Ken Burns effect on image - ensure source image is at least ${projectWidth}x${projectHeight}px for best quality (smaller images will be upscaled).`,
1169
- clip.url
1170
- )
1211
+ clip.url,
1212
+ ),
1171
1213
  );
1172
1214
  }
1173
1215
  }
@@ -1181,8 +1223,8 @@ function validateClip(clip, index, options = {}) {
1181
1223
  ValidationCodes.MISSING_REQUIRED,
1182
1224
  `${path}.color`,
1183
1225
  "Color is required for color clips",
1184
- clip.color
1185
- )
1226
+ clip.color,
1227
+ ),
1186
1228
  );
1187
1229
  } else if (typeof clip.color === "string") {
1188
1230
  if (!isValidFFmpegColor(clip.color)) {
@@ -1191,8 +1233,8 @@ function validateClip(clip, index, options = {}) {
1191
1233
  ValidationCodes.INVALID_VALUE,
1192
1234
  `${path}.color`,
1193
1235
  `Invalid color "${clip.color}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
1194
- clip.color
1195
- )
1236
+ clip.color,
1237
+ ),
1196
1238
  );
1197
1239
  }
1198
1240
  } else if (typeof clip.color === "object" && clip.color !== null) {
@@ -1203,8 +1245,8 @@ function validateClip(clip, index, options = {}) {
1203
1245
  ValidationCodes.INVALID_VALUE,
1204
1246
  `${path}.color.type`,
1205
1247
  `Invalid gradient type '${clip.color.type}'. Expected: ${validGradientTypes.join(", ")}`,
1206
- clip.color.type
1207
- )
1248
+ clip.color.type,
1249
+ ),
1208
1250
  );
1209
1251
  }
1210
1252
  if (!Array.isArray(clip.color.colors) || clip.color.colors.length < 2) {
@@ -1213,8 +1255,8 @@ function validateClip(clip, index, options = {}) {
1213
1255
  ValidationCodes.INVALID_VALUE,
1214
1256
  `${path}.color.colors`,
1215
1257
  "Gradient colors must be an array of at least 2 color strings",
1216
- clip.color.colors
1217
- )
1258
+ clip.color.colors,
1259
+ ),
1218
1260
  );
1219
1261
  } else {
1220
1262
  clip.color.colors.forEach((c, ci) => {
@@ -1224,8 +1266,8 @@ function validateClip(clip, index, options = {}) {
1224
1266
  ValidationCodes.INVALID_VALUE,
1225
1267
  `${path}.color.colors[${ci}]`,
1226
1268
  `Invalid gradient color "${c}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB), or "random".`,
1227
- c
1228
- )
1269
+ c,
1270
+ ),
1229
1271
  );
1230
1272
  }
1231
1273
  });
@@ -1238,8 +1280,8 @@ function validateClip(clip, index, options = {}) {
1238
1280
  ValidationCodes.INVALID_VALUE,
1239
1281
  `${path}.color.direction`,
1240
1282
  `Invalid gradient direction '${clip.color.direction}'. Expected: "vertical", "horizontal", or a number (angle in degrees)`,
1241
- clip.color.direction
1242
- )
1283
+ clip.color.direction,
1284
+ ),
1243
1285
  );
1244
1286
  }
1245
1287
  }
@@ -1249,8 +1291,8 @@ function validateClip(clip, index, options = {}) {
1249
1291
  ValidationCodes.INVALID_VALUE,
1250
1292
  `${path}.color`,
1251
1293
  "Color must be a string (flat color) or an object (gradient spec)",
1252
- clip.color
1253
- )
1294
+ clip.color,
1295
+ ),
1254
1296
  );
1255
1297
  }
1256
1298
  }
@@ -1268,8 +1310,8 @@ function validateClip(clip, index, options = {}) {
1268
1310
  ValidationCodes.INVALID_VALUE,
1269
1311
  `${path}.transition.duration`,
1270
1312
  "Transition duration must be a number",
1271
- clip.transition.duration
1272
- )
1313
+ clip.transition.duration,
1314
+ ),
1273
1315
  );
1274
1316
  } else if (!Number.isFinite(clip.transition.duration)) {
1275
1317
  errors.push(
@@ -1277,8 +1319,8 @@ function validateClip(clip, index, options = {}) {
1277
1319
  ValidationCodes.INVALID_VALUE,
1278
1320
  `${path}.transition.duration`,
1279
1321
  "Transition duration must be a finite number (not NaN or Infinity)",
1280
- clip.transition.duration
1281
- )
1322
+ clip.transition.duration,
1323
+ ),
1282
1324
  );
1283
1325
  } else if (clip.transition.duration <= 0) {
1284
1326
  errors.push(
@@ -1286,8 +1328,8 @@ function validateClip(clip, index, options = {}) {
1286
1328
  ValidationCodes.INVALID_VALUE,
1287
1329
  `${path}.transition.duration`,
1288
1330
  "Transition duration must be a positive number",
1289
- clip.transition.duration
1290
- )
1331
+ clip.transition.duration,
1332
+ ),
1291
1333
  );
1292
1334
  }
1293
1335
  }
@@ -1317,7 +1359,7 @@ function validateTimelineGaps(clips) {
1317
1359
  .filter(({ clip }) => clip.type === "video" || clip.type === "image" || clip.type === "color")
1318
1360
  .filter(
1319
1361
  ({ clip }) =>
1320
- typeof clip.position === "number" && typeof clip.end === "number"
1362
+ typeof clip.position === "number" && typeof clip.end === "number",
1321
1363
  )
1322
1364
  .sort((a, b) => a.clip.position - b.clip.position);
1323
1365
 
@@ -1330,15 +1372,15 @@ function validateTimelineGaps(clips) {
1330
1372
  ValidationCodes.TIMELINE_GAP,
1331
1373
  "timeline",
1332
1374
  `Gap at start of visual timeline [0, ${gap.end.toFixed(
1333
- 3
1375
+ 3,
1334
1376
  )}s]. If intentional, fill it with a { type: "color" } clip. Otherwise, start your first clip at position 0.`,
1335
- { start: gap.start, end: gap.end }
1336
- )
1377
+ { start: gap.start, end: gap.end },
1378
+ ),
1337
1379
  );
1338
1380
  } else {
1339
1381
  // Find the surrounding clip indices for a helpful message
1340
- const before = visual.filter(v => v.clip.end <= gap.start + 1e-3);
1341
- const after = visual.filter(v => v.clip.position >= gap.end - 1e-3);
1382
+ const before = visual.filter((v) => v.clip.end <= gap.start + 1e-3);
1383
+ const after = visual.filter((v) => v.clip.position >= gap.end - 1e-3);
1342
1384
  const prevIdx = before.length > 0 ? before[before.length - 1].index : "?";
1343
1385
  const nextIdx = after.length > 0 ? after[0].index : "?";
1344
1386
 
@@ -1347,10 +1389,10 @@ function validateTimelineGaps(clips) {
1347
1389
  ValidationCodes.TIMELINE_GAP,
1348
1390
  "timeline",
1349
1391
  `Gap in visual timeline [${gap.start.toFixed(3)}s, ${gap.end.toFixed(
1350
- 3
1392
+ 3,
1351
1393
  )}s] between clips[${prevIdx}] and clips[${nextIdx}]. If intentional, fill it with a { type: "color" } clip. Otherwise, adjust clip positions to remove the gap.`,
1352
- { start: gap.start, end: gap.end }
1353
- )
1394
+ { start: gap.start, end: gap.end },
1395
+ ),
1354
1396
  );
1355
1397
  }
1356
1398
  }
@@ -1377,8 +1419,8 @@ function validateConfig(clips, options = {}) {
1377
1419
  ValidationCodes.INVALID_TYPE,
1378
1420
  "clips",
1379
1421
  "Clips must be an array",
1380
- typeof clips
1381
- )
1422
+ typeof clips,
1423
+ ),
1382
1424
  );
1383
1425
  return { valid: false, errors: allErrors, warnings: allWarnings };
1384
1426
  }
@@ -1390,8 +1432,8 @@ function validateConfig(clips, options = {}) {
1390
1432
  ValidationCodes.MISSING_REQUIRED,
1391
1433
  "clips",
1392
1434
  "At least one clip is required",
1393
- []
1394
- )
1435
+ [],
1436
+ ),
1395
1437
  );
1396
1438
  return { valid: false, errors: allErrors, warnings: allWarnings };
1397
1439
  }
@@ -1408,6 +1450,47 @@ function validateConfig(clips, options = {}) {
1408
1450
  allErrors.push(...gapResult.errors);
1409
1451
  allWarnings.push(...gapResult.warnings);
1410
1452
 
1453
+ // Warn about non-visual clips positioned beyond the visual timeline
1454
+ const visualClips = clips.filter(
1455
+ (c) => c.type === "video" || c.type === "image" || c.type === "color",
1456
+ );
1457
+
1458
+ if (visualClips.length > 0) {
1459
+ const visualBaseSum = visualClips.reduce(
1460
+ (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
1461
+ 0,
1462
+ );
1463
+ const visualTransitionOverlap = visualClips.reduce((acc, c) => {
1464
+ const d =
1465
+ c.transition && typeof c.transition.duration === "number"
1466
+ ? c.transition.duration
1467
+ : 0;
1468
+ return acc + d;
1469
+ }, 0);
1470
+ const visualDuration = Math.max(0, visualBaseSum - visualTransitionOverlap);
1471
+
1472
+ if (visualDuration > 0) {
1473
+ const nonVisualTypes = ["text", "audio", "subtitle", "music", "backgroundAudio"];
1474
+ for (let i = 0; i < clips.length; i++) {
1475
+ const clip = clips[i];
1476
+ if (
1477
+ nonVisualTypes.includes(clip.type) &&
1478
+ typeof clip.position === "number" &&
1479
+ clip.position >= visualDuration
1480
+ ) {
1481
+ allWarnings.push(
1482
+ createIssue(
1483
+ ValidationCodes.OUTSIDE_BOUNDS,
1484
+ `clips[${i}]`,
1485
+ `${clip.type} clip starts at ${clip.position}s but visual timeline ends at ${visualDuration}s`,
1486
+ { position: clip.position, visualDuration },
1487
+ ),
1488
+ );
1489
+ }
1490
+ }
1491
+ }
1492
+ }
1493
+
1411
1494
  return {
1412
1495
  valid: allErrors.length === 0,
1413
1496
  errors: allErrors,