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.
@@ -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;
@@ -518,6 +561,20 @@ class SIMPLEFFMPEG {
518
561
  }
519
562
  }
520
563
 
564
+ // Expand fullDuration clips now that finalVisualEnd is known
565
+ for (const clip of this.effectClips) {
566
+ if (clip.fullDuration === true) {
567
+ clip.position = clip.position ?? 0;
568
+ clip.end = finalVisualEnd;
569
+ }
570
+ }
571
+ for (const clip of this.textClips) {
572
+ if (clip.fullDuration === true) {
573
+ clip.position = clip.position ?? 0;
574
+ clip.end = finalVisualEnd;
575
+ }
576
+ }
577
+
521
578
  // Overlay effects (adjustment layer clips) on the composed video output.
522
579
  if (this.effectClips.length > 0 && hasVideo && finalVideoLabel) {
523
580
  const effectRes = buildEffectFilters(this.effectClips, finalVideoLabel);
@@ -545,33 +602,15 @@ class SIMPLEFFMPEG {
545
602
 
546
603
  // Standalone audio clips
547
604
  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);
605
+ const sares = buildStandaloneAudioMix(this, audioClips, {
606
+ compensateTransitions: exportOptions.compensateTransitions,
607
+ videoClips,
608
+ hasAudio,
609
+ finalAudioLabel,
560
610
  });
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
- }
611
+ filterComplex += sares.filter;
612
+ finalAudioLabel = sares.finalAudioLabel || finalAudioLabel;
613
+ hasAudio = sares.hasAudio;
575
614
  }
576
615
 
577
616
  // Background music after other audio
@@ -580,7 +619,7 @@ class SIMPLEFFMPEG {
580
619
  this,
581
620
  backgroundClips,
582
621
  hasAudio ? finalAudioLabel : null,
583
- finalVisualEnd
622
+ finalVisualEnd,
584
623
  );
585
624
  filterComplex += bgres.filter;
586
625
  finalAudioLabel = bgres.finalAudioLabel || finalAudioLabel;
@@ -600,45 +639,9 @@ class SIMPLEFFMPEG {
600
639
  // Compensate text timings for transition overlap if enabled
601
640
  let adjustedTextClips = this.textClips;
602
641
  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
- });
642
+ adjustedTextClips = this.textClips.map((clip) =>
643
+ this._compensateClipTimings(videoClips, clip),
644
+ );
642
645
  }
643
646
 
644
647
  // Emoji handling: opt-in via emojiFont, otherwise strip emoji from text.
@@ -660,7 +663,7 @@ class SIMPLEFFMPEG {
660
663
  } else {
661
664
  console.warn(
662
665
  `simple-ffmpeg: Text "${textContent.slice(0, 40)}..." contains emoji but uses '${animType}' animation ` +
663
- `which is not supported in ASS. Emoji will be stripped.`
666
+ `which is not supported in ASS. Emoji will be stripped.`,
664
667
  );
665
668
  drawtextClips.push({ ...clip, text: stripEmoji(textContent) });
666
669
  }
@@ -669,8 +672,8 @@ class SIMPLEFFMPEG {
669
672
  this._emojiStrippedWarned = true;
670
673
  console.warn(
671
674
  "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' })"
675
+ "Emoji will be stripped. To render emoji, pass emojiFont in the constructor: " +
676
+ "new SIMPLEFFMPEG({ emojiFont: '/path/to/NotoEmoji-Regular.ttf' })",
674
677
  );
675
678
  }
676
679
  drawtextClips.push({ ...clip, text: stripEmoji(textContent) });
