diff --git a/changes/ass-canonical-animation-lines.md b/changes/ass-canonical-animation-lines.md index 5eeaf008..54e504cb 100644 --- a/changes/ass-canonical-animation-lines.md +++ b/changes/ass-canonical-animation-lines.md @@ -1,5 +1,5 @@ type: fixed area: subtitles -- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. +- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies of the line. - The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply. diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index cf3fe7c0..11f7ed2f 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -1530,3 +1530,43 @@ test('parseSubtitleCues recovers clear word gaps in a short romaji line', () => assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'boku wo yobu'); }); + +test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing it', () => { + // Main lyric: per-glyph fragments alive together for the whole line. + const lineFragments = [ + ['to', 972], + ['so', 1051], + ['u', 1113], + ['o', 1166], + ['mo', 1204], + ] as const; + // Highlight sweep: one syllable at a time over the same lyric, each event ending + // exactly as the next begins, so no two syllables are ever on screen together. + const sweepFragments = [ + ['to', 972, '0:00:01.00', '0:00:01.40'], + ['so', 1051, '0:00:01.40', '0:00:01.80'], + ['u', 1113, '0:00:01.80', '0:00:02.20'], + ['o', 1166, '0:00:02.20', '0:00:02.60'], + ['mo', 1204, '0:00:02.60', '0:00:03.00'], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + lineFragments.map( + ([fragment, x], index) => + `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}`, + ), + ), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo'); +}); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 1afc5f89..b5e7face 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -762,6 +762,37 @@ function clusterAssFragmentEvents( return clusters; } +/** + * A karaoke highlight sweep repaints one syllable at a time over an already-visible + * lyric line: each event ends as the next begins, so the cluster's concatenated text is + * never on screen as a whole. Publishing it would emit rolling partial copies of the + * lyric ("to sou omo" beside "akenakute ii to sou omotteta"). Layer copies share one + * placement and timing, so the test is whether any two distinct placements coexist. + */ +function isProgressiveHighlightSweep(events: readonly AnnotatedSubtitleCue[]): boolean { + const intervals: { startTime: number; endTime: number }[] = []; + const seen = new Set(); + 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); + intervals.push({ startTime: event.startTime, endTime: event.endTime }); + } + if (intervals.length < 2) { + return false; + } + intervals.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime); + let latestEnd = intervals[0]!.endTime; + for (let index = 1; index < intervals.length; index += 1) { + if (intervals[index]!.startTime < latestEnd - 0.001) { + return false; + } + latestEnd = Math.max(latestEnd, intervals[index]!.endTime); + } + return true; +} + function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null { const visibleLines = decodeSubtitleCueText(cue.rawText) .split('\n') @@ -903,6 +934,12 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate if (!line) { continue; } + // A sweep only re-highlights the lyric it decorates: hide its events without + // publishing the reconstruction. + if (isProgressiveHighlightSweep(cluster.events)) { + cluster.events.forEach((event) => suppressed.add(event)); + continue; + } recovered.push(line); cluster.events.forEach((event) => suppressed.add(event)); // Decoration is timed to the line it overlays, so it disappears with the line's @@ -916,7 +953,7 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate } } } - if (recovered.length === 0) { + if (recovered.length === 0 && suppressed.size === 0) { return dialogue; } return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(