simple-ffmpegjs 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-ffmpegjs",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Declarative video composition for Node.js — define clips, transitions, text, and audio as simple objects, and let FFmpeg handle the rest.",
5
5
  "author": "Brayden Blackwell <braydenblackwell21@gmail.com> (https://github.com/Fats403)",
6
6
  "license": "MIT",
@@ -1,4 +1,5 @@
1
1
  const fs = require("fs");
2
+ const nodePath = require("path");
2
3
  const { detectVisualGaps } = require("./gaps");
3
4
 
4
5
  // ========================================================================
@@ -118,6 +119,62 @@ const EFFECT_TYPES = [
118
119
  "letterbox",
119
120
  ];
120
121
 
122
+ const VIDEO_EXTENSIONS = new Set([
123
+ ".mp4",
124
+ ".mov",
125
+ ".m4v",
126
+ ".mkv",
127
+ ".webm",
128
+ ".avi",
129
+ ".flv",
130
+ ".wmv",
131
+ ".mpg",
132
+ ".mpeg",
133
+ ".m2ts",
134
+ ".mts",
135
+ ".ts",
136
+ ".3gp",
137
+ ".ogv",
138
+ ]);
139
+
140
+ const IMAGE_EXTENSIONS = new Set([
141
+ ".jpg",
142
+ ".jpeg",
143
+ ".png",
144
+ ".webp",
145
+ ".bmp",
146
+ ".tif",
147
+ ".tiff",
148
+ ".gif",
149
+ ".avif",
150
+ ]);
151
+
152
+ function validateMediaUrlExtension(clip, clipPath, errors) {
153
+ if (typeof clip.url !== "string" || clip.url.length === 0) {
154
+ return;
155
+ }
156
+
157
+ if (clip.type !== "video" && clip.type !== "image") {
158
+ return;
159
+ }
160
+
161
+ const ext = nodePath.extname(clip.url).toLowerCase();
162
+ const expectedExts = clip.type === "video" ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
163
+ const expectedLabel = clip.type === "video" ? "video" : "image";
164
+ const oppositeLabel = clip.type === "video" ? "image" : "video";
165
+
166
+ if (!ext || !expectedExts.has(ext)) {
167
+ errors.push(
168
+ createIssue(
169
+ ValidationCodes.INVALID_FORMAT,
170
+ `${clipPath}.url`,
171
+ `URL extension '${ext || "(none)"}' does not match clip type '${clip.type}'. Expected a ${expectedLabel} file extension, not ${oppositeLabel}.`,
172
+ clip.url
173
+ )
174
+ );
175
+ }
176
+ }
177
+
121
178
  function validateFiniteNumber(value, path, errors, opts = {}) {
122
179
  const { min = null, max = null, minInclusive = true, maxInclusive = true } = opts;
123
180
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -546,6 +603,8 @@ function validateClip(clip, index, options = {}) {
546
603
  } catch (_) {}
547
604
  }
548
605
 
606
+ validateMediaUrlExtension(clip, path, errors);
607
+
549
608
  if (typeof clip.cutFrom === "number") {
550
609
  if (!Number.isFinite(clip.cutFrom)) {
551
610
  errors.push(
@@ -1,5 +1,6 @@
1
1
  const os = require("os");
2
2
  const { escapeFilePath } = require("./strings");
3
+ const { SimpleffmpegError } = require("../core/errors");
3
4
 
4
5
  /**
5
6
  * Get the null device path for the current platform
@@ -258,10 +259,62 @@ function escapeMetadata(value) {
258
259
  .replace(/\n/g, "\\n");
259
260
  }
260
261
 
262
+ /**
263
+ * Sanitize a filter_complex string before passing it to FFmpeg.
264
+ *
265
+ * Guards against:
266
+ * - Trailing semicolons that create empty filter chains (some FFmpeg builds
267
+ * reject these with "No such filter: ''").
268
+ * - Double (or more) semicolons that produce empty chains between real ones.
269
+ * - Completely empty filter names between pad labels, e.g. "[a][b]" with no
270
+ * filter name — detected and surfaced as a descriptive error.
271
+ */
272
+ function sanitizeFilterComplex(fc) {
273
+ if (!fc || typeof fc !== "string") return fc;
274
+
275
+ // Collapse runs of semicolons (;;; → ;) that would produce empty chains
276
+ let sanitized = fc.replace(/;{2,}/g, ";");
277
+
278
+ // Strip leading/trailing semicolons
279
+ sanitized = sanitized.replace(/^;+/, "").replace(/;+$/, "");
280
+
281
+ // Detect empty filter names: a closing ']' immediately followed by an
282
+ // opening '[' with no filter name in between (at a chain boundary).
283
+ // Valid patterns like "[a][b]xfade=..." have a filter name after the
284
+ // second label. An empty name looks like "[a][b];" or "[a][b]," or
285
+ // "[a];[b]" at the very start of a chain.
286
+ //
287
+ // We check for the pattern: ';' followed by optional whitespace then '['
288
+ // where the preceding chain segment has no filter name.
289
+ // Also check for label sequences with no filter: "][" not followed by an
290
+ // alphanumeric filter name within the same chain segment.
291
+ const chains = sanitized.split(";");
292
+ for (let i = 0; i < chains.length; i++) {
293
+ const chain = chains[i].trim();
294
+ if (!chain) continue;
295
+
296
+ // Remove all pad labels to see if there's an actual filter name left
297
+ const withoutLabels = chain.replace(/\[[^\]]*\]/g, "").trim();
298
+ // After removing labels, what's left should start with a filter name
299
+ // (alphabetic). If it's empty or starts with ',' or '=' that means
300
+ // a filter name is missing.
301
+ if (withoutLabels.length === 0) {
302
+ throw new SimpleffmpegError(
303
+ `Empty filter name detected in filter_complex chain segment ${i}: "${chain}". ` +
304
+ `This usually means an effect or transition is not producing a valid FFmpeg filter. ` +
305
+ `Full filter_complex (truncated): "${sanitized.slice(0, 500)}..."`
306
+ );
307
+ }
308
+ }
309
+
310
+ return sanitized;
311
+ }
312
+
261
313
  module.exports = {
262
314
  buildMainCommand,
263
315
  buildTextBatchCommand,
264
316
  buildThumbnailCommand,
265
317
  buildSnapshotCommand,
266
318
  escapeMetadata,
319
+ sanitizeFilterComplex,
267
320
  };
@@ -155,6 +155,19 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
155
155
  );
156
156
  }
