diff --git a/changes/fix-secondary-subtitle-duplication.md b/changes/fix-secondary-subtitle-duplication.md index c3d6c3c2..1c15bcab 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 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. +- 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. 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. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 48be3fe4..ac62f5ce 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -120,10 +120,12 @@ coming and prefetching would otherwise idle for the rest of the cue. - Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their authored source order when no usable position exists. - Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces - survive concatenation, while scripts that discarded their word boundaries remain compact - instead of gaining false spaces between syllables. Short runs qualify only when overlapping - positioned events also show changing overrides or repeated layer copies; an English or romaji - style name alone never turns ordinary dialogue into a lyric. + survive concatenation. Latin fragment typesetting with no literal spaces also recovers word + boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that + line. Unpositioned fragments stay compact instead of gaining guessed spaces between syllables. + Short runs qualify only when overlapping positioned events also show changing overrides or + repeated layer copies; an English or romaji style name alone never turns ordinary dialogue into + a lyric. - Recovered canonical ASS text remains active for the generated animation envelope. For reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit fragments from the same style. diff --git a/src/core/services/ass-text.test.ts b/src/core/services/ass-text.test.ts index 216c23f8..c16ada9a 100644 --- a/src/core/services/ass-text.test.ts +++ b/src/core/services/ass-text.test.ts @@ -10,6 +10,7 @@ import { isAssTemporalCommand, normalizePlainSubtitleText, parseAssEffectField, + removeAssControlDebrisLines, } from './ass-text'; test('assToPlainText drops vector drawing runs', () => { @@ -74,6 +75,14 @@ test('assToPlainText normalizes CRLF before converting', () => { assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目'); }); +test('removeAssControlDebrisLines drops malformed spacer resets without eating dialogue', () => { + assert.equal( + removeAssControlDebrisLines('Visible line\n\\\n{\\fr0\n\\{\\frz287.5'), + 'Visible line', + ); + assert.equal(removeAssControlDebrisLines('本文{\\pos(1,2)'), '本文{\\pos(1,2)'); +}); + test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => { // A brace reaching this layer is literal text mpv chose to show, not markup. assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)'); diff --git a/src/core/services/ass-text.ts b/src/core/services/ass-text.ts index ca05b7fd..a405b312 100644 --- a/src/core/services/ass-text.ts +++ b/src/core/services/ass-text.ts @@ -91,6 +91,23 @@ export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): st return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak); } +const MALFORMED_ASS_ROTATION_RESET = /^\\?\{\\(?:fr|frx|fry|frz|fax|fay)[-+.0-9]*$/u; + +/** + * Drop non-rendering spacer events left as literal text by a malformed, unclosed ASS + * rotation reset. These events otherwise become repeated `\\` or `{\\fr0` subtitle + * lines after mpv-compatible decoding. + */ +export function removeAssControlDebrisLines(text: string): string { + return text + .split('\n') + .filter((line) => { + const compact = line.replace(/\s+/gu, ''); + return compact !== '\\' && !MALFORMED_ASS_ROTATION_RESET.test(compact); + }) + .join('\n'); +} + export interface NormalizePlainSubtitleTextOptions { /** Fold every line break into a single space. */ collapseLineBreaks?: boolean; diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 12c8b464..adbe2173 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -35,6 +35,12 @@ test('parseSrtCues handles multi-line subtitle text', () => { assert.equal(cues[0]!.text, 'これは\nテストです'); }); +test('parseSrtCues preserves lines that only resemble malformed ASS controls', () => { + const content = ['1', '00:01:00,000 --> 00:01:05,000', '\\', '{\\fr0', ''].join('\n'); + + assert.equal(parseSrtCues(content)[0]?.text, '\\\n{\\fr0'); +}); + test('parseSrtCues strips HTML-like markup while preserving line breaks', () => { const content = [ '1', @@ -1021,3 +1027,215 @@ test('parseSubtitleCues detects subtitle formats from remote URLs', () => { assert.equal(cues.length, 1); assert.equal(cues[0]!.text, 'URLテスト'); }); + +test('parseSubtitleCues skips zero-duration ASS metadata events', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,[Script Info]', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Real subtitle', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: 'Real subtitle' }, + ]); +}); + +test('parseSubtitleCues drops malformed ASS spacer reset debris', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Background,,0,0,0,,{\\pos(10,10)}\\h\\h\\h\\{\\fr0', + 'Dialogue: 1,0:00:01.00,0:00:02.00,Default,,0,0,0,,Visible line', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: 'Visible line' }, + ]); +}); + +test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph gaps', () => { + const glyphs = [ + ['T', 100], + ['h', 118], + ['e', 136], + ['s', 164], + ['t', 178], + ['a', 194], + ['r', 210], + ['s', 227], + ['I', 255], + ['s', 275], + ['e', 293], + ['e', 311], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + glyphs.map( + ([glyph, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'The stars I see'); +}); + +test('parseSubtitleCues adds a missing word space after positioned punctuation', () => { + const fragments = [ + ['H', 100], + ['i,', 119], + ['t', 153], + ['h', 168], + ['e', 186], + ['r', 202], + ['e', 216], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Hi, there'); +}); + +test('parseSubtitleCues does not split a positioned thousands separator', () => { + const fragments = [ + ['1,', 100], + ['000', 145], + ['0', 185], + ['0', 205], + ['0', 225], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, '1,000000'); +}); + +test('parseSubtitleCues does not split a wide glyph from its punctuated suffix', () => { + const fragments = [ + ['v', 904], + ['o', 924], + ['i', 939], + ['c', 955], + ['e', 976], + ['r', 1004], + ['e', 1021], + ['a', 1042], + ['c', 1063], + ['h', 1083], + ['e', 1104], + ['d', 1125], + ['m', 1161], + ['e,', 1193], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'voice reached me,'); +}); + +test('parseSubtitleCues spaces positioned lyric fragments across authored rows', () => { + const fragments = [ + ['My', 472, 39], + ['song!', 543, 39], + ['My', 507, 78], + ['song!', 578, 78], + ['ku', 643, 39], + ['chi', 683, 39], + ['zu', 722, 39], + ['sa', 757, 39], + ['n', 783, 39], + ['de', 811, 39], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x, y], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'My song! My song! kuchizusande'); +}); + +test('parseSubtitleCues recovers positioned word gaps between romaji fragments', () => { + const fragments = [ + ['sa', 380], + ['ga', 421], + ['shi', 467], + ['te', 510], + ['ta', 545], + ['ha', 593], + ['ji', 624], + ['ke', 655], + ['ta', 693], + ['i', 726], + ['ro', 749], + ['no', 798], + ['yu', 849], + ['me', 895], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'sagashiteta hajiketa iro no yume'); +}); + +test('parseSubtitleCues recovers clear word gaps in a short romaji line', () => { + const fragments = [ + ['bo', 542], + ['ku', 584], + ['wo', 640], + ['yo', 697], + ['bu', 738], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'boku wo yobu'); +}); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 99de766a..95a1f176 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -3,6 +3,7 @@ import { assToPlainText, collectAssOverrideCommands, parseAssEffectField, + removeAssControlDebrisLines, type AssEffectKind, type AssOverrideCommand, } from './ass-text'; @@ -91,6 +92,10 @@ function sanitizeSubtitleCueText(text: string): string { return decodeSubtitleCueText(text).trim(); } +function sanitizeAssCueText(text: string): string { + return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim(); +} + function attachAssLayout(cue: T, assLayout: AssCueLayout | undefined): T { if (assLayout) { Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false }); @@ -387,6 +392,124 @@ interface AssFragmentPart { text: string; } +interface AssFragmentPosition { + x: number; + y: number; +} + +const MIN_LATIN_POSITION_GAP_SAMPLES = 4; +const LATIN_WORD_GAP_RATIO = 1.2; + +function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null { + for (const command of cue.overrides) { + if (command.animated) continue; + const name = command.name.toLowerCase(); + const args = command.args.split(',').map((value) => Number(value.trim())); + if ( + name === 'pos' && + args.length >= 2 && + Number.isFinite(args[0]) && + Number.isFinite(args[1]) + ) { + return { x: args[0]!, y: args[1]! }; + } + if ( + name === 'move' && + args.length >= 4 && + args.slice(0, 4).every((value) => Number.isFinite(value)) + ) { + return { x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 }; + } + } + return null; +} + +function latinGlyphWidthWeight(glyph: string): number { + if (/[ilIjtfr]/u.test(glyph)) return 0.6; + if (/[mwMW]/u.test(glyph)) return 1.4; + if (/[A-Z]/u.test(glyph)) return 1.1; + return 1; +} + +function latinFragmentWidthWeight(text: string): number | null { + if (!/^[A-Za-z0-9'’.,!?;:-]+$/u.test(text)) return null; + const punctuationWeight = /^[A-Za-z0-9]['’.,!?;:-]$/u.test(text) ? 0.5 : 0.25; + return [...text].reduce( + (width, glyph) => + width + (/['’.,!?;:-]/u.test(glyph) ? punctuationWeight : latinGlyphWidthWeight(glyph)), + 0, + ); +} + +function normalizedLatinFragmentGap( + previous: AssFragmentPart, + current: AssFragmentPart, +): number | null { + const previousWeight = latinFragmentWidthWeight(previous.text); + const currentWeight = latinFragmentWidthWeight(current.text); + const previousPosition = fragmentPosition(previous.cue); + const currentPosition = fragmentPosition(current.cue); + if (previousWeight === null || currentWeight === null || !previousPosition || !currentPosition) { + return null; + } + const xDistance = currentPosition.x - previousPosition.x; + const yDistance = Math.abs(currentPosition.y - previousPosition.y); + if (yDistance <= 2 && xDistance <= 0) return null; + + // A wrapped authored line can return to the left on its next visual row. Preserve + // that measured row transition as a separator without treating backwards movement + // on the same row as a word gap. + const positionDistance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance; + return positionDistance / ((previousWeight + currentWeight) / 2); +} + +function commonLatinFragmentGap(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + // Romaji lines contain many short particles, so real word gaps can outnumber + // within-word transitions. A lower quantile still represents ordinary glyph advance + // while ignoring the narrowest character pair as an outlier. + return sorted[Math.floor((sorted.length - 1) * 0.35)]!; +} + +/** + * Character-by-character typesetting often omits literal spaces because the authored + * word gap exists only in each glyph's `\pos`. Estimate the normal adjacent-glyph + * advance within that one line, then preserve only materially larger horizontal gaps. + * Normalizing each gap by the neighboring fragment widths supports both single glyphs + * and multi-character karaoke syllables without guessing from the text itself. + */ +function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string { + if (parts.some((part) => /\s/u.test(part.text))) { + return parts + .map((part) => part.text) + .join('') + .trim(); + } + const normalizedGaps: number[] = []; + for (let index = 1; index < parts.length; index += 1) { + const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!); + if (gap !== null) normalizedGaps.push(gap); + } + const wordGapThreshold = + normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES + ? commonLatinFragmentGap(normalizedGaps) * LATIN_WORD_GAP_RATIO + : Infinity; + + let text = parts[0]?.text ?? ''; + for (let index = 1; index < parts.length; index += 1) { + const previous = parts[index - 1]!; + const current = parts[index]!; + const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text); + const normalizedGap = normalizedLatinFragmentGap(previous, current); + const hasPositionedWordGap = normalizedGap !== null && normalizedGap > wordGapThreshold; + if (!hasAuthoredSpace && hasPositionedWordGap) { + text += ' '; + } + text += current.text; + } + return text.trim(); +} + function reconstructedAssFragmentLayout( parts: readonly AssFragmentPart[], owner: AnnotatedSubtitleCue, @@ -522,10 +645,7 @@ function reconstructAssFragmentLine( return null; } - const text = parts - .map((part) => part.text) - .join('') - .trim(); + const text = joinAssFragmentParts(parts); if (!text) { return null; } @@ -911,12 +1031,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents { const startTime = parseAssTimestamp(fields[fieldIndex.start]!); const endTime = parseAssTimestamp(fields[fieldIndex.end]!); - if (startTime === null || endTime === null) { + if (startTime === null || endTime === null || endTime <= startTime) { continue; } const rawText = fields.slice(fieldIndex.text).join(','); - const text = sanitizeSubtitleCueText(rawText); + const text = sanitizeAssCueText(rawText); if (!text) { continue; } diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 910882d1..ba46884a 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -426,6 +426,13 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer text: '飛び越えてみたくて', source: 'canonical-ass', }, + { + startTime: 10, + endTime: 12, + text: 'MaidCafeMaidCafe', + source: 'reconstructed-ass', + assLayout: { kind: 'fragment-grid', sourceOrder: 2 }, + }, ], currentMediaPath: '/video.mkv', currentSubText: '', @@ -503,6 +510,11 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer handlers.recordSubtitleTiming('今', 0.8, 1.5); assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); + + handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12); + handlers.recordSubtitleTiming('Maid\nCafe', 10, 12); + assert.equal(immersion.length, 3); + assert.equal(timing.length, 5); }); test('subtitle-track changes stop stale canonical cues from substituting immediately', () => { diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 2b1c6bf9..5d87be61 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -218,6 +218,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { return; } text = stripFragmentsForRecording(text, start); + if (!text.trim()) { + return; + } if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) { return; } @@ -228,8 +231,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined; const canonical = resolveCanonicalSample(text, start); if (!canonical) { + const recordableText = stripFragmentsForRecording(text, start); + if (!recordableText.trim()) { + return; + } deps.appState.subtitleTimingTracker?.recordSubtitle?.( - stripFragmentsForRecording(text, start), + recordableText, start, end, secondaryText, diff --git a/src/main/runtime/primary-subtitle-text.test.ts b/src/main/runtime/primary-subtitle-text.test.ts index 05359ae9..085fd821 100644 --- a/src/main/runtime/primary-subtitle-text.test.ts +++ b/src/main/runtime/primary-subtitle-text.test.ts @@ -64,6 +64,34 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () = ); }); +test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'First line\nSecond line\nFirst line', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: 'First line\nSecond line' }, + { startTime: 1, endTime: 3, text: 'First line' }, + ], + }), + 'First line\nSecond line', + ); +}); + +test('resolvePrimarySubtitleText removes equivalent full-width duplicate lines', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '20分53秒\n20分53秒', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '20分53秒' }, + { startTime: 1, endTime: 3, text: '20分53秒' }, + ], + }), + '20分53秒', + ); +}); + test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => { const ass = [ '[Events]', @@ -152,6 +180,54 @@ test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of t assert.equal(text, '普通のセリフ\n今\n手にある'); }); +test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed lyric', () => { + const text = resolvePrimarySubtitleText({ + liveText: '普通のセリフ\n今\n今\n手\n手\nにある\nにある', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '普通のセリフ' }, + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'reconstructed-ass', + }, + ], + }); + + assert.equal(text, '普通のセリフ\n今 手にある'); +}); + +test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => { + const text = resolvePrimarySubtitleText({ + liveText: 'Ordinary dialogue\nMaid\nCafe', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: 'Ordinary dialogue' }, + { + startTime: 1, + endTime: 3, + text: 'MaidCafeMaidCafe', + source: 'reconstructed-ass', + assLayout: { kind: 'fragment-grid', sourceOrder: 2 }, + }, + ], + }); + + assert.equal(text, 'Ordinary dialogue'); +}); + +test('resolvePrimarySubtitleText drops malformed ASS control debris from live text', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'Visible line\n\\\n{\\fr0', + currentTimeSec: 2, + cues: null, + }), + 'Visible line', + ); +}); + test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => { const text = resolvePrimarySubtitleText({ liveText: '次のセリフ', diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts index 63e72a99..91ea6deb 100644 --- a/src/main/runtime/primary-subtitle-text.ts +++ b/src/main/runtime/primary-subtitle-text.ts @@ -1,4 +1,5 @@ import type { SubtitleCue } from '../../types'; +import { removeAssControlDebrisLines } from '../../core/services/ass-text'; // 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 @@ -23,9 +24,13 @@ function animationSpan(cue: SubtitleCue): { start: number; end: number } { function nearbyCanonicalCues( cues: readonly SubtitleCue[] | null | undefined, currentTimeSec: number, + includeFragmentGrids = false, ): SubtitleCue[] { return (cues ?? []).filter((cue) => { - if (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') { + if ( + (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') || + (!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid') + ) { return false; } const span = animationSpan(cue); @@ -37,7 +42,7 @@ function nearbyCanonicalCues( } function compactWhitespace(text: string): string { - return text.replace(/\s+/gu, ''); + return text.normalize('NFKC').replace(/\s+/gu, ''); } // ASS layers can encode the same visible spacing with ordinary, hard, or @@ -47,10 +52,12 @@ function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] { const texts: string[] = []; const seen = new Set(); for (const cue of cues) { - const compactText = compactWhitespace(cue.text); - if (seen.has(compactText)) continue; - seen.add(compactText); - texts.push(cue.text); + for (const line of cue.text.split('\n')) { + const compactText = compactWhitespace(line); + if (!compactText || seen.has(compactText)) continue; + seen.add(compactText); + texts.push(line); + } } return texts; } @@ -87,18 +94,37 @@ function resolveActiveParsedPrimarySubtitle(options: { return false; } const cueSegments = compactLineSegments(cue.text); - return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment)); + if (cueSegments.length === 0) return false; + if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') { + return liveSegments.some((segment) => + cueSegments.some((cueSegment) => cueSegment.includes(segment)), + ); + } + return 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))) { + const parsedSegments = selected.flatMap((cue) => + compactLineSegments(cue.text).map((segment) => ({ + segment, + recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass', + })), + ); + if ( + !liveSegments.every((liveSegment) => + parsedSegments.some(({ segment, recovered }) => + recovered ? segment.includes(liveSegment) : segment === liveSegment, + ), + ) + ) { return null; } - const texts = uniqueCueTexts(selected); + // Dense sign grids still explain their raw mpv fragments, but are visual + // typesetting rather than a publishable subtitle line. + const texts = uniqueCueTexts(selected.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')); return { text: texts.join('\n'), startTime: Math.min(...selected.map((cue) => cue.startTime)), @@ -179,8 +205,9 @@ export function resolveCanonicalPrimarySubtitle(options: { /** * Live text with generated-animation fragment lines removed. Recording paths use this * when full canonical substitution declined -- concurrent dialogue during an insert - * song: the dialogue is worth recording, the glyph fragments beside it are not. Returns - * the input unchanged when no canonical cue is near or nothing non-fragment remains. + * song: the dialogue is worth recording, the glyph fragments beside it are not. An + * all-fragment visual grid becomes empty; other all-matched input remains unchanged as a + * defensive fallback. */ export function stripCanonicalFragmentLines(options: { liveText: string; @@ -190,7 +217,7 @@ export function stripCanonicalFragmentLines(options: { if (!Number.isFinite(options.currentTimeSec)) { return options.liveText; } - const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec); + const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true); if (nearby.length === 0) { return options.liveText; } @@ -199,7 +226,8 @@ export function stripCanonicalFragmentLines(options: { const compact = compactWhitespace(line); return compact && !compactCues.some((cueText) => cueText.includes(compact)); }); - return kept.length > 0 ? kept.join('\n') : options.liveText; + if (kept.length > 0) return kept.join('\n'); + return nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid') ? '' : options.liveText; } export function resolvePrimarySubtitleText(options: { @@ -207,16 +235,17 @@ export function resolvePrimarySubtitleText(options: { currentTimeSec: number; cues: readonly SubtitleCue[] | null | undefined; }): string { - if (!options.liveText.trim()) { - return options.liveText; + const liveText = removeAssControlDebrisLines(options.liveText); + if (!liveText.trim()) { + return liveText; } return ( resolveCanonicalPrimarySubtitle({ - liveText: options.liveText, + liveText, currentTimeSec: options.currentTimeSec, cues: options.cues, })?.text ?? - resolveActiveParsedPrimarySubtitle(options)?.text ?? - options.liveText + resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ?? + liveText ); } diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts index e11dedba..d0769801 100644 --- a/src/main/runtime/secondary-subtitle-track.test.ts +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -20,6 +20,32 @@ test('findActiveSubtitleText combines unique simultaneous parsed cues', () => { ); }); +test('findActiveSubtitleText removes duplicate lines across multiline cues', () => { + assert.equal( + findActiveSubtitleText( + [ + { startTime: 1, endTime: 3, text: 'First line\nSecond line' }, + { startTime: 1, endTime: 3, text: 'First line' }, + ], + 2, + ), + 'First line\nSecond line', + ); +}); + +test('findActiveSubtitleText removes equivalent full-width duplicate lines', () => { + assert.equal( + findActiveSubtitleText( + [ + { startTime: 1, endTime: 3, text: '真白~' }, + { startTime: 1, endTime: 3, text: '真白~' }, + ], + 2, + ), + '真白~', + ); +}); + test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => { assert.equal( findActiveSubtitleText( @@ -380,6 +406,23 @@ test('secondary track controller falls back to live mpv text without a readable assert.deepEqual(broadcasts, ['live fallback']); }); +test('secondary live fallback drops malformed ASS control debris', () => { + const broadcasts: string[] = []; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => null, + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => null, + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + controller.handleLiveText('Visible line\n\\\n{\\fr0'); + + assert.deepEqual(broadcasts, ['Visible line']); +}); + 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 d323bead..e3443aa2 100644 --- a/src/main/runtime/secondary-subtitle-track.ts +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -1,5 +1,6 @@ import type { SubtitleCue } from '../../types/subtitle'; import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity'; +import { removeAssControlDebrisLines } from '../../core/services/ass-text'; type SecondarySubtitleMpvClient = { connected?: boolean; @@ -153,15 +154,17 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds activeCues.sort(compareAuthoredSubtitleOrder); for (const { cue } of activeCues) { - const text = cue.text.trim(); - const compactText = text.replace(/\s+/gu, ''); - if (!compactText || seenExact.has(compactText)) continue; - seenExact.add(compactText); + for (const line of cue.text.split('\n')) { + const text = line.trim(); + const compactText = text.normalize('NFKC').replace(/\s+/gu, ''); + if (!compactText || seenExact.has(compactText)) continue; + seenExact.add(compactText); - const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text); - if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue; - if (flattenedIdentity) seenFlattened.add(flattenedIdentity); - activeText.push(text); + const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text); + if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue; + if (flattenedIdentity) seenFlattened.add(flattenedIdentity); + activeText.push(text); + } } return activeText.join('\n'); } @@ -305,7 +308,7 @@ export function createSecondarySubtitleTrackController(deps: { refresh, scheduleRefresh, handleLiveText(text: string): void { - lastLiveText = text; + lastLiveText = removeAssControlDebrisLines(text); publish(resolveAtTime(deps.getCurrentTimePos())); }, handleTimePos(timeSeconds: number): void {