simple-ffmpegjs 0.5.2 → 0.5.3

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,15 +468,15 @@ 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
477
  // Types that require position/end on timeline
467
478
  const requiresTimeline = ["video", "audio", "text", "image", "color", "effect"].includes(
468
- clip.type
479
+ clip.type,
469
480
  );
470
481
 
471
482
  if (requiresTimeline) {
@@ -475,8 +486,8 @@ function validateClip(clip, index, options = {}) {
475
486
  ValidationCodes.MISSING_REQUIRED,
476
487
  `${path}.position`,
477
488
  "Position is required for this clip type",
478
- clip.position
479
- )
489
+ clip.position,
490
+ ),
480
491
  );
481
492
  } else if (!Number.isFinite(clip.position)) {
482
493
  errors.push(
@@ -484,8 +495,8 @@ function validateClip(clip, index, options = {}) {
484
495
  ValidationCodes.INVALID_VALUE,
485
496
  `${path}.position`,
486
497
  "Position must be a finite number (not NaN or Infinity)",
487
- clip.position
488
- )
498
+ clip.position,
499
+ ),
489
500
  );
490
501
  } else if (clip.position < 0) {
491
502
  errors.push(
@@ -493,8 +504,8 @@ function validateClip(clip, index, options = {}) {
493
504
  ValidationCodes.INVALID_RANGE,
494
505
  `${path}.position`,
495
506
  "Position must be >= 0",
496
- clip.position
497
- )
507
+ clip.position,
508
+ ),
498
509
  );
499
510
  }
500
511
 
@@ -504,8 +515,8 @@ function validateClip(clip, index, options = {}) {
504
515
  ValidationCodes.MISSING_REQUIRED,
505
516
  `${path}.end`,
506
517
  "End time is required for this clip type",
507
- clip.end
508
- )
518
+ clip.end,
519
+ ),
509
520
  );
510
521
  } else if (!Number.isFinite(clip.end)) {
511
522
  errors.push(
@@ -513,8 +524,8 @@ function validateClip(clip, index, options = {}) {
513
524
  ValidationCodes.INVALID_VALUE,
514
525
  `${path}.end`,
515
526
  "End time must be a finite number (not NaN or Infinity)",
516
- clip.end
517
- )
527
+ clip.end,
528
+ ),
518
529
  );
519
530
  } else if (Number.isFinite(clip.position) && clip.end <= clip.position) {
520
531
  errors.push(
@@ -522,8 +533,8 @@ function validateClip(clip, index, options = {}) {
522
533
  ValidationCodes.INVALID_TIMELINE,
523
534
  `${path}.end`,
524
535
  `End time (${clip.end}) must be greater than position (${clip.position})`,
525
- clip.end
526
- )
536
+ clip.end,
537
+ ),
527
538
  );
528
539
  }
529
540
  } else {
@@ -535,8 +546,8 @@ function validateClip(clip, index, options = {}) {
535
546
  ValidationCodes.INVALID_VALUE,
536
547
  `${path}.position`,
537
548
  "Position must be a finite number (not NaN or Infinity)",
538
- clip.position
539
- )
549
+ clip.position,
550
+ ),
540
551
  );
541
552
  } else if (clip.position < 0) {
542
553
  errors.push(
@@ -544,8 +555,8 @@ function validateClip(clip, index, options = {}) {
544
555
  ValidationCodes.INVALID_RANGE,
545
556
  `${path}.position`,
546
557
  "Position must be >= 0",
547
- clip.position
548
- )
558
+ clip.position,
559
+ ),
549
560
  );
550
561
  }
551
562
  }
@@ -556,8 +567,8 @@ function validateClip(clip, index, options = {}) {
556
567
  ValidationCodes.INVALID_VALUE,
557
568
  `${path}.end`,
558
569
  "End time must be a finite number (not NaN or Infinity)",
559
- clip.end
560
- )
570
+ clip.end,
571
+ ),
561
572
  );
562
573
  } else if (
563
574
  typeof clip.position === "number" &&
@@ -569,8 +580,8 @@ function validateClip(clip, index, options = {}) {
569
580
  ValidationCodes.INVALID_TIMELINE,
570
581
  `${path}.end`,
571
582
  `End time (${clip.end}) must be greater than position (${clip.position})`,
572
- clip.end
573
- )
583
+ clip.end,
584
+ ),
574
585
  );
