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.
- package/LICENSE +1 -1
- package/README.md +1096 -278
- package/package.json +48 -22
- package/src/core/constants.js +72 -1
- package/src/core/errors.js +64 -0
- package/src/core/gaps.js +81 -0
- package/src/core/media_info.js +141 -66
- package/src/core/rotation.js +76 -7
- package/src/core/validation.js +757 -140
- package/src/ffmpeg/command_builder.js +181 -10
- package/src/ffmpeg/strings.js +52 -2
- package/src/ffmpeg/subtitle_builder.js +707 -0
- package/src/ffmpeg/text_passes.js +41 -5
- package/src/ffmpeg/text_renderer.js +157 -13
- package/src/ffmpeg/video_builder.js +50 -3
- package/src/ffmpeg/watermark_builder.js +411 -0
- package/src/lib/utils.js +200 -1
- package/src/loaders.js +85 -11
- package/src/simpleffmpeg.js +1071 -273
- package/types/index.d.mts +478 -9
- package/types/index.d.ts +579 -11
package/src/simpleffmpeg.js
CHANGED
|
@@ -1,91 +1,315 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
-
const
|
|
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 {
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
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 "${
|
|
108
|
+
return `-loop 1 -t ${duration} -i "${escapedUrl}"`;
|
|
35
109
|
}
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
)}s (video:${visualCount}, audio:${audioCount}, music:${musicCount}, textPasses:0)`
|
|
616
|
+
const adjustedEnd = this._adjustTimestampForTransitions(
|
|
617
|
+
videoClips,
|
|
618
|
+
subClip.end || 0
|
|
305
619
|
);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|