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.
- package/README.md +3 -1
- package/package.json +10 -3
- package/src/core/media_info.js +5 -5
- package/src/core/rotation.js +4 -4
- package/src/core/validation.js +228 -170
- package/src/ffmpeg/audio_builder.js +1 -1
- package/src/ffmpeg/bgm_builder.js +5 -5
- package/src/ffmpeg/command_builder.js +6 -6
- package/src/ffmpeg/effect_builder.js +11 -11
- package/src/ffmpeg/standalone_audio_builder.js +75 -0
- package/src/ffmpeg/strings.js +4 -17
- package/src/ffmpeg/subtitle_builder.js +6 -14
- package/src/ffmpeg/text_passes.js +33 -8
- package/src/ffmpeg/text_renderer.js +17 -17
- package/src/ffmpeg/video_builder.js +6 -4
- package/src/ffmpeg/watermark_builder.js +1 -1
- package/src/lib/utils.js +4 -4
- package/src/loaders.js +8 -8
- package/src/schema/formatter.js +15 -15
- package/src/schema/index.js +4 -4
- package/src/schema/modules/music.js +1 -1
- package/src/simpleffmpeg.js +166 -167
- package/types/index.d.ts +20 -0
package/src/simpleffmpeg.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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 })
|