575
586
  }
576
587
  }
@@ -585,8 +596,8 @@ function validateClip(clip, index, options = {}) {
585
596
  ValidationCodes.MISSING_REQUIRED,
586
597
  `${path}.url`,
587
598
  "URL is required for media clips",
588
- clip.url
589
- )
599
+ clip.url,
600
+ ),
590
601
  );
591
602
  } else if (!skipFileChecks) {
592
603
  try {
@@ -596,8 +607,8 @@ function validateClip(clip, index, options = {}) {
596
607
  ValidationCodes.FILE_NOT_FOUND,
597
608
  `${path}.url`,
598
609
  `File not found: '${clip.url}'`,
599
- clip.url
600
- )
610
+ clip.url,
611
+ ),
601
612
  );
602
613
  }
603
614
  } catch (_) {}
@@ -612,8 +623,8 @@ function validateClip(clip, index, options = {}) {
612
623
  ValidationCodes.INVALID_VALUE,
613
624
  `${path}.cutFrom`,
614
625
  "cutFrom must be a finite number (not NaN or Infinity)",
615
- clip.cutFrom
616
- )
626
+ clip.cutFrom,
627
+ ),
617
628
  );
618
629
  } else if (clip.cutFrom < 0) {
619
630
  errors.push(
@@ -621,8 +632,8 @@ function validateClip(clip, index, options = {}) {
621
632
  ValidationCodes.INVALID_RANGE,
622
633
  `${path}.cutFrom`,
623
634
  "cutFrom must be >= 0",
624
- clip.cutFrom
625
- )
635
+ clip.cutFrom,
636
+ ),
626
637
  );
627
638
  }
628
639
  }
@@ -637,8 +648,8 @@ function validateClip(clip, index, options = {}) {
637
648
  ValidationCodes.INVALID_VALUE,
638
649
  `${path}.volume`,
639
650
  "Volume must be a finite number (not NaN or Infinity)",
640
- clip.volume
641
- )
651
+ clip.volume,
652
+ ),
642
653
  );
643
654
  } else if (clip.volume < 0) {
644
655
  errors.push(
@@ -646,8 +657,8 @@ function validateClip(clip, index, options = {}) {
646
657
  ValidationCodes.INVALID_RANGE,
647
658
  `${path}.volume`,
648
659
  "Volume must be >= 0",
649
- clip.volume
650
- )
660
+ clip.volume,
661
+ ),
651
662
  );
652
663
  }
653
664
  }
@@ -667,8 +678,8 @@ function validateClip(clip, index, options = {}) {
667
678
  ValidationCodes.MISSING_REQUIRED,
668
679
  `${wordPath}.text`,
669
680
  "Word text is required",
670
- w.text
671
- )
681
+ w.text,
682
+ ),
672
683
  );
673
684
  }
674
685
 
@@ -678,8 +689,8 @@ function validateClip(clip, index, options = {}) {
678
689
  ValidationCodes.MISSING_REQUIRED,
679
690
  `${wordPath}.start`,
680
691
  "Word start time is required",
681
- w.start
682
- )
692
+ w.start,
693
+ ),
683
694
  );
684
695
  } else if (!Number.isFinite(w.start)) {
685
696
  errors.push(
@@ -687,8 +698,8 @@ function validateClip(clip, index, options = {}) {
687
698
  ValidationCodes.INVALID_VALUE,
688
699
  `${wordPath}.start`,
689
700
  "Word start time must be a finite number (not NaN or Infinity)",
690
- w.start
691
- )
701
+ w.start,
702
+ ),
692
703
  );
693
704
  }
694
705
 
@@ -698,8 +709,8 @@ function validateClip(clip, index, options = {}) {
698
709
  ValidationCodes.MISSING_REQUIRED,
699
710
  `${wordPath}.end`,
700
711
  "Word end time is required",
701
- w.end
702
- )
712
+ w.end,
713
+ ),
703
714
  );
704
715
  } else if (!Number.isFinite(w.end)) {
705
716
  errors.push(
@@ -707,8 +718,8 @@ function validateClip(clip, index, options = {}) {
707
718
  ValidationCodes.INVALID_VALUE,
708
719
  `${wordPath}.end`,
709
720
  "Word end time must be a finite number (not NaN or Infinity)",
710
- w.end
711
- )
721
+ w.end,
722
+ ),
712
723
  );
