simple-ffmpegjs 0.1.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/LICENSE +21 -0
- package/README.md +420 -0
- package/index.js +1 -0
- package/package.json +37 -0
- package/src/core/constants.js +31 -0
- package/src/core/media_info.js +82 -0
- package/src/core/rotation.js +23 -0
- package/src/core/validation.js +188 -0
- package/src/ffmpeg/audio_builder.js +34 -0
- package/src/ffmpeg/bgm_builder.js +67 -0
- package/src/ffmpeg/command_builder.js +40 -0
- package/src/ffmpeg/strings.js +21 -0
- package/src/ffmpeg/text_passes.js +67 -0
- package/src/ffmpeg/text_renderer.js +363 -0
- package/src/ffmpeg/video_builder.js +130 -0
- package/src/lib/utils.js +13 -0
- package/src/loaders.js +136 -0
- package/src/simpleffmpeg.js +358 -0
- package/types/index.d.ts +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 simpleffmpeg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# simple-ffmpeg 🎬
|
|
2
|
+
|
|
3
|
+
Simple lightweight Node.js helper around FFmpeg for quick video composition, transitions, audio mixing, and animated text overlays.
|
|
4
|
+
|
|
5
|
+
## 🙌 Credits
|
|
6
|
+
|
|
7
|
+
Huge shoutout to the original inspiration for this library:
|
|
8
|
+
|
|
9
|
+
- John Chen (coldshower): https://github.com/coldshower
|
|
10
|
+
- ezffmpeg: https://github.com/ezffmpeg/ezffmpeg
|
|
11
|
+
|
|
12
|
+
This project builds on those ideas and extends them with a few opinionated defaults and features to make common video tasks easier.
|
|
13
|
+
|
|
14
|
+
## ✨ Why this project
|
|
15
|
+
|
|
16
|
+
Built for data pipelines: a tiny helper around FFmpeg that makes common edits trivial—clip concatenation with transitions, flexible text overlays, images with Ken Burns effects, and reliable audio mixing—without hiding FFmpeg. It favors safe defaults, scales to long scripts, and stays dependency‑free.
|
|
17
|
+
|
|
18
|
+
- ✅ Simple API for building FFmpeg filter graphs
|
|
19
|
+
- 🎞️ Concatenate clips with optional transitions (xfade)
|
|
20
|
+
- 🔊 Mix multiple audio sources and add background music (not affected by transition fades)
|
|
21
|
+
- 📝 Text overlays (static, word-by-word, cumulative) with opt-in animations (fade-in, pop, pop-bounce)
|
|
22
|
+
- 🧰 Safe defaults + guardrails (basic validation, media bounds clamping)
|
|
23
|
+
- 🧱 Scales to long scripts via optional multi-pass text batching
|
|
24
|
+
- 🧩 Ships TypeScript definitions without requiring TS
|
|
25
|
+
- 🪶 No external libraries (other than FFmpeg), no bundled fonts; extremely lightweight
|
|
26
|
+
- 🧑💻 Actively maintained; PRs and issues welcome
|
|
27
|
+
- 🖼️ Image support with Ken Burns (zoom-in/out, pan-left/right/up/down)
|
|
28
|
+
|
|
29
|
+
## 📦 Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install simple-ffmpegjs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## ⚙️ Requirements
|
|
36
|
+
|
|
37
|
+
Make sure you have ffmpeg installed on your system:
|
|
38
|
+
|
|
39
|
+
**Mac**: brew install ffmpeg
|
|
40
|
+
|
|
41
|
+
**Ubuntu/Debian**: apt-get install ffmpeg
|
|
42
|
+
|
|
43
|
+
**Windows**: Download from ffmpeg.org
|
|
44
|
+
|
|
45
|
+
Ensure `ffmpeg` and `ffprobe` are installed and available on your PATH.
|
|
46
|
+
|
|
47
|
+
For text overlays with `drawtext`, use an FFmpeg build that includes libfreetype and fontconfig. Make sure a system font is present so `font=Sans` resolves, or provide `fontFile`. Minimal containers often lack fonts, so install one explicitly.
|
|
48
|
+
|
|
49
|
+
### Examples:
|
|
50
|
+
|
|
51
|
+
Debian/Ubuntu:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
apt-get update && apt-get install -y ffmpeg fontconfig fonts-dejavu-core
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Alpine:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
apk add --no-cache ffmpeg fontconfig ttf-dejavu
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 🚀 Quick start
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const SIMPLEFFMPEG = require("simple-ffmpegjs");
|
|
67
|
+
|
|
68
|
+
(async () => {
|
|
69
|
+
const project = new SIMPLEFFMPEG({ width: 1080, height: 1920, fps: 30 });
|
|
70
|
+
|
|
71
|
+
await project.load([
|
|
72
|
+
{ type: "video", url: "./vids/a.mp4", position: 0, end: 5 },
|
|
73
|
+
{
|
|
74
|
+
type: "video",
|
|
75
|
+
url: "./vids/b.mp4",
|
|
76
|
+
position: 5,
|
|
77
|
+
end: 10,
|
|
78
|
+
transition: { type: "fade-in", duration: 0.5 },
|
|
79
|
+
},
|
|
80
|
+
{ type: "music", url: "./audio/bgm.wav", volume: 0.2 },
|
|
81
|
+
{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: "Hello world",
|
|
84
|
+
position: 1,
|
|
85
|
+
end: 3,
|
|
86
|
+
fontColor: "white",
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
await project.export({ outputPath: "./output.mp4" });
|
|
91
|
+
})();
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 📚 Examples
|
|
95
|
+
|
|
96
|
+
- 🎞️ Two clips + fade transition + background music
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
await project.load([
|
|
100
|
+
{ type: "video", url: "./a.mp4", position: 0, end: 5 },
|
|
101
|
+
{
|
|
102
|
+
type: "video",
|
|
103
|
+
url: "./b.mp4",
|
|
104
|
+
position: 5,
|
|
105
|
+
end: 10,
|
|
106
|
+
transition: { type: "fade-in", duration: 0.4 },
|
|
107
|
+
},
|
|
108
|
+
{ type: "music", url: "./bgm.wav", volume: 0.18 },
|
|
109
|
+
]);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- 📝 Static text (centered by default)
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
await project.load([
|
|
116
|
+
{ type: "video", url: "./clip.mp4", position: 0, end: 5 },
|
|
117
|
+
{
|
|
118
|
+
type: "text",
|
|
119
|
+
text: "Static Title",
|
|
120
|
+
position: 0.5,
|
|
121
|
+
end: 2.5,
|
|
122
|
+
fontColor: "white",
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- 🔤 Word-by-word replacement with fade-in
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
await project.load([
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
mode: "word-replace",
|
|
134
|
+
position: 2.0,
|
|
135
|
+
end: 4.0,
|
|
136
|
+
fontColor: "#00ffff",
|
|
137
|
+
centerX: 0,
|
|
138
|
+
centerY: -350,
|
|
139
|
+
animation: { type: "fade-in-out", in: 0.4, out: 0.4 },
|
|
140
|
+
words: [
|
|
141
|
+
{ text: "One", start: 2.0, end: 2.5 },
|
|
142
|
+
{ text: "Two", start: 2.5, end: 3.0 },
|
|
143
|
+
{ text: "Three", start: 3.0, end: 3.5 },
|
|
144
|
+
{ text: "Four", start: 3.5, end: 4.0 },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- 🔠 Word-by-word (auto) with pop-bounce
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
await project.load([
|
|
154
|
+
{
|
|
155
|
+
type: "text",
|
|
156
|
+
mode: "word-replace",
|
|
157
|
+
text: "Alpha Beta Gamma Delta",
|
|
158
|
+
position: 4.0,
|
|
159
|
+
end: 6.0,
|
|
160
|
+
fontSize: 64,
|
|
161
|
+
fontColor: "yellow",
|
|
162
|
+
centerX: 0,
|
|
163
|
+
centerY: -100,
|
|
164
|
+
animation: { type: "fade-in-out", in: 0.4, out: 0.4 },
|
|
165
|
+
wordTimestamps: [4.0, 4.5, 5.0, 5.5, 6.0],
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- 🎧 Standalone audio overlay
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
await project.load([
|
|
174
|
+
{ type: "audio", url: "./vo.mp3", position: 0, end: 10, volume: 1 },
|
|
175
|
+
]);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- 🖼️ Images with Ken Burns (zoom + pan)
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
await project.load([
|
|
182
|
+
// Zoom-in image (2s)
|
|
183
|
+
{
|
|
184
|
+
type: "image",
|
|
185
|
+
url: "./img.png",
|
|
186
|
+
position: 10,
|
|
187
|
+
end: 12,
|
|
188
|
+
kenBurns: "zoom-in",
|
|
189
|
+
},
|
|
190
|
+
// Pan-right image (2s)
|
|
191
|
+
{
|
|
192
|
+
type: "image",
|
|
193
|
+
url: "./img.png",
|
|
194
|
+
position: 12,
|
|
195
|
+
end: 14,
|
|
196
|
+
kenBurns: "pan-right",
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## 🧠 Behavior (in short)
|
|
202
|
+
|
|
203
|
+
- Timeline uses clip `[position, end)`; transitions are overlaps that reduce total duration by their length
|
|
204
|
+
- Background music is mixed after other audio, so transition acrossfades don’t attenuate it
|
|
205
|
+
- Clip audio is timeline-aligned (absolute position) and mixed once; avoids early starts and gaps around transitions
|
|
206
|
+
- Text animations are opt-in (none by default)
|
|
207
|
+
- For big scripts, text rendering can be batched into multiple passes automatically
|
|
208
|
+
- Visual gaps are not allowed: if there’s any gap with no video/image between clips (or at the very start), validation throws
|
|
209
|
+
|
|
210
|
+
## 🔌 API (at a glance)
|
|
211
|
+
|
|
212
|
+
- `new SIMPLEFFMPEG({ width?, height?, fps?, validationMode? })`
|
|
213
|
+
- `await project.load([...clips])` — video/audio/text/music descriptors
|
|
214
|
+
- `await project.export({ outputPath?, textMaxNodesPerPass? })`
|
|
215
|
+
|
|
216
|
+
That’s it—keep it simple. See the examples above for common cases.
|
|
217
|
+
|
|
218
|
+
## 🔬 API Details
|
|
219
|
+
|
|
220
|
+
### Constructor
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
new SIMPLEFFMPEG(options?: {
|
|
224
|
+
fps?: number; // default 30
|
|
225
|
+
width?: number; // default 1920
|
|
226
|
+
height?: number; // default 1080
|
|
227
|
+
validationMode?: 'warn' | 'strict'; // default 'warn'
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### project.load(clips)
|
|
232
|
+
|
|
233
|
+
Loads and pre-validates clips. Accepts an array of clip descriptors (video, audio, background music, text). Returns a Promise that resolves when inputs are prepared (e.g., metadata read, rotation handled later at export).
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
await project.load(clips: Clip[]);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### Clip union
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
type Clip = VideoClip | AudioClip | BackgroundMusicClip | ImageClip | TextClip;
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### Video clip
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
interface VideoClip {
|
|
249
|
+
type: "video";
|
|
250
|
+
url: string; // input video file path/URL
|
|
251
|
+
position: number; // timeline start (seconds)
|
|
252
|
+
end: number; // timeline end (seconds)
|
|
253
|
+
cutFrom?: number; // source offset (seconds), default 0
|
|
254
|
+
volume?: number; // if the source has audio, default 1
|
|
255
|
+
transition?: {
|
|
256
|
+
// optional transition at the boundary before this clip
|
|
257
|
+
type: string; // e.g., 'fade', 'wipeleft', etc. (xfade transitions)
|
|
258
|
+
duration: number; // in seconds
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Notes:
|
|
264
|
+
|
|
265
|
+
- All xfade transitions are supported you can see a list of them [here](https://trac.ffmpeg.org/wiki/Xfade)
|
|
266
|
+
- Each transition reduces total output duration by its duration (overlap semantics).
|
|
267
|
+
- Rotation metadata is handled automatically before export.
|
|
268
|
+
|
|
269
|
+
#### Audio clip (standalone)
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
interface AudioClip {
|
|
273
|
+
type: "audio";
|
|
274
|
+
url: string;
|
|
275
|
+
position: number; // timeline start
|
|
276
|
+
end: number; // timeline end
|
|
277
|
+
cutFrom?: number; // default 0
|
|
278
|
+
volume?: number; // default 1
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
#### Background music clip
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
interface BackgroundMusicClip {
|
|
286
|
+
type: "music" | "backgroundAudio";
|
|
287
|
+
url: string;
|
|
288
|
+
position?: number; // default 0
|
|
289
|
+
end?: number; // default project duration (video timeline)
|
|
290
|
+
cutFrom?: number; // default 0
|
|
291
|
+
volume?: number; // default 0.2
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Notes:
|
|
296
|
+
|
|
297
|
+
- Mixed after other audio and after acrossfades, so transition fades do not attenuate the background music.
|
|
298
|
+
- If no videos exist, `end` defaults to the max provided among BGM clips.
|
|
299
|
+
|
|
300
|
+
#### Text clip
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
interface TextClip {
|
|
304
|
+
type: "text";
|
|
305
|
+
// Time window
|
|
306
|
+
position: number; // start on timeline
|
|
307
|
+
end: number; // end on timeline
|
|
308
|
+
|
|
309
|
+
// Content & modes
|
|
310
|
+
text?: string; // used for 'static' and as source when auto-splitting words
|
|
311
|
+
mode?: "static" | "word-replace" | "word-sequential";
|
|
312
|
+
|
|
313
|
+
// Word timing (choose one form)
|
|
314
|
+
words?: Array<{ text: string; start: number; end: number }>; // explicit per-word timing (absolute seconds)
|
|
315
|
+
wordTimestamps?: number[]; // timestamps to split `text` by whitespace; N or N+1 entries
|
|
316
|
+
|
|
317
|
+
// Font & styling
|
|
318
|
+
fontFile?: string; // overrides fontFamily
|
|
319
|
+
fontFamily?: string; // default 'Sans' (fontconfig)
|
|
320
|
+
fontSize?: number; // default 48
|
|
321
|
+
fontColor?: string; // default '#FFFFFF'
|
|
322
|
+
|
|
323
|
+
// Positioning (center by default)
|
|
324
|
+
centerX?: number; // pixel offset from center (x)
|
|
325
|
+
centerY?: number; // pixel offset from center (y)
|
|
326
|
+
x?: number; // absolute x (left)
|
|
327
|
+
y?: number; // absolute y (top)
|
|
328
|
+
|
|
329
|
+
// Animation (opt-in)
|
|
330
|
+
animation?: {
|
|
331
|
+
type: "none" | "fade-in" | "fade-in-out" | "pop" | "pop-bounce"; // default 'none'
|
|
332
|
+
in?: number; // seconds for intro phase (e.g., fade-in duration)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Notes:
|
|
338
|
+
|
|
339
|
+
- If both `words` and `wordTimestamps` are provided, `words` takes precedence.
|
|
340
|
+
- For `wordTimestamps` with a single array: provide either per-word start times (end inferred by next start), or N+1 edge times; whitespace in `text` defines words.
|
|
341
|
+
- Defaults to centered placement if no explicit `x/y` or `centerX/centerY` provided.
|
|
342
|
+
|
|
343
|
+
#### Image clip
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
interface ImageClip {
|
|
347
|
+
type: "image";
|
|
348
|
+
url: string;
|
|
349
|
+
position: number; // timeline start
|
|
350
|
+
end: number; // timeline end
|
|
351
|
+
kenBurns?:
|
|
352
|
+
| "zoom-in"
|
|
353
|
+
| "zoom-out"
|
|
354
|
+
| "pan-left"
|
|
355
|
+
| "pan-right"
|
|
356
|
+
| "pan-up"
|
|
357
|
+
| "pan-down";
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Notes:
|
|
362
|
+
|
|
363
|
+
- Images are treated as video streams. Ken Burns uses `zoompan` internally with the correct frame count.
|
|
364
|
+
- For pan-only moves, a small base zoom is applied so there’s room to pan across.
|
|
365
|
+
|
|
366
|
+
### project.export(options)
|
|
367
|
+
|
|
368
|
+
Builds and runs the FFmpeg command. Returns the final `outputPath`.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
await project.export(options?: {
|
|
372
|
+
outputPath?: string; // default './output.mp4'
|
|
373
|
+
textMaxNodesPerPass?: number; // default 75 (batch size for multi-pass text)
|
|
374
|
+
intermediateVideoCodec?: string; // default 'libx264' (for text passes)
|
|
375
|
+
intermediateCrf?: number; // default 18 (for text passes)
|
|
376
|
+
intermediatePreset?: string; // default 'veryfast' (for text passes)
|
|
377
|
+
}): Promise<string>;
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Behavior:
|
|
381
|
+
|
|
382
|
+
- If text overlay count exceeds `textMaxNodesPerPass`, text is rendered in multiple passes using temporary files; audio is copied between passes; final output is fast-start.
|
|
383
|
+
- Mapping: final video/audio streams are mapped based on what exists; if only audio or only video is present, mapping adapts accordingly.
|
|
384
|
+
|
|
385
|
+
### Timeline semantics
|
|
386
|
+
|
|
387
|
+
- Each clip contributes `[position, end)` to the timeline.
|
|
388
|
+
- For transitions, the overlap reduces the final output duration by the transition duration.
|
|
389
|
+
- Background music defaults to the visual timeline end (max `end` across video/image clips) and is mixed after other audio and acrossfades.
|
|
390
|
+
|
|
391
|
+
### Animations
|
|
392
|
+
|
|
393
|
+
- `none` (default): plain text, no animation
|
|
394
|
+
- `fade-in`: alpha 0 → 1 over `in` seconds (e.g., 0.25–0.4)
|
|
395
|
+
- `fade-in-out`: alpha 0 → 1 over `in` seconds, then 1 → 0 over `out` seconds approaching the end
|
|
396
|
+
- `pop`: font size scales from ~70% → 100% over `in` seconds
|
|
397
|
+
- `pop-bounce`: scales ~70% → 110% during `in`, then settles to 100%
|
|
398
|
+
|
|
399
|
+
Tip: small `in` values (0.2–0.4s) feel snappy for word-by-word displays.
|
|
400
|
+
|
|
401
|
+
## 🤝 Contributing
|
|
402
|
+
|
|
403
|
+
- PRs and issues welcome
|
|
404
|
+
- Actively being worked on; I’ll review new contributions and iterate
|
|
405
|
+
|
|
406
|
+
## 🗺️ Roadmap
|
|
407
|
+
|
|
408
|
+
- Visual gap handling (opt-in fillers): optional `fillVisualGaps: 'none' | 'black'` if requested
|
|
409
|
+
- Additional text effects (typewriter, word-by-word fade-out variants, outlines/shadows presets)
|
|
410
|
+
- Image effects presets (Ken Burns paths presets, ease functions)
|
|
411
|
+
- Ken Burns upgrades: strength parameter, custom positioning, additional ease curves
|
|
412
|
+
- Optional audio transition coupling: tie clip audio fades to xfade boundaries
|
|
413
|
+
- Export options for different containers/codecs (HEVC, VP9/AV1, audio-only)
|
|
414
|
+
- Better error reporting with command dump helpers
|
|
415
|
+
- CLI wrapper for quick local use
|
|
416
|
+
- Performance: smarter batching and parallel intermediate renders
|
|
417
|
+
|
|
418
|
+
## 📜 License
|
|
419
|
+
|
|
420
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./src/simpleffmpeg");
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simple-ffmpegjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"types": "types/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"types",
|
|
9
|
+
"index.js"
|
|
10
|
+
],
|
|
11
|
+
"keywords": [
|
|
12
|
+
"ffmpeg",
|
|
13
|
+
"video",
|
|
14
|
+
"video-editing",
|
|
15
|
+
"video-processing",
|
|
16
|
+
"drawtext",
|
|
17
|
+
"xfade",
|
|
18
|
+
"acrossfade",
|
|
19
|
+
"amix",
|
|
20
|
+
"overlay",
|
|
21
|
+
"animation",
|
|
22
|
+
"nodejs",
|
|
23
|
+
"text"
|
|
24
|
+
],
|
|
25
|
+
"author": "Brayden Blackwell <braydenblackwell21@gmail.com> (https://github.com/Fats403)",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Fats403/simple-ffmpeg.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/Fats403/simple-ffmpeg/issues",
|
|
33
|
+
"email": "braydenblackwell21@gmail.com"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/Fats403/simple-ffmpeg#readme",
|
|
36
|
+
"description": "Simple Node.js helper around ffmpeg for video composition, transitions, audio mixing, and text rendering."
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
DEFAULT_FPS: 30,
|
|
3
|
+
DEFAULT_WIDTH: 1920,
|
|
4
|
+
DEFAULT_HEIGHT: 1080,
|
|
5
|
+
DEFAULT_VALIDATION_MODE: "warn",
|
|
6
|
+
|
|
7
|
+
// Text batching
|
|
8
|
+
DEFAULT_TEXT_MAX_NODES_PER_PASS: 75,
|
|
9
|
+
INTERMEDIATE_VIDEO_CODEC: "libx264",
|
|
10
|
+
INTERMEDIATE_CRF: 18,
|
|
11
|
+
INTERMEDIATE_PRESET: "veryfast",
|
|
12
|
+
|
|
13
|
+
// Encoding
|
|
14
|
+
VIDEO_CODEC: "libx264",
|
|
15
|
+
VIDEO_CRF: 23,
|
|
16
|
+
VIDEO_PRESET: "medium",
|
|
17
|
+
AUDIO_CODEC: "aac",
|
|
18
|
+
AUDIO_BITRATE: "192k",
|
|
19
|
+
|
|
20
|
+
// Fonts/Text
|
|
21
|
+
DEFAULT_FONT_FAMILY: "Sans",
|
|
22
|
+
DEFAULT_FONT_SIZE: 48,
|
|
23
|
+
DEFAULT_FONT_COLOR: "#FFFFFF",
|
|
24
|
+
DEFAULT_TEXT_ANIM_IN: 0.25,
|
|
25
|
+
|
|
26
|
+
// Audio
|
|
27
|
+
DEFAULT_BGM_VOLUME: 0.2,
|
|
28
|
+
|
|
29
|
+
// Transitions
|
|
30
|
+
DEFAULT_TRANSITION_DURATION: 0.5,
|
|
31
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { exec } = require("child_process");
|
|
2
|
+
|
|
3
|
+
function getVideoMetadata(url) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const cmd = `ffprobe -v error -show_streams -show_format -of json "${url}"`;
|
|
6
|
+
exec(cmd, (error, stdout) => {
|
|
7
|
+
if (error) {
|
|
8
|
+
console.error("Error getting video metadata:", error);
|
|
9
|
+
resolve({
|
|
10
|
+
iphoneRotation: 0,
|
|
11
|
+
hasAudio: false,
|
|
12
|
+
width: null,
|
|
13
|
+
height: null,
|
|
14
|
+
durationSec: null,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const metadata = JSON.parse(stdout);
|
|
20
|
+
const videoStream = metadata.streams.find(
|
|
21
|
+
(s) => s.codec_type === "video"
|
|
22
|
+
);
|
|
23
|
+
const hasAudio = metadata.streams.some((s) => s.codec_type === "audio");
|
|
24
|
+
const iphoneRotation = videoStream?.side_data_list?.[0]?.rotation
|
|
25
|
+
? videoStream.side_data_list[0].rotation
|
|
26
|
+
: 0;
|
|
27
|
+
const formatDuration = metadata.format?.duration
|
|
28
|
+
? parseFloat(metadata.format.duration)
|
|
29
|
+
: null;
|
|
30
|
+
const streamDuration = videoStream?.duration
|
|
31
|
+
? parseFloat(videoStream.duration)
|
|
32
|
+
: null;
|
|
33
|
+
const durationSec = Number.isFinite(formatDuration)
|
|
34
|
+
? formatDuration
|
|
35
|
+
: Number.isFinite(streamDuration)
|
|
36
|
+
? streamDuration
|
|
37
|
+
: null;
|
|
38
|
+
resolve({
|
|
39
|
+
iphoneRotation,
|
|
40
|
+
hasAudio,
|
|
41
|
+
width: videoStream?.width,
|
|
42
|
+
height: videoStream?.height,
|
|
43
|
+
durationSec,
|
|
44
|
+
});
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error("Error parsing metadata:", e);
|
|
47
|
+
resolve({
|
|
48
|
+
iphoneRotation: 0,
|
|
49
|
+
hasAudio: false,
|
|
50
|
+
width: null,
|
|
51
|
+
height: null,
|
|
52
|
+
durationSec: null,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getMediaDuration(url) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const cmd = `ffprobe -v error -show_format -of json "${url}"`;
|
|
62
|
+
exec(cmd, (error, stdout) => {
|
|
63
|
+
if (error) {
|
|
64
|
+
console.error("Error getting media duration:", error);
|
|
65
|
+
resolve(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const metadata = JSON.parse(stdout);
|
|
70
|
+
const formatDuration = metadata.format?.duration
|
|
71
|
+
? parseFloat(metadata.format.duration)
|
|
72
|
+
: null;
|
|
73
|
+
resolve(Number.isFinite(formatDuration) ? formatDuration : null);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.error("Error parsing media duration:", e);
|
|
76
|
+
resolve(null);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { getVideoMetadata, getMediaDuration };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const { randomUUID } = require("crypto");
|
|
4
|
+
const { exec } = require("child_process");
|
|
5
|
+
|
|
6
|
+
const tempDir = os.tmpdir();
|
|
7
|
+
|
|
8
|
+
function unrotateVideo(inputUrl) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const out = path.join(tempDir, `unrotated-${randomUUID()}.mp4`);
|
|
11
|
+
const cmd = `ffmpeg -y -i "${inputUrl}" "${out}"`;
|
|
12
|
+
exec(cmd, (error) => {
|
|
13
|
+
if (error) {
|
|
14
|
+
console.error("Error unrotating video:", error);
|
|
15
|
+
reject(error);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
resolve(out);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { unrotateVideo };
|