simple-ffmpegjs 0.3.2 → 0.3.4
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 +195 -35
- package/package.json +1 -1
- package/src/core/errors.js +14 -0
- package/src/core/media_info.js +127 -90
- package/src/core/resolve.js +96 -0
- package/src/core/validation.js +43 -0
- package/src/ffmpeg/command_builder.js +40 -0
- package/src/ffmpeg/strings.js +33 -16
- package/src/ffmpeg/subtitle_builder.js +2 -2
- package/src/ffmpeg/text_passes.js +16 -3
- package/src/ffmpeg/text_renderer.js +1 -1
- package/src/ffmpeg/video_builder.js +8 -6
- package/src/ffmpeg/watermark_builder.js +8 -20
- package/src/lib/utils.js +28 -6
- package/src/loaders.js +13 -11
- package/src/schema/formatter.js +6 -0
- package/src/schema/modules/audio.js +5 -2
- package/src/schema/modules/image.js +12 -5
- package/src/schema/modules/music.js +2 -1
- package/src/schema/modules/subtitle.js +2 -1
- package/src/schema/modules/text.js +3 -1
- package/src/schema/modules/video.js +14 -4
- package/src/simpleffmpeg.js +185 -5
- package/types/index.d.mts +135 -2
- package/types/index.d.ts +135 -2
package/src/core/validation.js
CHANGED
|
@@ -79,6 +79,49 @@ function validateClip(clip, index, options = {}) {
|
|
|
79
79
|
return { errors, warnings }; // Can't validate further with invalid type
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Validate duration field if present (applies to all clip types)
|
|
83
|
+
if (clip.duration != null) {
|
|
84
|
+
if (typeof clip.duration !== "number") {
|
|
85
|
+
errors.push(
|
|
86
|
+
createIssue(
|
|
87
|
+
ValidationCodes.INVALID_VALUE,
|
|
88
|
+
`${path}.duration`,
|
|
89
|
+
"Duration must be a number",
|
|
90
|
+
clip.duration
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
} else if (!Number.isFinite(clip.duration)) {
|
|
94
|
+
errors.push(
|
|
95
|
+
createIssue(
|
|
96
|
+
ValidationCodes.INVALID_VALUE,
|
|
97
|
+
`${path}.duration`,
|
|
98
|
+
"Duration must be a finite number (not NaN or Infinity)",
|
|
99
|
+
clip.duration
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
} else if (clip.duration <= 0) {
|
|
103
|
+
errors.push(
|
|
104
|
+
createIssue(
|
|
105
|
+
ValidationCodes.INVALID_RANGE,
|
|
106
|
+
`${path}.duration`,
|
|
107
|
+
"Duration must be greater than 0",
|
|
108
|
+
clip.duration
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
// Conflict check: duration + end both set
|
|
113
|
+
if (clip.end != null) {
|
|
114
|
+
errors.push(
|
|
115
|
+
createIssue(
|
|
116
|
+
ValidationCodes.INVALID_VALUE,
|
|
117
|
+
`${path}`,
|
|
118
|
+
"Cannot specify both 'duration' and 'end'. Use one or the other.",
|
|
119
|
+
{ duration: clip.duration, end: clip.end }
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
82
125
|
// Types that require position/end on timeline
|
|
83
126
|
const requiresTimeline = ["video", "audio", "text", "image"].includes(
|
|
84
127
|
clip.type
|
|
@@ -209,6 +209,45 @@ function buildThumbnailCommand({ inputPath, outputPath, time, width, height }) {
|
|
|
209
209
|
return cmd;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Build command to capture a single frame from a video file.
|
|
214
|
+
* Output format is determined by the outputPath extension (jpg, png, webp, etc.).
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} options
|
|
217
|
+
* @param {string} options.inputPath - Path to source video
|
|
218
|
+
* @param {string} options.outputPath - Output image path (extension determines format)
|
|
219
|
+
* @param {number} [options.time=0] - Time in seconds to capture
|
|
220
|
+
* @param {number} [options.width] - Output width (maintains aspect if height omitted)
|
|
221
|
+
* @param {number} [options.height] - Output height (maintains aspect if width omitted)
|
|
222
|
+
* @param {number} [options.quality] - JPEG quality 1-31, lower is better (only applies to JPEG)
|
|
223
|
+
* @returns {string} FFmpeg command string
|
|
224
|
+
*/
|
|
225
|
+
function buildSnapshotCommand({
|
|
226
|
+
inputPath,
|
|
227
|
+
outputPath,
|
|
228
|
+
time = 0,
|
|
229
|
+
width,
|
|
230
|
+
height,
|
|
231
|
+
quality,
|
|
232
|
+
}) {
|
|
233
|
+
let cmd = `ffmpeg -y -ss ${time} -i "${escapeFilePath(
|
|
234
|
+
inputPath
|
|
235
|
+
)}" -vframes 1 `;
|
|
236
|
+
|
|
237
|
+
if (width || height) {
|
|
238
|
+
const w = width || -1;
|
|
239
|
+
const h = height || -1;
|
|
240
|
+
cmd += `-vf "scale=${w}:${h}" `;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (quality != null) {
|
|
244
|
+
cmd += `-q:v ${quality} `;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
cmd += `"${escapeFilePath(outputPath)}"`;
|
|
248
|
+
return cmd;
|
|
249
|
+
}
|
|
250
|
+
|
|
212
251
|
/**
|
|
213
252
|
* Escape metadata value for FFmpeg
|
|
214
253
|
*/
|
|
@@ -223,5 +262,6 @@ module.exports = {
|
|
|
223
262
|
buildMainCommand,
|
|
224
263
|
buildTextBatchCommand,
|
|
225
264
|
buildThumbnailCommand,
|
|
265
|
+
buildSnapshotCommand,
|
|
226
266
|
escapeMetadata,
|
|
227
267
|
};
|
package/src/ffmpeg/strings.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
function escapeSingleQuotes(text) {
|
|
2
|
-
return String(text).replace(/'/g, "\\'");
|
|
3
|
-
}
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
2
|
* Escape a file path for use in FFmpeg command line arguments.
|
|
7
3
|
* Prevents command injection by escaping quotes and backslashes.
|
|
@@ -21,32 +17,54 @@ function escapeFilePath(filePath) {
|
|
|
21
17
|
*/
|
|
22
18
|
function hasProblematicChars(text) {
|
|
23
19
|
if (typeof text !== "string") return false;
|
|
24
|
-
// These characters cannot be reliably escaped in filter_complex parsing
|
|
25
|
-
//
|
|
26
|
-
|
|
20
|
+
// These characters cannot be reliably escaped in filter_complex parsing.
|
|
21
|
+
// Single quotes (') are included because FFmpeg's av_get_token does NOT
|
|
22
|
+
// support \' inside single-quoted strings — ' always ends the quoted value.
|
|
23
|
+
// Non-ASCII characters are also routed to textfile to avoid parser issues
|
|
24
|
+
// with UTF-8 inside filter_complex.
|
|
25
|
+
return /[,;{}\[\]"']/.test(text) || /[^\x20-\x7E]/.test(text);
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
function escapeDrawtextText(text) {
|
|
30
29
|
if (typeof text !== "string") return "";
|
|
31
|
-
// Escape characters that have special meaning in FFmpeg drawtext filter
|
|
32
|
-
//
|
|
30
|
+
// Escape characters that have special meaning in FFmpeg drawtext filter.
|
|
31
|
+
//
|
|
32
|
+
// FFmpeg parses the filter_complex value through TWO levels of av_get_token:
|
|
33
|
+
// Level 1 — filter-graph parser (terminators: [ , ; \n)
|
|
34
|
+
// Level 2 — filter option parser (terminators: : = )
|
|
35
|
+
// Both levels handle '...' quoting and \x escaping identically.
|
|
36
|
+
//
|
|
37
|
+
// To embed a literal single quote that survives both levels:
|
|
38
|
+
// 1. End the current level-1 quoted segment: '
|
|
39
|
+
// 2. \\ — level 1 escape → produces \ in output
|
|
40
|
+
// 3. \' — level 1 escape → produces ' in output
|
|
41
|
+
// So level 2 sees \' → escaped quote → literal '
|
|
42
|
+
// 4. Re-open a new level-1 quoted segment: '
|
|
43
|
+
// The 6-char replacement per apostrophe is: '\\\''
|
|
44
|
+
//
|
|
45
|
+
// Backslash (\\) and colon (\:) escaping inside the quoted segments is
|
|
46
|
+
// passed through literally by level 1 (no escape processing inside '...'),
|
|
47
|
+
// then processed by level 2 as escape sequences: \\ → \ and \: → :
|
|
33
48
|
return text
|
|
34
|
-
.replace(/\\/g, "\\\\") // Escape backslashes
|
|
35
|
-
.replace(/'/g, "
|
|
36
|
-
.replace(
|
|
37
|
-
.replace(/:/g, "\\:") // Escape colons (option separator)
|
|
49
|
+
.replace(/\\/g, "\\\\") // Escape backslashes (level 2 decodes \\ → \)
|
|
50
|
+
.replace(/'/g, "'\\\\\\''" ) // End quote, \\' (two-level escape), re-open quote
|
|
51
|
+
.replace(/:/g, "\\:") // Escape colons (level 2 decodes \: → :)
|
|
38
52
|
.replace(/\n/g, " ") // Replace newlines with space (multiline not supported)
|
|
39
53
|
.replace(/\r/g, ""); // Remove carriage returns
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
/**
|
|
43
|
-
* Escape a file path for use
|
|
57
|
+
* Escape a file path for use inside single-quoted FFmpeg filter parameters
|
|
58
|
+
* (e.g., textfile='...', ass='...').
|
|
59
|
+
*
|
|
60
|
+
* These paths are parsed through two levels of av_get_token, so single
|
|
61
|
+
* quotes need the same '\\\'' two-level escape as drawtext text values.
|
|
44
62
|
*/
|
|
45
63
|
function escapeTextFilePath(filePath) {
|
|
46
64
|
if (typeof filePath !== "string") return "";
|
|
47
|
-
// FFmpeg on Windows needs forward slashes and escaped colons
|
|
48
65
|
return filePath
|
|
49
66
|
.replace(/\\/g, "/") // Convert backslashes to forward slashes
|
|
67
|
+
.replace(/'/g, "'\\\\\\''" ) // Two-level apostrophe escape (same as escapeDrawtextText)
|
|
50
68
|
.replace(/:/g, "\\:"); // Escape colons (for Windows drive letters)
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -62,7 +80,6 @@ function getClipAudioString(clip, inputIndex) {
|
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
module.exports = {
|
|
65
|
-
escapeSingleQuotes,
|
|
66
83
|
escapeFilePath,
|
|
67
84
|
escapeDrawtextText,
|
|
68
85
|
getClipAudioString,
|
|
@@ -637,11 +637,11 @@ function loadSubtitleFile(filePath, options, canvasWidth, canvasHeight) {
|
|
|
637
637
|
* @returns {{ filter: string, finalLabel: string }}
|
|
638
638
|
*/
|
|
639
639
|
function buildASSFilter(assFilePath, inputLabel) {
|
|
640
|
-
// Escape path for FFmpeg filter
|
|
640
|
+
// Escape path for FFmpeg filter (must survive two levels of av_get_token)
|
|
641
641
|
const escapedPath = assFilePath
|
|
642
642
|
.replace(/\\/g, "/")
|
|
643
643
|
.replace(/:/g, "\\:")
|
|
644
|
-
.replace(/'/g, "'
|
|
644
|
+
.replace(/'/g, "'\\\\\\''" );
|
|
645
645
|
|
|
646
646
|
const outputLabel = "[outass]";
|
|
647
647
|
const filter = `${inputLabel}ass='${escapedPath}'${outputLabel}`;
|
|
@@ -8,10 +8,11 @@ const { parseFFmpegCommand } = require("../lib/utils");
|
|
|
8
8
|
/**
|
|
9
9
|
* Run an FFmpeg command using spawn() to avoid command injection.
|
|
10
10
|
* @param {string} cmd - The full FFmpeg command string
|
|
11
|
+
* @param {Function} [onLog] - Optional log callback receiving { level, message }
|
|
11
12
|
* @returns {Promise<void>}
|
|
12
13
|
* @throws {FFmpegError} If ffmpeg fails
|
|
13
14
|
*/
|
|
14
|
-
function runCmd(cmd) {
|
|
15
|
+
function runCmd(cmd, onLog) {
|
|
15
16
|
return new Promise((resolve, reject) => {
|
|
16
17
|
const args = parseFFmpegCommand(cmd);
|
|
17
18
|
const ffmpegPath = args.shift(); // Remove 'ffmpeg' from args
|
|
@@ -22,8 +23,19 @@ function runCmd(cmd) {
|
|
|
22
23
|
|
|
23
24
|
let stderr = "";
|
|
24
25
|
|
|
26
|
+
proc.stdout.on("data", (data) => {
|
|
27
|
+
const chunk = data.toString();
|
|
28
|
+
if (onLog && typeof onLog === "function") {
|
|
29
|
+
onLog({ level: "stdout", message: chunk });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
25
33
|
proc.stderr.on("data", (data) => {
|
|
26
|
-
|
|
34
|
+
const chunk = data.toString();
|
|
35
|
+
stderr += chunk;
|
|
36
|
+
if (onLog && typeof onLog === "function") {
|
|
37
|
+
onLog({ level: "stderr", message: chunk });
|
|
38
|
+
}
|
|
27
39
|
});
|
|
28
40
|
|
|
29
41
|
proc.on("error", (error) => {
|
|
@@ -61,6 +73,7 @@ async function runTextPasses({
|
|
|
61
73
|
intermediatePreset,
|
|
62
74
|
intermediateCrf,
|
|
63
75
|
batchSize = 75,
|
|
76
|
+
onLog,
|
|
64
77
|
}) {
|
|
65
78
|
const tempOutputs = [];
|
|
66
79
|
let currentInput = baseOutputPath;
|
|
@@ -89,7 +102,7 @@ async function runTextPasses({
|
|
|
89
102
|
intermediateCrf,
|
|
90
103
|
outputPath: batchOutput,
|
|
91
104
|
});
|
|
92
|
-
await runCmd(cmd);
|
|
105
|
+
await runCmd(cmd, onLog);
|
|
93
106
|
currentInput = batchOutput;
|
|
94
107
|
passes += 1;
|
|
95
108
|
}
|
|
@@ -147,7 +147,7 @@ function buildDrawtextParams(
|
|
|
147
147
|
end
|
|
148
148
|
) {
|
|
149
149
|
const fontSpec = baseClip.fontFile
|
|
150
|
-
? `fontfile
|
|
150
|
+
? `fontfile='${Strings.escapeTextFilePath(baseClip.fontFile)}'`
|
|
151
151
|
: baseClip.fontFamily
|
|
152
152
|
? `font=${baseClip.fontFamily}`
|
|
153
153
|
: `font=Sans`;
|
|
@@ -29,7 +29,7 @@ function buildVideoFilter(project, videoClips) {
|
|
|
29
29
|
if (gaps.length > 0) {
|
|
30
30
|
const blackClips = createBlackClipsForGaps(gaps, fps, width, height);
|
|
31
31
|
allVisualClips = [...videoClips, ...blackClips].sort(
|
|
32
|
-
(a, b) => (a.position || 0) - (b.position || 0)
|
|
32
|
+
(a, b) => (a.position || 0) - (b.position || 0),
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -41,7 +41,7 @@ function buildVideoFilter(project, videoClips) {
|
|
|
41
41
|
|
|
42
42
|
const requestedDuration = Math.max(
|
|
43
43
|
0,
|
|
44
|
-
(clip.end || 0) - (clip.position || 0)
|
|
44
|
+
(clip.end || 0) - (clip.position || 0),
|
|
45
45
|
);
|
|
46
46
|
|
|
47
47
|
// Handle synthetic black fill clips
|
|
@@ -85,12 +85,12 @@ function buildVideoFilter(project, videoClips) {
|
|
|
85
85
|
if (type === "zoom-in") {
|
|
86
86
|
const inc = (zoomAmount / framesMinusOne).toFixed(6);
|
|
87
87
|
// Ensure first frame starts exactly at base zoom=1 (on==0)
|
|
88
|
-
zoomExpr = `if(eq(on
|
|
88
|
+
zoomExpr = `if(eq(on,0),1,zoom+${inc})`;
|
|
89
89
|
} else if (type === "zoom-out") {
|
|
90
90
|
const start = (1 + zoomAmount).toFixed(4);
|
|
91
91
|
const dec = (zoomAmount / framesMinusOne).toFixed(6);
|
|
92
92
|
// Start pre-zoomed on first frame to avoid jump
|
|
93
|
-
zoomExpr = `if(eq(on
|
|
93
|
+
zoomExpr = `if(eq(on,0),${start},zoom-${dec})`;
|
|
94
94
|
} else {
|
|
95
95
|
const panZoom = 1.12;
|
|
96
96
|
zoomExpr = `${panZoom}`;
|
|
@@ -113,7 +113,9 @@ function buildVideoFilter(project, videoClips) {
|
|
|
113
113
|
|
|
114
114
|
// Scale to cover target dimensions (upscaling if needed), then center crop
|
|
115
115
|
// force_original_aspect_ratio=increase ensures image covers the target area
|
|
116
|
-
|
|
116
|
+
// select='eq(n,0)' uses single quotes to protect the comma from the graph parser.
|
|
117
|
+
// 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};`;
|
|
117
119
|
} else {
|
|
118
120
|
filterComplex += `[${inputIndex}:v]trim=start=${
|
|
119
121
|
clip.cutFrom || 0
|
|
@@ -134,7 +136,7 @@ function buildVideoFilter(project, videoClips) {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
const hasTransitions = scaledStreams.some(
|
|
137
|
-
(s, i) => i > 0 && s.clip.transition
|
|
139
|
+
(s, i) => i > 0 && s.clip.transition,
|
|
138
140
|
);
|
|
139
141
|
|
|
140
142
|
if (!hasTransitions) {
|
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
const Strings = require("./strings");
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Position presets for watermarks
|
|
5
|
-
*/
|
|
6
|
-
const POSITION_PRESETS = {
|
|
7
|
-
"top-left": (margin) => ({ x: margin, y: margin }),
|
|
8
|
-
"top-right": (margin) => ({ x: `W-w-${margin}`, y: margin }),
|
|
9
|
-
"bottom-left": (margin) => ({ x: margin, y: `H-h-${margin}` }),
|
|
10
|
-
"bottom-right": (margin) => ({ x: `W-w-${margin}`, y: `H-h-${margin}` }),
|
|
11
|
-
center: () => ({ x: "(W-w)/2", y: "(H-h)/2" }),
|
|
12
|
-
};
|
|
13
|
-
|
|
14
3
|
/**
|
|
15
4
|
* Calculate position expressions for overlay/drawtext
|
|
16
5
|
* @param {Object} config - Watermark config with position, margin
|
|
@@ -91,7 +80,7 @@ function buildImageWatermark(
|
|
|
91
80
|
watermarkInputIndex,
|
|
92
81
|
canvasWidth,
|
|
93
82
|
canvasHeight,
|
|
94
|
-
totalDuration
|
|
83
|
+
totalDuration,
|
|
95
84
|
) {
|
|
96
85
|
const scale = typeof config.scale === "number" ? config.scale : 0.15;
|
|
97
86
|
const opacity = typeof config.opacity === "number" ? config.opacity : 1;
|
|
@@ -144,7 +133,7 @@ function buildTextWatermark(
|
|
|
144
133
|
videoLabel,
|
|
145
134
|
canvasWidth,
|
|
146
135
|
canvasHeight,
|
|
147
|
-
totalDuration
|
|
136
|
+
totalDuration,
|
|
148
137
|
) {
|
|
149
138
|
const text = config.text || "";
|
|
150
139
|
const fontSize = typeof config.fontSize === "number" ? config.fontSize : 24;
|
|
@@ -158,9 +147,8 @@ function buildTextWatermark(
|
|
|
158
147
|
// Escape text for drawtext
|
|
159
148
|
const escapedText = Strings.escapeDrawtextText(text);
|
|
160
149
|
|
|
161
|
-
// Font specification
|
|
162
150
|
const fontSpec = config.fontFile
|
|
163
|
-
? `fontfile
|
|
151
|
+
? `fontfile='${Strings.escapeTextFilePath(config.fontFile)}'`
|
|
164
152
|
: `font=${fontFamily}`;
|
|
165
153
|
|
|
166
154
|
// Calculate position (for text)
|
|
@@ -257,7 +245,7 @@ function buildWatermarkFilter(
|
|
|
257
245
|
watermarkInputIndex,
|
|
258
246
|
canvasWidth,
|
|
259
247
|
canvasHeight,
|
|
260
|
-
totalDuration
|
|
248
|
+
totalDuration,
|
|
261
249
|
) {
|
|
262
250
|
if (!config || typeof config !== "object") {
|
|
263
251
|
return { filter: "", finalLabel: videoLabel, needsInput: false };
|
|
@@ -271,7 +259,7 @@ function buildWatermarkFilter(
|
|
|
271
259
|
videoLabel,
|
|
272
260
|
canvasWidth,
|
|
273
261
|
canvasHeight,
|
|
274
|
-
totalDuration
|
|
262
|
+
totalDuration,
|
|
275
263
|
);
|
|
276
264
|
return { ...result, needsInput: false };
|
|
277
265
|
}
|
|
@@ -288,7 +276,7 @@ function buildWatermarkFilter(
|
|
|
288
276
|
watermarkInputIndex,
|
|
289
277
|
canvasWidth,
|
|
290
278
|
canvasHeight,
|
|
291
|
-
totalDuration
|
|
279
|
+
totalDuration,
|
|
292
280
|
);
|
|
293
281
|
return { ...result, needsInput: true };
|
|
294
282
|
}
|
|
@@ -369,8 +357,8 @@ function validateWatermarkConfig(config) {
|
|
|
369
357
|
if (!validPositions.includes(config.position)) {
|
|
370
358
|
errors.push(
|
|
371
359
|
`watermark.position must be one of: ${validPositions.join(
|
|
372
|
-
", "
|
|
373
|
-
)}; got '${config.position}'
|
|
360
|
+
", ",
|
|
361
|
+
)}; got '${config.position}'`,
|
|
374
362
|
);
|
|
375
363
|
}
|
|
376
364
|
} else if (typeof config.position === "object" && config.position !== null) {
|
package/src/lib/utils.js
CHANGED
|
@@ -76,9 +76,10 @@ function parseFFmpegProgress(line, totalDuration) {
|
|
|
76
76
|
* @param {number} options.totalDuration - Expected output duration in seconds (for progress %)
|
|
77
77
|
* @param {Function} options.onProgress - Progress callback
|
|
78
78
|
* @param {AbortSignal} options.signal - AbortSignal for cancellation
|
|
79
|
+
* @param {Function} options.onLog - Log callback receiving { level: "stderr"|"stdout", message: string }
|
|
79
80
|
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
80
81
|
*/
|
|
81
|
-
function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
|
|
82
|
+
function runFFmpeg({ command, totalDuration = 0, onProgress, signal, onLog }) {
|
|
82
83
|
return new Promise((resolve, reject) => {
|
|
83
84
|
// Parse command into args (simple split, assumes no quoted args with spaces in values)
|
|
84
85
|
// FFmpeg commands from this library don't have spaces in quoted paths handled this way
|
|
@@ -115,17 +116,26 @@ function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
proc.stdout.on("data", (data) => {
|
|
118
|
-
|
|
119
|
+
const chunk = data.toString();
|
|
120
|
+
stdout += chunk;
|
|
121
|
+
if (onLog && typeof onLog === "function") {
|
|
122
|
+
onLog({ level: "stdout", message: chunk });
|
|
123
|
+
}
|
|
119
124
|
});
|
|
120
125
|
|
|
121
126
|
proc.stderr.on("data", (data) => {
|
|
122
127
|
const chunk = data.toString();
|
|
123
128
|
stderr += chunk;
|
|
124
129
|
|
|
130
|
+
if (onLog && typeof onLog === "function") {
|
|
131
|
+
onLog({ level: "stderr", message: chunk });
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
// Parse progress from stderr (FFmpeg outputs progress to stderr)
|
|
126
135
|
if (onProgress && typeof onProgress === "function") {
|
|
127
136
|
const progress = parseFFmpegProgress(chunk, totalDuration);
|
|
128
137
|
if (Object.keys(progress).length > 0) {
|
|
138
|
+
progress.phase = "rendering";
|
|
129
139
|
onProgress(progress);
|
|
130
140
|
}
|
|
131
141
|
}
|
|
@@ -163,8 +173,22 @@ function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
|
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
/**
|
|
166
|
-
* Parse a command string into an array of arguments
|
|
167
|
-
*
|
|
176
|
+
* Parse a command string into an array of arguments.
|
|
177
|
+
*
|
|
178
|
+
* Inside quoted strings (single or double):
|
|
179
|
+
* - All characters are literal (no escape processing).
|
|
180
|
+
* - The matching closing quote ends the argument segment.
|
|
181
|
+
*
|
|
182
|
+
* This deliberately avoids backslash-escape handling because the
|
|
183
|
+
* filter_complex value relies on \\, \, and \: being passed through
|
|
184
|
+
* verbatim to FFmpeg. For example drawtext's fontsize expressions
|
|
185
|
+
* use \\, (which drawtext decodes as \, → escaped comma) and text
|
|
186
|
+
* values use \\\\ (which drawtext decodes as \\ → literal backslash).
|
|
187
|
+
* Any unescaping here would corrupt those sequences.
|
|
188
|
+
*
|
|
189
|
+
* Outside quotes:
|
|
190
|
+
* - Whitespace separates arguments.
|
|
191
|
+
* - All other characters (including backslash) are literal.
|
|
168
192
|
*/
|
|
169
193
|
function parseFFmpegCommand(command) {
|
|
170
194
|
const args = [];
|
|
@@ -178,14 +202,12 @@ function parseFFmpegCommand(command) {
|
|
|
178
202
|
if (inQuote) {
|
|
179
203
|
if (char === quoteChar) {
|
|
180
204
|
inQuote = false;
|
|
181
|
-
// Don't add the closing quote to the argument
|
|
182
205
|
} else {
|
|
183
206
|
current += char;
|
|
184
207
|
}
|
|
185
208
|
} else if (char === '"' || char === "'") {
|
|
186
209
|
inQuote = true;
|
|
187
210
|
quoteChar = char;
|
|
188
|
-
// Don't add the opening quote to the argument
|
|
189
211
|
} else if (char === " " || char === "\t") {
|
|
190
212
|
if (current.length > 0) {
|
|
191
213
|
args.push(current);
|
package/src/loaders.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
|
-
const {
|
|
3
|
+
const { probeMedia } = require("./core/media_info");
|
|
4
4
|
const { ValidationError, MediaNotFoundError } = require("./core/errors");
|
|
5
5
|
const C = require("./core/constants");
|
|
6
6
|
|
|
7
7
|
async function loadVideo(project, clipObj) {
|
|
8
|
-
const metadata = await
|
|
9
|
-
if (typeof clipObj.cutFrom === "number" && metadata.
|
|
10
|
-
if (clipObj.cutFrom >= metadata.
|
|
8
|
+
const metadata = await probeMedia(clipObj.url);
|
|
9
|
+
if (typeof clipObj.cutFrom === "number" && metadata.duration != null) {
|
|
10
|
+
if (clipObj.cutFrom >= metadata.duration) {
|
|
11
11
|
throw new ValidationError(
|
|
12
|
-
`Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.
|
|
12
|
+
`Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.duration}s)`,
|
|
13
13
|
{
|
|
14
14
|
errors: [
|
|
15
15
|
{
|
|
@@ -26,10 +26,10 @@ async function loadVideo(project, clipObj) {
|
|
|
26
26
|
typeof clipObj.position === "number" &&
|
|
27
27
|
typeof clipObj.end === "number" &&
|
|
28
28
|
typeof clipObj.cutFrom === "number" &&
|
|
29
|
-
metadata.
|
|
29
|
+
metadata.duration != null
|
|
30
30
|
) {
|
|
31
31
|
const requestedDuration = Math.max(0, clipObj.end - clipObj.position);
|
|
32
|
-
const maxAvailable = Math.max(0, metadata.
|
|
32
|
+
const maxAvailable = Math.max(0, metadata.duration - clipObj.cutFrom);
|
|
33
33
|
if (requestedDuration > maxAvailable) {
|
|
34
34
|
const clampedEnd = clipObj.position + maxAvailable;
|
|
35
35
|
console.warn(
|
|
@@ -42,14 +42,15 @@ async function loadVideo(project, clipObj) {
|
|
|
42
42
|
}
|
|
43
43
|
project.videoOrAudioClips.push({
|
|
44
44
|
...clipObj,
|
|
45
|
-
iphoneRotation: metadata.
|
|
45
|
+
iphoneRotation: metadata.rotation,
|
|
46
46
|
hasAudio: metadata.hasAudio,
|
|
47
|
-
mediaDuration: metadata.
|
|
47
|
+
mediaDuration: metadata.duration,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
async function loadAudio(project, clipObj) {
|
|
52
|
-
const
|
|
52
|
+
const metadata = await probeMedia(clipObj.url);
|
|
53
|
+
const durationSec = metadata.duration;
|
|
53
54
|
if (typeof clipObj.cutFrom === "number" && durationSec != null) {
|
|
54
55
|
if (clipObj.cutFrom >= durationSec) {
|
|
55
56
|
throw new ValidationError(
|
|
@@ -93,7 +94,8 @@ function loadImage(project, clipObj) {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
async function loadBackgroundAudio(project, clipObj) {
|
|
96
|
-
const
|
|
97
|
+
const metadata = await probeMedia(clipObj.url);
|
|
98
|
+
const durationSec = metadata.duration;
|
|
97
99
|
const clip = {
|
|
98
100
|
...clipObj,
|
|
99
101
|
volume:
|
package/src/schema/formatter.js
CHANGED
|
@@ -107,6 +107,12 @@ function formatSchema(modules, options = {}) {
|
|
|
107
107
|
lines.push(
|
|
108
108
|
"- All times are in **seconds**. `position` = when the clip starts, `end` = when it ends."
|
|
109
109
|
);
|
|
110
|
+
lines.push(
|
|
111
|
+
"- **`duration`** can be used instead of `end`: the library computes `end = position + duration`. Cannot use both."
|
|
112
|
+
);
|
|
113
|
+
lines.push(
|
|
114
|
+
"- **Auto-sequencing:** For video, image, and audio clips, `position` can be omitted. The clip will be placed immediately after the previous clip on its track. The first clip defaults to position 0."
|
|
115
|
+
);
|
|
110
116
|
lines.push(
|
|
111
117
|
"- Video/image clips form the visual timeline. Audio, text, and music are layered on top."
|
|
112
118
|
);
|
|
@@ -6,8 +6,9 @@ module.exports = {
|
|
|
6
6
|
schema: `{
|
|
7
7
|
type: "audio"; // Required: clip type identifier
|
|
8
8
|
url: string; // Required: path to audio file
|
|
9
|
-
position
|
|
10
|
-
end
|
|
9
|
+
position?: number; // Start time on timeline (seconds). Omit to auto-sequence after previous audio 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.
|
|
11
12
|
cutFrom?: number; // Start playback from this point in the source (default: 0)
|
|
12
13
|
volume?: number; // Volume multiplier (default: 1, 0 = mute, >1 = amplify)
|
|
13
14
|
}`,
|
|
@@ -22,6 +23,8 @@ module.exports = {
|
|
|
22
23
|
},
|
|
23
24
|
],
|
|
24
25
|
notes: [
|
|
26
|
+
"If position is omitted, the clip is placed immediately after the previous audio clip (auto-sequencing). The first clip defaults to position 0.",
|
|
27
|
+
"Use duration instead of end to specify how long the clip plays: end = position + duration. Cannot use both.",
|
|
25
28
|
"Audio clips are mixed (layered) with video audio and background music — they don't replace other audio.",
|
|
26
29
|
"Use cutFrom to start playback partway through the source file.",
|
|
27
30
|
],
|
|
@@ -6,8 +6,9 @@ module.exports = {
|
|
|
6
6
|
schema: `{
|
|
7
7
|
type: "image"; // Required: clip type identifier
|
|
8
8
|
url: string; // Required: path to image file (jpg, png, etc.)
|
|
9
|
-
position
|
|
10
|
-
end
|
|
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.
|
|
11
12
|
kenBurns?: KenBurnsEffect; // Optional: apply pan/zoom motion to the image
|
|
12
13
|
}`,
|
|
13
14
|
enums: {
|
|
@@ -23,14 +24,20 @@ module.exports = {
|
|
|
23
24
|
examples: [
|
|
24
25
|
{
|
|
25
26
|
label: "Static image for 5 seconds",
|
|
26
|
-
code: `{ type: "image", url: "photo.jpg",
|
|
27
|
+
code: `{ type: "image", url: "photo.jpg", duration: 5 }`,
|
|
27
28
|
},
|
|
28
29
|
{
|
|
29
|
-
label: "Image with
|
|
30
|
-
code: `
|
|
30
|
+
label: "Image slideshow with Ken Burns effects (auto-sequenced)",
|
|
31
|
+
code: `[
|
|
32
|
+
{ type: "image", url: "photo1.jpg", duration: 3, kenBurns: "zoom-in" },
|
|
33
|
+
{ type: "image", url: "photo2.jpg", duration: 3, kenBurns: "pan-right" },
|
|
34
|
+
{ type: "image", url: "photo3.jpg", duration: 3, kenBurns: "zoom-out" }
|
|
35
|
+
]`,
|
|
31
36
|
},
|
|
32
37
|
],
|
|
33
38
|
notes: [
|
|
39
|
+
"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
|
+
"Use duration instead of end to specify how long the image displays: end = position + duration. Cannot use both.",
|
|
34
41
|
"Images are scaled to fill the project canvas. For Ken Burns, use images at least as large as the output resolution for best quality.",
|
|
35
42
|
"Image clips can be placed on the same timeline as video clips and can use transitions between them.",
|
|
36
43
|
],
|
|
@@ -7,7 +7,8 @@ module.exports = {
|
|
|
7
7
|
type: "music"; // Required: clip type ("music" or "backgroundAudio")
|
|
8
8
|
url: string; // Required: path to audio file
|
|
9
9
|
position?: number; // Start time on timeline (default: 0)
|
|
10
|
-
end?: number; // End time on timeline (default: end of video)
|
|
10
|
+
end?: number; // End time on timeline (default: end of video). Use end OR duration, not both.
|
|
11
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
11
12
|
cutFrom?: number; // Start playback from this point in the source (default: 0)
|
|
12
13
|
volume?: number; // Volume multiplier (default: 0.2 — quieter than main audio)
|
|
13
14
|
loop?: boolean; // Loop the track to fill the entire video duration (default: false)
|
|
@@ -7,7 +7,8 @@ module.exports = {
|
|
|
7
7
|
type: "subtitle"; // Required: clip type identifier
|
|
8
8
|
url: string; // Required: path to subtitle file (.srt, .vtt, .ass, .ssa)
|
|
9
9
|
position?: number; // Timeline offset — shifts all subtitle timestamps forward (default: 0)
|
|
10
|
-
end?: number; // Optional end time to cut off subtitles
|
|
10
|
+
end?: number; // Optional end time to cut off subtitles. Use end OR duration, not both.
|
|
11
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
11
12
|
|
|
12
13
|
// Styling (applies to SRT/VTT imports — ASS/SSA files use their own embedded styles)
|
|
13
14
|
fontFamily?: string; // Font family (default: "Sans")
|
|
@@ -6,7 +6,8 @@ module.exports = {
|
|
|
6
6
|
schema: `{
|
|
7
7
|
type: "text"; // Required: clip type identifier
|
|
8
8
|
position: number; // Required: start time on timeline (seconds)
|
|
9
|
-
end
|
|
9
|
+
end?: number; // End time on timeline (seconds). Use end OR duration, not both.
|
|
10
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
10
11
|
|
|
11
12
|
// Content
|
|
12
13
|
text?: string; // Text content (required for "static" mode)
|
|
@@ -111,6 +112,7 @@ module.exports = {
|
|
|
111
112
|
},
|
|
112
113
|
],
|
|
113
114
|
notes: [
|
|
115
|
+
"Use duration instead of end to specify how long the text appears: end = position + duration. Cannot use both.",
|
|
114
116
|
"If no position is specified (xPercent/yPercent/x/y), text defaults to center of the screen.",
|
|
115
117
|
"For karaoke mode, provide the words array with per-word start/end times.",
|
|
116
118
|
"For word-replace and word-sequential, you can use either words[] or wordTimestamps[] (paired with a space-separated text string).",
|