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.
@@ -9,8 +9,8 @@ const { buildVideoFilter } = require("./ffmpeg/video_builder");
9
9
  const { buildAudioForVideoClips } = require("./ffmpeg/audio_builder");
10
10
  const { buildBackgroundMusicMix } = require("./ffmpeg/bgm_builder");
11
11
  const { buildEffectFilters } = require("./ffmpeg/effect_builder");
12
+ const { buildStandaloneAudioMix } = require("./ffmpeg/standalone_audio_builder");
12
13
  const {
13
- getClipAudioString,
14
14
  hasProblematicChars,
15
15
  hasEmoji,
16
16
  stripEmoji,
@@ -87,7 +87,7 @@ class SIMPLEFFMPEG {
87
87
  console.warn(
88
88
  `Unknown platform preset '${
89
89
  options.preset
90
- }'. Valid presets: ${Object.keys(C.PLATFORM_PRESETS).join(", ")}`
90
+ }'. Valid presets: ${Object.keys(C.PLATFORM_PRESETS).join(", ")}`,
91
91
  );
92
92
  }
93
93
 
@@ -105,12 +105,12 @@ class SIMPLEFFMPEG {
105
105
  if (this.options.tempDir) {
106
106
  if (typeof this.options.tempDir !== "string") {
107
107
  throw new SimpleffmpegError(
108
- "tempDir must be a string path to an existing directory."
108
+ "tempDir must be a string path to an existing directory.",
109
109
  );
110
110
  }
111
111
  if (!fs.existsSync(this.options.tempDir)) {
112
112
  throw new SimpleffmpegError(
113
- `tempDir "${this.options.tempDir}" does not exist. Create it before constructing SIMPLEFFMPEG.`
113
+ `tempDir "${this.options.tempDir}" does not exist. Create it before constructing SIMPLEFFMPEG.`,
114
114
  );
115
115
  }
116
116
  }
@@ -119,7 +119,7 @@ class SIMPLEFFMPEG {
119
119
  const family = parseFontFamily(this.options.emojiFont);
120
120
  if (!family) {
121
121
  console.warn(
122
- `simple-ffmpeg: Could not parse font family from "${this.options.emojiFont}". Emoji will be stripped from text.`
122
+ `simple-ffmpeg: Could not parse font family from "${this.options.emojiFont}". Emoji will be stripped from text.`,
123
123
  );
124
124
  } else {
125
125
  this._emojiFontInfo = {
@@ -154,7 +154,7 @@ class SIMPLEFFMPEG {
154
154
  const escapedUrl = escapeFilePath(clip.url);
155
155
  // Gradient color clips and image clips are looped images
156
156
  if (clip.type === "image" || (clip.type === "color" && !clip._isFlatColor)) {
157
- const duration = Math.max(0, clip.end - clip.position || 0);
157
+ const duration = Math.max(0, (clip.end ?? 0) - (clip.position ?? 0));
158
158
  return `-loop 1 -t ${duration} -i "${escapedUrl}"`;
159
159
  }
160
160
  // Loop background music if specified
@@ -189,7 +189,7 @@ class SIMPLEFFMPEG {
189
189
  console.error("Error cleaning up file:", error);
190
190
  }
191
191
  }
192
- })
192
+ }),
193
193
  );
194
194
  }
195
195
 
@@ -230,6 +230,41 @@ class SIMPLEFFMPEG {
230
230
  return timestamp - this._getTransitionOffsetAt(videoClips, timestamp);
231
231
  }
232
232
 
