From 929e78b28c6c24b803f881c6ee7a733d6ad10196 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 25 Oct 2026 21:32:59 -0700 Subject: [PATCH] fix(anki): clip mined media on the sub-delay retimed timeline mpv's sub-start/sub-end and parsed cues carry the subtitle file's own timestamps; sub-delay (z/x, sub-step) only moves where mpv plays them. Mining now shifts subtitle timings onto the video clock before media generation, review, and MiscInfo timestamps. --- changes/mining-respects-sub-delay.md | 4 ++ e2e/harness/fake-anki.ts | 35 ++++++++++++-- e2e/playback.e2e.test.ts | 48 +++++++++++++++++-- src/anki-integration.ts | 17 ++++--- src/anki-integration/card-creation.ts | 9 ++-- src/core/services/index.ts | 1 + src/core/services/mining.test.ts | 20 +++++++- src/core/services/mining.ts | 37 ++++++++++++-- src/core/services/mpv-properties.ts | 1 + src/core/services/mpv-protocol.test.ts | 17 +++++++ src/core/services/mpv-protocol.ts | 4 ++ src/core/services/mpv.ts | 5 ++ src/main.ts | 33 +++++++++---- .../runtime/mpv-main-event-main-deps.test.ts | 15 ++++++ src/main/runtime/mpv-main-event-main-deps.ts | 12 +++-- src/types/runtime.ts | 2 + src/types/subtitle.ts | 1 + 17 files changed, 223 insertions(+), 38 deletions(-) create mode 100644 changes/mining-respects-sub-delay.md diff --git a/changes/mining-respects-sub-delay.md b/changes/mining-respects-sub-delay.md new file mode 100644 index 00000000..9361e93c --- /dev/null +++ b/changes/mining-respects-sub-delay.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- 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. diff --git a/e2e/harness/fake-anki.ts b/e2e/harness/fake-anki.ts index c848e82b..9e744dac 100644 --- a/e2e/harness/fake-anki.ts +++ b/e2e/harness/fake-anki.ts @@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -// A recording AnkiConnect stand-in: an empty collection with one deck and one -// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes. +// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type. +// Added notes are echoed back by `notesInfo` so the app can resolve their fields; +// scenarios still assert on `requests`, not on stored notes. export async function startFakeAnki(): Promise { const requests: AnkiRequest[] = []; + const addedNotes = new Map>(); let nextNoteId = 1000; const handle = (body: unknown): unknown => { @@ -59,10 +61,33 @@ export async function startFakeAnki(): Promise { return [FAKE_ANKI_MODEL]; case 'modelFieldNames': return MODEL_FIELDS; - case 'addNote': - return nextNoteId++; + case 'addNote': { + const noteId = nextNoteId++; + const note = isRecord(params.note) ? params.note : {}; + addedNotes.set(noteId, isRecord(note.fields) ? note.fields : {}); + return noteId; + } + case 'notesInfo': { + const ids = Array.isArray(params.notes) ? params.notes : []; + return ids.flatMap((id) => { + const fields = typeof id === 'number' ? addedNotes.get(id) : undefined; + if (!fields) return []; + return [ + { + noteId: id, + modelName: FAKE_ANKI_MODEL, + tags: [], + fields: Object.fromEntries( + MODEL_FIELDS.map((name, order) => [ + name, + { value: String(fields[name] ?? ''), order }, + ]), + ), + }, + ]; + }); + } case 'findNotes': - case 'notesInfo': return []; case 'storeMediaFile': return params.filename ?? null; diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index 547b3bff..29605f42 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise + request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'), + ); +} + function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean { return tokens.some((token) => token.surface === surface && token.headword === headword); } @@ -126,10 +134,10 @@ test( session.anki.requests.length = 0; await session.runAppCommand('--mine-sentence'); - const update = await waitUntil( - () => session.anki.requests.find((request) => request.action === 'updateNoteFields'), - { description: 'media fields to be written to the mined note', timeoutMs: 30_000 }, - ); + const update = await waitUntil(() => findMediaUpdate(session), { + description: 'media fields to be written to the mined note', + timeoutMs: 30_000, + }); const added = session.anki.requests.filter((request) => request.action === 'addNote'); assert.equal(added.length, 1); @@ -156,6 +164,38 @@ test( }, ); +test( + 'mining a line retimed with sub-delay clips it where mpv plays it', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + const cue = FIXTURE_CUES[1]; + const delay = 2; + await session.mpv.command('set_property', 'sub-delay', delay); + try { + // Past the cue's own end, so the line is only on screen because of the delay. + await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact'); + await overlay.waitFor( + `document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`, + { description: 'overlay to show the delayed line' }, + ); + session.anki.requests.length = 0; + + await session.runAppCommand('--mine-sentence'); + await waitUntil(() => findMediaUpdate(session), { + description: 'media fields to be written to the mined note', + timeoutMs: 30_000, + }); + + // MiscInfo stamps the clip start: the cue's start on the video clock. + const written = JSON.stringify(session.anki.requests); + assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written); + } finally { + await session.mpv.command('set_property', 'sub-delay', 0); + } + }, +); + test( 'open subtitle sidebar lists the parsed cues and follows playback', { timeout: TEST_TIMEOUT_MS }, diff --git a/src/anki-integration.ts b/src/anki-integration.ts index cb2327c6..ee29fcac 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -44,7 +44,10 @@ import { getPreferredWordValueFromExtractedFields, } from './anki-field-config'; import { createLogger } from './logger'; -import { captureLiveSubtitleMiningContext } from './core/services/mining'; +import { + captureLiveSubtitleMiningContext, + shiftSubtitleRangeToVideoClock, +} from './core/services/mining'; import { createUiFeedbackState, beginUpdateProgress, @@ -1029,10 +1032,10 @@ export class AnkiIntegration { Number.isFinite(this.mpvClient.currentSubEnd) && this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart ) { - return { - startTime: this.mpvClient.currentSubStart, - endTime: this.mpvClient.currentSubEnd, - }; + return shiftSubtitleRangeToVideoClock( + { startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd }, + this.mpvClient.currentSubDelay, + ); } const currentTime = this.mpvClient.currentTimePos || 0; @@ -1672,7 +1675,7 @@ export class AnkiIntegration { if (this.config.fields?.miscInfo) { result.miscInfoValue = this.formatMiscInfoPattern( audioFilename, - this.mpvClient.currentSubStart, + this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay, ); } } @@ -1695,7 +1698,7 @@ export class AnkiIntegration { if (this.config.fields?.miscInfo && !result.miscInfoValue) { result.miscInfoValue = this.formatMiscInfoPattern( imageFilename, - this.mpvClient.currentSubStart, + this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay, ); } } diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 5917b292..ddc4065e 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -10,6 +10,7 @@ import { type MediaTimingReviewRequest, type WordCardKind, } from '../types/anki'; +import { shiftSubtitleRangeToVideoClock } from '../core/services/mining'; import { createLogger } from '../logger'; import type { MediaInput } from '../media-input'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; @@ -428,10 +429,12 @@ export class CardCreationService { return; } - let startTime = mpvClient.currentSubStart; - let endTime = mpvClient.currentSubEnd; + let { startTime, endTime } = shiftSubtitleRangeToVideoClock( + { startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd }, + mpvClient.currentSubDelay, + ); - if (startTime === undefined || endTime === undefined) { + if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) { const currentTime = mpvClient.currentTimePos || 0; const fallback = this.deps.getFallbackDurationSeconds() / 2; startTime = currentTime - fallback; diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c184fe21..1b28e02b 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -22,6 +22,7 @@ export { handleMultiCopyDigit, markLastCardAsAudioCard, mineSentenceCard, + shiftSubtitleRangeToVideoClock, triggerFieldGrouping, updateLastCardFromClipboard, } from './mining'; diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 772f1f94..d089f368 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -77,6 +77,7 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async currentSubText: 'line', currentSubStart: 1, currentSubEnd: 2, + currentSubDelay: 0, }, showMpvOsd: (text) => osd.push(text), }), @@ -109,6 +110,7 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async () currentSubText: 'subtitle line', currentSubStart: 10, currentSubEnd: 12, + currentSubDelay: 0, currentSecondarySubText: 'secondary line', }, showMpvOsd: () => {}, @@ -148,6 +150,7 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async () currentSubText: '今今今手手手', currentSubStart: 11.4, currentSubEnd: 11.8, + currentSubDelay: 0, currentSecondarySubText: 'English subtitle', }, primarySubtitle: { @@ -187,6 +190,7 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m currentSubText: '日本語字幕', currentSubStart: 10, currentSubEnd: 12, + currentSubDelay: 0, currentSecondarySubText: 'Your\nmosaic', requestProperty: async () => { requestedRawSecondaryText = true; @@ -218,6 +222,7 @@ test('mineSentenceCard omits normalized secondary text that matches the primary currentSubText: '日本語字幕', currentSubStart: 10, currentSubEnd: 12, + currentSubDelay: 0, currentSecondarySubText: '日本語字幕', }, showMpvOsd: () => {}, @@ -396,7 +401,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => { const context = captureLiveSubtitleMiningContext( - { currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 }, + { currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 }, () => 1234, ); @@ -409,6 +414,16 @@ test('captureLiveSubtitleMiningContext snapshots the current line and timings', }); }); +test('captureLiveSubtitleMiningContext moves sub-delay retimed lines onto the video clock', () => { + const context = captureLiveSubtitleMiningContext( + { currentSubText: '食べる', currentSubStart: 5, currentSubEnd: 8, currentSubDelay: -1.5 }, + () => 1234, + ); + + assert.equal(context?.startTime, 3.5); + assert.equal(context?.endTime, 6.5); +}); + test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => { assert.equal(captureLiveSubtitleMiningContext(null), null); assert.equal( @@ -416,6 +431,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba currentSubText: ' ', currentSubStart: 1, currentSubEnd: 2, + currentSubDelay: 0, }), null, ); @@ -424,6 +440,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba currentSubText: 'line', currentSubStart: 5, currentSubEnd: 5, + currentSubDelay: 0, }), null, ); @@ -432,6 +449,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba currentSubText: 'line', currentSubStart: NaN, currentSubEnd: 2, + currentSubDelay: 0, }), null, ); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index b6aa59e7..423bbe20 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -25,6 +25,7 @@ interface MpvClientLike { currentSubText: string; currentSubStart: number; currentSubEnd: number; + currentSubDelay: number; currentSecondarySubText?: string; requestProperty?: (name: string) => Promise; } @@ -55,6 +56,21 @@ 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( + 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), @@ -62,7 +78,10 @@ export function handleMultiCopyDigit( * line is on screen by then; callers capture here at mining time instead. */ export function captureLiveSubtitleMiningContext( - client: Pick | null, + client: Pick< + MpvClientLike, + 'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay' + > | null, now: () => number = Date.now, ): SubtitleMiningContext | null { if (!client) { @@ -73,7 +92,10 @@ export function captureLiveSubtitleMiningContext( if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) { return null; } - return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() }; + return shiftSubtitleRangeToVideoClock( + { source: 'overlay', text, startTime, endTime, capturedAtMs: now() }, + client.currentSubDelay, + ); } export function copyCurrentSubtitle(deps: { @@ -167,6 +189,7 @@ export async function markLastCardAsAudioCard(deps: { export async function mineSentenceCard(deps: { ankiIntegration: AnkiIntegrationLike | null; mpvClient: MpvClientLike | null; + /** Already on the video clock, like every `SubtitleMiningContext`. */ primarySubtitle?: Pick; showMpvOsd: (text: string) => void; }): Promise { @@ -185,10 +208,16 @@ export async function mineSentenceCard(deps: { } const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText); + const range = + deps.primarySubtitle ?? + shiftSubtitleRangeToVideoClock( + { startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd }, + mpvClient.currentSubDelay, + ); return await anki.createSentenceCard( primaryText, - deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart, - deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd, + range.startTime, + range.endTime, secondarySubText, ); } diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 158ffdc3..71b6a266 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'path', 'sub-start', 'sub-end', + 'sub-delay', 'time-pos', 'secondary-sub-text', 'aid', diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 4cada30f..4e7d5fcc 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -73,6 +73,7 @@ function createDeps(overrides: Partial = {}): { getCurrentSubStart: () => 0, setCurrentSubEnd: () => {}, getCurrentSubEnd: () => 0, + setCurrentSubDelay: () => {}, emitMediaPathChange: (payload) => { state.mediaPath = payload.path; }, @@ -131,6 +132,22 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); }); +test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => { + const delays: number[] = []; + const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) }); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sub-delay', data: 1.5 }, + deps, + ); + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sub-delay', data: null }, + deps, + ); + + assert.deepEqual(delays, [1.5, 0]); +}); + test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => { const { deps, state } = createDeps(); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 36cbc351..e07f24d2 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -64,6 +64,7 @@ export interface MpvProtocolHandleMessageDeps { getCurrentSubStart: () => number; setCurrentSubEnd: (value: number) => void; getCurrentSubEnd: () => number; + setCurrentSubDelay: (value: number) => void; emitMediaPathChange: (payload: { path: string }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void; emitTimePosChange: (payload: { time: number }) => void; @@ -272,6 +273,9 @@ export async function dispatchMpvProtocolMessage( start: deps.getCurrentSubStart(), end: deps.getCurrentSubEnd(), }); + } else if (msg.name === 'sub-delay') { + const delay = typeof msg.data === 'number' ? msg.data : Number(msg.data); + deps.setCurrentSubDelay(Number.isFinite(delay) ? delay : 0); } else if (msg.name === 'secondary-sub-text') { const nextSubText = (msg.data as string) || ''; deps.setCurrentSecondarySubText(nextSubText); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 5f3e46f7..f06967c1 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -160,6 +160,8 @@ export class MpvIpcClient implements MpvClient { public currentTimePos = 0; public currentSubStart = 0; public currentSubEnd = 0; + /** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */ + public currentSubDelay = 0; public currentSubText = ''; public currentSecondarySubText = ''; public currentAudioStreamIndex: number | null = null; @@ -459,6 +461,9 @@ export class MpvIpcClient implements MpvClient { this.currentSubEnd = value; }, getCurrentSubEnd: () => this.currentSubEnd, + setCurrentSubDelay: (value: number) => { + this.currentSubDelay = value; + }, emitMediaPathChange: (payload) => { this.emit('media-path-change', payload); }, diff --git a/src/main.ts b/src/main.ts index ff8a36e1..0fb1c674 100644 --- a/src/main.ts +++ b/src/main.ts @@ -328,6 +328,7 @@ import { setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, setVisibleOverlayVisible as setVisibleOverlayVisibleCore, + shiftSubtitleRangeToVideoClock, showMpvOsdRuntime, JellyfinPlaybackReporter, tokenizeSubtitle as tokenizeSubtitleCore, @@ -1951,9 +1952,11 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n // Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active // event, so a finished caption row lingering beside a fresh line would end up on the // card. The parsed view also carries the cue's own timings for the clip range. + // Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first. + const subDelay = appState.mpvClient?.currentSubDelay ?? 0; const resolved = resolvePrimarySubtitle({ liveText: appState.mpvClient?.currentSubText ?? '', - currentTimeSec: Number(appState.mpvClient?.currentTimePos), + currentTimeSec: Number(appState.mpvClient?.currentTimePos) - subDelay, cues: appState.activeParsedSubtitleCues, }); // Same validity bar as the live capture path: an unusable resolved span must fall @@ -1968,13 +1971,16 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n ) { return captureLiveSubtitleMiningContext(appState.mpvClient); } - return { - source: 'overlay', - text: resolvedText, - startTime: resolved.startTime, - endTime: resolved.endTime, - capturedAtMs: Date.now(), - }; + return shiftSubtitleRangeToVideoClock( + { + source: 'overlay', + text: resolvedText, + startTime: resolved.startTime, + endTime: resolved.endTime, + capturedAtMs: Date.now(), + }, + subDelay, + ); } function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void { const timedPayload = withCurrentSubtitleTiming(payload); @@ -3115,7 +3121,10 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range), getSubtitleContextLines: (range) => collectMediaTimingContextLines({ - cues: appState.activeParsedSubtitleCues, + // Review ranges and tracker entries are on the video clock; parsed cues are not. + cues: appState.activeParsedSubtitleCues.map((cue) => + shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0), + ), fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [], startTime: range.startTime, endTime: range.endTime, @@ -6028,7 +6037,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ // live mpv sub timings at lookup time so media generation clips the mined line even // when extraction finishes long after playback has moved on. recordSubtitleMiningContext: (context) => - recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()), + recordSubtitleMiningContext( + context + ? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0) + : captureCurrentPrimarySubtitleMiningContext(), + ), tokenizeCurrentSubtitle: async () => { const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred; return resolveCurrentSubtitleForRenderer({ diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index d46d4357..aeb9f5f4 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -176,6 +176,21 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues assert.deepEqual(recordedStarts.slice(-1), [20]); }); +test('subtitle timing history records lines on the video clock under sub-delay', () => { + const timing: Array<{ text: string; start: number; end: number }> = []; + const { handlers } = buildMainDeps({ + mpvClient: { currentSubDelay: 2 }, + subtitleTimingTracker: { + recordSubtitle: (text: string, start: number, end: number) => + timing.push({ text, start, end }), + }, + }); + + handlers.recordSubtitleTiming('食べる', 5, 8); + + assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]); +}); + test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => { const immersion: Array<{ text: string; start: number; end: number }> = []; const timing: Array<{ text: string; start: number; end: number }> = []; diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index ac30fd73..17602791 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -23,6 +23,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { currentSubText?: string; currentSecondarySubText?: string; currentTimePos?: number; + currentSubDelay?: number; requestProperty?: (name: string) => Promise; } | null; immersionTracker: { @@ -231,6 +232,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), recordSubtitleTiming: (text: string, start: number, end: number) => { const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined; + // The tracker feeds multi-line mining, so it stores video-clock times: the subtitle + // timings plus the `sub-delay` in effect while the line was on screen. + const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0; const canonical = resolveCanonicalSample(text, start); if (!canonical) { const recordableText = resolveTextForRecording(text, start); @@ -239,8 +243,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { } deps.appState.subtitleTimingTracker?.recordSubtitle?.( recordableText, - start, - end, + start + subDelay, + end + subDelay, secondaryText, ); return; @@ -253,8 +257,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { recordedTimingCanonicalKeys.add(key); deps.appState.subtitleTimingTracker?.recordSubtitle?.( cue.text, - cue.startTime, - cue.endTime, + cue.startTime + subDelay, + cue.endTime + subDelay, secondaryText, ); } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 6d9a7253..494caa10 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -90,6 +90,8 @@ export interface MpvClient { currentTimePos: number; currentSubStart: number; currentSubEnd: number; + /** mpv `sub-delay`; `sub-start`/`sub-end` do not include it. */ + currentSubDelay: number; currentAudioStreamIndex: number | null; requestProperty?: (name: string) => Promise; send(command: { command: unknown[]; request_id?: number }): boolean; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 0785a77a..8b35a6e3 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -251,6 +251,7 @@ export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState { export interface SubtitleMiningContext { source: 'subtitle-sidebar' | 'overlay'; text: string; + /** Video-clock seconds: subtitle timings plus mpv `sub-delay` at capture time. */ startTime: number; endTime: number; capturedAtMs?: number;