mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
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:
17 files changed
+223
-38
No files matched your search
@@ -22,6 +22,7 @@ export {
|
||||
handleMultiCopyDigit,
|
||||
markLastCardAsAudioCard,
|
||||
mineSentenceCard,
|
||||
shiftSubtitleRangeToVideoClock,
|
||||
triggerFieldGrouping,
|
||||
updateLastCardFromClipboard,
|
||||
} from './mining';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'path',
|
||||
'sub-start',
|
||||
'sub-end',
|
||||
'sub-delay',
|
||||
'time-pos',
|
||||
'secondary-sub-text',
|
||||
'aid',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user