713
724
  }
714
725
 
@@ -722,26 +733,32 @@ function validateClip(clip, index, options = {}) {
722
733
  ValidationCodes.INVALID_WORD_TIMING,
723
734
  `${wordPath}.end`,
724
735
  `Word end (${w.end}) must be greater than start (${w.start})`,
725
- w.end
726
- )
736
+ w.end,
737
+ ),
727
738
  );
728
739
  }
729
740
 
730
741
  // Check if word is within clip bounds
742
+ // Words can use absolute timings [clip.position, clip.end]
743
+ // or relative timings [0, clipDuration]. Accept either.
731
744
  if (
732
745
  typeof w.start === "number" &&
733
746
  typeof w.end === "number" &&
734
747
  typeof clip.position === "number" &&
735
748
  typeof clip.end === "number"
736
749
  ) {
737
- if (w.start < clip.position || w.end > clip.end) {
750
+ const clipDuration = clip.end - clip.position;
751
+ const inAbsolute =
752
+ w.start >= clip.position && w.end <= clip.end;
753
+ const inRelative = w.start >= 0 && w.end <= clipDuration;
754
+ if (!inAbsolute && !inRelative) {
738
755
  warnings.push(
739
756
  createIssue(
740
757
  ValidationCodes.OUTSIDE_BOUNDS,
741
758
  wordPath,
742
- `Word timing [${w.start}, ${w.end}] outside clip bounds [${clip.position}, ${clip.end}]`,
743
- { start: w.start, end: w.end }
744
- )
759
+ `Word timing [${w.start}, ${w.end}] outside clip bounds [${clip.position}, ${clip.end}] (duration: ${clipDuration}s)`,
760
+ { start: w.start, end: w.end },
761
+ ),
745
762
  );
746
763
  }
747
764
  }
@@ -758,8 +775,8 @@ function validateClip(clip, index, options = {}) {
758
775
  ValidationCodes.INVALID_VALUE,
759
776
  `${path}.wordTimestamps[${i}]`,
760
777
  "Word timestamps must be numbers",
761
- ts[i]
762
- )
778
+ ts[i],
779
+ ),
763
780
  );
764
781
  break;
765
782
  }
@@ -769,8 +786,8 @@ function validateClip(clip, index, options = {}) {
769
786
  ValidationCodes.INVALID_WORD_TIMING,
770
787
  `${path}.wordTimestamps[${i}]`,
771
788
  `Timestamps must be non-decreasing (${ts[i - 1]} -> ${ts[i]})`,
772
- ts[i]
773
- )
789
+ ts[i],
790
+ ),
774
791
  );
775
792
  break;
776
793
  }
@@ -786,8 +803,8 @@ function validateClip(clip, index, options = {}) {
786
803
  ValidationCodes.FILE_NOT_FOUND,
787
804
  `${path}.fontFile`,
788
805
  `Font file not found: '${clip.fontFile}'. Will fall back to fontFamily.`,
789
- clip.fontFile
790
- )
806
+ clip.fontFile,
807
+ ),
791
808
  );
792
809
  }
793
810
  } catch (_) {}
@@ -804,8 +821,8 @@ function validateClip(clip, index, options = {}) {
804
821
  ValidationCodes.INVALID_VALUE,
805
822
  `${path}.text`,
806
823
  "Multiline text is only supported in karaoke mode. Newlines will be replaced with spaces.",
807
- clip.text
808
- )
824
+ clip.text,
825
+ ),
809
826
  );
810
827
  }
811
828
 
@@ -817,8 +834,8 @@ function validateClip(clip, index, options = {}) {
817
834
  ValidationCodes.INVALID_VALUE,
818
835
  `${path}.mode`,
819
836
  `Invalid mode '${clip.mode}'. Expected: ${validModes.join(", ")}`,
820
- clip.mode
821
- )
837
+ clip.mode,
838
+ ),
822
839
  );
823
840
  }
824
841
 
@@ -833,8 +850,8 @@ function validateClip(clip, index, options = {}) {
833
850
  `Invalid highlightStyle '${
834
851
  clip.highlightStyle
835
852
  }'. Expected: ${validStyles.join(", ")}`,
836
- clip.highlightStyle
837
- )
853
+ clip.highlightStyle,
854
+ ),
838
855
  );
839
856
  }
840
857
  }
