simple-ffmpegjs 0.4.4 → 0.5.0

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 CHANGED
@@ -68,7 +68,8 @@ _Click to watch a "Wonders of the World" video created with simple-ffmpeg — co
68
68
 
69
69
  **Video & Images**
70
70
  - **Video Concatenation** — Join multiple clips with optional xfade transitions
71
- - **Image Support** — Ken Burns effects (zoom, pan) for static images
71
+ - **Image Support** — Ken Burns effects (zoom, pan) for static images with intelligent aspect ratio handling
72
+ - **Image Fitting** — Automatic blur-fill, cover, or contain modes when image aspect ratio differs from output
72
73
  - **Color Clips** — Flat colors and gradients (linear, radial) as first-class timeline clips with full transition support
73
74
 
74
75
  **Audio**
@@ -76,12 +77,16 @@ _Click to watch a "Wonders of the World" video created with simple-ffmpeg — co
76
77
 
77
78
  **Overlays & Effects**
78
79
  - **Text Overlays** — Static, word-by-word, and cumulative text with animations
80
+ - **Emoji Support** — Opt-in emoji rendering via custom font + libass; stripped by default for clean output
79
81
  - **Text Animations** — Typewriter, scale-in, pulse, fade effects
80
82
  - **Karaoke Mode** — Word-by-word highlighting with customizable colors
81
83
  - **Subtitle Import** — Load SRT, VTT, ASS/SSA subtitle files
82
84
  - **Watermarks** — Text or image overlays with positioning and timing control
83
85
  - **Effect Clips** — Timed overlay effects (vignette, film grain, blur, color adjust, sepia, black & white, sharpen, chromatic aberration, letterbox) with fade-in/out envelopes
84
86
 
87
+ **Analysis & Extraction**
88
+ - **Keyframe Extraction** — Scene-change detection or fixed-interval frame sampling, returning in-memory buffers or files on disk
89
+
85
90
  **Developer Experience**
86
91
  - **Platform Presets** — Quick configuration for TikTok, YouTube, Instagram, etc.
87
92
  - **Progress Tracking** — Real-time export progress callbacks
@@ -123,6 +128,20 @@ apk add --no-cache ffmpeg fontconfig ttf-dejavu
123
128
  apt-get install -y ffmpeg fontconfig fonts-dejavu-core
124
129
  ```
125
130
 
131
+ **Emoji in text overlays** are handled gracefully: by default, emoji characters are automatically detected and silently stripped from text to prevent blank boxes (tofu). To render emoji, pass an `emojiFont` path in the constructor:
132
+
133
+ ```javascript
134
+ const project = new SIMPLEFFMPEG({
135
+ width: 1920,
136
+ height: 1080,
137
+ emojiFont: '/path/to/NotoEmoji-Regular.ttf'
138
+ });
139
+ ```
140
+
141
+ Recommended font: [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) (B&W outline, ~2 MB, SIL OFL). Download from [Google Fonts](https://fonts.google.com/noto/specimen/Noto+Emoji) or [GitHub](https://github.com/google/fonts/raw/main/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf). When an emoji font is configured, emoji text is routed through libass (ASS subtitle path) with inline `\fn` font switching for per-glyph rendering.
142
+
143
+ > **Note:** Emoji render as monochrome outlines because libass does not yet support color emoji font formats. The shapes are recognizable and correctly spaced, just not multi-colored. Without `emojiFont`, emoji are stripped and a one-time console warning is logged.
144
+
126
145
  ## Quick Start
127
146
 
128
147
  ```js
@@ -254,7 +273,7 @@ Available modules:
254
273
  | ---------- | ----------------------------------------------------------- |
255
274
  | `video` | Video clips, transitions, volume, trimming |
256
275
  | `audio` | Standalone audio clips |
257
- | `image` | Image clips, Ken Burns effects |
276
+ | `image` | Image clips, Ken Burns effects, image fitting modes |
258
277
  | `color` | Color clips — flat colors, linear/radial gradients |
259
278
  | `effect` | Overlay adjustment effects — vignette, grain, blur, color adjust, sepia, B&W, sharpen, chromatic aberration, letterbox |
