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 type: fixed
area: subtitles 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 type: fixed
area: overlay 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`. 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 ### Display Modes
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime: 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: 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`). - 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. - 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. 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 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 command remains the fallback when no parsed destination exists. This prevents generated karaoke
frames from consuming next/previous subtitle presses. 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 - 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. 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 - 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 - `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
still appear without waiting for file resolution. 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 - `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 are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
source resolver used by primary subtitle prefetching. source resolver used by primary subtitle prefetching.
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate - 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 and animation collapse. Playback `time-pos` selects the active parsed cue after applying
`secondary-sub-delay`. `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 - 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 overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
text when a readable source is available. 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 { createSubtitleProcessingController } from './subtitle-processing-controller';
export { export {
resolveSanitizedSubtitleSeekCommand, resolveSanitizedSubtitleSeekCommand,
subtitleCueListSeekTime,
subtitleCueSeekTime, subtitleCueSeekTime,
} from './subtitle-cue-navigation'; } from './subtitle-cue-navigation';
export { createFrequencyDictionaryLookup } from './frequency-dictionary'; 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 test from 'node:test';
import { import {
resolveSanitizedSubtitleSeekCommand, resolveSanitizedSubtitleSeekCommand,
subtitleCueListSeekTime,
subtitleCueSeekTime, subtitleCueSeekTime,
} from './subtitle-cue-navigation'; } 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: 2, text: 'line' }), 1.08);
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03); 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 * 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 * 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 = export type AssCueLayout =
| { kind: 'positioned'; sourceOrder: number; y: number } | { kind: 'positioned'; sourceOrder: number; y: number }
| { kind: 'fragment-grid'; sourceOrder: number }
| { kind: 'source-order'; sourceOrder: number }; | { kind: 'source-order'; sourceOrder: number };
export interface SubtitleCue { export interface SubtitleCue {
@@ -210,6 +211,7 @@ const MIN_FRAGMENT_LINE_EVENTS = 8;
const MIN_FRAGMENT_LINE_PARTS = 4; const MIN_FRAGMENT_LINE_PARTS = 4;
const MAX_FRAGMENT_MEDIAN_LENGTH = 4; const MAX_FRAGMENT_MEDIAN_LENGTH = 4;
const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2; const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2;
const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48;
function parseAssTimestamp(raw: string): number | null { function parseAssTimestamp(raw: string): number | null {
const match = ASS_TIMING_PATTERN.exec(raw.trim()); const match = ASS_TIMING_PATTERN.exec(raw.trim());
@@ -385,6 +387,30 @@ interface AssFragmentPart {
text: string; 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 { interface AssFragmentTimingCluster {
events: AnnotatedSubtitleCue[]; events: AnnotatedSubtitleCue[];
minStartTime: number; minStartTime: number;
@@ -515,6 +541,7 @@ function reconstructAssFragmentLine(
source: 'reconstructed-ass', source: 'reconstructed-ass',
animationStartTime, animationStartTime,
animationEndTime, animationEndTime,
assLayout: reconstructedAssFragmentLayout(parts, owner),
overrides: [], overrides: [],
overrideSignature: '', 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', () => { 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 }) => 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`; `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 type { SubtitleCue } from '../../types/subtitle';
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
type SecondarySubtitleMpvClient = { type SecondarySubtitleMpvClient = {
connected?: boolean; connected?: boolean;
@@ -110,6 +111,7 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
const activeReconstructed = cues.filter( const activeReconstructed = cues.filter(
(cue) => (cue) =>
cue.source === 'reconstructed-ass' && cue.source === 'reconstructed-ass' &&
cue.assLayout?.kind !== 'fragment-grid' &&
cue.startTime <= timeSeconds && cue.startTime <= timeSeconds &&
cue.endTime > timeSeconds, cue.endTime > timeSeconds,
); );
@@ -135,7 +137,8 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
} }
const selectedReconstructed = new Set(reconstructedByStyle.values()); 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 activeText: string[] = [];
const activeCues: IndexedSubtitleCue[] = []; const activeCues: IndexedSubtitleCue[] = [];
cues.forEach((cue, index) => { cues.forEach((cue, index) => {
@@ -152,8 +155,12 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
for (const { cue } of activeCues) { for (const { cue } of activeCues) {
const text = cue.text.trim(); const text = cue.text.trim();
const compactText = text.replace(/\s+/gu, ''); const compactText = text.replace(/\s+/gu, '');
if (!compactText || seen.has(compactText)) continue; if (!compactText || seenExact.has(compactText)) continue;
seen.add(compactText); seenExact.add(compactText);
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
activeText.push(text); activeText.push(text);
} }
return activeText.join('\n'); 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 = { const snapshot: SubtitleSidebarSnapshot = {
cues: [ cues: [
{ startTime: 1, endTime: 2, text: 'first' }, { startTime: 1, endTime: 3.4, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' }, { startTime: 3, endTime: 4, text: 'second' },
], ],
currentSubtitle: { currentSubtitle: {
text: 'second', text: 'second',
startTime: 3, startTime: 3.5,
endTime: 4, endTime: 4,
}, },
currentTimeSec: 3.5,
config: { config: {
enabled: true, enabled: true,
autoOpen: false, autoOpen: false,
@@ -361,6 +362,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
modal.seekToCue(snapshot.cues[0]!); modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']); 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(); modal.closeSubtitleSidebarModal();
assert.deepEqual(visibilityChanges, [true, false]); assert.deepEqual(visibilityChanges, [true, false]);
assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']); assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
+6 -2
View File
@@ -4,7 +4,7 @@ import type {
SubtitleMiningContext, SubtitleMiningContext,
SubtitleSidebarSnapshot, SubtitleSidebarSnapshot,
} from '../../types'; } 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 type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import { import {
@@ -392,7 +392,11 @@ export function createSubtitleSidebarModal(
} }
function seekToCue(cue: SubtitleCue): void { 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 { 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); 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', () => { test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => {
const dialogue = ['Wait', 'Go!', 'No!', 'Run!']; const dialogue = ['Wait', 'Go!', 'No!', 'Run!'];
+13 -1
View File
@@ -6,6 +6,7 @@ import type {
SubtitleRendererStyleConfig, SubtitleRendererStyleConfig,
} from '../types'; } from '../types';
import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js'; 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 type { RendererContext } from './context';
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js'; 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; 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[] { export function prepareSecondarySubtitleLines(text: string): string[] {
// The one display-side ASS decode: secondary text also reaches the overlay from // 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 // 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()) .map((line) => line.trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0);
if (!isKaraokeLikeLineSet(lines)) { if (!isKaraokeLikeLineSet(lines)) {
return lines; return collapseFullLineFallbackCopies(lines);
} }
const seen = new Set<string>(); const seen = new Set<string>();