mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-25 23:35:21 -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
@@ -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.
|
||||||
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
// 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<FakeAnki> {
|
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||||
const requests: AnkiRequest[] = [];
|
const requests: AnkiRequest[] = [];
|
||||||
|
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||||
let nextNoteId = 1000;
|
let nextNoteId = 1000;
|
||||||
|
|
||||||
const handle = (body: unknown): unknown => {
|
const handle = (body: unknown): unknown => {
|
||||||
@@ -59,10 +61,33 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
|||||||
return [FAKE_ANKI_MODEL];
|
return [FAKE_ANKI_MODEL];
|
||||||
case 'modelFieldNames':
|
case 'modelFieldNames':
|
||||||
return MODEL_FIELDS;
|
return MODEL_FIELDS;
|
||||||
case 'addNote':
|
case 'addNote': {
|
||||||
return nextNoteId++;
|
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 'findNotes':
|
||||||
case 'notesInfo':
|
|
||||||
return [];
|
return [];
|
||||||
case 'storeMediaFile':
|
case 'storeMediaFile':
|
||||||
return params.filename ?? null;
|
return params.filename ?? null;
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Card-kind flags are written before media, so wait for the update carrying audio.
|
||||||
|
function findMediaUpdate(session: E2eSession) {
|
||||||
|
return session.anki.requests.find(
|
||||||
|
(request) =>
|
||||||
|
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||||
}
|
}
|
||||||
@@ -126,10 +134,10 @@ test(
|
|||||||
session.anki.requests.length = 0;
|
session.anki.requests.length = 0;
|
||||||
|
|
||||||
await session.runAppCommand('--mine-sentence');
|
await session.runAppCommand('--mine-sentence');
|
||||||
const update = await waitUntil(
|
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
description: 'media fields to be written to the mined note',
|
||||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
timeoutMs: 30_000,
|
||||||
);
|
});
|
||||||
|
|
||||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||||
assert.equal(added.length, 1);
|
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(
|
test(
|
||||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||||
{ timeout: TEST_TIMEOUT_MS },
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
|||||||
+10
-7
@@ -44,7 +44,10 @@ import {
|
|||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from './anki-field-config';
|
} from './anki-field-config';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
import {
|
||||||
|
captureLiveSubtitleMiningContext,
|
||||||
|
shiftSubtitleRangeToVideoClock,
|
||||||
|
} from './core/services/mining';
|
||||||
import {
|
import {
|
||||||
createUiFeedbackState,
|
createUiFeedbackState,
|
||||||
beginUpdateProgress,
|
beginUpdateProgress,
|
||||||
@@ -1029,10 +1032,10 @@ export class AnkiIntegration {
|
|||||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||||
) {
|
) {
|
||||||
return {
|
return shiftSubtitleRangeToVideoClock(
|
||||||
startTime: this.mpvClient.currentSubStart,
|
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
|
||||||
endTime: this.mpvClient.currentSubEnd,
|
this.mpvClient.currentSubDelay,
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||||
@@ -1672,7 +1675,7 @@ export class AnkiIntegration {
|
|||||||
if (this.config.fields?.miscInfo) {
|
if (this.config.fields?.miscInfo) {
|
||||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||||
audioFilename,
|
audioFilename,
|
||||||
this.mpvClient.currentSubStart,
|
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1695,7 +1698,7 @@ export class AnkiIntegration {
|
|||||||
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
||||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||||
imageFilename,
|
imageFilename,
|
||||||
this.mpvClient.currentSubStart,
|
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MediaTimingReviewRequest,
|
type MediaTimingReviewRequest,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
} from '../types/anki';
|
} from '../types/anki';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from '../core/services/mining';
|
||||||
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';
|
||||||
@@ -428,10 +429,12 @@ export class CardCreationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let startTime = mpvClient.currentSubStart;
|
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
|
||||||
let endTime = mpvClient.currentSubEnd;
|
{ 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 currentTime = mpvClient.currentTimePos || 0;
|
||||||
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
||||||
startTime = currentTime - fallback;
|
startTime = currentTime - fallback;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export {
|
|||||||
handleMultiCopyDigit,
|
handleMultiCopyDigit,
|
||||||
markLastCardAsAudioCard,
|
markLastCardAsAudioCard,
|
||||||
mineSentenceCard,
|
mineSentenceCard,
|
||||||
|
shiftSubtitleRangeToVideoClock,
|
||||||
triggerFieldGrouping,
|
triggerFieldGrouping,
|
||||||
updateLastCardFromClipboard,
|
updateLastCardFromClipboard,
|
||||||
} from './mining';
|
} from './mining';
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: 1,
|
currentSubStart: 1,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => osd.push(text),
|
showMpvOsd: (text) => osd.push(text),
|
||||||
}),
|
}),
|
||||||
@@ -109,6 +110,7 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
|||||||
currentSubText: 'subtitle line',
|
currentSubText: 'subtitle line',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'secondary line',
|
currentSecondarySubText: 'secondary line',
|
||||||
},
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
@@ -148,6 +150,7 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
|
|||||||
currentSubText: '今今今手手手',
|
currentSubText: '今今今手手手',
|
||||||
currentSubStart: 11.4,
|
currentSubStart: 11.4,
|
||||||
currentSubEnd: 11.8,
|
currentSubEnd: 11.8,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'English subtitle',
|
currentSecondarySubText: 'English subtitle',
|
||||||
},
|
},
|
||||||
primarySubtitle: {
|
primarySubtitle: {
|
||||||
@@ -187,6 +190,7 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
|
|||||||
currentSubText: '日本語字幕',
|
currentSubText: '日本語字幕',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'Your\nmosaic',
|
currentSecondarySubText: 'Your\nmosaic',
|
||||||
requestProperty: async () => {
|
requestProperty: async () => {
|
||||||
requestedRawSecondaryText = true;
|
requestedRawSecondaryText = true;
|
||||||
@@ -218,6 +222,7 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
|
|||||||
currentSubText: '日本語字幕',
|
currentSubText: '日本語字幕',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: '日本語字幕',
|
currentSecondarySubText: '日本語字幕',
|
||||||
},
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
@@ -396,7 +401,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
|
|||||||
|
|
||||||
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
||||||
const context = captureLiveSubtitleMiningContext(
|
const context = captureLiveSubtitleMiningContext(
|
||||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
|
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
|
||||||
() => 1234,
|
() => 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', () => {
|
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
|
||||||
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -416,6 +431,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: ' ',
|
currentSubText: ' ',
|
||||||
currentSubStart: 1,
|
currentSubStart: 1,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -424,6 +440,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: 5,
|
currentSubStart: 5,
|
||||||
currentSubEnd: 5,
|
currentSubEnd: 5,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -432,6 +449,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: NaN,
|
currentSubStart: NaN,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface MpvClientLike {
|
|||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubStart: number;
|
currentSubStart: number;
|
||||||
currentSubEnd: number;
|
currentSubEnd: number;
|
||||||
|
currentSubDelay: number;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
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
|
* 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),
|
||||||
@@ -62,7 +78,10 @@ export function handleMultiCopyDigit(
|
|||||||
* line is on screen by then; callers capture here at mining time instead.
|
* line is on screen by then; callers capture here at mining time instead.
|
||||||
*/
|
*/
|
||||||
export function captureLiveSubtitleMiningContext(
|
export function captureLiveSubtitleMiningContext(
|
||||||
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
|
client: Pick<
|
||||||
|
MpvClientLike,
|
||||||
|
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
|
||||||
|
> | null,
|
||||||
now: () => number = Date.now,
|
now: () => number = Date.now,
|
||||||
): SubtitleMiningContext | null {
|
): SubtitleMiningContext | null {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -73,7 +92,10 @@ export function captureLiveSubtitleMiningContext(
|
|||||||
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||||
return null;
|
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: {
|
export function copyCurrentSubtitle(deps: {
|
||||||
@@ -167,6 +189,7 @@ export async function markLastCardAsAudioCard(deps: {
|
|||||||
export async function mineSentenceCard(deps: {
|
export async function mineSentenceCard(deps: {
|
||||||
ankiIntegration: AnkiIntegrationLike | null;
|
ankiIntegration: AnkiIntegrationLike | null;
|
||||||
mpvClient: MpvClientLike | null;
|
mpvClient: MpvClientLike | null;
|
||||||
|
/** Already on the video clock, like every `SubtitleMiningContext`. */
|
||||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
@@ -185,10 +208,16 @@ export async function mineSentenceCard(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||||
|
const range =
|
||||||
|
deps.primarySubtitle ??
|
||||||
|
shiftSubtitleRangeToVideoClock(
|
||||||
|
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||||
|
mpvClient.currentSubDelay,
|
||||||
|
);
|
||||||
return await anki.createSentenceCard(
|
return await anki.createSentenceCard(
|
||||||
primaryText,
|
primaryText,
|
||||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
range.startTime,
|
||||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
range.endTime,
|
||||||
secondarySubText,
|
secondarySubText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
|||||||
'path',
|
'path',
|
||||||
'sub-start',
|
'sub-start',
|
||||||
'sub-end',
|
'sub-end',
|
||||||
|
'sub-delay',
|
||||||
'time-pos',
|
'time-pos',
|
||||||
'secondary-sub-text',
|
'secondary-sub-text',
|
||||||
'aid',
|
'aid',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
|||||||
getCurrentSubStart: () => 0,
|
getCurrentSubStart: () => 0,
|
||||||
setCurrentSubEnd: () => {},
|
setCurrentSubEnd: () => {},
|
||||||
getCurrentSubEnd: () => 0,
|
getCurrentSubEnd: () => 0,
|
||||||
|
setCurrentSubDelay: () => {},
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
state.mediaPath = payload.path;
|
state.mediaPath = payload.path;
|
||||||
},
|
},
|
||||||
@@ -131,6 +132,22 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
|||||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
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 () => {
|
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
||||||
const { deps, state } = createDeps();
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
getCurrentSubStart: () => number;
|
getCurrentSubStart: () => number;
|
||||||
setCurrentSubEnd: (value: number) => void;
|
setCurrentSubEnd: (value: number) => void;
|
||||||
getCurrentSubEnd: () => number;
|
getCurrentSubEnd: () => number;
|
||||||
|
setCurrentSubDelay: (value: number) => void;
|
||||||
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;
|
||||||
@@ -272,6 +273,9 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
start: deps.getCurrentSubStart(),
|
start: deps.getCurrentSubStart(),
|
||||||
end: deps.getCurrentSubEnd(),
|
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') {
|
} else if (msg.name === 'secondary-sub-text') {
|
||||||
const nextSubText = (msg.data as string) || '';
|
const nextSubText = (msg.data as string) || '';
|
||||||
deps.setCurrentSecondarySubText(nextSubText);
|
deps.setCurrentSecondarySubText(nextSubText);
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
public currentTimePos = 0;
|
public currentTimePos = 0;
|
||||||
public currentSubStart = 0;
|
public currentSubStart = 0;
|
||||||
public currentSubEnd = 0;
|
public currentSubEnd = 0;
|
||||||
|
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
|
||||||
|
public currentSubDelay = 0;
|
||||||
public currentSubText = '';
|
public currentSubText = '';
|
||||||
public currentSecondarySubText = '';
|
public currentSecondarySubText = '';
|
||||||
public currentAudioStreamIndex: number | null = null;
|
public currentAudioStreamIndex: number | null = null;
|
||||||
@@ -459,6 +461,9 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
this.currentSubEnd = value;
|
this.currentSubEnd = value;
|
||||||
},
|
},
|
||||||
getCurrentSubEnd: () => this.currentSubEnd,
|
getCurrentSubEnd: () => this.currentSubEnd,
|
||||||
|
setCurrentSubDelay: (value: number) => {
|
||||||
|
this.currentSubDelay = value;
|
||||||
|
},
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
this.emit('media-path-change', payload);
|
this.emit('media-path-change', payload);
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-10
@@ -328,6 +328,7 @@ import {
|
|||||||
setMpvSubVisibilityRuntime,
|
setMpvSubVisibilityRuntime,
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
setOverlayDebugVisualizationEnabledRuntime,
|
||||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||||
|
shiftSubtitleRangeToVideoClock,
|
||||||
showMpvOsdRuntime,
|
showMpvOsdRuntime,
|
||||||
JellyfinPlaybackReporter,
|
JellyfinPlaybackReporter,
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
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
|
// 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
|
// 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.
|
// 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({
|
const resolved = resolvePrimarySubtitle({
|
||||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
currentTimeSec: 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
|
||||||
@@ -1968,13 +1971,16 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
|||||||
) {
|
) {
|
||||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||||
}
|
}
|
||||||
return {
|
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||||
source: 'overlay',
|
{
|
||||||
text: resolvedText,
|
source: 'overlay',
|
||||||
startTime: resolved.startTime,
|
text: resolvedText,
|
||||||
endTime: resolved.endTime,
|
startTime: resolved.startTime,
|
||||||
capturedAtMs: Date.now(),
|
endTime: resolved.endTime,
|
||||||
};
|
capturedAtMs: Date.now(),
|
||||||
|
},
|
||||||
|
subDelay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
@@ -3115,7 +3121,10 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
|||||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||||
getSubtitleContextLines: (range) =>
|
getSubtitleContextLines: (range) =>
|
||||||
collectMediaTimingContextLines({
|
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) ?? [],
|
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||||
startTime: range.startTime,
|
startTime: range.startTime,
|
||||||
endTime: range.endTime,
|
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
|
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||||
// when extraction finishes long after playback has moved on.
|
// when extraction finishes long after playback has moved on.
|
||||||
recordSubtitleMiningContext: (context) =>
|
recordSubtitleMiningContext: (context) =>
|
||||||
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
|
recordSubtitleMiningContext(
|
||||||
|
context
|
||||||
|
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||||
|
: captureCurrentPrimarySubtitleMiningContext(),
|
||||||
|
),
|
||||||
tokenizeCurrentSubtitle: async () => {
|
tokenizeCurrentSubtitle: async () => {
|
||||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||||
return resolveCurrentSubtitleForRenderer({
|
return resolveCurrentSubtitleForRenderer({
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
|||||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
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', () => {
|
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
currentSubText?: string;
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubDelay?: number;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
immersionTracker: {
|
immersionTracker: {
|
||||||
@@ -231,6 +232,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
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);
|
const canonical = resolveCanonicalSample(text, start);
|
||||||
if (!canonical) {
|
if (!canonical) {
|
||||||
const recordableText = resolveTextForRecording(text, start);
|
const recordableText = resolveTextForRecording(text, start);
|
||||||
@@ -239,8 +243,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
}
|
}
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
recordableText,
|
recordableText,
|
||||||
start,
|
start + subDelay,
|
||||||
end,
|
end + subDelay,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -253,8 +257,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
recordedTimingCanonicalKeys.add(key);
|
recordedTimingCanonicalKeys.add(key);
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
cue.text,
|
cue.text,
|
||||||
cue.startTime,
|
cue.startTime + subDelay,
|
||||||
cue.endTime,
|
cue.endTime + subDelay,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export interface MpvClient {
|
|||||||
currentTimePos: number;
|
currentTimePos: number;
|
||||||
currentSubStart: number;
|
currentSubStart: number;
|
||||||
currentSubEnd: number;
|
currentSubEnd: number;
|
||||||
|
/** mpv `sub-delay`; `sub-start`/`sub-end` do not include it. */
|
||||||
|
currentSubDelay: number;
|
||||||
currentAudioStreamIndex: number | null;
|
currentAudioStreamIndex: number | null;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
|
|||||||
export interface SubtitleMiningContext {
|
export interface SubtitleMiningContext {
|
||||||
source: 'subtitle-sidebar' | 'overlay';
|
source: 'subtitle-sidebar' | 'overlay';
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Video-clock seconds: subtitle timings plus mpv `sub-delay` at capture time. */
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
capturedAtMs?: number;
|
capturedAtMs?: number;
|
||||||
|
|||||||
Reference in new issue
Block a user