233
+ /**
234
+ * Compensate a clip's position, end, words, and wordTimestamps for
235
+ * transition timeline compression. Returns a new clip object.
236
+ * @private
237
+ * @param {Array} videoClips - Array of video clips sorted by position
238
+ * @param {Object} clip - The clip to compensate
239
+ * @returns {Object} New clip with adjusted timings
240
+ */
241
+ _compensateClipTimings(videoClips, clip) {
242
+ const adjusted = {
243
+ ...clip,
244
+ position: this._adjustTimestampForTransitions(
245
+ videoClips,
246
+ clip.position || 0,
247
+ ),
248
+ end: this._adjustTimestampForTransitions(videoClips, clip.end || 0),
249
+ };
250
+ if (Array.isArray(clip.words)) {
251
+ adjusted.words = clip.words.map((word) => ({
252
+ ...word,
253
+ start: this._adjustTimestampForTransitions(
254
+ videoClips,
255
+ word.start || 0,
256
+ ),
257
+ end: this._adjustTimestampForTransitions(videoClips, word.end || 0),
258
+ }));
259
+ }
260
+ if (Array.isArray(clip.wordTimestamps)) {
261
+ adjusted.wordTimestamps = clip.wordTimestamps.map((ts) =>
262
+ this._adjustTimestampForTransitions(videoClips, ts),
263
+ );
264
+ }
265
+ return adjusted;
266
+ }
267
+
233
268
  /**
234
269
  * Load clips into the project for processing
235
270
  *
@@ -257,13 +292,20 @@ class SIMPLEFFMPEG {
257
292
  // Guard against concurrent load() calls
258
293
  if (this._isLoading) {
259
294
  throw new SimpleffmpegError(
260
- "Cannot call load() while another load() is in progress. Await the previous load() call first."
295
+ "Cannot call load() while another load() is in progress. Await the previous load() call first.",
261
296
  );
262
297
  }
263
298
 
264
299
  this._isLoading = true;
265
300
 
266
301
  try {
302
+ // Clear previous state for idempotent reload
303
+ this.videoOrAudioClips = [];
304
+ this.textClips = [];
305
+ this.subtitleClips = [];
306
+ this.effectClips = [];
307
+ this.filesToClean = [];
308
+
267
309
  // Resolve shorthand: duration → end, auto-sequential positioning
268
310
  const resolved = resolveClips(clipObjs);
269
311
 
@@ -299,7 +341,7 @@ class SIMPLEFFMPEG {
299
341
  resolvedClips.map((clipObj) => {
300
342
  if (clipObj.type === "video" || clipObj.type === "audio") {
301
343
  clipObj.volume = clipObj.volume != null ? clipObj.volume : 1;
302
- clipObj.cutFrom = clipObj.cutFrom || 0;
344
+ clipObj.cutFrom = clipObj.cutFrom ?? 0;
303
345
  }
304
346
  // Normalize transitions for all visual clip types
305
347
  if (
@@ -308,7 +350,7 @@ class SIMPLEFFMPEG {
308
350
  ) {
309
351
  clipObj.transition = {
310
352
  type: clipObj.transition.type || clipObj.transition,
311
- duration: clipObj.transition.duration || 0.5,
353
+ duration: clipObj.transition.duration ?? 0.5,
312
354
  };
313
355
  }
314
356
  if (clipObj.type === "video") {
@@ -335,7 +377,7 @@ class SIMPLEFFMPEG {
335
377
  if (clipObj.type === "subtitle") {
336
378
  return Loaders.loadSubtitle(this, clipObj);
337
379
  }
338
- })
380
+ }),
339
381
  );
340
382
  } finally {
341
383
  this._isLoading = false;
@@ -422,7 +464,8 @@ class SIMPLEFFMPEG {
422
464
  if (!a.position) return -1;
423
465
  if (!b.position) return 1;
424
466
  if (a.position < b.position) return -1;
425
- return 1;
467
+ if (a.position > b.position) return 1;
468
+ return 0;
426
469
  });
427
470
 
428
471
  // Handle rotation
@@ -435,7 +478,7 @@ class SIMPLEFFMPEG {
435
478
  this.filesToClean.push(unrotatedUrl);
436
479
  clip.url = unrotatedUrl;
437
480
  }
438
- })
481
+ }),
439
482
  );
440
483
 
441
484
  // Build a mapping from clip to its FFmpeg input stream index.
@@ -453,13 +496,13 @@ class SIMPLEFFMPEG {
453
496
  }
454
497
 
455
498
  const videoClips = this.videoOrAudioClips.filter(
456
- (clip) => clip.type === "video" || clip.type === "image" || clip.type === "color"
499
+ (clip) => clip.type === "video" || clip.type === "image" || clip.type === "color",
457
500
  );
458
501
  const audioClips = this.videoOrAudioClips.filter(
459
- (clip) => clip.type === "audio"
502
+ (clip) => clip.type === "audio",
460
503
  );
461
504
  const backgroundClips = this.videoOrAudioClips.filter(
462
- (clip) => clip.type === "music" || clip.type === "backgroundAudio"
505
+ (clip) => clip.type === "music" || clip.type === "backgroundAudio",
463
506
  );
464
507
 
465
508
  let filterComplex = "";
@@ -472,7 +515,7 @@ class SIMPLEFFMPEG {
472
515
  if (videoClips.length === 0) return 0;
473
516
  const baseSum = videoClips.reduce(
474
517
  (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
475
- 0
518
+ 0,
476
519
  );
477
520
  const transitionsOverlap = videoClips.reduce((acc, c) => {
478
521
  const d =
@@ -492,7 +535,7 @@ class SIMPLEFFMPEG {
492
535
  (c) =>
493
536
  c.type === "audio" ||
494
537
  c.type === "music" ||
495
- c.type === "backgroundAudio"
538
+ c.type === "backgroundAudio",
496
539
  )
497
540
  .map((c) => (typeof c.end === "number" ? c.end : 0));
498
541
  const bgOrAudioEnd = audioEnds.length > 0 ? Math.max(...audioEnds) : 0;
@@ -545,33 +588,15 @@ class SIMPLEFFMPEG {
545
588
 
546
589
  // Standalone audio clips
547
590
  if (audioClips.length > 0) {
548
- let audioString = "";
549
- let audioConcatInputs = [];
550
- audioClips.forEach((clip) => {
551
- const inputIndex = this._inputIndexMap
552
- ? this._inputIndexMap.get(clip)
553
- : this.videoOrAudioClips.indexOf(clip);
554
- const { audioStringPart, audioConcatInput } = getClipAudioString(
555
- clip,
556
- inputIndex
557
- );
558
- audioString += audioStringPart;
559
- audioConcatInputs.push(audioConcatInput);
591
+ const sares = buildStandaloneAudioMix(this, audioClips, {
592
+ compensateTransitions: exportOptions.compensateTransitions,
593
+ videoClips,
594
+ hasAudio,
595
+ finalAudioLabel,
560
596
  });
561
- if (audioConcatInputs.length > 0) {
562
- filterComplex += audioString;
563
- filterComplex += audioConcatInputs.join("");
564
- if (hasAudio) {
565
- filterComplex += `${finalAudioLabel}amix=inputs=${
566
- audioConcatInputs.length + 1
567
- }:duration=longest[finalaudio];`;
568
- finalAudioLabel = "[finalaudio]";
569
- } else {
570
- filterComplex += `amix=inputs=${audioConcatInputs.length}:duration=longest[finalaudio];`;
571
- finalAudioLabel = "[finalaudio]";
572
- hasAudio = true;
573
- }
574
- }
597
+ filterComplex += sares.filter;
598
+ finalAudioLabel = sares.finalAudioLabel || finalAudioLabel;
599
+ hasAudio = sares.hasAudio;
575
600
  }
576
601
 
577
602
  // Background music after other audio
@@ -580,7 +605,7 @@ class SIMPLEFFMPEG {
580
605
  this,
581
606
  backgroundClips,
582
607
  hasAudio ? finalAudioLabel : null,
583
- finalVisualEnd
608
+ finalVisualEnd,
584
609
  );
585
610
  filterComplex += bgres.filter;
586
611
  finalAudioLabel = bgres.finalAudioLabel || finalAudioLabel;
@@ -600,45 +625,9 @@ class SIMPLEFFMPEG {
600
625
  // Compensate text timings for transition overlap if enabled
601
626
  let adjustedTextClips = this.textClips;
602
627
  if (exportOptions.compensateTransitions && videoClips.length > 1) {
603
- adjustedTextClips = this.textClips.map((clip) => {
604
- const adjustedPosition = this._adjustTimestampForTransitions(
605
- videoClips,
606
- clip.position || 0
607
- );
608
- const adjustedEnd = this._adjustTimestampForTransitions(
609
- videoClips,
610
- clip.end || 0
611
- );
612
- // Also adjust word timings if present
613
- let adjustedWords = clip.words;
614
- if (Array.isArray(clip.words)) {
615
- adjustedWords = clip.words.map((word) => ({
616
- ...word,
617
- start: this._adjustTimestampForTransitions(
618
- videoClips,
619
- word.start || 0
620
- ),
621
- end: this._adjustTimestampForTransitions(
622
- videoClips,
623
- word.end || 0
624
- ),
625
- }));
626
- }
627
- // Also adjust wordTimestamps if present
628
- let adjustedWordTimestamps = clip.wordTimestamps;
629
- if (Array.isArray(clip.wordTimestamps)) {
630
- adjustedWordTimestamps = clip.wordTimestamps.map((ts) =>
631
- this._adjustTimestampForTransitions(videoClips, ts)
632
- );
633
- }
634
- return {
635
- ...clip,
636
- position: adjustedPosition,
637
- end: adjustedEnd,
638
- words: adjustedWords,
639
- wordTimestamps: adjustedWordTimestamps,
640
- };
641
- });
628
+ adjustedTextClips = this.textClips.map((clip) =>
629
+ this._compensateClipTimings(videoClips, clip),
630
+ );
642
631
  }
643
632
 
644
633
  // Emoji handling: opt-in via emojiFont, otherwise strip emoji from text.
@@ -660,7 +649,7 @@ class SIMPLEFFMPEG {
660
649
  } else {
661
650
  console.warn(
662
651
  `simple-ffmpeg: Text "${textContent.slice(0, 40)}..." contains emoji but uses '${animType}' animation ` +
663
- `which is not supported in ASS. Emoji will be stripped.`
652
+ `which is not supported in ASS. Emoji will be stripped.`,
664
653
  );
665
654
  drawtextClips.push({ ...clip, text: stripEmoji(textContent) });
666
655
  }
@@ -669,8 +658,8 @@ class SIMPLEFFMPEG {
669
658
  this._emojiStrippedWarned = true;
670
659
  console.warn(
671
660
  "simple-ffmpeg: Text contains emoji but no emojiFont is configured. " +
672
- "Emoji will be stripped. To render emoji, pass emojiFont in the constructor: " +
673
- "new SIMPLEFFMPEG({ emojiFont: '/path/to/NotoEmoji-Regular.ttf' })"
661
+ "Emoji will be stripped. To render emoji, pass emojiFont in the constructor: " +
662
+ "new SIMPLEFFMPEG({ emojiFont: '/path/to/NotoEmoji-Regular.ttf' })",
674
663
  );
675
664
  }
676
665
  drawtextClips.push({ ...clip, text: stripEmoji(textContent) });
@@ -680,7 +669,7 @@ class SIMPLEFFMPEG {
680
669
  if (!(clip.text || "").trim()) {
681
670
  console.warn(
682
671
  `simple-ffmpeg: Text clip at ${clip.position}s–${clip.end}s ` +
683
- `has no visible text after emoji stripping. Skipping.`
672
+ `has no visible text after emoji stripping. Skipping.`,
684
673
  );
685
674
  return false;
686
675
  }
@@ -695,7 +684,7 @@ class SIMPLEFFMPEG {
695
684
  if (hasProblematicChars(textContent)) {
696
685
  const tempPath = path.join(
697
686
  textTempBase,
698
- `.simpleffmpeg_text_${idx}_${Date.now()}.txt`
687
+ `.simpleffmpeg_text_${idx}_${Date.now()}.txt`,
699
688
  );
700
689
  const normalizedText = textContent.replace(/\r?\n/g, " ").replace(/ {2,}/g, " ");
701
690
  try {
@@ -703,7 +692,7 @@ class SIMPLEFFMPEG {
703
692
  } catch (writeError) {
704
693
  throw new SimpleffmpegError(
705
694
  `Failed to write temporary text file "${tempPath}": ${writeError.message}`,
706
- { cause: writeError }
695
+ { cause: writeError },
707
696
  );
708
697
  }
709
698
  this.filesToClean.push(tempPath);
@@ -728,7 +717,7 @@ class SIMPLEFFMPEG {
728
717
  adjustedTextClips,
729
718
  this.options.width,
730
719
  this.options.height,
731
- finalVideoLabel
720
+ finalVideoLabel,
732
721
  );
733
722
 
734
723
  // Auto-batch if filter_complex would exceed safe command length limit
@@ -737,18 +726,18 @@ class SIMPLEFFMPEG {
737
726
  // Calculate optimal batch size based on filter length
738
727
  const avgNodeLength = filterString.length / textWindows.length;
739
728
  const safeNodes = Math.floor(
740
- (C.MAX_FILTER_COMPLEX_LENGTH - filterComplex.length) / avgNodeLength
729
+ (C.MAX_FILTER_COMPLEX_LENGTH - filterComplex.length) / avgNodeLength,
741
730
  );
742
731
  exportOptions.textMaxNodesPerPass = Math.max(
743
732
  10,
744
- Math.min(safeNodes, 50)
733
+ Math.min(safeNodes, 50),
745
734
  );
746
735
  needTextPasses = true;
747
736
 
748
737
  if (exportOptions.verbose) {
749
738
  console.log(
750
739
  `simple-ffmpeg: Auto-batching text (filter too long: ${potentialLength} > ${C.MAX_FILTER_COMPLEX_LENGTH}). ` +
751
- `Using ${exportOptions.textMaxNodesPerPass} nodes per pass.`
740
+ `Using ${exportOptions.textMaxNodesPerPass} nodes per pass.`,
752
741
  );
753
742
  }
754
743
  } else {
@@ -766,18 +755,18 @@ class SIMPLEFFMPEG {
766
755
  emojiClip,
767
756
  this.options.width,
768
757
  this.options.height,
769
- emojiFont
758
+ emojiFont,
770
759
  );
771
760
  const assFilePath = path.join(
772
761
  this.options.tempDir || path.dirname(exportOptions.outputPath),
773
- `.simpleffmpeg_emoji_${i}_${Date.now()}.ass`
762
+ `.simpleffmpeg_emoji_${i}_${Date.now()}.ass`,
774
763
  );
775
764
  try {
776
765
  fs.writeFileSync(assFilePath, assContent, "utf-8");
777
766
  } catch (writeError) {
778
767
  throw new SimpleffmpegError(
779
768
  `Failed to write temporary ASS file "${assFilePath}": ${writeError.message}`,
780
- { cause: writeError }
769
+ { cause: writeError },
781
770
  );
782
771
  }
783
772
  this.filesToClean.push(assFilePath);
@@ -788,7 +777,7 @@ class SIMPLEFFMPEG {
788
777
  const uniqueLabel = `[outemoji${i}]`;
789
778
  const filter = assResult.filter.replace(
790
779
  assResult.finalLabel,
791
- uniqueLabel
780
+ uniqueLabel,
792
781
  );
793
782
  filterComplex += filter + ";";
794
783
  finalVideoLabel = uniqueLabel;
@@ -808,41 +797,7 @@ class SIMPLEFFMPEG {
808
797
  videoClips.length > 1 &&
809
798
  subClip.mode === "karaoke"
810
799
  ) {
811
- const adjustedPosition = this._adjustTimestampForTransitions(
812
- videoClips,
813
- subClip.position || 0
814
- );
815
- const adjustedEnd = this._adjustTimestampForTransitions(
816
- videoClips,
817
- subClip.end || 0
818
- );
819
- let adjustedWords = subClip.words;
820
- if (Array.isArray(subClip.words)) {
821
- adjustedWords = subClip.words.map((word) => ({
822
- ...word,
823
- start: this._adjustTimestampForTransitions(
824
- videoClips,
825
- word.start || 0
826
- ),
827
- end: this._adjustTimestampForTransitions(
828
- videoClips,
829
- word.end || 0
830
- ),
831
- }));
832
- }
833
- let adjustedWordTimestamps = subClip.wordTimestamps;
834
- if (Array.isArray(subClip.wordTimestamps)) {
835
- adjustedWordTimestamps = subClip.wordTimestamps.map((ts) =>
836
- this._adjustTimestampForTransitions(videoClips, ts)
837
- );
838
- }
839
- subClip = {
840
- ...subClip,
841
- position: adjustedPosition,
842
- end: adjustedEnd,
843
- words: adjustedWords,
844
- wordTimestamps: adjustedWordTimestamps,
845
- };
800
+ subClip = this._compensateClipTimings(videoClips, subClip);
846
801
  }
847
802
 
848
803
  let assContent = "";
@@ -860,7 +815,7 @@ class SIMPLEFFMPEG {
860
815
  subClip.url,
861
816
  subClip,
862
817
  this.options.width,
863
- this.options.height
818
+ this.options.height,
864
819
  );
865
820
  }
866
821
  } else if (subClip.mode === "karaoke") {
@@ -868,7 +823,7 @@ class SIMPLEFFMPEG {
868
823
  assContent = buildKaraokeASS(
869
824
  subClip,
870
825
  this.options.width,
871
- this.options.height
826
+ this.options.height,
872
827
  );
873
828
  }
874
829
 
@@ -876,14 +831,14 @@ class SIMPLEFFMPEG {
876
831
  if (assContent && !assFilePath) {
877
832
  assFilePath = path.join(
878
833
  this.options.tempDir || path.dirname(exportOptions.outputPath),
879
- `.simpleffmpeg_sub_${i}_${Date.now()}.ass`
834
+ `.simpleffmpeg_sub_${i}_${Date.now()}.ass`,
880
835
  );
881
836
  try {
882
837
  fs.writeFileSync(assFilePath, assContent, "utf-8");
883
838
  } catch (writeError) {
884
839
  throw new SimpleffmpegError(
885
840
  `Failed to write temporary ASS file "${assFilePath}": ${writeError.message}`,
886
- { cause: writeError }
841
+ { cause: writeError },
887
842
  );
888
843
  }
889
844
  assFilesToClean.push(assFilePath);
@@ -897,7 +852,7 @@ class SIMPLEFFMPEG {
897
852
  const uniqueLabel = `[outsub${i}]`;
898
853
  const filter = assResult.filter.replace(
899
854
  assResult.finalLabel,
900
- uniqueLabel
855
+ uniqueLabel,
901
856
  );
902
857
  filterComplex += filter + ";";
903
858
  finalVideoLabel = uniqueLabel;
@@ -913,7 +868,7 @@ class SIMPLEFFMPEG {
913
868
  const wmValidation = validateWatermarkConfig(exportOptions.watermark);
914
869
  if (!wmValidation.valid) {
915
870
  throw new Error(
916
- `Watermark validation failed: ${wmValidation.errors.join(", ")}`
871
+ `Watermark validation failed: ${wmValidation.errors.join(", ")}`,
917
872
  );
918
873
  }
919
874
 
@@ -934,7 +889,7 @@ class SIMPLEFFMPEG {
934
889
  watermarkInputIndex,
935
890
  this.options.width,
936
891
  this.options.height,
937
- totalVideoDuration
892
+ totalVideoDuration,
938
893
  );
939
894
 
940
895
  if (wmResult.filter) {
@@ -1010,12 +965,16 @@ class SIMPLEFFMPEG {
1010
965
  * @returns {Promise<{command: string, filterComplex: string, totalDuration: number}>}
1011
966
  */
