mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
fix(subtitles): follow sub-delay in sidebar, navigation, and overlay matching
Anything comparing time-pos with parsed cues now converts through a shared subtitle-clock helper: the sidebar highlight and click-to-seek, the sub-seek cue sanitizer, overlay/primer line resolution, and the replay pause point after sub-seek.
This commit is contained in:
20 files changed
+177
-41
No files matched your search
@@ -22,7 +22,6 @@ export {
|
||||
handleMultiCopyDigit,
|
||||
markLastCardAsAudioCard,
|
||||
mineSentenceCard,
|
||||
shiftSubtitleRangeToVideoClock,
|
||||
triggerFieldGrouping,
|
||||
updateLastCardFromClipboard,
|
||||
} from './mining';
|
||||
@@ -56,6 +55,7 @@ export {
|
||||
subtitleCueListSeekTime,
|
||||
subtitleCueSeekTime,
|
||||
} from './subtitle-cue-navigation';
|
||||
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||
export {
|
||||
|
||||
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||
cuesRevision: 1,
|
||||
cues: [],
|
||||
currentTimeSec: null,
|
||||
subDelay: 0,
|
||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||
config: {
|
||||
enabled: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||
import type { SubtitleMiningContext } from '../../types/subtitle';
|
||||
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
|
||||
|
||||
interface SubtitleTimingTrackerLike {
|
||||
getRecentBlocks: (count: number) => string[];
|
||||
@@ -56,21 +57,6 @@ export function handleMultiCopyDigit(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps,
|
||||
* but mpv plays each line `sub-delay` seconds later (z/x retiming). Media is cut from the
|
||||
* video, so subtitle timings move onto the video clock before they reach mining.
|
||||
*/
|
||||
export function shiftSubtitleRangeToVideoClock<T extends { startTime: number; endTime: number }>(
|
||||
range: T,
|
||||
subDelay: number,
|
||||
): T {
|
||||
if (!Number.isFinite(subDelay) || subDelay === 0) {
|
||||
return range;
|
||||
}
|
||||
return { ...range, startTime: range.startTime + subDelay, endTime: range.endTime + subDelay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the live mpv subtitle line and its timings as a mining context. Media
|
||||
* generation can run tens of seconds after the user mines (slow audio extraction),
|
||||
|
||||
@@ -74,6 +74,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
setCurrentSubEnd: () => {},
|
||||
getCurrentSubEnd: () => 0,
|
||||
setCurrentSubDelay: () => {},
|
||||
getCurrentSubDelay: () => 0,
|
||||
emitMediaPathChange: (payload) => {
|
||||
state.mediaPath = payload.path;
|
||||
},
|
||||
@@ -396,6 +397,21 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => {
|
||||
let pauseAtTime: number | null = null;
|
||||
const { deps } = createDeps({
|
||||
getPendingPauseAtSubEnd: () => true,
|
||||
getCurrentSubDelay: () => 1.5,
|
||||
setPauseAtTime: (next) => {
|
||||
pauseAtTime = next;
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
|
||||
|
||||
assert.equal(pauseAtTime, 43.5);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentTimePos = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||
import { toVideoClock } from './subtitle-clock';
|
||||
|
||||
export type MpvMessage = {
|
||||
event?: string;
|
||||
@@ -65,6 +66,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
setCurrentSubEnd: (value: number) => void;
|
||||
getCurrentSubEnd: () => number;
|
||||
setCurrentSubDelay: (value: number) => void;
|
||||
getCurrentSubDelay: () => number;
|
||||
emitMediaPathChange: (payload: { path: string }) => void;
|
||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
emitTimePosChange: (payload: { time: number }) => void;
|
||||
@@ -264,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
|
||||
const subEnd = (msg.data as number) || 0;
|
||||
deps.setCurrentSubEnd(subEnd);
|
||||
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
||||
deps.setPauseAtTime(subEnd);
|
||||
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`.
|
||||
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
|
||||
deps.setPendingPauseAtSubEnd(false);
|
||||
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
||||
}
|
||||
|
||||
@@ -464,6 +464,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
setCurrentSubDelay: (value: number) => {
|
||||
this.currentSubDelay = value;
|
||||
},
|
||||
getCurrentSubDelay: () => this.currentSubDelay,
|
||||
emitMediaPathChange: (payload) => {
|
||||
this.emit('media-path-change', payload);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps
|
||||
* (the subtitle clock), but mpv plays each line `sub-delay` seconds later (z/x retiming,
|
||||
* `sub-step`). Playback position, seeks, and media cuts are on the video clock, so any
|
||||
* value crossing between the two converts here.
|
||||
*/
|
||||
|
||||
function finiteDelay(subDelay: number): number {
|
||||
return Number.isFinite(subDelay) ? subDelay : 0;
|
||||
}
|
||||
|
||||
/** Playback position (`time-pos`) on the subtitle clock, comparable with cue times. */
|
||||
export function toSubtitleClock(videoTimeSec: number, subDelay: number): number {
|
||||
return videoTimeSec - finiteDelay(subDelay);
|
||||
}
|
||||
|
||||
/** Subtitle-clock seconds on the video clock, for seeks. */
|
||||
export function toVideoClock(subtitleTimeSec: number, subDelay: number): number {
|
||||
return subtitleTimeSec + finiteDelay(subDelay);
|
||||
}
|
||||
|
||||
/** A subtitle-clock range on the video clock, for mining and media generation. */
|
||||
export function shiftSubtitleRangeToVideoClock<T extends { startTime: number; endTime: number }>(
|
||||
range: T,
|
||||
subDelay: number,
|
||||
): T {
|
||||
const delay = finiteDelay(subDelay);
|
||||
if (delay === 0) {
|
||||
return range;
|
||||
}
|
||||
return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay };
|
||||
}
|
||||
@@ -26,7 +26,7 @@ test('next subtitle navigation skips generated ASS events and seeks to the next
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [
|
||||
'seek',
|
||||
13.08,
|
||||
'absolute+exact',
|
||||
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
|
||||
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
|
||||
'seek',
|
||||
13.08,
|
||||
'absolute+exact',
|
||||
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
|
||||
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
|
||||
'seek',
|
||||
16.08,
|
||||
'absolute+exact',
|
||||
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
||||
{ startTime: 13, endTime: 16, text: 'current line' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
|
||||
'seek',
|
||||
10.08,
|
||||
'absolute+exact',
|
||||
@@ -78,8 +78,22 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
||||
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);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null);
|
||||
});
|
||||
|
||||
test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => {
|
||||
const cues = [
|
||||
{ startTime: 10, endTime: 13, text: 'one' },
|
||||
{ startTime: 13, endTime: 16, text: 'two' },
|
||||
];
|
||||
|
||||
// At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock).
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [
|
||||
'seek',
|
||||
15.08,
|
||||
'absolute+exact',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
|
||||
export function resolveSanitizedSubtitleSeekCommand(
|
||||
command: readonly (string | number)[],
|
||||
cues: readonly SubtitleCue[],
|
||||
currentTimeSec: number,
|
||||
videoTimeSec: number,
|
||||
subDelay: number,
|
||||
): (string | number)[] | null {
|
||||
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
|
||||
if (
|
||||
command.length < 2 ||
|
||||
command[0] !== 'sub-seek' ||
|
||||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
|
||||
if (!destination) {
|
||||
return null;
|
||||
}
|
||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
||||
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
|
||||
}
|
||||
Reference in new issue
Block a user