260
279
  | `text` | Text overlays — all modes, animations, positioning, styling |
@@ -296,9 +315,24 @@ new SIMPLEFFMPEG(options?: {
296
315
  validationMode?: 'warn' | 'strict'; // Validation behavior (default: 'warn')
297
316
  preset?: string; // Platform preset (e.g., 'tiktok', 'youtube', 'instagram-post')
298
317
  fontFile?: string; // Default font file for all text clips (individual clips can override)
318
+ emojiFont?: string; // Path to emoji font .ttf for opt-in emoji rendering (stripped by default)
319
+ tempDir?: string; // Custom temp directory for intermediate files (default: OS temp)
299
320
  })
300
321
  ```
301
322
 
323
+ **Custom Temp Directory:**
324
+
325
+ Set `tempDir` to route all temporary files (gradient images, unrotated videos, text/subtitle temp files, batch intermediate renders) to a custom location. Useful for fast SSDs, ramdisks, Docker containers with limited `/tmp`, or any environment where temp storage performance matters:
326
+
327
+ ```ts
328
+ const project = new SIMPLEFFMPEG({
329
+ preset: "youtube",
330
+ tempDir: "/mnt/fast-nvme/tmp",
331
+ });
332
+ ```
333
+
334
+ When not set, temp files go to the OS default (`os.tmpdir()`) or next to the output file, depending on the operation. Cross-filesystem moves are handled automatically.
335
+
302
336
  When `fontFile` is set at the project level, every text clip (including karaoke) inherits it automatically. You can still override it on any individual clip:
303
337
 
304
338
  ```js
@@ -422,6 +456,67 @@ await SIMPLEFFMPEG.snapshot("./video.mp4", {
422
456
  });
423
457
  ```
424
458
 
459
+ #### `SIMPLEFFMPEG.extractKeyframes(filePath, options)`
460
+
461
+ Extract keyframes from a video using scene-change detection or fixed time intervals. This is a static method — no project instance needed.
462
+
463
+ **Scene-change mode** (default) uses FFmpeg's `select=gt(scene,N)` filter to intelligently detect visual transitions and extract frames at cut points. **Interval mode** extracts frames at fixed time intervals.
464
+
465
+ When `outputDir` is provided, frames are written to disk and the method returns an array of file paths. Without `outputDir`, frames are returned as in-memory `Buffer` objects (no temp files left behind).
466
+
467
+ ```ts
468
+ // Scene-change detection — returns Buffer[]
469
+ const frames = await SIMPLEFFMPEG.extractKeyframes("./video.mp4", {
470
+ mode: "scene-change",
471
+ sceneThreshold: 0.4,
472
+ maxFrames: 8,
473
+ format: "jpeg",
474
+ });
475
+
476
+ // Fixed interval — writes to disk, returns string[]
477
+ const paths = await SIMPLEFFMPEG.extractKeyframes("./video.mp4", {
478
+ mode: "interval",
479
+ intervalSeconds: 5,
480
+ outputDir: "./frames/",
481
+ format: "png",
482
+ });
483
+ ```
484
+
485
+ **Keyframe Options:**
486
+
487
+ | Option | Type | Default | Description |
488
+ | ----------------- | -------- | ---------------- | ------------------------------------------------------------------------------- |
489
+ | `mode` | `string` | `'scene-change'` | `'scene-change'` for intelligent detection, `'interval'` for fixed time spacing |
490
+ | `sceneThreshold` | `number` | `0.3` | Scene detection sensitivity 0-1 (lower = more frames). Scene-change mode only. |
491
+ | `intervalSeconds` | `number` | `5` | Seconds between frames. Interval mode only. |
492
+ | `maxFrames` | `number` | - | Maximum number of frames to extract |
493
+ | `format` | `string` | `'jpeg'` | Output format: `'jpeg'` or `'png'` |
494
+ | `quality` | `number` | - | JPEG quality 1-31, lower is better (only applies to JPEG) |
495
+ | `width` | `number` | - | Output width in pixels (maintains aspect ratio if height omitted) |
496
+ | `height` | `number` | - | Output height in pixels (maintains aspect ratio if width omitted) |
497
+ | `outputDir` | `string` | - | Directory to write frames to. If omitted, returns `Buffer[]` instead. |
498
+ | `tempDir` | `string` | `os.tmpdir()` | Custom temp directory (only when `outputDir` is not set). Useful for fast SSDs or ramdisks. |
499
+
500
+ ```ts
501
+ // Scene-change with resize and JPEG quality
502
+ const frames = await SIMPLEFFMPEG.extractKeyframes("./long-video.mp4", {
503
+ sceneThreshold: 0.25,
504
+ maxFrames: 12,
505
+ width: 640,
506
+ quality: 4,
507
+ });
508
+
509
+ // One frame every 10 seconds, saved as PNG
510
+ const paths = await SIMPLEFFMPEG.extractKeyframes("./presentation.mp4", {
511
+ mode: "interval",
512
+ intervalSeconds: 10,
513
+ outputDir: "./thumbnails/",
514
+ format: "png",
515
+ });
516
+ ```
517
+
518
+ Throws `FFmpegError` if FFmpeg fails during extraction.
519
+
425
520
  #### `project.export(options)`
426
521
 
427
522
  Build and execute the FFmpeg command to render the final video.
@@ -540,6 +635,8 @@ All [xfade transitions](https://trac.ffmpeg.org/wiki/Xfade) are supported.
540
635
  duration?: number; // Duration in seconds (alternative to end)
541
636
  width?: number; // Optional: source image width (skip probe / override)
542
637
  height?: number; // Optional: source image height (skip probe / override)
638
+ imageFit?: "cover" | "contain" | "blur-fill"; // How to handle aspect ratio mismatch (see below)
639
+ blurIntensity?: number; // Blur strength for blur-fill background (default: 40, range: 10-80)
543
640
  kenBurns?:
544
641
  | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "pan-up" | "pan-down"
545
642
  | "smart" | "custom"
@@ -557,6 +654,58 @@ All [xfade transitions](https://trac.ffmpeg.org/wiki/Xfade) are supported.
557
654
  }
558
655
  ```