@@ -863,8 +880,8 @@ function validateClip(clip, index, options = {}) {
863
880
  `Invalid animation type '${
864
881
  clip.animation.type
865
882
  }'. Expected: ${validAnimations.join(", ")}`,
866
- clip.animation.type
867
- )
883
+ clip.animation.type,
884
+ ),
868
885
  );
869
886
  }
870
887
  }
@@ -885,8 +902,8 @@ function validateClip(clip, index, options = {}) {
885
902
  ValidationCodes.INVALID_VALUE,
886
903
  `${path}.${prop}`,
887
904
  `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
- )
905
+ clip[prop],
906
+ ),
890
907
  );
891
908
  }
892
909
  }
@@ -901,8 +918,8 @@ function validateClip(clip, index, options = {}) {
901
918
  ValidationCodes.MISSING_REQUIRED,
902
919
  `${path}.url`,
903
920
  "URL is required for subtitle clips",
904
- clip.url
905
- )
921
+ clip.url,
922
+ ),
906
923
  );
907
924
  } else {
908
925
  // Check file extension
@@ -916,8 +933,8 @@ function validateClip(clip, index, options = {}) {
916
933
  `Unsupported subtitle format '.${ext}'. Expected: ${validExts
917
934
  .map((e) => "." + e)
918
935
  .join(", ")}`,
919
- clip.url
920
- )
936
+ clip.url,
937
+ ),
921
938
  );
922
939
  }
923
940
 
@@ -930,8 +947,8 @@ function validateClip(clip, index, options = {}) {
930
947
  ValidationCodes.FILE_NOT_FOUND,
931
948
  `${path}.url`,
932
949
  `Subtitle file not found: '${clip.url}'`,
933
- clip.url
934
- )
950
+ clip.url,
951
+ ),
935
952
  );
936
953
  }
937
954
  } catch (_) {}
@@ -945,8 +962,8 @@ function validateClip(clip, index, options = {}) {
945
962
  ValidationCodes.INVALID_RANGE,
946
963
  `${path}.position`,
947
964
  "Subtitle position offset must be >= 0",
948
- clip.position
949
- )
965
+ clip.position,
966
+ ),
950
967
  );
951
968
  }
952
969
 
@@ -960,8 +977,8 @@ function validateClip(clip, index, options = {}) {
960
977
  ValidationCodes.INVALID_VALUE,
961
978
  `${path}.${prop}`,
962
979
  `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
- )
980
+ clip[prop],
981
+ ),
965
982
  );
966
983
  }
967
984
  }
@@ -978,8 +995,8 @@ function validateClip(clip, index, options = {}) {
978
995
  ValidationCodes.INVALID_VALUE,
979
996
  `${path}.imageFit`,
980
997
  `Invalid imageFit '${clip.imageFit}'. Expected: ${validImageFit.join(", ")}`,
981
- clip.imageFit
982
- )
998
+ clip.imageFit,
999
+ ),
983
1000
  );
984
1001
  }
985
1002
  }
@@ -991,8 +1008,8 @@ function validateClip(clip, index, options = {}) {
991
1008
  ValidationCodes.INVALID_TYPE,
992
1009
  `${path}.blurIntensity`,
993
1010
  `blurIntensity must be a finite number`,
994
- clip.blurIntensity
995
- )
1011
+ clip.blurIntensity,
1012
+ ),
996
1013
  );
997
1014
  } else if (clip.blurIntensity <= 0) {
998
1015
  errors.push(
@@ -1000,8 +1017,8 @@ function validateClip(clip, index, options = {}) {
1000
1017
  ValidationCodes.INVALID_RANGE,
1001
1018
  `${path}.blurIntensity`,
1002
1019
  `blurIntensity must be > 0`,
1003
- clip.blurIntensity
1004
- )
1020
+ clip.blurIntensity,
1021
+ ),
1005
1022
  );
1006
1023
  }
1007
1024
  }
@@ -1027,10 +1044,10 @@ function validateClip(clip, index, options = {}) {
1027
1044
  ValidationCodes.INVALID_VALUE,
1028
1045
  `${path}.kenBurns`,
1029
1046
  `Invalid kenBurns effect '${kbType}'. Expected: ${validKenBurns.join(
1030
- ", "
1047
+ ", ",
1031
1048
  )}`,
1032
- kbType
1033
- )
1049
+ kbType,
1050
+ ),
1034
1051
  );
