simple-ffmpegjs 0.1.1 → 0.3.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,91 +1,315 @@
1
1
  const fs = require("fs");
2
- const { exec } = require("child_process");
2
+ const fsPromises = require("fs").promises;
3
+ const path = require("path");
3
4
  const TextRenderer = require("./ffmpeg/text_renderer");
4
5
  const { unrotateVideo } = require("./core/rotation");
5
6
  const Loaders = require("./loaders");
6
7
  const { buildVideoFilter } = require("./ffmpeg/video_builder");
7
8
  const { buildAudioForVideoClips } = require("./ffmpeg/audio_builder");
8
9
  const { buildBackgroundMusicMix } = require("./ffmpeg/bgm_builder");
9
- const { getClipAudioString } = require("./ffmpeg/strings");
10
- const { validateClips } = require("./core/validation");
10
+ const {
11
+ getClipAudioString,
12
+ hasProblematicChars,
13
+ escapeFilePath,
14
+ } = require("./ffmpeg/strings");
15
+ const {
16
+ validateConfig,
17
+ formatValidationResult,
18
+ ValidationCodes,
19
+ } = require("./core/validation");
20
+ const {
21
+ SimpleffmpegError,
22
+ ValidationError,
23
+ FFmpegError,
24
+ MediaNotFoundError,
25
+ ExportCancelledError,
26
+ } = require("./core/errors");
11
27
  const C = require("./core/constants");
12
- const { buildMainCommand } = require("./ffmpeg/command_builder");
28
+ const {
29
+ buildMainCommand,
30
+ buildThumbnailCommand,
31
+ } = require("./ffmpeg/command_builder");
13
32
  const { runTextPasses } = require("./ffmpeg/text_passes");
14
- const { formatBytes } = require("./lib/utils");
33
+ const { formatBytes, runFFmpeg } = require("./lib/utils");
34
+ const {
35
+ buildWatermarkFilter,
36
+ validateWatermarkConfig,
37
+ } = require("./ffmpeg/watermark_builder");
38
+ const {
39
+ buildKaraokeASS,
40
+ loadSubtitleFile,
41
+ buildASSFilter,
42
+ } = require("./ffmpeg/subtitle_builder");
15
43
 
16
44
  class SIMPLEFFMPEG {
17
- constructor(options) {
45
+ /**
46
+ * Create a new SIMPLEFFMPEG project
47
+ *
48
+ * @param {Object} options - Project configuration options
49
+ * @param {number} options.width - Output width in pixels (default: 1920)
50
+ * @param {number} options.height - Output height in pixels (default: 1080)
51
+ * @param {number} options.fps - Frames per second (default: 30)
52
+ * @param {string} options.preset - Platform preset ('tiktok', 'youtube', 'instagram-post', etc.)
53
+ * @param {string} options.validationMode - Validation behavior: 'warn' or 'strict' (default: 'warn')
54
+ * @param {string} options.fillGaps - Gap handling: 'none' or 'black' (default: 'none')
55
+ *
56
+ * @example
57
+ * const project = new SIMPLEFFMPEG({ preset: 'tiktok' });
58
+ *
59
+ * @example
60
+ * const project = new SIMPLEFFMPEG({
61
+ * width: 1920,
62
+ * height: 1080,
63
+ * fps: 30,
64
+ * fillGaps: 'black'
65
+ * });
66
+ */
67
+ constructor(options = {}) {
68
+ // Apply platform preset if specified
69
+ let presetConfig = {};
70
+ if (options.preset && C.PLATFORM_PRESETS[options.preset]) {
71
+ presetConfig = C.PLATFORM_PRESETS[options.preset];
72
+ } else if (options.preset) {
73
+ console.warn(
74
+ `Unknown platform preset '${
75
+ options.preset
76
+ }'. Valid presets: ${Object.keys(C.PLATFORM_PRESETS).join(", ")}`
77
+ );
78
+ }
79
+
80
+ // Explicit options override preset values
18
81
  this.options = {
19
- fps: options.fps || C.DEFAULT_FPS,
20
- width: options.width || C.DEFAULT_WIDTH,
21
- height: options.height || C.DEFAULT_HEIGHT,
82
+ fps: options.fps || presetConfig.fps || C.DEFAULT_FPS,
83
+ width: options.width || presetConfig.width || C.DEFAULT_WIDTH,
84
+ height: options.height || presetConfig.height || C.DEFAULT_HEIGHT,
22
85
  validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
86
+ fillGaps: options.fillGaps || "none", // 'none' | 'black'
87
+ preset: options.preset || null,
23
88
  };
24
89
  this.videoOrAudioClips = [];
25
90
  this.textClips = [];
91
+ this.subtitleClips = [];
26
92
  this.filesToClean = [];
93
+ this._isLoading = false; // Guard against concurrent load() calls
94
+ this._isExporting = false; // Guard against concurrent export() calls
27
95
  }
28
96
 
97
+ /**
98
+ * Build FFmpeg input stream arguments for all loaded clips
99
+ * @private
100
+ * @returns {string} FFmpeg input arguments string
101
+ */
29
102
  _getInputStreams() {
30
103
  return this.videoOrAudioClips
31
104
  .map((clip) => {
105
+ const escapedUrl = escapeFilePath(clip.url);
32
106
  if (clip.type === "image") {
33
107
  const duration = Math.max(0, clip.end - clip.position || 0);
34
- return `-loop 1 -t ${duration} -i "${clip.url}"`;
108
+ return `-loop 1 -t ${duration} -i "${escapedUrl}"`;
35
109
  }
36
- return `-i "${clip.url}"`;
110
+ // Loop background music if specified
111
+ if (
112
+ (clip.type === "music" || clip.type === "backgroundAudio") &&
113
+ clip.loop
114
+ ) {
115
+ return `-stream_loop -1 -i "${escapedUrl}"`;
116
+ }
117
+ return `-i "${escapedUrl}"`;
37
118
  })
38
119
  .join(" ");
39
120
  }
40
121
 
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
- }
122
+ /**
123
+ * Clean up temporary files created during export (unrotated videos, temp ASS files, etc.)
124
+ * @private
125
+ * @returns {Promise<void>}
126
+ */
127
+ async _cleanup() {
128
+ const files = [...this.filesToClean];
129
+ this.filesToClean = []; // Clear the list to prevent double cleanup
52
130
 
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
- };
131
+ await Promise.all(
132
+ files.map(async (file) => {
133
+ try {
134
+ await fsPromises.unlink(file);
135
+ console.log("File cleaned up:", file);
136
+ } catch (error) {
137
+ // Ignore ENOENT (file already deleted), log others
138
+ if (error.code !== "ENOENT") {
139
+ console.error("Error cleaning up file:", error);
65
140
  }
66
141
  }
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
142
  })
83
143
  );
84
144
  }
