simple-ffmpegjs 0.1.1 → 0.3.0
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/LICENSE +1 -1
- package/README.md +1096 -278
- package/package.json +48 -22
- package/src/core/constants.js +72 -1
- package/src/core/errors.js +64 -0
- package/src/core/gaps.js +81 -0
- package/src/core/media_info.js +141 -66
- package/src/core/rotation.js +76 -7
- package/src/core/validation.js +757 -140
- package/src/ffmpeg/command_builder.js +181 -10
- package/src/ffmpeg/strings.js +52 -2
- package/src/ffmpeg/subtitle_builder.js +707 -0
- package/src/ffmpeg/text_passes.js +41 -5
- package/src/ffmpeg/text_renderer.js +157 -13
- package/src/ffmpeg/video_builder.js +50 -3
- package/src/ffmpeg/watermark_builder.js +411 -0
- package/src/lib/utils.js +200 -1
- package/src/loaders.js +85 -11
- package/src/simpleffmpeg.js +1071 -273
- package/types/index.d.mts +478 -9
- package/types/index.d.ts +579 -11
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
const Strings = require("./strings");
|
|
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
|
+
/**
|
|
15
|
+
* Calculate position expressions for overlay/drawtext
|
|
16
|
+
* @param {Object} config - Watermark config with position, margin
|
|
17
|
+
* @param {number} canvasWidth - Video width
|
|
18
|
+
* @param {number} canvasHeight - Video height
|
|
19
|
+
* @param {boolean} isText - Whether this is for drawtext (uses different variables)
|
|
20
|
+
* @returns {{ x: string, y: string }}
|
|
21
|
+
*/
|
|
22
|
+
function calculatePosition(config, canvasWidth, canvasHeight, isText = false) {
|
|
23
|
+
const margin = typeof config.margin === "number" ? config.margin : 20;
|
|
24
|
+
|
|
25
|
+
// Text uses 'tw'/'th' for text width/height, overlay uses 'w'/'h'
|
|
26
|
+
const wVar = isText ? "tw" : "w";
|
|
27
|
+
const hVar = isText ? "th" : "h";
|
|
28
|
+
const W = isText ? canvasWidth : "W";
|
|
29
|
+
const H = isText ? canvasHeight : "H";
|
|
30
|
+
|
|
31
|
+
// Preset positions
|
|
32
|
+
if (typeof config.position === "string") {
|
|
33
|
+
const preset = config.position;
|
|
34
|
+
if (preset === "top-left") {
|
|
35
|
+
return { x: `${margin}`, y: `${margin}` };
|
|
36
|
+
}
|
|
37
|
+
if (preset === "top-right") {
|
|
38
|
+
return { x: `${W}-${wVar}-${margin}`, y: `${margin}` };
|
|
39
|
+
}
|
|
40
|
+
if (preset === "bottom-left") {
|
|
41
|
+
return { x: `${margin}`, y: `${H}-${hVar}-${margin}` };
|
|
42
|
+
}
|
|
43
|
+
if (preset === "bottom-right") {
|
|
44
|
+
return { x: `${W}-${wVar}-${margin}`, y: `${H}-${hVar}-${margin}` };
|
|
45
|
+
}
|
|
46
|
+
if (preset === "center") {
|
|
47
|
+
return { x: `(${W}-${wVar})/2`, y: `(${H}-${hVar})/2` };
|
|
48
|
+
}
|
|
49
|
+
// Default to bottom-right if unknown preset
|
|
50
|
+
return { x: `${W}-${wVar}-${margin}`, y: `${H}-${hVar}-${margin}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Custom positioning object
|
|
54
|
+
if (typeof config.position === "object" && config.position !== null) {
|
|
55
|
+
const pos = config.position;
|
|
56
|
+
|
|
57
|
+
// Percentage-based positioning
|
|
58
|
+
if (typeof pos.xPercent === "number" && typeof pos.yPercent === "number") {
|
|
59
|
+
const xPos = pos.xPercent * canvasWidth;
|
|
60
|
+
const yPos = pos.yPercent * canvasHeight;
|
|
61
|
+
// Center the watermark on the position point
|
|
62
|
+
return {
|
|
63
|
+
x: `${xPos}-${wVar}/2`,
|
|
64
|
+
y: `${yPos}-${hVar}/2`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pixel-based positioning
|
|
69
|
+
if (typeof pos.x === "number" && typeof pos.y === "number") {
|
|
70
|
+
return { x: `${pos.x}`, y: `${pos.y}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Default to bottom-right
|
|
75
|
+
return { x: `${W}-${wVar}-${margin}`, y: `${H}-${hVar}-${margin}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build filter for image watermark
|
|
80
|
+
* @param {Object} config - Watermark configuration
|
|
81
|
+
* @param {string} videoLabel - Current video stream label (e.g., "[outVideoAndText]")
|
|
82
|
+
* @param {number} watermarkInputIndex - FFmpeg input index for watermark image
|
|
83
|
+
* @param {number} canvasWidth - Video width
|
|
84
|
+
* @param {number} canvasHeight - Video height
|
|
85
|
+
* @param {number} totalDuration - Total video duration for enable expression
|
|
86
|
+
* @returns {{ filter: string, finalLabel: string }}
|
|
87
|
+
*/
|
|
88
|
+
function buildImageWatermark(
|
|
89
|
+
config,
|
|
90
|
+
videoLabel,
|
|
91
|
+
watermarkInputIndex,
|
|
92
|
+
canvasWidth,
|
|
93
|
+
canvasHeight,
|
|
94
|
+
totalDuration
|
|
95
|
+
) {
|
|
96
|
+
const scale = typeof config.scale === "number" ? config.scale : 0.15;
|
|
97
|
+
const opacity = typeof config.opacity === "number" ? config.opacity : 1;
|
|
98
|
+
const startTime = typeof config.startTime === "number" ? config.startTime : 0;
|
|
99
|
+
const endTime =
|
|
100
|
+
typeof config.endTime === "number" ? config.endTime : totalDuration;
|
|
101
|
+
|
|
102
|
+
// Calculate scaled width based on percentage of video width
|
|
103
|
+
const scaledWidth = Math.round(canvasWidth * scale);
|
|
104
|
+
|
|
105
|
+
// Build scale and opacity filter for watermark input
|
|
106
|
+
let wmFilter = `[${watermarkInputIndex}:v]`;
|
|
107
|
+
wmFilter += `scale=${scaledWidth}:-1`; // Scale to width, maintain aspect ratio
|
|
108
|
+
|
|
109
|
+
// Apply opacity if not fully opaque
|
|
110
|
+
if (opacity < 1) {
|
|
111
|
+
wmFilter += `,format=rgba,colorchannelmixer=aa=${opacity}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
wmFilter += `[wm_scaled];`;
|
|
115
|
+
|
|
116
|
+
// Calculate position
|
|
117
|
+
const pos = calculatePosition(config, canvasWidth, canvasHeight, false);
|
|
118
|
+
|
|
119
|
+
// Build overlay filter
|
|
120
|
+
const enableExpr =
|
|
121
|
+
startTime === 0 && endTime >= totalDuration
|
|
122
|
+
? "" // Always enabled
|
|
123
|
+
: `:enable='between(t,${startTime},${endTime})'`;
|
|
124
|
+
|
|
125
|
+
const overlayFilter = `${videoLabel}[wm_scaled]overlay=${pos.x}:${pos.y}${enableExpr}[outwm]`;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
filter: wmFilter + overlayFilter,
|
|
129
|
+
finalLabel: "[outwm]",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build filter for text watermark
|
|
135
|
+
* @param {Object} config - Watermark configuration
|
|
136
|
+
* @param {string} videoLabel - Current video stream label
|
|
137
|
+
* @param {number} canvasWidth - Video width
|
|
138
|
+
* @param {number} canvasHeight - Video height
|
|
139
|
+
* @param {number} totalDuration - Total video duration for enable expression
|
|
140
|
+
* @returns {{ filter: string, finalLabel: string }}
|
|
141
|
+
*/
|
|
142
|
+
function buildTextWatermark(
|
|
143
|
+
config,
|
|
144
|
+
videoLabel,
|
|
145
|
+
canvasWidth,
|
|
146
|
+
canvasHeight,
|
|
147
|
+
totalDuration
|
|
148
|
+
) {
|
|
149
|
+
const text = config.text || "";
|
|
150
|
+
const fontSize = typeof config.fontSize === "number" ? config.fontSize : 24;
|
|
151
|
+
const fontColor = config.fontColor || "#FFFFFF";
|
|
152
|
+
const fontFamily = config.fontFamily || "Sans";
|
|
153
|
+
const opacity = typeof config.opacity === "number" ? config.opacity : 1;
|
|
154
|
+
const startTime = typeof config.startTime === "number" ? config.startTime : 0;
|
|
155
|
+
const endTime =
|
|
156
|
+
typeof config.endTime === "number" ? config.endTime : totalDuration;
|
|
157
|
+
|
|
158
|
+
// Escape text for drawtext
|
|
159
|
+
const escapedText = Strings.escapeDrawtextText(text);
|
|
160
|
+
|
|
161
|
+
// Font specification
|
|
162
|
+
const fontSpec = config.fontFile
|
|
163
|
+
? `fontfile=${config.fontFile}`
|
|
164
|
+
: `font=${fontFamily}`;
|
|
165
|
+
|
|
166
|
+
// Calculate position (for text)
|
|
167
|
+
const pos = calculatePosition(config, canvasWidth, canvasHeight, true);
|
|
168
|
+
|
|
169
|
+
// Build drawtext filter
|
|
170
|
+
let params = `drawtext=text='${escapedText}':${fontSpec}`;
|
|
171
|
+
params += `:fontsize=${fontSize}`;
|
|
172
|
+
|
|
173
|
+
// Apply opacity to font color if needed
|
|
174
|
+
if (opacity < 1) {
|
|
175
|
+
// Check if color is hex format (#RGB, #RRGGBB, or 0xRRGGBB)
|
|
176
|
+
const isHexColor = fontColor.startsWith("#") || fontColor.startsWith("0x");
|
|
177
|
+
if (isHexColor) {
|
|
178
|
+
// Convert opacity to hex alpha (0-255)
|
|
179
|
+
const alphaHex = Math.round(opacity * 255)
|
|
180
|
+
.toString(16)
|
|
181
|
+
.padStart(2, "0");
|
|
182
|
+
// Append alpha to hex color
|
|
183
|
+
params += `:fontcolor=${fontColor}${alphaHex}`;
|
|
184
|
+
} else {
|
|
185
|
+
// Named color - use FFmpeg's @opacity syntax
|
|
186
|
+
params += `:fontcolor=${fontColor}@${opacity}`;
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
params += `:fontcolor=${fontColor}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
params += `:x=${pos.x}:y=${pos.y}`;
|
|
193
|
+
|
|
194
|
+
// Border/outline
|
|
195
|
+
if (config.borderColor) {
|
|
196
|
+
const borderColor = config.borderColor;
|
|
197
|
+
// Apply same opacity to border if needed
|
|
198
|
+
if (opacity < 1) {
|
|
199
|
+
const isHexColor =
|
|
200
|
+
borderColor.startsWith("#") || borderColor.startsWith("0x");
|
|
201
|
+
if (isHexColor) {
|
|
202
|
+
const alphaHex = Math.round(opacity * 255)
|
|
203
|
+
.toString(16)
|
|
204
|
+
.padStart(2, "0");
|
|
205
|
+
params += `:bordercolor=${borderColor}${alphaHex}`;
|
|
206
|
+
} else {
|
|
207
|
+
// Named color - use FFmpeg's @opacity syntax
|
|
208
|
+
params += `:bordercolor=${borderColor}@${opacity}`;
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
params += `:bordercolor=${borderColor}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (typeof config.borderWidth === "number") {
|
|
215
|
+
params += `:borderw=${config.borderWidth}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Shadow
|
|
219
|
+
if (config.shadowColor) {
|
|
220
|
+
params += `:shadowcolor=${config.shadowColor}`;
|
|
221
|
+
if (typeof config.shadowX === "number")
|
|
222
|
+
params += `:shadowx=${config.shadowX}`;
|
|
223
|
+
if (typeof config.shadowY === "number")
|
|
224
|
+
params += `:shadowy=${config.shadowY}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Enable expression for timing
|
|
228
|
+
if (startTime !== 0 || endTime < totalDuration) {
|
|
229
|
+
params += `:enable='between(t,${startTime},${endTime})'`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Remove the leading label bracket if present for proper filter syntax
|
|
233
|
+
const inputLabel = videoLabel.startsWith("[")
|
|
234
|
+
? videoLabel
|
|
235
|
+
: `[${videoLabel}]`;
|
|
236
|
+
const filter = `${inputLabel}${params}[outwm]`;
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
filter: filter,
|
|
240
|
+
finalLabel: "[outwm]",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build watermark filter based on config type
|
|
246
|
+
* @param {Object} config - Watermark configuration
|
|
247
|
+
* @param {string} videoLabel - Current video stream label
|
|
248
|
+
* @param {number|null} watermarkInputIndex - FFmpeg input index for image watermark (null for text)
|
|
249
|
+
* @param {number} canvasWidth - Video width
|
|
250
|
+
* @param {number} canvasHeight - Video height
|
|
251
|
+
* @param {number} totalDuration - Total video duration
|
|
252
|
+
* @returns {{ filter: string, finalLabel: string, needsInput: boolean }}
|
|
253
|
+
*/
|
|
254
|
+
function buildWatermarkFilter(
|
|
255
|
+
config,
|
|
256
|
+
videoLabel,
|
|
257
|
+
watermarkInputIndex,
|
|
258
|
+
canvasWidth,
|
|
259
|
+
canvasHeight,
|
|
260
|
+
totalDuration
|
|
261
|
+
) {
|
|
262
|
+
if (!config || typeof config !== "object") {
|
|
263
|
+
return { filter: "", finalLabel: videoLabel, needsInput: false };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const type = config.type || "image";
|
|
267
|
+
|
|
268
|
+
if (type === "text") {
|
|
269
|
+
const result = buildTextWatermark(
|
|
270
|
+
config,
|
|
271
|
+
videoLabel,
|
|
272
|
+
canvasWidth,
|
|
273
|
+
canvasHeight,
|
|
274
|
+
totalDuration
|
|
275
|
+
);
|
|
276
|
+
return { ...result, needsInput: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Default to image watermark
|
|
280
|
+
if (type === "image") {
|
|
281
|
+
if (typeof watermarkInputIndex !== "number") {
|
|
282
|
+
// No input index provided, can't build image watermark
|
|
283
|
+
return { filter: "", finalLabel: videoLabel, needsInput: true };
|
|
284
|
+
}
|
|
285
|
+
const result = buildImageWatermark(
|
|
286
|
+
config,
|
|
287
|
+
videoLabel,
|
|
288
|
+
watermarkInputIndex,
|
|
289
|
+
canvasWidth,
|
|
290
|
+
canvasHeight,
|
|
291
|
+
totalDuration
|
|
292
|
+
);
|
|
293
|
+
return { ...result, needsInput: true };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Unknown type, return unchanged
|
|
297
|
+
return { filter: "", finalLabel: videoLabel, needsInput: false };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Validate watermark configuration
|
|
302
|
+
* @param {Object} config - Watermark configuration
|
|
303
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
304
|
+
*/
|
|
305
|
+
function validateWatermarkConfig(config) {
|
|
306
|
+
const errors = [];
|
|
307
|
+
|
|
308
|
+
if (!config || typeof config !== "object") {
|
|
309
|
+
return { valid: true, errors: [] }; // No watermark is valid
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const type = config.type || "image";
|
|
313
|
+
|
|
314
|
+
if (type !== "image" && type !== "text") {
|
|
315
|
+
errors.push(`watermark.type must be 'image' or 'text', got '${type}'`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (type === "image") {
|
|
319
|
+
if (typeof config.url !== "string" || config.url.length === 0) {
|
|
320
|
+
errors.push("watermark.url is required for image watermarks");
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
typeof config.scale === "number" &&
|
|
324
|
+
(config.scale <= 0 || config.scale > 1)
|
|
325
|
+
) {
|
|
326
|
+
errors.push("watermark.scale must be between 0 and 1");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (type === "text") {
|
|
331
|
+
if (typeof config.text !== "string" || config.text.length === 0) {
|
|
332
|
+
errors.push("watermark.text is required for text watermarks");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Common validations
|
|
337
|
+
if (
|
|
338
|
+
typeof config.opacity === "number" &&
|
|
339
|
+
(config.opacity < 0 || config.opacity > 1)
|
|
340
|
+
) {
|
|
341
|
+
errors.push("watermark.opacity must be between 0 and 1");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (typeof config.margin === "number" && config.margin < 0) {
|
|
345
|
+
errors.push("watermark.margin must be >= 0");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (typeof config.startTime === "number" && config.startTime < 0) {
|
|
349
|
+
errors.push("watermark.startTime must be >= 0");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (
|
|
353
|
+
typeof config.startTime === "number" &&
|
|
354
|
+
typeof config.endTime === "number" &&
|
|
355
|
+
config.endTime <= config.startTime
|
|
356
|
+
) {
|
|
357
|
+
errors.push("watermark.endTime must be > startTime");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Position validation
|
|
361
|
+
if (typeof config.position === "string") {
|
|
362
|
+
const validPositions = [
|
|
363
|
+
"top-left",
|
|
364
|
+
"top-right",
|
|
365
|
+
"bottom-left",
|
|
366
|
+
"bottom-right",
|
|
367
|
+
"center",
|
|
368
|
+
];
|
|
369
|
+
if (!validPositions.includes(config.position)) {
|
|
370
|
+
errors.push(
|
|
371
|
+
`watermark.position must be one of: ${validPositions.join(
|
|
372
|
+
", "
|
|
373
|
+
)}; got '${config.position}'`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
} else if (typeof config.position === "object" && config.position !== null) {
|
|
377
|
+
const pos = config.position;
|
|
378
|
+
const hasPercent =
|
|
379
|
+
typeof pos.xPercent === "number" || typeof pos.yPercent === "number";
|
|
380
|
+
const hasPixel = typeof pos.x === "number" || typeof pos.y === "number";
|
|
381
|
+
|
|
382
|
+
if (hasPercent && hasPixel) {
|
|
383
|
+
errors.push("watermark.position cannot mix percentage and pixel values");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (hasPercent) {
|
|
387
|
+
if (
|
|
388
|
+
typeof pos.xPercent !== "number" ||
|
|
389
|
+
typeof pos.yPercent !== "number"
|
|
390
|
+
) {
|
|
391
|
+
errors.push("watermark.position requires both xPercent and yPercent");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (hasPixel) {
|
|
396
|
+
if (typeof pos.x !== "number" || typeof pos.y !== "number") {
|
|
397
|
+
errors.push("watermark.position requires both x and y");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { valid: errors.length === 0, errors };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = {
|
|
406
|
+
buildWatermarkFilter,
|
|
407
|
+
buildImageWatermark,
|
|
408
|
+
buildTextWatermark,
|
|
409
|
+
validateWatermarkConfig,
|
|
410
|
+
calculatePosition,
|
|
411
|
+
};
|
package/src/lib/utils.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
const { spawn } = require("child_process");
|
|
2
|
+
const { FFmpegError, ExportCancelledError } = require("../core/errors");
|
|
3
|
+
|
|
1
4
|
const formatBytes = (bytes) => {
|
|
2
5
|
if (!Number.isFinite(bytes)) return `${bytes}`;
|
|
3
6
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
@@ -10,4 +13,200 @@ const formatBytes = (bytes) => {
|
|
|
10
13
|
return `${n.toFixed(n < 10 && i > 0 ? 2 : 1)} ${units[i]}`;
|
|
11
14
|
};
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Parse FFmpeg time string (HH:MM:SS.ms) to seconds
|
|
18
|
+
*/
|
|
19
|
+
function parseFFmpegTime(timeStr) {
|
|
20
|
+
if (!timeStr) return 0;
|
|
21
|
+
const parts = timeStr.split(":");
|
|
22
|
+
if (parts.length === 3) {
|
|
23
|
+
const [hours, minutes, seconds] = parts;
|
|
24
|
+
return (
|
|
25
|
+
parseFloat(hours) * 3600 + parseFloat(minutes) * 60 + parseFloat(seconds)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return parseFloat(timeStr) || 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse FFmpeg progress line and extract metrics
|
|
33
|
+
*/
|
|
34
|
+
function parseFFmpegProgress(line, totalDuration) {
|
|
35
|
+
const progress = {};
|
|
36
|
+
|
|
37
|
+
// Parse frame= 120
|
|
38
|
+
const frameMatch = line.match(/frame=\s*(\d+)/);
|
|
39
|
+
if (frameMatch) progress.frame = parseInt(frameMatch[1], 10);
|
|
40
|
+
|
|
41
|
+
// Parse fps=45.2
|
|
42
|
+
const fpsMatch = line.match(/fps=\s*([\d.]+)/);
|
|
43
|
+
if (fpsMatch) progress.fps = parseFloat(fpsMatch[1]);
|
|
44
|
+
|
|
45
|
+
// Parse time=00:00:04.00
|
|
46
|
+
const timeMatch = line.match(/time=\s*([\d:.]+)/);
|
|
47
|
+
if (timeMatch) {
|
|
48
|
+
progress.timeProcessed = parseFFmpegTime(timeMatch[1]);
|
|
49
|
+
if (totalDuration > 0) {
|
|
50
|
+
progress.percent = Math.min(
|
|
51
|
+
100,
|
|
52
|
+
Math.round((progress.timeProcessed / totalDuration) * 100)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse speed=1.5x
|
|
58
|
+
const speedMatch = line.match(/speed=\s*([\d.]+)x/);
|
|
59
|
+
if (speedMatch) progress.speed = parseFloat(speedMatch[1]);
|
|
60
|
+
|
|
61
|
+
// Parse bitrate=1234kbits/s
|
|
62
|
+
const bitrateMatch = line.match(/bitrate=\s*([\d.]+)kbits\/s/);
|
|
63
|
+
if (bitrateMatch) progress.bitrate = parseFloat(bitrateMatch[1]);
|
|
64
|
+
|
|
65
|
+
// Parse size= 1234kB
|
|
66
|
+
const sizeMatch = line.match(/size=\s*(\d+)kB/);
|
|
67
|
+
if (sizeMatch) progress.size = parseInt(sizeMatch[1], 10) * 1024;
|
|
68
|
+
|
|
69
|
+
return progress;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run FFmpeg command with spawn, supporting progress callbacks and cancellation
|
|
74
|
+
* @param {Object} options
|
|
75
|
+
* @param {string} options.command - The full FFmpeg command string
|
|
76
|
+
* @param {number} options.totalDuration - Expected output duration in seconds (for progress %)
|
|
77
|
+
* @param {Function} options.onProgress - Progress callback
|
|
78
|
+
* @param {AbortSignal} options.signal - AbortSignal for cancellation
|
|
79
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
80
|
+
*/
|
|
81
|
+
function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
// Parse command into args (simple split, assumes no quoted args with spaces in values)
|
|
84
|
+
// FFmpeg commands from this library don't have spaces in quoted paths handled this way
|
|
85
|
+
// We need to handle the command string properly
|
|
86
|
+
const args = parseFFmpegCommand(command);
|
|
87
|
+
const ffmpegPath = args.shift(); // Remove 'ffmpeg' from args
|
|
88
|
+
|
|
89
|
+
const proc = spawn(ffmpegPath, args, {
|
|
90
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let stdout = "";
|
|
94
|
+
let stderr = "";
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
|
|
97
|
+
// Handle cancellation
|
|
98
|
+
if (signal) {
|
|
99
|
+
const abortHandler = () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
proc.kill("SIGTERM");
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (signal.aborted) {
|
|
105
|
+
proc.kill("SIGTERM");
|
|
106
|
+
reject(new ExportCancelledError());
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
111
|
+
|
|
112
|
+
proc.on("close", () => {
|
|
113
|
+
signal.removeEventListener("abort", abortHandler);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
proc.stdout.on("data", (data) => {
|
|
118
|
+
stdout += data.toString();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.stderr.on("data", (data) => {
|
|
122
|
+
const chunk = data.toString();
|
|
123
|
+
stderr += chunk;
|
|
124
|
+
|
|
125
|
+
// Parse progress from stderr (FFmpeg outputs progress to stderr)
|
|
126
|
+
if (onProgress && typeof onProgress === "function") {
|
|
127
|
+
const progress = parseFFmpegProgress(chunk, totalDuration);
|
|
128
|
+
if (Object.keys(progress).length > 0) {
|
|
129
|
+
onProgress(progress);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
proc.on("error", (error) => {
|
|
135
|
+
reject(
|
|
136
|
+
new FFmpegError(`FFmpeg process error: ${error.message}`, {
|
|
137
|
+
stderr,
|
|
138
|
+
command,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
proc.on("close", (code) => {
|
|
144
|
+
if (cancelled) {
|
|
145
|
+
reject(new ExportCancelledError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (code !== 0) {
|
|
150
|
+
reject(
|
|
151
|
+
new FFmpegError(`FFmpeg exited with code ${code}`, {
|
|
152
|
+
stderr,
|
|
153
|
+
command,
|
|
154
|
+
exitCode: code,
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
resolve({ stdout, stderr });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse a command string into an array of arguments
|
|
167
|
+
* Handles quoted strings and escaped characters
|
|
168
|
+
*/
|
|
169
|
+
function parseFFmpegCommand(command) {
|
|
170
|
+
const args = [];
|
|
171
|
+
let current = "";
|
|
172
|
+
let inQuote = false;
|
|
173
|
+
let quoteChar = "";
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < command.length; i++) {
|
|
176
|
+
const char = command[i];
|
|
177
|
+
|
|
178
|
+
if (inQuote) {
|
|
179
|
+
if (char === quoteChar) {
|
|
180
|
+
inQuote = false;
|
|
181
|
+
// Don't add the closing quote to the argument
|
|
182
|
+
} else {
|
|
183
|
+
current += char;
|
|
184
|
+
}
|
|
185
|
+
} else if (char === '"' || char === "'") {
|
|
186
|
+
inQuote = true;
|
|
187
|
+
quoteChar = char;
|
|
188
|
+
// Don't add the opening quote to the argument
|
|
189
|
+
} else if (char === " " || char === "\t") {
|
|
190
|
+
if (current.length > 0) {
|
|
191
|
+
args.push(current);
|
|
192
|
+
current = "";
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
current += char;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (current.length > 0) {
|
|
200
|
+
args.push(current);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return args;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
formatBytes,
|
|
208
|
+
parseFFmpegTime,
|
|
209
|
+
parseFFmpegProgress,
|
|
210
|
+
runFFmpeg,
|
|
211
|
+
parseFFmpegCommand,
|
|
212
|
+
};
|