1035
1052
  }
1036
1053
 
@@ -1054,10 +1071,10 @@ function validateClip(clip, index, options = {}) {
1054
1071
  ValidationCodes.INVALID_VALUE,
1055
1072
  `${path}.kenBurns.anchor`,
1056
1073
  `Invalid kenBurns anchor '${anchor}'. Expected: ${validAnchors.join(
1057
- ", "
1074
+ ", ",
1058
1075
  )}`,
1059
- anchor
1060
- )
1076
+ anchor,
1077
+ ),
1061
1078
  );
1062
1079
  }
1063
1080
  }
@@ -1070,10 +1087,10 @@ function validateClip(clip, index, options = {}) {
1070
1087
  ValidationCodes.INVALID_VALUE,
1071
1088
  `${path}.kenBurns.easing`,
1072
1089
  `Invalid kenBurns easing '${easing}'. Expected: ${validEasing.join(
1073
- ", "
1090
+ ", ",
1074
1091
  )}`,
1075
- easing
1076
- )
1092
+ easing,
1093
+ ),
1077
1094
  );
1078
1095
  }
1079
1096
  }
@@ -1097,8 +1114,8 @@ function validateClip(clip, index, options = {}) {
1097
1114
  ValidationCodes.INVALID_TYPE,
1098
1115
  `${path}.kenBurns.${field}`,
1099
1116
  `kenBurns.${field} must be a finite number`,
1100
- value
1101
- )
1117
+ value,
1118
+ ),
1102
1119
  );
1103
1120
  return;
1104
1121
  }
@@ -1109,8 +1126,8 @@ function validateClip(clip, index, options = {}) {
1109
1126
  ValidationCodes.INVALID_RANGE,
1110
1127
  `${path}.kenBurns.${field}`,
1111
1128
  `kenBurns.${field} must be > 0`,
1112
- value
1113
- )
1129
+ value,
1130
+ ),
1114
1131
  );
1115
1132
  }
1116
1133
 
@@ -1119,15 +1136,15 @@ function validateClip(clip, index, options = {}) {
1119
1136
  field === "startY" ||
1120
1137
  field === "endX" ||
1121
1138
  field === "endY") &&
1122
- (value < 0 || value > 1)
1139
+ (value < 0 || value > 1)
1123
1140
  ) {
1124
1141
  errors.push(
1125
1142
  createIssue(
1126
1143
  ValidationCodes.INVALID_RANGE,
1127
1144
  `${path}.kenBurns.${field}`,
1128
1145
  `kenBurns.${field} must be between 0 and 1`,
1129
- value
1130
- )
1146
+ value,
1147
+ ),
1131
1148
  );
1132
1149
  }
1133
1150
  });
@@ -1149,7 +1166,7 @@ function validateClip(clip, index, options = {}) {
1149
1166
  strictKenBurns
1150
1167
  ? `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
1168
  : `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 }
1169
+ { width: clip.width, height: clip.height },
1153
1170
  );
1154
1171
 
1155
1172
  if (strictKenBurns) {
@@ -1166,8 +1183,8 @@ function validateClip(clip, index, options = {}) {
1166
1183
  ValidationCodes.INVALID_VALUE,
1167
1184
  `${path}`,
1168
1185
  `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
- )
1186
+ clip.url,
1187
+ ),
1171
1188
  );
1172
1189
  }
1173
1190
  }
@@ -1181,8 +1198,8 @@ function validateClip(clip, index, options = {}) {
1181
1198
  ValidationCodes.MISSING_REQUIRED,
1182
1199
  `${path}.color`,
1183
1200
  "Color is required for color clips",
1184
- clip.color
1185
- )
1201
+ clip.color,
1202
+ ),
1186
1203
  );
1187
1204
  } else if (typeof clip.color === "string") {
1188
1205
  if (!isValidFFmpegColor(clip.color)) {
@@ -1191,8 +1208,8 @@ function validateClip(clip, index, options = {}) {
1191
1208
  ValidationCodes.INVALID_VALUE,
1192
1209
  `${path}.color`,
1193
1210
  `Invalid color "${clip.color}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
1194
- clip.color
1195
- )
1211
+ clip.color,
1212
+ ),
1196
1213
  );
1197
1214
  }
