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
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
@@ -59,10 +61,33 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
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 },
|
||||
|
||||
Reference in new issue
Block a user