mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-25 23:35:21 -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
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: anki
|
area: subtitles
|
||||||
|
|
||||||
- Mined card audio, images, and timestamps now follow mpv's manual subtitle retiming (`sub-delay`, z/x), instead of clipping the subtitle file's original timing.
|
- mpv's manual subtitle retiming (`sub-delay`, z/x and sub-step) now carries through SubMiner: mined card audio, images, and timestamps clip the retimed line, and the subtitle sidebar highlight, click-to-seek, subtitle navigation, overlay line matching, and replay pause point all follow the retimed timing.
|
||||||
@@ -224,6 +224,49 @@ test(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'subtitle sidebar follows and seeks a sub-delay retimed track',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
const delay = 2;
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||||
|
try {
|
||||||
|
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
|
||||||
|
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
|
||||||
|
{ description: 'overlay to show the delayed line' },
|
||||||
|
);
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
|
||||||
|
{ description: 'sidebar to highlight the delayed line' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await overlay.evaluate(
|
||||||
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
|
||||||
|
);
|
||||||
|
const target = FIXTURE_CUES[2].start + delay;
|
||||||
|
await waitUntil(
|
||||||
|
async () => {
|
||||||
|
const time = Number(await session.mpv.command('get_property', 'time-pos'));
|
||||||
|
return time >= target && time < target + 0.5;
|
||||||
|
},
|
||||||
|
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||||
|
{ description: 'sidebar to close' },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Runs last: it leaves the lookup popup open over the overlay.
|
// Runs last: it leaves the lookup popup open over the overlay.
|
||||||
test(
|
test(
|
||||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ import {
|
|||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from './anki-field-config';
|
} from './anki-field-config';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
import {
|
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||||
captureLiveSubtitleMiningContext,
|
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
|
||||||
shiftSubtitleRangeToVideoClock,
|
|
||||||
} from './core/services/mining';
|
|
||||||
import {
|
import {
|
||||||
createUiFeedbackState,
|
createUiFeedbackState,
|
||||||
beginUpdateProgress,
|
beginUpdateProgress,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type MediaTimingReviewRequest,
|
type MediaTimingReviewRequest,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
} from '../types/anki';
|
} from '../types/anki';
|
||||||
import { shiftSubtitleRangeToVideoClock } from '../core/services/mining';
|
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export {
|
|||||||
handleMultiCopyDigit,
|
handleMultiCopyDigit,
|
||||||
markLastCardAsAudioCard,
|
markLastCardAsAudioCard,
|
||||||
mineSentenceCard,
|
mineSentenceCard,
|
||||||
shiftSubtitleRangeToVideoClock,
|
|
||||||
triggerFieldGrouping,
|
triggerFieldGrouping,
|
||||||
updateLastCardFromClipboard,
|
updateLastCardFromClipboard,
|
||||||
} from './mining';
|
} from './mining';
|
||||||
@@ -56,6 +55,7 @@ export {
|
|||||||
subtitleCueListSeekTime,
|
subtitleCueListSeekTime,
|
||||||
subtitleCueSeekTime,
|
subtitleCueSeekTime,
|
||||||
} from './subtitle-cue-navigation';
|
} from './subtitle-cue-navigation';
|
||||||
|
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
|||||||
cuesRevision: 1,
|
cuesRevision: 1,
|
||||||
cues: [],
|
cues: [],
|
||||||
currentTimeSec: null,
|
currentTimeSec: null,
|
||||||
|
subDelay: 0,
|
||||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||||
import type { SubtitleMiningContext } from '../../types/subtitle';
|
import type { SubtitleMiningContext } from '../../types/subtitle';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
|
||||||
|
|
||||||
interface SubtitleTimingTrackerLike {
|
interface SubtitleTimingTrackerLike {
|
||||||
getRecentBlocks: (count: number) => string[];
|
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
|
* 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),
|
* generation can run tens of seconds after the user mines (slow audio extraction),
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
|||||||
setCurrentSubEnd: () => {},
|
setCurrentSubEnd: () => {},
|
||||||
getCurrentSubEnd: () => 0,
|
getCurrentSubEnd: () => 0,
|
||||||
setCurrentSubDelay: () => {},
|
setCurrentSubDelay: () => {},
|
||||||
|
getCurrentSubDelay: () => 0,
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
state.mediaPath = payload.path;
|
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 () => {
|
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let currentTimePos = 0;
|
let currentTimePos = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||||
|
import { toVideoClock } from './subtitle-clock';
|
||||||
|
|
||||||
export type MpvMessage = {
|
export type MpvMessage = {
|
||||||
event?: string;
|
event?: string;
|
||||||
@@ -65,6 +66,7 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
setCurrentSubEnd: (value: number) => void;
|
setCurrentSubEnd: (value: number) => void;
|
||||||
getCurrentSubEnd: () => number;
|
getCurrentSubEnd: () => number;
|
||||||
setCurrentSubDelay: (value: number) => void;
|
setCurrentSubDelay: (value: number) => void;
|
||||||
|
getCurrentSubDelay: () => number;
|
||||||
emitMediaPathChange: (payload: { path: string }) => void;
|
emitMediaPathChange: (payload: { path: string }) => void;
|
||||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||||
emitTimePosChange: (payload: { time: number }) => void;
|
emitTimePosChange: (payload: { time: number }) => void;
|
||||||
@@ -264,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
const subEnd = (msg.data as number) || 0;
|
const subEnd = (msg.data as number) || 0;
|
||||||
deps.setCurrentSubEnd(subEnd);
|
deps.setCurrentSubEnd(subEnd);
|
||||||
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
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.setPendingPauseAtSubEnd(false);
|
||||||
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
setCurrentSubDelay: (value: number) => {
|
setCurrentSubDelay: (value: number) => {
|
||||||
this.currentSubDelay = value;
|
this.currentSubDelay = value;
|
||||||
},
|
},
|
||||||
|
getCurrentSubDelay: () => this.currentSubDelay,
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
this.emit('media-path-change', 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',
|
'seek',
|
||||||
13.08,
|
13.08,
|
||||||
'absolute+exact',
|
'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' },
|
{ 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',
|
'seek',
|
||||||
13.08,
|
13.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
|
|||||||
{ startTime: 16, endTime: 19, text: 'next 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',
|
'seek',
|
||||||
16.08,
|
16.08,
|
||||||
'absolute+exact',
|
'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' },
|
{ 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',
|
'seek',
|
||||||
10.08,
|
10.08,
|
||||||
'absolute+exact',
|
'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', () => {
|
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
||||||
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
||||||
|
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), 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', () => {
|
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';
|
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||||
|
|
||||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||||
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
|
|||||||
export function resolveSanitizedSubtitleSeekCommand(
|
export function resolveSanitizedSubtitleSeekCommand(
|
||||||
command: readonly (string | number)[],
|
command: readonly (string | number)[],
|
||||||
cues: readonly SubtitleCue[],
|
cues: readonly SubtitleCue[],
|
||||||
currentTimeSec: number,
|
videoTimeSec: number,
|
||||||
|
subDelay: number,
|
||||||
): (string | number)[] | null {
|
): (string | number)[] | null {
|
||||||
|
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
|
||||||
if (
|
if (
|
||||||
command.length < 2 ||
|
command.length < 2 ||
|
||||||
command[0] !== 'sub-seek' ||
|
command[0] !== 'sub-seek' ||
|
||||||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
|
|||||||
if (!destination) {
|
if (!destination) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
|
||||||
}
|
}
|
||||||
+13
-3
@@ -332,6 +332,7 @@ import {
|
|||||||
showMpvOsdRuntime,
|
showMpvOsdRuntime,
|
||||||
JellyfinPlaybackReporter,
|
JellyfinPlaybackReporter,
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||||
|
toSubtitleClock,
|
||||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||||
upsertYomitanDictionarySettings,
|
upsertYomitanDictionarySettings,
|
||||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||||
@@ -1938,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
|
|||||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||||
const canonical = resolveCanonicalPrimarySubtitle({
|
const canonical = resolveCanonicalPrimarySubtitle({
|
||||||
liveText: payload.text,
|
liveText: payload.text,
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(
|
||||||
|
Number(appState.mpvClient?.currentTimePos),
|
||||||
|
appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
|
),
|
||||||
cues: appState.activeParsedSubtitleCues,
|
cues: appState.activeParsedSubtitleCues,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -1956,7 +1960,7 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
|||||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
const resolved = resolvePrimarySubtitle({
|
const resolved = resolvePrimarySubtitle({
|
||||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos) - subDelay,
|
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||||
cues: appState.activeParsedSubtitleCues,
|
cues: appState.activeParsedSubtitleCues,
|
||||||
});
|
});
|
||||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||||
@@ -2122,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
|
|||||||
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
||||||
subtitleSidebarCuesRevision += 1;
|
subtitleSidebarCuesRevision += 1;
|
||||||
}
|
}
|
||||||
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
return {
|
return {
|
||||||
sourceKey: JSON.stringify([
|
sourceKey: JSON.stringify([
|
||||||
appState.activeParsedSubtitleMediaPath,
|
appState.activeParsedSubtitleMediaPath,
|
||||||
appState.activeParsedSubtitleSource,
|
appState.activeParsedSubtitleSource,
|
||||||
]),
|
]),
|
||||||
cuesRevision: subtitleSidebarCuesRevision,
|
cuesRevision: subtitleSidebarCuesRevision,
|
||||||
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
currentTimeSec:
|
||||||
|
appState.mpvClient?.currentTimePos === undefined
|
||||||
|
? null
|
||||||
|
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
|
||||||
|
subDelay,
|
||||||
currentSubtitle: {
|
currentSubtitle: {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
@@ -2167,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
|||||||
rawCommand,
|
rawCommand,
|
||||||
appState.activeParsedSubtitleCues,
|
appState.activeParsedSubtitleCues,
|
||||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||||
|
appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
) ?? rawCommand;
|
) ?? rawCommand;
|
||||||
if (isExplicitMpvSeekCommand(command)) {
|
if (isExplicitMpvSeekCommand(command)) {
|
||||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||||
@@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
currentVideoPath?: string;
|
currentVideoPath?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubDelay?: number;
|
||||||
currentSubText?: string;
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
@@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||||
return resolvePrimarySubtitleText({
|
return resolvePrimarySubtitleText({
|
||||||
liveText: text,
|
liveText: text,
|
||||||
currentTimeSec,
|
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
|
||||||
cues: deps.getActiveParsedSubtitleCues(),
|
cues: deps.getActiveParsedSubtitleCues(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSeconds = Number(
|
const currentTimeSeconds = Number(
|
||||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
);
|
);
|
||||||
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
const resolvedTimeSeconds = toSubtitleClock(
|
||||||
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||||
|
client?.currentSubDelay ?? 0,
|
||||||
|
);
|
||||||
const cue = selectAutoplayStartupCue(
|
const cue = selectAutoplayStartupCue(
|
||||||
cues,
|
cues,
|
||||||
resolvedTimeSeconds,
|
resolvedTimeSeconds,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -162,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
resolveSubtitleText: (liveText: string) =>
|
resolveSubtitleText: (liveText: string) =>
|
||||||
resolvePrimarySubtitleText({
|
resolvePrimarySubtitleText({
|
||||||
liveText,
|
liveText,
|
||||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(
|
||||||
|
Number(deps.appState.mpvClient?.currentTimePos),
|
||||||
|
deps.appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
|
),
|
||||||
cues: deps.appState.activeParsedSubtitleCues,
|
cues: deps.appState.activeParsedSubtitleCues,
|
||||||
}),
|
}),
|
||||||
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function setup() {
|
|||||||
],
|
],
|
||||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||||
currentTimeSec: 0,
|
currentTimeSec: 0,
|
||||||
|
subDelay: 0,
|
||||||
config: {
|
config: {
|
||||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
||||||
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
|
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
|
||||||
|
subDelay: 0,
|
||||||
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
||||||
};
|
};
|
||||||
const requests = { snapshot: 0, playback: 0 };
|
const requests = { snapshot: 0, playback: 0 };
|
||||||
@@ -226,8 +227,8 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
},
|
},
|
||||||
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
|
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
|
||||||
requests.playback += 1;
|
requests.playback += 1;
|
||||||
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
|
const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot;
|
||||||
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
|
return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle };
|
||||||
},
|
},
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command: MpvCommand) => {
|
sendMpvCommand: (command: MpvCommand) => {
|
||||||
@@ -528,6 +529,16 @@ test('subtitle sidebar seeks to the selected cue start using the overlap-aware s
|
|||||||
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
|
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar seeks to where mpv plays a sub-delay retimed cue', async () => {
|
||||||
|
const h = createSidebarHarness();
|
||||||
|
h.setSnapshot({ subDelay: 2 });
|
||||||
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
|
||||||
|
h.modal.seekToCue({ startTime: 1, endTime: 2, text: 'first' });
|
||||||
|
|
||||||
|
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.08, 'absolute+exact']);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
|
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
|
||||||
const h = createSidebarHarness();
|
const h = createSidebarHarness();
|
||||||
await h.modal.openSubtitleSidebarModal();
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
SubtitleSidebarSnapshot,
|
SubtitleSidebarSnapshot,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { toVideoClock } from '../../core/services/subtitle-clock.js';
|
||||||
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
@@ -231,6 +232,8 @@ export function createSubtitleSidebarModal(
|
|||||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||||
let subtitleSourceKey: string | null = null;
|
let subtitleSourceKey: string | null = null;
|
||||||
let subtitleCuesRevision: number | null = null;
|
let subtitleCuesRevision: number | null = null;
|
||||||
|
// Latest mpv `sub-delay` from main; cue times plus this are where mpv plays the cue.
|
||||||
|
let subtitleSubDelay = 0;
|
||||||
|
|
||||||
function restoreEmbeddedSidebarPassthrough(): void {
|
function restoreEmbeddedSidebarPassthrough(): void {
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
@@ -416,7 +419,7 @@ export function createSubtitleSidebarModal(
|
|||||||
function seekToCue(cue: SubtitleCue): void {
|
function seekToCue(cue: SubtitleCue): void {
|
||||||
window.electronAPI.sendMpvCommand([
|
window.electronAPI.sendMpvCommand([
|
||||||
'seek',
|
'seek',
|
||||||
subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue),
|
toVideoClock(subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), subtitleSubDelay),
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -591,6 +594,7 @@ export function createSubtitleSidebarModal(
|
|||||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||||
pollsSinceFullSnapshot = 0;
|
pollsSinceFullSnapshot = 0;
|
||||||
subtitleCuesRevision = snapshot.cuesRevision;
|
subtitleCuesRevision = snapshot.cuesRevision;
|
||||||
|
subtitleSubDelay = snapshot.subDelay;
|
||||||
if (snapshot.sourceKey !== subtitleSourceKey) {
|
if (snapshot.sourceKey !== subtitleSourceKey) {
|
||||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||||
lastSubtitleSidebarLookupCueIndex = -1;
|
lastSubtitleSidebarLookupCueIndex = -1;
|
||||||
@@ -640,6 +644,7 @@ export function createSubtitleSidebarModal(
|
|||||||
await refreshSnapshot();
|
await refreshSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
subtitleSubDelay = playback.subDelay;
|
||||||
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
|
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ export interface SubtitleSidebarPlaybackState {
|
|||||||
sourceKey: string | null;
|
sourceKey: string | null;
|
||||||
/** Bumps whenever main swaps in a different parsed cue array. */
|
/** Bumps whenever main swaps in a different parsed cue array. */
|
||||||
cuesRevision: number;
|
cuesRevision: number;
|
||||||
|
/** Playback position on the subtitle clock (`time-pos` minus `sub-delay`), like cue times. */
|
||||||
currentTimeSec: number | null;
|
currentTimeSec: number | null;
|
||||||
|
/** mpv `sub-delay`: cue times plus this give the video-clock seek target. */
|
||||||
|
subDelay: number;
|
||||||
currentSubtitle: {
|
currentSubtitle: {
|
||||||
text: string;
|
text: string;
|
||||||
startTime: number | null;
|
startTime: number | null;
|
||||||
|
|||||||
Reference in new issue
Block a user