1198
1215
  } else if (typeof clip.color === "object" && clip.color !== null) {
@@ -1203,8 +1220,8 @@ function validateClip(clip, index, options = {}) {
1203
1220
  ValidationCodes.INVALID_VALUE,
1204
1221
  `${path}.color.type`,
1205
1222
  `Invalid gradient type '${clip.color.type}'. Expected: ${validGradientTypes.join(", ")}`,
1206
- clip.color.type
1207
- )
1223
+ clip.color.type,
1224
+ ),
1208
1225
  );
1209
1226
  }
1210
1227
  if (!Array.isArray(clip.color.colors) || clip.color.colors.length < 2) {
@@ -1213,8 +1230,8 @@ function validateClip(clip, index, options = {}) {
1213
1230
  ValidationCodes.INVALID_VALUE,
1214
1231
  `${path}.color.colors`,
1215
1232
  "Gradient colors must be an array of at least 2 color strings",
1216
- clip.color.colors
1217
- )
1233
+ clip.color.colors,
1234
+ ),
1218
1235
  );
1219
1236
  } else {
1220
1237
  clip.color.colors.forEach((c, ci) => {
@@ -1224,8 +1241,8 @@ function validateClip(clip, index, options = {}) {
1224
1241
  ValidationCodes.INVALID_VALUE,
1225
1242
  `${path}.color.colors[${ci}]`,
1226
1243
  `Invalid gradient color "${c}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB), or "random".`,
1227
- c
1228
- )
1244
+ c,
1245
+ ),
1229
1246
  );
1230
1247
  }
1231
1248
  });
@@ -1238,8 +1255,8 @@ function validateClip(clip, index, options = {}) {
1238
1255
  ValidationCodes.INVALID_VALUE,
1239
1256
  `${path}.color.direction`,
1240
1257
  `Invalid gradient direction '${clip.color.direction}'. Expected: "vertical", "horizontal", or a number (angle in degrees)`,
1241
- clip.color.direction
1242
- )
1258
+ clip.color.direction,
1259
+ ),
1243
1260
  );
1244
1261
  }
1245
1262
  }
@@ -1249,8 +1266,8 @@ function validateClip(clip, index, options = {}) {
1249
1266
  ValidationCodes.INVALID_VALUE,
1250
1267
  `${path}.color`,
1251
1268
  "Color must be a string (flat color) or an object (gradient spec)",
1252
- clip.color
1253
- )
1269
+ clip.color,
1270
+ ),
1254
1271
  );
1255
1272
  }
1256
1273
  }
@@ -1268,8 +1285,8 @@ function validateClip(clip, index, options = {}) {
1268
1285
  ValidationCodes.INVALID_VALUE,
1269
1286
  `${path}.transition.duration`,
1270
1287
  "Transition duration must be a number",
1271
- clip.transition.duration
1272
- )
1288
+ clip.transition.duration,
1289
+ ),
1273
1290
  );
1274
1291
  } else if (!Number.isFinite(clip.transition.duration)) {
1275
1292
  errors.push(
@@ -1277,8 +1294,8 @@ function validateClip(clip, index, options = {}) {
1277
1294
  ValidationCodes.INVALID_VALUE,
1278
1295
  `${path}.transition.duration`,
1279
1296
  "Transition duration must be a finite number (not NaN or Infinity)",
1280
- clip.transition.duration
1281
- )
1297
+ clip.transition.duration,
1298
+ ),
1282
1299
  );
1283
1300
  } else if (clip.transition.duration <= 0) {
1284
1301
  errors.push(
@@ -1286,8 +1303,8 @@ function validateClip(clip, index, options = {}) {
1286
1303
  ValidationCodes.INVALID_VALUE,
1287
1304
  `${path}.transition.duration`,
1288
1305
  "Transition duration must be a positive number",
1289
- clip.transition.duration
1290
- )
1306
+ clip.transition.duration,
1307
+ ),
1291
1308
  );
1292
1309
  }
1293
1310
  }
