simple-ffmpegjs 0.4.4 → 0.5.1
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 +192 -3
- package/package.json +1 -1
- package/src/core/rotation.js +6 -5
- package/src/core/validation.js +36 -0
- package/src/ffmpeg/command_builder.js +50 -0
- package/src/ffmpeg/strings.js +82 -0
- package/src/ffmpeg/subtitle_builder.js +163 -7
- package/src/ffmpeg/text_passes.js +3 -1
- package/src/ffmpeg/video_builder.js +85 -20
- package/src/loaders.js +1 -1
- package/src/schema/modules/image.js +17 -1
- package/src/simpleffmpeg.js +309 -10
- package/types/index.d.mts +219 -4
- package/types/index.d.ts +100 -0
package/src/simpleffmpeg.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const fsPromises = require("fs").promises;
|
|
3
3
|
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
4
5
|
const TextRenderer = require("./ffmpeg/text_renderer");
|
|
5
6
|
const { unrotateVideo } = require("./core/rotation");
|
|
6
7
|
const Loaders = require("./loaders");
|
|
@@ -11,6 +12,9 @@ const { buildEffectFilters } = require("./ffmpeg/effect_builder");
|
|
|
11
12
|
const {
|
|
12
13
|
getClipAudioString,
|
|
13
14
|
hasProblematicChars,
|
|
15
|
+
hasEmoji,
|
|
16
|
+
stripEmoji,
|
|
17
|
+
parseFontFamily,
|
|
14
18
|
escapeFilePath,
|
|
15
19
|
} = require("./ffmpeg/strings");
|
|
16
20
|
const {
|
|
@@ -30,6 +34,7 @@ const {
|
|
|
30
34
|
buildMainCommand,
|
|
31
35
|
buildThumbnailCommand,
|
|
32
36
|
buildSnapshotCommand,
|
|
37
|
+
buildKeyframeCommand,
|
|
33
38
|
sanitizeFilterComplex,
|
|
34
39
|
} = require("./ffmpeg/command_builder");
|
|
35
40
|
const { runTextPasses } = require("./ffmpeg/text_passes");
|
|
@@ -40,6 +45,7 @@ const {
|
|
|
40
45
|
} = require("./ffmpeg/watermark_builder");
|
|
41
46
|
const {
|
|
42
47
|
buildKaraokeASS,
|
|
48
|
+
buildTextClipASS,
|
|
43
49
|
loadSubtitleFile,
|
|
44
50
|
buildASSFilter,
|
|
45
51
|
} = require("./ffmpeg/subtitle_builder");
|
|
@@ -58,6 +64,8 @@ class SIMPLEFFMPEG {
|
|
|
58
64
|
* @param {string} options.preset - Platform preset ('tiktok', 'youtube', 'instagram-post', etc.)
|
|
59
65
|
* @param {string} options.validationMode - Validation behavior: 'warn' or 'strict' (default: 'warn')
|
|
60
66
|
* @param {string} options.fontFile - Default font file path (.ttf, .otf) applied to all text clips unless overridden per-clip
|
|
67
|
+
* @param {string} options.emojiFont - Path to a .ttf/.otf emoji font for rendering emoji in text overlays (opt-in). Without this, emoji are silently stripped from text. Recommended: Noto Emoji (B&W outline).
|
|
68
|
+
* @param {string} options.tempDir - Custom directory for temporary files (gradient images, unrotated videos, intermediate renders). Defaults to os.tmpdir(). Useful for fast SSDs, ramdisks, or environments with constrained /tmp.
|
|
61
69
|
*
|
|
62
70
|
* @example
|
|
63
71
|
* const project = new SIMPLEFFMPEG({ preset: 'tiktok' });
|
|
@@ -66,7 +74,8 @@ class SIMPLEFFMPEG {
|
|
|
66
74
|
* const project = new SIMPLEFFMPEG({
|
|
67
75
|
* width: 1920,
|
|
68
76
|
* height: 1080,
|
|
69
|
-
* fps: 30
|
|
77
|
+
* fps: 30,
|
|
78
|
+
* emojiFont: '/path/to/NotoEmoji-Regular.ttf'
|
|
70
79
|
* });
|
|
71
80
|
*/
|
|
72
81
|
constructor(options = {}) {
|
|
@@ -90,14 +99,43 @@ class SIMPLEFFMPEG {
|
|
|
90
99
|
validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
|
|
91
100
|
preset: options.preset || null,
|
|
92
101
|
fontFile: options.fontFile || null,
|
|
102
|
+
emojiFont: options.emojiFont || null,
|
|
103
|
+
tempDir: options.tempDir || null,
|
|
93
104
|
};
|
|
105
|
+
if (this.options.tempDir) {
|
|
106
|
+
if (typeof this.options.tempDir !== "string") {
|
|
107
|
+
throw new SimpleffmpegError(
|
|
108
|
+
"tempDir must be a string path to an existing directory."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (!fs.existsSync(this.options.tempDir)) {
|
|
112
|
+
throw new SimpleffmpegError(
|
|
113
|
+
`tempDir "${this.options.tempDir}" does not exist. Create it before constructing SIMPLEFFMPEG.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this._emojiFontInfo = null;
|
|
118
|
+
if (this.options.emojiFont) {
|
|
119
|
+
const family = parseFontFamily(this.options.emojiFont);
|
|
120
|
+
if (!family) {
|
|
121
|
+
console.warn(
|
|
122
|
+
`simple-ffmpeg: Could not parse font family from "${this.options.emojiFont}". Emoji will be stripped from text.`
|
|
123
|
+
);
|
|
124
|
+
} else {
|
|
125
|
+
this._emojiFontInfo = {
|
|
126
|
+
fontName: family,
|
|
127
|
+
fontsDir: path.dirname(path.resolve(this.options.emojiFont)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
this._emojiStrippedWarned = false;
|
|
94
132
|
this.videoOrAudioClips = [];
|
|
95
133
|
this.textClips = [];
|
|
96
134
|
this.subtitleClips = [];
|
|
97
135
|
this.effectClips = [];
|
|
98
136
|
this.filesToClean = [];
|
|
99
|
-
this._isLoading = false;
|
|
100
|
-
this._isExporting = false;
|
|
137
|
+
this._isLoading = false;
|
|
138
|
+
this._isExporting = false;
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
/**
|
|
@@ -391,7 +429,9 @@ class SIMPLEFFMPEG {
|
|
|
391
429
|
await Promise.all(
|
|
392
430
|
this.videoOrAudioClips.map(async (clip) => {
|
|
393
431
|
if (clip.type === "video" && clip.iphoneRotation !== 0) {
|
|
394
|
-
const unrotatedUrl = await unrotateVideo(clip.url
|
|
432
|
+
const unrotatedUrl = await unrotateVideo(clip.url, {
|
|
433
|
+
tempDir: this.options.tempDir,
|
|
434
|
+
});
|
|
395
435
|
this.filesToClean.push(unrotatedUrl);
|
|
396
436
|
clip.url = unrotatedUrl;
|
|
397
437
|
}
|
|
@@ -601,16 +641,63 @@ class SIMPLEFFMPEG {
|
|
|
601
641
|
});
|
|
602
642
|
}
|
|
603
643
|
|
|
644
|
+
// Emoji handling: opt-in via emojiFont, otherwise strip emoji from text.
|
|
645
|
+
const emojiASSClips = [];
|
|
646
|
+
const drawtextClips = [];
|
|
647
|
+
const ASS_COMPATIBLE_ANIMS = new Set([
|
|
648
|
+
"none", "fade-in", "fade-out", "fade-in-out", "fade",
|
|
649
|
+
]);
|
|
650
|
+
for (const clip of adjustedTextClips) {
|
|
651
|
+
const textContent = clip.text || "";
|
|
652
|
+
if (!hasEmoji(textContent)) {
|
|
653
|
+
drawtextClips.push(clip);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (this._emojiFontInfo) {
|
|
657
|
+
const animType = (clip.animation && clip.animation.type) || "none";
|
|
658
|
+
if (ASS_COMPATIBLE_ANIMS.has(animType)) {
|
|
659
|
+
emojiASSClips.push(clip);
|
|
660
|
+
} else {
|
|
661
|
+
console.warn(
|
|
662
|
+
`simple-ffmpeg: Text "${textContent.slice(0, 40)}..." contains emoji but uses '${animType}' animation ` +
|
|
663
|
+
`which is not supported in ASS. Emoji will be stripped.`
|
|
664
|
+
);
|
|
665
|
+
drawtextClips.push({ ...clip, text: stripEmoji(textContent) });
|
|
666
|
+
}
|
|
667
|
+
} else {
|
|
668
|
+
if (!this._emojiStrippedWarned) {
|
|
669
|
+
this._emojiStrippedWarned = true;
|
|
670
|
+
console.warn(
|
|
671
|
+
"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' })"
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
drawtextClips.push({ ...clip, text: stripEmoji(textContent) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
adjustedTextClips = drawtextClips.filter((clip) => {
|
|
680
|
+
if (!(clip.text || "").trim()) {
|
|
681
|
+
console.warn(
|
|
682
|
+
`simple-ffmpeg: Text clip at ${clip.position}s–${clip.end}s ` +
|
|
683
|
+
`has no visible text after emoji stripping. Skipping.`
|
|
684
|
+
);
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
return true;
|
|
688
|
+
});
|
|
689
|
+
|
|
604
690
|
// For text with problematic characters, use temp files (textfile approach)
|
|
691
|
+
const textTempBase =
|
|
692
|
+
this.options.tempDir || path.dirname(exportOptions.outputPath);
|
|
605
693
|
adjustedTextClips = adjustedTextClips.map((clip, idx) => {
|
|
606
694
|
const textContent = clip.text || "";
|
|
607
695
|
if (hasProblematicChars(textContent)) {
|
|
608
696
|
const tempPath = path.join(
|
|
609
|
-
|
|
697
|
+
textTempBase,
|
|
610
698
|
`.simpleffmpeg_text_${idx}_${Date.now()}.txt`
|
|
611
699
|
);
|
|
612
|
-
|
|
613
|
-
const normalizedText = textContent.replace(/\r?\n/g, " ");
|
|
700
|
+
const normalizedText = textContent.replace(/\r?\n/g, " ").replace(/ {2,}/g, " ");
|
|
614
701
|
try {
|
|
615
702
|
fs.writeFileSync(tempPath, normalizedText, "utf-8");
|
|
616
703
|
} catch (writeError) {
|
|
@@ -669,6 +756,44 @@ class SIMPLEFFMPEG {
|
|
|
669
756
|
finalVideoLabel = outLabel;
|
|
670
757
|
}
|
|
671
758
|
}
|
|
759
|
+
|
|
760
|
+
// Emoji text overlays (ASS-based, only when emojiFont is configured)
|
|
761
|
+
if (emojiASSClips.length > 0 && this._emojiFontInfo) {
|
|
762
|
+
const { fontName: emojiFont, fontsDir: emojiFontsDir } = this._emojiFontInfo;
|
|
763
|
+
for (let i = 0; i < emojiASSClips.length; i++) {
|
|
764
|
+
const emojiClip = emojiASSClips[i];
|
|
765
|
+
const assContent = buildTextClipASS(
|
|
766
|
+
emojiClip,
|
|
767
|
+
this.options.width,
|
|
768
|
+
this.options.height,
|
|
769
|
+
emojiFont
|
|
770
|
+
);
|
|
771
|
+
const assFilePath = path.join(
|
|
772
|
+
this.options.tempDir || path.dirname(exportOptions.outputPath),
|
|
773
|
+
`.simpleffmpeg_emoji_${i}_${Date.now()}.ass`
|
|
774
|
+
);
|
|
775
|
+
try {
|
|
776
|
+
fs.writeFileSync(assFilePath, assContent, "utf-8");
|
|
777
|
+
} catch (writeError) {
|
|
778
|
+
throw new SimpleffmpegError(
|
|
779
|
+
`Failed to write temporary ASS file "${assFilePath}": ${writeError.message}`,
|
|
780
|
+
{ cause: writeError }
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
this.filesToClean.push(assFilePath);
|
|
784
|
+
|
|
785
|
+
const assResult = buildASSFilter(assFilePath, finalVideoLabel, {
|
|
786
|
+
fontsDir: emojiFontsDir,
|
|
787
|
+
});
|
|
788
|
+
const uniqueLabel = `[outemoji${i}]`;
|
|
789
|
+
const filter = assResult.filter.replace(
|
|
790
|
+
assResult.finalLabel,
|
|
791
|
+
uniqueLabel
|
|
792
|
+
);
|
|
793
|
+
filterComplex += filter + ";";
|
|
794
|
+
finalVideoLabel = uniqueLabel;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
672
797
|
}
|
|
673
798
|
|
|
674
799
|
// Subtitle overlays (ASS-based: karaoke mode and imported subtitles)
|
|
@@ -750,7 +875,7 @@ class SIMPLEFFMPEG {
|
|
|
750
875
|
// Write temp ASS file if we generated content
|
|
751
876
|
if (assContent && !assFilePath) {
|
|
752
877
|
assFilePath = path.join(
|
|
753
|
-
path.dirname(exportOptions.outputPath),
|
|
878
|
+
this.options.tempDir || path.dirname(exportOptions.outputPath),
|
|
754
879
|
`.simpleffmpeg_sub_${i}_${Date.now()}.ass`
|
|
755
880
|
);
|
|
756
881
|
try {
|
|
@@ -982,7 +1107,7 @@ class SIMPLEFFMPEG {
|
|
|
982
1107
|
// Two-pass encoding
|
|
983
1108
|
if (exportOptions.twoPass && exportOptions.videoBitrate && hasVideo) {
|
|
984
1109
|
const passLogFile = path.join(
|
|
985
|
-
path.dirname(exportOptions.outputPath),
|
|
1110
|
+
this.options.tempDir || path.dirname(exportOptions.outputPath),
|
|
986
1111
|
`ffmpeg2pass-${Date.now()}`
|
|
987
1112
|
);
|
|
988
1113
|
|
|
@@ -1093,10 +1218,20 @@ class SIMPLEFFMPEG {
|
|
|
1093
1218
|
intermediateCrf: exportOptions.intermediateCrf,
|
|
1094
1219
|
batchSize: exportOptions.textMaxNodesPerPass,
|
|
1095
1220
|
onLog,
|
|
1221
|
+
tempDir: this.options.tempDir,
|
|
1096
1222
|
});
|
|
1097
1223
|
passes = textPasses;
|
|
1098
1224
|
if (finalPath !== exportOptions.outputPath) {
|
|
1099
|
-
|
|
1225
|
+
try {
|
|
1226
|
+
fs.renameSync(finalPath, exportOptions.outputPath);
|
|
1227
|
+
} catch (renameErr) {
|
|
1228
|
+
if (renameErr.code === "EXDEV") {
|
|
1229
|
+
fs.copyFileSync(finalPath, exportOptions.outputPath);
|
|
1230
|
+
fs.unlinkSync(finalPath);
|
|
1231
|
+
} else {
|
|
1232
|
+
throw renameErr;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1100
1235
|
}
|
|
1101
1236
|
tempOutputs.slice(0, -1).forEach((f) => {
|
|
1102
1237
|
try {
|
|
@@ -1349,6 +1484,170 @@ class SIMPLEFFMPEG {
|
|
|
1349
1484
|
return outputPath;
|
|
1350
1485
|
}
|
|
1351
1486
|
|
|
1487
|
+
/**
|
|
1488
|
+
* Extract keyframes from a video using scene-change detection or fixed time intervals.
|
|
1489
|
+
*
|
|
1490
|
+
* Scene-change mode uses FFmpeg's select=gt(scene,N) filter to detect visual transitions.
|
|
1491
|
+
* Interval mode extracts frames at fixed time intervals using FFmpeg's fps filter.
|
|
1492
|
+
*
|
|
1493
|
+
* When `outputDir` is provided, frames are written to disk and the method returns an
|
|
1494
|
+
* array of file paths. Without `outputDir`, frames are returned as in-memory Buffer
|
|
1495
|
+
* objects (no temp files left behind).
|
|
1496
|
+
*
|
|
1497
|
+
* @param {string} filePath - Path to the source video file
|
|
1498
|
+
* @param {Object} [options] - Extraction options
|
|
1499
|
+
* @param {string} [options.mode='scene-change'] - 'scene-change' for intelligent detection, 'interval' for fixed time spacing
|
|
1500
|
+
* @param {number} [options.sceneThreshold=0.3] - Scene detection sensitivity 0-1, lower = more frames (scene-change mode only)
|
|
1501
|
+
* @param {number} [options.intervalSeconds=5] - Seconds between frames (interval mode only)
|
|
1502
|
+
* @param {number} [options.maxFrames] - Maximum number of frames to extract
|
|
1503
|
+
* @param {string} [options.format='jpeg'] - Output format: 'jpeg' or 'png'
|
|
1504
|
+
* @param {number} [options.quality] - JPEG quality 1-31, lower is better (JPEG only)
|
|
1505
|
+
* @param {number} [options.width] - Output width in pixels (maintains aspect ratio if height omitted)
|
|
1506
|
+
* @param {number} [options.height] - Output height in pixels (maintains aspect ratio if width omitted)
|
|
1507
|
+
* @param {string} [options.outputDir] - Directory to write frames to. If omitted, returns Buffer[] instead of string[].
|
|
1508
|
+
* @param {string} [options.tempDir] - Custom directory for temporary files (default: os.tmpdir()). Only used when outputDir is not set.
|
|
1509
|
+
* @returns {Promise<Buffer[]|string[]>} Buffer[] when no outputDir, string[] of file paths when outputDir is set
|
|
1510
|
+
* @throws {SimpleffmpegError} If arguments are invalid
|
|
1511
|
+
* @throws {FFmpegError} If FFmpeg fails during extraction
|
|
1512
|
+
*
|
|
1513
|
+
* @example
|
|
1514
|
+
* // Scene-change detection — returns Buffer[]
|
|
1515
|
+
* const frames = await SIMPLEFFMPEG.extractKeyframes("./video.mp4", {
|
|
1516
|
+
* mode: "scene-change",
|
|
1517
|
+
* sceneThreshold: 0.4,
|
|
1518
|
+
* maxFrames: 8,
|
|
1519
|
+
* format: "jpeg",
|
|
1520
|
+
* });
|
|
1521
|
+
*
|
|
1522
|
+
* @example
|
|
1523
|
+
* // Fixed interval — writes to disk, returns string[]
|
|
1524
|
+
* const paths = await SIMPLEFFMPEG.extractKeyframes("./video.mp4", {
|
|
1525
|
+
* mode: "interval",
|
|
1526
|
+
* intervalSeconds: 5,
|
|
1527
|
+
* outputDir: "./frames/",
|
|
1528
|
+
* format: "png",
|
|
1529
|
+
* });
|
|
1530
|
+
*/
|
|
1531
|
+
static async extractKeyframes(filePath, options = {}) {
|
|
1532
|
+
if (!filePath) {
|
|
1533
|
+
throw new SimpleffmpegError(
|
|
1534
|
+
"extractKeyframes() requires a filePath as the first argument"
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const {
|
|
1539
|
+
mode = "scene-change",
|
|
1540
|
+
sceneThreshold = 0.3,
|
|
1541
|
+
intervalSeconds = 5,
|
|
1542
|
+
maxFrames,
|
|
1543
|
+
format = "jpeg",
|
|
1544
|
+
quality,
|
|
1545
|
+
width,
|
|
1546
|
+
height,
|
|
1547
|
+
outputDir,
|
|
1548
|
+
tempDir,
|
|
1549
|
+
} = options;
|
|
1550
|
+
|
|
1551
|
+
if (mode !== "scene-change" && mode !== "interval") {
|
|
1552
|
+
throw new SimpleffmpegError(
|
|
1553
|
+
`extractKeyframes() invalid mode: "${mode}". Must be "scene-change" or "interval".`
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
if (format !== "jpeg" && format !== "png") {
|
|
1558
|
+
throw new SimpleffmpegError(
|
|
1559
|
+
`extractKeyframes() invalid format: "${format}". Must be "jpeg" or "png".`
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (
|
|
1564
|
+
mode === "scene-change" &&
|
|
1565
|
+
(typeof sceneThreshold !== "number" ||
|
|
1566
|
+
sceneThreshold < 0 ||
|
|
1567
|
+
sceneThreshold > 1)
|
|
1568
|
+
) {
|
|
1569
|
+
throw new SimpleffmpegError(
|
|
1570
|
+
"extractKeyframes() sceneThreshold must be a number between 0 and 1."
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
if (
|
|
1575
|
+
mode === "interval" &&
|
|
1576
|
+
(typeof intervalSeconds !== "number" || intervalSeconds <= 0)
|
|
1577
|
+
) {
|
|
1578
|
+
throw new SimpleffmpegError(
|
|
1579
|
+
"extractKeyframes() intervalSeconds must be a positive number."
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
if (maxFrames != null && (!Number.isInteger(maxFrames) || maxFrames < 1)) {
|
|
1584
|
+
throw new SimpleffmpegError(
|
|
1585
|
+
"extractKeyframes() maxFrames must be a positive integer."
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (tempDir != null && typeof tempDir === "string" && !fs.existsSync(tempDir)) {
|
|
1590
|
+
throw new SimpleffmpegError(
|
|
1591
|
+
`extractKeyframes() tempDir "${tempDir}" does not exist.`
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const ext = format === "png" ? ".png" : ".jpg";
|
|
1596
|
+
const useTemp = !outputDir;
|
|
1597
|
+
|
|
1598
|
+
let targetDir;
|
|
1599
|
+
if (outputDir) {
|
|
1600
|
+
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
1601
|
+
targetDir = outputDir;
|
|
1602
|
+
} else {
|
|
1603
|
+
const tmpBase = tempDir || os.tmpdir();
|
|
1604
|
+
targetDir = await fsPromises.mkdtemp(
|
|
1605
|
+
path.join(tmpBase, "simpleffmpeg-keyframes-")
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const outputPattern = path.join(targetDir, `frame-%04d${ext}`);
|
|
1610
|
+
|
|
1611
|
+
const command = buildKeyframeCommand({
|
|
1612
|
+
inputPath: filePath,
|
|
1613
|
+
outputPattern,
|
|
1614
|
+
mode,
|
|
1615
|
+
sceneThreshold,
|
|
1616
|
+
intervalSeconds,
|
|
1617
|
+
maxFrames,
|
|
1618
|
+
width,
|
|
1619
|
+
height,
|
|
1620
|
+
quality,
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
try {
|
|
1624
|
+
await runFFmpeg({ command });
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
if (useTemp) {
|
|
1627
|
+
await fsPromises
|
|
1628
|
+
.rm(targetDir, { recursive: true, force: true })
|
|
1629
|
+
.catch(() => {});
|
|
1630
|
+
}
|
|
1631
|
+
throw err;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const files = (await fsPromises.readdir(targetDir))
|
|
1635
|
+
.filter((f) => f.startsWith("frame-") && f.endsWith(ext))
|
|
1636
|
+
.sort();
|
|
1637
|
+
|
|
1638
|
+
if (useTemp) {
|
|
1639
|
+
const buffers = await Promise.all(
|
|
1640
|
+
files.map((f) => fsPromises.readFile(path.join(targetDir, f)))
|
|
1641
|
+
);
|
|
1642
|
+
await fsPromises
|
|
1643
|
+
.rm(targetDir, { recursive: true, force: true })
|
|
1644
|
+
.catch(() => {});
|
|
1645
|
+
return buffers;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
return files.map((f) => path.join(targetDir, f));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1352
1651
|
/**
|
|
1353
1652
|
* Format validation result as human-readable string
|
|
1354
1653
|
* @param {Object} result - Validation result from validate()
|