559
656
 
657
+ **Image Fitting (`imageFit`):**
658
+
659
+ When an image's aspect ratio doesn't match the output (e.g., a landscape photo in a portrait video), `imageFit` controls how the mismatch is resolved:
660
+
661
+ | Mode | Behavior | Default for |
662
+ |---|---|---|
663
+ | `blur-fill` | Scale to fit, fill empty space with a blurred version of the image | Static images (no Ken Burns) |
664
+ | `cover` | Scale to fill the entire frame, center-crop any excess | Ken Burns images |
665
+ | `contain` | Scale to fit within the frame, pad with black bars | — |
666
+
667
+ If `imageFit` is not specified, the library picks the best default: **`blur-fill`** for static images (produces polished output similar to TikTok/Reels) and **`cover`** for Ken Burns images (ensures full-frame cinematic motion).
668
+
669
+ ```ts
670
+ // Landscape photo in a portrait video — blurred background fills the bars (default)
671
+ { type: "image", url: "./landscape.jpg", duration: 5 }
672
+
673
+ // Explicit cover — crops to fill the frame
674
+ { type: "image", url: "./landscape.jpg", duration: 5, imageFit: "cover" }
675
+
676
+ // Black bars (letterbox/pillarbox)
677
+ { type: "image", url: "./landscape.jpg", duration: 5, imageFit: "contain" }
678
+
679
+ // Stronger blur effect
680
+ { type: "image", url: "./landscape.jpg", duration: 5, imageFit: "blur-fill", blurIntensity: 70 }
681
+ ```
682
+
683
+ **Ken Burns + imageFit:** When using Ken Burns with `blur-fill` or `contain`, the pan/zoom motion applies only to the image content — the blurred background or black bars remain static, matching the behavior of modern phone video editors. Source dimensions (`width`/`height`) are required for KB + `blur-fill`/`contain`; without them it falls back to `cover`.
684
+
685
+ ```ts
686
+ // Ken Burns zoom on contained image with blurred background
687
+ {
688
+ type: "image",
689
+ url: "./landscape.jpg",
690
+ duration: 5,
691
+ width: 1920,
692
+ height: 1080,
693
+ kenBurns: "zoom-in",
694
+ imageFit: "blur-fill",
695
+ }
696
+
697
+ // Ken Burns pan with black bars
698
+ {
699
+ type: "image",
700
+ url: "./landscape.jpg",
701
+ duration: 5,
702
+ width: 1920,
703
+ height: 1080,
704
+ kenBurns: "pan-right",
705
+ imageFit: "contain",
706
+ }
707
+ ```
708
+
560
709
  #### Color Clip