@@ -1317,7 +1334,7 @@ function validateTimelineGaps(clips) {
1317
1334
  .filter(({ clip }) => clip.type === "video" || clip.type === "image" || clip.type === "color")
1318
1335
  .filter(
1319
1336
  ({ clip }) =>
1320
- typeof clip.position === "number" && typeof clip.end === "number"
1337
+ typeof clip.position === "number" && typeof clip.end === "number",
1321
1338
  )
1322
1339
  .sort((a, b) => a.clip.position - b.clip.position);
1323
1340
 
@@ -1330,15 +1347,15 @@ function validateTimelineGaps(clips) {
1330
1347
  ValidationCodes.TIMELINE_GAP,
1331
1348
  "timeline",
1332
1349
  `Gap at start of visual timeline [0, ${gap.end.toFixed(
1333
- 3
1350
+ 3,
1334
1351
  )}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
- )
1352
+ { start: gap.start, end: gap.end },
1353
+ ),
1337
1354
  );
1338
1355
  } else {
1339
1356
  // 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);
1357
+ const before = visual.filter((v) => v.clip.end <= gap.start + 1e-3);
1358
+ const after = visual.filter((v) => v.clip.position >= gap.end - 1e-3);
1342
1359
  const prevIdx = before.length > 0 ? before[before.length - 1].index : "?";
1343
1360
  const nextIdx = after.length > 0 ? after[0].index : "?";
1344
1361
 
@@ -1347,10 +1364,10 @@ function validateTimelineGaps(clips) {
1347
1364
  ValidationCodes.TIMELINE_GAP,
1348
1365
  "timeline",
1349
1366
  `Gap in visual timeline [${gap.start.toFixed(3)}s, ${gap.end.toFixed(
1350
- 3
1367
+ 3,
1351
1368
  )}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
- )
1369
+ { start: gap.start, end: gap.end },
1370
+ ),
1354
1371
  );
1355
1372
  }
1356
1373
  }
@@ -1377,8 +1394,8 @@ function validateConfig(clips, options = {}) {
1377
1394
  ValidationCodes.INVALID_TYPE,
1378
1395
  "clips",
1379
1396
  "Clips must be an array",
1380
- typeof clips
1381
- )
1397
+ typeof clips,
1398
+ ),
1382
1399
  );
1383
1400
  return { valid: false, errors: allErrors, warnings: allWarnings };
1384
1401
  }
@@ -1390,8 +1407,8 @@ function validateConfig(clips, options = {}) {
1390
1407
  ValidationCodes.MISSING_REQUIRED,
1391
1408
  "clips",
1392
1409
  "At least one clip is required",
1393
- []
1394
- )
1410
+ [],
1411
+ ),
1395
1412
  );
1396
1413
  return { valid: false, errors: allErrors, warnings: allWarnings };
1397
1414
  }
@@ -1408,6 +1425,47 @@ function validateConfig(clips, options = {}) {
1408
1425
  allErrors.push(...gapResult.errors);
1409
1426
  allWarnings.push(...gapResult.warnings);
1410
1427
 
1428
+ // Warn about non-visual clips positioned beyond the visual timeline
1429
+ const visualClips = clips.filter(
1430
+ (c) => c.type === "video" || c.type === "image" || c.type === "color",
1431
+ );
1432
+
1433
+ if (visualClips.length > 0) {
1434
+ const visualBaseSum = visualClips.reduce(
1435
+ (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
1436
+ 0,
1437
+ );
1438
+ const visualTransitionOverlap = visualClips.reduce((acc, c) => {
1439
+ const d =
1440
+ c.transition && typeof c.transition.duration === "number"
1441
+ ? c.transition.duration
1442
+ : 0;
1443
+ return acc + d;
1444
+ }, 0);
1445
+ const visualDuration = Math.max(0, visualBaseSum - visualTransitionOverlap);
1446
+
1447
+ if (visualDuration > 0) {
1448
+ const nonVisualTypes = ["text", "audio", "subtitle", "music", "backgroundAudio"];
1449
+ for (let i = 0; i < clips.length; i++) {
1450
+ const clip = clips[i];
1451
+ if (
1452
+ nonVisualTypes.includes(clip.type) &&
1453
+ typeof clip.position === "number" &&
1454
+ clip.position >= visualDuration
1455
+ ) {
1456
+ allWarnings.push(
1457
+ createIssue(
1458
+ ValidationCodes.OUTSIDE_BOUNDS,
1459
+ `clips[${i}]`,
1460
+ `${clip.type} clip starts at ${clip.position}s but visual timeline ends at ${visualDuration}s`,
1461
+ { position: clip.position, visualDuration },
1462
+ ),
1463
+ );
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+
1411
1469
  return {
1412
1470
  valid: allErrors.length === 0,
1413
1471
  errors: allErrors,