157
157
 
158
+ /**
159
+ * Extract the FFmpeg filter name from a filter segment string.
160
+ * Expected format: "[inputLabel]filterName=params[outputLabel];"
161
+ * Returns the filter name or an empty string if it can't be found.
162
+ */
163
+ function extractFilterName(filterSegment) {
164
+ // Strip leading pad labels like [fxsrc0]
165
+ const withoutLabels = filterSegment.replace(/\[[^\]]*\]/g, "");
166
+ // The filter name is the first word before '=' or ',' or ';' or end-of-string
167
+ const match = withoutLabels.match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
168
+ return match ? match[1] : "";
169
+ }
170
+
158
171
  function buildEffectFilters(effectClips, inputLabel) {
159
172
  if (!Array.isArray(effectClips) || effectClips.length === 0) {
160
173
  return { filter: "", finalVideoLabel: inputLabel };
@@ -190,6 +203,20 @@ function buildEffectFilters(effectClips, inputLabel) {
190
203
  procSrcLabel,
191
204
  fxLabel
192
205
  );
206
+
207
+ // Safeguard: verify the filter builder produced a non-empty filter name.
208
+ // If any effect resolves to an empty filter (e.g. unsupported on the
209
+ // running FFmpeg version), FFmpeg would receive '' as a filter name and
210
+ // fail with "No such filter: ''".
211
+ const filterName = extractFilterName(fxFilter);
212
+ if (!filterName) {
213
+ throw new Error(
214
+ `Effect '${clip.effect}' produced an empty filter name. ` +
215
+ `This usually means the effect is not supported by the current FFmpeg version. ` +
216
+ `Generated filter segment: ${JSON.stringify(fxFilter)}`
217
+ );
218
+ }
219
+
193
220
  filter += fxFilter;
194
221
 
195
222
  const start = formatNumber(clip.position || 0, 4);
@@ -217,4 +244,4 @@ function buildEffectFilters(effectClips, inputLabel) {
217
244
  return { filter, finalVideoLabel: currentLabel };
218
245
  }
219
246
 
220
- module.exports = { buildEffectFilters };
247
+ module.exports = { buildEffectFilters, extractFilterName };
@@ -30,6 +30,7 @@ const {
30
30
  buildMainCommand,
31
31
  buildThumbnailCommand,
32
32
  buildSnapshotCommand,
33
+ sanitizeFilterComplex,
33
34
  } = require("./ffmpeg/command_builder");
34
35
  const { runTextPasses } = require("./ffmpeg/text_passes");
35
36
  const { formatBytes, runFFmpeg } = require("./lib/utils");
@@ -799,9 +800,12 @@ class SIMPLEFFMPEG {
799
800
 
800
801
  const wmConfig = exportOptions.watermark;
801
802
 
802
- // For image watermarks, we need to add an input
803
+ // For image watermarks, we need to add an input.
804
+ // Use the actual file input count (from _inputIndexMap) rather than
805
+ // videoOrAudioClips.length, because flat color clips use the color=
806
+ // filter source and don't produce file inputs.
803
807
  if (wmConfig.type === "image" && wmConfig.url) {
804
- watermarkInputIndex = this.videoOrAudioClips.length;
808
+ watermarkInputIndex = this._inputIndexMap.size;
805
809
  watermarkInputString = ` -i "${escapeFilePath(wmConfig.url)}"`;
806
810
  }
807
811
 
@@ -830,6 +834,12 @@ class SIMPLEFFMPEG {
830
834
  }
831
835
  }
832
836
 
837
+ // Sanitize the filter complex string before passing to FFmpeg.
838
+ // Remove trailing semicolons (which create empty filter chains on some
839
+ // FFmpeg builds) and collapse double semicolons that could result from
840
+ // concatenating builder outputs where one returned an empty string.
841
+ filterComplex = sanitizeFilterComplex(filterComplex);
842
+
833
843
  // Build command
834
844
  const command = buildMainCommand({
835
845
  inputs: this._getInputStreams() + watermarkInputString,