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