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 +1 -1
- package/src/core/validation.js +59 -0
- package/src/ffmpeg/command_builder.js +53 -0
- package/src/ffmpeg/effect_builder.js +28 -1
- package/src/simpleffmpeg.js +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simple-ffmpegjs",
|
|
3
|
-
"version": "0.4.
|
|
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",
|
package/src/core/validation.js
CHANGED
|
@@ -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 };
|
package/src/simpleffmpeg.js
CHANGED
|
@@ -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.
|
|
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,
|