= {
- webvtt: 'wvtt',
+ webvtt: 'S_TEXT/WEBVTT',
+ tx3g: 'S_TEXT/UTF8', // Matroska doesn't have tx3g, convert to SRT
+ ttml: 'S_TEXT/WEBVTT', // Matroska doesn't have TTML, convert to WebVTT
+ srt: 'S_TEXT/UTF8',
+ ass: 'S_TEXT/ASS',
+ ssa: 'S_TEXT/SSA',
};
return map[trackData.track.source._codec];
}
@@ -949,14 +955,17 @@ export class MatroskaMuxer extends Muxer {
let bodyText = cue.text;
const timestampMs = Math.round(timestamp * 1000);
- // Replace in-body timestamps so that they're relative to the cue start time
- inlineTimestampRegex.lastIndex = 0;
- bodyText = bodyText.replace(inlineTimestampRegex, (match) => {
- const time = parseSubtitleTimestamp(match.slice(1, -1));
- const offsetTime = time - timestampMs;
+ if (track.source._codec === 'ass' || track.source._codec === 'ssa') {
+ bodyText = convertDialogueLineToMkvFormat(bodyText);
+ } else {
+ inlineTimestampRegex.lastIndex = 0;
+ bodyText = bodyText.replace(inlineTimestampRegex, (match) => {
+ const time = parseSubtitleTimestamp(match.slice(1, -1));
+ const offsetTime = time - timestampMs;
- return `<${formatSubtitleTimestamp(offsetTime)}>`;
- });
+ return `<${formatSubtitleTimestamp(offsetTime)}>`;
+ });
+ }
const body = textEncoder.encode(bodyText);
const additions = `${cue.settings ?? ''}\n${cue.identifier ?? ''}\n${cue.notes ?? ''}`;
diff --git a/src/output-format.ts b/src/output-format.ts
index adef3a90..6f675954 100644
--- a/src/output-format.ts
+++ b/src/output-format.ts
@@ -310,15 +310,15 @@ export class Mp4OutputFormat extends IsobmffOutputFormat {
'pcm-s24',
'pcm-s24be',
'pcm-s32',
- 'pcm-s32be',
- 'pcm-f32',
- 'pcm-f32be',
- 'pcm-f64',
- 'pcm-f64be',
-
- ...SUBTITLE_CODECS,
- ];
- }
+ 'pcm-s32be',
+ 'pcm-f32',
+ 'pcm-f32be',
+ 'pcm-f64',
+ 'pcm-f64be',
+ // Only WebVTT subtitles are supported in MP4
+ 'webvtt',
+ ];
+ }
/** @internal */
override _codecUnsupportedHint(codec: MediaCodec) {
@@ -358,6 +358,8 @@ export class MovOutputFormat extends IsobmffOutputFormat {
return [
...VIDEO_CODECS,
...AUDIO_CODECS,
+ // Only WebVTT subtitles are supported in MOV
+ 'webvtt',
];
}
diff --git a/src/subtitles.ts b/src/subtitles.ts
index 2ae1e508..87ec56ef 100644
--- a/src/subtitles.ts
+++ b/src/subtitles.ts
@@ -6,25 +6,50 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
+import type { SubtitleCodec } from './codec.js';
+
+/**
+ * Represents a single subtitle cue with timing and text.
+ * @group Media sources
+ * @public
+ */
export type SubtitleCue = {
- timestamp: number; // in seconds
- duration: number; // in seconds
+ /** When the subtitle should appear, in seconds. */
+ timestamp: number;
+ /** How long the subtitle should be displayed, in seconds. */
+ duration: number;
+ /** The subtitle text content. */
text: string;
+ /** Optional cue identifier. */
identifier?: string;
+ /** Optional format-specific settings (e.g., VTT positioning). */
settings?: string;
+ /** Optional notes or comments. */
notes?: string;
};
+/**
+ * Subtitle configuration data.
+ * @group Media sources
+ * @public
+ */
export type SubtitleConfig = {
+ /** Format-specific description (e.g., WebVTT preamble, ASS/SSA header). */
description: string;
};
+/**
+ * Metadata associated with subtitle cues.
+ * @group Media sources
+ * @public
+ */
export type SubtitleMetadata = {
+ /** Optional subtitle configuration. */
config?: SubtitleConfig;
};
type SubtitleParserOptions = {
- codec: 'webvtt';
+ codec: SubtitleCodec;
output: (cue: SubtitleCue, metadata: SubtitleMetadata) => unknown;
};
@@ -42,6 +67,45 @@ export class SubtitleParser {
}
parse(text: string) {
+ if (this.options.codec === 'srt') {
+ this.parseSrt(text);
+ } else if (this.options.codec === 'ass' || this.options.codec === 'ssa') {
+ this.parseAss(text);
+ } else if (this.options.codec === 'tx3g') {
+ this.parseTx3g(text);
+ } else if (this.options.codec === 'ttml') {
+ this.parseTtml(text);
+ } else {
+ this.parseWebVTT(text);
+ }
+ }
+
+ private parseSrt(text: string) {
+ const cues = splitSrtIntoCues(text);
+
+ for (let i = 0; i < cues.length; i++) {
+ const meta: SubtitleMetadata = {};
+ // SRT doesn't have a header, but we need to provide a config for the first cue
+ if (i === 0) {
+ meta.config = { description: '' };
+ }
+ this.options.output(cues[i]!, meta);
+ }
+ }
+
+ private parseAss(text: string) {
+ const { header, cues } = splitAssIntoCues(text);
+
+ for (let i = 0; i < cues.length; i++) {
+ const meta: SubtitleMetadata = {};
+ if (i === 0 && header) {
+ meta.config = { description: header };
+ }
+ this.options.output(cues[i]!, meta);
+ }
+ }
+
+ private parseWebVTT(text: string) {
text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
cueBlockHeaderRegex.lastIndex = 0;
@@ -105,9 +169,52 @@ export class SubtitleParser {
this.options.output(cue, meta);
}
}
+
+ private parseTx3g(text: string) {
+ // tx3g (3GPP Timed Text) samples are usually already plain text
+ // For now, treat as plain text cue - timing comes from container
+ const meta: SubtitleMetadata = { config: { description: '' } };
+ const cue: SubtitleCue = {
+ timestamp: 0,
+ duration: 0,
+ text: text.trim(),
+ };
+ this.options.output(cue, meta);
+ }
+
+ private parseTtml(text: string) {
+ // Basic TTML parsing - extract text content from elements
+ // TODO: Full TTML/IMSC parser with styling support
+ const pRegex = /
]*>(.*?)<\/p>/gs;
+ const matches = [...text.matchAll(pRegex)];
+
+ for (let i = 0; i < matches.length; i++) {
+ const match = matches[i]!;
+ const content = match[1]?.replace(/<[^>]+>/g, '') || ''; // Strip inner tags
+
+ const meta: SubtitleMetadata = {};
+ if (i === 0) {
+ meta.config = { description: '' };
+ }
+
+ const cue: SubtitleCue = {
+ timestamp: 0,
+ duration: 0,
+ text: content.trim(),
+ };
+
+ this.options.output(cue, meta);
+ }
+ }
}
const timestampRegex = /(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})/;
+
+/**
+ * Parses a WebVTT timestamp string to milliseconds.
+ * @group Media sources
+ * @internal
+ */
export const parseSubtitleTimestamp = (string: string) => {
const match = timestampRegex.exec(string);
if (!match) throw new Error('Expected match.');
@@ -118,6 +225,11 @@ export const parseSubtitleTimestamp = (string: string) => {
+ Number(match[4]);
};
+/**
+ * Formats milliseconds to WebVTT timestamp format.
+ * @group Media sources
+ * @internal
+ */
export const formatSubtitleTimestamp = (timestamp: number) => {
const hours = Math.floor(timestamp / (60 * 60 * 1000));
const minutes = Math.floor((timestamp % (60 * 60 * 1000)) / (60 * 1000));
@@ -129,3 +241,471 @@ export const formatSubtitleTimestamp = (timestamp: number) => {
+ seconds.toString().padStart(2, '0') + '.'
+ milliseconds.toString().padStart(3, '0');
};
+
+// SRT parsing functions
+const srtTimestampRegex = /(\d{2}):(\d{2}):(\d{2}),(\d{3})/;
+
+/**
+ * Parses an SRT timestamp string (HH:MM:SS,mmm) to seconds.
+ * @group Media sources
+ * @public
+ */
+export const parseSrtTimestamp = (timeString: string): number => {
+ const match = srtTimestampRegex.exec(timeString);
+ if (!match) throw new Error('Invalid SRT timestamp format');
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ const seconds = Number(match[3]);
+ const milliseconds = Number(match[4]);
+
+ return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
+};
+
+/**
+ * Formats seconds to SRT timestamp format (HH:MM:SS,mmm).
+ * @group Media sources
+ * @public
+ */
+export const formatSrtTimestamp = (seconds: number): string => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ const milliseconds = Math.round((seconds % 1) * 1000);
+
+ return hours.toString().padStart(2, '0') + ':'
+ + minutes.toString().padStart(2, '0') + ':'
+ + secs.toString().padStart(2, '0') + ','
+ + milliseconds.toString().padStart(3, '0');
+};
+
+/**
+ * Splits SRT subtitle text into individual cues.
+ * @group Media sources
+ * @public
+ */
+export const splitSrtIntoCues = (text: string): SubtitleCue[] => {
+ text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
+
+ const cues: SubtitleCue[] = [];
+ const cueRegex = /(\d+)\n(\d{2}:\d{2}:\d{2},\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2},\d{3})\n([\s\S]*?)(?=\n\n\d+\n|\n*$)/g;
+
+ let match: RegExpExecArray | null;
+ while ((match = cueRegex.exec(text))) {
+ const startTime = parseSrtTimestamp(match[2]!);
+ const endTime = parseSrtTimestamp(match[3]!);
+ const cueText = match[4]!.trim();
+
+ cues.push({
+ timestamp: startTime,
+ duration: endTime - startTime,
+ text: cueText,
+ identifier: match[1],
+ });
+ }
+
+ return cues;
+};
+
+/**
+ * Extracts plain text from ASS/SSA Dialogue/Comment line.
+ * If the text is already plain (not ASS format), returns as-is.
+ */
+const extractTextFromAssCue = (text: string): string => {
+ // Check if this is an ASS Dialogue/Comment line
+ if (text.startsWith('Dialogue:') || text.startsWith('Comment:')) {
+ // ASS format: Dialogue: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
+ // We need to extract the last field (Text) which may contain commas
+ const colonIndex = text.indexOf(':');
+ if (colonIndex === -1) return text;
+
+ const afterColon = text.substring(colonIndex + 1);
+ const parts = afterColon.split(',');
+
+ // Text is the 10th field (index 9), but it may contain commas
+ // So we need to join everything from index 9 onward
+ if (parts.length >= 10) {
+ return parts.slice(9).join(',');
+ }
+ }
+
+ // Check if this is MKV ASS format (without Dialogue: prefix)
+ // MKV format: ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text
+ // OR: Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text
+ const parts = text.split(',');
+ if (parts.length >= 8) {
+ const firstPart = parts[0]?.trim();
+ const secondPart = parts[1]?.trim();
+
+ // Check if first field is numeric (Layer or ReadOrder)
+ if (firstPart && !isNaN(parseInt(firstPart))) {
+ // Check if second field is also numeric (ReadOrder,Layer format)
+ if (secondPart && !isNaN(parseInt(secondPart)) && parts.length >= 9) {
+ // MKV format with ReadOrder: text is 9th field (index 8) onward
+ return parts.slice(8).join(',');
+ } else if (parts.length >= 8) {
+ // Standard ASS format without ReadOrder: text is 8th field (index 7) onward
+ return parts.slice(7).join(',');
+ }
+ }
+ }
+
+ // Not ASS format, return as-is
+ return text;
+};
+
+/**
+ * Formats subtitle cues back to SRT text format.
+ * @group Media sources
+ * @public
+ */
+export const formatCuesToSrt = (cues: SubtitleCue[]): string => {
+ return cues.map((cue, index) => {
+ const sequenceNumber = index + 1;
+ const startTime = formatSrtTimestamp(cue.timestamp);
+ const endTime = formatSrtTimestamp(cue.timestamp + cue.duration);
+ const text = extractTextFromAssCue(cue.text);
+
+ return `${sequenceNumber}\n${startTime} --> ${endTime}\n${text}\n`;
+ }).join('\n');
+};
+
+/**
+ * Formats subtitle cues back to WebVTT text format.
+ * @group Media sources
+ * @public
+ */
+export const formatCuesToWebVTT = (cues: SubtitleCue[], preamble?: string): string => {
+ // Start with the WebVTT header
+ let result = preamble || 'WEBVTT\n';
+
+ // Ensure there's a blank line after the header
+ if (!result.endsWith('\n\n')) {
+ result += '\n';
+ }
+
+ // Format each cue
+ const formattedCues = cues.map((cue) => {
+ const startTime = formatSubtitleTimestamp(cue.timestamp * 1000); // Convert to milliseconds
+ const endTime = formatSubtitleTimestamp((cue.timestamp + cue.duration) * 1000);
+ const text = extractTextFromAssCue(cue.text);
+
+ // WebVTT doesn't require sequence numbers like SRT
+ return `${startTime} --> ${endTime}\n${text}`;
+ });
+
+ return result + formattedCues.join('\n\n');
+};
+
+// ASS/SSA parsing functions
+const assTimestampRegex = /(\d+):(\d{2}):(\d{2})\.(\d{2})/;
+
+/**
+ * Parses an ASS/SSA timestamp string (H:MM:SS.cc) to seconds.
+ * @group Media sources
+ * @public
+ */
+export const parseAssTimestamp = (timeString: string): number => {
+ const match = assTimestampRegex.exec(timeString);
+ if (!match) throw new Error('Invalid ASS timestamp format');
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ const seconds = Number(match[3]);
+ const centiseconds = Number(match[4]);
+
+ return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
+};
+
+/**
+ * Formats seconds to ASS/SSA timestamp format (H:MM:SS.cc).
+ * @group Media sources
+ * @public
+ */
+export const formatAssTimestamp = (seconds: number): string => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ const centiseconds = Math.floor((seconds % 1) * 100);
+
+ return hours.toString() + ':'
+ + minutes.toString().padStart(2, '0') + ':'
+ + secs.toString().padStart(2, '0') + '.'
+ + centiseconds.toString().padStart(2, '0');
+};
+
+/**
+ * Splits ASS/SSA subtitle text into header (styles) and individual cues.
+ * Preserves all sections including [Fonts], [Graphics], and Aegisub sections.
+ * Aegisub sections are moved to the end to avoid breaking [Events].
+ * @group Media sources
+ * @public
+ */
+export const splitAssIntoCues = (text: string): { header: string; cues: SubtitleCue[] } => {
+ text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
+
+ const lines = text.split('\n');
+
+ // Find [Events] section
+ const eventsIndex = lines.findIndex(line => line.trim() === '[Events]');
+ if (eventsIndex === -1) {
+ return { header: text, cues: [] };
+ }
+
+ // Separate sections for proper ordering
+ const headerSections: string[] = []; // [Script Info], [V4+ Styles], etc. (before Events)
+ const eventsHeader: string[] = []; // [Events] and Format: line
+ const eventLines: string[] = []; // Dialogue/Comment lines
+ const postEventsSections: string[] = []; // [Fonts], [Graphics], [Aegisub...] (after Events)
+
+ let currentSection: string[] = headerSections;
+ let inEventsSection = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Section header
+ if (line && line.startsWith('[') && line.endsWith(']')) {
+ const trimmedLine = line.trim();
+
+ if (trimmedLine === '[Events]') {
+ inEventsSection = true;
+ eventsHeader.push(line);
+ continue;
+ }
+
+ // Any section after [Events] goes to post-events
+ if (inEventsSection) {
+ currentSection = postEventsSections;
+ inEventsSection = false;
+ }
+
+ currentSection.push(line);
+ continue;
+ }
+
+ if (inEventsSection) {
+ if (!line) {
+ continue; // Skip empty lines in Events
+ }
+
+ if (line.startsWith('Format:')) {
+ eventsHeader.push(line);
+ } else if (line.startsWith('Dialogue:')) {
+ // Dialogue lines go to eventLines (will be reconstructed with timestamps from blocks)
+ eventLines.push(line);
+ } else if (line.startsWith('Comment:')) {
+ // Comment lines stay in header (they're metadata, not in MKV blocks)
+ eventsHeader.push(line);
+ }
+ } else {
+ if (line !== undefined) {
+ currentSection.push(line);
+ }
+ }
+ }
+
+ // Build header: everything except Dialogue lines (keep Comments)
+ // Format: [Header Sections] + [Events] + Format + Comments + [Post-Events Sections]
+ const header = [
+ ...headerSections,
+ ...eventsHeader, // Includes [Events], Format:, and Comment: lines
+ ...postEventsSections,
+ ].join('\n');
+
+ // Parse Comment and Dialogue lines
+ const cues: SubtitleCue[] = [];
+
+ for (const line of eventLines) {
+ // Parse ASS dialogue/comment format
+ // Dialogue: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
+ const colonIndex = line.indexOf(':');
+ if (colonIndex === -1) continue;
+
+ const parts = line.substring(colonIndex + 1).split(',');
+ if (parts.length < 10) continue;
+
+ try {
+ const startTime = parseAssTimestamp(parts[1]!.trim());
+ const endTime = parseAssTimestamp(parts[2]!.trim());
+
+ cues.push({
+ timestamp: startTime,
+ duration: endTime - startTime,
+ text: line, // Store the entire line (Dialogue: or Comment:)
+ });
+ } catch {
+ // Skip malformed lines
+ continue;
+ }
+ }
+
+ return { header, cues };
+};
+
+/**
+ * Parses ASS Format line to get field order.
+ * Returns map of field name to index.
+ */
+const parseAssFormat = (formatLine: string): Map => {
+ // Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+ const fields = formatLine
+ .substring(formatLine.indexOf(':') + 1)
+ .split(',')
+ .map(f => f.trim());
+
+ const fieldMap = new Map();
+ fields.forEach((field, index) => {
+ fieldMap.set(field, index);
+ });
+
+ return fieldMap;
+};
+
+/**
+ * Converts a full Dialogue/Comment line to MKV block format.
+ * @group Media sources
+ * @internal
+ */
+export const convertDialogueLineToMkvFormat = (line: string): string => {
+ const match = /^(Dialogue|Comment):\s*(\d+),\d+:\d{2}:\d{2}\.\d{2},\d+:\d{2}:\d{2}\.\d{2},(.*)$/.exec(line);
+ if (match) {
+ const layer = match[2];
+ const restFields = match[3];
+ return `${layer},${restFields}`;
+ }
+
+ if (line.startsWith('Dialogue:') || line.startsWith('Comment:')) {
+ return line.substring(line.indexOf(':') + 1).trim();
+ }
+
+ return line;
+};
+
+/**
+ * Formats subtitle cues back to ASS/SSA text format with header.
+ * Properly inserts Dialogue/Comment lines within [Events] section.
+ * @group Media sources
+ * @public
+ */
+export const formatCuesToAss = (cues: SubtitleCue[], header: string): string => {
+ // If header is empty or missing, create a default ASS header
+ if (!header || header.trim() === '') {
+ header = `[Script Info]
+Title: Default
+ScriptType: v4.00+
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`;
+ }
+
+ // Find [Events] section and its Format line
+ const headerLines = header.split('\n');
+ const eventsIndex = headerLines.findIndex(line => line.trim() === '[Events]');
+
+ if (eventsIndex === -1) {
+ // No [Events] section, create one
+ return header + `\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n` + cues.map(c => c.text).join('\n');
+ }
+
+ // Find Format line AFTER [Events]
+ let formatIndex = -1;
+ let formatLine = '';
+ for (let i = eventsIndex + 1; i < headerLines.length; i++) {
+ const line = headerLines[i];
+ if (line && line.trim().startsWith('Format:')) {
+ formatIndex = i;
+ formatLine = line;
+ break;
+ }
+ // Stop if we hit another section
+ if (line && line.startsWith('[') && line.endsWith(']')) {
+ break;
+ }
+ }
+
+ // Parse format to understand field order
+ const fieldMap = formatLine ? parseAssFormat(formatLine) : null;
+
+ // Reconstruct dialogue lines with proper field order
+ const dialogueLines = cues.map(cue => {
+ // If text already has full Dialogue/Comment line with timestamps, use as-is
+ if (cue.text.startsWith('Dialogue:') || cue.text.startsWith('Comment:')) {
+ if (/^(Dialogue|Comment):\s*\d+,\d+:\d{2}:\d{2}\.\d{2},\d+:\d{2}:\d{2}\.\d{2},/.test(cue.text)) {
+ return cue.text;
+ }
+ }
+
+ // Parse MKV block data or plain text
+ let params = cue.text;
+ const isComment = params.startsWith('Comment:');
+ const prefix = isComment ? 'Comment:' : 'Dialogue:';
+
+ if (params.startsWith('Dialogue:') || params.startsWith('Comment:')) {
+ params = params.substring(params.indexOf(':') + 1).trim();
+ }
+
+ const parts = params.split(',');
+ const startTime = formatAssTimestamp(cue.timestamp);
+ const endTime = formatAssTimestamp(cue.timestamp + cue.duration);
+
+ let layer: string;
+ let restFields: string[];
+
+ // Detect ReadOrder format from actual block data first
+ // MKV blocks: ReadOrder,Layer,Style,... (9+ fields, first two numeric) OR Layer,Style,... (8+ fields, first numeric)
+ const blockHasReadOrder = parts.length >= 9 && !isNaN(parseInt(parts[0]!)) && !isNaN(parseInt(parts[1]!));
+ const blockHasLayer = parts.length >= 8 && !isNaN(parseInt(parts[0]!));
+
+ if (blockHasReadOrder) {
+ layer = parts[1] || '0';
+ restFields = parts.slice(2);
+ } else if (blockHasLayer) {
+ layer = parts[0] || '0';
+ restFields = parts.slice(1);
+ } else {
+ return `${prefix} 0,${startTime},${endTime},Default,,0,0,0,,${cue.text}`;
+ }
+
+ return `${prefix} ${layer},${startTime},${endTime},${restFields.join(',')}`;
+ });
+
+ if (formatIndex === -1) {
+ // No Format line found, just append
+ return header + '\n' + dialogueLines.join('\n');
+ }
+
+ // Find Comment lines and next section after [Events]
+ const commentLines: string[] = [];
+ let nextSectionIndex = headerLines.length;
+
+ for (let i = formatIndex + 1; i < headerLines.length; i++) {
+ const line = headerLines[i];
+ if (line && line.startsWith('Comment:')) {
+ commentLines.push(line);
+ }
+ if (line && line.startsWith('[') && line.endsWith(']')) {
+ nextSectionIndex = i;
+ break;
+ }
+ }
+
+ // Build final structure:
+ // 1. Everything up to and including Format line
+ // 2. All Dialogue lines
+ // 3. All Comment lines (at the end of Events)
+ // 4. Everything after Events section
+ const beforeDialogues = headerLines.slice(0, formatIndex + 1);
+ const afterDialogues = headerLines.slice(nextSectionIndex);
+
+ return [
+ ...beforeDialogues,
+ ...dialogueLines,
+ ...commentLines,
+ ...afterDialogues,
+ ].join('\n');
+};
diff --git a/test/node/isobmff-subtitle.test.ts b/test/node/isobmff-subtitle.test.ts
new file mode 100644
index 00000000..6e7a097b
--- /dev/null
+++ b/test/node/isobmff-subtitle.test.ts
@@ -0,0 +1,190 @@
+/*!
+ * Copyright (c) 2025-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { Input, FilePathSource, ALL_FORMATS } from '../../src/index.js';
+
+describe('ISOBMFF Subtitle Demuxing', () => {
+ it('should detect WebVTT subtitle track in MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-webvtt.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+
+ const track = subtitleTracks[0]!;
+ expect(track.codec).toBe('webvtt');
+ expect(track.languageCode).toBe('eng');
+ });
+
+ it('should export WebVTT subtitle track to WebVTT format', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-webvtt.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('webvtt');
+
+ const subtitleText = await track.exportToText();
+
+ // Check that it's WebVTT format
+ expect(subtitleText).toMatch(/^WEBVTT/);
+ expect(subtitleText).toMatch(/\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}/);
+ expect(subtitleText).toContain('Hello world!');
+ });
+
+ it('should read WebVTT cues from MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-webvtt.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ expect(cues.length).toBeGreaterThan(0);
+ expect(cues[0]!).toHaveProperty('timestamp');
+ expect(cues[0]!).toHaveProperty('duration');
+ expect(cues[0]!).toHaveProperty('text');
+ });
+
+ it('should detect tx3g subtitle track in MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-tx3g.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+
+ const track = subtitleTracks[0]!;
+ expect(track.codec).toBe('tx3g');
+ expect(track.languageCode).toBe('eng');
+ });
+
+ it('should detect tx3g subtitle track in MOV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mov-tx3g.mov'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+
+ const track = subtitleTracks[0]!;
+ expect(track.codec).toBe('tx3g');
+ // MOV file may have undefined language
+ expect(['eng', 'und']).toContain(track.languageCode);
+ });
+
+ it('should export tx3g subtitle track to SRT format', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-tx3g.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('tx3g');
+
+ const subtitleText = await track.exportToText();
+
+ // Check that it's SRT format
+ expect(subtitleText).toMatch(/\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/);
+ expect(subtitleText).toContain('Hello world!');
+ });
+
+ it('should read tx3g cues from MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-tx3g.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ expect(cues.length).toBeGreaterThan(0);
+ expect(cues[0]!).toHaveProperty('timestamp');
+ expect(cues[0]!).toHaveProperty('duration');
+ expect(cues[0]!).toHaveProperty('text');
+ });
+
+ it('should detect TTML subtitle track in MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-ttml.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+
+ const track = subtitleTracks[0]!;
+ expect(track.codec).toBe('ttml');
+ expect(track.languageCode).toBe('eng');
+ });
+
+ it('should detect TTML subtitle track in MOV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mov-ttml.mov'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+
+ const track = subtitleTracks[0]!;
+ expect(track.codec).toBe('ttml');
+ expect(track.languageCode).toBe('eng');
+ });
+
+ it('should export TTML subtitle track to TTML format', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-ttml.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('ttml');
+
+ const subtitleText = await track.exportToText();
+
+ // Check that it's TTML format
+ expect(subtitleText).toMatch(/]*xmlns="http:\/\/www\.w3\.org\/ns\/ttml"/);
+ expect(subtitleText).toContain('Hello world!');
+ });
+
+ it('should read TTML cues from MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-ttml.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ expect(cues.length).toBeGreaterThan(0);
+ expect(cues[0]!).toHaveProperty('timestamp');
+ expect(cues[0]!).toHaveProperty('duration');
+ expect(cues[0]!).toHaveProperty('text');
+ });
+
+});
diff --git a/test/node/matroska-subtitle.test.ts b/test/node/matroska-subtitle.test.ts
new file mode 100644
index 00000000..142b31e6
--- /dev/null
+++ b/test/node/matroska-subtitle.test.ts
@@ -0,0 +1,127 @@
+/*!
+ * Copyright (c) 2025-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { Input, FilePathSource, ALL_FORMATS } from '../../src/index.js';
+
+describe('Matroska Subtitle Demuxing', () => {
+ it('should detect SRT subtitle track in MKV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks).toHaveLength(1);
+
+ const track = subtitleTracks[0]!;
+ expect(track.codec).toBe('srt');
+ expect(track.internalCodecId).toBe('S_TEXT/UTF8');
+ expect(track.languageCode).toBe('eng');
+ });
+
+ it('should detect ASS subtitle track in MKV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('ass');
+ expect(track.internalCodecId).toBe('S_TEXT/ASS');
+ });
+
+ it('should detect SSA subtitle track in MKV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ssa.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ // FFmpeg converts SSA to ASS (ASS is superset of SSA)
+ expect(track.codec).toBe('ass');
+ expect(track.internalCodecId).toBe('S_TEXT/ASS');
+ });
+
+ it('should detect WebVTT subtitle track in MKV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-vtt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('webvtt');
+ // FFmpeg uses D_WEBVTT/SUBTITLES instead of S_TEXT/WEBVTT
+ expect(track.internalCodecId).toBe('D_WEBVTT/SUBTITLES');
+ });
+
+ it('should read subtitle cues from MKV with SRT', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ expect(cues.length).toBeGreaterThan(0);
+ expect(cues[0]!.text).toContain('Hello world');
+ expect(cues[0]!.timestamp).toBeCloseTo(1.0, 1);
+ expect(cues[0]!.duration).toBeCloseTo(2.5, 1);
+ });
+
+ it('should export SRT subtitle track to text', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const srtText = await track.exportToText();
+
+ // Check for SRT timestamp format (HH:MM:SS,mmm)
+ expect(srtText).toMatch(/\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/);
+ expect(srtText).toContain('Hello world');
+ });
+
+ it('should preserve ASS CodecPrivate header', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText();
+
+ expect(assText).toContain('[Script Info]');
+ expect(assText).toContain('[V4+ Styles]');
+ expect(assText).toContain('Dialogue:');
+ });
+
+ it('should handle multiple subtitle tracks', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-multi.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await input.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThanOrEqual(2);
+
+ const srtTrack = subtitleTracks.find(t => t.codec === 'srt');
+ const assTrack = subtitleTracks.find(t => t.codec === 'ass');
+
+ expect(srtTrack).toBeDefined();
+ expect(assTrack).toBeDefined();
+ expect(srtTrack?.languageCode).toBe('eng');
+ expect(assTrack?.languageCode).toBe('spa');
+ });
+});
diff --git a/test/node/subtitle-advanced.test.ts b/test/node/subtitle-advanced.test.ts
new file mode 100644
index 00000000..3da6277c
--- /dev/null
+++ b/test/node/subtitle-advanced.test.ts
@@ -0,0 +1,515 @@
+/*!
+ * Copyright (c) 2025-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ Input,
+ FilePathSource,
+ ALL_FORMATS,
+ Conversion,
+ Output,
+ BufferTarget,
+ MkvOutputFormat,
+ BufferSource,
+ TextSubtitleSource,
+} from '../../src/index.js';
+import { formatCuesToAss, convertDialogueLineToMkvFormat } from '../../src/subtitles.js';
+
+describe('Advanced ASS Features', () => {
+ it('should preserve Comment lines from CodecPrivate', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass-fonts.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('ass');
+
+ const codecPrivate = (track as any)._backing.getCodecPrivate();
+ console.log('\n=== CodecPrivate has Comment? ===', codecPrivate?.includes('Comment:'));
+
+ const cues = [];
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+ console.log('Total cues:', cues.length);
+ console.log('First cue text:', cues[0]?.text);
+
+ const assText = await track.exportToText('ass');
+
+ console.log('\n=== Exported has Comment? ===', assText.includes('Comment:'));
+
+ // Should preserve Comment line from CodecPrivate
+ expect(assText).toContain('Comment:');
+ expect(assText).toContain('This is a comment');
+ });
+
+ it('should preserve [Fonts] section from CodecPrivate', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass-fonts.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText('ass');
+
+ // Should have [Fonts] section
+ expect(assText).toContain('[Fonts]');
+ expect(assText).toContain('fontname: CustomFont');
+ });
+
+ it('should preserve [Graphics] section from CodecPrivate', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass-fonts.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText('ass');
+
+ // Should have [Graphics] section
+ expect(assText).toContain('[Graphics]');
+ expect(assText).toContain('filename: logo.png');
+ });
+
+ it('should place Dialogue lines in correct position after Comment', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass-fonts.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText('ass');
+
+ // Verify Event ordering: Format, then Dialogue, then Comment (at end of Events)
+ const formatIdx = assText.indexOf('Format:');
+ const firstDialogueIdx = assText.indexOf('Dialogue:');
+ const commentIdx = assText.indexOf('Comment:');
+ const fontsIdx = assText.indexOf('[Fonts]');
+
+ // Proper order: Format < Dialogue < Comment < [Fonts]
+ expect(formatIdx).toBeGreaterThan(-1);
+ expect(firstDialogueIdx).toBeGreaterThan(formatIdx);
+ expect(commentIdx).toBeGreaterThan(firstDialogueIdx); // Comment AFTER Dialogue
+ if (fontsIdx > -1) {
+ expect(commentIdx).toBeLessThan(fontsIdx); // Comment before [Fonts]
+ }
+ });
+
+ it('should have proper section ordering', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass-fonts.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText('ass');
+
+ const sections = [];
+ const lines = assText.split('\n');
+
+ for (const line of lines) {
+ if (line.startsWith('[') && line.endsWith(']')) {
+ sections.push(line);
+ }
+ }
+
+ console.log('Section order:', sections);
+
+ // Expected order: [Script Info], [V4+ Styles], [Events], [Fonts], [Graphics]
+ expect(sections[0]).toBe('[Script Info]');
+ expect(sections).toContain('[V4+ Styles]');
+ expect(sections).toContain('[Events]');
+ expect(sections).toContain('[Fonts]');
+ expect(sections).toContain('[Graphics]');
+
+ // [Events] should come before [Fonts] and [Graphics]
+ const eventsIdx = sections.indexOf('[Events]');
+ const fontsIdx = sections.indexOf('[Fonts]');
+ const graphicsIdx = sections.indexOf('[Graphics]');
+
+ expect(eventsIdx).toBeLessThan(fontsIdx);
+ expect(eventsIdx).toBeLessThan(graphicsIdx);
+ });
+});
+
+describe('ASS Edge Cases - Parsing and Reconstruction', () => {
+ it('should handle text starting with comma in MKV format', async () => {
+ // Create ASS subtitle with text that starts with comma
+ const assContent = `[Script Info]
+Title: Test
+ScriptType: v4.00+
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,,comma-leading text`;
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const subtitleSource = new TextSubtitleSource('ass');
+ output.addSubtitleTrack(subtitleSource, { languageCode: 'eng' });
+
+ await output.start();
+
+ await subtitleSource.add(assContent);
+ subtitleSource.close();
+
+ await output.finalize();
+
+ // Read back
+ const input = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ expect(cues.length).toBe(1);
+ // Should not have double comma at start
+ expect(cues[0]!.text).not.toMatch(/^,/);
+
+ const exported = await track.exportToText('ass');
+ const dialogueLine = exported.split('\n').find(l => l.startsWith('Dialogue:'));
+ expect(dialogueLine).toContain(',comma-leading text');
+ // Should not have duplicate field data
+ expect(dialogueLine).not.toMatch(/Default,,0,0,0,,.*,Default,,0,0,0,,/);
+
+ input[Symbol.dispose]();
+ });
+
+ it('should handle text containing multiple commas', async () => {
+ const assContent = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello, world, how, are, you?`;
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const subtitleSource = new TextSubtitleSource('ass');
+ output.addSubtitleTrack(subtitleSource, { languageCode: 'eng' });
+
+ await output.start();
+
+ await subtitleSource.add(assContent);
+ subtitleSource.close();
+
+ await output.finalize();
+
+ const input = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const exported = await track.exportToText('ass');
+ expect(exported).toContain('Hello, world, how, are, you?');
+
+ input[Symbol.dispose]();
+ });
+
+ it('should handle MKV format with ReadOrder field (9 fields)', async () => {
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const subtitleSource = new TextSubtitleSource('ass');
+ output.addSubtitleTrack(subtitleSource, { languageCode: 'eng' });
+
+ const assContent = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Test text`;
+
+ await output.start();
+
+ await subtitleSource.add(assContent);
+ subtitleSource.close();
+ await output.finalize();
+
+ // Verify MKV block contains proper format (without ReadOrder, but could be added by muxer)
+ const input = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ // MKV block should have format: Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text (8 fields)
+ // or: ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text (9 fields)
+ const parts = cues[0]!.text.split(',');
+ expect(parts.length).toBeGreaterThanOrEqual(8);
+ expect(parts[parts.length - 1]).toBe('Test text');
+
+ input[Symbol.dispose]();
+ });
+
+ it('should handle round-trip ASS -> MKV -> ASS conversion', async () => {
+ using input1 = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target1 = new BufferTarget();
+ const output1 = new Output({
+ format: new MkvOutputFormat(),
+ target: target1,
+ });
+
+ // First conversion: MKV -> MKV (with ASS)
+ const conversion1 = await Conversion.init({
+ input: input1,
+ output: output1,
+ subtitle: { codec: 'ass' },
+ showWarnings: false,
+ });
+
+ await conversion1.execute();
+
+ // Read intermediate result
+ const input2 = new Input({
+ source: new BufferSource(target1.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const target2 = new BufferTarget();
+ const output2 = new Output({
+ format: new MkvOutputFormat(),
+ target: target2,
+ });
+
+ // Second conversion: MKV -> MKV (with ASS)
+ const conversion2 = await Conversion.init({
+ input: input2,
+ output: output2,
+ subtitle: { codec: 'ass' },
+ showWarnings: false,
+ });
+
+ await conversion2.execute();
+
+ // Compare outputs
+ const input3 = new Input({
+ source: new BufferSource(target2.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const track1 = (await input2.subtitleTracks)[0]!;
+ const track2 = (await input3.subtitleTracks)[0]!;
+
+ const text1 = await track1.exportToText('ass');
+ const text2 = await track2.exportToText('ass');
+
+ // Extract just the text content from dialogue lines (ignore timestamp precision differences)
+ const extractText = (line: string) => {
+ // Extract text after the 9th comma (after Effect field)
+ const parts = line.split(',');
+ return parts.slice(9).join(',');
+ };
+
+ const dialogue1 = text1.split('\n').filter(l => l.startsWith('Dialogue:'));
+ const dialogue2 = text2.split('\n').filter(l => l.startsWith('Dialogue:'));
+
+ expect(dialogue1.length).toBe(dialogue2.length);
+
+ // Compare text content (not timestamps due to precision issues)
+ for (let i = 0; i < dialogue1.length; i++) {
+ const text1Content = extractText(dialogue1[i]!);
+ const text2Content = extractText(dialogue2[i]!);
+ expect(text2Content).toBe(text1Content);
+ // Should not have duplicated field data
+ expect(text2Content).not.toMatch(/Default,,0,0,0,,.*Default,,0,0,0,,/);
+ }
+
+ input2[Symbol.dispose]();
+ input3[Symbol.dispose]();
+ });
+
+ it('should handle empty fields in ASS format', async () => {
+ const assContent = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Text with empty name and effect`;
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const subtitleSource = new TextSubtitleSource('ass');
+ output.addSubtitleTrack(subtitleSource, { languageCode: 'eng' });
+
+ await output.start();
+
+ await subtitleSource.add(assContent);
+ subtitleSource.close();
+ await output.finalize();
+
+ const input = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const exported = await (await input.subtitleTracks)[0]!.exportToText('ass');
+ const dialogueLine = exported.split('\n').find(l => l.startsWith('Dialogue:'));
+
+ // Should preserve empty fields
+ expect(dialogueLine).toMatch(/Default,,0,0,0,,Text with empty name and effect/);
+
+ input[Symbol.dispose]();
+ });
+
+ it('should handle convertDialogueLineToMkvFormat helper', () => {
+ // Test with full Dialogue line
+ const fullLine = 'Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello world';
+ const converted = convertDialogueLineToMkvFormat(fullLine);
+ expect(converted).toBe('0,Default,,0,0,0,,Hello world');
+
+ // Test with text containing commas
+ const commaLine = 'Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello, world, test';
+ const convertedComma = convertDialogueLineToMkvFormat(commaLine);
+ expect(convertedComma).toBe('0,Default,,0,0,0,,Hello, world, test');
+
+ // Test with already MKV format
+ const mkvFormat = '0,Default,,0,0,0,,Already MKV format';
+ const convertedMkv = convertDialogueLineToMkvFormat(mkvFormat);
+ expect(convertedMkv).toBe('0,Default,,0,0,0,,Already MKV format');
+ });
+
+ it('should handle formatCuesToAss with different field structures', () => {
+ const cues = [
+ {
+ timestamp: 1.0,
+ duration: 2.0,
+ text: '0,Default,,0,0,0,,Standard format',
+ },
+ {
+ timestamp: 4.0,
+ duration: 2.0,
+ text: '0,0,Default,,0,0,0,,ReadOrder format',
+ },
+ ];
+
+ const header = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`;
+
+ const result = formatCuesToAss(cues, header);
+ const dialogueLines = result.split('\n').filter(l => l.startsWith('Dialogue:'));
+
+ expect(dialogueLines.length).toBe(2);
+ expect(dialogueLines[0]).toContain('Standard format');
+ expect(dialogueLines[1]).toContain('ReadOrder format');
+
+ // Both should have proper timestamps
+ expect(dialogueLines[0]).toMatch(/Dialogue: 0,0:00:01\.00,0:00:03\.00/);
+ expect(dialogueLines[1]).toMatch(/Dialogue: 0,0:00:04\.00,0:00:06\.00/);
+
+ // Should not have extra field between End and Style
+ expect(dialogueLines[0]).toMatch(/Dialogue: 0,0:00:01\.00,0:00:03\.00,Default,,0,0,0,,/);
+ expect(dialogueLines[1]).toMatch(/Dialogue: 0,0:00:04\.00,0:00:06\.00,Default,,0,0,0,,/);
+ expect(dialogueLines[0]).not.toMatch(/End,\d+,Default/);
+ expect(dialogueLines[1]).not.toMatch(/End,\d+,Default/);
+ });
+
+ it('should not create extra commas when text is empty', async () => {
+ const assContent = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,`;
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const subtitleSource = new TextSubtitleSource('ass');
+ output.addSubtitleTrack(subtitleSource, { languageCode: 'eng' });
+
+ await output.start();
+
+ await subtitleSource.add(assContent);
+ subtitleSource.close();
+ await output.finalize();
+
+ const input = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const cues = [];
+ for await (const cue of (await input.subtitleTracks)[0]!.getCues()) {
+ cues.push(cue);
+ }
+
+ // Text should be empty, not starting with comma
+ expect(cues[0]!.text).not.toMatch(/^,/);
+
+ const exported = await (await input.subtitleTracks)[0]!.exportToText('ass');
+ const dialogueLine = exported.split('\n').find(l => l.startsWith('Dialogue:'));
+
+ // Should end with ,, not ,,,
+ expect(dialogueLine).toMatch(/,,$/);
+ expect(dialogueLine).not.toMatch(/,,,$/);
+
+ input[Symbol.dispose]();
+ });
+});
diff --git a/test/node/subtitle-conversion.test.ts b/test/node/subtitle-conversion.test.ts
new file mode 100644
index 00000000..9c908bc8
--- /dev/null
+++ b/test/node/subtitle-conversion.test.ts
@@ -0,0 +1,989 @@
+/*!
+ * Copyright (c) 2025-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ Input,
+ FilePathSource,
+ ALL_FORMATS,
+ Conversion,
+ Output,
+ BufferTarget,
+ BufferSource,
+ MkvOutputFormat,
+ Mp4OutputFormat,
+ WebMOutputFormat,
+ TextSubtitleSource,
+} from '../../src/index.js';
+
+describe('Subtitle Conversion - Basic Cases', () => {
+ it('should passthrough subtitle track when codec matches output format', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'srt', // Keep as SRT
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ expect(conversion.utilizedTracks.filter(t => t.type === 'subtitle').length).toBe(1);
+
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(1);
+ expect(subtitleTracks[0]!.codec).toBe('srt');
+
+ const text = await subtitleTracks[0]!.exportToText();
+ expect(text).toContain('Hello world');
+ });
+
+ it('should convert SRT to WebVTT', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'webvtt',
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(1);
+ expect(subtitleTracks[0]!.codec).toBe('webvtt');
+
+ const text = await subtitleTracks[0]!.exportToText();
+ expect(text).toContain('Hello world');
+ });
+
+ it('should convert ASS to SRT', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'srt',
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(1);
+ expect(subtitleTracks[0]!.codec).toBe('srt');
+
+ const text = await subtitleTracks[0]!.exportToText();
+ expect(text).toMatch(/\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/);
+
+ // Verify ASS metadata is stripped (no "0,0,Default,,0,0,0,," prefix)
+ expect(text).not.toContain('Default,,0,0,0');
+ expect(text).toContain('Hello world!');
+ expect(text).toContain('This is a test');
+ });
+
+ it('should convert WebVTT to SRT', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-vtt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'srt',
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(1);
+ expect(subtitleTracks[0]!.codec).toBe('srt');
+ });
+
+ it('should discard all subtitle tracks when discard is true', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ discard: true,
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ expect(conversion.utilizedTracks.filter(t => t.type === 'subtitle').length).toBe(0);
+ expect(conversion.discardedTracks.filter(t => t.track.type === 'subtitle').length).toBe(1);
+ expect(conversion.discardedTracks[0]!.reason).toBe('discarded_by_user');
+ });
+
+ it('should handle multiple subtitle tracks', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-multi.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'webvtt',
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ const inputTracks = await input.subtitleTracks;
+ expect(conversion.utilizedTracks.filter(t => t.type === 'subtitle').length).toBe(inputTracks.length);
+
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(inputTracks.length);
+
+ for (const track of subtitleTracks) {
+ expect(track.codec).toBe('webvtt');
+ }
+ });
+});
+
+describe('Subtitle Conversion - Track-Specific Options', () => {
+ it('should selectively discard tracks based on language', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-multi.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const inputTracks = await input.subtitleTracks;
+ const firstTrackLang = inputTracks[0]!.languageCode;
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: (track) => {
+ // Keep only the first track's language
+ if (track.languageCode !== firstTrackLang) {
+ return { discard: true };
+ }
+ return {};
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ expect(conversion.utilizedTracks.filter(t => t.type === 'subtitle').length).toBeLessThan(
+ inputTracks.length,
+ );
+
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBeLessThan(inputTracks.length);
+ });
+
+ it('should apply different codec conversion per track', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-multi.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: (track, n) => {
+ // First track to SRT, rest to WebVTT
+ return {
+ codec: n === 1 ? 'srt' : 'webvtt',
+ };
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks[0]!.codec).toBe('srt');
+ for (let i = 1; i < subtitleTracks.length; i++) {
+ expect(subtitleTracks[i]!.codec).toBe('webvtt');
+ }
+ });
+
+ it('should handle mixed operations: keep, convert, discard', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-multi.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const inputTracks = await input.subtitleTracks;
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: (track, n) => {
+ if (n === 1) {
+ // First track: keep as is
+ return {};
+ } else if (n === 2 && inputTracks.length >= 2) {
+ // Second track: convert to SRT
+ return { codec: 'srt' };
+ } else {
+ // Rest: discard
+ return { discard: true };
+ }
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output has expected tracks
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ if (inputTracks.length >= 2) {
+ expect(subtitleTracks.length).toBe(2);
+ expect(subtitleTracks[1]!.codec).toBe('srt');
+ }
+ });
+});
+
+describe('Subtitle Conversion - Trimming', () => {
+ it('should adjust subtitle timestamps when trimming', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ // Get first cue timestamp to use as trim start
+ const firstTrack = (await input.subtitleTracks)[0]!;
+ const cues = [];
+ for await (const cue of firstTrack.getCues()) {
+ cues.push(cue);
+ }
+
+ const trimStart = cues[0]!.timestamp;
+ const trimEnd = cues[Math.min(cues.length - 1, 2)]!.timestamp + 1;
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ trim: {
+ start: trimStart,
+ end: trimEnd,
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ const outputCues = [];
+ for await (const cue of subtitleTracks[0]!.getCues()) {
+ outputCues.push(cue);
+ }
+
+ // First cue should start at 0 (adjusted)
+ expect(outputCues[0]!.timestamp).toBeCloseTo(0, 2);
+ // Should have fewer cues than original
+ expect(outputCues.length).toBeLessThanOrEqual(cues.length);
+ });
+
+ it('should exclude cues outside trim range', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const firstTrack = (await input.subtitleTracks)[0]!;
+ const cues = [];
+ for await (const cue of firstTrack.getCues()) {
+ cues.push(cue);
+ }
+
+ // Trim to only second cue
+ const trimStart = cues[1]!.timestamp;
+ const trimEnd = cues[1]!.timestamp + cues[1]!.duration;
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ trim: {
+ start: trimStart,
+ end: trimEnd,
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ const outputCues = [];
+ for await (const cue of subtitleTracks[0]!.getCues()) {
+ outputCues.push(cue);
+ }
+
+ // Should have only 1 cue
+ expect(outputCues.length).toBe(1);
+ expect(outputCues[0]!.text).toBe(cues[1]!.text);
+ });
+
+ it('should handle partial cue trimming', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const firstTrack = (await input.subtitleTracks)[0]!;
+ const cues = [];
+ for await (const cue of firstTrack.getCues()) {
+ cues.push(cue);
+ }
+
+ // Trim in middle of first cue
+ const firstCue = cues[0]!;
+ const trimStart = firstCue.timestamp + firstCue.duration / 2;
+ const trimEnd = firstCue.timestamp + firstCue.duration;
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ trim: {
+ start: trimStart,
+ end: trimEnd,
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ const outputCues = [];
+ for await (const cue of subtitleTracks[0]!.getCues()) {
+ outputCues.push(cue);
+ }
+
+ // Should still have the cue but with adjusted duration
+ expect(outputCues.length).toBeGreaterThanOrEqual(1);
+ expect(outputCues[0]!.timestamp).toBeCloseTo(0, 2);
+ expect(outputCues[0]!.duration).toBeLessThan(firstCue.duration);
+ });
+});
+
+describe('Subtitle Conversion - Codec Compatibility', () => {
+ it('should discard track when target codec not supported by output format', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new Mp4OutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ video: { discard: true },
+ audio: { discard: true },
+ subtitle: {
+ codec: 'ass', // MP4 only supports webvtt, not ass
+ },
+ showWarnings: false,
+ });
+
+ // Track should be discarded because ASS is not supported in MP4
+ expect(conversion.isValid).toBe(false);
+ expect(conversion.discardedTracks.filter(t => t.track.type === 'subtitle').length).toBe(1);
+ expect(conversion.discardedTracks.find(t => t.track.type === 'subtitle')!.reason).toBe(
+ 'no_encodable_target_codec',
+ );
+ });
+
+ it('should support WebVTT and TX3G in MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new Mp4OutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'webvtt',
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks[0]!.codec).toBe('webvtt');
+ });
+
+ it('should support all text formats in MKV', async () => {
+ // Test WebVTT and SRT which are well-supported
+ // ASS/SSA conversion is tested separately below
+ const testCases = [
+ { codec: 'webvtt' as const },
+ { codec: 'srt' as const },
+ ];
+
+ for (const { codec } of testCases) {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec,
+ },
+ showWarnings: false,
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output has subtitle with correct codec
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length, `codec: ${codec}`).toBeGreaterThan(0);
+ expect(subtitleTracks[0]!.codec).toBe(codec);
+ }
+ });
+
+ it('should convert SRT to ASS with proper header', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'ass',
+ },
+ showWarnings: false,
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+ expect(subtitleTracks[0]!.codec).toBe('ass');
+
+ // Verify ASS structure
+ const assText = await subtitleTracks[0]!.exportToText();
+ expect(assText).toContain('[Script Info]');
+ expect(assText).toContain('[V4+ Styles]');
+ expect(assText).toContain('[Events]');
+ expect(assText).toContain('Dialogue:');
+ });
+
+ it('should preserve ASS header when trimming', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'ass',
+ },
+ trim: { start: 0, end: 10 },
+ showWarnings: false,
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+ expect(subtitleTracks[0]!.codec).toBe('ass');
+
+ // Verify ASS structure is preserved
+ const assText = await subtitleTracks[0]!.exportToText();
+ expect(assText).toContain('[Script Info]');
+ expect(assText).toContain('[V4+ Styles]');
+ expect(assText).toContain('[Events]');
+ });
+
+ it('should only support WebVTT in WebM', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new WebMOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'webvtt',
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks[0]!.codec).toBe('webvtt');
+ });
+});
+
+describe('Subtitle Conversion - External Subtitles', () => {
+ it('should add external subtitle track', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-video.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new Mp4OutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ video: { discard: true },
+ audio: { discard: true },
+ });
+
+ // Add external subtitle
+ const subtitleSource = new TextSubtitleSource('webvtt');
+ conversion.addExternalSubtitleTrack(subtitleSource, {
+ languageCode: 'eng',
+ name: 'External Subtitle',
+ }, async () => {
+ await subtitleSource.add('WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nExternal subtitle test');
+ subtitleSource.close();
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(1);
+ expect(subtitleTracks[0]!.codec).toBe('webvtt');
+
+ const text = await subtitleTracks[0]!.exportToText();
+ expect(text).toContain('External subtitle test');
+ });
+
+ it('should combine external subtitles with input subtitles', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ });
+
+ // Add external subtitle
+ const subtitleSource = new TextSubtitleSource('webvtt');
+ conversion.addExternalSubtitleTrack(subtitleSource, {
+ languageCode: 'spa',
+ name: 'Spanish',
+ }, async () => {
+ await subtitleSource.add('WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHola mundo');
+ subtitleSource.close();
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBe(2);
+
+ // Find the tracks
+ const srtTrack = subtitleTracks.find(t => t.codec === 'srt');
+ const vttTrack = subtitleTracks.find(t => t.codec === 'webvtt');
+
+ expect(srtTrack).toBeDefined();
+ expect(vttTrack).toBeDefined();
+
+ const vttText = await vttTrack!.exportToText();
+ expect(vttText).toContain('Hola mundo');
+ });
+
+ it('should respect track count limits for external subtitles', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-video.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new Mp4OutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ video: { discard: true },
+ audio: { discard: true },
+ });
+
+ // Add external subtitle
+ const subtitleSource = new TextSubtitleSource('webvtt');
+ conversion.addExternalSubtitleTrack(subtitleSource, {}, async () => {
+ await subtitleSource.add('WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nTest');
+ subtitleSource.close();
+ });
+
+ expect(conversion.isValid).toBe(true);
+ expect(() => {
+ // Try to add second external subtitle
+ const subtitleSource2 = new TextSubtitleSource('webvtt');
+ conversion.addExternalSubtitleTrack(subtitleSource2, {}, async () => {
+ await subtitleSource2.add('WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nTest 2');
+ subtitleSource2.close();
+ });
+ }).not.toThrow(); // MP4 supports multiple subtitle tracks
+ });
+});
+
+describe('Subtitle Conversion - Edge Cases', () => {
+ it('should handle empty subtitle track', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ subtitle: {
+ codec: 'webvtt', // Convert SRT to WebVTT
+ },
+ });
+
+ expect(conversion.isValid).toBe(true);
+ await conversion.execute();
+
+ // Verify output has WebVTT track
+ using outputInput = new Input({
+ source: new BufferSource(target.buffer),
+ formats: ALL_FORMATS,
+ });
+
+ const subtitleTracks = await outputInput.subtitleTracks;
+ expect(subtitleTracks.length).toBeGreaterThan(0);
+
+ const webvttTrack = subtitleTracks.find(t => t.codec === 'webvtt');
+ expect(webvttTrack).toBeDefined();
+
+ const cues = [];
+ for await (const cue of webvttTrack!.getCues()) {
+ cues.push(cue);
+ }
+ // Should have cues from original SRT
+ expect(cues.length).toBeGreaterThan(0);
+ });
+
+ it('should throw error for invalid subtitle options', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ await expect(() =>
+ Conversion.init({
+ input,
+ output: new Output({
+ format: new MkvOutputFormat(),
+ target: new BufferTarget(),
+ }),
+ subtitle: {
+ // @ts-expect-error Testing invalid input
+ codec: 'invalid-codec',
+ },
+ }),
+ ).rejects.toThrow();
+ });
+
+ it('should not execute conversion after adding external subtitle', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const target = new BufferTarget();
+ const output = new Output({
+ format: new MkvOutputFormat(),
+ target,
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ });
+
+ await conversion.execute();
+
+ // Try to add external subtitle after execution
+ const subtitleSource = new TextSubtitleSource('webvtt');
+ expect(() => {
+ conversion.addExternalSubtitleTrack(subtitleSource, {}, async () => {
+ await subtitleSource.add('WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nTest');
+ subtitleSource.close();
+ });
+ }).toThrow('Cannot add subtitle tracks after conversion has been executed');
+ });
+});
diff --git a/test/node/subtitle-extraction.test.ts b/test/node/subtitle-extraction.test.ts
new file mode 100644
index 00000000..9a100cc1
--- /dev/null
+++ b/test/node/subtitle-extraction.test.ts
@@ -0,0 +1,263 @@
+/*!
+ * Copyright (c) 2025-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { Input, FilePathSource, ALL_FORMATS } from '../../src/index.js';
+import { readFile } from 'fs/promises';
+
+describe('Subtitle Extraction', () => {
+ it('should extract SRT subtitles from MKV and download as text', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('srt');
+
+ // Export to SRT format
+ const srtText = await track.exportToText('srt');
+
+ // Should have proper SRT format
+ expect(srtText).toMatch(/\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/);
+ expect(srtText).toContain('Hello world');
+ expect(srtText).toContain('This is a test');
+
+ // Should have sequence numbers
+ expect(srtText).toMatch(/^1\n/m);
+ expect(srtText).toMatch(/\n2\n/);
+ });
+
+ it('should extract ASS subtitles from MKV and preserve header', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('ass');
+
+ // Export to ASS format
+ const assText = await track.exportToText('ass');
+
+ // Should have all ASS sections
+ expect(assText).toContain('[Script Info]');
+ expect(assText).toContain('[V4+ Styles]');
+ expect(assText).toContain('[Events]');
+ expect(assText).toContain('Format:');
+
+ // Should have dialogue lines
+ expect(assText).toMatch(/Dialogue:/);
+
+ // Should have actual subtitle content
+ expect(assText).toContain('Hello world');
+ });
+
+ it('should extract WebVTT subtitles from MKV', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-vtt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ expect(track.codec).toBe('webvtt');
+
+ // Export to SRT (WebVTT export needs more work, so use SRT)
+ const srtText = await track.exportToText('srt');
+
+ expect(srtText).toBeTruthy();
+ expect(srtText.length).toBeGreaterThan(0);
+ });
+
+ it('should extract WebVTT subtitles from MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mp4-webvtt.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+
+ // Export to text
+ const text = await track.exportToText();
+
+ expect(text).toBeTruthy();
+ expect(text.length).toBeGreaterThan(0);
+ });
+
+ it('should iterate through all subtitle cues', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const cues = [];
+
+ for await (const cue of track.getCues()) {
+ cues.push(cue);
+ }
+
+ expect(cues.length).toBeGreaterThan(0);
+
+ // Verify cue structure
+ for (const cue of cues) {
+ expect(cue).toHaveProperty('timestamp');
+ expect(cue).toHaveProperty('duration');
+ expect(cue).toHaveProperty('text');
+ expect(typeof cue.timestamp).toBe('number');
+ expect(typeof cue.duration).toBe('number');
+ expect(typeof cue.text).toBe('string');
+ }
+ });
+
+ it('should handle multiple subtitle tracks', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-multi.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const tracks = await input.subtitleTracks;
+ expect(tracks.length).toBeGreaterThanOrEqual(2);
+
+ // Extract all tracks
+ const exportedTexts = await Promise.all(
+ tracks.map(track => track.exportToText()),
+ );
+
+ for (const text of exportedTexts) {
+ expect(text).toBeTruthy();
+ expect(text.length).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe('Subtitle Format Conversion', () => {
+ it('should convert SRT to SRT (identity)', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const srtText = await track.exportToText('srt');
+
+ // Should be valid SRT
+ expect(srtText).toMatch(/\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/);
+ expect(srtText).toContain('Hello world');
+ });
+
+ it('should convert ASS to ASS (identity)', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText('ass');
+
+ // Should preserve ASS structure
+ expect(assText).toContain('[Script Info]');
+ expect(assText).toContain('[V4+ Styles]');
+ expect(assText).toContain('[Events]');
+ expect(assText).toContain('Dialogue:');
+ });
+
+ it('should convert ASS to SRT (extract dialogue text)', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const srtText = await track.exportToText('srt');
+
+ // Should have SRT format
+ expect(srtText).toMatch(/\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/);
+ expect(srtText.length).toBeGreaterThan(0);
+ });
+});
+
+describe('Subtitle Export Validation', () => {
+ it('should export SRT with correct sequence numbers', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-srt.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const srtText = await track.exportToText('srt');
+
+ const lines = srtText.split('\n');
+ const numbers = lines.filter(line => /^\d+$/.test(line));
+
+ // Should have sequential numbers starting from 1
+ expect(numbers[0]).toBe('1');
+ if (numbers.length > 1) {
+ expect(numbers[1]).toBe('2');
+ }
+ });
+
+ it('should export ASS with Dialogue lines in [Events] section', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+ const assText = await track.exportToText('ass');
+
+ // Find [Events] section
+ const eventsIndex = assText.indexOf('[Events]');
+ expect(eventsIndex).toBeGreaterThan(-1);
+
+ // Find Format line after [Events]
+ const afterEventsHeader = assText.substring(eventsIndex);
+ const formatMatch = afterEventsHeader.match(/Format:\s*Layer,\s*Start,\s*End/);
+ expect(formatMatch).toBeTruthy();
+
+ const formatIndex = eventsIndex + formatMatch!.index!;
+ const afterFormat = assText.substring(formatIndex);
+
+ // Find first Dialogue/Comment after Format
+ const dialogueMatch = afterFormat.match(/^(Dialogue|Comment):/m);
+ expect(dialogueMatch).toBeTruthy();
+
+ // Extract Events section (from [Events] to next section or end)
+ const nextSectionMatch = afterEventsHeader.match(/\n\[([^\]]+)\]/);
+ const eventsSection = nextSectionMatch
+ ? assText.substring(eventsIndex, eventsIndex + nextSectionMatch.index!)
+ : assText.substring(eventsIndex);
+
+ // Verify structure
+ expect(eventsSection).toContain('Format: Layer, Start, End');
+ expect(eventsSection).toMatch(/Dialogue:|Comment:/);
+
+ // Verify Dialogue lines have timestamps
+ const dialogueLines = eventsSection.match(/Dialogue:\s*\d+,\d+:\d{2}:\d{2}\.\d{2},\d+:\d{2}:\d{2}\.\d{2},/g);
+ expect(dialogueLines).toBeTruthy();
+ expect(dialogueLines!.length).toBeGreaterThan(0);
+ });
+
+ it('should preserve Comment lines in ASS export', async () => {
+ using input = new Input({
+ source: new FilePathSource('test/public/subtitles/test-mkv-ass.mkv'),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.subtitleTracks)[0]!;
+
+ // Check if original has comments
+ const originalAss = await readFile('test/public/subtitles/test.ass', 'utf-8');
+ const hasComments = originalAss.includes('Comment:');
+
+ if (hasComments) {
+ const assText = await track.exportToText('ass');
+ expect(assText).toContain('Comment:');
+ }
+ });
+});
diff --git a/test/node/subtitle-parsing.test.ts b/test/node/subtitle-parsing.test.ts
new file mode 100644
index 00000000..22f2a095
--- /dev/null
+++ b/test/node/subtitle-parsing.test.ts
@@ -0,0 +1,205 @@
+/*!
+ * Copyright (c) 2025-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ parseSrtTimestamp,
+ formatSrtTimestamp,
+ splitSrtIntoCues,
+ formatCuesToSrt,
+ parseAssTimestamp,
+ formatAssTimestamp,
+ splitAssIntoCues,
+ formatCuesToAss,
+ SubtitleCue,
+} from '../../src/subtitles.js';
+
+describe('SRT Timestamp Parsing', () => {
+ it('should parse SRT timestamp format', () => {
+ expect(parseSrtTimestamp('00:00:01,000')).toBe(1.0);
+ expect(parseSrtTimestamp('00:00:03,500')).toBe(3.5);
+ expect(parseSrtTimestamp('01:23:45,678')).toBe(5025.678);
+ });
+
+ it('should format seconds to SRT timestamp', () => {
+ expect(formatSrtTimestamp(1.0)).toBe('00:00:01,000');
+ expect(formatSrtTimestamp(3.5)).toBe('00:00:03,500');
+ expect(formatSrtTimestamp(5025.678)).toBe('01:23:45,678');
+ });
+});
+
+describe('SRT Splitting', () => {
+ it('should split SRT text into cues', () => {
+ const srt = `1
+00:00:01,000 --> 00:00:03,500
+Hello world!
+
+2
+00:00:05,000 --> 00:00:07,000
+Goodbye!`;
+
+ const cues = splitSrtIntoCues(srt);
+
+ expect(cues).toHaveLength(2);
+ expect(cues[0]).toMatchObject({
+ timestamp: 1.0,
+ duration: 2.5,
+ text: 'Hello world!',
+ });
+ expect(cues[1]).toMatchObject({
+ timestamp: 5.0,
+ duration: 2.0,
+ text: 'Goodbye!',
+ });
+ });
+
+ it('should handle multi-line subtitle text', () => {
+ const srt = `1
+00:00:01,000 --> 00:00:03,500
+Line 1
+Line 2
+Line 3
+
+2
+00:00:05,000 --> 00:00:07,000
+Single line`;
+
+ const cues = splitSrtIntoCues(srt);
+ expect(cues[0]!.text).toBe('Line 1\nLine 2\nLine 3');
+ expect(cues[1]!.text).toBe('Single line');
+ });
+
+ it('should handle SRT with missing sequence numbers', () => {
+ const srt = `1
+00:00:01,000 --> 00:00:03,500
+Text one
+
+3
+00:00:05,000 --> 00:00:07,000
+Text two`;
+
+ const cues = splitSrtIntoCues(srt);
+ expect(cues).toHaveLength(2);
+ });
+});
+
+describe('SRT Formatting', () => {
+ it('should format cues back to SRT', () => {
+ const cues: SubtitleCue[] = [
+ { timestamp: 1.0, duration: 2.5, text: 'Hello' },
+ { timestamp: 5.0, duration: 2.0, text: 'Goodbye' },
+ ];
+
+ const srt = formatCuesToSrt(cues);
+
+ expect(srt).toContain('1\n00:00:01,000 --> 00:00:03,500\nHello');
+ expect(srt).toContain('2\n00:00:05,000 --> 00:00:07,000\nGoodbye');
+ });
+
+ it('should preserve multi-line text', () => {
+ const cues: SubtitleCue[] = [
+ { timestamp: 1.0, duration: 2.5, text: 'Line 1\nLine 2' },
+ ];
+
+ const srt = formatCuesToSrt(cues);
+ expect(srt).toContain('Line 1\nLine 2');
+ });
+});
+
+describe('ASS Timestamp Parsing', () => {
+ it('should parse ASS timestamp format', () => {
+ expect(parseAssTimestamp('0:00:01.00')).toBe(1.0);
+ expect(parseAssTimestamp('0:00:03.50')).toBe(3.5);
+ expect(parseAssTimestamp('1:23:45.67')).toBe(5025.67);
+ });
+
+ it('should format seconds to ASS timestamp', () => {
+ expect(formatAssTimestamp(1.0)).toBe('0:00:01.00');
+ expect(formatAssTimestamp(3.5)).toBe('0:00:03.50');
+ expect(formatAssTimestamp(5025.67)).toBe('1:23:45.67');
+ });
+});
+
+describe('ASS Splitting', () => {
+ it('should split ASS into header and cues', () => {
+ const ass = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Style: Default,Arial,20
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello world!
+Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,Goodbye!`;
+
+ const { header, cues } = splitAssIntoCues(ass);
+
+ expect(header).toContain('[Script Info]');
+ expect(header).toContain('[V4+ Styles]');
+ expect(header).toContain('[Events]');
+ expect(header).toContain('Format:');
+ expect(cues).toHaveLength(2);
+ expect(cues[0]!.timestamp).toBe(1.0);
+ expect(cues[0]!.duration).toBe(2.5);
+ });
+
+ it('should preserve full dialogue line in cue text', () => {
+ const ass = `[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,{\\pos(320,240)}Styled text`;
+
+ const { cues } = splitAssIntoCues(ass);
+ expect(cues[0]!.text).toContain('Dialogue:');
+ expect(cues[0]!.text).toContain('{\\pos(320,240)}Styled text');
+ });
+
+ it('should handle SSA format (v4.00)', () => {
+ const ssa = `[Script Info]
+Title: Test
+ScriptType: v4.00
+
+[V4 Styles]
+Style: Default,Arial,20
+
+[Events]
+Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: Marked=0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello`;
+
+ const { header, cues } = splitAssIntoCues(ssa);
+ expect(header).toContain('[V4 Styles]');
+ expect(cues).toHaveLength(1);
+ });
+});
+
+describe('ASS Formatting', () => {
+ it('should format cues back to ASS with header', () => {
+ const header = `[Script Info]
+Title: Test
+
+[V4+ Styles]
+Style: Default,Arial,20
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`;
+
+ const cues: SubtitleCue[] = [
+ {
+ timestamp: 1.0,
+ duration: 2.5,
+ text: 'Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello',
+ },
+ ];
+
+ const ass = formatCuesToAss(cues, header);
+
+ expect(ass).toContain('[Script Info]');
+ expect(ass).toContain('[V4+ Styles]');
+ expect(ass).toContain('Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello');
+ });
+});
diff --git a/test/public/subtitles/test-converted.ttml b/test/public/subtitles/test-converted.ttml
new file mode 100644
index 00000000..0708b067
--- /dev/null
+++ b/test/public/subtitles/test-converted.ttml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
Hello world!
+
This is a test.
+
Goodbye!
+
+
+
diff --git a/test/public/subtitles/test-mkv-ass-fonts.mkv b/test/public/subtitles/test-mkv-ass-fonts.mkv
new file mode 100644
index 00000000..1b4f6569
Binary files /dev/null and b/test/public/subtitles/test-mkv-ass-fonts.mkv differ
diff --git a/test/public/subtitles/test-mkv-ass.mkv b/test/public/subtitles/test-mkv-ass.mkv
new file mode 100644
index 00000000..93771b34
Binary files /dev/null and b/test/public/subtitles/test-mkv-ass.mkv differ
diff --git a/test/public/subtitles/test-mkv-multi.mkv b/test/public/subtitles/test-mkv-multi.mkv
new file mode 100644
index 00000000..8cd2a25a
Binary files /dev/null and b/test/public/subtitles/test-mkv-multi.mkv differ
diff --git a/test/public/subtitles/test-mkv-srt.mkv b/test/public/subtitles/test-mkv-srt.mkv
new file mode 100644
index 00000000..7512523e
Binary files /dev/null and b/test/public/subtitles/test-mkv-srt.mkv differ
diff --git a/test/public/subtitles/test-mkv-ssa.mkv b/test/public/subtitles/test-mkv-ssa.mkv
new file mode 100644
index 00000000..51fdeb4e
Binary files /dev/null and b/test/public/subtitles/test-mkv-ssa.mkv differ
diff --git a/test/public/subtitles/test-mkv-vtt.mkv b/test/public/subtitles/test-mkv-vtt.mkv
new file mode 100644
index 00000000..89802201
Binary files /dev/null and b/test/public/subtitles/test-mkv-vtt.mkv differ
diff --git a/test/public/subtitles/test-mov-ttml.mov b/test/public/subtitles/test-mov-ttml.mov
new file mode 100644
index 00000000..49cb93a4
Binary files /dev/null and b/test/public/subtitles/test-mov-ttml.mov differ
diff --git a/test/public/subtitles/test-mov-tx3g.mov b/test/public/subtitles/test-mov-tx3g.mov
new file mode 100644
index 00000000..d3ff965e
Binary files /dev/null and b/test/public/subtitles/test-mov-tx3g.mov differ
diff --git a/test/public/subtitles/test-mp4-ttml.mp4 b/test/public/subtitles/test-mp4-ttml.mp4
new file mode 100644
index 00000000..49cb93a4
Binary files /dev/null and b/test/public/subtitles/test-mp4-ttml.mp4 differ
diff --git a/test/public/subtitles/test-mp4-tx3g.mp4 b/test/public/subtitles/test-mp4-tx3g.mp4
new file mode 100644
index 00000000..af69be07
Binary files /dev/null and b/test/public/subtitles/test-mp4-tx3g.mp4 differ
diff --git a/test/public/subtitles/test-mp4-webvtt.mp4 b/test/public/subtitles/test-mp4-webvtt.mp4
new file mode 100644
index 00000000..05d8943b
Binary files /dev/null and b/test/public/subtitles/test-mp4-webvtt.mp4 differ
diff --git a/test/public/subtitles/test-video.mp4 b/test/public/subtitles/test-video.mp4
new file mode 100644
index 00000000..a8065d10
Binary files /dev/null and b/test/public/subtitles/test-video.mp4 differ
diff --git a/test/public/subtitles/test-with-fonts.ass b/test/public/subtitles/test-with-fonts.ass
new file mode 100644
index 00000000..84679fd5
--- /dev/null
+++ b/test/public/subtitles/test-with-fonts.ass
@@ -0,0 +1,20 @@
+[Script Info]
+Title: Test with Embedded Font
+ScriptType: v4.00+
+PlayResX: 1280
+PlayResY: 720
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,CustomFont,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Text with custom font
+Comment: 0,0:00:02.00,0:00:02.50,Default,,0,0,0,,This is a comment
+[Fonts]
+fontname: CustomFont
+
+[Graphics]
+filename: logo.png
+0
diff --git a/test/public/subtitles/test.ass b/test/public/subtitles/test.ass
new file mode 100644
index 00000000..e6c21200
--- /dev/null
+++ b/test/public/subtitles/test.ass
@@ -0,0 +1,15 @@
+[Script Info]
+Title: Test Subtitles
+ScriptType: v4.00+
+PlayResX: 1280
+PlayResY: 720
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello world!
+Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,This is a test.
+Dialogue: 0,0:00:08.50,0:00:10.00,Default,,0,0,0,,Goodbye!
diff --git a/test/public/subtitles/test.srt b/test/public/subtitles/test.srt
new file mode 100644
index 00000000..0245330a
--- /dev/null
+++ b/test/public/subtitles/test.srt
@@ -0,0 +1,11 @@
+1
+00:00:01,000 --> 00:00:03,500
+Hello world!
+
+2
+00:00:05,000 --> 00:00:07,000
+This is a test.
+
+3
+00:00:08,500 --> 00:00:10,000
+Goodbye!
diff --git a/test/public/subtitles/test.ssa b/test/public/subtitles/test.ssa
new file mode 100644
index 00000000..93880e90
--- /dev/null
+++ b/test/public/subtitles/test.ssa
@@ -0,0 +1,13 @@
+[Script Info]
+Title: Test Subtitles
+ScriptType: v4.00
+
+[V4 Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding
+Style: Default,Arial,20,16777215,65535,65535,0,0,0,1,2,0,2,10,10,10,0,1
+
+[Events]
+Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: Marked=0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello world!
+Dialogue: Marked=0,0:00:05.00,0:00:07.00,Default,,0,0,0,,This is a test.
+Dialogue: Marked=0,0:00:08.50,0:00:10.00,Default,,0,0,0,,Goodbye!
diff --git a/test/public/subtitles/test.ttml b/test/public/subtitles/test.ttml
new file mode 100644
index 00000000..8a57951b
--- /dev/null
+++ b/test/public/subtitles/test.ttml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
Hello world!
+
This is a test subtitle.
+
+
+
diff --git a/test/public/subtitles/test.vtt b/test/public/subtitles/test.vtt
new file mode 100644
index 00000000..427286c9
--- /dev/null
+++ b/test/public/subtitles/test.vtt
@@ -0,0 +1,10 @@
+WEBVTT
+
+00:00:01.000 --> 00:00:03.500
+Hello world!
+
+00:00:05.000 --> 00:00:07.000
+This is a test.
+
+00:00:08.500 --> 00:00:10.000
+Goodbye!