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.
@@ -0,0 +1,358 @@
1
+ const fs = require("fs");
2
+ const { exec } = require("child_process");
3
+ const TextRenderer = require("./ffmpeg/text_renderer");
4
+ const { unrotateVideo } = require("./core/rotation");
5
+ const Loaders = require("./loaders");
6
+ const { buildVideoFilter } = require("./ffmpeg/video_builder");
7
+ const { buildAudioForVideoClips } = require("./ffmpeg/audio_builder");
8
+ const { buildBackgroundMusicMix } = require("./ffmpeg/bgm_builder");
9
+ const { getClipAudioString } = require("./ffmpeg/strings");
10
+ const { validateClips } = require("./core/validation");
11
+ const C = require("./core/constants");
12
+ const { buildMainCommand } = require("./ffmpeg/command_builder");
13
+ const { runTextPasses } = require("./ffmpeg/text_passes");
14
+ const { formatBytes } = require("./lib/utils");
15
+
16
+ class SIMPLEFFMPEG {
17
+ constructor(options) {
18
+ this.options = {
19
+ fps: options.fps || C.DEFAULT_FPS,
20
+ width: options.width || C.DEFAULT_WIDTH,
21
+ height: options.height || C.DEFAULT_HEIGHT,
22
+ validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
23
+ };
24
+ this.videoOrAudioClips = [];
25
+ this.textClips = [];
26
+ this.filesToClean = [];
27
+ }
28
+
29
+ _getInputStreams() {
30
+ return this.videoOrAudioClips
31
+ .map((clip) => {
32
+ if (clip.type === "image") {
33
+ const duration = Math.max(0, clip.end - clip.position || 0);
34
+ return `-loop 1 -t ${duration} -i "${clip.url}"`;
35
+ }
36
+ return `-i "${clip.url}"`;
37
+ })
38
+ .join(" ");
39
+ }
40
+
41
+ _cleanup() {
42
+ this.filesToClean.forEach((file) => {
43
+ fs.unlink(file, (error) => {
44
+ if (error) {
45
+ console.error("Error cleaning up file:", error);
46
+ } else {
47
+ console.log("File cleaned up:", file);
48
+ }
49
+ });
50
+ });
51
+ }
52
+
53
+ load(clipObjs) {
54
+ validateClips(clipObjs, this.options.validationMode);
55
+ return Promise.all(
56
+ clipObjs.map((clipObj) => {
57
+ if (clipObj.type === "video" || clipObj.type === "audio") {
58
+ clipObj.volume = clipObj.volume || 1;
59
+ clipObj.cutFrom = clipObj.cutFrom || 0;
60
+ if (clipObj.type === "video" && clipObj.transition) {
61
+ clipObj.transition = {
62
+ type: clipObj.transition.type || clipObj.transition,
63
+ duration: clipObj.transition.duration || 0.5,
64
+ };
65
+ }
66
+ }
67
+ if (clipObj.type === "video") {
68
+ return Loaders.loadVideo(this, clipObj);
69
+ }
70
+ if (clipObj.type === "audio") {
71
+ return Loaders.loadAudio(this, clipObj);
72
+ }
73
+ if (clipObj.type === "text") {
74
+ return Loaders.loadText(this, clipObj);
75
+ }
76
+ if (clipObj.type === "image") {
77
+ return Loaders.loadImage(this, clipObj);
78
+ }
79
+ if (clipObj.type === "music" || clipObj.type === "backgroundAudio") {
80
+ return Loaders.loadBackgroundAudio(this, clipObj);
81
+ }
82
+ })
83
+ );
84
+ }
85
+
86
+ export(options) {
87
+ const exportOptions = {
88
+ outputPath: options.outputPath || "./output.mp4",
89
+ textMaxNodesPerPass:
90
+ typeof options.textMaxNodesPerPass === "number"
91
+ ? options.textMaxNodesPerPass
92
+ : C.DEFAULT_TEXT_MAX_NODES_PER_PASS,
93
+ intermediateVideoCodec:
94
+ options.intermediateVideoCodec || C.INTERMEDIATE_VIDEO_CODEC,
95
+ intermediateCrf:
96
+ typeof options.intermediateCrf === "number"
97
+ ? options.intermediateCrf
98
+ : C.INTERMEDIATE_CRF,
99
+ intermediatePreset: options.intermediatePreset || C.INTERMEDIATE_PRESET,
100
+ };
101
+
102
+ return new Promise(async (resolve, reject) => {
103
+ const t0 = Date.now();
104
+ this.videoOrAudioClips.sort((a, b) => {
105
+ if (!a.position) return -1;
106
+ if (!b.position) return 1;
107
+ if (a.position < b.position) return -1;
108
+ return 1;
109
+ });
110
+
111
+ // Handle rotation
112
+ await Promise.all(
113
+ this.videoOrAudioClips.map(async (clip) => {
114
+ if (clip.type === "video" && clip.iphoneRotation !== 0) {
115
+ const unrotatedUrl = await unrotateVideo(clip.url);
116
+ this.filesToClean.push(unrotatedUrl);
117
+ clip.url = unrotatedUrl;
118
+ }
119
+ })
120
+ );
121
+
122
+ const videoClips = this.videoOrAudioClips.filter(
123
+ (clip) => clip.type === "video" || clip.type === "image"
124
+ );
125
+ const audioClips = this.videoOrAudioClips.filter(
126
+ (clip) => clip.type === "audio"
127
+ );
128
+ const backgroundClips = this.videoOrAudioClips.filter(
129
+ (clip) => clip.type === "music" || clip.type === "backgroundAudio"
130
+ );
131
+
132
+ let filterComplex = "";
133
+ let finalVideoLabel = "";
134
+ let finalAudioLabel = "";
135
+ let hasVideo = false;
136
+ let hasAudio = false;
137
+
138
+ const totalVideoDuration = (() => {
139
+ if (videoClips.length === 0) return 0;
140
+ const baseSum = videoClips.reduce(
141
+ (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
142
+ 0
143
+ );
144
+ const transitionsOverlap = videoClips.reduce((acc, c, idx) => {
145
+ if (idx === 0) return acc;
146
+ const d =
147
+ c.transition && typeof c.transition.duration === "number"
148
+ ? c.transition.duration
149
+ : 0;
150
+ return acc + d;
151
+ }, 0);
152
+ return Math.max(0, baseSum - transitionsOverlap);
153
+ })();
154
+ const textEnd =
155
+ this.textClips.length > 0
156
+ ? Math.max(...this.textClips.map((c) => c.end || 0))
157
+ : 0;
158
+ const audioEnds = this.videoOrAudioClips
159
+ .filter(
160
+ (c) =>
161
+ c.type === "audio" ||
162
+ c.type === "music" ||
163
+ c.type === "backgroundAudio"
164
+ )
165
+ .map((c) => (typeof c.end === "number" ? c.end : 0));
166
+ const bgOrAudioEnd = audioEnds.length > 0 ? Math.max(...audioEnds) : 0;
167
+ const finalVisualEnd =
168
+ videoClips.length > 0
169
+ ? Math.max(...videoClips.map((c) => c.end))
170
+ : Math.max(textEnd, bgOrAudioEnd);
171
+
172
+ // Build video filter
173
+ if (videoClips.length > 0) {
174
+ const vres = buildVideoFilter(this, videoClips);
175
+ filterComplex += vres.filter;
176
+ finalVideoLabel = vres.finalVideoLabel;
177
+ hasVideo = vres.hasVideo;
178
+ }
179
+
180
+ // Audio for video clips (aligned amix)
181
+ if (videoClips.length > 0) {
182
+ const ares = buildAudioForVideoClips(this, videoClips);
183
+ filterComplex += ares.filter;
184
+ finalAudioLabel = ares.finalAudioLabel || finalAudioLabel;
185
+ hasAudio = hasAudio || ares.hasAudio;
186
+ }
187
+
188
+ // Standalone audio clips
189
+ if (audioClips.length > 0) {
190
+ let audioString = "";
191
+ let audioConcatInputs = [];
192
+ audioClips.forEach((clip) => {
193
+ const inputIndex = this.videoOrAudioClips.indexOf(clip);
194
+ const { audioStringPart, audioConcatInput } = getClipAudioString(
195
+ clip,
196
+ inputIndex
197
+ );
198
+ audioString += audioStringPart;
199
+ audioConcatInputs.push(audioConcatInput);
200
+ });
201
+ if (audioConcatInputs.length > 0) {
202
+ filterComplex += audioString;
203
+ filterComplex += audioConcatInputs.join("");
204
+ if (hasAudio) {
205
+ filterComplex += `${finalAudioLabel}amix=inputs=${
206
+ audioConcatInputs.length + 1
207
+ }:duration=longest[finalaudio];`;
208
+ finalAudioLabel = "[finalaudio]";
209
+ } else {
210
+ filterComplex += `amix=inputs=${audioConcatInputs.length}:duration=longest[finalaudio];`;
211
+ finalAudioLabel = "[finalaudio]";
212
+ hasAudio = true;
213
+ }
214
+ }
215
+ }
216
+
217
+ // Background music after other audio
218
+ if (backgroundClips.length > 0) {
219
+ const bgres = buildBackgroundMusicMix(
220
+ this,
221
+ backgroundClips,
222
+ hasAudio ? finalAudioLabel : null,
223
+ finalVisualEnd
224
+ );
225
+ filterComplex += bgres.filter;
226
+ finalAudioLabel = bgres.finalAudioLabel || finalAudioLabel;
227
+ hasAudio = hasAudio || bgres.hasAudio;
228
+ }
229
+
230
+ if (hasAudio && finalAudioLabel) {
231
+ const trimEnd =
232
+ finalVisualEnd > 0 ? finalVisualEnd : totalVideoDuration;
233
+ filterComplex += `${finalAudioLabel}apad,atrim=end=${trimEnd}[audfit];`;
234
+ finalAudioLabel = "[audfit]";
235
+ }
236
+
237
+ // Text overlays
238
+ let needTextPasses = false;
239
+ let textWindows = [];
240
+ if (this.textClips.length > 0 && hasVideo) {
241
+ textWindows = TextRenderer.expandTextWindows(this.textClips);
242
+ const projectDuration = totalVideoDuration;
243
+ textWindows = textWindows
244
+ .filter(
245
+ (w) => typeof w.start === "number" && w.start < projectDuration
246
+ )
247
+ .map((w) => ({ ...w, end: Math.min(w.end, projectDuration) }));
248
+ needTextPasses = textWindows.length > exportOptions.textMaxNodesPerPass;
249
+ if (!needTextPasses) {
250
+ const { filterString, finalVideoLabel: outLabel } =
251
+ TextRenderer.buildTextFilters(
252
+ this.textClips,
253
+ this.options.width,
254
+ this.options.height,
255
+ finalVideoLabel
256
+ );
257
+ filterComplex += filterString;
258
+ finalVideoLabel = outLabel;
259
+ }
260
+ }
261
+
262
+ // Command
263
+ const ffmpegCmd = buildMainCommand({
264
+ inputs: this._getInputStreams(),
265
+ filterComplex,
266
+ mapVideo: finalVideoLabel,
267
+ mapAudio: finalAudioLabel,
268
+ hasVideo,
269
+ hasAudio,
270
+ videoCodec: C.VIDEO_CODEC,
271
+ videoPreset: C.VIDEO_PRESET,
272
+ videoCrf: C.VIDEO_CRF,
273
+ audioCodec: C.AUDIO_CODEC,
274
+ audioBitrate: C.AUDIO_BITRATE,
275
+ shortest: true,
276
+ faststart: true,
277
+ outputPath: exportOptions.outputPath,
278
+ });
279
+
280
+ console.log("simple-ffmpeg: Starting export...");
281
+ exec(ffmpegCmd, async (error, stdout, stderr) => {
282
+ if (error) {
283
+ console.error("FFmpeg stderr:", stderr);
284
+ reject(error);
285
+ this._cleanup();
286
+ return;
287
+ }
288
+ if (!needTextPasses) {
289
+ const elapsedMs = Date.now() - t0;
290
+ const visualCount = videoClips.length;
291
+ const audioCount = audioClips.length;
292
+ const musicCount = backgroundClips.length;
293
+ let fileSizeStr = "?";
294
+ try {
295
+ const { size } = fs.statSync(exportOptions.outputPath);
296
+ fileSizeStr = formatBytes(size);
297
+ } catch (_) {}
298
+ console.log(
299
+ `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`
300
+ );
301
+ console.log(
302
+ `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
303
+ 2
304
+ )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:0)`
305
+ );
306
+ resolve(exportOptions.outputPath);
307
+ this._cleanup();
308
+ return;
309
+ }
310
+ try {
311
+ // Multi-pass text overlay batching via helper
312
+ const { finalPath, tempOutputs, passes } = await runTextPasses({
313
+ baseOutputPath: exportOptions.outputPath,
314
+ textWindows,
315
+ canvasWidth: this.options.width,
316
+ canvasHeight: this.options.height,
317
+ intermediateVideoCodec: exportOptions.intermediateVideoCodec,
318
+ intermediatePreset: exportOptions.intermediatePreset,
319
+ intermediateCrf: exportOptions.intermediateCrf,
320
+ batchSize: exportOptions.textMaxNodesPerPass,
321
+ });
322
+ if (finalPath !== exportOptions.outputPath) {
323
+ fs.renameSync(finalPath, exportOptions.outputPath);
324
+ }
325
+ tempOutputs.slice(0, -1).forEach((f) => {
326
+ try {
327
+ fs.unlinkSync(f);
328
+ } catch (_) {}
329
+ });
330
+ const elapsedMs = Date.now() - t0;
331
+ const visualCount = videoClips.length;
332
+ const audioCount = audioClips.length;
333
+ const musicCount = backgroundClips.length;
334
+ let fileSizeStr = "?";
335
+ try {
336
+ const { size } = fs.statSync(exportOptions.outputPath);
337
+ fileSizeStr = formatBytes(size);
338
+ } catch (_) {}
339
+ console.log(
340
+ `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`
341
+ );
342
+ console.log(
343
+ `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
344
+ 2
345
+ )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`
346
+ );
347
+ resolve(exportOptions.outputPath);
348
+ this._cleanup();
349
+ } catch (batchErr) {
350
+ reject(batchErr);
351
+ this._cleanup();
352
+ }
353
+ });
354
+ });
355
+ }
356
+ }
357
+
358
+ module.exports = SIMPLEFFMPEG;
@@ -0,0 +1,128 @@
1
+ export type ClipType =
2
+ | "video"
3
+ | "audio"
4
+ | "text"
5
+ | "music"
6
+ | "backgroundAudio"
7
+ | "image";
8
+
9
+ export interface BaseClip {
10
+ type: ClipType;
11
+ url?: string;
12
+ position: number;
13
+ end: number;
14
+ }
15
+
16
+ export interface VideoClip extends BaseClip {
17
+ type: "video";
18
+ url: string;
19
+ cutFrom?: number;
20
+ transition?: { type: string; duration: number };
21
+ }
22
+
23
+ export interface AudioClip extends BaseClip {
24
+ type: "audio";
25
+ url: string;
26
+ cutFrom?: number;
27
+ volume?: number;
28
+ }
29
+
30
+ export interface BackgroundMusicClip extends BaseClip {
31
+ type: "music" | "backgroundAudio";
32
+ url: string;
33
+ cutFrom?: number;
34
+ volume?: number;
35
+ }
36
+
37
+ export interface ImageClip extends BaseClip {
38
+ type: "image";
39
+ url: string;
40
+ kenBurns?:
41
+ | "zoom-in"
42
+ | "zoom-out"
43
+ | "pan-left"
44
+ | "pan-right"
45
+ | "pan-up"
46
+ | "pan-down";
47
+ }
48
+
49
+ export type TextMode = "static" | "word-replace" | "word-sequential";
50
+ export type TextAnimationType =
51
+ | "none"
52
+ | "fade-in"
53
+ | "fade-in-out"
54
+ | "pop"
55
+ | "pop-bounce";
56
+
57
+ export interface TextWordWindow {
58
+ text: string;
59
+ start: number;
60
+ end: number;
61
+ }
62
+
63
+ export interface TextClip {
64
+ type: "text";
65
+ text?: string;
66
+ position: number;
67
+ end: number;
68
+ mode?: TextMode;
69
+ words?: TextWordWindow[];
70
+ wordTimestamps?: number[];
71
+
72
+ // Font
73
+ fontFile?: string;
74
+ fontFamily?: string; // defaults to 'Sans' via fontconfig
75
+ fontSize?: number; // default 48
76
+ fontColor?: string; // default '#FFFFFF'
77
+
78
+ // Position
79
+ centerX?: number;
80
+ centerY?: number;
81
+ x?: number;
82
+ y?: number;
83
+
84
+ // Styling
85
+ borderColor?: string;
86
+ borderWidth?: number;
87
+ shadowColor?: string;
88
+ shadowX?: number;
89
+ shadowY?: number;
90
+ backgroundColor?: string;
91
+ backgroundOpacity?: number;
92
+ padding?: number;
93
+
94
+ // Animation
95
+ animation?: {
96
+ type: TextAnimationType;
97
+ in?: number; // seconds
98
+ out?: number; // seconds
99
+ };
100
+ }
101
+
102
+ export type Clip =
103
+ | VideoClip
104
+ | AudioClip
105
+ | BackgroundMusicClip
106
+ | ImageClip
107
+ | TextClip;
108
+
109
+ export interface SIMPLEFFMPEGOptions {
110
+ fps?: number;
111
+ width?: number;
112
+ height?: number;
113
+ validationMode?: "warn" | "strict";
114
+ }
115
+
116
+ export interface ExportOptions {
117
+ outputPath?: string;
118
+ textMaxNodesPerPass?: number;
119
+ intermediateVideoCodec?: string;
120
+ intermediateCrf?: number;
121
+ intermediatePreset?: string;
122
+ }
123
+
124
+ export default class SIMPLEFFMPEG {
125
+ constructor(options: SIMPLEFFMPEGOptions);
126
+ load(clips: Clip[]): Promise<void[]>;
127
+ export(options: ExportOptions): Promise<string>;
128
+ }