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.
@@ -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; // Guard against concurrent load() calls
100
- this._isExporting = false; // Guard against concurrent export() calls
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
- path.dirname(exportOptions.outputPath),
697
+ textTempBase,
610
698
  `.simpleffmpeg_text_${idx}_${Date.now()}.txt`
611
699
  );
612
- // Replace newlines with space for non-karaoke (consistent with escapeDrawtextText)
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
- fs.renameSync(finalPath, exportOptions.outputPath);
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()