diff --git a/changes/fix-primary-ass-layer-duplication.md b/changes/fix-primary-ass-layer-duplication.md new file mode 100644 index 00000000..e7495921 --- /dev/null +++ b/changes/fix-primary-ass-layer-duplication.md @@ -0,0 +1,4 @@ +type: fixed +area: subtitles + +- Primary ASS subtitles now use the active parsed cue when it fully accounts for mpv's live text, preventing fill, border, blur, and shadow copies of the same full-span lyric from appearing repeatedly while preserving unmatched overlapping dialogue and signs. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 6acd5602..460ea8f4 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -3,7 +3,7 @@ # Subtitle Overlay Priming Status: active -Last verified: 2026-08-17 +Last verified: 2026-08-18 Owner: Kyle Yasuda Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated @@ -69,6 +69,11 @@ coming and prefetching would otherwise idle for the rest of the cue. ## Live Cue Delivery +- Primary live text first resolves recovered canonical ASS animations. Otherwise, when + every live mpv line matches an active parsed cue, it uses the parsed cue text so exact + full-span style layers appear once instead of repeating for fill, border, blur, and + shadow events. Any unmatched live line keeps the complete live stack, preserving + dialogue or signs that overlap a lyric. - A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so live work does not contend for Yomitan state. - If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty diff --git a/src/main/runtime/primary-subtitle-text.test.ts b/src/main/runtime/primary-subtitle-text.test.ts index 046476eb..e43a94f1 100644 --- a/src/main/runtime/primary-subtitle-text.test.ts +++ b/src/main/runtime/primary-subtitle-text.test.ts @@ -1,11 +1,80 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; import { resolveCanonicalPrimarySubtitle, resolvePrimarySubtitleText, stripCanonicalFragmentLines, } from './primary-subtitle-text'; +test('resolvePrimarySubtitleText collapses full-span ASS style layers through parsed cues', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 3,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord0\\blur0.8}鏡の奥まで目を凝らして', + 'Dialogue: 2,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)}鏡の奥まで目を凝らして', + 'Dialogue: 1,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord6}鏡の奥まで目を凝らして', + 'Dialogue: 0,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord8\\blur4}鏡の奥まで目を凝らして', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass'); + + assert.deepEqual(cues, [ + { startTime: 22 * 60 + 40.05, endTime: 22 * 60 + 45.76, text: '鏡の奥まで目を凝らして' }, + ]); + + assert.equal( + resolvePrimarySubtitleText({ + liveText: [ + '鏡の奥まで目を凝らして', + '鏡の奥まで目を凝らして', + '鏡の奥まで目を凝らして', + '鏡の奥まで目を凝らして', + ].join('\n'), + currentTimeSec: 22 * 60 + 44, + cues, + }), + '鏡の奥まで目を凝らして', + ); +}); + +test('resolvePrimarySubtitleText keeps live text when active parsed cues do not explain it all', () => { + const liveText = '普通のセリフ\n鏡の奥まで目を凝らして\n鏡の奥まで目を凝らして'; + + assert.equal( + resolvePrimarySubtitleText({ + liveText, + currentTimeSec: 2, + cues: [{ startTime: 1, endTime: 3, text: '鏡の奥まで目を凝らして' }], + }), + liveText, + ); +}); + +test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '一行目\n一行目\n二行目\n二行目', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '一行目' }, + { startTime: 1, endTime: 3, text: '二行目' }, + ], + }), + '一行目\n二行目', + ); +}); + +test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '新しい行\n新しい行', + currentTimeSec: 0.8, + cues: [{ startTime: 1, endTime: 3, text: '新しい行' }], + }), + '新しい行', + ); +}); + test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => { // mpv renders each simultaneously active ASS event on its own sub-text line. const text = resolvePrimarySubtitleText({ diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts index f4efcd6f..6e1164ef 100644 --- a/src/main/runtime/primary-subtitle-text.ts +++ b/src/main/runtime/primary-subtitle-text.ts @@ -3,13 +3,13 @@ import type { SubtitleCue } from '../../types'; // Slack on top of each cue's recorded animation envelope, for time-pos observation // staleness and small user sub-delay offsets. The envelope itself covers how far // entrance/exit frames actually run past the authored timing. -const CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS = 1; +const LIVE_CUE_EDGE_TOLERANCE_SECONDS = 1; export interface ResolvedPrimarySubtitle { text: string; startTime: number; endTime: number; - /** The canonical cues behind `text`, for consumers that record lines individually. */ + /** The parsed cues behind `text`, for consumers that record lines individually. */ cues: SubtitleCue[]; } @@ -30,8 +30,8 @@ function nearbyCanonicalCues( } const span = animationSpan(cue); return ( - span.end >= currentTimeSec - CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS && - span.start <= currentTimeSec + CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS + span.end >= currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS && + span.start <= currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS ); }); } @@ -40,6 +40,65 @@ function compactWhitespace(text: string): string { return text.replace(/\s+/gu, ''); } +function compactLineSegments(text: string): string[] { + return text.split('\n').map(compactWhitespace).filter(Boolean); +} + +/** + * Parsed cues have already collapsed exact ASS layers and animation runs. Trust that + * cleaner view only when every live mpv line is accounted for by an active parsed cue. + * This keeps unrelated concurrent dialogue on the live fallback while removing style + * stacks where mpv repeats one full lyric for fill, border, blur, and shadow layers. + */ +function resolveActiveParsedPrimarySubtitle(options: { + liveText: string; + currentTimeSec: number; + cues: readonly SubtitleCue[] | null | undefined; +}): ResolvedPrimarySubtitle | null { + if (!Number.isFinite(options.currentTimeSec)) { + return null; + } + + const liveSegments = compactLineSegments(options.liveText); + if (liveSegments.length === 0) { + return null; + } + const liveSegmentSet = new Set(liveSegments); + const selected = (options.cues ?? []).filter((cue) => { + if ( + cue.startTime > options.currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS || + cue.endTime <= options.currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS + ) { + return false; + } + const cueSegments = compactLineSegments(cue.text); + return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment)); + }); + if (selected.length === 0) { + return null; + } + + const parsedSegmentSet = new Set(selected.flatMap((cue) => compactLineSegments(cue.text))); + if (!liveSegments.every((segment) => parsedSegmentSet.has(segment))) { + return null; + } + + const texts: string[] = []; + const seen = new Set(); + for (const cue of selected) { + if (!seen.has(cue.text)) { + seen.add(cue.text); + texts.push(cue.text); + } + } + return { + text: texts.join('\n'), + startTime: Math.min(...selected.map((cue) => cue.startTime)), + endTime: Math.max(...selected.map((cue) => cue.endTime)), + cues: selected, + }; +} + /** * mpv's `sub-text` renders each simultaneously active ASS event on its own line, so * while a generated animation plays every live line is a contiguous piece of the @@ -155,6 +214,8 @@ export function resolvePrimarySubtitleText(options: { liveText: options.liveText, currentTimeSec: options.currentTimeSec, cues: options.cues, - })?.text ?? options.liveText + })?.text ?? + resolveActiveParsedPrimarySubtitle(options)?.text ?? + options.liveText ); }