diff --git a/changes/fix-primary-ass-layer-duplication.md b/changes/fix-primary-ass-layer-duplication.md index 0d09be2a..90d58190 100644 --- a/changes/fix-primary-ass-layer-duplication.md +++ b/changes/fix-primary-ass-layer-duplication.md @@ -1,4 +1,4 @@ type: fixed area: subtitles -- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, and navigate song lyrics by sanitized lines instead of generated animation events while preserving unmatched dialogue and signs. +- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, navigate song lyrics by sanitized lines instead of generated animation events, and keep sidebar selections on the requested overlapping lyric while preserving unmatched dialogue and signs. diff --git a/changes/fix-secondary-subtitle-duplication.md b/changes/fix-secondary-subtitle-duplication.md index 07682311..e97b5afe 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. Live mpv text remains the fallback for unreadable tracks. +- 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. diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 08c40957..8dd0c304 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -110,6 +110,8 @@ The secondary bar is a compact top-strip region in the same overlay window. It s It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`. +SubMiner collapses duplicate ASS layers in parsed secondary tracks. Long lines repeated as dialogue and positioned signs are treated as the same line when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar. When SubMiner must use mpv's live text as a fallback, it still filters full-line duplicates while preserving short repeated dialogue. + ### Display Modes Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime: diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index 79d669e8..4478098b 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -9,7 +9,7 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open: - The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`). -- Clicking any cue seeks mpv to that timestamp. +- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining. - The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously. For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index b896adec..111ba67e 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -84,6 +84,9 @@ coming and prefetching would otherwise idle for the rest of the cue. share one boundary, overlapping lyrics advance from the latest active boundary, and mpv's native command remains the fallback when no parsed destination exists. This prevents generated karaoke frames from consuming next/previous subtitle presses. +- Subtitle sidebar selections seek past the preceding sanitized cue's overlapping exit span when + the selected cue has enough time remaining. This keeps direct row selection on the requested + karaoke line while clamping the seek inside that cue. - If startup paints raw text before embedded ASS parsing finishes, parsed cue arrival may replace that provisional line. The one-prime-per-media guard still suppresses identical repeats. - If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty @@ -96,12 +99,18 @@ coming and prefetching would otherwise idle for the rest of the cue. - `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources still appear without waiting for file resolution. +- Parsed secondary text and the live fallback share a flattened-line identity for long lines. This + removes dialogue/sign repetitions that differ only in whitespace or terminal punctuation while + retaining short repeated lines that can represent authored dialogue without source metadata. - `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed source resolver used by primary subtitle prefetching. - The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate and animation collapse. Playback `time-pos` selects the active parsed cue after applying `secondary-sub-delay`. +- Fragment reconstruction marks positioned parts that span multiple vertical rows as a grid. + Secondary text omits those grids instead of flattening a translated table or schedule into one + synthetic line. Reconstructed single-line karaoke remains eligible for display. - The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary text when a readable source is available. diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 9c091b15..b348c2ce 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -52,6 +52,7 @@ export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime export { createSubtitleProcessingController } from './subtitle-processing-controller'; export { resolveSanitizedSubtitleSeekCommand, + subtitleCueListSeekTime, subtitleCueSeekTime, } from './subtitle-cue-navigation'; export { createFrequencyDictionaryLookup } from './frequency-dictionary'; diff --git a/src/core/services/secondary-subtitle-line-identity.ts b/src/core/services/secondary-subtitle-line-identity.ts new file mode 100644 index 00000000..16442abf --- /dev/null +++ b/src/core/services/secondary-subtitle-line-identity.ts @@ -0,0 +1,15 @@ +const MIN_FLATTENED_DUPLICATE_LENGTH = 16; +const TERMINAL_SENTENCE_PUNCTUATION = /[.!?。!?…⋯]+$/gu; + +/** + * Identifies long lines that become duplicates when positioned ASS events are + * flattened into the secondary subtitle bar. Short dialogue stays distinct. + */ +export function flattenedSecondarySubtitleLineIdentity(text: string): string | null { + const identity = text + .normalize('NFKC') + .replace(/\s+/gu, '') + .replace(TERMINAL_SENTENCE_PUNCTUATION, ''); + + return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null; +} diff --git a/src/core/services/subtitle-cue-navigation.test.ts b/src/core/services/subtitle-cue-navigation.test.ts index f9824ae8..00198fb4 100644 --- a/src/core/services/subtitle-cue-navigation.test.ts +++ b/src/core/services/subtitle-cue-navigation.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { resolveSanitizedSubtitleSeekCommand, + subtitleCueListSeekTime, subtitleCueSeekTime, } from './subtitle-cue-navigation'; @@ -85,3 +86,23 @@ test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08); assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03); }); + +test('sidebar cue selection clears an overlapping previous lyric', () => { + const cues = [ + { startTime: 1, endTime: 3.4, text: 'previous lyric' }, + { startTime: 3, endTime: 5, text: 'selected lyric' }, + ]; + + assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48); +}); + +test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => { + const cues = [ + { startTime: 1, endTime: 3.4, text: 'previous lyric' }, + { startTime: 3, endTime: 3.2, text: 'selected lyric' }, + ]; + + const seekTime = subtitleCueListSeekTime(cues, cues[1]!); + assert.ok(seekTime >= 3.19); + assert.ok(seekTime < cues[1]!.endTime); +}); diff --git a/src/core/services/subtitle-cue-navigation.ts b/src/core/services/subtitle-cue-navigation.ts index acd2b619..f0248102 100644 --- a/src/core/services/subtitle-cue-navigation.ts +++ b/src/core/services/subtitle-cue-navigation.ts @@ -42,6 +42,36 @@ export function subtitleCueSeekTime(cue: SubtitleCue): number { ); } +/** + * Choose a stable point inside a selected cue. Karaoke lines can overlap while the + * previous line animates out, so a sidebar selection should clear that overlap when + * the selected cue has enough time remaining. + */ +export function subtitleCueListSeekTime( + cues: readonly SubtitleCue[], + selectedCue: SubtitleCue, +): number { + const groups = groupCueBoundaries(cues); + const selectedGroupIndex = groups.findIndex( + (group) => + selectedCue.startTime >= group.startTime && + selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS, + ); + const previousGroupEndTime = + selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined; + if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) { + return subtitleCueSeekTime(selectedCue); + } + + return Math.max( + selectedCue.startTime, + Math.min( + selectedCue.endTime - CUE_END_GUARD_SECONDS, + previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS, + ), + ); +} + /** * Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can * contain hundreds of subtitle events for one visible line, while the parsed list has diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 5f498afd..696c0d03 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -10,6 +10,7 @@ import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedu export type AssCueLayout = | { kind: 'positioned'; sourceOrder: number; y: number } + | { kind: 'fragment-grid'; sourceOrder: number } | { kind: 'source-order'; sourceOrder: number }; export interface SubtitleCue { @@ -210,6 +211,7 @@ const MIN_FRAGMENT_LINE_EVENTS = 8; const MIN_FRAGMENT_LINE_PARTS = 4; const MAX_FRAGMENT_MEDIAN_LENGTH = 4; const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2; +const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48; function parseAssTimestamp(raw: string): number | null { const match = ASS_TIMING_PATTERN.exec(raw.trim()); @@ -385,6 +387,30 @@ interface AssFragmentPart { text: string; } +function reconstructedAssFragmentLayout( + parts: readonly AssFragmentPart[], + owner: AnnotatedSubtitleCue, +): AssCueLayout | undefined { + let positionedPartCount = 0; + let minimumY = Infinity; + let maximumY = -Infinity; + for (const part of parts) { + const layout = part.cue.assLayout; + if (layout?.kind !== 'positioned') continue; + positionedPartCount += 1; + minimumY = Math.min(minimumY, layout.y); + maximumY = Math.max(maximumY, layout.y); + } + + if ( + positionedPartCount >= MIN_FRAGMENT_LINE_PARTS && + maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN + ) { + return { kind: 'fragment-grid', sourceOrder: owner.order }; + } + return owner.assLayout; +} + interface AssFragmentTimingCluster { events: AnnotatedSubtitleCue[]; minStartTime: number; @@ -515,6 +541,7 @@ function reconstructAssFragmentLine( source: 'reconstructed-ass', animationStartTime, animationEndTime, + assLayout: reconstructedAssFragmentLayout(parts, owner), overrides: [], overrideSignature: '', }; diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts index 97b6c5ab..5b6ea2f6 100644 --- a/src/main/runtime/secondary-subtitle-track.test.ts +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -34,6 +34,44 @@ test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () ); }); +test('parsed secondary text collapses a positioned sign that repeats dialogue without punctuation', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 10,0:03:58.49,0:04:00.34,GJM_Main_1080p,Nar,0,0,0,,{\\i1}A question veiled as an insult!', + 'Dialogue: 1,0:03:58.59,0:04:00.34,iFanzSigns,,0,0,0,,{\\pos(960,75)}A question veiled as an insult', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'kaguya-s02e10.ass'); + + assert.equal(findActiveSubtitleText(cues, 238.48), ''); + assert.equal(findActiveSubtitleText(cues, 238.5), 'A question veiled as an insult!'); + assert.equal(findActiveSubtitleText(cues, 239), 'A question veiled as an insult!'); + assert.equal(findActiveSubtitleText(cues, 240.34), ''); +}); + +test('parsed secondary text drops a reconstructed grid of positioned sign fragments', () => { + const signFragment = (text: string, x: number, y: number) => + `Dialogue: 1,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(${x},${y})\\t(0,100,\\fscx101)}${text}`; + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 10,0:00:01.00,0:00:03.00,Default,Speaker,0,0,0,,Come on, wake up!', + signFragment('Timetable', 1700, 150), + signFragment('Mon', 1750, 230), + signFragment('Tue', 1850, 230), + signFragment('1', 1650, 320), + signFragment('2', 1650, 390), + signFragment('Civics', 1750, 320), + signFragment('Math', 1850, 390), + signFragment('PE', 1850, 460), + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'kaguya-s02e11.ass'), 2), + 'Come on, wake up!', + ); +}); + test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => { const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) => `Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`; diff --git a/src/main/runtime/secondary-subtitle-track.ts b/src/main/runtime/secondary-subtitle-track.ts index 62bda6ad..d323bead 100644 --- a/src/main/runtime/secondary-subtitle-track.ts +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -1,4 +1,5 @@ import type { SubtitleCue } from '../../types/subtitle'; +import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity'; type SecondarySubtitleMpvClient = { connected?: boolean; @@ -110,6 +111,7 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds const activeReconstructed = cues.filter( (cue) => cue.source === 'reconstructed-ass' && + cue.assLayout?.kind !== 'fragment-grid' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds, ); @@ -135,7 +137,8 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds } const selectedReconstructed = new Set(reconstructedByStyle.values()); - const seen = new Set(); + const seenExact = new Set(); + const seenFlattened = new Set(); const activeText: string[] = []; const activeCues: IndexedSubtitleCue[] = []; cues.forEach((cue, index) => { @@ -152,8 +155,12 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds for (const { cue } of activeCues) { const text = cue.text.trim(); const compactText = text.replace(/\s+/gu, ''); - if (!compactText || seen.has(compactText)) continue; - seen.add(compactText); + 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); } return activeText.join('\n'); diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index f9a46b35..e369f88a 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -240,14 +240,15 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback const snapshot: SubtitleSidebarSnapshot = { cues: [ - { startTime: 1, endTime: 2, text: 'first' }, + { startTime: 1, endTime: 3.4, text: 'first' }, { startTime: 3, endTime: 4, text: 'second' }, ], currentSubtitle: { text: 'second', - startTime: 3, + startTime: 3.5, endTime: 4, }, + currentTimeSec: 3.5, config: { enabled: true, autoOpen: false, @@ -361,6 +362,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback modal.seekToCue(snapshot.cues[0]!); assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']); + modal.seekToCue(snapshot.cues[1]!); + assert.deepEqual(mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']); + modal.closeSubtitleSidebarModal(); assert.deepEqual(visibilityChanges, [true, false]); assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index cdceddfa..8d5f797f 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -4,7 +4,7 @@ import type { SubtitleMiningContext, SubtitleSidebarSnapshot, } from '../../types'; -import { subtitleCueSeekTime } from '../../core/services/subtitle-cue-navigation.js'; +import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { @@ -392,7 +392,11 @@ export function createSubtitleSidebarModal( } function seekToCue(cue: SubtitleCue): void { - window.electronAPI.sendMpvCommand(['seek', subtitleCueSeekTime(cue), 'absolute+exact']); + window.electronAPI.sendMpvCommand([ + 'seek', + subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), + 'absolute+exact', + ]); } function getCueRowLabel(cue: SubtitleCue): string { diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 29c8e5ec..64a645e8 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -1454,6 +1454,15 @@ test('prepareSecondarySubtitleLines preserves repeated short dialogue without la assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue); }); +test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => { + const dialogue = 'A question veiled as an insult!'; + const positionedSign = 'A question veiled as an insult'; + + assert.deepEqual(prepareSecondarySubtitleLines([dialogue, positionedSign].join('\\N')), [ + dialogue, + ]); +}); + test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => { const dialogue = ['Wait', 'Go!', 'No!', 'Run!']; diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 8b63c004..12e9bc04 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -6,6 +6,7 @@ import type { SubtitleRendererStyleConfig, } from '../types'; import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js'; +import { flattenedSecondarySubtitleLineIdentity } from '../core/services/secondary-subtitle-line-identity.js'; import type { RendererContext } from './context'; import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js'; @@ -665,6 +666,17 @@ function isKaraokeLikeLineSet(lines: string[]): boolean { return median <= KARAOKE_MAX_MEDIAN_LINE_LENGTH; } +function collapseFullLineFallbackCopies(lines: string[]): string[] { + const seen = new Set(); + return lines.filter((line) => { + const identity = flattenedSecondarySubtitleLineIdentity(line); + if (!identity) return true; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} + export function prepareSecondarySubtitleLines(text: string): string[] { // The one display-side ASS decode: secondary text also reaches the overlay from // websocket clients that forward their source line untouched, so unlike the primary @@ -678,7 +690,7 @@ export function prepareSecondarySubtitleLines(text: string): string[] { .map((line) => line.trim()) .filter((line) => line.length > 0); if (!isKaraokeLikeLineSet(lines)) { - return lines; + return collapseFullLineFallbackCopies(lines); } const seen = new Set();