mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
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:
@@ -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'];
|
||||
}
|
||||
Reference in New Issue
Block a user