mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!'];
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user