simple-ffmpegjs 0.2.0 → 0.3.1

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/loaders.js CHANGED
@@ -1,12 +1,24 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
1
3
  const { getVideoMetadata, getMediaDuration } = require("./core/media_info");
4
+ const { ValidationError, MediaNotFoundError } = require("./core/errors");
2
5
  const C = require("./core/constants");
3
6
 
4
7
  async function loadVideo(project, clipObj) {
5
8
  const metadata = await getVideoMetadata(clipObj.url);
6
9
  if (typeof clipObj.cutFrom === "number" && metadata.durationSec != null) {
7
10
  if (clipObj.cutFrom >= metadata.durationSec) {
8
- throw new Error(
9
- `Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.durationSec}s)`
11
+ throw new ValidationError(
12
+ `Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.durationSec}s)`,
13
+ {
14
+ errors: [
15
+ {
16
+ code: "INVALID_RANGE",
17
+ path: "cutFrom",
18
+ message: `cutFrom exceeds source duration`,
19
+ },
20
+ ],
21
+ }
10
22
  );
11
23
  }
12
24
  }
@@ -40,8 +52,17 @@ async function loadAudio(project, clipObj) {
40
52
  const durationSec = await getMediaDuration(clipObj.url);
41
53
  if (typeof clipObj.cutFrom === "number" && durationSec != null) {
42
54
  if (clipObj.cutFrom >= durationSec) {
43
- throw new Error(
44
- `Audio clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${durationSec}s)`
55
+ throw new ValidationError(
56
+ `Audio clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${durationSec}s)`,
57
+ {
58
+ errors: [
59
+ {
60
+ code: "INVALID_RANGE",
61
+ path: "cutFrom",
62
+ message: `cutFrom exceeds source duration`,
63
+ },
64
+ ],
65
+ }
45
66
  );
46
67
  }
47
68
  }
@@ -84,8 +105,17 @@ async function loadBackgroundAudio(project, clipObj) {
84
105
  };
85
106
  if (typeof clip.cutFrom === "number" && durationSec != null) {
86
107
  if (clip.cutFrom >= durationSec) {
87
- throw new Error(
88
- `Background audio cutFrom (${clip.cutFrom}s) must be < source duration (${durationSec}s)`
108
+ throw new ValidationError(
109
+ `Background audio cutFrom (${clip.cutFrom}s) must be < source duration (${durationSec}s)`,
110
+ {
111
+ errors: [
112
+ {
113
+ code: "INVALID_RANGE",
114
+ path: "cutFrom",
115
+ message: `cutFrom exceeds source duration`,
116
+ },
117
+ ],
118
+ }
89
119
  );
90
120
  }
91
121
  }
@@ -124,7 +154,50 @@ function loadText(project, clipObj) {
124
154
  if (typeof clipObj.yPercent === "number") clip.yPercent = clipObj.yPercent;
125
155
  else if (typeof clipObj.y === "number") clip.y = clipObj.y;
126
156
  else clip.yPercent = 0.5; // Default to centered
127
- project.textClips.push(clip);
157
+
158
+ // Karaoke mode uses ASS subtitles, so store separately
159
+ if (clip.mode === "karaoke") {
160
+ clip.highlightColor = clip.highlightColor || C.DEFAULT_KARAOKE_HIGHLIGHT;
161
+ project.subtitleClips.push(clip);
162
+ } else {
163
+ project.textClips.push(clip);
164
+ }
165
+ }
166
+
167
+ function loadSubtitle(project, clipObj) {
168
+ // Validate file exists
169
+ if (!fs.existsSync(clipObj.url)) {
170
+ throw new MediaNotFoundError(`Subtitle file not found: ${clipObj.url}`, {
171
+ path: clipObj.url,
172
+ });
173
+ }
174
+
175
+ // Validate format
176
+ const ext = path.extname(clipObj.url).toLowerCase();
177
+ if (![".srt", ".ass", ".ssa", ".vtt"].includes(ext)) {
178
+ throw new ValidationError(
179
+ `Unsupported subtitle format '${ext}'. Supported: .srt, .ass, .ssa, .vtt`,
180
+ {
181
+ errors: [
182
+ {
183
+ code: "INVALID_FORMAT",
184
+ path: "url",
185
+ message: `Unsupported subtitle format '${ext}'`,
186
+ },
187
+ ],
188
+ }
189
+ );
190
+ }
191
+
192
+ const clip = {
193
+ ...clipObj,
194
+ fontFamily: clipObj.fontFamily || C.DEFAULT_FONT_FAMILY,
195
+ fontSize: clipObj.fontSize || C.DEFAULT_FONT_SIZE,
196
+ fontColor: clipObj.fontColor || C.DEFAULT_FONT_COLOR,
197
+ position: clipObj.position || 0,
198
+ };
199
+
200
+ project.subtitleClips.push(clip);
128
201
  }
129
202
 
130
203
  module.exports = {
@@ -133,4 +206,5 @@ module.exports = {
133
206
  loadImage,
134
207
  loadBackgroundAudio,
135
208
  loadText,
209
+ loadSubtitle,
136
210
  };