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 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 };