mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -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'];
|
||||
}
|
||||
+28
-2
@@ -312,6 +312,7 @@ import {
|
||||
promoteSettingsWindowAboveOverlay,
|
||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||
replayCurrentSubtitleRuntime,
|
||||
resolveSanitizedSubtitleSeekCommand,
|
||||
resolveJellyfinPlaybackPlanRuntime,
|
||||
runStartupBootstrapRuntime,
|
||||
saveJellyfinSubtitleDelay,
|
||||
@@ -1958,6 +1959,31 @@ let linuxVisibleOverlayOwnerBindingKey: string | null = null;
|
||||
let linuxVisibleOverlayWindowModeSwitchToken = 0;
|
||||
let subtitleSidebarRequestedOpen = false;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
|
||||
let explicitSeekIntentExpiresAtMs = 0;
|
||||
|
||||
function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean {
|
||||
return command[0] === 'seek' || command[0] === 'sub-seek';
|
||||
}
|
||||
|
||||
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
||||
const command =
|
||||
resolveSanitizedSubtitleSeekCommand(
|
||||
rawCommand,
|
||||
appState.activeParsedSubtitleCues,
|
||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
) ?? rawCommand;
|
||||
if (isExplicitMpvSeekCommand(command)) {
|
||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||
}
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
}
|
||||
|
||||
function consumeExplicitSeekIntent(): boolean {
|
||||
const pending = explicitSeekIntentExpiresAtMs >= Date.now();
|
||||
explicitSeekIntentExpiresAtMs = 0;
|
||||
return pending;
|
||||
}
|
||||
|
||||
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
@@ -4580,6 +4606,7 @@ const {
|
||||
reportJellyfinRemoteProgress: (forceImmediate) => {
|
||||
void reportJellyfinRemoteProgress(forceImmediate);
|
||||
},
|
||||
consumeExplicitSeek: () => consumeExplicitSeekIntent(),
|
||||
onTimePosUpdate: (time) => {
|
||||
const delta = time - lastObservedTimePos;
|
||||
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
|
||||
@@ -5485,8 +5512,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
||||
sendMpvCommand: (rawCommand: (string | number)[]) => sendRendererMpvCommand(rawCommand),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
||||
|
||||
@@ -358,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('time-pos handler treats an explicit short jump as a seek', () => {
|
||||
const updateKinds: string[] = [];
|
||||
let explicitSeekPending = false;
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
recordPlaybackPosition: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
consumeExplicitSeek: () => {
|
||||
const pending = explicitSeekPending;
|
||||
explicitSeekPending = false;
|
||||
return pending;
|
||||
},
|
||||
onTimePosUpdate: (_time, kind) => updateKinds.push(kind),
|
||||
});
|
||||
|
||||
timeHandler({ time: 10 });
|
||||
explicitSeekPending = true;
|
||||
timeHandler({ time: 11.5 });
|
||||
timeHandler({ time: 11.6 });
|
||||
|
||||
assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']);
|
||||
});
|
||||
|
||||
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||
const watchedSeconds: unknown[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
|
||||
@@ -141,14 +141,16 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
|
||||
consumeExplicitSeek?: () => boolean;
|
||||
}) {
|
||||
let lastObservedTime: number | null = null;
|
||||
|
||||
return ({ time }: { time: number }): void => {
|
||||
const explicitSeek = deps.consumeExplicitSeek?.() ?? false;
|
||||
const updateKind: TimePosUpdateKind =
|
||||
lastObservedTime === null
|
||||
? 'initial'
|
||||
: isSeekLikeTimeChange(lastObservedTime, time)
|
||||
: explicitSeek || isSeekLikeTimeChange(lastObservedTime, time)
|
||||
? 'seek'
|
||||
: 'playback';
|
||||
const forceImmediate = updateKind === 'seek';
|
||||
|
||||
@@ -79,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
recordMediaDuration: (durationSec: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
consumeExplicitSeek?: () => boolean;
|
||||
onFullscreenChange?: (fullscreen: boolean) => void;
|
||||
recordPauseState: (paused: boolean) => void;
|
||||
|
||||
@@ -172,6 +173,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||
onTimePosUpdate: (time, updateKind) => {
|
||||
deps.onTimePosUpdate?.(time);
|
||||
if (updateKind === 'playback') return;
|
||||
|
||||
@@ -86,6 +86,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
consumeExplicitSeek?: () => boolean;
|
||||
onFullscreenChange?: (fullscreen: boolean) => void;
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
@@ -334,6 +335,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||
onTimePosUpdate: (time: number) => {
|
||||
// Timing history is a viewing log: after a real backward seek, a rewatched
|
||||
// canonical line should enter it again. Immersion stats keep their
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SubtitleMiningContext,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import { subtitleCueSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
|
||||
const MANUAL_SCROLL_HOLD_MS = 1500;
|
||||
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
||||
const CLICK_SEEK_OFFSET_SEC = 0.08;
|
||||
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
||||
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
||||
@@ -392,12 +392,7 @@ export function createSubtitleSidebarModal(
|
||||
}
|
||||
|
||||
function seekToCue(cue: SubtitleCue): void {
|
||||
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'seek',
|
||||
Math.max(cue.startTime, targetTime),
|
||||
'absolute+exact',
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand(['seek', subtitleCueSeekTime(cue), 'absolute+exact']);
|
||||
}
|
||||
|
||||
function getCueRowLabel(cue: SubtitleCue): string {
|
||||
|
||||
Reference in New Issue
Block a user