simple-ffmpegjs 0.3.4 → 0.3.6
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/README.md +113 -48
- package/package.json +1 -1
- package/src/core/gaps.js +27 -1
- package/src/core/validation.js +250 -2
- package/src/ffmpeg/audio_builder.js +12 -3
- package/src/ffmpeg/video_builder.js +219 -57
- package/src/ffmpeg/watermark_builder.js +13 -0
- package/src/loaders.js +9 -2
- package/src/schema/modules/image.js +24 -6
- package/src/simpleffmpeg.js +102 -8
- package/types/index.d.mts +32 -11
- package/types/index.d.ts +32 -11
|
@@ -1,19 +1,203 @@
|
|
|
1
1
|
const { detectVisualGaps } = require("../core/gaps");
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Create synthetic
|
|
4
|
+
* Create synthetic clips to fill visual gaps.
|
|
5
|
+
* The actual fill color is determined by the project's fillGaps option
|
|
6
|
+
* and applied when building the filter graph.
|
|
5
7
|
*/
|
|
6
|
-
function
|
|
8
|
+
function createGapFillClips(gaps) {
|
|
7
9
|
return gaps.map((gap, index) => ({
|
|
8
|
-
type: "
|
|
10
|
+
type: "_gapfill",
|
|
9
11
|
position: gap.start,
|
|
10
12
|
end: gap.end,
|
|
11
13
|
_gapIndex: index,
|
|
12
|
-
|
|
14
|
+
_isGapFill: true,
|
|
13
15
|
}));
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
const DEFAULT_KEN_BURNS_ZOOM = 0.15;
|
|
19
|
+
const DEFAULT_PAN_ZOOM = 1.12;
|
|
20
|
+
|
|
21
|
+
function clamp01(value) {
|
|
22
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return Math.min(1, Math.max(0, value));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatNumber(value, decimals) {
|
|
29
|
+
return Number(value.toFixed(decimals)).toString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildEasingExpr(framesMinusOne, easing) {
|
|
33
|
+
const t = `(on/${framesMinusOne})`;
|
|
34
|
+
if (easing === "ease-in") {
|
|
35
|
+
return `(${t})*(${t})`;
|
|
36
|
+
}
|
|
37
|
+
if (easing === "ease-out") {
|
|
38
|
+
return `1-((1-${t})*(1-${t}))`;
|
|
39
|
+
}
|
|
40
|
+
if (easing === "ease-in-out") {
|
|
41
|
+
return `0.5-0.5*cos(PI*${t})`;
|
|
42
|
+
}
|
|
43
|
+
return t;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildInterpolatedExpr(start, end, framesMinusOne, easing, decimals) {
|
|
47
|
+
const delta = end - start;
|
|
48
|
+
const startStr = formatNumber(start, decimals);
|
|
49
|
+
if (framesMinusOne <= 1 || Math.abs(delta) < 1e-8) {
|
|
50
|
+
return startStr;
|
|
51
|
+
}
|
|
52
|
+
const deltaStr = formatNumber(delta, decimals);
|
|
53
|
+
const ease = buildEasingExpr(framesMinusOne, easing);
|
|
54
|
+
return `${startStr}+(${deltaStr})*(${ease})`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildZoomExpr(startZoom, endZoom, framesMinusOne, easing) {
|
|
58
|
+
return buildInterpolatedExpr(startZoom, endZoom, framesMinusOne, easing, 4);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildPositionExpr(start, end, framesMinusOne, easing) {
|
|
62
|
+
return buildInterpolatedExpr(start, end, framesMinusOne, easing, 4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveKenBurnsOptions(kenBurns, width, height, sourceWidth, sourceHeight) {
|
|
66
|
+
const kb = typeof kenBurns === "object" && kenBurns ? kenBurns : {};
|
|
67
|
+
const type = typeof kenBurns === "string" ? kenBurns : kb.type || "custom";
|
|
68
|
+
const easing = kb.easing || "ease-in-out";
|
|
69
|
+
|
|
70
|
+
let startZoom = 1;
|
|
71
|
+
let endZoom = 1;
|
|
72
|
+
let startX = 0.5;
|
|
73
|
+
let startY = 0.5;
|
|
74
|
+
let endX = 0.5;
|
|
75
|
+
let endY = 0.5;
|
|
76
|
+
|
|
77
|
+
if (type === "zoom-in") {
|
|
78
|
+
startZoom = 1;
|
|
79
|
+
endZoom = 1 + DEFAULT_KEN_BURNS_ZOOM;
|
|
80
|
+
} else if (type === "zoom-out") {
|
|
81
|
+
startZoom = 1 + DEFAULT_KEN_BURNS_ZOOM;
|
|
82
|
+
endZoom = 1;
|
|
83
|
+
} else if (type === "pan-left") {
|
|
84
|
+
startZoom = DEFAULT_PAN_ZOOM;
|
|
85
|
+
endZoom = DEFAULT_PAN_ZOOM;
|
|
86
|
+
startX = 1;
|
|
87
|
+
endX = 0;
|
|
88
|
+
} else if (type === "pan-right") {
|
|
89
|
+
startZoom = DEFAULT_PAN_ZOOM;
|
|
90
|
+
endZoom = DEFAULT_PAN_ZOOM;
|
|
91
|
+
startX = 0;
|
|
92
|
+
endX = 1;
|
|
93
|
+
} else if (type === "pan-up") {
|
|
94
|
+
startZoom = DEFAULT_PAN_ZOOM;
|
|
95
|
+
endZoom = DEFAULT_PAN_ZOOM;
|
|
96
|
+
startY = 1;
|
|
97
|
+
endY = 0;
|
|
98
|
+
} else if (type === "pan-down") {
|
|
99
|
+
startZoom = DEFAULT_PAN_ZOOM;
|
|
100
|
+
endZoom = DEFAULT_PAN_ZOOM;
|
|
101
|
+
startY = 0;
|
|
102
|
+
endY = 1;
|
|
103
|
+
} else if (type === "smart") {
|
|
104
|
+
const anchor = kb.anchor;
|
|
105
|
+
startZoom = DEFAULT_PAN_ZOOM;
|
|
106
|
+
endZoom = DEFAULT_PAN_ZOOM;
|
|
107
|
+
|
|
108
|
+
let panAxis = "horizontal";
|
|
109
|
+
const hasSourceDims =
|
|
110
|
+
typeof sourceWidth === "number" &&
|
|
111
|
+
typeof sourceHeight === "number" &&
|
|
112
|
+
sourceWidth > 0 &&
|
|
113
|
+
sourceHeight > 0;
|
|
114
|
+
|
|
115
|
+
if (hasSourceDims) {
|
|
116
|
+
const outputAspect = width / height;
|
|
117
|
+
const sourceAspect = sourceWidth / sourceHeight;
|
|
118
|
+
if (Math.abs(sourceAspect - outputAspect) > 0.001) {
|
|
119
|
+
panAxis = sourceAspect < outputAspect ? "vertical" : "horizontal";
|
|
120
|
+
} else {
|
|
121
|
+
panAxis = height > width ? "vertical" : "horizontal";
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
panAxis = height > width ? "vertical" : "horizontal";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (panAxis === "vertical") {
|
|
128
|
+
if (anchor === "top") {
|
|
129
|
+
startY = 0;
|
|
130
|
+
endY = 1;
|
|
131
|
+
} else {
|
|
132
|
+
startY = 1;
|
|
133
|
+
endY = 0;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
if (anchor === "left") {
|
|
137
|
+
startX = 0;
|
|
138
|
+
endX = 1;
|
|
139
|
+
} else {
|
|
140
|
+
startX = 1;
|
|
141
|
+
endX = 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof kb.startZoom === "number" && Number.isFinite(kb.startZoom)) {
|
|
147
|
+
startZoom = kb.startZoom;
|
|
148
|
+
}
|
|
149
|
+
if (typeof kb.endZoom === "number" && Number.isFinite(kb.endZoom)) {
|
|
150
|
+
endZoom = kb.endZoom;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const customStartX = clamp01(kb.startX);
|
|
154
|
+
const customEndX = clamp01(kb.endX);
|
|
155
|
+
const customStartY = clamp01(kb.startY);
|
|
156
|
+
const customEndY = clamp01(kb.endY);
|
|
157
|
+
|
|
158
|
+
if (typeof customStartX === "number") {
|
|
159
|
+
startX = customStartX;
|
|
160
|
+
}
|
|
161
|
+
if (typeof customEndX === "number") {
|
|
162
|
+
endX = customEndX;
|
|
163
|
+
}
|
|
164
|
+
if (typeof customStartY === "number") {
|
|
165
|
+
startY = customStartY;
|
|
166
|
+
}
|
|
167
|
+
if (typeof customEndY === "number") {
|
|
168
|
+
endY = customEndY;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// When positions indicate panning but zoom wasn't explicitly set (still at
|
|
172
|
+
// the default 1.0), apply a default pan zoom. At zoom=1.0 the zoompan
|
|
173
|
+
// visible window equals the full image, so (iw - iw/zoom) = 0 and position
|
|
174
|
+
// values are multiplied by zero — making the pan completely invisible.
|
|
175
|
+
const hasPan = startX !== endX || startY !== endY;
|
|
176
|
+
const zoomWasExplicit =
|
|
177
|
+
typeof kb.startZoom === "number" || typeof kb.endZoom === "number";
|
|
178
|
+
if (hasPan && !zoomWasExplicit && startZoom === 1 && endZoom === 1) {
|
|
179
|
+
startZoom = DEFAULT_PAN_ZOOM;
|
|
180
|
+
endZoom = DEFAULT_PAN_ZOOM;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { startZoom, endZoom, startX, startY, endX, endY, easing };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function computeOverscanWidth(width, startZoom, endZoom) {
|
|
187
|
+
const maxZoom = Math.max(1, startZoom, endZoom);
|
|
188
|
+
// Generous pre-scale ensures zoompan has enough pixel resolution for smooth
|
|
189
|
+
// sub-pixel motion. The integer x/y crop offsets in zoompan need a large
|
|
190
|
+
// canvas so that small position changes don't appear as visible stepping.
|
|
191
|
+
// 3x output width (floor 4000px) provides ~5-6 pixels of displacement per
|
|
192
|
+
// frame for typical pan speeds, which is imperceptible on screen.
|
|
193
|
+
let overscan = Math.max(width * 3, 4000, Math.round(width * maxZoom * 2));
|
|
194
|
+
if (overscan % 2 !== 0) {
|
|
195
|
+
overscan += 1;
|
|
196
|
+
}
|
|
197
|
+
return overscan;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildVideoFilter(project, videoClips, options = {}) {
|
|
17
201
|
let filterComplex = "";
|
|
18
202
|
let videoIndex = 0;
|
|
19
203
|
let blackGapIndex = 0;
|
|
@@ -22,13 +206,13 @@ function buildVideoFilter(project, videoClips) {
|
|
|
22
206
|
const height = project.options.height;
|
|
23
207
|
const fillGaps = project.options.fillGaps || "none";
|
|
24
208
|
|
|
25
|
-
// Detect and fill gaps if fillGaps is enabled
|
|
209
|
+
// Detect and fill gaps if fillGaps is enabled (any value other than "none")
|
|
26
210
|
let allVisualClips = [...videoClips];
|
|
27
|
-
if (fillGaps
|
|
28
|
-
const gaps = detectVisualGaps(videoClips);
|
|
211
|
+
if (fillGaps !== "none") {
|
|
212
|
+
const gaps = detectVisualGaps(videoClips, { timelineEnd: options.timelineEnd });
|
|
29
213
|
if (gaps.length > 0) {
|
|
30
|
-
const
|
|
31
|
-
allVisualClips = [...videoClips, ...
|
|
214
|
+
const gapClips = createGapFillClips(gaps);
|
|
215
|
+
allVisualClips = [...videoClips, ...gapClips].sort(
|
|
32
216
|
(a, b) => (a.position || 0) - (b.position || 0),
|
|
33
217
|
);
|
|
34
218
|
}
|
|
@@ -44,10 +228,10 @@ function buildVideoFilter(project, videoClips) {
|
|
|
44
228
|
(clip.end || 0) - (clip.position || 0),
|
|
45
229
|
);
|
|
46
230
|
|
|
47
|
-
// Handle synthetic
|
|
48
|
-
if (clip.
|
|
49
|
-
// Generate a
|
|
50
|
-
filterComplex += `color=c
|
|
231
|
+
// Handle synthetic gap fill clips
|
|
232
|
+
if (clip._isGapFill) {
|
|
233
|
+
// Generate a color source for the gap duration
|
|
234
|
+
filterComplex += `color=c=${fillGaps}:s=${width}x${height}:d=${requestedDuration},fps=${fps},settb=1/${fps}${scaledLabel};`;
|
|
51
235
|
scaledStreams.push({
|
|
52
236
|
label: scaledLabel,
|
|
53
237
|
clip,
|
|
@@ -71,55 +255,31 @@ function buildVideoFilter(project, videoClips) {
|
|
|
71
255
|
const framesMinusOne = Math.max(1, frames - 1);
|
|
72
256
|
const s = `${width}x${height}`;
|
|
73
257
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} else if (type === "zoom-out") {
|
|
90
|
-
const start = (1 + zoomAmount).toFixed(4);
|
|
91
|
-
const dec = (zoomAmount / framesMinusOne).toFixed(6);
|
|
92
|
-
// Start pre-zoomed on first frame to avoid jump
|
|
93
|
-
zoomExpr = `if(eq(on,0),${start},zoom-${dec})`;
|
|
94
|
-
} else {
|
|
95
|
-
const panZoom = 1.12;
|
|
96
|
-
zoomExpr = `${panZoom}`;
|
|
97
|
-
const dx = `(iw - iw/${panZoom})`;
|
|
98
|
-
const dy = `(ih - ih/${panZoom})`;
|
|
99
|
-
if (type === "pan-left") {
|
|
100
|
-
xExpr = `${dx} - ${dx}*on/${framesMinusOne}`;
|
|
101
|
-
yExpr = `(ih - ih/zoom)/2`;
|
|
102
|
-
} else if (type === "pan-right") {
|
|
103
|
-
xExpr = `${dx}*on/${framesMinusOne}`;
|
|
104
|
-
yExpr = `(ih - ih/zoom)/2`;
|
|
105
|
-
} else if (type === "pan-up") {
|
|
106
|
-
xExpr = `(iw - iw/zoom)/2`;
|
|
107
|
-
yExpr = `${dy} - ${dy}*on/${framesMinusOne}`;
|
|
108
|
-
} else if (type === "pan-down") {
|
|
109
|
-
xExpr = `(iw - iw/zoom)/2`;
|
|
110
|
-
yExpr = `${dy}*on/${framesMinusOne}`;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
258
|
+
const { startZoom, endZoom, startX, startY, endX, endY, easing } =
|
|
259
|
+
resolveKenBurnsOptions(
|
|
260
|
+
clip.kenBurns,
|
|
261
|
+
width,
|
|
262
|
+
height,
|
|
263
|
+
clip.width,
|
|
264
|
+
clip.height
|
|
265
|
+
);
|
|
266
|
+
// Overscan provides enough pixel resolution for smooth zoompan motion.
|
|
267
|
+
const overscanW = computeOverscanWidth(width, startZoom, endZoom);
|
|
268
|
+
const zoomExpr = buildZoomExpr(startZoom, endZoom, framesMinusOne, easing);
|
|
269
|
+
const xPosExpr = buildPositionExpr(startX, endX, framesMinusOne, easing);
|
|
270
|
+
const yPosExpr = buildPositionExpr(startY, endY, framesMinusOne, easing);
|
|
271
|
+
const xExpr = `(iw - iw/zoom)*(${xPosExpr})`;
|
|
272
|
+
const yExpr = `(ih - ih/zoom)*(${yPosExpr})`;
|
|
113
273
|
|
|
114
274
|
// Scale to cover target dimensions (upscaling if needed), then center crop
|
|
115
275
|
// force_original_aspect_ratio=increase ensures image covers the target area
|
|
116
276
|
// select='eq(n,0)' uses single quotes to protect the comma from the graph parser.
|
|
117
277
|
// zoompan z/x/y values are also single-quoted — commas inside are literal.
|
|
118
|
-
filterComplex += `[${inputIndex}:v]select='eq(n,0)',setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=increase,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}
|
|
278
|
+
filterComplex += `[${inputIndex}:v]select='eq(n,0)',setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=increase,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},setsar=1:1,settb=1/${fps}${scaledLabel};`;
|
|
119
279
|
} else {
|
|
120
280
|
filterComplex += `[${inputIndex}:v]trim=start=${
|
|
121
281
|
clip.cutFrom || 0
|
|
122
|
-
}: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};`;
|
|
282
|
+
}:duration=${clipDuration},setpts=PTS-STARTPTS,fps=${fps},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1:1,settb=1/${fps}${scaledLabel};`;
|
|
123
283
|
}
|
|
124
284
|
|
|
125
285
|
scaledStreams.push({
|
|
@@ -132,7 +292,7 @@ function buildVideoFilter(project, videoClips) {
|
|
|
132
292
|
});
|
|
133
293
|
|
|
134
294
|
if (scaledStreams.length === 0) {
|
|
135
|
-
return { filter: "", finalVideoLabel: null, hasVideo: false };
|
|
295
|
+
return { filter: "", finalVideoLabel: null, hasVideo: false, videoDuration: 0 };
|
|
136
296
|
}
|
|
137
297
|
|
|
138
298
|
const hasTransitions = scaledStreams.some(
|
|
@@ -141,10 +301,11 @@ function buildVideoFilter(project, videoClips) {
|
|
|
141
301
|
|
|
142
302
|
if (!hasTransitions) {
|
|
143
303
|
const labels = scaledStreams.map((s) => s.label);
|
|
304
|
+
const videoDuration = scaledStreams.reduce((sum, s) => sum + s.duration, 0);
|
|
144
305
|
filterComplex += `${labels.join("")}concat=n=${
|
|
145
306
|
labels.length
|
|
146
307
|
}:v=1:a=0,fps=${fps},settb=1/${fps}[outv];`;
|
|
147
|
-
return { filter: filterComplex, finalVideoLabel: "[outv]", hasVideo: true };
|
|
308
|
+
return { filter: filterComplex, finalVideoLabel: "[outv]", hasVideo: true, videoDuration };
|
|
148
309
|
}
|
|
149
310
|
|
|
150
311
|
let currentVideo = scaledStreams[0].label;
|
|
@@ -173,6 +334,7 @@ function buildVideoFilter(project, videoClips) {
|
|
|
173
334
|
filter: filterComplex,
|
|
174
335
|
finalVideoLabel: currentVideo,
|
|
175
336
|
hasVideo: true,
|
|
337
|
+
videoDuration: currentVideoDuration,
|
|
176
338
|
};
|
|
177
339
|
}
|
|
178
340
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const Strings = require("./strings");
|
|
2
|
+
const { isValidFFmpegColor } = require("../core/validation");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Calculate position expressions for overlay/drawtext
|
|
@@ -321,6 +322,18 @@ function validateWatermarkConfig(config) {
|
|
|
321
322
|
}
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
// Validate color properties
|
|
326
|
+
const colorProps = ["fontColor", "borderColor", "shadowColor"];
|
|
327
|
+
for (const prop of colorProps) {
|
|
328
|
+
if (config[prop] != null && typeof config[prop] === "string") {
|
|
329
|
+
if (!isValidFFmpegColor(config[prop])) {
|
|
330
|
+
errors.push(
|
|
331
|
+
`watermark.${prop} "${config[prop]}" is not a valid FFmpeg color. Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5").`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
324
337
|
// Common validations
|
|
325
338
|
if (
|
|
326
339
|
typeof config.opacity === "number" &&
|
package/src/loaders.js
CHANGED
|
@@ -88,8 +88,15 @@ async function loadAudio(project, clipObj) {
|
|
|
88
88
|
project.videoOrAudioClips.push({ ...clipObj, mediaDuration: durationSec });
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function loadImage(project, clipObj) {
|
|
92
|
-
const
|
|
91
|
+
async function loadImage(project, clipObj) {
|
|
92
|
+
const metadata = await probeMedia(clipObj.url);
|
|
93
|
+
const clip = {
|
|
94
|
+
...clipObj,
|
|
95
|
+
hasAudio: false,
|
|
96
|
+
cutFrom: 0,
|
|
97
|
+
width: clipObj.width ?? metadata.width,
|
|
98
|
+
height: clipObj.height ?? metadata.height,
|
|
99
|
+
};
|
|
93
100
|
project.videoOrAudioClips.push(clip);
|
|
94
101
|
}
|
|
95
102
|
|
|
@@ -4,12 +4,14 @@ module.exports = {
|
|
|
4
4
|
description:
|
|
5
5
|
"Display still images on the timeline, optionally with Ken Burns (pan/zoom) motion effects.",
|
|
6
6
|
schema: `{
|
|
7
|
-
type: "image";
|
|
8
|
-
url: string;
|
|
9
|
-
position?: number;
|
|
10
|
-
end?: number;
|
|
11
|
-
duration?: number;
|
|
12
|
-
|
|
7
|
+
type: "image"; // Required: clip type identifier
|
|
8
|
+
url: string; // Required: path to image file (jpg, png, etc.)
|
|
9
|
+
position?: number; // Start time on timeline (seconds). Omit to auto-sequence after previous video/image clip.
|
|
10
|
+
end?: number; // End time on timeline (seconds). Use end OR duration, not both.
|
|
11
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
12
|
+
width?: number; // Optional: source image width (skip probe / override)
|
|
13
|
+
height?: number; // Optional: source image height (skip probe / override)
|
|
14
|
+
kenBurns?: KenBurnsEffect | KenBurnsSpec; // Optional: apply pan/zoom motion to the image
|
|
13
15
|
}`,
|
|
14
16
|
enums: {
|
|
15
17
|
KenBurnsEffect: [
|
|
@@ -19,7 +21,11 @@ module.exports = {
|
|
|
19
21
|
"pan-right",
|
|
20
22
|
"pan-up",
|
|
21
23
|
"pan-down",
|
|
24
|
+
"smart",
|
|
25
|
+
"custom",
|
|
22
26
|
],
|
|
27
|
+
KenBurnsAnchor: ["top", "bottom", "left", "right"],
|
|
28
|
+
KenBurnsEasing: ["linear", "ease-in", "ease-out", "ease-in-out"],
|
|
23
29
|
},
|
|
24
30
|
examples: [
|
|
25
31
|
{
|
|
@@ -34,11 +40,23 @@ module.exports = {
|
|
|
34
40
|
{ type: "image", url: "photo3.jpg", duration: 3, kenBurns: "zoom-out" }
|
|
35
41
|
]`,
|
|
36
42
|
},
|
|
43
|
+
{
|
|
44
|
+
label: "Custom Ken Burns pan with smart anchor",
|
|
45
|
+
code: `{ type: "image", url: "portrait.jpg", duration: 5, kenBurns: { type: "smart", anchor: "bottom", startZoom: 1.05, endZoom: 1.2 } }`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: "Custom Ken Burns with explicit pan endpoints",
|
|
49
|
+
code: `{ type: "image", url: "photo.jpg", duration: 4, kenBurns: { type: "custom", startX: 0.2, startY: 0.8, endX: 0.7, endY: 0.3 } }`,
|
|
50
|
+
},
|
|
37
51
|
],
|
|
38
52
|
notes: [
|
|
39
53
|
"If position is omitted, the clip is placed immediately after the previous video/image clip (auto-sequencing). The first clip defaults to position 0.",
|
|
40
54
|
"Use duration instead of end to specify how long the image displays: end = position + duration. Cannot use both.",
|
|
41
55
|
"Images are scaled to fill the project canvas. For Ken Burns, use images at least as large as the output resolution for best quality.",
|
|
56
|
+
"If width/height are provided, they override probed dimensions (useful for remote or generated images).",
|
|
42
57
|
"Image clips can be placed on the same timeline as video clips and can use transitions between them.",
|
|
58
|
+
"Advanced Ken Burns accepts custom zoom/pan endpoints via normalized coordinates (0 = left/top, 1 = right/bottom).",
|
|
59
|
+
"smart mode auto-pans along the dominant axis; use anchor to pick a starting edge.",
|
|
60
|
+
"Use easing ('linear', 'ease-in', 'ease-out', 'ease-in-out') to smooth motion (default: ease-in-out).",
|
|
43
61
|
],
|
|
44
62
|
};
|
package/src/simpleffmpeg.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
validateConfig,
|
|
17
17
|
formatValidationResult,
|
|
18
18
|
ValidationCodes,
|
|
19
|
+
normalizeFillGaps,
|
|
19
20
|
} = require("./core/validation");
|
|
20
21
|
const {
|
|
21
22
|
SimpleffmpegError,
|
|
@@ -44,6 +45,7 @@ const {
|
|
|
44
45
|
const { getSchema, getSchemaModules } = require("./schema");
|
|
45
46
|
const { resolveClips } = require("./core/resolve");
|
|
46
47
|
const { probeMedia } = require("./core/media_info");
|
|
48
|
+
const { detectVisualGaps } = require("./core/gaps");
|
|
47
49
|
|
|
48
50
|
class SIMPLEFFMPEG {
|
|
49
51
|
/**
|
|
@@ -55,7 +57,7 @@ class SIMPLEFFMPEG {
|
|
|
55
57
|
* @param {number} options.fps - Frames per second (default: 30)
|
|
56
58
|
* @param {string} options.preset - Platform preset ('tiktok', 'youtube', 'instagram-post', etc.)
|
|
57
59
|
* @param {string} options.validationMode - Validation behavior: 'warn' or 'strict' (default: 'warn')
|
|
58
|
-
* @param {string} options.fillGaps - Gap handling: 'none'
|
|
60
|
+
* @param {string|boolean} options.fillGaps - Gap handling: 'none'/false (disabled), true/'black' (black fill), or any valid FFmpeg color (default: 'none')
|
|
59
61
|
*
|
|
60
62
|
* @example
|
|
61
63
|
* const project = new SIMPLEFFMPEG({ preset: 'tiktok' });
|
|
@@ -67,6 +69,14 @@ class SIMPLEFFMPEG {
|
|
|
67
69
|
* fps: 30,
|
|
68
70
|
* fillGaps: 'black'
|
|
69
71
|
* });
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* // Fill gaps with a custom color
|
|
75
|
+
* const project = new SIMPLEFFMPEG({
|
|
76
|
+
* width: 1920,
|
|
77
|
+
* height: 1080,
|
|
78
|
+
* fillGaps: '#1a1a2e' // dark blue-gray
|
|
79
|
+
* });
|
|
70
80
|
*/
|
|
71
81
|
constructor(options = {}) {
|
|
72
82
|
// Apply platform preset if specified
|
|
@@ -81,13 +91,19 @@ class SIMPLEFFMPEG {
|
|
|
81
91
|
);
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
// Normalise and validate fillGaps
|
|
95
|
+
const fillGapsResult = normalizeFillGaps(options.fillGaps);
|
|
96
|
+
if (fillGapsResult.error) {
|
|
97
|
+
throw new ValidationError(fillGapsResult.error);
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
// Explicit options override preset values
|
|
85
101
|
this.options = {
|
|
86
102
|
fps: options.fps || presetConfig.fps || C.DEFAULT_FPS,
|
|
87
103
|
width: options.width || presetConfig.width || C.DEFAULT_WIDTH,
|
|
88
104
|
height: options.height || presetConfig.height || C.DEFAULT_HEIGHT,
|
|
89
105
|
validationMode: options.validationMode || C.DEFAULT_VALIDATION_MODE,
|
|
90
|
-
fillGaps:
|
|
106
|
+
fillGaps: fillGapsResult.color, // "none" | valid FFmpeg color
|
|
91
107
|
preset: options.preset || null,
|
|
92
108
|
};
|
|
93
109
|
this.videoOrAudioClips = [];
|
|
@@ -253,7 +269,7 @@ class SIMPLEFFMPEG {
|
|
|
253
269
|
await Promise.all(
|
|
254
270
|
resolvedClips.map((clipObj) => {
|
|
255
271
|
if (clipObj.type === "video" || clipObj.type === "audio") {
|
|
256
|
-
clipObj.volume = clipObj.volume
|
|
272
|
+
clipObj.volume = clipObj.volume != null ? clipObj.volume : 1;
|
|
257
273
|
clipObj.cutFrom = clipObj.cutFrom || 0;
|
|
258
274
|
if (clipObj.type === "video" && clipObj.transition) {
|
|
259
275
|
clipObj.transition = {
|
|
@@ -397,7 +413,7 @@ class SIMPLEFFMPEG {
|
|
|
397
413
|
let hasVideo = false;
|
|
398
414
|
let hasAudio = false;
|
|
399
415
|
|
|
400
|
-
|
|
416
|
+
let totalVideoDuration = (() => {
|
|
401
417
|
if (videoClips.length === 0) return 0;
|
|
402
418
|
const baseSum = videoClips.reduce(
|
|
403
419
|
(acc, c) => acc + Math.max(0, (c.end || 0) - (c.position || 0)),
|
|
@@ -425,22 +441,100 @@ class SIMPLEFFMPEG {
|
|
|
425
441
|
)
|
|
426
442
|
.map((c) => (typeof c.end === "number" ? c.end : 0));
|
|
427
443
|
const bgOrAudioEnd = audioEnds.length > 0 ? Math.max(...audioEnds) : 0;
|
|
428
|
-
|
|
444
|
+
|
|
445
|
+
// Compute desired timeline end for trailing gap filling.
|
|
446
|
+
// When fillGaps is enabled, extend the video to cover text/audio clips
|
|
447
|
+
// that extend past the last visual clip (e.g. ending with text on black).
|
|
448
|
+
//
|
|
449
|
+
// The trailing gap duration must be precise so the video output ends
|
|
450
|
+
// exactly when the last content ends. Two factors affect this:
|
|
451
|
+
//
|
|
452
|
+
// 1. Transition compensation — when active (default), text timestamps
|
|
453
|
+
// shift left by the cumulative transition overlap, so the target end
|
|
454
|
+
// is the compensated value. When off, use the raw overall end.
|
|
455
|
+
//
|
|
456
|
+
// 2. Existing gaps — leading/middle gaps that will also be filled add
|
|
457
|
+
// to the video output but are NOT included in totalVideoDuration.
|
|
458
|
+
// We must subtract them so the trailing gap isn't oversized.
|
|
459
|
+
let timelineEnd;
|
|
460
|
+
if (this.options.fillGaps !== "none" && videoClips.length > 0) {
|
|
461
|
+
const visualEnd = Math.max(...videoClips.map((c) => c.end || 0));
|
|
462
|
+
const overallEnd = Math.max(visualEnd, textEnd, bgOrAudioEnd);
|
|
463
|
+
if (overallEnd - visualEnd > 1e-3) {
|
|
464
|
+
// Target output duration depends on whether text is compensated
|
|
465
|
+
let desiredOutputDuration;
|
|
466
|
+
if (exportOptions.compensateTransitions && videoClips.length > 1) {
|
|
467
|
+
const transitionOverlap = this._getTransitionOffsetAt(
|
|
468
|
+
videoClips,
|
|
469
|
+
overallEnd
|
|
470
|
+
);
|
|
471
|
+
desiredOutputDuration = overallEnd - transitionOverlap;
|
|
472
|
+
} else {
|
|
473
|
+
desiredOutputDuration = overallEnd;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Account for existing gaps (leading + middle) that will also be
|
|
477
|
+
// filled — these add to the video output but aren't reflected in
|
|
478
|
+
// totalVideoDuration (which only sums clip durations − transitions).
|
|
479
|
+
const existingGaps = detectVisualGaps(videoClips);
|
|
480
|
+
const existingGapDuration = existingGaps.reduce(
|
|
481
|
+
(sum, g) => sum + g.duration,
|
|
482
|
+
0
|
|
483
|
+
);
|
|
484
|
+
const videoOutputBeforeTrailing =
|
|
485
|
+
totalVideoDuration + existingGapDuration;
|
|
486
|
+
|
|
487
|
+
const trailingGapDuration =
|
|
488
|
+
desiredOutputDuration - videoOutputBeforeTrailing;
|
|
489
|
+
if (trailingGapDuration > 1e-3) {
|
|
490
|
+
timelineEnd = visualEnd + trailingGapDuration;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let finalVisualEnd =
|
|
429
496
|
videoClips.length > 0
|
|
430
497
|
? Math.max(...videoClips.map((c) => c.end))
|
|
431
498
|
: Math.max(textEnd, bgOrAudioEnd);
|
|
432
499
|
|
|
433
500
|
// Build video filter
|
|
434
501
|
if (videoClips.length > 0) {
|
|
435
|
-
const vres = buildVideoFilter(this, videoClips);
|
|
502
|
+
const vres = buildVideoFilter(this, videoClips, { timelineEnd });
|
|
436
503
|
filterComplex += vres.filter;
|
|
437
504
|
finalVideoLabel = vres.finalVideoLabel;
|
|
438
505
|
hasVideo = vres.hasVideo;
|
|
506
|
+
|
|
507
|
+
// Update durations to account for gap fills (including trailing gaps).
|
|
508
|
+
// videoDuration reflects the actual output length of the video filter
|
|
509
|
+
// chain, which includes any black gap-fill clips.
|
|
510
|
+
if (typeof vres.videoDuration === "number" && vres.videoDuration > 0) {
|
|
511
|
+
totalVideoDuration = vres.videoDuration;
|
|
512
|
+
}
|
|
513
|
+
// Use the actual video output length for finalVisualEnd so that
|
|
514
|
+
// audio trim and BGM duration match the real video stream length,
|
|
515
|
+
// rather than an original-timeline position that may differ due to
|
|
516
|
+
// transition compression.
|
|
517
|
+
if (
|
|
518
|
+
typeof vres.videoDuration === "number" &&
|
|
519
|
+
vres.videoDuration > finalVisualEnd
|
|
520
|
+
) {
|
|
521
|
+
finalVisualEnd = vres.videoDuration;
|
|
522
|
+
}
|
|
439
523
|
}
|
|
440
524
|
|
|
441
525
|
// Audio for video clips (aligned amix)
|
|
526
|
+
// Compute cumulative transition offsets so audio adelay values
|
|
527
|
+
// match the xfade-compressed video timeline.
|
|
442
528
|
if (videoClips.length > 0) {
|
|
443
|
-
const
|
|
529
|
+
const transitionOffsets = new Map();
|
|
530
|
+
let cumOffset = 0;
|
|
531
|
+
for (let i = 0; i < videoClips.length; i++) {
|
|
532
|
+
if (i > 0 && videoClips[i].transition) {
|
|
533
|
+
cumOffset += videoClips[i].transition.duration || 0;
|
|
534
|
+
}
|
|
535
|
+
transitionOffsets.set(videoClips[i], cumOffset);
|
|
536
|
+
}
|
|
537
|
+
const ares = buildAudioForVideoClips(this, videoClips, transitionOffsets);
|
|
444
538
|
filterComplex += ares.filter;
|
|
445
539
|
finalAudioLabel = ares.finalAudioLabel || finalAudioLabel;
|
|
446
540
|
hasAudio = hasAudio || ares.hasAudio;
|
|
@@ -1109,7 +1203,7 @@ class SIMPLEFFMPEG {
|
|
|
1109
1203
|
* @param {Array} clips - Array of clip objects to validate
|
|
1110
1204
|
* @param {Object} options - Validation options
|
|
1111
1205
|
* @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI)
|
|
1112
|
-
* @param {string} options.fillGaps - Gap handling ('none'
|
|
1206
|
+
* @param {string|boolean} options.fillGaps - Gap handling ('none'/false to disable, or any valid FFmpeg color) - affects gap validation
|
|
1113
1207
|
* @returns {Object} Validation result { valid, errors, warnings }
|
|
1114
1208
|
*
|
|
1115
1209
|
* @example
|