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,363 @@
|
|
|
1
|
+
const Strings = require("./strings");
|
|
2
|
+
const C = require("../core/constants");
|
|
3
|
+
|
|
4
|
+
function buildXParam(baseClip, canvasWidth) {
|
|
5
|
+
if (typeof baseClip.centerX === "number") {
|
|
6
|
+
return `:x=(${canvasWidth} - text_w)/2 + ${baseClip.centerX}`;
|
|
7
|
+
} else if (typeof baseClip.x === "number") {
|
|
8
|
+
return `:x=${baseClip.x}`;
|
|
9
|
+
}
|
|
10
|
+
return `:x=(${canvasWidth} - text_w)/2`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function baseYExpression(baseClip, canvasHeight) {
|
|
14
|
+
if (typeof baseClip.centerY === "number") {
|
|
15
|
+
return `(${canvasHeight} - text_h)/2 + ${baseClip.centerY}`;
|
|
16
|
+
} else if (typeof baseClip.y === "number") {
|
|
17
|
+
return `${baseClip.y}`;
|
|
18
|
+
}
|
|
19
|
+
return `(${canvasHeight} - text_h)/2`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildYParamAnimated(baseClip, canvasHeight, start, end) {
|
|
23
|
+
const baseY = baseYExpression(baseClip, canvasHeight);
|
|
24
|
+
return `:y=${baseY}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildAlphaParam(baseClip, start, end) {
|
|
28
|
+
const anim = baseClip.animation || {};
|
|
29
|
+
const type = anim.type || "none";
|
|
30
|
+
if (type === "fade-in") {
|
|
31
|
+
const entry =
|
|
32
|
+
typeof anim.in === "number" ? anim.in : C.DEFAULT_TEXT_ANIM_IN;
|
|
33
|
+
return `:alpha=if(lt(t\\,${start})\\,0\\,if(lt(t\\,${
|
|
34
|
+
start + entry
|
|
35
|
+
})\\,(t-${start})/${entry}\\,1))`;
|
|
36
|
+
}
|
|
37
|
+
if (type === "fade-in-out" || type === "fade") {
|
|
38
|
+
const entry =
|
|
39
|
+
typeof anim.in === "number" ? anim.in : C.DEFAULT_TEXT_ANIM_IN;
|
|
40
|
+
const exit = typeof anim.out === "number" ? anim.out : entry;
|
|
41
|
+
const fadeOutStart = Math.max(start, end - exit);
|
|
42
|
+
return `:alpha=if(lt(t\\,${start})\\,0\\,if(lt(t\\,${
|
|
43
|
+
start + entry
|
|
44
|
+
})\\,(t-${start})/${entry}\\,if(lt(t\\,${fadeOutStart})\\,1\\,if(lt(t\\,${end})\\,((${end}-t)/${exit})\\,0))))`;
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildFontsizeParam(baseClip, start) {
|
|
50
|
+
const anim = baseClip.animation || {};
|
|
51
|
+
const type = anim.type || "none";
|
|
52
|
+
const baseSize = baseClip.fontSize;
|
|
53
|
+
if (type === "pop") {
|
|
54
|
+
const entry =
|
|
55
|
+
typeof anim.in === "number" ? anim.in : C.DEFAULT_TEXT_ANIM_IN;
|
|
56
|
+
return `:fontsize=if(lt(t\\,${start + entry})\\,${(baseSize * 0.7).toFixed(
|
|
57
|
+
3
|
|
58
|
+
)}+${(baseSize * 0.3).toFixed(
|
|
59
|
+
3
|
|
60
|
+
)}*sin(PI/2*(t-${start})/${entry})\\,${baseSize})`;
|
|
61
|
+
}
|
|
62
|
+
if (type === "pop-bounce") {
|
|
63
|
+
const entry =
|
|
64
|
+
typeof anim.in === "number" ? anim.in : C.DEFAULT_TEXT_ANIM_IN;
|
|
65
|
+
return `:fontsize=if(lt(t\\,${start + entry})\\,${(baseSize * 0.7).toFixed(
|
|
66
|
+
3
|
|
67
|
+
)}+${(baseSize * 0.4).toFixed(
|
|
68
|
+
3
|
|
69
|
+
)}*sin(PI/2*(t-${start})/${entry})\\,${baseSize})`;
|
|
70
|
+
}
|
|
71
|
+
return `:fontsize=${baseSize}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildDrawtextParams(
|
|
75
|
+
baseClip,
|
|
76
|
+
text,
|
|
77
|
+
canvasWidth,
|
|
78
|
+
canvasHeight,
|
|
79
|
+
start,
|
|
80
|
+
end
|
|
81
|
+
) {
|
|
82
|
+
const fontSpec = baseClip.fontFile
|
|
83
|
+
? `fontfile=${baseClip.fontFile}`
|
|
84
|
+
: baseClip.fontFamily
|
|
85
|
+
? `font=${baseClip.fontFamily}`
|
|
86
|
+
: `font=Sans`;
|
|
87
|
+
|
|
88
|
+
const escaped = Strings.escapeDrawtextText(text);
|
|
89
|
+
let params = `drawtext=text='${escaped}':${fontSpec}`;
|
|
90
|
+
params += buildFontsizeParam(baseClip, start);
|
|
91
|
+
params += `:fontcolor=${baseClip.fontColor}`;
|
|
92
|
+
params += buildXParam(baseClip, canvasWidth);
|
|
93
|
+
params += buildYParamAnimated(baseClip, canvasHeight, start, end);
|
|
94
|
+
params += buildAlphaParam(baseClip, start, end);
|
|
95
|
+
|
|
96
|
+
if (baseClip.borderColor) params += `:bordercolor=${baseClip.borderColor}`;
|
|
97
|
+
if (baseClip.borderWidth) params += `:borderw=${baseClip.borderWidth}`;
|
|
98
|
+
if (baseClip.shadowColor) params += `:shadowcolor=${baseClip.shadowColor}`;
|
|
99
|
+
if (baseClip.shadowX) params += `:shadowx=${baseClip.shadowX}`;
|
|
100
|
+
if (baseClip.shadowY) params += `:shadowy=${baseClip.shadowY}`;
|
|
101
|
+
if (baseClip.backgroundColor) {
|
|
102
|
+
params += `:box=1:boxcolor=${baseClip.backgroundColor}`;
|
|
103
|
+
if (baseClip.backgroundOpacity) params += `@${baseClip.backgroundOpacity}`;
|
|
104
|
+
}
|
|
105
|
+
if (baseClip.padding) params += `:boxborderw=${baseClip.padding}`;
|
|
106
|
+
|
|
107
|
+
return params;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function computeWordWindows(clip, words) {
|
|
111
|
+
const windows = [];
|
|
112
|
+
const startBase = clip.position;
|
|
113
|
+
const endBase = clip.end;
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(clip.words) && clip.words.length > 0) {
|
|
116
|
+
clip.words.forEach((w) => {
|
|
117
|
+
if (
|
|
118
|
+
typeof w.start === "number" &&
|
|
119
|
+
typeof w.end === "number" &&
|
|
120
|
+
typeof w.text === "string"
|
|
121
|
+
) {
|
|
122
|
+
const start = Math.max(startBase, w.start);
|
|
123
|
+
const end = Math.min(endBase, w.end);
|
|
124
|
+
if (end > start) windows.push({ start, end, text: w.text });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return windows;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const timestamps = Array.isArray(clip.wordTimestamps)
|
|
131
|
+
? clip.wordTimestamps.slice()
|
|
132
|
+
: [];
|
|
133
|
+
if (timestamps.length > 0) {
|
|
134
|
+
const ts = timestamps
|
|
135
|
+
.map((t) => Math.min(endBase, Math.max(startBase, t)))
|
|
136
|
+
.filter((t) => typeof t === "number")
|
|
137
|
+
.sort((a, b) => a - b);
|
|
138
|
+
|
|
139
|
+
if (ts.length === words.length + 1) {
|
|
140
|
+
for (let i = 0; i < words.length; i++) {
|
|
141
|
+
const start = ts[i];
|
|
142
|
+
const end = ts[i + 1];
|
|
143
|
+
if (end > start) windows.push({ start, end, text: words[i] });
|
|
144
|
+
}
|
|
145
|
+
return windows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ts.length === words.length) {
|
|
149
|
+
for (let i = 0; i < words.length; i++) {
|
|
150
|
+
const start = ts[i];
|
|
151
|
+
const end = i + 1 < ts.length ? ts[i + 1] : endBase;
|
|
152
|
+
if (end > start) windows.push({ start, end, text: words[i] });
|
|
153
|
+
}
|
|
154
|
+
return windows;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const total = Math.max(0, endBase - startBase);
|
|
159
|
+
if (words.length === 0 || total <= 0) return windows;
|
|
160
|
+
if (words.length === 1) {
|
|
161
|
+
windows.push({ start: startBase, end: endBase, text: words[0] });
|
|
162
|
+
return windows;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const step = total / words.length;
|
|
166
|
+
for (let i = 0; i < words.length; i++) {
|
|
167
|
+
const start = startBase + i * step;
|
|
168
|
+
const end = i === words.length - 1 ? endBase : startBase + (i + 1) * step;
|
|
169
|
+
windows.push({ start, end, text: words[i] });
|
|
170
|
+
}
|
|
171
|
+
return windows;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildTextFilters(
|
|
175
|
+
textClips,
|
|
176
|
+
canvasWidth,
|
|
177
|
+
canvasHeight,
|
|
178
|
+
initialVideoLabel
|
|
179
|
+
) {
|
|
180
|
+
let filterString = "";
|
|
181
|
+
let currentLabel = initialVideoLabel;
|
|
182
|
+
let labelIndex = 0;
|
|
183
|
+
const nextLabel = () => {
|
|
184
|
+
const label = `[vtext${labelIndex}]`;
|
|
185
|
+
labelIndex += 1;
|
|
186
|
+
return label;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (const clip of textClips) {
|
|
190
|
+
const mode = clip.mode || "static";
|
|
191
|
+
if (mode === "static") {
|
|
192
|
+
const params = buildDrawtextParams(
|
|
193
|
+
clip,
|
|
194
|
+
clip.text,
|
|
195
|
+
canvasWidth,
|
|
196
|
+
canvasHeight,
|
|
197
|
+
clip.position,
|
|
198
|
+
clip.end
|
|
199
|
+
);
|
|
200
|
+
const enable = `:enable='between(t,${clip.position},${clip.end})'`;
|
|
201
|
+
const outLabel = nextLabel();
|
|
202
|
+
filterString += `${currentLabel}${params}${enable}${outLabel};`;
|
|
203
|
+
currentLabel = outLabel;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const splitWords = (clip.text || "").split(/\s+/).filter(Boolean);
|
|
207
|
+
const sourceWords =
|
|
208
|
+
Array.isArray(clip.words) && clip.words.length > 0
|
|
209
|
+
? clip.words.map((w) => w.text)
|
|
210
|
+
: splitWords;
|
|
211
|
+
const windows = computeWordWindows(clip, sourceWords);
|
|
212
|
+
|
|
213
|
+
if (mode === "word-replace") {
|
|
214
|
+
for (const w of windows) {
|
|
215
|
+
const params = buildDrawtextParams(
|
|
216
|
+
clip,
|
|
217
|
+
w.text,
|
|
218
|
+
canvasWidth,
|
|
219
|
+
canvasHeight,
|
|
220
|
+
w.start,
|
|
221
|
+
w.end
|
|
222
|
+
);
|
|
223
|
+
const enable = `:enable='between(t,${w.start},${w.end})'`;
|
|
224
|
+
const outLabel = nextLabel();
|
|
225
|
+
filterString += `${currentLabel}${params}${enable}${outLabel};`;
|
|
226
|
+
currentLabel = outLabel;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (mode === "word-sequential") {
|
|
232
|
+
for (let i = 0; i < windows.length; i++) {
|
|
233
|
+
const visible = sourceWords.slice(0, i + 1).join(" ");
|
|
234
|
+
const w = windows[i];
|
|
235
|
+
const params = buildDrawtextParams(
|
|
236
|
+
clip,
|
|
237
|
+
visible,
|
|
238
|
+
canvasWidth,
|
|
239
|
+
canvasHeight,
|
|
240
|
+
w.start,
|
|
241
|
+
w.end
|
|
242
|
+
);
|
|
243
|
+
const enable = `:enable='between(t,${w.start},${w.end})'`;
|
|
244
|
+
const outLabel = nextLabel();
|
|
245
|
+
filterString += `${currentLabel}${params}${enable}${outLabel};`;
|
|
246
|
+
currentLabel = outLabel;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Unknown mode -> static fallback
|
|
252
|
+
const params = buildDrawtextParams(
|
|
253
|
+
clip,
|
|
254
|
+
clip.text,
|
|
255
|
+
canvasWidth,
|
|
256
|
+
canvasHeight,
|
|
257
|
+
clip.position,
|
|
258
|
+
clip.end
|
|
259
|
+
);
|
|
260
|
+
const enable = `:enable='between(t,${clip.position},${clip.end})'`;
|
|
261
|
+
const outLabel = nextLabel();
|
|
262
|
+
filterString += `${currentLabel}${params}${enable}${outLabel};`;
|
|
263
|
+
currentLabel = outLabel;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (currentLabel !== initialVideoLabel) {
|
|
267
|
+
filterString += `${currentLabel}null[outVideoAndText];`;
|
|
268
|
+
currentLabel = "[outVideoAndText]";
|
|
269
|
+
}
|
|
270
|
+
return { filterString, finalVideoLabel: currentLabel };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function expandTextWindows(textClips) {
|
|
274
|
+
const ops = [];
|
|
275
|
+
for (const clip of textClips) {
|
|
276
|
+
const effectiveClip = { ...clip };
|
|
277
|
+
const mode = effectiveClip.mode || "static";
|
|
278
|
+
if (mode === "static") {
|
|
279
|
+
ops.push({
|
|
280
|
+
text: effectiveClip.text || "",
|
|
281
|
+
start: effectiveClip.position,
|
|
282
|
+
end: effectiveClip.end,
|
|
283
|
+
clip: effectiveClip,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const splitWords = (effectiveClip.text || "").split(/\s+/).filter(Boolean);
|
|
288
|
+
const sourceWords =
|
|
289
|
+
Array.isArray(effectiveClip.words) && effectiveClip.words.length > 0
|
|
290
|
+
? effectiveClip.words.map((w) => w.text)
|
|
291
|
+
: splitWords;
|
|
292
|
+
const windows = computeWordWindows(effectiveClip, sourceWords);
|
|
293
|
+
|
|
294
|
+
if (mode === "word-replace") {
|
|
295
|
+
for (const w of windows)
|
|
296
|
+
ops.push({
|
|
297
|
+
text: w.text,
|
|
298
|
+
start: w.start,
|
|
299
|
+
end: w.end,
|
|
300
|
+
clip: effectiveClip,
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (mode === "word-sequential") {
|
|
305
|
+
for (let i = 0; i < windows.length; i++) {
|
|
306
|
+
const visible = sourceWords.slice(0, i + 1).join(" ");
|
|
307
|
+
const w = windows[i];
|
|
308
|
+
ops.push({
|
|
309
|
+
text: visible,
|
|
310
|
+
start: w.start,
|
|
311
|
+
end: w.end,
|
|
312
|
+
clip: effectiveClip,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return ops;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildFiltersForWindows(
|
|
322
|
+
windows,
|
|
323
|
+
canvasWidth,
|
|
324
|
+
canvasHeight,
|
|
325
|
+
initialVideoLabel
|
|
326
|
+
) {
|
|
327
|
+
let filterString = "";
|
|
328
|
+
let currentLabel = initialVideoLabel;
|
|
329
|
+
let labelIndex = 0;
|
|
330
|
+
const nextLabel = () => {
|
|
331
|
+
const label = `[vtextb${labelIndex}]`;
|
|
332
|
+
labelIndex += 1;
|
|
333
|
+
return label;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
for (const win of windows) {
|
|
337
|
+
const params = buildDrawtextParams(
|
|
338
|
+
win.clip,
|
|
339
|
+
win.text,
|
|
340
|
+
canvasWidth,
|
|
341
|
+
canvasHeight,
|
|
342
|
+
win.start,
|
|
343
|
+
win.end
|
|
344
|
+
);
|
|
345
|
+
const enable = `:enable='between(t,${win.start},${win.end})'`;
|
|
346
|
+
const outLabel = nextLabel();
|
|
347
|
+
filterString += `${currentLabel}${params}${enable}${outLabel};`;
|
|
348
|
+
currentLabel = outLabel;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (currentLabel !== initialVideoLabel) {
|
|
352
|
+
filterString += `${currentLabel}null[outVideoAndText];`;
|
|
353
|
+
currentLabel = "[outVideoAndText]";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { filterString, finalVideoLabel: currentLabel };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
buildTextFilters,
|
|
361
|
+
expandTextWindows,
|
|
362
|
+
buildFiltersForWindows,
|
|
363
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
function buildVideoFilter(project, videoClips) {
|
|
2
|
+
let filterComplex = "";
|
|
3
|
+
let videoIndex = 0;
|
|
4
|
+
const fps = project.options.fps;
|
|
5
|
+
const width = project.options.width;
|
|
6
|
+
const height = project.options.height;
|
|
7
|
+
|
|
8
|
+
// Build scaled streams
|
|
9
|
+
const scaledStreams = [];
|
|
10
|
+
videoClips.forEach((clip) => {
|
|
11
|
+
const inputIndex = project.videoOrAudioClips.indexOf(clip);
|
|
12
|
+
const scaledLabel = `[scaled${videoIndex}]`;
|
|
13
|
+
|
|
14
|
+
const requestedDuration = Math.max(
|
|
15
|
+
0,
|
|
16
|
+
(clip.end || 0) - (clip.position || 0)
|
|
17
|
+
);
|
|
18
|
+
const maxAvailable =
|
|
19
|
+
typeof clip.mediaDuration === "number" && typeof clip.cutFrom === "number"
|
|
20
|
+
? Math.max(0, clip.mediaDuration - clip.cutFrom)
|
|
21
|
+
: requestedDuration;
|
|
22
|
+
const clipDuration = Math.max(0, Math.min(requestedDuration, maxAvailable));
|
|
23
|
+
|
|
24
|
+
if (clip.type === "image" && clip.kenBurns) {
|
|
25
|
+
const frames = Math.max(1, Math.round(clipDuration * fps));
|
|
26
|
+
const framesMinusOne = Math.max(1, frames - 1);
|
|
27
|
+
const s = `${width}x${height}`;
|
|
28
|
+
|
|
29
|
+
// Use overscan pre-scale + center crop, then zoompan with center-based x/y
|
|
30
|
+
const overscanW = Math.max(width * 3, 4000);
|
|
31
|
+
const kb = clip.kenBurns;
|
|
32
|
+
const type = typeof kb === "string" ? kb : kb.type;
|
|
33
|
+
// Simplified fixed-intensity zoom. API no longer exposes strength.
|
|
34
|
+
const zoomAmount = 0.15;
|
|
35
|
+
|
|
36
|
+
let zoomExpr = `1`;
|
|
37
|
+
let xExpr = `round(iw/2 - (iw/zoom)/2)`;
|
|
38
|
+
let yExpr = `round(ih/2 - (ih/zoom)/2)`;
|
|
39
|
+
|
|
40
|
+
if (type === "zoom-in") {
|
|
41
|
+
const inc = (zoomAmount / framesMinusOne).toFixed(6);
|
|
42
|
+
// Ensure first frame starts exactly at base zoom=1 (on==0)
|
|
43
|
+
zoomExpr = `if(eq(on\\,0)\\,1\\,zoom+${inc})`;
|
|
44
|
+
} else if (type === "zoom-out") {
|
|
45
|
+
const start = (1 + zoomAmount).toFixed(4);
|
|
46
|
+
const dec = (zoomAmount / framesMinusOne).toFixed(6);
|
|
47
|
+
// Start pre-zoomed on first frame to avoid jump
|
|
48
|
+
zoomExpr = `if(eq(on\\,0)\\,${start}\\,zoom-${dec})`;
|
|
49
|
+
} else {
|
|
50
|
+
const panZoom = 1.12;
|
|
51
|
+
zoomExpr = `${panZoom}`;
|
|
52
|
+
const dx = `(iw - iw/${panZoom})`;
|
|
53
|
+
const dy = `(ih - ih/${panZoom})`;
|
|
54
|
+
if (type === "pan-left") {
|
|
55
|
+
xExpr = `${dx} - ${dx}*on/${framesMinusOne}`;
|
|
56
|
+
yExpr = `(ih - ih/zoom)/2`;
|
|
57
|
+
} else if (type === "pan-right") {
|
|
58
|
+
xExpr = `${dx}*on/${framesMinusOne}`;
|
|
59
|
+
yExpr = `(ih - ih/zoom)/2`;
|
|
60
|
+
} else if (type === "pan-up") {
|
|
61
|
+
xExpr = `(iw - iw/zoom)/2`;
|
|
62
|
+
yExpr = `${dy} - ${dy}*on/${framesMinusOne}`;
|
|
63
|
+
} else if (type === "pan-down") {
|
|
64
|
+
xExpr = `(iw - iw/zoom)/2`;
|
|
65
|
+
yExpr = `${dy}*on/${framesMinusOne}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
filterComplex += `[${inputIndex}:v]select=eq(n\\,0),setpts=PTS-STARTPTS,scale=${width}:-2,setsar=1:1,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,scale=${overscanW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${s},fps=${fps},settb=1/${fps}${scaledLabel};`;
|
|
70
|
+
} else {
|
|
71
|
+
filterComplex += `[${inputIndex}:v]trim=start=${
|
|
72
|
+
clip.cutFrom || 0
|
|
73
|
+
}:duration=${clipDuration},setpts=PTS-STARTPTS,fps=${fps},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,settb=1/${fps}${scaledLabel};`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
scaledStreams.push({
|
|
77
|
+
label: scaledLabel,
|
|
78
|
+
clip,
|
|
79
|
+
index: videoIndex,
|
|
80
|
+
duration: clipDuration,
|
|
81
|
+
});
|
|
82
|
+
videoIndex++;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (scaledStreams.length === 0) {
|
|
86
|
+
return { filter: "", finalVideoLabel: null, hasVideo: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const hasTransitions = scaledStreams.some(
|
|
90
|
+
(s, i) => i > 0 && s.clip.transition
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (!hasTransitions) {
|
|
94
|
+
const labels = scaledStreams.map((s) => s.label);
|
|
95
|
+
filterComplex += `${labels.join("")}concat=n=${
|
|
96
|
+
labels.length
|
|
97
|
+
}:v=1:a=0,fps=${fps},settb=1/${fps}[outv];`;
|
|
98
|
+
return { filter: filterComplex, finalVideoLabel: "[outv]", hasVideo: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let currentVideo = scaledStreams[0].label;
|
|
102
|
+
let currentVideoDuration = scaledStreams[0].duration;
|
|
103
|
+
for (let i = 1; i < scaledStreams.length; i++) {
|
|
104
|
+
const nextVideoLabel = scaledStreams[i].label;
|
|
105
|
+
const transClip = scaledStreams[i].clip;
|
|
106
|
+
const transitionedVideoLabel = `[vtrans${i}]`;
|
|
107
|
+
if (transClip.transition) {
|
|
108
|
+
const type = transClip.transition.type;
|
|
109
|
+
const duration = transClip.transition.duration;
|
|
110
|
+
const offset = Math.max(0, currentVideoDuration - duration);
|
|
111
|
+
filterComplex += `${currentVideo}${nextVideoLabel}xfade=transition=${type}:duration=${duration}:offset=${offset},fps=${fps},settb=1/${fps}${transitionedVideoLabel};`;
|
|
112
|
+
currentVideoDuration =
|
|
113
|
+
currentVideoDuration + scaledStreams[i].duration - duration;
|
|
114
|
+
currentVideo = transitionedVideoLabel;
|
|
115
|
+
} else {
|
|
116
|
+
const concatenatedVideoLabel = `[vcat${i}]`;
|
|
117
|
+
filterComplex += `${currentVideo}${nextVideoLabel}concat=n=2:v=1:a=0,fps=${fps},settb=1/${fps}${concatenatedVideoLabel};`;
|
|
118
|
+
currentVideo = concatenatedVideoLabel;
|
|
119
|
+
currentVideoDuration = currentVideoDuration + scaledStreams[i].duration;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
filter: filterComplex,
|
|
125
|
+
finalVideoLabel: currentVideo,
|
|
126
|
+
hasVideo: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { buildVideoFilter };
|
package/src/lib/utils.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const formatBytes = (bytes) => {
|
|
2
|
+
if (!Number.isFinite(bytes)) return `${bytes}`;
|
|
3
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
4
|
+
let i = 0;
|
|
5
|
+
let n = bytes;
|
|
6
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
7
|
+
n /= 1024;
|
|
8
|
+
i++;
|
|
9
|
+
}
|
|
10
|
+
return `${n.toFixed(n < 10 && i > 0 ? 2 : 1)} ${units[i]}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
module.exports = { formatBytes };
|
package/src/loaders.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const { getVideoMetadata, getMediaDuration } = require("./core/media_info");
|
|
2
|
+
const C = require("./core/constants");
|
|
3
|
+
|
|
4
|
+
async function loadVideo(project, clipObj) {
|
|
5
|
+
const metadata = await getVideoMetadata(clipObj.url);
|
|
6
|
+
if (typeof clipObj.cutFrom === "number" && metadata.durationSec != null) {
|
|
7
|
+
if (clipObj.cutFrom >= metadata.durationSec) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.durationSec}s)`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (
|
|
14
|
+
typeof clipObj.position === "number" &&
|
|
15
|
+
typeof clipObj.end === "number" &&
|
|
16
|
+
typeof clipObj.cutFrom === "number" &&
|
|
17
|
+
metadata.durationSec != null
|
|
18
|
+
) {
|
|
19
|
+
const requestedDuration = Math.max(0, clipObj.end - clipObj.position);
|
|
20
|
+
const maxAvailable = Math.max(0, metadata.durationSec - clipObj.cutFrom);
|
|
21
|
+
if (requestedDuration > maxAvailable) {
|
|
22
|
+
const clampedEnd = clipObj.position + maxAvailable;
|
|
23
|
+
console.warn(
|
|
24
|
+
`Video clip overruns source by ${(
|
|
25
|
+
requestedDuration - maxAvailable
|
|
26
|
+
).toFixed(3)}s. Clamping end from ${clipObj.end}s to ${clampedEnd}s.`
|
|
27
|
+
);
|
|
28
|
+
clipObj.end = clampedEnd;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
project.videoOrAudioClips.push({
|
|
32
|
+
...clipObj,
|
|
33
|
+
iphoneRotation: metadata.iphoneRotation,
|
|
34
|
+
hasAudio: metadata.hasAudio,
|
|
35
|
+
mediaDuration: metadata.durationSec,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function loadAudio(project, clipObj) {
|
|
40
|
+
const durationSec = await getMediaDuration(clipObj.url);
|
|
41
|
+
if (typeof clipObj.cutFrom === "number" && durationSec != null) {
|
|
42
|
+
if (clipObj.cutFrom >= durationSec) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Audio clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${durationSec}s)`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
typeof clipObj.position === "number" &&
|
|
50
|
+
typeof clipObj.end === "number" &&
|
|
51
|
+
typeof clipObj.cutFrom === "number" &&
|
|
52
|
+
durationSec != null
|
|
53
|
+
) {
|
|
54
|
+
const requestedDuration = Math.max(0, clipObj.end - clipObj.position);
|
|
55
|
+
const maxAvailable = Math.max(0, durationSec - clipObj.cutFrom);
|
|
56
|
+
if (requestedDuration > maxAvailable) {
|
|
57
|
+
const clampedEnd = clipObj.position + maxAvailable;
|
|
58
|
+
console.warn(
|
|
59
|
+
`Audio clip overruns source by ${(
|
|
60
|
+
requestedDuration - maxAvailable
|
|
61
|
+
).toFixed(3)}s. Clamping end from ${clipObj.end}s to ${clampedEnd}s.`
|
|
62
|
+
);
|
|
63
|
+
clipObj.end = clampedEnd;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
project.videoOrAudioClips.push({ ...clipObj, mediaDuration: durationSec });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadImage(project, clipObj) {
|
|
70
|
+
const clip = { ...clipObj, hasAudio: false, cutFrom: 0 };
|
|
71
|
+
project.videoOrAudioClips.push(clip);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loadBackgroundAudio(project, clipObj) {
|
|
75
|
+
const durationSec = await getMediaDuration(clipObj.url);
|
|
76
|
+
const clip = {
|
|
77
|
+
...clipObj,
|
|
78
|
+
volume:
|
|
79
|
+
typeof clipObj.volume === "number"
|
|
80
|
+
? clipObj.volume
|
|
81
|
+
: C.DEFAULT_BGM_VOLUME,
|
|
82
|
+
cutFrom: typeof clipObj.cutFrom === "number" ? clipObj.cutFrom : 0,
|
|
83
|
+
position: typeof clipObj.position === "number" ? clipObj.position : 0,
|
|
84
|
+
};
|
|
85
|
+
if (typeof clip.cutFrom === "number" && durationSec != null) {
|
|
86
|
+
if (clip.cutFrom >= durationSec) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Background audio cutFrom (${clip.cutFrom}s) must be < source duration (${durationSec}s)`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
typeof clip.position === "number" &&
|
|
94
|
+
typeof clip.end === "number" &&
|
|
95
|
+
typeof clip.cutFrom === "number" &&
|
|
96
|
+
durationSec != null
|
|
97
|
+
) {
|
|
98
|
+
const requestedDuration = Math.max(0, clip.end - clip.position);
|
|
99
|
+
const maxAvailable = Math.max(0, durationSec - clip.cutFrom);
|
|
100
|
+
if (requestedDuration > maxAvailable) {
|
|
101
|
+
const clampedEnd = clip.position + maxAvailable;
|
|
102
|
+
console.warn(
|
|
103
|
+
`Background audio overruns source by ${(
|
|
104
|
+
requestedDuration - maxAvailable
|
|
105
|
+
).toFixed(3)}s. Clamping end from ${clip.end}s to ${clampedEnd}s.`
|
|
106
|
+
);
|
|
107
|
+
clip.end = clampedEnd;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
project.videoOrAudioClips.push({ ...clip, mediaDuration: durationSec });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadText(project, clipObj) {
|
|
114
|
+
const clip = {
|
|
115
|
+
...clipObj,
|
|
116
|
+
fontFile: clipObj.fontFile || null,
|
|
117
|
+
fontFamily: clipObj.fontFamily || C.DEFAULT_FONT_FAMILY,
|
|
118
|
+
fontSize: clipObj.fontSize || C.DEFAULT_FONT_SIZE,
|
|
119
|
+
fontColor: clipObj.fontColor || C.DEFAULT_FONT_COLOR,
|
|
120
|
+
};
|
|
121
|
+
if (typeof clipObj.centerX === "number") clip.centerX = clipObj.centerX;
|
|
122
|
+
else if (typeof clipObj.x === "number") clip.x = clipObj.x;
|
|
123
|
+
else clip.centerX = 0;
|
|
124
|
+
if (typeof clipObj.centerY === "number") clip.centerY = clipObj.centerY;
|
|
125
|
+
else if (typeof clipObj.y === "number") clip.y = clipObj.y;
|
|
126
|
+
else clip.centerY = 0;
|
|
127
|
+
project.textClips.push(clip);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
loadVideo,
|
|
132
|
+
loadAudio,
|
|
133
|
+
loadImage,
|
|
134
|
+
loadBackgroundAudio,
|
|
135
|
+
loadText,
|
|
136
|
+
};
|