85
145
 
86
- export(options) {
146
+ /**
147
+ * Calculate cumulative transition offset at a given timestamp.
148
+ * Transitions cause timeline compression - this returns how much time
149
+ * has been "lost" to transitions before the given timestamp.
150
+ * @private
151
+ * @param {Array} videoClips - Array of video clips sorted by position
152
+ * @param {number} timestamp - The original timeline timestamp
153
+ * @returns {number} Cumulative transition duration before this timestamp
154
+ */
155
+ _getTransitionOffsetAt(videoClips, timestamp) {
156
+ let cumulativeOffset = 0;
157
+ for (let i = 1; i < videoClips.length; i++) {
158
+ const clip = videoClips[i];
159
+ const transitionPoint = clip.position || 0;
160
+ // Only count transitions that occur at or before this timestamp
161
+ if (transitionPoint <= timestamp && clip.transition) {
162
+ const duration =
163
+ typeof clip.transition.duration === "number"
164
+ ? clip.transition.duration
165
+ : 0;
166
+ cumulativeOffset += duration;
167
+ }
168
+ }
169
+ return cumulativeOffset;
170
+ }
171
+
172
+ /**
173
+ * Adjust a timestamp to account for transition timeline compression.
174
+ * @private
175
+ * @param {Array} videoClips - Array of video clips sorted by position
176
+ * @param {number} timestamp - The original timeline timestamp
177
+ * @returns {number} Adjusted timestamp for the compressed timeline
178
+ */
179
+ _adjustTimestampForTransitions(videoClips, timestamp) {
180
+ return timestamp - this._getTransitionOffsetAt(videoClips, timestamp);
181
+ }
182
+
183
+ /**
184
+ * Load clips into the project for processing
185
+ *
186
+ * @param {Array} clipObjs - Array of clip configuration objects
187
+ * @param {string} clipObjs[].type - Clip type: 'video', 'audio', 'image', 'text', 'music', 'backgroundAudio', 'subtitle'
188
+ * @param {string} clipObjs[].url - Media file path (required for video, audio, image, music, subtitle)
189
+ * @param {number} clipObjs[].position - Start time on timeline in seconds
190
+ * @param {number} clipObjs[].end - End time on timeline in seconds
191
+ * @param {number} clipObjs[].cutFrom - Start time within source media (default: 0)
192
+ * @param {number} clipObjs[].volume - Audio volume multiplier (default: 1)
193
+ * @param {Object|string} clipObjs[].transition - Transition effect for video clips
194
+ * @param {string} clipObjs[].text - Text content (for text clips)
195
+ * @param {string} clipObjs[].mode - Text mode: 'static', 'word-replace', 'word-sequential', 'karaoke'
196
+ * @param {string} clipObjs[].kenBurns - Ken Burns effect for images: 'zoom-in', 'zoom-out', 'pan-left', etc.
197
+ * @returns {Promise<void>} Resolves when all clips are loaded
198
+ * @throws {ValidationError} If clip configuration is invalid
199
+ *
200
+ * @example
201
+ * await project.load([
202
+ * { type: 'video', url: './clip.mp4', position: 0, end: 5 },
203
+ * { type: 'text', text: 'Hello', position: 1, end: 4, fontSize: 48 }
204
+ * ]);
205
+ */
206
+ async load(clipObjs) {
207
+ // Guard against concurrent load() calls
208
+ if (this._isLoading) {
209
+ throw new SimpleffmpegError(
210
+ "Cannot call load() while another load() is in progress. Await the previous load() call first."
211
+ );
212
+ }
213
+
214
+ this._isLoading = true;
215
+
216
+ try {
217
+ const result = validateConfig(clipObjs, {
218
+ fillGaps: this.options.fillGaps,
219
+ width: this.options.width,
220
+ height: this.options.height,
221
+ });
222
+
223
+ if (!result.valid) {
224
+ throw new ValidationError(formatValidationResult(result), {
225
+ errors: result.errors,
226
+ warnings: result.warnings,
227
+ });
228
+ }
229
+
230
+ // Log warnings in warn mode
231
+ if (
232
+ this.options.validationMode === "warn" &&
233
+ result.warnings.length > 0
234
+ ) {
235
+ result.warnings.forEach((w) => console.warn(`${w.path}: ${w.message}`));
236
+ }
237
+
238
+ await Promise.all(
239
+ clipObjs.map((clipObj) => {
240
+ if (clipObj.type === "video" || clipObj.type === "audio") {
241
+ clipObj.volume = clipObj.volume || 1;
242
+ clipObj.cutFrom = clipObj.cutFrom || 0;
243
+ if (clipObj.type === "video" && clipObj.transition) {
244
+ clipObj.transition = {
245
+ type: clipObj.transition.type || clipObj.transition,
246
+ duration: clipObj.transition.duration || 0.5,
247
+ };
248
+ }
249
+ }
250
+ if (clipObj.type === "video") {
251
+ return Loaders.loadVideo(this, clipObj);
252
+ }
253
+ if (clipObj.type === "audio") {
254
+ return Loaders.loadAudio(this, clipObj);
255
+ }
256
+ if (clipObj.type === "text") {
257
+ return Loaders.loadText(this, clipObj);
258
+ }
259
+ if (clipObj.type === "image") {
260
+ return Loaders.loadImage(this, clipObj);
261
+ }
262
+ if (clipObj.type === "music" || clipObj.type === "backgroundAudio") {
263
+ return Loaders.loadBackgroundAudio(this, clipObj);
264
+ }
265
+ if (clipObj.type === "subtitle") {
266
+ return Loaders.loadSubtitle(this, clipObj);
267
+ }
268
+ })
269
+ );
270
+ } finally {
271
+ this._isLoading = false;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Build the export command and metadata (internal helper)
277
+ * @private
278
+ */
279
+ async _prepareExport(options = {}) {
87
280
  const exportOptions = {
281
+ // Output
88
282
  outputPath: options.outputPath || "./output.mp4",
283
+
284
+ // Video encoding
285
+ videoCodec: options.videoCodec || C.VIDEO_CODEC,
286
+ videoCrf: typeof options.crf === "number" ? options.crf : C.VIDEO_CRF,
287
+ videoPreset: options.preset || C.VIDEO_PRESET,
288
+ videoBitrate: options.videoBitrate || C.VIDEO_BITRATE,
289
+
290
+ // Audio encoding
291
+ audioCodec: options.audioCodec || C.AUDIO_CODEC,
292
+ audioBitrate: options.audioBitrate || C.AUDIO_BITRATE,
293
+ audioSampleRate: options.audioSampleRate || C.AUDIO_SAMPLE_RATE,
294
+
295
+ // Features
296
+ hwaccel: options.hwaccel || "none",
297
+ audioOnly: options.audioOnly || false,
298
+ twoPass: options.twoPass || false,
299
+ metadata: options.metadata || null,
300
+ thumbnail: options.thumbnail || null,
301
+
302
+ // Verbose/debug
303
+ verbose: options.verbose || false,
304
+ logLevel: options.logLevel || "warning",
305
+ saveCommand: options.saveCommand || null,
306
+
307
+ // Output resolution (scale on export)
308
+ outputWidth: options.outputWidth || null,
309
+ outputHeight: options.outputHeight || null,
310
+ outputResolution: options.outputResolution || null, // '720p', '1080p', '4k'
311
+
312
+ // Text batching
89
313
  textMaxNodesPerPass:
90
314
  typeof options.textMaxNodesPerPass === "number"
91
315
  ? options.textMaxNodesPerPass
@@ -97,261 +321,835 @@ class SIMPLEFFMPEG {
97
321
  ? options.intermediateCrf
98
322
  : C.INTERMEDIATE_CRF,
99
323
  intermediatePreset: options.intermediatePreset || C.INTERMEDIATE_PRESET,
324
+
325
+ // Watermark
326
+ watermark: options.watermark || null,
327
+
328
+ // Timeline compensation
329
+ compensateTransitions:
330
+ typeof options.compensateTransitions === "boolean"
331
+ ? options.compensateTransitions
332
+ : true, // Default: true
100
333
  };
101
334
 
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
- });
335
+ // Handle resolution presets
336
+ if (exportOptions.outputResolution) {
337
+ const presets = {
338
+ "480p": { width: 854, height: 480 },
339
+ "720p": { width: 1280, height: 720 },
340
+ "1080p": { width: 1920, height: 1080 },
341
+ "1440p": { width: 2560, height: 1440 },
342
+ "4k": { width: 3840, height: 2160 },
343
+ };
344
+ const preset = presets[exportOptions.outputResolution];
345
+ if (preset) {
346
+ exportOptions.outputWidth = preset.width;
347
+ exportOptions.outputHeight = preset.height;
348
+ }
349
+ }
110
350
 
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
- );
351
+ this.videoOrAudioClips.sort((a, b) => {
352
+ if (!a.position) return -1;
353
+ if (!b.position) return 1;
354
+ if (a.position < b.position) return -1;
355
+ return 1;
356
+ });
121
357
 
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"
358
+ // Handle rotation
359
+ await Promise.all(
360
+ this.videoOrAudioClips.map(async (clip) => {
361
+ if (clip.type === "video" && clip.iphoneRotation !== 0) {
362
+ const unrotatedUrl = await unrotateVideo(clip.url);
363
+ this.filesToClean.push(unrotatedUrl);
364
+ clip.url = unrotatedUrl;
365
+ }
366
+ })
367
+ );
368
+
369
+ const videoClips = this.videoOrAudioClips.filter(
370
+ (clip) => clip.type === "video" || clip.type === "image"
371
+ );
372
+ const audioClips = this.videoOrAudioClips.filter(
373
+ (clip) => clip.type === "audio"
374
+ );
375
+ const backgroundClips = this.videoOrAudioClips.filter(
376
+ (clip) => clip.type === "music" || clip.type === "backgroundAudio"
377
+ );
378
+
379
+ let filterComplex = "";
380
+ let finalVideoLabel = "";
381
+ let finalAudioLabel = "";
382
+ let hasVideo = false;
383
+ let hasAudio = false;
384
+
385
+ const totalVideoDuration = (() => {
386
+ if (videoClips.length === 0) return 0;
387
+ const baseSum = videoClips.reduce(
388
+ (acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
389
+ 0
130
390
  );
391
+ const transitionsOverlap = videoClips.reduce((acc, c) => {
392
+ const d =
393
+ c.transition && typeof c.transition.duration === "number"
394
+ ? c.transition.duration
395
+ : 0;
396
+ return acc + d;
397
+ }, 0);
398
+ return Math.max(0, baseSum - transitionsOverlap);
399
+ })();
400
+ const textEnd =
401
+ this.textClips.length > 0
402
+ ? Math.max(...this.textClips.map((c) => c.end || 0))
403
+ : 0;
404
+ const audioEnds = this.videoOrAudioClips
405
+ .filter(
406
+ (c) =>
407
+ c.type === "audio" ||
408
+ c.type === "music" ||
409
+ c.type === "backgroundAudio"
410
+ )
411
+ .map((c) => (typeof c.end === "number" ? c.end : 0));
412
+ const bgOrAudioEnd = audioEnds.length > 0 ? Math.max(...audioEnds) : 0;
413
+ const finalVisualEnd =
414
+ videoClips.length > 0
415
+ ? Math.max(...videoClips.map((c) => c.end))
416
+ : Math.max(textEnd, bgOrAudioEnd);
417
+
418
+ // Build video filter
419
+ if (videoClips.length > 0) {
420
+ const vres = buildVideoFilter(this, videoClips);
421
+ filterComplex += vres.filter;
422
+ finalVideoLabel = vres.finalVideoLabel;
423
+ hasVideo = vres.hasVideo;
424
+ }
131
425
 
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
426
+ // Audio for video clips (aligned amix)
427
+ if (videoClips.length > 0) {
428
+ const ares = buildAudioForVideoClips(this, videoClips);
429
+ filterComplex += ares.filter;
430
+ finalAudioLabel = ares.finalAudioLabel || finalAudioLabel;
431
+ hasAudio = hasAudio || ares.hasAudio;
432
+ }
433
+
434
+ // Standalone audio clips
435
+ if (audioClips.length > 0) {
436
+ let audioString = "";
437
+ let audioConcatInputs = [];
438
+ audioClips.forEach((clip) => {
439
+ const inputIndex = this.videoOrAudioClips.indexOf(clip);
440
+ const { audioStringPart, audioConcatInput } = getClipAudioString(
441
+ clip,
442
+ inputIndex
143
443
  );
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;
444
+ audioString += audioStringPart;
445
+ audioConcatInputs.push(audioConcatInput);
446
+ });
447
+ if (audioConcatInputs.length > 0) {
448
+ filterComplex += audioString;
449
+ filterComplex += audioConcatInputs.join("");
450
+ if (hasAudio) {
451
+ filterComplex += `${finalAudioLabel}amix=inputs=${
452
+ audioConcatInputs.length + 1
453
+ }:duration=longest[finalaudio];`;
454
+ finalAudioLabel = "[finalaudio]";
455
+ } else {
456
+ filterComplex += `amix=inputs=${audioConcatInputs.length}:duration=longest[finalaudio];`;
457
+ finalAudioLabel = "[finalaudio]";
458
+ hasAudio = true;
459
+ }
178
460
  }
461
+ }
179
462
 
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
- }
463
+ // Background music after other audio
464
+ if (backgroundClips.length > 0) {
465
+ const bgres = buildBackgroundMusicMix(
466
+ this,
467
+ backgroundClips,
468
+ hasAudio ? finalAudioLabel : null,
469
+ finalVisualEnd
470
+ );
471
+ filterComplex += bgres.filter;
472
+ finalAudioLabel = bgres.finalAudioLabel || finalAudioLabel;
473
+ hasAudio = hasAudio || bgres.hasAudio;
474
+ }
187
475
 
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
476
+ if (hasAudio && finalAudioLabel) {
477
+ const trimEnd = finalVisualEnd > 0 ? finalVisualEnd : totalVideoDuration;
478
+ filterComplex += `${finalAudioLabel}apad,atrim=end=${trimEnd}[audfit];`;
479
+ finalAudioLabel = "[audfit]";
480
+ }
481
+
482
+ // Text overlays (drawtext-based)
483
+ let needTextPasses = false;
484
+ let textWindows = [];
485
+ if (this.textClips.length > 0 && hasVideo) {
486
+ // Compensate text timings for transition overlap if enabled
487
+ let adjustedTextClips = this.textClips;
488
+ if (exportOptions.compensateTransitions && videoClips.length > 1) {
489
+ adjustedTextClips = this.textClips.map((clip) => {
490
+ const adjustedPosition = this._adjustTimestampForTransitions(
491
+ videoClips,
492
+ clip.position || 0
493
+ );
494
+ const adjustedEnd = this._adjustTimestampForTransitions(
495
+ videoClips,
496
+ clip.end || 0
197
497
  );
198
- audioString += audioStringPart;
199
- audioConcatInputs.push(audioConcatInput);
498
+ // Also adjust word timings if present
499
+ let adjustedWords = clip.words;
500
+ if (Array.isArray(clip.words)) {
501
+ adjustedWords = clip.words.map((word) => ({
502
+ ...word,
503
+ start: this._adjustTimestampForTransitions(
504
+ videoClips,
505
+ word.start || 0
506
+ ),
507
+ end: this._adjustTimestampForTransitions(
508
+ videoClips,
509
+ word.end || 0
510
+ ),
511
+ }));
512
+ }
513
+ // Also adjust wordTimestamps if present
514
+ let adjustedWordTimestamps = clip.wordTimestamps;
515
+ if (Array.isArray(clip.wordTimestamps)) {
516
+ adjustedWordTimestamps = clip.wordTimestamps.map((ts) =>
517
+ this._adjustTimestampForTransitions(videoClips, ts)
518
+ );
519
+ }
520
+ return {
521
+ ...clip,
522
+ position: adjustedPosition,
523
+ end: adjustedEnd,
524
+ words: adjustedWords,
525
+ wordTimestamps: adjustedWordTimestamps,
526
+ };
200
527
  });
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;
528
+ }
529
+
530
+ // For text with problematic characters, use temp files (textfile approach)
531
+ adjustedTextClips = adjustedTextClips.map((clip, idx) => {
532
+ const textContent = clip.text || "";
533
+ if (hasProblematicChars(textContent)) {
534
+ const tempPath = path.join(
535
+ path.dirname(exportOptions.outputPath),
536
+ `.simpleffmpeg_text_${idx}_${Date.now()}.txt`
537
+ );
538
+ // Replace newlines with space for non-karaoke (consistent with escapeDrawtextText)
539
+ const normalizedText = textContent.replace(/\r?\n/g, " ");
540
+ try {
541
+ fs.writeFileSync(tempPath, normalizedText, "utf-8");
542
+ } catch (writeError) {
543
+ throw new SimpleffmpegError(
544
+ `Failed to write temporary text file "${tempPath}": ${writeError.message}`,
545
+ { cause: writeError }
546
+ );
213
547
  }
548
+ this.filesToClean.push(tempPath);
549
+ return { ...clip, _textFilePath: tempPath };
214
550
  }
215
- }
551
+ return clip;
552
+ });
216
553
 
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
- }
554
+ textWindows = TextRenderer.expandTextWindows(adjustedTextClips);
555
+ const projectDuration = totalVideoDuration;
556
+ textWindows = textWindows
557
+ .filter((w) => typeof w.start === "number" && w.start < projectDuration)
558
+ .map((w) => ({ ...w, end: Math.min(w.end, projectDuration) }));
229
559
 
230
- if (hasAudio && finalAudioLabel) {
231
- const trimEnd =
232
- finalVisualEnd > 0 ? finalVisualEnd : totalVideoDuration;
233
- filterComplex += `${finalAudioLabel}apad,atrim=end=${trimEnd}[audfit];`;
234
- finalAudioLabel = "[audfit]";
235
- }
560
+ // Check if we need batching based on node count
561
+ needTextPasses = textWindows.length > exportOptions.textMaxNodesPerPass;
236
562
 
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
563
+ if (!needTextPasses) {
564
+ // Build the filter and check if it's too long
565
+ const { filterString, finalVideoLabel: outLabel } =
566
+ TextRenderer.buildTextFilters(
567
+ adjustedTextClips,
568
+ this.options.width,
569
+ this.options.height,
570
+ finalVideoLabel
571
+ );
572
+
573
+ // Auto-batch if filter_complex would exceed safe command length limit
574
+ const potentialLength = filterComplex.length + filterString.length;
575
+ if (potentialLength > C.MAX_FILTER_COMPLEX_LENGTH) {
576
+ // Calculate optimal batch size based on filter length
577
+ const avgNodeLength = filterString.length / textWindows.length;
578
+ const safeNodes = Math.floor(
579
+ (C.MAX_FILTER_COMPLEX_LENGTH - filterComplex.length) / avgNodeLength
580
+ );
581
+ exportOptions.textMaxNodesPerPass = Math.max(
582
+ 10,
583
+ Math.min(safeNodes, 50)
584
+ );
585
+ needTextPasses = true;
586
+
587
+ if (exportOptions.verbose) {
588
+ console.log(
589
+ `simple-ffmpeg: Auto-batching text (filter too long: ${potentialLength} > ${C.MAX_FILTER_COMPLEX_LENGTH}). ` +
590
+ `Using ${exportOptions.textMaxNodesPerPass} nodes per pass.`
256
591
  );
592
+ }
593
+ } else {
257
594
  filterComplex += filterString;
258
595
  finalVideoLabel = outLabel;
259
596
  }
260
597
  }
598
+ }
261
599
 
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
- });
600
+ // Subtitle overlays (ASS-based: karaoke mode and imported subtitles)
601
+ let assFilesToClean = [];
602
+ if (this.subtitleClips.length > 0 && hasVideo) {
603
+ for (let i = 0; i < this.subtitleClips.length; i++) {
604
+ let subClip = this.subtitleClips[i];
279
605
 
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})`
606
+ // Compensate subtitle timings for transition overlap if enabled
607
+ if (
608
+ exportOptions.compensateTransitions &&
609
+ videoClips.length > 1 &&
610
+ subClip.mode === "karaoke"
611
+ ) {
612
+ const adjustedPosition = this._adjustTimestampForTransitions(
613
+ videoClips,
614
+ subClip.position || 0
300
615
  );
301
- console.log(
302
- `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
303
- 2
304
- )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:0)`
616
+ const adjustedEnd = this._adjustTimestampForTransitions(
617
+ videoClips,
618
+ subClip.end || 0
305
619
  );
306
- resolve(exportOptions.outputPath);
307
- this._cleanup();
308
- return;
620
+ let adjustedWords = subClip.words;
621
+ if (Array.isArray(subClip.words)) {
622
+ adjustedWords = subClip.words.map((word) => ({
623
+ ...word,
624
+ start: this._adjustTimestampForTransitions(
625
+ videoClips,
626
+ word.start || 0
627
+ ),
628
+ end: this._adjustTimestampForTransitions(
629
+ videoClips,
630
+ word.end || 0
631
+ ),
632
+ }));
633
+ }
634
+ let adjustedWordTimestamps = subClip.wordTimestamps;
635
+ if (Array.isArray(subClip.wordTimestamps)) {
636
+ adjustedWordTimestamps = subClip.wordTimestamps.map((ts) =>
637
+ this._adjustTimestampForTransitions(videoClips, ts)
638
+ );
639
+ }
640
+ subClip = {
641
+ ...subClip,
642
+ position: adjustedPosition,
643
+ end: adjustedEnd,
644
+ words: adjustedWords,
645
+ wordTimestamps: adjustedWordTimestamps,
646
+ };
309
647
  }
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);
648
+
649
+ let assContent = "";
650
+ let assFilePath = "";
651
+
652
+ if (subClip.type === "subtitle") {
653
+ // Imported subtitle file
654
+ const ext = path.extname(subClip.url).toLowerCase();
655
+ if (ext === ".ass" || ext === ".ssa") {
656
+ // Use ASS file directly
657
+ assFilePath = subClip.url;
658
+ } else {
659
+ // Convert SRT/VTT to ASS
660
+ assContent = loadSubtitleFile(
661
+ subClip.url,
662
+ subClip,
663
+ this.options.width,
664
+ this.options.height
665
+ );
324
666
  }
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})`
667
+ } else if (subClip.mode === "karaoke") {
668
+ // Generate karaoke ASS
669
+ assContent = buildKaraokeASS(
670
+ subClip,
671
+ this.options.width,
672
+ this.options.height
341
673
  );
342
- console.log(
343
- `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
344
- 2
345
- )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`
674
+ }
675
+
676
+ // Write temp ASS file if we generated content
677
+ if (assContent && !assFilePath) {
678
+ assFilePath = path.join(
679
+ path.dirname(exportOptions.outputPath),
680
+ `.simpleffmpeg_sub_${i}_${Date.now()}.ass`
346
681
  );
347
- resolve(exportOptions.outputPath);
348
- this._cleanup();
349
- } catch (batchErr) {
350
- reject(batchErr);
351
- this._cleanup();
682
+ try {
683
+ fs.writeFileSync(assFilePath, assContent, "utf-8");
684
+ } catch (writeError) {
685
+ throw new SimpleffmpegError(
686
+ `Failed to write temporary ASS file "${assFilePath}": ${writeError.message}`,
687
+ { cause: writeError }
688
+ );
689
+ }
690
+ assFilesToClean.push(assFilePath);
691
+ this.filesToClean.push(assFilePath);
352
692
  }
353
- });
693
+
694
+ // Apply ASS filter
695
+ if (assFilePath) {
696
+ const assResult = buildASSFilter(assFilePath, finalVideoLabel);
697
+ // Need to rename output label to avoid conflicts
698
+ const uniqueLabel = `[outsub${i}]`;
699
+ const filter = assResult.filter.replace(
700
+ assResult.finalLabel,
701
+ uniqueLabel
702
+ );
703
+ filterComplex += filter + ";";
704
+ finalVideoLabel = uniqueLabel;
705
+ }
706
+ }
707
+ }
708
+
709
+ // Watermark overlay
710
+ let watermarkInputIndex = null;
711
+ let watermarkInputString = "";
712
+ if (exportOptions.watermark && hasVideo) {
713
+ // Validate watermark config
714
+ const wmValidation = validateWatermarkConfig(exportOptions.watermark);
715
+ if (!wmValidation.valid) {
716
+ throw new Error(
717
+ `Watermark validation failed: ${wmValidation.errors.join(", ")}`
718
+ );
719
+ }
720
+
721
+ const wmConfig = exportOptions.watermark;
722
+
723
+ // For image watermarks, we need to add an input
724
+ if (wmConfig.type === "image" && wmConfig.url) {
725
+ watermarkInputIndex = this.videoOrAudioClips.length;
726
+ watermarkInputString = ` -i "${escapeFilePath(wmConfig.url)}"`;
727
+ }
728
+
729
+ const wmResult = buildWatermarkFilter(
730
+ wmConfig,
731
+ finalVideoLabel,
732
+ watermarkInputIndex,
733
+ this.options.width,
734
+ this.options.height,
735
+ totalVideoDuration
736
+ );
737
+
738
+ if (wmResult.filter) {
739
+ filterComplex += wmResult.filter + ";";
740
+ finalVideoLabel = wmResult.finalLabel;
741
+ }
742
+ }
743
+
744
+ // Add output scaling filter if needed
745
+ if (exportOptions.outputWidth || exportOptions.outputHeight) {
746
+ const scaleW = exportOptions.outputWidth || -2;
747
+ const scaleH = exportOptions.outputHeight || -2;
748
+ if (hasVideo && finalVideoLabel) {
749
+ filterComplex += `${finalVideoLabel}scale=${scaleW}:${scaleH}:force_original_aspect_ratio=decrease,pad=${scaleW}:${scaleH}:(ow-iw)/2:(oh-ih)/2[outscaled];`;
750
+ finalVideoLabel = "[outscaled]";
751
+ }
752
+ }
753
+
754
+ // Build command
755
+ const command = buildMainCommand({
756
+ inputs: this._getInputStreams() + watermarkInputString,
757
+ filterComplex,
758
+ mapVideo: finalVideoLabel,
759
+ mapAudio: finalAudioLabel,
760
+ hasVideo,
761
+ hasAudio,
762
+ // Video encoding
763
+ videoCodec: exportOptions.videoCodec,
764
+ videoPreset: exportOptions.videoPreset,
765
+ videoCrf: exportOptions.videoCrf,
766
+ videoBitrate: exportOptions.videoBitrate,
767
+ // Audio encoding
768
+ audioCodec: exportOptions.audioCodec,
769
+ audioBitrate: exportOptions.audioBitrate,
770
+ audioSampleRate: exportOptions.audioSampleRate,
771
+ // Options
772
+ shortest: true,
773
+ faststart: true,
774
+ outputPath: exportOptions.outputPath,
775
+ // New options
776
+ hwaccel: exportOptions.hwaccel,
777
+ audioOnly: exportOptions.audioOnly,
778
+ metadata: exportOptions.metadata,
779
+ twoPass: exportOptions.twoPass,
354
780
  });
