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.
- package/README.md +4 -1
- package/package.json +10 -3
- package/src/core/media_info.js +5 -5
- package/src/core/resolve.js +33 -0
- package/src/core/rotation.js +4 -4
- package/src/core/validation.js +255 -172
- 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/effect.js +5 -4
- package/src/schema/modules/music.js +1 -1
- package/src/schema/modules/text.js +4 -3
- package/src/simpleffmpeg.js +180 -167
- package/types/index.d.mts +33 -39
- package/types/index.d.ts +33 -39
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;
|
|
@@ -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
|
-
|
|
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);
|
|
605
|
+
const sares = buildStandaloneAudioMix(this, audioClips, {
|
|
606
|
+
compensateTransitions: exportOptions.compensateTransitions,
|
|
607
|
+
videoClips,
|
|
608
|
+
hasAudio,
|
|
609
|
+
finalAudioLabel,
|
|
560
610
|
});
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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 })
|