1012
967
  async preview(options = {}) {
1013
- const result = await this._prepareExport(options);
1014
- return {
1015
- command: result.command,
1016
- filterComplex: result.filterComplex,
1017
- totalDuration: result.totalDuration,
1018
- };
968
+ try {
969
+ const result = await this._prepareExport(options);
970
+ return {
971
+ command: result.command,
972
+ filterComplex: result.filterComplex,
973
+ totalDuration: result.totalDuration,
974
+ };
975
+ } finally {
976
+ await this._cleanup();
977
+ }
1019
978
  }
1020
979
 
1021
980
  /**
@@ -1048,7 +1007,7 @@ class SIMPLEFFMPEG {
1048
1007
  // Guard against concurrent export() calls
1049
1008
  if (this._isExporting) {
1050
1009
  throw new SimpleffmpegError(
1051
- "Cannot call export() while another export() is in progress. Await the previous export() call first."
1010
+ "Cannot call export() while another export() is in progress. Await the previous export() call first.",
1052
1011
  );
1053
1012
  }
1054
1013
 
@@ -1082,7 +1041,7 @@ class SIMPLEFFMPEG {
1082
1041
  if (exportOptions.verbose) {
1083
1042
  console.log(
1084
1043
  "simple-ffmpeg: Export options:",
1085
- JSON.stringify(exportOptions, null, 2)
1044
+ JSON.stringify(exportOptions, null, 2),
1086
1045
  );
1087
1046
  }
1088
1047
 
@@ -1091,12 +1050,12 @@ class SIMPLEFFMPEG {
1091
1050
  try {
1092
1051
  fs.writeFileSync(exportOptions.saveCommand, command, "utf8");
1093
1052
  console.log(
1094
- `simple-ffmpeg: Command saved to ${exportOptions.saveCommand}`
1053
+ `simple-ffmpeg: Command saved to ${exportOptions.saveCommand}`,
1095
1054
  );
1096
1055
  } catch (writeError) {
1097
1056
  throw new SimpleffmpegError(
1098
1057
  `Failed to save command to "${exportOptions.saveCommand}": ${writeError.message}`,
1099
- { cause: writeError }
1058
+ { cause: writeError },
1100
1059
  );
1101
1060
  }
1102
1061
  }
@@ -1108,7 +1067,7 @@ class SIMPLEFFMPEG {
1108
1067
  if (exportOptions.twoPass && exportOptions.videoBitrate && hasVideo) {
1109
1068
  const passLogFile = path.join(
1110
1069
  this.options.tempDir || path.dirname(exportOptions.outputPath),
1111
- `ffmpeg2pass-${Date.now()}`
1070
+ `ffmpeg2pass-${Date.now()}`,
1112
1071
  );
1113
1072
 
1114
1073
  // First pass
@@ -1219,6 +1178,7 @@ class SIMPLEFFMPEG {
1219
1178
  batchSize: exportOptions.textMaxNodesPerPass,
1220
1179
  onLog,
1221
1180
  tempDir: this.options.tempDir,
1181
+ signal,
1222
1182
  });
1223
1183
  passes = textPasses;
1224
1184
  if (finalPath !== exportOptions.outputPath) {
@@ -1270,12 +1230,12 @@ class SIMPLEFFMPEG {
1270
1230
  fileSizeStr = formatBytes(size);
1271
1231
  } catch (_) {}
1272
1232
  console.log(
1273
- `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`
1233
+ `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`,
1274
1234
  );
1275
1235
  console.log(
1276
1236
  `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
1277
- 2
1278
- )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`
1237
+ 2,
1238
+ )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`,
1279
1239
  );
1280
1240
 
1281
1241
  await this._cleanup();
@@ -1360,14 +1320,14 @@ class SIMPLEFFMPEG {
1360
1320
 
1361
1321
  // Filter to visual clips (video + image + color)
1362
1322
  const visual = resolved.filter(
1363
- (c) => c.type === "video" || c.type === "image" || c.type === "color"
1323
+ (c) => c.type === "video" || c.type === "image" || c.type === "color",
1364
1324
  );
1365
1325
 
1366
1326
  if (visual.length === 0) return 0;
1367
1327
 
1368
1328
  const baseSum = visual.reduce(
1369
1329
  (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
1370
- 0
1330
+ 0,
1371
1331
  );
1372
1332
 
1373
1333
  const transitionsOverlap = visual.reduce((acc, c) => {
@@ -1381,6 +1341,45 @@ class SIMPLEFFMPEG {
1381
1341
  return Math.max(0, baseSum - transitionsOverlap);
1382
1342
  }
1383
1343
 
1344
+ /**
1345
+ * Calculate the total transition overlap for a clips configuration.
1346
+ * Resolves shorthand (duration, auto-sequencing) before computing.
1347
+ * Returns the total seconds consumed by xfade transition overlaps
1348
+ * among visual clips (video, image, color).
1349
+ *
1350
+ * This is a pure function — same clips always produce the same result.
1351
+ * No file I/O is performed.
1352
+ *
1353
+ * @param {Array} clips - Array of clip objects
1354
+ * @returns {number} Total transition overlap in seconds
1355
+ *
1356
+ * @example
1357
+ * const overlap = SIMPLEFFMPEG.getTransitionOverlap([
1358
+ * { type: "video", url: "./a.mp4", duration: 5 },
1359
+ * { type: "video", url: "./b.mp4", duration: 10, transition: { type: "fade", duration: 0.5 } },
1360
+ * ]);
1361
+ * // overlap === 0.5
1362
+ */
1363
+ static getTransitionOverlap(clips) {
1364
+ if (!Array.isArray(clips) || clips.length === 0) return 0;
1365
+
1366
+ const { clips: resolved } = resolveClips(clips);
1367
+
1368
+ const visual = resolved.filter(
1369
+ (c) => c.type === "video" || c.type === "image" || c.type === "color",
1370
+ );
1371
+
1372
+ if (visual.length === 0) return 0;
1373
+
1374
+ return visual.reduce((acc, c) => {
1375
+ const d =
1376
+ c.transition && typeof c.transition.duration === "number"
1377
+ ? c.transition.duration
1378
+ : 0;
1379
+ return acc + d;
1380
+ }, 0);
1381
+ }
1382
+
1384
1383
  /**
1385
1384
  * Probe a media file and return comprehensive metadata.
1386
1385
  *
@@ -1454,12 +1453,12 @@ class SIMPLEFFMPEG {
1454
1453
  static async snapshot(filePath, options = {}) {
1455
1454
  if (!filePath) {
1456
1455
  throw new SimpleffmpegError(
1457
- "snapshot() requires a filePath as the first argument"
1456
+ "snapshot() requires a filePath as the first argument",
1458
1457
  );
1459
1458
  }
1460
1459
  if (!options.outputPath) {
1461
1460
  throw new SimpleffmpegError(
1462
- "snapshot() requires options.outputPath to be specified"
1461
+ "snapshot() requires options.outputPath to be specified",
1463
1462
  );
1464
1463
  }
1465
1464
 
@@ -1531,7 +1530,7 @@ class SIMPLEFFMPEG {
1531
1530
  static async extractKeyframes(filePath, options = {}) {
1532
1531
  if (!filePath) {
1533
1532
  throw new SimpleffmpegError(
1534
- "extractKeyframes() requires a filePath as the first argument"
1533
+ "extractKeyframes() requires a filePath as the first argument",
1535
1534
  );
1536
1535
  }
1537
1536
 
@@ -1550,13 +1549,13 @@ class SIMPLEFFMPEG {
1550
1549
 
1551
1550
  if (mode !== "scene-change" && mode !== "interval") {
1552
1551
  throw new SimpleffmpegError(
1553
- `extractKeyframes() invalid mode: "${mode}". Must be "scene-change" or "interval".`
1552
+ `extractKeyframes() invalid mode: "${mode}". Must be "scene-change" or "interval".`,
1554
1553
  );
1555
1554
  }
1556
1555
 
1557
1556
  if (format !== "jpeg" && format !== "png") {
1558
1557
  throw new SimpleffmpegError(
1559
- `extractKeyframes() invalid format: "${format}". Must be "jpeg" or "png".`
1558
+ `extractKeyframes() invalid format: "${format}". Must be "jpeg" or "png".`,
1560
1559
  );
1561
1560
  }
1562
1561
 
@@ -1567,7 +1566,7 @@ class SIMPLEFFMPEG {
1567
1566
  sceneThreshold > 1)
1568
1567
  ) {
1569
1568
  throw new SimpleffmpegError(
1570
- "extractKeyframes() sceneThreshold must be a number between 0 and 1."
1569
+ "extractKeyframes() sceneThreshold must be a number between 0 and 1.",
1571
1570
  );
1572
1571
  }
1573
1572
 
@@ -1576,19 +1575,19 @@ class SIMPLEFFMPEG {
1576
1575
  (typeof intervalSeconds !== "number" || intervalSeconds <= 0)
1577
1576
  ) {
1578
1577
  throw new SimpleffmpegError(
1579
- "extractKeyframes() intervalSeconds must be a positive number."
1578
+ "extractKeyframes() intervalSeconds must be a positive number.",
1580
1579
  );
1581
1580
  }
1582
1581
 
1583
1582
  if (maxFrames != null && (!Number.isInteger(maxFrames) || maxFrames < 1)) {
1584
1583
  throw new SimpleffmpegError(
1585
- "extractKeyframes() maxFrames must be a positive integer."
1584
+ "extractKeyframes() maxFrames must be a positive integer.",
1586
1585
  );
1587
1586
  }
1588
1587
 
1589
1588
  if (tempDir != null && typeof tempDir === "string" && !fs.existsSync(tempDir)) {
1590
1589
  throw new SimpleffmpegError(
1591
- `extractKeyframes() tempDir "${tempDir}" does not exist.`
1590
+ `extractKeyframes() tempDir "${tempDir}" does not exist.`,
1592
1591
  );
1593
1592
  }
1594
1593
 
@@ -1602,7 +1601,7 @@ class SIMPLEFFMPEG {
1602
1601
  } else {
1603
1602
  const tmpBase = tempDir || os.tmpdir();
1604
1603
  targetDir = await fsPromises.mkdtemp(
1605
- path.join(tmpBase, "simpleffmpeg-keyframes-")
1604
+ path.join(tmpBase, "simpleffmpeg-keyframes-"),
1606
1605
  );
1607
1606
  }
1608
1607
 
@@ -1637,7 +1636,7 @@ class SIMPLEFFMPEG {
1637
1636
 
1638
1637
  if (useTemp) {
1639
1638
  const buffers = await Promise.all(
1640
- files.map((f) => fsPromises.readFile(path.join(targetDir, f)))
1639
+ files.map((f) => fsPromises.readFile(path.join(targetDir, f))),
1641
1640
  );
1642
1641
  await fsPromises
1643
1642
  .rm(targetDir, { recursive: true, force: true })