@@ -680,7 +683,7 @@ class SIMPLEFFMPEG {
680
683
  if (!(clip.text || "").trim()) {
681
684
  console.warn(
682
685
  `simple-ffmpeg: Text clip at ${clip.position}s–${clip.end}s ` +
683
- `has no visible text after emoji stripping. Skipping.`
686
+ `has no visible text after emoji stripping. Skipping.`,
684
687
  );
685
688
  return false;
686
689
  }
@@ -695,7 +698,7 @@ class SIMPLEFFMPEG {
695
698
  if (hasProblematicChars(textContent)) {
696
699
  const tempPath = path.join(
697
700
  textTempBase,
698
- `.simpleffmpeg_text_${idx}_${Date.now()}.txt`
701
+ `.simpleffmpeg_text_${idx}_${Date.now()}.txt`,
699
702
  );
700
703
  const normalizedText = textContent.replace(/\r?\n/g, " ").replace(/ {2,}/g, " ");
701
704
  try {
@@ -703,7 +706,7 @@ class SIMPLEFFMPEG {
703
706
  } catch (writeError) {
704
707
  throw new SimpleffmpegError(
705
708
  `Failed to write temporary text file "${tempPath}": ${writeError.message}`,
706
- { cause: writeError }
709
+ { cause: writeError },
707
710
  );
708
711
  }
709
712
  this.filesToClean.push(tempPath);
@@ -728,7 +731,7 @@ class SIMPLEFFMPEG {
728
731
  adjustedTextClips,
729
732
  this.options.width,
730
733
  this.options.height,
731
- finalVideoLabel
734
+ finalVideoLabel,
732
735
  );
733
736
 
734
737
  // Auto-batch if filter_complex would exceed safe command length limit
@@ -737,18 +740,18 @@ class SIMPLEFFMPEG {
737
740
  // Calculate optimal batch size based on filter length
738
741
  const avgNodeLength = filterString.length / textWindows.length;
739
742
  const safeNodes = Math.floor(
740
- (C.MAX_FILTER_COMPLEX_LENGTH - filterComplex.length) / avgNodeLength
743
+ (C.MAX_FILTER_COMPLEX_LENGTH - filterComplex.length) / avgNodeLength,
741
744
  );
742
745
  exportOptions.textMaxNodesPerPass = Math.max(
743
746
  10,
744
- Math.min(safeNodes, 50)
747
+ Math.min(safeNodes, 50),
745
748
  );
746
749
  needTextPasses = true;
747
750
 
748
751
  if (exportOptions.verbose) {
749
752
  console.log(
750
753
  `simple-ffmpeg: Auto-batching text (filter too long: ${potentialLength} > ${C.MAX_FILTER_COMPLEX_LENGTH}). ` +
751
- `Using ${exportOptions.textMaxNodesPerPass} nodes per pass.`
754
+ `Using ${exportOptions.textMaxNodesPerPass} nodes per pass.`,
752
755
  );
753
756
  }
754
757
  } else {
@@ -766,18 +769,18 @@ class SIMPLEFFMPEG {
766
769
  emojiClip,
767
770
  this.options.width,
768
771
  this.options.height,
769
- emojiFont
772
+ emojiFont,
770
773
  );
771
774
  const assFilePath = path.join(
772
775
  this.options.tempDir || path.dirname(exportOptions.outputPath),
773
- `.simpleffmpeg_emoji_${i}_${Date.now()}.ass`
776
+ `.simpleffmpeg_emoji_${i}_${Date.now()}.ass`,
774
777
  );
775
778
  try {
776
779
  fs.writeFileSync(assFilePath, assContent, "utf-8");
777
780
  } catch (writeError) {
778
781
  throw new SimpleffmpegError(
779
782
  `Failed to write temporary ASS file "${assFilePath}": ${writeError.message}`,
780
- { cause: writeError }
783
+ { cause: writeError },
781
784
  );
782
785
  }
783
786
  this.filesToClean.push(assFilePath);
@@ -788,7 +791,7 @@ class SIMPLEFFMPEG {
788
791
  const uniqueLabel = `[outemoji${i}]`;
789
792
  const filter = assResult.filter.replace(
790
793
  assResult.finalLabel,
791
- uniqueLabel
794
+ uniqueLabel,
792
795
  );
793
796
  filterComplex += filter + ";";
794
797
  finalVideoLabel = uniqueLabel;
@@ -808,41 +811,7 @@ class SIMPLEFFMPEG {
808
811
  videoClips.length > 1 &&
809
812
  subClip.mode === "karaoke"
810
813
  ) {
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
- };
814
+ subClip = this._compensateClipTimings(videoClips, subClip);
846
815
  }
847
816
 
848
817
  let assContent = "";
@@ -860,7 +829,7 @@ class SIMPLEFFMPEG {
860
829
  subClip.url,
861
830
  subClip,
862
831
  this.options.width,
863
- this.options.height
832
+ this.options.height,
864
833
  );
865
834
  }
866
835
  } else if (subClip.mode === "karaoke") {
@@ -868,7 +837,7 @@ class SIMPLEFFMPEG {
868
837
  assContent = buildKaraokeASS(
869
838
  subClip,
870
839
  this.options.width,
871
- this.options.height
840
+ this.options.height,
872
841
  );
873
842
  }
874
843
 
@@ -876,14 +845,14 @@ class SIMPLEFFMPEG {
876
845
  if (assContent && !assFilePath) {
877
846
  assFilePath = path.join(
878
847
  this.options.tempDir || path.dirname(exportOptions.outputPath),
879
- `.simpleffmpeg_sub_${i}_${Date.now()}.ass`
848
+ `.simpleffmpeg_sub_${i}_${Date.now()}.ass`,
880
849
  );
881
850
  try {
882
851
  fs.writeFileSync(assFilePath, assContent, "utf-8");
883
852
  } catch (writeError) {
884
853
  throw new SimpleffmpegError(
885
854
  `Failed to write temporary ASS file "${assFilePath}": ${writeError.message}`,
886
- { cause: writeError }
855
+ { cause: writeError },
887
856
  );
888
857
  }
889
858
  assFilesToClean.push(assFilePath);
@@ -897,7 +866,7 @@ class SIMPLEFFMPEG {
897
866
  const uniqueLabel = `[outsub${i}]`;
898
867
  const filter = assResult.filter.replace(
899
868
  assResult.finalLabel,
900
- uniqueLabel
869
+ uniqueLabel,
901
870
  );
902
871
  filterComplex += filter + ";";
903
872
  finalVideoLabel = uniqueLabel;
@@ -913,7 +882,7 @@ class SIMPLEFFMPEG {
913
882
  const wmValidation = validateWatermarkConfig(exportOptions.watermark);
914
883
  if (!wmValidation.valid) {
915
884
  throw new Error(
916
- `Watermark validation failed: ${wmValidation.errors.join(", ")}`
885
+ `Watermark validation failed: ${wmValidation.errors.join(", ")}`,
917
886
  );
918
887
  }
919
888
 
@@ -934,7 +903,7 @@ class SIMPLEFFMPEG {
934
903
  watermarkInputIndex,
935
904
  this.options.width,
936
905
  this.options.height,
937
- totalVideoDuration
906
+ totalVideoDuration,
938
907
  );
939
908
 
940
909
  if (wmResult.filter) {
@@ -1010,12 +979,16 @@ class SIMPLEFFMPEG {
1010
979
  * @returns {Promise<{command: string, filterComplex: string, totalDuration: number}>}
1011
980
  */
1012
981
  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
- };
982
+ try {
983
+ const result = await this._prepareExport(options);
984
+ return {
985
+ command: result.command,
986
+ filterComplex: result.filterComplex,
987
+ totalDuration: result.totalDuration,
988
+ };
989
+ } finally {
990
+ await this._cleanup();
991
+ }
1019
992
  }
1020
993
 
1021
994
  /**
@@ -1048,7 +1021,7 @@ class SIMPLEFFMPEG {
1048
1021
  // Guard against concurrent export() calls
1049
1022
  if (this._isExporting) {
1050
1023
  throw new SimpleffmpegError(
1051
- "Cannot call export() while another export() is in progress. Await the previous export() call first."
1024
+ "Cannot call export() while another export() is in progress. Await the previous export() call first.",
1052
1025
  );
1053
1026
  }
1054
1027
 
@@ -1082,7 +1055,7 @@ class SIMPLEFFMPEG {
1082
1055
  if (exportOptions.verbose) {
1083
1056
  console.log(
1084
1057
  "simple-ffmpeg: Export options:",
1085
- JSON.stringify(exportOptions, null, 2)
1058
+ JSON.stringify(exportOptions, null, 2),
1086
1059
  );
1087
1060
  }
1088
1061
 
@@ -1091,12 +1064,12 @@ class SIMPLEFFMPEG {
1091
1064
  try {
1092
1065
  fs.writeFileSync(exportOptions.saveCommand, command, "utf8");
1093
1066
  console.log(
1094
- `simple-ffmpeg: Command saved to ${exportOptions.saveCommand}`
1067
+ `simple-ffmpeg: Command saved to ${exportOptions.saveCommand}`,
1095
1068
  );
1096
1069
  } catch (writeError) {
1097
1070
  throw new SimpleffmpegError(
1098
1071
  `Failed to save command to "${exportOptions.saveCommand}": ${writeError.message}`,
1099
- { cause: writeError }
1072
+ { cause: writeError },
1100
1073
  );
1101
1074
  }
1102
1075
  }
@@ -1108,7 +1081,7 @@ class SIMPLEFFMPEG {
1108
1081
  if (exportOptions.twoPass && exportOptions.videoBitrate && hasVideo) {
1109
1082
  const passLogFile = path.join(
1110
1083
  this.options.tempDir || path.dirname(exportOptions.outputPath),
1111
- `ffmpeg2pass-${Date.now()}`
1084
+ `ffmpeg2pass-${Date.now()}`,
1112
1085
  );
1113
1086
 
1114
1087
  // First pass
@@ -1219,6 +1192,7 @@ class SIMPLEFFMPEG {
1219
1192
  batchSize: exportOptions.textMaxNodesPerPass,
1220
1193
  onLog,
1221
1194
  tempDir: this.options.tempDir,
1195
+ signal,
1222
1196
  });
1223
1197
  passes = textPasses;
1224
1198
  if (finalPath !== exportOptions.outputPath) {
@@ -1270,12 +1244,12 @@ class SIMPLEFFMPEG {
1270
1244
  fileSizeStr = formatBytes(size);
1271
1245
  } catch (_) {}
1272
1246
  console.log(
1273
- `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`
1247
+ `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`,
1274
1248
  );
1275
1249
  console.log(
1276
1250
  `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
1277
- 2
1278
- )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`
1251
+ 2,
1252
+ )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`,
1279
1253
  );
1280
1254
 
1281
1255
  await this._cleanup();
@@ -1360,14 +1334,14 @@ class SIMPLEFFMPEG {
1360
1334
 
1361
1335
  // Filter to visual clips (video + image + color)
1362
1336
  const visual = resolved.filter(
1363
- (c) => c.type === "video" || c.type === "image" || c.type === "color"
1337
+ (c) => c.type === "video" || c.type === "image" || c.type === "color",
1364
1338
  );
1365
1339
 
1366
1340
  if (visual.length === 0) return 0;
1367
1341
 
1368
1342
  const baseSum = visual.reduce(
1369
1343
  (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
1370
- 0
1344
+ 0,
1371
1345
  );
1372
1346
 
1373
1347
  const transitionsOverlap = visual.reduce((acc, c) => {
@@ -1381,6 +1355,45 @@ class SIMPLEFFMPEG {
1381
1355
  return Math.max(0, baseSum - transitionsOverlap);
1382
1356
  }
1383
1357
 
1358
+ /**
1359
+ * Calculate the total transition overlap for a clips configuration.
1360
+ * Resolves shorthand (duration, auto-sequencing) before computing.
1361
+ * Returns the total seconds consumed by xfade transition overlaps
1362
+ * among visual clips (video, image, color).
1363
+ *
1364
+ * This is a pure function — same clips always produce the same result.
1365
+ * No file I/O is performed.
1366
+ *
1367
+ * @param {Array} clips - Array of clip objects
1368
+ * @returns {number} Total transition overlap in seconds
1369
+ *
1370
+ * @example
1371
+ * const overlap = SIMPLEFFMPEG.getTransitionOverlap([
1372
+ * { type: "video", url: "./a.mp4", duration: 5 },
1373
+ * { type: "video", url: "./b.mp4", duration: 10, transition: { type: "fade", duration: 0.5 } },
1374
+ * ]);
1375
+ * // overlap === 0.5
1376
+ */
1377
+ static getTransitionOverlap(clips) {
1378
+ if (!Array.isArray(clips) || clips.length === 0) return 0;
1379
+
1380
+ const { clips: resolved } = resolveClips(clips);
1381
+
1382
+ const visual = resolved.filter(
1383
+ (c) => c.type === "video" || c.type === "image" || c.type === "color",
1384
+ );
1385
+
1386
+ if (visual.length === 0) return 0;
1387
+
1388
+ return visual.reduce((acc, c) => {
1389
+ const d =
1390
+ c.transition && typeof c.transition.duration === "number"
1391
+ ? c.transition.duration
1392
+ : 0;
1393
+ return acc + d;
1394
+ }, 0);
1395
+ }
1396
+
1384
1397
  /**
1385
1398
  * Probe a media file and return comprehensive metadata.
1386
1399
  *
@@ -1454,12 +1467,12 @@ class SIMPLEFFMPEG {
1454
1467
  static async snapshot(filePath, options = {}) {
1455
1468
  if (!filePath) {
1456
1469
  throw new SimpleffmpegError(
1457
- "snapshot() requires a filePath as the first argument"
1470
+ "snapshot() requires a filePath as the first argument",
1458
1471
  );
1459
1472
  }
1460
1473
  if (!options.outputPath) {
1461
1474
  throw new SimpleffmpegError(
1462
- "snapshot() requires options.outputPath to be specified"
1475
+ "snapshot() requires options.outputPath to be specified",
1463
1476
  );
1464
1477
  }
1465
1478
 
@@ -1531,7 +1544,7 @@ class SIMPLEFFMPEG {
1531
1544
  static async extractKeyframes(filePath, options = {}) {
1532
1545
  if (!filePath) {
1533
1546
  throw new SimpleffmpegError(
1534
- "extractKeyframes() requires a filePath as the first argument"
1547
+ "extractKeyframes() requires a filePath as the first argument",
1535
1548
  );
1536
1549
  }
1537
1550
 
@@ -1550,13 +1563,13 @@ class SIMPLEFFMPEG {
1550
1563
 
1551
1564
  if (mode !== "scene-change" && mode !== "interval") {
1552
1565
  throw new SimpleffmpegError(
1553
- `extractKeyframes() invalid mode: "${mode}". Must be "scene-change" or "interval".`
1566
+ `extractKeyframes() invalid mode: "${mode}". Must be "scene-change" or "interval".`,
1554
1567
  );
1555
1568
  }
1556
1569
 
1557
1570
  if (format !== "jpeg" && format !== "png") {
1558
1571
  throw new SimpleffmpegError(
1559
- `extractKeyframes() invalid format: "${format}". Must be "jpeg" or "png".`
1572
+ `extractKeyframes() invalid format: "${format}". Must be "jpeg" or "png".`,
1560
1573
  );
1561
1574
  }
1562
1575
 
@@ -1567,7 +1580,7 @@ class SIMPLEFFMPEG {
1567
1580
  sceneThreshold > 1)
1568
1581
  ) {
1569
1582
  throw new SimpleffmpegError(
1570
- "extractKeyframes() sceneThreshold must be a number between 0 and 1."
1583
+ "extractKeyframes() sceneThreshold must be a number between 0 and 1.",
1571
1584
  );
1572
1585
  }
1573
1586
 
@@ -1576,19 +1589,19 @@ class SIMPLEFFMPEG {
1576
1589
  (typeof intervalSeconds !== "number" || intervalSeconds <= 0)
1577
1590
  ) {
1578
1591
  throw new SimpleffmpegError(
1579
- "extractKeyframes() intervalSeconds must be a positive number."
1592
+ "extractKeyframes() intervalSeconds must be a positive number.",
1580
1593
  );
1581
1594
  }
1582
1595
 
1583
1596
  if (maxFrames != null && (!Number.isInteger(maxFrames) || maxFrames < 1)) {
1584
1597
  throw new SimpleffmpegError(
1585
- "extractKeyframes() maxFrames must be a positive integer."
1598
+ "extractKeyframes() maxFrames must be a positive integer.",
1586
1599
  );
1587
1600
  }
1588
1601
 
1589
1602
  if (tempDir != null && typeof tempDir === "string" && !fs.existsSync(tempDir)) {
1590
1603
  throw new SimpleffmpegError(
1591
- `extractKeyframes() tempDir "${tempDir}" does not exist.`
1604
+ `extractKeyframes() tempDir "${tempDir}" does not exist.`,
1592
1605
  );
1593
1606
  }
1594
1607
 
@@ -1602,7 +1615,7 @@ class SIMPLEFFMPEG {
1602
1615
  } else {
1603
1616
  const tmpBase = tempDir || os.tmpdir();
1604
1617
  targetDir = await fsPromises.mkdtemp(
1605
- path.join(tmpBase, "simpleffmpeg-keyframes-")
1618
+ path.join(tmpBase, "simpleffmpeg-keyframes-"),
1606
1619
  );
1607
1620
  }
1608
1621
 
@@ -1637,7 +1650,7 @@ class SIMPLEFFMPEG {
1637
1650
 
1638
1651
  if (useTemp) {
1639
1652
  const buffers = await Promise.all(
1640
- files.map((f) => fsPromises.readFile(path.join(targetDir, f)))
1653
+ files.map((f) => fsPromises.readFile(path.join(targetDir, f))),
1641
1654
  );
1642
1655
  await fsPromises
1643
1656
  .rm(targetDir, { recursive: true, force: true })