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/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.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
package/src/core/rotation.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
package/src/core/validation.js
CHANGED
|
@@ -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
|
};
|
package/src/ffmpeg/strings.js
CHANGED
|
@@ -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
|
};
|