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.
- package/README.md +706 -34
- package/package.json +3 -4
- package/src/core/constants.js +33 -0
- package/src/core/media_info.js +141 -66
- package/src/core/rotation.js +76 -7
- package/src/core/validation.js +758 -145
- package/src/ffmpeg/command_builder.js +33 -6
- 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 +165 -16
- package/src/ffmpeg/video_builder.js +3 -1
- package/src/ffmpeg/watermark_builder.js +411 -0
- package/src/loaders.js +81 -7
- package/src/simpleffmpeg.js +604 -62
- package/types/index.d.mts +266 -7
- package/types/index.d.ts +305 -9
- package/assets/example-thumbnail.jpg +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
};
|