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.
@@ -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 };
@@ -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
+ };