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.
@@ -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
+ };