fix(subtitles): preserve lyric selection and deduplicate secondary text

- Seek sidebar selections past overlapping karaoke exit spans
- Collapse duplicate long ASS lines and omit reconstructed sign grids
This commit is contained in:
2026-08-21 01:28:00 -07:00
parent 9445aef004
commit 88bb3edfa4
16 changed files with 190 additions and 11 deletions
+1 -1
View File
@@ -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.
@@ -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.
+2
View File
@@ -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:
+1 -1
View File
@@ -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.
@@ -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.
+1
View File
@@ -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';
@@ -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;
}
@@ -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);
});
@@ -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
+27
View File
@@ -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: '',
};
@@ -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`;
+10 -3
View File
@@ -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<string>();
const seenExact = new Set<string>();
const seenFlattened = new Set<string>();
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');
+6 -2
View File
@@ -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']);
+6 -2
View File
@@ -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 {
+9
View File
@@ -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!'];
+13 -1
View File
@@ -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<string>();
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<string>();