simple-ffmpegjs 0.1.0 → 0.2.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 +1 -1
- package/README.md +453 -295
- package/assets/example-thumbnail.jpg +0 -0
- package/index.cjs +2 -0
- package/index.mjs +2 -0
- package/package.json +64 -22
- package/src/core/constants.js +39 -1
- package/src/core/errors.js +64 -0
- package/src/core/gaps.js +81 -0
- package/src/core/validation.js +30 -26
- package/src/ffmpeg/command_builder.js +168 -8
- package/src/ffmpeg/text_renderer.js +10 -4
- package/src/ffmpeg/video_builder.js +47 -2
- package/src/lib/utils.js +200 -1
- package/src/loaders.js +4 -4
- package/src/simpleffmpeg.js +493 -237
- package/types/index.d.mts +342 -0
- package/types/index.d.ts +399 -116
- package/index.js +0 -1
package/src/simpleffmpeg.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
-
const
|
|
2
|
+
const path = require("path");
|
|
3
3
|
const TextRenderer = require("./ffmpeg/text_renderer");
|
|
4
4
|
const { unrotateVideo } = require("./core/rotation");
|
|
5
5
|
const Loaders = require("./loaders");
|
|
@@ -9,17 +9,21 @@ const { buildBackgroundMusicMix } = require("./ffmpeg/bgm_builder");
|
|
|
9
9
|
const { getClipAudioString } = require("./ffmpeg/strings");
|
|
10
10
|
const { validateClips } = require("./core/validation");
|
|
11
11
|
const C = require("./core/constants");
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
buildMainCommand,
|
|
14
|
+
buildThumbnailCommand,
|
|
15
|
+
} = require("./ffmpeg/command_builder");
|
|
13
16
|
const { runTextPasses } = require("./ffmpeg/text_passes");
|
|
14
|
-
const { formatBytes } = require("./lib/utils");
|
|
17
|
+
const { formatBytes, runFFmpeg } = require("./lib/utils");
|
|
15
18
|
|
|
16
19
|
class SIMPLEFFMPEG {
|
|
17
|
-
constructor(options) {
|
|
20
|
+
constructor(options = {}) {
|
|
18
21
|
this.options = {
|
|
19
22
|
fps: options.fps || C.DEFAULT_FPS,
|
|
20
23
|
width: options.width || C.DEFAULT_WIDTH,
|
|
21
24
|
height: options.height || C.DEFAULT_HEIGHT,
|
|
22
25
|
validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
|
|
26
|
+
fillGaps: options.fillGaps || "none", // 'none' | 'black'
|
|
23
27
|
};
|
|
24
28
|
this.videoOrAudioClips = [];
|
|
25
29
|
this.textClips = [];
|
|
@@ -51,7 +55,9 @@ class SIMPLEFFMPEG {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
load(clipObjs) {
|
|
54
|
-
validateClips(clipObjs, this.options.validationMode
|
|
58
|
+
validateClips(clipObjs, this.options.validationMode, {
|
|
59
|
+
fillGaps: this.options.fillGaps,
|
|
60
|
+
});
|
|
55
61
|
return Promise.all(
|
|
56
62
|
clipObjs.map((clipObj) => {
|
|
57
63
|
if (clipObj.type === "video" || clipObj.type === "audio") {
|
|
@@ -83,9 +89,44 @@ class SIMPLEFFMPEG {
|
|
|
83
89
|
);
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Build the export command and metadata (internal helper)
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
async _prepareExport(options = {}) {
|
|
87
97
|
const exportOptions = {
|
|
98
|
+
// Output
|
|
88
99
|
outputPath: options.outputPath || "./output.mp4",
|
|
100
|
+
|
|
101
|
+
// Video encoding
|
|
102
|
+
videoCodec: options.videoCodec || C.VIDEO_CODEC,
|
|
103
|
+
videoCrf: typeof options.crf === "number" ? options.crf : C.VIDEO_CRF,
|
|
104
|
+
videoPreset: options.preset || C.VIDEO_PRESET,
|
|
105
|
+
videoBitrate: options.videoBitrate || C.VIDEO_BITRATE,
|
|
106
|
+
|
|
107
|
+
// Audio encoding
|
|
108
|
+
audioCodec: options.audioCodec || C.AUDIO_CODEC,
|
|
109
|
+
audioBitrate: options.audioBitrate || C.AUDIO_BITRATE,
|
|
110
|
+
audioSampleRate: options.audioSampleRate || C.AUDIO_SAMPLE_RATE,
|
|
111
|
+
|
|
112
|
+
// Features
|
|
113
|
+
hwaccel: options.hwaccel || "none",
|
|
114
|
+
audioOnly: options.audioOnly || false,
|
|
115
|
+
twoPass: options.twoPass || false,
|
|
116
|
+
metadata: options.metadata || null,
|
|
117
|
+
thumbnail: options.thumbnail || null,
|
|
118
|
+
|
|
119
|
+
// Verbose/debug
|
|
120
|
+
verbose: options.verbose || false,
|
|
121
|
+
logLevel: options.logLevel || "warning",
|
|
122
|
+
saveCommand: options.saveCommand || null,
|
|
123
|
+
|
|
124
|
+
// Output resolution (scale on export)
|
|
125
|
+
outputWidth: options.outputWidth || null,
|
|
126
|
+
outputHeight: options.outputHeight || null,
|
|
127
|
+
outputResolution: options.outputResolution || null, // '720p', '1080p', '4k'
|
|
128
|
+
|
|
129
|
+
// Text batching
|
|
89
130
|
textMaxNodesPerPass:
|
|
90
131
|
typeof options.textMaxNodesPerPass === "number"
|
|
91
132
|
? options.textMaxNodesPerPass
|
|
@@ -99,259 +140,474 @@ class SIMPLEFFMPEG {
|
|
|
99
140
|
intermediatePreset: options.intermediatePreset || C.INTERMEDIATE_PRESET,
|
|
100
141
|
};
|
|
101
142
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
// Handle resolution presets
|
|
144
|
+
if (exportOptions.outputResolution) {
|
|
145
|
+
const presets = {
|
|
146
|
+
"480p": { width: 854, height: 480 },
|
|
147
|
+
"720p": { width: 1280, height: 720 },
|
|
148
|
+
"1080p": { width: 1920, height: 1080 },
|
|
149
|
+
"1440p": { width: 2560, height: 1440 },
|
|
150
|
+
"4k": { width: 3840, height: 2160 },
|
|
151
|
+
};
|
|
152
|
+
const preset = presets[exportOptions.outputResolution];
|
|
153
|
+
if (preset) {
|
|
154
|
+
exportOptions.outputWidth = preset.width;
|
|
155
|
+
exportOptions.outputHeight = preset.height;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
110
158
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
clip.url = unrotatedUrl;
|
|
118
|
-
}
|
|
119
|
-
})
|
|
120
|
-
);
|
|
159
|
+
this.videoOrAudioClips.sort((a, b) => {
|
|
160
|
+
if (!a.position) return -1;
|
|
161
|
+
if (!b.position) return 1;
|
|
162
|
+
if (a.position < b.position) return -1;
|
|
163
|
+
return 1;
|
|
164
|
+
});
|
|
121
165
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
166
|
+
// Handle rotation
|
|
167
|
+
await Promise.all(
|
|
168
|
+
this.videoOrAudioClips.map(async (clip) => {
|
|
169
|
+
if (clip.type === "video" && clip.iphoneRotation !== 0) {
|
|
170
|
+
const unrotatedUrl = await unrotateVideo(clip.url);
|
|
171
|
+
this.filesToClean.push(unrotatedUrl);
|
|
172
|
+
clip.url = unrotatedUrl;
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const videoClips = this.videoOrAudioClips.filter(
|
|
178
|
+
(clip) => clip.type === "video" || clip.type === "image"
|
|
179
|
+
);
|
|
180
|
+
const audioClips = this.videoOrAudioClips.filter(
|
|
181
|
+
(clip) => clip.type === "audio"
|
|
182
|
+
);
|
|
183
|
+
const backgroundClips = this.videoOrAudioClips.filter(
|
|
184
|
+
(clip) => clip.type === "music" || clip.type === "backgroundAudio"
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
let filterComplex = "";
|
|
188
|
+
let finalVideoLabel = "";
|
|
189
|
+
let finalAudioLabel = "";
|
|
190
|
+
let hasVideo = false;
|
|
191
|
+
let hasAudio = false;
|
|
192
|
+
|
|
193
|
+
const totalVideoDuration = (() => {
|
|
194
|
+
if (videoClips.length === 0) return 0;
|
|
195
|
+
const baseSum = videoClips.reduce(
|
|
196
|
+
(acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
|
|
197
|
+
0
|
|
130
198
|
);
|
|
199
|
+
const transitionsOverlap = videoClips.reduce((acc, c, idx) => {
|
|
200
|
+
if (idx === 0) return acc;
|
|
201
|
+
const d =
|
|
202
|
+
c.transition && typeof c.transition.duration === "number"
|
|
203
|
+
? c.transition.duration
|
|
204
|
+
: 0;
|
|
205
|
+
return acc + d;
|
|
206
|
+
}, 0);
|
|
207
|
+
return Math.max(0, baseSum - transitionsOverlap);
|
|
208
|
+
})();
|
|
209
|
+
const textEnd =
|
|
210
|
+
this.textClips.length > 0
|
|
211
|
+
? Math.max(...this.textClips.map((c) => c.end || 0))
|
|
212
|
+
: 0;
|
|
213
|
+
const audioEnds = this.videoOrAudioClips
|
|
214
|
+
.filter(
|
|
215
|
+
(c) =>
|
|
216
|
+
c.type === "audio" ||
|
|
217
|
+
c.type === "music" ||
|
|
218
|
+
c.type === "backgroundAudio"
|
|
219
|
+
)
|
|
220
|
+
.map((c) => (typeof c.end === "number" ? c.end : 0));
|
|
221
|
+
const bgOrAudioEnd = audioEnds.length > 0 ? Math.max(...audioEnds) : 0;
|
|
222
|
+
const finalVisualEnd =
|
|
223
|
+
videoClips.length > 0
|
|
224
|
+
? Math.max(...videoClips.map((c) => c.end))
|
|
225
|
+
: Math.max(textEnd, bgOrAudioEnd);
|
|
131
226
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
227
|
+
// Build video filter
|
|
228
|
+
if (videoClips.length > 0) {
|
|
229
|
+
const vres = buildVideoFilter(this, videoClips);
|
|
230
|
+
filterComplex += vres.filter;
|
|
231
|
+
finalVideoLabel = vres.finalVideoLabel;
|
|
232
|
+
hasVideo = vres.hasVideo;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Audio for video clips (aligned amix)
|
|
236
|
+
if (videoClips.length > 0) {
|
|
237
|
+
const ares = buildAudioForVideoClips(this, videoClips);
|
|
238
|
+
filterComplex += ares.filter;
|
|
239
|
+
finalAudioLabel = ares.finalAudioLabel || finalAudioLabel;
|
|
240
|
+
hasAudio = hasAudio || ares.hasAudio;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Standalone audio clips
|
|
244
|
+
if (audioClips.length > 0) {
|
|
245
|
+
let audioString = "";
|
|
246
|
+
let audioConcatInputs = [];
|
|
247
|
+
audioClips.forEach((clip) => {
|
|
248
|
+
const inputIndex = this.videoOrAudioClips.indexOf(clip);
|
|
249
|
+
const { audioStringPart, audioConcatInput } = getClipAudioString(
|
|
250
|
+
clip,
|
|
251
|
+
inputIndex
|
|
143
252
|
);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
253
|
+
audioString += audioStringPart;
|
|
254
|
+
audioConcatInputs.push(audioConcatInput);
|
|
255
|
+
});
|
|
256
|
+
if (audioConcatInputs.length > 0) {
|
|
257
|
+
filterComplex += audioString;
|
|
258
|
+
filterComplex += audioConcatInputs.join("");
|
|
259
|
+
if (hasAudio) {
|
|
260
|
+
filterComplex += `${finalAudioLabel}amix=inputs=${
|
|
261
|
+
audioConcatInputs.length + 1
|
|
262
|
+
}:duration=longest[finalaudio];`;
|
|
263
|
+
finalAudioLabel = "[finalaudio]";
|
|
264
|
+
} else {
|
|
265
|
+
filterComplex += `amix=inputs=${audioConcatInputs.length}:duration=longest[finalaudio];`;
|
|
266
|
+
finalAudioLabel = "[finalaudio]";
|
|
267
|
+
hasAudio = true;
|
|
268
|
+
}
|
|
178
269
|
}
|
|
270
|
+
}
|
|
179
271
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
hasAudio
|
|
186
|
-
|
|
272
|
+
// Background music after other audio
|
|
273
|
+
if (backgroundClips.length > 0) {
|
|
274
|
+
const bgres = buildBackgroundMusicMix(
|
|
275
|
+
this,
|
|
276
|
+
backgroundClips,
|
|
277
|
+
hasAudio ? finalAudioLabel : null,
|
|
278
|
+
finalVisualEnd
|
|
279
|
+
);
|
|
280
|
+
filterComplex += bgres.filter;
|
|
281
|
+
finalAudioLabel = bgres.finalAudioLabel || finalAudioLabel;
|
|
282
|
+
hasAudio = hasAudio || bgres.hasAudio;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (hasAudio && finalAudioLabel) {
|
|
286
|
+
const trimEnd = finalVisualEnd > 0 ? finalVisualEnd : totalVideoDuration;
|
|
287
|
+
filterComplex += `${finalAudioLabel}apad,atrim=end=${trimEnd}[audfit];`;
|
|
288
|
+
finalAudioLabel = "[audfit]";
|
|
289
|
+
}
|
|
187
290
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
291
|
+
// Text overlays
|
|
292
|
+
let needTextPasses = false;
|
|
293
|
+
let textWindows = [];
|
|
294
|
+
if (this.textClips.length > 0 && hasVideo) {
|
|
295
|
+
textWindows = TextRenderer.expandTextWindows(this.textClips);
|
|
296
|
+
const projectDuration = totalVideoDuration;
|
|
297
|
+
textWindows = textWindows
|
|
298
|
+
.filter((w) => typeof w.start === "number" && w.start < projectDuration)
|
|
299
|
+
.map((w) => ({ ...w, end: Math.min(w.end, projectDuration) }));
|
|
300
|
+
needTextPasses = textWindows.length > exportOptions.textMaxNodesPerPass;
|
|
301
|
+
if (!needTextPasses) {
|
|
302
|
+
const { filterString, finalVideoLabel: outLabel } =
|
|
303
|
+
TextRenderer.buildTextFilters(
|
|
304
|
+
this.textClips,
|
|
305
|
+
this.options.width,
|
|
306
|
+
this.options.height,
|
|
307
|
+
finalVideoLabel
|
|
197
308
|
);
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
}
|
|
309
|
+
filterComplex += filterString;
|
|
310
|
+
finalVideoLabel = outLabel;
|
|
215
311
|
}
|
|
312
|
+
}
|
|
216
313
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
225
|
-
filterComplex += bgres.filter;
|
|
226
|
-
finalAudioLabel = bgres.finalAudioLabel || finalAudioLabel;
|
|
227
|
-
hasAudio = hasAudio || bgres.hasAudio;
|
|
314
|
+
// Add output scaling filter if needed
|
|
315
|
+
if (exportOptions.outputWidth || exportOptions.outputHeight) {
|
|
316
|
+
const scaleW = exportOptions.outputWidth || -2;
|
|
317
|
+
const scaleH = exportOptions.outputHeight || -2;
|
|
318
|
+
if (hasVideo && finalVideoLabel) {
|
|
319
|
+
filterComplex += `${finalVideoLabel}scale=${scaleW}:${scaleH}:force_original_aspect_ratio=decrease,pad=${scaleW}:${scaleH}:(ow-iw)/2:(oh-ih)/2[outscaled];`;
|
|
320
|
+
finalVideoLabel = "[outscaled]";
|
|
228
321
|
}
|
|
322
|
+
}
|
|
229
323
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
324
|
+
// Build command
|
|
325
|
+
const command = buildMainCommand({
|
|
326
|
+
inputs: this._getInputStreams(),
|
|
327
|
+
filterComplex,
|
|
328
|
+
mapVideo: finalVideoLabel,
|
|
329
|
+
mapAudio: finalAudioLabel,
|
|
330
|
+
hasVideo,
|
|
331
|
+
hasAudio,
|
|
332
|
+
// Video encoding
|
|
333
|
+
videoCodec: exportOptions.videoCodec,
|
|
334
|
+
videoPreset: exportOptions.videoPreset,
|
|
335
|
+
videoCrf: exportOptions.videoCrf,
|
|
336
|
+
videoBitrate: exportOptions.videoBitrate,
|
|
337
|
+
// Audio encoding
|
|
338
|
+
audioCodec: exportOptions.audioCodec,
|
|
339
|
+
audioBitrate: exportOptions.audioBitrate,
|
|
340
|
+
audioSampleRate: exportOptions.audioSampleRate,
|
|
341
|
+
// Options
|
|
342
|
+
shortest: true,
|
|
343
|
+
faststart: true,
|
|
344
|
+
outputPath: exportOptions.outputPath,
|
|
345
|
+
// New options
|
|
346
|
+
hwaccel: exportOptions.hwaccel,
|
|
347
|
+
audioOnly: exportOptions.audioOnly,
|
|
348
|
+
metadata: exportOptions.metadata,
|
|
349
|
+
twoPass: exportOptions.twoPass,
|
|
350
|
+
});
|
|
236
351
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this.options.width,
|
|
254
|
-
this.options.height,
|
|
255
|
-
finalVideoLabel
|
|
256
|
-
);
|
|
257
|
-
filterComplex += filterString;
|
|
258
|
-
finalVideoLabel = outLabel;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
352
|
+
return {
|
|
353
|
+
command,
|
|
354
|
+
filterComplex,
|
|
355
|
+
exportOptions,
|
|
356
|
+
totalDuration: totalVideoDuration || finalVisualEnd,
|
|
357
|
+
needTextPasses,
|
|
358
|
+
textWindows,
|
|
359
|
+
videoClips,
|
|
360
|
+
audioClips,
|
|
361
|
+
backgroundClips,
|
|
362
|
+
hasVideo,
|
|
363
|
+
hasAudio,
|
|
364
|
+
finalVideoLabel,
|
|
365
|
+
finalAudioLabel,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
261
368
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
shortest: true,
|
|
276
|
-
faststart: true,
|
|
277
|
-
outputPath: exportOptions.outputPath,
|
|
278
|
-
});
|
|
369
|
+
/**
|
|
370
|
+
* Get a preview of the FFmpeg command without executing it (dry-run)
|
|
371
|
+
* @param {Object} options - Same options as export()
|
|
372
|
+
* @returns {Promise<{command: string, filterComplex: string, totalDuration: number}>}
|
|
373
|
+
*/
|
|
374
|
+
async preview(options = {}) {
|
|
375
|
+
const result = await this._prepareExport(options);
|
|
376
|
+
return {
|
|
377
|
+
command: result.command,
|
|
378
|
+
filterComplex: result.filterComplex,
|
|
379
|
+
totalDuration: result.totalDuration,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
279
382
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Export the project to a video file
|
|
385
|
+
* @param {Object} options - Export options
|
|
386
|
+
* @param {string} options.outputPath - Output file path (default: './output.mp4')
|
|
387
|
+
* @param {Function} options.onProgress - Progress callback ({percent, timeProcessed, fps, speed})
|
|
388
|
+
* @param {AbortSignal} options.signal - AbortSignal for cancellation
|
|
389
|
+
* @param {string} options.videoCodec - Video codec (default: 'libx264')
|
|
390
|
+
* @param {number} options.crf - Quality level 0-51 (default: 23)
|
|
391
|
+
* @param {string} options.preset - Encoding preset (default: 'medium')
|
|
392
|
+
* @param {string} options.videoBitrate - Target bitrate (e.g., '5M')
|
|
393
|
+
* @param {string} options.audioCodec - Audio codec (default: 'aac')
|
|
394
|
+
* @param {string} options.audioBitrate - Audio bitrate (default: '192k')
|
|
395
|
+
* @param {number} options.audioSampleRate - Sample rate (default: 48000)
|
|
396
|
+
* @param {string} options.hwaccel - Hardware acceleration ('auto', 'videotoolbox', 'nvenc', 'vaapi', 'qsv', 'none')
|
|
397
|
+
* @param {boolean} options.audioOnly - Export audio only
|
|
398
|
+
* @param {boolean} options.twoPass - Enable two-pass encoding
|
|
399
|
+
* @param {Object} options.metadata - Metadata to embed
|
|
400
|
+
* @param {Object} options.thumbnail - Thumbnail options {outputPath, time, width?, height?}
|
|
401
|
+
* @param {boolean} options.verbose - Enable verbose logging
|
|
402
|
+
* @param {string} options.logLevel - FFmpeg log level
|
|
403
|
+
* @param {string} options.saveCommand - Save FFmpeg command to file
|
|
404
|
+
* @param {number} options.outputWidth - Output width (scales video)
|
|
405
|
+
* @param {number} options.outputHeight - Output height (scales video)
|
|
406
|
+
* @param {string} options.outputResolution - Resolution preset ('720p', '1080p', '4k')
|
|
407
|
+
* @returns {Promise<string>} The output file path
|
|
408
|
+
*/
|
|
409
|
+
async export(options = {}) {
|
|
410
|
+
const t0 = Date.now();
|
|
411
|
+
const { onProgress, signal } = options;
|
|
412
|
+
|
|
413
|
+
const prepared = await this._prepareExport(options);
|
|
414
|
+
const {
|
|
415
|
+
command,
|
|
416
|
+
exportOptions,
|
|
417
|
+
totalDuration,
|
|
418
|
+
needTextPasses,
|
|
419
|
+
textWindows,
|
|
420
|
+
videoClips,
|
|
421
|
+
audioClips,
|
|
422
|
+
backgroundClips,
|
|
423
|
+
hasVideo,
|
|
424
|
+
hasAudio,
|
|
425
|
+
finalVideoLabel,
|
|
426
|
+
finalAudioLabel,
|
|
427
|
+
} = prepared;
|
|
428
|
+
|
|
429
|
+
// Verbose logging
|
|
430
|
+
if (exportOptions.verbose) {
|
|
431
|
+
console.log(
|
|
432
|
+
"simple-ffmpeg: Export options:",
|
|
433
|
+
JSON.stringify(exportOptions, null, 2)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Save command to file if requested
|
|
438
|
+
if (exportOptions.saveCommand) {
|
|
439
|
+
fs.writeFileSync(exportOptions.saveCommand, command, "utf8");
|
|
440
|
+
console.log(
|
|
441
|
+
`simple-ffmpeg: Command saved to ${exportOptions.saveCommand}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
console.log("simple-ffmpeg: Starting export...");
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
// Two-pass encoding
|
|
449
|
+
if (exportOptions.twoPass && exportOptions.videoBitrate && hasVideo) {
|
|
450
|
+
const passLogFile = path.join(
|
|
451
|
+
path.dirname(exportOptions.outputPath),
|
|
452
|
+
`ffmpeg2pass-${Date.now()}`
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// First pass
|
|
456
|
+
if (exportOptions.verbose) {
|
|
457
|
+
console.log("simple-ffmpeg: Running first pass...");
|
|
287
458
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
459
|
+
|
|
460
|
+
const pass1Command = buildMainCommand({
|
|
461
|
+
inputs: this._getInputStreams(),
|
|
462
|
+
filterComplex: prepared.filterComplex,
|
|
463
|
+
mapVideo: finalVideoLabel,
|
|
464
|
+
mapAudio: finalAudioLabel,
|
|
465
|
+
hasVideo,
|
|
466
|
+
hasAudio: false, // No audio in first pass
|
|
467
|
+
videoCodec: exportOptions.videoCodec,
|
|
468
|
+
videoPreset: exportOptions.videoPreset,
|
|
469
|
+
videoCrf: null,
|
|
470
|
+
videoBitrate: exportOptions.videoBitrate,
|
|
471
|
+
audioCodec: exportOptions.audioCodec,
|
|
472
|
+
audioBitrate: exportOptions.audioBitrate,
|
|
473
|
+
shortest: false,
|
|
474
|
+
faststart: false,
|
|
475
|
+
outputPath: exportOptions.outputPath,
|
|
476
|
+
hwaccel: exportOptions.hwaccel,
|
|
477
|
+
twoPass: true,
|
|
478
|
+
passNumber: 1,
|
|
479
|
+
passLogFile,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await runFFmpeg({
|
|
483
|
+
command: pass1Command,
|
|
484
|
+
totalDuration,
|
|
485
|
+
signal,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Second pass
|
|
489
|
+
if (exportOptions.verbose) {
|
|
490
|
+
console.log("simple-ffmpeg: Running second pass...");
|
|
309
491
|
}
|
|
492
|
+
|
|
493
|
+
const pass2Command = buildMainCommand({
|
|
494
|
+
inputs: this._getInputStreams(),
|
|
495
|
+
filterComplex: prepared.filterComplex,
|
|
496
|
+
mapVideo: finalVideoLabel,
|
|
497
|
+
mapAudio: finalAudioLabel,
|
|
498
|
+
hasVideo,
|
|
499
|
+
hasAudio,
|
|
500
|
+
videoCodec: exportOptions.videoCodec,
|
|
501
|
+
videoPreset: exportOptions.videoPreset,
|
|
502
|
+
videoCrf: null,
|
|
503
|
+
videoBitrate: exportOptions.videoBitrate,
|
|
504
|
+
audioCodec: exportOptions.audioCodec,
|
|
505
|
+
audioBitrate: exportOptions.audioBitrate,
|
|
506
|
+
audioSampleRate: exportOptions.audioSampleRate,
|
|
507
|
+
shortest: true,
|
|
508
|
+
faststart: true,
|
|
509
|
+
outputPath: exportOptions.outputPath,
|
|
510
|
+
hwaccel: exportOptions.hwaccel,
|
|
511
|
+
metadata: exportOptions.metadata,
|
|
512
|
+
twoPass: true,
|
|
513
|
+
passNumber: 2,
|
|
514
|
+
passLogFile,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await runFFmpeg({
|
|
518
|
+
command: pass2Command,
|
|
519
|
+
totalDuration,
|
|
520
|
+
onProgress,
|
|
521
|
+
signal,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Clean up pass log files
|
|
310
525
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
526
|
+
fs.unlinkSync(`${passLogFile}-0.log`);
|
|
527
|
+
fs.unlinkSync(`${passLogFile}-0.log.mbtree`);
|
|
528
|
+
} catch (_) {}
|
|
529
|
+
} else {
|
|
530
|
+
// Single-pass encoding
|
|
531
|
+
await runFFmpeg({
|
|
532
|
+
command,
|
|
533
|
+
totalDuration,
|
|
534
|
+
onProgress,
|
|
535
|
+
signal,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Handle multi-pass text overlays if needed
|
|
540
|
+
let passes = 0;
|
|
541
|
+
if (needTextPasses) {
|
|
542
|
+
const {
|
|
543
|
+
finalPath,
|
|
544
|
+
tempOutputs,
|
|
545
|
+
passes: textPasses,
|
|
546
|
+
} = await runTextPasses({
|
|
547
|
+
baseOutputPath: exportOptions.outputPath,
|
|
548
|
+
textWindows,
|
|
549
|
+
canvasWidth: exportOptions.outputWidth || this.options.width,
|
|
550
|
+
canvasHeight: exportOptions.outputHeight || this.options.height,
|
|
551
|
+
intermediateVideoCodec: exportOptions.intermediateVideoCodec,
|
|
552
|
+
intermediatePreset: exportOptions.intermediatePreset,
|
|
553
|
+
intermediateCrf: exportOptions.intermediateCrf,
|
|
554
|
+
batchSize: exportOptions.textMaxNodesPerPass,
|
|
555
|
+
});
|
|
556
|
+
passes = textPasses;
|
|
557
|
+
if (finalPath !== exportOptions.outputPath) {
|
|
558
|
+
fs.renameSync(finalPath, exportOptions.outputPath);
|
|
559
|
+
}
|
|
560
|
+
tempOutputs.slice(0, -1).forEach((f) => {
|
|
335
561
|
try {
|
|
336
|
-
|
|
337
|
-
fileSizeStr = formatBytes(size);
|
|
562
|
+
fs.unlinkSync(f);
|
|
338
563
|
} catch (_) {}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Generate thumbnail if requested
|
|
568
|
+
if (exportOptions.thumbnail && exportOptions.thumbnail.outputPath) {
|
|
569
|
+
const thumbOptions = exportOptions.thumbnail;
|
|
570
|
+
const thumbCommand = buildThumbnailCommand({
|
|
571
|
+
inputPath: exportOptions.outputPath,
|
|
572
|
+
outputPath: thumbOptions.outputPath,
|
|
573
|
+
time: thumbOptions.time || 0,
|
|
574
|
+
width: thumbOptions.width,
|
|
575
|
+
height: thumbOptions.height,
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
if (exportOptions.verbose) {
|
|
579
|
+
console.log("simple-ffmpeg: Generating thumbnail...");
|
|
352
580
|
}
|
|
353
|
-
|
|
354
|
-
|
|
581
|
+
|
|
582
|
+
await runFFmpeg({ command: thumbCommand });
|
|
583
|
+
console.log(`simple-ffmpeg: Thumbnail -> ${thumbOptions.outputPath}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Log completion
|
|
587
|
+
const elapsedMs = Date.now() - t0;
|
|
588
|
+
const visualCount = videoClips.length;
|
|
589
|
+
const audioCount = audioClips.length;
|
|
590
|
+
const musicCount = backgroundClips.length;
|
|
591
|
+
let fileSizeStr = "?";
|
|
592
|
+
try {
|
|
593
|
+
const { size } = fs.statSync(exportOptions.outputPath);
|
|
594
|
+
fileSizeStr = formatBytes(size);
|
|
595
|
+
} catch (_) {}
|
|
596
|
+
console.log(
|
|
597
|
+
`simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`
|
|
598
|
+
);
|
|
599
|
+
console.log(
|
|
600
|
+
`simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
|
|
601
|
+
2
|
|
602
|
+
)}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
this._cleanup();
|
|
606
|
+
return exportOptions.outputPath;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
this._cleanup();
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
355
611
|
}
|
|
356
612
|
}
|
|
357
613
|
|