781
+
782
+ return {
783
+ command,
784
+ filterComplex,
785
+ exportOptions,
786
+ totalDuration: totalVideoDuration || finalVisualEnd,
787
+ needTextPasses,
788
+ textWindows,
789
+ videoClips,
790
+ audioClips,
791
+ backgroundClips,
792
+ hasVideo,
793
+ hasAudio,
794
+ finalVideoLabel,
795
+ finalAudioLabel,
796
+ };
797
+ }
798
+
799
+ /**
800
+ * Get a preview of the FFmpeg command without executing it (dry-run)
801
+ * @param {Object} options - Same options as export()
802
+ * @returns {Promise<{command: string, filterComplex: string, totalDuration: number}>}
803
+ */
804
+ async preview(options = {}) {
805
+ const result = await this._prepareExport(options);
806
+ return {
807
+ command: result.command,
808
+ filterComplex: result.filterComplex,
809
+ totalDuration: result.totalDuration,
810
+ };
811
+ }
812
+
813
+ /**
814
+ * Export the project to a video file
815
+ * @param {Object} options - Export options
816
+ * @param {string} options.outputPath - Output file path (default: './output.mp4')
817
+ * @param {Function} options.onProgress - Progress callback ({percent, timeProcessed, fps, speed})
818
+ * @param {AbortSignal} options.signal - AbortSignal for cancellation
819
+ * @param {string} options.videoCodec - Video codec (default: 'libx264')
820
+ * @param {number} options.crf - Quality level 0-51 (default: 23)
821
+ * @param {string} options.preset - Encoding preset (default: 'medium')
822
+ * @param {string} options.videoBitrate - Target bitrate (e.g., '5M')
823
+ * @param {string} options.audioCodec - Audio codec (default: 'aac')
824
+ * @param {string} options.audioBitrate - Audio bitrate (default: '192k')
825
+ * @param {number} options.audioSampleRate - Sample rate (default: 48000)
826
+ * @param {string} options.hwaccel - Hardware acceleration ('auto', 'videotoolbox', 'nvenc', 'vaapi', 'qsv', 'none')
827
+ * @param {boolean} options.audioOnly - Export audio only
828
+ * @param {boolean} options.twoPass - Enable two-pass encoding
829
+ * @param {Object} options.metadata - Metadata to embed
830
+ * @param {Object} options.thumbnail - Thumbnail options {outputPath, time, width?, height?}
831
+ * @param {boolean} options.verbose - Enable verbose logging
832
+ * @param {string} options.logLevel - FFmpeg log level
833
+ * @param {string} options.saveCommand - Save FFmpeg command to file
834
+ * @param {number} options.outputWidth - Output width (scales video)
835
+ * @param {number} options.outputHeight - Output height (scales video)
836
+ * @param {string} options.outputResolution - Resolution preset ('720p', '1080p', '4k')
837
+ * @returns {Promise<string>} The output file path
838
+ */
839
+ async export(options = {}) {
840
+ // Guard against concurrent export() calls
841
+ if (this._isExporting) {
842
+ throw new SimpleffmpegError(
843
+ "Cannot call export() while another export() is in progress. Await the previous export() call first."
844
+ );
845
+ }
846
+
847
+ this._isExporting = true;
848
+ const t0 = Date.now();
849
+ const { onProgress, signal } = options;
850
+
851
+ let prepared;
852
+ try {
853
+ prepared = await this._prepareExport(options);
854
+ } catch (error) {
855
+ this._isExporting = false;
856
+ throw error;
857
+ }
858
+ const {
859
+ command,
860
+ exportOptions,
861
+ totalDuration,
862
+ needTextPasses,
863
+ textWindows,
864
+ videoClips,
865
+ audioClips,
866
+ backgroundClips,
867
+ hasVideo,
868
+ hasAudio,
869
+ finalVideoLabel,
870
+ finalAudioLabel,
871
+ } = prepared;
872
+
873
+ // Verbose logging
874
+ if (exportOptions.verbose) {
875
+ console.log(
876
+ "simple-ffmpeg: Export options:",
877
+ JSON.stringify(exportOptions, null, 2)
878
+ );
879
+ }
880
+
881
+ // Save command to file if requested
882
+ if (exportOptions.saveCommand) {
883
+ try {
884
+ fs.writeFileSync(exportOptions.saveCommand, command, "utf8");
885
+ console.log(
886
+ `simple-ffmpeg: Command saved to ${exportOptions.saveCommand}`
887
+ );
888
+ } catch (writeError) {
889
+ throw new SimpleffmpegError(
890
+ `Failed to save command to "${exportOptions.saveCommand}": ${writeError.message}`,
891
+ { cause: writeError }
892
+ );
893
+ }
894
+ }
895
+
896
+ console.log("simple-ffmpeg: Starting export...");
897
+
898
+ try {
899
+ // Two-pass encoding
900
+ if (exportOptions.twoPass && exportOptions.videoBitrate && hasVideo) {
901
+ const passLogFile = path.join(
902
+ path.dirname(exportOptions.outputPath),
903
+ `ffmpeg2pass-${Date.now()}`
904
+ );
905
+
906
+ // First pass
907
+ if (exportOptions.verbose) {
908
+ console.log("simple-ffmpeg: Running first pass...");
909
+ }
910
+
911
+ const pass1Command = buildMainCommand({
912
+ inputs: this._getInputStreams(),
913
+ filterComplex: prepared.filterComplex,
914
+ mapVideo: finalVideoLabel,
915
+ mapAudio: finalAudioLabel,
916
+ hasVideo,
917
+ hasAudio: false, // No audio in first pass
918
+ videoCodec: exportOptions.videoCodec,
919
+ videoPreset: exportOptions.videoPreset,
920
+ videoCrf: null,
921
+ videoBitrate: exportOptions.videoBitrate,
922
+ audioCodec: exportOptions.audioCodec,
923
+ audioBitrate: exportOptions.audioBitrate,
924
+ shortest: false,
925
+ faststart: false,
926
+ outputPath: exportOptions.outputPath,
927
+ hwaccel: exportOptions.hwaccel,
928
+ twoPass: true,
929
+ passNumber: 1,
930
+ passLogFile,
931
+ });
932
+
933
+ await runFFmpeg({
934
+ command: pass1Command,
935
+ totalDuration,
936
+ signal,
937
+ });
938
+
939
+ // Second pass
940
+ if (exportOptions.verbose) {
941
+ console.log("simple-ffmpeg: Running second pass...");
942
+ }
943
+
944
+ const pass2Command = buildMainCommand({
945
+ inputs: this._getInputStreams(),
946
+ filterComplex: prepared.filterComplex,
947
+ mapVideo: finalVideoLabel,
948
+ mapAudio: finalAudioLabel,
949
+ hasVideo,
950
+ hasAudio,
951
+ videoCodec: exportOptions.videoCodec,
952
+ videoPreset: exportOptions.videoPreset,
953
+ videoCrf: null,
954
+ videoBitrate: exportOptions.videoBitrate,
955
+ audioCodec: exportOptions.audioCodec,
956
+ audioBitrate: exportOptions.audioBitrate,
957
+ audioSampleRate: exportOptions.audioSampleRate,
958
+ shortest: true,
959
+ faststart: true,
960
+ outputPath: exportOptions.outputPath,
961
+ hwaccel: exportOptions.hwaccel,
962
+ metadata: exportOptions.metadata,
963
+ twoPass: true,
964
+ passNumber: 2,
965
+ passLogFile,
966
+ });
967
+
968
+ await runFFmpeg({
969
+ command: pass2Command,
970
+ totalDuration,
971
+ onProgress,
972
+ signal,
973
+ });
974
+
975
+ // Clean up pass log files
976
+ try {
977
+ fs.unlinkSync(`${passLogFile}-0.log`);
978
+ fs.unlinkSync(`${passLogFile}-0.log.mbtree`);
979
+ } catch (_) {}
980
+ } else {
981
+ // Single-pass encoding
982
+ await runFFmpeg({
983
+ command,
984
+ totalDuration,
985
+ onProgress,
986
+ signal,
987
+ });
988
+ }
989
+
990
+ // Handle multi-pass text overlays if needed
991
+ let passes = 0;
992
+ if (needTextPasses) {
993
+ const {
994
+ finalPath,
995
+ tempOutputs,
996
+ passes: textPasses,
997
+ } = await runTextPasses({
998
+ baseOutputPath: exportOptions.outputPath,
999
+ textWindows,
1000
+ canvasWidth: exportOptions.outputWidth || this.options.width,
1001
+ canvasHeight: exportOptions.outputHeight || this.options.height,
1002
+ intermediateVideoCodec: exportOptions.intermediateVideoCodec,
1003
+ intermediatePreset: exportOptions.intermediatePreset,
1004
+ intermediateCrf: exportOptions.intermediateCrf,
1005
+ batchSize: exportOptions.textMaxNodesPerPass,
1006
+ });
1007
+ passes = textPasses;
1008
+ if (finalPath !== exportOptions.outputPath) {
1009
+ fs.renameSync(finalPath, exportOptions.outputPath);
1010
+ }
1011
+ tempOutputs.slice(0, -1).forEach((f) => {
1012
+ try {
1013
+ fs.unlinkSync(f);
1014
+ } catch (_) {}
1015
+ });
1016
+ }
1017
+
1018
+ // Generate thumbnail if requested
1019
+ if (exportOptions.thumbnail && exportOptions.thumbnail.outputPath) {
1020
+ const thumbOptions = exportOptions.thumbnail;
1021
+ const thumbCommand = buildThumbnailCommand({
1022
+ inputPath: exportOptions.outputPath,
1023
+ outputPath: thumbOptions.outputPath,
1024
+ time: thumbOptions.time || 0,
1025
+ width: thumbOptions.width,
1026
+ height: thumbOptions.height,
1027
+ });
1028
+
1029
+ if (exportOptions.verbose) {
1030
+ console.log("simple-ffmpeg: Generating thumbnail...");
1031
+ }
1032
+
1033
+ await runFFmpeg({ command: thumbCommand });
1034
+ console.log(`simple-ffmpeg: Thumbnail -> ${thumbOptions.outputPath}`);
1035
+ }
1036
+
1037
+ // Log completion
1038
+ const elapsedMs = Date.now() - t0;
1039
+ const visualCount = videoClips.length;
1040
+ const audioCount = audioClips.length;
1041
+ const musicCount = backgroundClips.length;
1042
+ let fileSizeStr = "?";
1043
+ try {
1044
+ const { size } = fs.statSync(exportOptions.outputPath);
1045
+ fileSizeStr = formatBytes(size);
1046
+ } catch (_) {}
1047
+ console.log(
1048
+ `simple-ffmpeg: Output -> ${exportOptions.outputPath} (${fileSizeStr})`
1049
+ );
1050
+ console.log(
1051
+ `simple-ffmpeg: Export finished in ${(elapsedMs / 1000).toFixed(
1052
+ 2
1053
+ )}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:${passes})`
1054
+ );
1055
+
1056
+ await this._cleanup();
1057
+ this._isExporting = false;
1058
+ return exportOptions.outputPath;
1059
+ } catch (error) {
1060
+ await this._cleanup();
1061
+ this._isExporting = false;
1062
+ throw error;
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Get available platform presets
1068
+ * @returns {Object} Map of preset names to their configurations
1069
+ */
1070
+ static getPresets() {
1071
+ // Deep copy to prevent mutation of original
1072
+ return JSON.parse(JSON.stringify(C.PLATFORM_PRESETS));
1073
+ }
1074
+
1075
+ /**
1076
+ * Get list of available preset names
1077
+ * @returns {string[]} Array of preset names
1078
+ */
1079
+ static getPresetNames() {
1080
+ return Object.keys(C.PLATFORM_PRESETS);
1081
+ }
1082
+
1083
+ /**
1084
+ * Validate clips configuration without creating a project
1085
+ * Useful for AI feedback loops and pre-validation before processing
1086
+ *
1087
+ * @param {Array} clips - Array of clip objects to validate
1088
+ * @param {Object} options - Validation options
1089
+ * @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI)
1090
+ * @param {string} options.fillGaps - Gap handling ('none' | 'black') - affects gap validation
1091
+ * @returns {Object} Validation result { valid, errors, warnings }
1092
+ *
1093
+ * @example
1094
+ * const result = SIMPLEFFMPEG.validate(clips, { skipFileChecks: true });
1095
+ * if (!result.valid) {
1096
+ * console.log('Errors:', result.errors);
1097
+ * // Each error has: { code, path, message, received? }
1098
+ * }
1099
+ */
1100
+ static validate(clips, options = {}) {
1101
+ return validateConfig(clips, options);
1102
+ }
1103
+
1104
+ /**
1105
+ * Format validation result as human-readable string
1106
+ * @param {Object} result - Validation result from validate()
1107
+ * @returns {string} Formatted validation result
1108
+ */
1109
+ static formatValidationResult(result) {
1110
+ return formatValidationResult(result);
1111
+ }
1112
+
1113
+ /**
1114
+ * Validation error codes for programmatic handling
1115
+ */
1116
+ static get ValidationCodes() {
1117
+ return ValidationCodes;
1118
+ }
1119
+
1120
+ /**
1121
+ * Base error class for all simple-ffmpeg errors
1122
+ */
1123
+ static get SimpleffmpegError() {
1124
+ return SimpleffmpegError;
1125
+ }
1126
+
1127
+ /**
1128
+ * Thrown when clip validation fails
1129
+ */
1130
+ static get ValidationError() {
1131
+ return ValidationError;
1132
+ }
1133
+
1134
+ /**
1135
+ * Thrown when FFmpeg command execution fails
1136
+ */
1137
+ static get FFmpegError() {
1138
+ return FFmpegError;
1139
+ }
1140
+
1141
+ /**
1142
+ * Thrown when a media file cannot be found or accessed
1143
+ */
1144
+ static get MediaNotFoundError() {
1145
+ return MediaNotFoundError;
1146
+ }
1147
+
1148
+ /**
1149
+ * Thrown when export is cancelled via AbortSignal
1150
+ */
1151
+ static get ExportCancelledError() {
1152
+ return ExportCancelledError;
355
1153
  }
356
1154
  }
357
1155