561
710
 
562
711
  Color clips add flat colors or gradients as first-class visual elements. They support transitions, text overlays, and all the same timeline features as video and image clips. Use them for intros, outros, title cards, or anywhere you need a background.
@@ -1066,6 +1215,7 @@ When `position` is omitted, clips are placed sequentially — see [Auto-Sequenci
1066
1215
  > **Note:** Ken Burns effects work best with images at least as large as your output resolution. Smaller images are automatically upscaled (with a validation warning). Use `strictKenBurns: true` in validation options to enforce size requirements instead.
1067
1216
  > If you pass `width`/`height`, they override probed dimensions (useful for remote or generated images).
1068
1217
  > `smart` mode uses source vs output aspect (when known) to choose pan direction.
1218
+ > Ken Burns defaults to `imageFit: "cover"` (full-frame motion). Set `imageFit: "blur-fill"` or `"contain"` for phone-style editing where the motion applies to the contained image while the background stays static.
1069
1219
 
1070
1220
  ### Text & Animations
1071
1221
 
@@ -1125,6 +1275,43 @@ await project.load([
1125
1275
  // Also available: fade-in, fade-out, fade-in-out, pop, pop-bounce, scale-in
1126
1276
  ```
1127
1277
 
1278
+ **Emoji in text overlays:**
1279
+
1280
+ Emoji characters are automatically detected. By default they are stripped from text to prevent tofu (blank boxes). To render emoji, configure an `emojiFont` path in the constructor:
1281
+
1282
+ ```ts
1283
+ // Enable emoji rendering by providing a font path
1284
+ const project = new SIMPLEFFMPEG({
1285
+ width: 1920,
1286
+ height: 1080,
1287
+ emojiFont: "./fonts/NotoEmoji-Regular.ttf",
1288
+ });
1289
+
1290
+ await project.load([
1291
+ { type: "video", url: "./bg.mp4", position: 0, end: 10 },
1292
+ {
1293
+ type: "text",
1294
+ text: "small dog, big heart 🐾",
1295
+ position: 1,
1296
+ end: 5,
1297
+ fontSize: 48,
1298
+ fontColor: "#FFFFFF",
1299
+ yPercent: 0.5,
1300
+ },
1301
+ {
1302
+ type: "text",
1303
+ text: "Movie night! 🎬🍿✨",
1304
+ position: 5,
1305
+ end: 9,
1306
+ fontSize: 48,
1307
+ fontColor: "#FFFFFF",
1308
+ animation: { type: "fade-in-out", in: 0.5, out: 0.5 },
1309
+ },
1310
+ ]);
1311
+ ```
1312
+
1313
+ > **Note:** Without `emojiFont`, emoji are silently stripped (no tofu). With `emojiFont`, emoji render as monochrome outlines via the ASS path. Supports fade animations (`fade-in`, `fade-out`, `fade-in-out`) and static text. For other animation types (`pop`, `typewriter`, etc.), emoji are stripped and a console warning is logged.
1314
+
1128
1315
  ### Karaoke
1129
1316
 
1130
1317
  Word-by-word highlighting with customizable colors. Use `highlightStyle: "instant"` for immediate color changes instead of the default smooth fill:
@@ -1526,7 +1713,7 @@ npm run test:watch
1526
1713
  For visual verification, run the demo suite to generate sample videos covering all major features. Each demo outputs to its own subfolder under `examples/output/` and includes annotated expected timelines so you know exactly what to look for:
1527
1714
 
1528
1715
  ```bash
1529
- # Run all demos (color clips, effects, transitions, text, Ken Burns, audio, watermarks, karaoke, torture test)
1716
+ # Run all demos (color clips, effects, transitions, text, emoji, Ken Burns, audio, watermarks, karaoke, torture test)
1530
1717
  node examples/run-examples.js
1531
1718
 
1532
1719
  # Run a specific demo by name (partial match)
@@ -1542,10 +1729,12 @@ Available demo scripts (can also be run individually):
1542
1729
  | `demo-effects.js` | Timed overlay effects (all 9 effects) with smooth fade ramps |
1543
1730
  | `demo-transitions.js` | Fade, wipe, slide, dissolve, fadeblack/white, short/long durations, image transitions |
1544
1731
  | `demo-text-and-animations.js` | Positioning, fade, pop, pop-bounce, typewriter, scale-in, pulse, styling, word-replace |
1732
+ | `demo-emoji-text.js` | Emoji stripping (default) and opt-in rendering via emojiFont, fade, styling, fallback |
1545
1733
  | `demo-ken-burns.js` | All 6 presets, smart anchors, custom diagonal, slideshow with transitions |
1546
1734
  | `demo-audio-mixing.js` | Volume levels, background music, standalone audio, loop, multi-source mix |
1547
1735
  | `demo-watermarks.js` | Text/image watermarks, all positions, timed appearance, styled over transitions |
1548
1736
  | `demo-karaoke-and-subtitles.js` | Smooth/instant karaoke, word timestamps, multiline, SRT, VTT, mixed text+karaoke |
1737
+ | `demo-image-fit.js` | Image fitting modes (blur-fill, cover, contain), Ken Burns + imageFit, mixed timelines |
1549
1738
  | `demo-torture-test.js` | Kitchen sink, many clips+gaps+transitions, 6 simultaneous text animations, edge cases |
1550
1739
 
1551
1740
  Each script header contains a `WHAT TO CHECK` section describing the expected visual output at every timestamp, making it easy to spot regressions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-ffmpegjs",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Declarative video composition for Node.js — define clips, transitions, text, and audio as simple objects, and let FFmpeg handle the rest.",
5
5
  "author": "Brayden Blackwell <braydenblackwell21@gmail.com> (https://github.com/Fats403)",
6
6
  "license": "MIT",
@@ -5,8 +5,6 @@ const { randomUUID } = require("crypto");
5
5
  const { spawn } = require("child_process");
6
6
  const { FFmpegError } = require("./errors");
7
7
 
8
- const tempDir = os.tmpdir();
9
-
10
8
  /** Default timeout for unrotate operations (5 minutes) */
11
9
  const DEFAULT_UNROTATE_TIMEOUT_MS = 5 * 60 * 1000;
12
10
 
@@ -14,13 +12,16 @@ const DEFAULT_UNROTATE_TIMEOUT_MS = 5 * 60 * 1000;
14
12
  * Unrotate a video (remove iPhone rotation metadata) using ffmpeg.
15
13
  * Uses spawn() with argument array to avoid command injection.
16
14
  * @param {string} inputUrl - Path to the input video file
17
- * @param {number} [timeoutMs] - Timeout in milliseconds (default: 5 minutes)
15
+ * @param {Object} [options] - Options
16
+ * @param {number} [options.timeoutMs] - Timeout in milliseconds (default: 5 minutes)
17
+ * @param {string} [options.tempDir] - Custom temp directory (default: os.tmpdir())
18
18
  * @returns {Promise<string>} Path to the unrotated temporary video file
19
19
  * @throws {FFmpegError} If ffmpeg fails or times out
20
20
  */
21
- function unrotateVideo(inputUrl, timeoutMs = DEFAULT_UNROTATE_TIMEOUT_MS) {
21
+ function unrotateVideo(inputUrl, options = {}) {
22
+ const timeoutMs = options.timeoutMs || DEFAULT_UNROTATE_TIMEOUT_MS;
22
23
  return new Promise((resolve, reject) => {
23
- const out = path.join(tempDir, `unrotated-${randomUUID()}.mp4`);
24
+ const out = path.join(options.tempDir || os.tmpdir(), `unrotated-${randomUUID()}.mp4`);
24
25
  const args = ["-y", "-i", inputUrl, out];
25
26
  let timedOut = false;
26
27
 
@@ -970,6 +970,42 @@ function validateClip(clip, index, options = {}) {
970
970
 
971
971
  // Image clip validation
972
972
  if (clip.type === "image") {
973
+ if (clip.imageFit !== undefined) {
974
+ const validImageFit = ["cover", "contain", "blur-fill"];
975
+ if (!validImageFit.includes(clip.imageFit)) {
976
+ errors.push(
977
+ createIssue(
978
+ ValidationCodes.INVALID_VALUE,
979
+ `${path}.imageFit`,
980
+ `Invalid imageFit '${clip.imageFit}'. Expected: ${validImageFit.join(", ")}`,
981
+ clip.imageFit
982
+ )
983
+ );
984
+ }
985
+ }
986
+
987
+ if (clip.blurIntensity !== undefined) {
988
+ if (typeof clip.blurIntensity !== "number" || !Number.isFinite(clip.blurIntensity)) {
989
+ errors.push(
990
+ createIssue(
991
+ ValidationCodes.INVALID_TYPE,
992
+ `${path}.blurIntensity`,
993
+ `blurIntensity must be a finite number`,
994
+ clip.blurIntensity
995
+ )
996
+ );
997
+ } else if (clip.blurIntensity <= 0) {
998
+ errors.push(
999
+ createIssue(
1000
+ ValidationCodes.INVALID_RANGE,
1001
+ `${path}.blurIntensity`,
1002
+ `blurIntensity must be > 0`,
1003
+ clip.blurIntensity
1004
+ )
1005
+ );
1006
+ }
1007
+ }
1008
+
973
1009
  if (clip.kenBurns) {
974
1010
  const validKenBurns = [
975
1011
  "zoom-in",
@@ -310,11 +310,61 @@ function sanitizeFilterComplex(fc) {
310
310
  return sanitized;
311
311
  }
312
312
 
313
+ /**
314
+ * Build FFmpeg command to extract keyframes from a video.
315
+ *
316
+ * Supports two modes:
317
+ * - "scene-change": uses select='gt(scene,N)' to detect visual transitions
318
+ * - "interval": uses fps=1/N to sample at fixed time intervals
319
+ */
320
+ function buildKeyframeCommand({
321
+ inputPath,
322
+ outputPattern,
323
+ mode,
324
+ sceneThreshold,
325
+ intervalSeconds,
326
+ maxFrames,
327
+ width,
328
+ height,
329
+ quality,
330
+ }) {
331
+ let cmd = `ffmpeg -y -i "${escapeFilePath(inputPath)}"`;
332
+
333
+ const filters = [];
334
+
335
+ if (mode === "scene-change") {
336
+ filters.push(`select='gt(scene,${sceneThreshold})'`);
337
+ } else {
338
+ filters.push(`fps=1/${intervalSeconds}`);
339
+ }
340
+
341
+ if (width || height) {
342
+ const w = width || -1;
343
+ const h = height || -1;
344
+ filters.push(`scale=${w}:${h}`);
345
+ }
346
+
347
+ cmd += ` -vf "${filters.join(",")}"`;
348
+ cmd += ` -vsync vfr`;
349
+
350
+ if (maxFrames != null) {
351
+ cmd += ` -frames:v ${maxFrames}`;
352
+ }
353
+
354
+ if (quality != null) {
355
+ cmd += ` -q:v ${quality}`;
356
+ }
357
+
358
+ cmd += ` "${escapeFilePath(outputPattern)}"`;
359
+ return cmd;
360
+ }
361
+
313
362
  module.exports = {
314
363
  buildMainCommand,
315
364
  buildTextBatchCommand,
316
365
  buildThumbnailCommand,
317
366
  buildSnapshotCommand,
367
+ buildKeyframeCommand,
318
368
  escapeMetadata,
319
369
  sanitizeFilterComplex,
320
370
  };
@@ -68,6 +68,85 @@ function escapeTextFilePath(filePath) {
68
68
  .replace(/:/g, "\\:"); // Escape colons (for Windows drive letters)
69
69
  }
70
70
 
71
+ /**
72
+ * Check if text contains emoji characters that require ASS-based rendering
73
+ * for proper font fallback (drawtext uses a single font with no fallback).
74
+ * Matches two classes:
75
+ * 1. \p{Emoji_Presentation} — inherently visual emoji (e.g. 🐾, 🎬)
76
+ * 2. \p{Emoji}\uFE0F — text-default emoji made visual by variation selector (e.g. ❤️)
77
+ * Does NOT match bare digits, #, * etc. which have \p{Emoji} but lack the selector.
78
+ */
79
+ function hasEmoji(text) {
80
+ if (typeof text !== "string") return false;
81
+ return /\p{Emoji_Presentation}/u.test(text) || /\p{Emoji}\uFE0F/u.test(text);
82
+ }
83
+
84
+ const fs = require("fs");
85
+ const path = require("path");
86
+
87
+ const VISUAL_EMOJI_RE = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu;
88
+
89
+ /**
90
+ * Remove visual emoji characters from text.
91
+ * Collapses any resulting double-spaces but preserves leading/trailing whitespace.
92
+ * @param {string} text
93
+ * @returns {string}
94
+ */
95
+ function stripEmoji(text) {
96
+ if (typeof text !== "string") return text;
97
+ return text.replace(VISUAL_EMOJI_RE, "").replace(/ {2,}/g, " ").trim();
98
+ }
99
+
100
+ /**
101
+ * Parse the font family name from a TrueType/OpenType font file.
102
+ * Reads the 'name' table (nameID 1) directly — no dependencies needed.
103
+ * Returns the family name string or null if parsing fails.
104
+ * @param {string} fontPath - Absolute or relative path to a .ttf/.otf file
105
+ * @returns {string|null}
106
+ */
107
+ function parseFontFamily(fontPath) {
108
+ try {
109
+ const buf = fs.readFileSync(fontPath);
110
+ if (buf.length < 12) return null;
111
+ const numTables = buf.readUInt16BE(4);
112
+ for (let i = 0; i < numTables; i++) {
113
+ const off = 12 + i * 16;
114
+ if (off + 16 > buf.length) return null;
115
+ const tag = buf.toString("ascii", off, off + 4);
116
+ if (tag !== "name") continue;
117
+ const tableOffset = buf.readUInt32BE(off + 8);
118
+ if (tableOffset + 6 > buf.length) return null;
119
+ const count = buf.readUInt16BE(tableOffset + 2);
120
+ const stringStorageOffset = tableOffset + buf.readUInt16BE(tableOffset + 4);
121
+ for (let j = 0; j < count; j++) {
122
+ const recOff = tableOffset + 6 + j * 12;
123
+ if (recOff + 12 > buf.length) return null;
124
+ const platformID = buf.readUInt16BE(recOff);
125
+ const nameID = buf.readUInt16BE(recOff + 6);
126
+ const length = buf.readUInt16BE(recOff + 8);
127
+ const strOff = buf.readUInt16BE(recOff + 10);
128
+ if (nameID !== 1) continue;
129
+ const start = stringStorageOffset + strOff;
130
+ if (start + length > buf.length) continue;
131
+ if (platformID === 1) {
132
+ return buf.toString("utf8", start, start + length);
133
+ }
134
+ if (platformID === 3) {
135
+ let name = "";
136
+ for (let k = 0; k < length; k += 2) {
137
+ name += String.fromCharCode(buf.readUInt16BE(start + k));
138
+ }
139
+ return name;
140
+ }
141
+ }
142
+ break;
143
+ }
144
+ } catch {
145
+ return null;
146
+ }
147
+ return null;
148
+ }
149
+
71
150
  function getClipAudioString(clip, inputIndex) {
72
151
  const adelay = Math.round(Math.max(0, (clip.position || 0) * 1000));
73
152
  const audioConcatInput = `[a${inputIndex}]`;
@@ -84,5 +163,8 @@ module.exports = {
84
163
  escapeDrawtextText,
85
164
  getClipAudioString,
86
165
  hasProblematicChars,
166
+ hasEmoji,
167
+ stripEmoji,
168
+ parseFontFamily,
87
169
  escapeTextFilePath,
88
170
  };