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.
This commit is contained in:
sudacode committed 2026-10-25 21:32:59 -07:00
1 parent 3068d02f89
commit 929e78b28c
17 files changed
+223 -38

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ export {
handleMultiCopyDigit,
markLastCardAsAudioCard,
mineSentenceCard,
shiftSubtitleRangeToVideoClock,
triggerFieldGrouping,
updateLastCardFromClipboard,
} from './mining';
+19 -1
View File
@@ -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,
);
+33 -4
View File
@@ -25,6 +25,7 @@ interface MpvClientLike {
currentSubText: string;
currentSubStart: number;
currentSubEnd: number;
currentSubDelay: number;
currentSecondarySubText?: string;
requestProperty?: (name: string) => Promise<unknown>;
}
@@ -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<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),
@@ -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<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | 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<SubtitleMiningContext>(
{ 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<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
showMpvOsd: (text: string) => void;
}): Promise<boolean> {
@@ -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,
);
}
+1
View File
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'path',
'sub-start',
'sub-end',
'sub-delay',
'time-pos',
'secondary-sub-text',
'aid',
+17
View File
@@ -73,6 +73,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
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();
+4
View File
@@ -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);
+5
View File
@@ -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);
},