diff --git a/changes/fix-secondary-subtitle-duplication.md b/changes/fix-secondary-subtitle-duplication.md index 2a292ff2..4b4d6d1a 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. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. 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 concatenated primary or secondary lines. 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 and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, and overlapping decorative glyphs remain suppressed. 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 concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact. diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 2f015682..74d25c04 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -1558,17 +1558,23 @@ test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing `Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,fx,{\\pos(${x},60)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, ), ), - ...[40, 41].flatMap((layer) => - sweepFragments.map( - ([fragment, x, start, end]) => - `Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${x},60)\\t(150,290,\\1a&HFF&)}${fragment}`, + ...sweepFragments.flatMap(([fragment, x, start, end]) => + [ + [40, x, 60], + [41, x + 4, 64], + ].map( + ([layer, copyX, copyY]) => + `Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${copyX},${copyY})\\t(150,290,\\1a&HFF&)}${fragment}`, ), ), + 'Dialogue: 42,0:00:01.20,0:00:01.30,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}a', + 'Dialogue: 42,0:00:04.00,0:00:04.20,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}z', ].join('\n'); const cues = parseSubtitleCues(content, 'test.ass'); - assert.equal(cues.length, 1); + assert.equal(cues.length, 2); assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo'); + assert.equal(cues[1]?.text, 'z'); }); test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixels', () => { diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index f85680df..ddee3594 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -809,15 +809,21 @@ interface FragmentInterval { endTime: number; } -/** Event time ranges with layer copies (same text, placement, and timing) collapsed. */ +/** Event time ranges with repeated same-text, same-time layer copies collapsed. */ function distinctFragmentIntervals(events: readonly AnnotatedSubtitleCue[]): FragmentInterval[] { const intervals: FragmentInterval[] = []; - const seen = new Set(); + const previousEvents: AnnotatedSubtitleCue[] = []; for (const event of events) { - const anchors = [...fragmentPlacementAnchors(event)].sort().join('|'); - const key = `${compactCueMatchText(event)}\0${anchors}\0${event.startTime}\0${event.endTime}`; - if (seen.has(key)) continue; - seen.add(key); + const compactText = compactCueMatchText(event); + const isLayerCopy = previousEvents.some( + (previous) => + compactCueMatchText(previous) === compactText && + previous.startTime === event.startTime && + previous.endTime === event.endTime && + isRepeatedFragmentCopy(previous, event), + ); + previousEvents.push(event); + if (isLayerCopy) continue; intervals.push({ startTime: event.startTime, endTime: event.endTime }); } return intervals.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime); @@ -1018,6 +1024,13 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate const lineEvents = decorative.size ? events.filter((event) => !decorative.has(event)) : events; if (isProgressiveHighlightSweepGroup(lineEvents)) { lineEvents.forEach((event) => suppressed.add(event)); + const spanStart = Math.min(...lineEvents.map((event) => event.startTime)); + const spanEnd = Math.max(...lineEvents.map((event) => event.endTime)); + for (const overlay of decorative) { + if (overlay.startTime < spanEnd && overlay.endTime > spanStart) { + suppressed.add(overlay); + } + } continue; } for (const cluster of clusterAssFragmentEvents(lineEvents)) { diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts index f15cd6d8..c91d776f 100644 --- a/src/main/runtime/secondary-subtitle-track.test.ts +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -490,6 +490,41 @@ test('secondary disconnect clears stale ASS fallback sanitization state', async assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']); }); +test('secondary source refresh failure clears stale ASS fallback sanitization state', async () => { + let resolveCalls = 0; + const broadcasts: string[] = []; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => { + resolveCalls += 1; + if (resolveCalls === 1) { + return { path: '/subs/english.ass', sourceKey: 'english' }; + } + throw new Error('source refresh failed'); + }, + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + await controller.refresh(); + broadcasts.length = 0; + controller.handleLiveText('Visible line\n\\\n{\\fr0'); + + assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']); +}); + test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => { let resolveCalls = 0; let parseCalls = 0; diff --git a/src/main/runtime/secondary-subtitle-track.ts b/src/main/runtime/secondary-subtitle-track.ts index 412da142..7628ab51 100644 --- a/src/main/runtime/secondary-subtitle-track.ts +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -291,6 +291,7 @@ export function createSecondarySubtitleTrackController(deps: { publish(resolveAtTime(deps.getCurrentTimePos())); } catch (error) { if (generation !== refreshGeneration) return; + activeSourceUsesAssSyntax = false; deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error); useLiveFallback(); } finally {