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.
@@ -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
- module.exports = { formatBytes };
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
+ };