From 03ea90392775f8455b7d076a05b59932c241628d Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 22 Aug 2026 23:32:35 -0700 Subject: [PATCH] fix(subtitles): preserve authored spaces in fragmented ASS karaoke - Only trim indentation before slicing ASS event fields so a trailing authored space at a fragment's end is kept, preventing words from being joined together when fragmented karaoke lines are reconstructed - Add regression test covering event-boundary word spacing --- changes/fix-secondary-subtitle-duplication.md | 2 +- src/core/services/subtitle-cue-parser.ts | 10 ++++-- .../runtime/secondary-subtitle-track.test.ts | 35 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/changes/fix-secondary-subtitle-duplication.md b/changes/fix-secondary-subtitle-duplication.md index e97b5afe..c3d6c3c2 100644 --- a/changes/fix-secondary-subtitle-duplication.md +++ b/changes/fix-secondary-subtitle-duplication.md @@ -1,4 +1,4 @@ type: fixed area: overlay -- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. +- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries instead of joining every word together. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 696c0d03..99de766a 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -853,6 +853,10 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents { for (const line of lines) { const trimmed = line.trim(); + // Event text can end in an authored space. Fragmented karaoke commonly uses that + // space to retain word boundaries when its separately positioned events are joined + // back into a line, so only remove indentation before slicing the event fields. + const eventLine = line.trimStart(); if (trimmed.startsWith('[') && trimmed.endsWith(']')) { inEventsSection = trimmed.toLowerCase() === '[events]'; @@ -883,9 +887,9 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents { continue; } - const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX) + const eventPrefix = eventLine.startsWith(ASS_DIALOGUE_PREFIX) ? ASS_DIALOGUE_PREFIX - : trimmed.startsWith(ASS_COMMENT_PREFIX) + : eventLine.startsWith(ASS_COMMENT_PREFIX) ? ASS_COMMENT_PREFIX : null; if (!eventPrefix) { @@ -896,7 +900,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents { continue; } - const fields = trimmed.slice(eventPrefix.length).split(','); + const fields = eventLine.slice(eventPrefix.length).split(','); if ( fieldIndex.start >= fields.length || fieldIndex.end >= fields.length || diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts index 5b6ea2f6..e11dedba 100644 --- a/src/main/runtime/secondary-subtitle-track.test.ts +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -181,6 +181,41 @@ test('ASS fragment karaoke stays separated by style with authored word spacing', assert.equal(findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 8.5), 'Iwanttogo'); }); +test('ASS fragment karaoke preserves word spaces authored at event boundaries', () => { + const fragments = [ + 'The ', + 'shoot', + 'ing ', + 'stars ', + 'arc', + 'ing ', + 'across ', + 'the ', + 'sky ', + 'I ', + 'wish ', + 'upon,', + ]; + const events: string[] = []; + for (const layer of [0, 1]) { + fragments.forEach((fragment, index) => { + events.push( + `Dialogue: ${layer},0:00:01.00,0:00:04.00,op_english,,0,0,0,,{\\pos(${100 + index * 40},110)\\t(0,200,\\fscx110)}${fragment}`, + ); + }); + } + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...events, + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'bravern-s01e10.ass'), 2), + 'The shooting stars arcing across the sky I wish upon,', + ); +}); + test('findActiveSubtitleText keeps a complete reconstructed line over entrance fragments', () => { const current = { startTime: 1,