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.
@@ -1,5 +1,5 @@
1
1
  const fs = require("fs");
2
- const { exec } = require("child_process");
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 { buildMainCommand } = require("./ffmpeg/command_builder");
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
- export(options) {
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
- 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
- });
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
- // 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
- );
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
- 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"
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
- 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
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
- 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;
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
- // 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
- }
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
- // 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
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
- 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
- }
309
+ filterComplex += filterString;
310
+ finalVideoLabel = outLabel;
215
311
  }
312
+ }
216
313
 
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;
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
- if (hasAudio && finalAudioLabel) {
231
- const trimEnd =
232
- finalVisualEnd > 0 ? finalVisualEnd : totalVideoDuration;
233
- filterComplex += `${finalAudioLabel}apad,atrim=end=${trimEnd}[audfit];`;
234
- finalAudioLabel = "[audfit]";
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
- // 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
- }
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
- // 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
- });
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
- 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;
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
- 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;
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
- // 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 = "?";
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
- const { size } = fs.statSync(exportOptions.outputPath);
337
- fileSizeStr = formatBytes(size);
562
+ fs.unlinkSync(f);
338
563
  } 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();
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