simple-ffmpegjs 0.2.0 → 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/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 +17 -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 +149 -11
- 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,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitle builder for ASS-based text rendering
|
|
3
|
+
* Supports karaoke mode and subtitle file imports (SRT, ASS, VTT)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert hex color (#RRGGBB or #RRGGBBAA) to ASS color format (&HAABBGGRR)
|
|
11
|
+
* ASS uses BGR order with alpha, not RGB
|
|
12
|
+
*/
|
|
13
|
+
function hexToASSColor(hex, opacity = 1) {
|
|
14
|
+
// Remove # if present
|
|
15
|
+
let color = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
16
|
+
|
|
17
|
+
// Handle named colors
|
|
18
|
+
const namedColors = {
|
|
19
|
+
white: "FFFFFF",
|
|
20
|
+
black: "000000",
|
|
21
|
+
red: "FF0000",
|
|
22
|
+
green: "00FF00",
|
|
23
|
+
blue: "0000FF",
|
|
24
|
+
yellow: "FFFF00",
|
|
25
|
+
cyan: "00FFFF",
|
|
26
|
+
magenta: "FF00FF",
|
|
27
|
+
orange: "FFA500",
|
|
28
|
+
pink: "FFC0CB",
|
|
29
|
+
purple: "800080",
|
|
30
|
+
gold: "FFD700",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (namedColors[color.toLowerCase()]) {
|
|
34
|
+
color = namedColors[color.toLowerCase()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Ensure 6 characters (RGB)
|
|
38
|
+
if (color.length === 3) {
|
|
39
|
+
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract RGB
|
|
43
|
+
const r = color.slice(0, 2);
|
|
44
|
+
const g = color.slice(2, 4);
|
|
45
|
+
const b = color.slice(4, 6);
|
|
46
|
+
|
|
47
|
+
// Calculate alpha (00 = fully opaque in ASS, FF = fully transparent)
|
|
48
|
+
const alpha = Math.round((1 - opacity) * 255)
|
|
49
|
+
.toString(16)
|
|
50
|
+
.padStart(2, "0")
|
|
51
|
+
.toUpperCase();
|
|
52
|
+
|
|
53
|
+
// ASS format: &HAABBGGRR (alpha, blue, green, red)
|
|
54
|
+
return `&H${alpha}${b}${g}${r}`.toUpperCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert seconds to ASS timestamp format (H:MM:SS.cc)
|
|
59
|
+
* ASS uses centiseconds (1/100th of a second)
|
|
60
|
+
*/
|
|
61
|
+
function secondsToASSTime(seconds) {
|
|
62
|
+
const h = Math.floor(seconds / 3600);
|
|
63
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
64
|
+
const s = Math.floor(seconds % 60);
|
|
65
|
+
const cs = Math.round((seconds % 1) * 100);
|
|
66
|
+
|
|
67
|
+
return `${h}:${m.toString().padStart(2, "0")}:${s
|
|
68
|
+
.toString()
|
|
69
|
+
.padStart(2, "0")}.${cs.toString().padStart(2, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Escape text for ASS format
|
|
74
|
+
*/
|
|
75
|
+
function escapeASSText(text) {
|
|
76
|
+
return text
|
|
77
|
+
.replace(/\\/g, "\\\\")
|
|
78
|
+
.replace(/\{/g, "\\{")
|
|
79
|
+
.replace(/\}/g, "\\}")
|
|
80
|
+
.replace(/\n/g, "\\N");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate ASS header/script info section
|
|
85
|
+
*/
|
|
86
|
+
function generateASSHeader(width, height, title = "simple-ffmpeg subtitles") {
|
|
87
|
+
return `[Script Info]
|
|
88
|
+
Title: ${title}
|
|
89
|
+
ScriptType: v4.00+
|
|
90
|
+
WrapStyle: 0
|
|
91
|
+
ScaledBorderAndShadow: yes
|
|
92
|
+
YCbCr Matrix: TV.709
|
|
93
|
+
PlayResX: ${width}
|
|
94
|
+
PlayResY: ${height}
|
|
95
|
+
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generate ASS style definition
|
|
101
|
+
*/
|
|
102
|
+
function generateASSStyle(options = {}) {
|
|
103
|
+
const {
|
|
104
|
+
name = "Default",
|
|
105
|
+
fontFamily = "Arial",
|
|
106
|
+
fontSize = 48,
|
|
107
|
+
primaryColor = "#FFFFFF",
|
|
108
|
+
secondaryColor = "#FFFF00", // Used for karaoke highlight
|
|
109
|
+
outlineColor = "#000000",
|
|
110
|
+
backColor = "#000000",
|
|
111
|
+
bold = false,
|
|
112
|
+
italic = false,
|
|
113
|
+
underline = false,
|
|
114
|
+
strikeOut = false,
|
|
115
|
+
scaleX = 100,
|
|
116
|
+
scaleY = 100,
|
|
117
|
+
spacing = 0,
|
|
118
|
+
angle = 0,
|
|
119
|
+
borderStyle = 1, // 1 = outline + shadow, 3 = opaque box
|
|
120
|
+
outline = 2,
|
|
121
|
+
shadow = 1,
|
|
122
|
+
alignment = 5, // 1-9 numpad style, 5 = center
|
|
123
|
+
marginL = 20,
|
|
124
|
+
marginR = 20,
|
|
125
|
+
marginV = 20,
|
|
126
|
+
encoding = 1,
|
|
127
|
+
opacity = 1,
|
|
128
|
+
outlineOpacity = 1,
|
|
129
|
+
} = options;
|
|
130
|
+
|
|
131
|
+
const primary = hexToASSColor(primaryColor, opacity);
|
|
132
|
+
const secondary = hexToASSColor(secondaryColor, opacity);
|
|
133
|
+
const outlineCol = hexToASSColor(outlineColor, outlineOpacity);
|
|
134
|
+
const back = hexToASSColor(backColor, 0.5);
|
|
135
|
+
|
|
136
|
+
return `Style: ${name},${fontFamily},${fontSize},${primary},${secondary},${outlineCol},${back},${
|
|
137
|
+
bold ? -1 : 0
|
|
138
|
+
},${italic ? -1 : 0},${underline ? -1 : 0},${
|
|
139
|
+
strikeOut ? -1 : 0
|
|
140
|
+
},${scaleX},${scaleY},${spacing},${angle},${borderStyle},${outline},${shadow},${alignment},${marginL},${marginR},${marginV},${encoding}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generate ASS styles section
|
|
145
|
+
*/
|
|
146
|
+
function generateASSStyles(styles = []) {
|
|
147
|
+
let section = `[V4+ Styles]
|
|
148
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
if (styles.length === 0) {
|
|
152
|
+
// Default style
|
|
153
|
+
section += generateASSStyle() + "\n";
|
|
154
|
+
} else {
|
|
155
|
+
for (const style of styles) {
|
|
156
|
+
section += generateASSStyle(style) + "\n";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return section + "\n";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Generate a single ASS dialogue line
|
|
165
|
+
*/
|
|
166
|
+
function generateASSDialogue(options) {
|
|
167
|
+
const {
|
|
168
|
+
layer = 0,
|
|
169
|
+
start,
|
|
170
|
+
end,
|
|
171
|
+
style = "Default",
|
|
172
|
+
name = "",
|
|
173
|
+
marginL = 0,
|
|
174
|
+
marginR = 0,
|
|
175
|
+
marginV = 0,
|
|
176
|
+
effect = "",
|
|
177
|
+
text,
|
|
178
|
+
} = options;
|
|
179
|
+
|
|
180
|
+
const startTime = secondsToASSTime(start);
|
|
181
|
+
const endTime = secondsToASSTime(end);
|
|
182
|
+
|
|
183
|
+
return `Dialogue: ${layer},${startTime},${endTime},${style},${name},${marginL},${marginR},${marginV},${effect},${text}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Generate ASS events section
|
|
188
|
+
*/
|
|
189
|
+
function generateASSEvents(dialogues = []) {
|
|
190
|
+
let section = `[Events]
|
|
191
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
192
|
+
`;
|
|
193
|
+
|
|
194
|
+
for (const dialogue of dialogues) {
|
|
195
|
+
section += generateASSDialogue(dialogue) + "\n";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return section;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build karaoke ASS content for a text clip
|
|
203
|
+
* @param {Object} clip - Text clip with karaoke settings
|
|
204
|
+
* @param {number} canvasWidth - Video width
|
|
205
|
+
* @param {number} canvasHeight - Video height
|
|
206
|
+
* @returns {string} Complete ASS file content
|
|
207
|
+
*/
|
|
208
|
+
function buildKaraokeASS(clip, canvasWidth, canvasHeight) {
|
|
209
|
+
const {
|
|
210
|
+
text = "",
|
|
211
|
+
position: clipStart,
|
|
212
|
+
end: clipEnd,
|
|
213
|
+
words,
|
|
214
|
+
wordTimestamps,
|
|
215
|
+
fontFamily = "Arial",
|
|
216
|
+
fontSize = 48,
|
|
217
|
+
fontColor = "#FFFFFF",
|
|
218
|
+
highlightColor = "#FFFF00",
|
|
219
|
+
highlightStyle = "smooth", // "smooth" (gradual fill) or "instant"
|
|
220
|
+
borderColor = "#000000",
|
|
221
|
+
borderWidth = 2,
|
|
222
|
+
shadowColor,
|
|
223
|
+
shadowX = 0,
|
|
224
|
+
shadowY = 0,
|
|
225
|
+
xPercent,
|
|
226
|
+
yPercent,
|
|
227
|
+
x,
|
|
228
|
+
y,
|
|
229
|
+
opacity = 1,
|
|
230
|
+
} = clip;
|
|
231
|
+
|
|
232
|
+
// Parse words and their timings, preserving line breaks
|
|
233
|
+
// Split by lines first, then by spaces within each line
|
|
234
|
+
const lines = text.split(/\n/);
|
|
235
|
+
const splitWords = [];
|
|
236
|
+
const lineBreakAfter = new Set(); // Track which word indices have line breaks after them
|
|
237
|
+
|
|
238
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
239
|
+
const lineWords = lines[lineIdx].split(/\s+/).filter(Boolean);
|
|
240
|
+
for (const word of lineWords) {
|
|
241
|
+
splitWords.push(word);
|
|
242
|
+
}
|
|
243
|
+
// Mark line break after the last word of this line (except for the last line)
|
|
244
|
+
if (lineIdx < lines.length - 1 && splitWords.length > 0) {
|
|
245
|
+
lineBreakAfter.add(splitWords.length - 1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let wordList = [];
|
|
250
|
+
|
|
251
|
+
if (Array.isArray(words) && words.length > 0) {
|
|
252
|
+
// User provided explicit word timings - check if they include lineBreak property
|
|
253
|
+
wordList = words.map((w) => ({
|
|
254
|
+
text: w.text,
|
|
255
|
+
start: w.start,
|
|
256
|
+
end: w.end,
|
|
257
|
+
lineBreak: w.lineBreak || false,
|
|
258
|
+
}));
|
|
259
|
+
} else if (Array.isArray(wordTimestamps) && wordTimestamps.length > 0) {
|
|
260
|
+
const ts = wordTimestamps;
|
|
261
|
+
for (let i = 0; i < splitWords.length; i++) {
|
|
262
|
+
const start = ts[i] !== undefined ? ts[i] : clipStart;
|
|
263
|
+
const end = ts[i + 1] !== undefined ? ts[i + 1] : clipEnd;
|
|
264
|
+
wordList.push({
|
|
265
|
+
text: splitWords[i],
|
|
266
|
+
start,
|
|
267
|
+
end,
|
|
268
|
+
lineBreak: lineBreakAfter.has(i),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
// Distribute evenly
|
|
273
|
+
const duration = clipEnd - clipStart;
|
|
274
|
+
const wordDuration = duration / splitWords.length;
|
|
275
|
+
for (let i = 0; i < splitWords.length; i++) {
|
|
276
|
+
wordList.push({
|
|
277
|
+
text: splitWords[i],
|
|
278
|
+
start: clipStart + i * wordDuration,
|
|
279
|
+
end: clipStart + (i + 1) * wordDuration,
|
|
280
|
+
lineBreak: lineBreakAfter.has(i),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Calculate alignment based on position
|
|
286
|
+
// ASS alignment: 1=bottom-left, 2=bottom-center, 3=bottom-right
|
|
287
|
+
// 4=mid-left, 5=mid-center, 6=mid-right
|
|
288
|
+
// 7=top-left, 8=top-center, 9=top-right
|
|
289
|
+
let alignment = 5; // default center
|
|
290
|
+
if (typeof yPercent === "number") {
|
|
291
|
+
if (yPercent < 0.33) alignment = 8; // top
|
|
292
|
+
else if (yPercent > 0.66) alignment = 2; // bottom
|
|
293
|
+
else alignment = 5; // middle
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Calculate margins for positioning
|
|
297
|
+
let marginV = 20;
|
|
298
|
+
if (typeof yPercent === "number") {
|
|
299
|
+
// Convert percentage to margin from edge
|
|
300
|
+
if (yPercent < 0.5) {
|
|
301
|
+
marginV = Math.round(yPercent * canvasHeight);
|
|
302
|
+
} else {
|
|
303
|
+
marginV = Math.round((1 - yPercent) * canvasHeight);
|
|
304
|
+
}
|
|
305
|
+
} else if (typeof y === "number") {
|
|
306
|
+
marginV = y;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Generate header
|
|
310
|
+
let ass = generateASSHeader(canvasWidth, canvasHeight, "Karaoke");
|
|
311
|
+
|
|
312
|
+
// Generate style
|
|
313
|
+
ass += generateASSStyles([
|
|
314
|
+
{
|
|
315
|
+
name: "Karaoke",
|
|
316
|
+
fontFamily,
|
|
317
|
+
fontSize,
|
|
318
|
+
primaryColor: fontColor,
|
|
319
|
+
secondaryColor: highlightColor,
|
|
320
|
+
outlineColor: borderColor || "#000000",
|
|
321
|
+
outline: borderWidth,
|
|
322
|
+
shadow: shadowColor ? 1 : 0,
|
|
323
|
+
alignment,
|
|
324
|
+
marginV,
|
|
325
|
+
opacity,
|
|
326
|
+
},
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
// Build karaoke text with \k tags
|
|
330
|
+
// \k = instant color change, \kf = smooth fill (gradual highlight)
|
|
331
|
+
// Duration is in centiseconds
|
|
332
|
+
const karaokeTag = highlightStyle === "instant" ? "\\k" : "\\kf";
|
|
333
|
+
let karaokeText = "";
|
|
334
|
+
for (let i = 0; i < wordList.length; i++) {
|
|
335
|
+
const word = wordList[i];
|
|
336
|
+
const duration = Math.round((word.end - word.start) * 100); // centiseconds
|
|
337
|
+
karaokeText += `{${karaokeTag}${duration}}${escapeASSText(word.text)}`;
|
|
338
|
+
if (i < wordList.length - 1) {
|
|
339
|
+
// Add line break or space after word
|
|
340
|
+
karaokeText += word.lineBreak ? "\\N" : " ";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Generate dialogue
|
|
345
|
+
ass += generateASSEvents([
|
|
346
|
+
{
|
|
347
|
+
start: clipStart,
|
|
348
|
+
end: clipEnd,
|
|
349
|
+
style: "Karaoke",
|
|
350
|
+
text: karaokeText,
|
|
351
|
+
},
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
return ass;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Build ASS content for a regular subtitle clip (imported or defined)
|
|
359
|
+
* @param {Object} clip - Subtitle clip
|
|
360
|
+
* @param {number} canvasWidth - Video width
|
|
361
|
+
* @param {number} canvasHeight - Video height
|
|
362
|
+
* @returns {string} Complete ASS file content
|
|
363
|
+
*/
|
|
364
|
+
function buildSubtitleASS(clip, canvasWidth, canvasHeight) {
|
|
365
|
+
const {
|
|
366
|
+
text = "",
|
|
367
|
+
position: clipStart,
|
|
368
|
+
end: clipEnd,
|
|
369
|
+
fontFamily = "Arial",
|
|
370
|
+
fontSize = 48,
|
|
371
|
+
fontColor = "#FFFFFF",
|
|
372
|
+
borderColor = "#000000",
|
|
373
|
+
borderWidth = 2,
|
|
374
|
+
yPercent,
|
|
375
|
+
opacity = 1,
|
|
376
|
+
} = clip;
|
|
377
|
+
|
|
378
|
+
let alignment = 2; // bottom center default for subtitles
|
|
379
|
+
let marginV = 20;
|
|
380
|
+
|
|
381
|
+
if (typeof yPercent === "number") {
|
|
382
|
+
if (yPercent < 0.33) {
|
|
383
|
+
alignment = 8;
|
|
384
|
+
marginV = Math.round(yPercent * canvasHeight);
|
|
385
|
+
} else if (yPercent > 0.66) {
|
|
386
|
+
alignment = 2;
|
|
387
|
+
marginV = Math.round((1 - yPercent) * canvasHeight);
|
|
388
|
+
} else {
|
|
389
|
+
alignment = 5;
|
|
390
|
+
marginV = 20;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let ass = generateASSHeader(canvasWidth, canvasHeight, "Subtitles");
|
|
395
|
+
|
|
396
|
+
ass += generateASSStyles([
|
|
397
|
+
{
|
|
398
|
+
name: "Default",
|
|
399
|
+
fontFamily,
|
|
400
|
+
fontSize,
|
|
401
|
+
primaryColor: fontColor,
|
|
402
|
+
outlineColor: borderColor,
|
|
403
|
+
outline: borderWidth,
|
|
404
|
+
alignment,
|
|
405
|
+
marginV,
|
|
406
|
+
opacity,
|
|
407
|
+
},
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
ass += generateASSEvents([
|
|
411
|
+
{
|
|
412
|
+
start: clipStart,
|
|
413
|
+
end: clipEnd,
|
|
414
|
+
style: "Default",
|
|
415
|
+
text: escapeASSText(text),
|
|
416
|
+
},
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
return ass;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Parse SRT content and convert to ASS dialogue events
|
|
424
|
+
* @param {string} srtContent - Raw SRT file content
|
|
425
|
+
* @returns {Array} Array of dialogue objects
|
|
426
|
+
*/
|
|
427
|
+
function parseSRT(srtContent) {
|
|
428
|
+
const dialogues = [];
|
|
429
|
+
const blocks = srtContent.trim().split(/\n\n+/);
|
|
430
|
+
|
|
431
|
+
for (const block of blocks) {
|
|
432
|
+
const lines = block.split("\n");
|
|
433
|
+
if (lines.length < 2) continue;
|
|
434
|
+
|
|
435
|
+
// Find the timestamp line (format: 00:00:00,000 --> 00:00:00,000)
|
|
436
|
+
let timestampLine = null;
|
|
437
|
+
let textStartIndex = 0;
|
|
438
|
+
|
|
439
|
+
for (let i = 0; i < lines.length; i++) {
|
|
440
|
+
if (lines[i].includes("-->")) {
|
|
441
|
+
timestampLine = lines[i];
|
|
442
|
+
textStartIndex = i + 1;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!timestampLine) continue;
|
|
448
|
+
|
|
449
|
+
const match = timestampLine.match(
|
|
450
|
+
/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
if (!match) continue;
|
|
454
|
+
|
|
455
|
+
const start =
|
|
456
|
+
parseInt(match[1]) * 3600 +
|
|
457
|
+
parseInt(match[2]) * 60 +
|
|
458
|
+
parseInt(match[3]) +
|
|
459
|
+
parseInt(match[4]) / 1000;
|
|
460
|
+
|
|
461
|
+
const end =
|
|
462
|
+
parseInt(match[5]) * 3600 +
|
|
463
|
+
parseInt(match[6]) * 60 +
|
|
464
|
+
parseInt(match[7]) +
|
|
465
|
+
parseInt(match[8]) / 1000;
|
|
466
|
+
|
|
467
|
+
const text = lines
|
|
468
|
+
.slice(textStartIndex)
|
|
469
|
+
.join("\\N")
|
|
470
|
+
.replace(/<[^>]+>/g, ""); // Strip HTML tags
|
|
471
|
+
|
|
472
|
+
if (text.trim()) {
|
|
473
|
+
dialogues.push({
|
|
474
|
+
start,
|
|
475
|
+
end,
|
|
476
|
+
style: "Default",
|
|
477
|
+
text: escapeASSText(text),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return dialogues;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Parse VTT content and convert to ASS dialogue events
|
|
487
|
+
* @param {string} vttContent - Raw VTT file content
|
|
488
|
+
* @returns {Array} Array of dialogue objects
|
|
489
|
+
*/
|
|
490
|
+
function parseVTT(vttContent) {
|
|
491
|
+
const dialogues = [];
|
|
492
|
+
// Remove WEBVTT header and split into cues
|
|
493
|
+
const content = vttContent.replace(/^WEBVTT.*?\n\n/s, "");
|
|
494
|
+
const blocks = content.trim().split(/\n\n+/);
|
|
495
|
+
|
|
496
|
+
for (const block of blocks) {
|
|
497
|
+
const lines = block.split("\n");
|
|
498
|
+
if (lines.length < 2) continue;
|
|
499
|
+
|
|
500
|
+
// Find timestamp line
|
|
501
|
+
let timestampLine = null;
|
|
502
|
+
let textStartIndex = 0;
|
|
503
|
+
|
|
504
|
+
for (let i = 0; i < lines.length; i++) {
|
|
505
|
+
if (lines[i].includes("-->")) {
|
|
506
|
+
timestampLine = lines[i];
|
|
507
|
+
textStartIndex = i + 1;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!timestampLine) continue;
|
|
513
|
+
|
|
514
|
+
// VTT format: 00:00:00.000 --> 00:00:00.000
|
|
515
|
+
const match = timestampLine.match(
|
|
516
|
+
/(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})\.(\d{3})/
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
if (!match) {
|
|
520
|
+
// Try shorter format: 00:00.000
|
|
521
|
+
const shortMatch = timestampLine.match(
|
|
522
|
+
/(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2})\.(\d{3})/
|
|
523
|
+
);
|
|
524
|
+
if (shortMatch) {
|
|
525
|
+
const start =
|
|
526
|
+
parseInt(shortMatch[1]) * 60 +
|
|
527
|
+
parseInt(shortMatch[2]) +
|
|
528
|
+
parseInt(shortMatch[3]) / 1000;
|
|
529
|
+
const end =
|
|
530
|
+
parseInt(shortMatch[4]) * 60 +
|
|
531
|
+
parseInt(shortMatch[5]) +
|
|
532
|
+
parseInt(shortMatch[6]) / 1000;
|
|
533
|
+
const text = lines
|
|
534
|
+
.slice(textStartIndex)
|
|
535
|
+
.join("\\N")
|
|
536
|
+
.replace(/<[^>]+>/g, "");
|
|
537
|
+
if (text.trim()) {
|
|
538
|
+
dialogues.push({ start, end, style: "Default", text });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const start =
|
|
545
|
+
parseInt(match[1]) * 3600 +
|
|
546
|
+
parseInt(match[2]) * 60 +
|
|
547
|
+
parseInt(match[3]) +
|
|
548
|
+
parseInt(match[4]) / 1000;
|
|
549
|
+
|
|
550
|
+
const end =
|
|
551
|
+
parseInt(match[5]) * 3600 +
|
|
552
|
+
parseInt(match[6]) * 60 +
|
|
553
|
+
parseInt(match[7]) +
|
|
554
|
+
parseInt(match[8]) / 1000;
|
|
555
|
+
|
|
556
|
+
const text = lines
|
|
557
|
+
.slice(textStartIndex)
|
|
558
|
+
.join("\\N")
|
|
559
|
+
.replace(/<[^>]+>/g, "");
|
|
560
|
+
|
|
561
|
+
if (text.trim()) {
|
|
562
|
+
dialogues.push({
|
|
563
|
+
start,
|
|
564
|
+
end,
|
|
565
|
+
style: "Default",
|
|
566
|
+
text: escapeASSText(text),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return dialogues;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Load and convert a subtitle file to ASS format
|
|
576
|
+
* @param {string} filePath - Path to subtitle file
|
|
577
|
+
* @param {Object} options - Styling options
|
|
578
|
+
* @param {number} canvasWidth - Video width
|
|
579
|
+
* @param {number} canvasHeight - Video height
|
|
580
|
+
* @returns {string} ASS content
|
|
581
|
+
*/
|
|
582
|
+
function loadSubtitleFile(filePath, options, canvasWidth, canvasHeight) {
|
|
583
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
584
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
585
|
+
|
|
586
|
+
// If already ASS, return as-is (or could parse and restyle)
|
|
587
|
+
if (ext === ".ass" || ext === ".ssa") {
|
|
588
|
+
return content;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let dialogues = [];
|
|
592
|
+
|
|
593
|
+
if (ext === ".srt") {
|
|
594
|
+
dialogues = parseSRT(content);
|
|
595
|
+
} else if (ext === ".vtt") {
|
|
596
|
+
dialogues = parseVTT(content);
|
|
597
|
+
} else {
|
|
598
|
+
throw new Error(`Unsupported subtitle format: ${ext}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Apply time offset if specified
|
|
602
|
+
if (typeof options.position === "number" && options.position !== 0) {
|
|
603
|
+
const offset = options.position;
|
|
604
|
+
dialogues = dialogues.map((d) => ({
|
|
605
|
+
...d,
|
|
606
|
+
start: d.start + offset,
|
|
607
|
+
end: d.end + offset,
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Generate ASS with styling
|
|
612
|
+
let ass = generateASSHeader(canvasWidth, canvasHeight, "Imported Subtitles");
|
|
613
|
+
|
|
614
|
+
ass += generateASSStyles([
|
|
615
|
+
{
|
|
616
|
+
name: "Default",
|
|
617
|
+
fontFamily: options.fontFamily || "Arial",
|
|
618
|
+
fontSize: options.fontSize || 48,
|
|
619
|
+
primaryColor: options.fontColor || "#FFFFFF",
|
|
620
|
+
outlineColor: options.borderColor || "#000000",
|
|
621
|
+
outline: options.borderWidth || 2,
|
|
622
|
+
alignment: 2, // bottom center
|
|
623
|
+
marginV: 30,
|
|
624
|
+
opacity: options.opacity || 1,
|
|
625
|
+
},
|
|
626
|
+
]);
|
|
627
|
+
|
|
628
|
+
ass += generateASSEvents(dialogues);
|
|
629
|
+
|
|
630
|
+
return ass;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Build the FFmpeg filter string for ASS subtitles
|
|
635
|
+
* @param {string} assFilePath - Path to the ASS file
|
|
636
|
+
* @param {string} inputLabel - Current video stream label
|
|
637
|
+
* @returns {{ filter: string, finalLabel: string }}
|
|
638
|
+
*/
|
|
639
|
+
function buildASSFilter(assFilePath, inputLabel) {
|
|
640
|
+
// Escape path for FFmpeg filter
|
|
641
|
+
const escapedPath = assFilePath
|
|
642
|
+
.replace(/\\/g, "/")
|
|
643
|
+
.replace(/:/g, "\\:")
|
|
644
|
+
.replace(/'/g, "'\\''");
|
|
645
|
+
|
|
646
|
+
const outputLabel = "[outass]";
|
|
647
|
+
const filter = `${inputLabel}ass='${escapedPath}'${outputLabel}`;
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
filter,
|
|
651
|
+
finalLabel: outputLabel,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Validate subtitle clip configuration
|
|
657
|
+
* @param {Object} clip - Subtitle clip
|
|
658
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
659
|
+
*/
|
|
660
|
+
function validateSubtitleClip(clip) {
|
|
661
|
+
const errors = [];
|
|
662
|
+
|
|
663
|
+
if (clip.type === "subtitle") {
|
|
664
|
+
if (!clip.url || typeof clip.url !== "string") {
|
|
665
|
+
errors.push("subtitle clip requires a 'url' path to the subtitle file");
|
|
666
|
+
} else {
|
|
667
|
+
const ext = path.extname(clip.url).toLowerCase();
|
|
668
|
+
if (![".srt", ".ass", ".ssa", ".vtt"].includes(ext)) {
|
|
669
|
+
errors.push(
|
|
670
|
+
`Unsupported subtitle format '${ext}'. Supported: .srt, .ass, .ssa, .vtt`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (clip.type === "text" && clip.mode === "karaoke") {
|
|
677
|
+
if (!clip.text || typeof clip.text !== "string" || !clip.text.trim()) {
|
|
678
|
+
errors.push("karaoke mode requires 'text' to be specified");
|
|
679
|
+
}
|
|
680
|
+
if (typeof clip.position !== "number") {
|
|
681
|
+
errors.push("karaoke mode requires 'position' (start time)");
|
|
682
|
+
}
|
|
683
|
+
if (typeof clip.end !== "number") {
|
|
684
|
+
errors.push("karaoke mode requires 'end' time");
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return { valid: errors.length === 0, errors };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
module.exports = {
|
|
692
|
+
buildKaraokeASS,
|
|
693
|
+
buildSubtitleASS,
|
|
694
|
+
buildASSFilter,
|
|
695
|
+
loadSubtitleFile,
|
|
696
|
+
parseSRT,
|
|
697
|
+
parseVTT,
|
|
698
|
+
validateSubtitleClip,
|
|
699
|
+
hexToASSColor,
|
|
700
|
+
secondsToASSTime,
|
|
701
|
+
escapeASSText,
|
|
702
|
+
generateASSHeader,
|
|
703
|
+
generateASSStyle,
|
|
704
|
+
generateASSStyles,
|
|
705
|
+
generateASSDialogue,
|
|
706
|
+
generateASSEvents,
|
|
707
|
+
};
|