simple-ffmpegjs 0.3.5 → 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.
@@ -1,19 +1,203 @@
1
1
  const { detectVisualGaps } = require("../core/gaps");
2
2
 
3
3
  /**
4
- * Create synthetic black clips to fill visual gaps
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 createBlackClipsForGaps(gaps, fps, width, height) {
8
+ function createGapFillClips(gaps) {
7
9
  return gaps.map((gap, index) => ({
8
- type: "_black",
10
+ type: "_gapfill",
9
11
  position: gap.start,
10
12
  end: gap.end,
11
13
  _gapIndex: index,
12
- _isBlackFill: true,
14
+ _isGapFill: true,
13
15
  }));
14
16
  }
15
17
 
16
- function buildVideoFilter(project, videoClips) {
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 === "black") {
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 blackClips = createBlackClipsForGaps(gaps, fps, width, height);
31
- allVisualClips = [...videoClips, ...blackClips].sort(
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 black fill clips
48
- if (clip._isBlackFill) {
49
- // Generate a black color source for the gap duration
50
- filterComplex += `color=c=black:s=${width}x${height}:d=${requestedDuration},fps=${fps},settb=1/${fps}${scaledLabel};`;
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
- // Use overscan pre-scale + center crop, then zoompan with center-based x/y
75
- const overscanW = Math.max(width * 3, 4000);
76
- const kb = clip.kenBurns;
77
- const type = typeof kb === "string" ? kb : kb.type;
78
- // Simplified fixed-intensity zoom. API no longer exposes strength.
79
- const zoomAmount = 0.15;
80
-
81
- let zoomExpr = `1`;
82
- let xExpr = `round(iw/2 - (iw/zoom)/2)`;
83
- let yExpr = `round(ih/2 - (ih/zoom)/2)`;
84
-
85
- if (type === "zoom-in") {
86
- const inc = (zoomAmount / framesMinusOne).toFixed(6);
87
- // Ensure first frame starts exactly at base zoom=1 (on==0)
88
- zoomExpr = `if(eq(on,0),1,zoom+${inc})`;
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},fps=${fps},settb=1/${fps}${scaledLabel};`;
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 clip = { ...clipObj, hasAudio: false, cutFrom: 0 };
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"; // 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
- kenBurns?: KenBurnsEffect; // Optional: apply pan/zoom motion to the image
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
  };
@@ -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' or 'black' (default: '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: options.fillGaps || "none", // 'none' | 'black'
106
+ fillGaps: fillGapsResult.color, // "none" | valid FFmpeg color
91
107
  preset: options.preset || null,
92
108
  };
93
109
  this.videoOrAudioClips = [];
@@ -397,7 +413,7 @@ class SIMPLEFFMPEG {
397
413
  let hasVideo = false;
398
414
  let hasAudio = false;
399
415
 
400
- const totalVideoDuration = (() => {
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,17 +441,85 @@ 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
- const finalVisualEnd =
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)
@@ -1119,7 +1203,7 @@ class SIMPLEFFMPEG {
1119
1203
  * @param {Array} clips - Array of clip objects to validate
1120
1204
  * @param {Object} options - Validation options
1121
1205
  * @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI)
1122
- * @param {string} options.fillGaps - Gap handling ('none' | 'black') - affects gap validation
1206
+ * @param {string|boolean} options.fillGaps - Gap handling ('none'/false to disable, or any valid FFmpeg color) - affects gap validation
1123
1207
  * @returns {Object} Validation result { valid, errors, warnings }
1124
1208
  *
1125
1209
  * @example
package/types/index.d.mts CHANGED
@@ -88,16 +88,37 @@ declare namespace SIMPLEFFMPEG {
88
88
  loop?: boolean;
89
89
  }
90
90
 
91
+ type KenBurnsEffect =
92
+ | "zoom-in"
93
+ | "zoom-out"
94
+ | "pan-left"
95
+ | "pan-right"
96
+ | "pan-up"
97
+ | "pan-down"
98
+ | "smart"
99
+ | "custom";
100
+
101
+ type KenBurnsAnchor = "top" | "bottom" | "left" | "right";
102
+ type KenBurnsEasing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
103
+
104
+ interface KenBurnsSpec {
105
+ type?: KenBurnsEffect;
106
+ startZoom?: number;
107
+ endZoom?: number;
108
+ startX?: number;
109
+ startY?: number;
110
+ endX?: number;
111
+ endY?: number;
112
+ anchor?: KenBurnsAnchor;
113
+ easing?: KenBurnsEasing;
114
+ }
115
+
91
116
  interface ImageClip extends BaseClip {
92
117
  type: "image";
93
118
  url: string;
94
- kenBurns?:
95
- | "zoom-in"
96
- | "zoom-out"
97
- | "pan-left"
98
- | "pan-right"
99
- | "pan-up"
100
- | "pan-down";
119
+ width?: number;
120
+ height?: number;
121
+ kenBurns?: KenBurnsEffect | KenBurnsSpec;
101
122
  }
102
123
 
103
124
  type TextMode = "static" | "word-replace" | "word-sequential" | "karaoke";
@@ -276,8 +297,8 @@ declare namespace SIMPLEFFMPEG {
276
297
  interface ValidateOptions {
277
298
  /** Skip file existence checks (useful for AI generating configs before files exist) */
278
299
  skipFileChecks?: boolean;
279
- /** Gap handling mode - affects timeline gap validation */
280
- fillGaps?: "none" | "black";
300
+ /** Gap handling mode - affects timeline gap validation. Any valid FFmpeg color, or "none"/false to disable. */
301
+ fillGaps?: "none" | string | boolean;
281
302
  /** Project width - used to validate Ken Burns images are large enough */
282
303
  width?: number;
283
304
  /** Project height - used to validate Ken Burns images are large enough */
@@ -297,8 +318,8 @@ declare namespace SIMPLEFFMPEG {
297
318
  height?: number;
298
319
  /** Validation mode: 'warn' logs warnings, 'strict' throws on warnings (default: 'warn') */
299
320
  validationMode?: "warn" | "strict";
300
- /** How to handle visual gaps: 'none' throws error, 'black' fills with black frames (default: 'none') */
301
- fillGaps?: "none" | "black";
321
+ /** How to handle visual gaps: 'none'/false (disabled), true/'black' (black fill), or any valid FFmpeg color name/hex (default: 'none') */
322
+ fillGaps?: "none" | string | boolean;
302
323
  }
303
324
 
304
325
  /** Log entry passed to onLog callback */