fix(subtitles): navigate by sanitized cues across ASS animation

- Route subtitle navigation through parsed cue boundaries
- Treat explicit short seeks as subtitle priming events
This commit is contained in:
2026-08-20 00:20:51 -07:00
parent c01bcd9d0f
commit 9445aef004
11 changed files with 259 additions and 14 deletions
+4
View File
@@ -50,6 +50,10 @@ export {
} from './tokenizer/yomitan-parser-runtime';
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export {
resolveSanitizedSubtitleSeekCommand,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveSanitizedSubtitleSeekCommand,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
test('next subtitle navigation skips generated ASS events and seeks to the next sanitized cue', () => {
const cues = [
{
startTime: 10,
endTime: 13,
text: 'first lyric',
source: 'canonical-ass' as const,
animationStartTime: 9.7,
animationEndTime: 13.4,
},
{
startTime: 13,
endTime: 16,
text: 'second lyric',
source: 'canonical-ass' as const,
animationStartTime: 12.7,
animationEndTime: 16.4,
},
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
'seek',
13.08,
'absolute+exact',
]);
});
test('next subtitle navigation treats simultaneous sanitized cues as one line boundary', () => {
const cues = [
{ startTime: 10, endTime: 13, text: 'romaji' },
{ startTime: 10.02, endTime: 13, text: 'English' },
{ startTime: 13, endTime: 16, text: 'next romaji' },
{ startTime: 13.02, endTime: 16, text: 'Next English' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
'seek',
13.08,
'absolute+exact',
]);
});
test('next subtitle navigation advances past the latest overlapping lyric', () => {
const cues = [
{ startTime: 10, endTime: 14, text: 'exiting lyric' },
{ startTime: 13, endTime: 16, text: 'current lyric' },
{ startTime: 16, endTime: 19, text: 'next lyric' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
'seek',
16.08,
'absolute+exact',
]);
});
test('previous subtitle navigation leaves the current cue and seeks to the prior cue', () => {
const cues = [
{ startTime: 10, endTime: 12, text: 'first line' },
{ startTime: 13, endTime: 16, text: 'current line' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
'seek',
10.08,
'absolute+exact',
]);
});
test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
});
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);
});
@@ -0,0 +1,98 @@
import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
const CUE_BOUNDARY_SEEK_OFFSET_SECONDS = 0.08;
const CUE_END_GUARD_SECONDS = 0.01;
type CueGroup = {
startTime: number;
endTime: number;
cue: SubtitleCue;
};
function isValidCue(cue: SubtitleCue): boolean {
return (
Number.isFinite(cue.startTime) && Number.isFinite(cue.endTime) && cue.endTime > cue.startTime
);
}
function groupCueBoundaries(cues: readonly SubtitleCue[]): CueGroup[] {
const sorted = cues.filter(isValidCue).sort((left, right) => {
return left.startTime - right.startTime || left.endTime - right.endTime;
});
const groups: CueGroup[] = [];
for (const cue of sorted) {
const current = groups.at(-1);
if (current && cue.startTime - current.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS) {
current.endTime = Math.max(current.endTime, cue.endTime);
continue;
}
groups.push({ startTime: cue.startTime, endTime: cue.endTime, cue });
}
return groups;
}
/** A small offset avoids asking mpv to render exactly on a subtitle boundary. */
export function subtitleCueSeekTime(cue: SubtitleCue): number {
return Math.max(
cue.startTime,
Math.min(cue.endTime - CUE_END_GUARD_SECONDS, cue.startTime + 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
* already collapsed those events into the authored lines the user expects to navigate.
*/
export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[],
cues: readonly SubtitleCue[],
currentTimeSec: number,
): (string | number)[] | null {
if (
command.length < 2 ||
command[0] !== 'sub-seek' ||
(command[1] !== -1 && command[1] !== 1) ||
!Number.isFinite(currentTimeSec)
) {
return null;
}
const groups = groupCueBoundaries(cues);
if (groups.length === 0) {
return null;
}
let activeIndex = -1;
for (const [index, group] of groups.entries()) {
if (group.startTime <= currentTimeSec && group.endTime > currentTimeSec) {
activeIndex = index;
}
}
let destination: CueGroup | undefined;
if (command[1] === 1) {
destination =
activeIndex >= 0
? groups[activeIndex + 1]
: groups.find((group) => group.startTime > currentTimeSec);
} else if (activeIndex >= 0) {
destination = groups[activeIndex - 1];
} else {
for (let index = groups.length - 1; index >= 0; index -= 1) {
const group = groups[index]!;
if (group.startTime < currentTimeSec) {
destination = group;
break;
}
}
}
if (!destination) {
return null;
}
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
}