diff --git a/packages/vidstack/src/core/tracks/text/text-track.test.ts b/packages/vidstack/src/core/tracks/text/text-track.test.ts new file mode 100644 index 000000000..aa87170d3 --- /dev/null +++ b/packages/vidstack/src/core/tracks/text/text-track.test.ts @@ -0,0 +1,407 @@ +import { vi } from 'vitest'; + +import { TextTrack } from './text-track'; + +// Mock media-captions module +const mockParseText = vi.fn(); +const mockVTTCue = vi.fn(); +const mockVTTRegion = vi.fn(); + +vi.mock('media-captions', () => ({ + parseText: mockParseText, + VTTCue: mockVTTCue, + VTTRegion: mockVTTRegion, +})); + +// Mock DOMEvent to prevent JSDOM issues +vi.mock('maverick.js/std', async () => { + const actual = (await vi.importActual('maverick.js/std')) as any; + return { + ...actual, + DOMEvent: vi.fn().mockImplementation((type, eventInit) => { + const event = new Event(type, eventInit); + if (eventInit?.detail !== undefined) { + Object.defineProperty(event, 'detail', { + value: eventInit.detail, + writable: false, + }); + } + if (eventInit?.trigger !== undefined) { + Object.defineProperty(event, 'trigger', { + value: eventInit.trigger, + writable: false, + }); + } + return event; + }), + }; +}); + +describe('TextTrack Content Parsing', function () { + beforeEach(() => { + vi.clearAllMocks(); + mockParseText.mockResolvedValue({ cues: [], regions: [] }); + }); + + describe('whitespace normalization in #parseContent', function () { + it('should normalize indented template literal content', async function () { + const indentedSrtContent = ` 1 + 00:00:01,000 --> 00:00:05,000 + First subtitle + + 2 + 00:00:06,000 --> 00:00:10,000 + Second subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: indentedSrtContent, + type: 'srt', + }); + + // Wait for the async parseContent to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle\n\n2\n00:00:06,000 --> 00:00:10,000\nSecond subtitle`, + { type: 'srt' }, + ); + }); + + it('should handle content with mixed indentation', async function () { + const mixedIndentationContent = ` 1 + 00:00:01,000 --> 00:00:05,000 + First subtitle + + 2 + 00:00:06,000 --> 00:00:10,000 + Second subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: mixedIndentationContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle\n\n2\n00:00:06,000 --> 00:00:10,000\nSecond subtitle`, + { type: 'srt' }, + ); + }); + + it('should handle content with leading and trailing whitespace', async function () { + const whitespaceContent = ` + 1 + 00:00:01,000 --> 00:00:05,000 + First subtitle + `; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: whitespaceContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle`, + { type: 'srt' }, + ); + }); + + it('should preserve empty lines in content', async function () { + const contentWithEmptyLines = ` 1 + 00:00:01,000 --> 00:00:05,000 + First subtitle + + 2 + 00:00:06,000 --> 00:00:10,000 + Second subtitle + + + 3 + 00:00:11,000 --> 00:00:15,000 + Third subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: contentWithEmptyLines, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle\n\n2\n00:00:06,000 --> 00:00:10,000\nSecond subtitle\n\n\n3\n00:00:11,000 --> 00:00:15,000\nThird subtitle`, + { type: 'srt' }, + ); + }); + + it('should handle already normalized content without changes', async function () { + const normalizedContent = `1 +00:00:01,000 --> 00:00:05,000 +First subtitle + +2 +00:00:06,000 --> 00:00:10,000 +Second subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: normalizedContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith(normalizedContent, { type: 'srt' }); + }); + + it('should handle VTT content with indentation', async function () { + const indentedVttContent = ` WEBVTT + + 1 + 00:00:01.000 --> 00:00:05.000 + First subtitle + + 2 + 00:00:06.000 --> 00:00:10.000 + Second subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: indentedVttContent, + type: 'vtt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `WEBVTT\n\n1\n00:00:01.000 --> 00:00:05.000\nFirst subtitle\n\n2\n00:00:06.000 --> 00:00:10.000\nSecond subtitle`, + { type: 'vtt' }, + ); + }); + + it('should handle content with only whitespace', async function () { + const whitespaceOnlyContent = ` + + `; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: whitespaceOnlyContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith('', { type: 'srt' }); + }); + + it('should handle single line content with indentation', async function () { + const singleLineContent = ` WEBVTT`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: singleLineContent, + type: 'vtt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith('WEBVTT', { type: 'vtt' }); + }); + + it('should not process JSON content through whitespace normalization', async function () { + const jsonContent = { + cues: [ + { startTime: 1, endTime: 5, text: 'First subtitle' }, + { startTime: 6, endTime: 10, text: 'Second subtitle' }, + ], + }; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: jsonContent, + type: 'json', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // parseText should not be called for JSON content + expect(mockParseText).not.toHaveBeenCalled(); + }); + + it('should not process string JSON content through whitespace normalization', async function () { + const jsonStringContent = `{"cues": [{"startTime": 1, "endTime": 5, "text": "First subtitle"}]}`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: jsonStringContent, + type: 'json', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // parseText should not be called for JSON content + expect(mockParseText).not.toHaveBeenCalled(); + }); + + it('should handle complex SRT content with formatting and special characters', async function () { + const complexSrtContent = ` 1 + 00:00:01,000 --> 00:00:05,000 + Italic text with special chars: éñü + + 2 + 00:00:06,500 --> 00:00:10,750 + Line 1 + Line 2 with bold + + 3 + 00:00:11,123 --> 00:00:15,999 + Text with "quotes" and 'apostrophes'`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: complexSrtContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nItalic text with special chars: éñü\n\n2\n00:00:06,500 --> 00:00:10,750\nLine 1\nLine 2 with bold\n\n3\n00:00:11,123 --> 00:00:15,999\nText with "quotes" and 'apostrophes'`, + { type: 'srt' }, + ); + }); + }); + + describe('edge cases for content normalization', function () { + it('should handle tabs and mixed whitespace characters', async function () { + const tabContent = `\t1\n\t00:00:01,000 --> 00:00:05,000\n\t First subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: tabContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle`, + { type: 'srt' }, + ); + }); + + it('should handle content with Windows line endings', async function () { + const windowsContent = ` 1\r\n 00:00:01,000 --> 00:00:05,000\r\n First subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: windowsContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle`, + { type: 'srt' }, + ); + }); + + it('should handle extremely large indentation', async function () { + const largeIndentContent = ` 1 + 00:00:01,000 --> 00:00:05,000 + First subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: largeIndentContent, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle`, + { type: 'srt' }, + ); + }); + }); + + describe('regression tests', function () { + it('should ensure parseText receives normalized content for SRT with comma timestamps', async function () { + // This tests the original issue: SRT files with comma timestamps should work + const srtWithCommas = ` 1 + 00:00:01,000 --> 00:00:05,000 + First subtitle + + 2 + 00:00:06,500 --> 00:00:10,750 + Second subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: srtWithCommas, + type: 'srt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify that parseText is called with properly normalized content + expect(mockParseText).toHaveBeenCalledWith( + `1\n00:00:01,000 --> 00:00:05,000\nFirst subtitle\n\n2\n00:00:06,500 --> 00:00:10,750\nSecond subtitle`, + { type: 'srt' }, + ); + + // Verify parseText was called exactly once + expect(mockParseText).toHaveBeenCalledTimes(1); + }); + + it('should work correctly with VTT content that has period timestamps', async function () { + const vttWithPeriods = ` WEBVTT + + 1 + 00:00:01.000 --> 00:00:05.000 + First subtitle + + 2 + 00:00:06.500 --> 00:00:10.750 + Second subtitle`; + + const track = new TextTrack({ + kind: 'subtitles', + label: 'Test Subtitles', + content: vttWithPeriods, + type: 'vtt', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockParseText).toHaveBeenCalledWith( + `WEBVTT\n\n1\n00:00:01.000 --> 00:00:05.000\nFirst subtitle\n\n2\n00:00:06.500 --> 00:00:10.750\nSecond subtitle`, + { type: 'vtt' }, + ); + }); + }); +}); diff --git a/packages/vidstack/src/core/tracks/text/text-track.ts b/packages/vidstack/src/core/tracks/text/text-track.ts index f76fdc2e2..41c3078f0 100644 --- a/packages/vidstack/src/core/tracks/text/text-track.ts +++ b/packages/vidstack/src/core/tracks/text/text-track.ts @@ -217,7 +217,12 @@ export class TextTrack extends EventsTarget { this.#parseJSON(init.content!, VTTCue, VTTRegion); if (this.readyState !== 3) this.#ready(); } else { - parseText(init.content!, { type: init.type as 'vtt' }).then(({ cues, regions }) => { + const content = init + .content!.split('\n') + .map((line) => line.trim()) + .join('\n') + .trim(); + parseText(content, { type: init.type as 'vtt' }).then(({ cues, regions }) => { this.#cues = cues; this.#regions = regions; this.#ready();