mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-25 23:35:21 -07:00
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.
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test, { after } from 'node:test';
|
|
import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp';
|
|
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki';
|
|
import { FIXTURE_CUES } from './harness/fixtures';
|
|
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
|
import { waitUntil } from './harness/wait';
|
|
|
|
type Started = { session: E2eSession; overlay: CdpPage };
|
|
|
|
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
|
// session boots inside whichever test runs first and every test budgets for it.
|
|
const TEST_TIMEOUT_MS = 120_000;
|
|
let started: Promise<Started> | undefined;
|
|
|
|
function useSession(): Promise<Started> {
|
|
started ??= (async () => {
|
|
const session = await startE2eSession();
|
|
try {
|
|
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
|
} catch (error) {
|
|
await session.dispose();
|
|
throw error;
|
|
}
|
|
})();
|
|
return started;
|
|
}
|
|
|
|
after(async () => {
|
|
const running = await started?.catch(() => undefined);
|
|
await running?.session.dispose();
|
|
});
|
|
|
|
type RenderedToken = { surface: string; headword: string | null };
|
|
|
|
// Parks playback inside a cue and waits for the overlay to show that line.
|
|
async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToken[]> {
|
|
const { session, overlay } = await useSession();
|
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
|
// One expression reads the line and its tokens together, so a re-render
|
|
// between two round trips cannot hand back a half-updated subtitle.
|
|
return overlay
|
|
.waitFor<RenderedToken[] | false>(
|
|
`(() => {
|
|
const root = document.getElementById('subtitleRoot');
|
|
const words = Array.from(root.querySelectorAll('.word'), (word) => ({
|
|
surface: word.textContent,
|
|
headword: word.getAttribute('data-headword'),
|
|
}));
|
|
return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words;
|
|
})()`,
|
|
{ description: `overlay to render "${cue.text}" tokenized` },
|
|
)
|
|
.catch(async (error: unknown) => {
|
|
// Say what the overlay was showing, what main thinks the line is, and
|
|
// what the renderer logged, so a dropped update can be placed.
|
|
const state = await overlay.evaluate<string>(
|
|
`(async () => JSON.stringify({
|
|
visibility: document.visibilityState,
|
|
text: document.getElementById('subtitleRoot').textContent,
|
|
html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200),
|
|
mainLine: (await window.electronAPI.getCurrentSubtitle())?.text,
|
|
errorToast: document.getElementById('overlayErrorToast')?.textContent,
|
|
}))()`,
|
|
);
|
|
// Diagnostics must not replace the cue failure if the inspector is gone too.
|
|
const windows = await session
|
|
.mainProcess()
|
|
.then((main) =>
|
|
main.evaluate<string>(
|
|
`JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({
|
|
id: w.id,
|
|
title: w.getTitle(),
|
|
url: w.webContents.getURL().split('/').pop(),
|
|
visible: w.isVisible(),
|
|
loading: w.webContents.isLoading(),
|
|
documentLoaded: w.__subminerOverlayDocumentLoaded,
|
|
contentReady: w.__subminerOverlayContentReady,
|
|
})))`,
|
|
),
|
|
)
|
|
.catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`);
|
|
const targets = (await listCdpTargets(session.cdpPort))
|
|
.filter((t) => t.type === 'page')
|
|
.map((t) => t.url.split('/').pop());
|
|
const log = overlay.console.slice(-20).join('\n');
|
|
throw new Error(
|
|
`Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`,
|
|
{ cause: error },
|
|
);
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
test(
|
|
'overlay renders the current mpv subtitle line as dictionary tokens',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const tokens = await showCue(FIXTURE_CUES[0]);
|
|
|
|
assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens));
|
|
assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const tokens = await showCue(FIXTURE_CUES[2]);
|
|
|
|
assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'mining a sentence card sends the line with generated audio and image to Anki',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const { session } = await useSession();
|
|
const cue = FIXTURE_CUES[1];
|
|
await showCue(cue);
|
|
session.anki.requests.length = 0;
|
|
|
|
await session.runAppCommand('--mine-sentence');
|
|
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);
|
|
assert.partialDeepStrictEqual(added[0]?.params.note, {
|
|
deckName: FAKE_ANKI_DECK,
|
|
modelName: FAKE_ANKI_MODEL,
|
|
fields: { Sentence: cue.text },
|
|
});
|
|
|
|
const stored = session.anki.requests
|
|
.filter((request) => request.action === 'storeMediaFile')
|
|
.map((request) => String(request.params.filename));
|
|
assert.ok(
|
|
stored.some((name) => name.endsWith('.mp3')),
|
|
`no audio in ${stored.join(', ')}`,
|
|
);
|
|
assert.ok(
|
|
stored.some((name) => name.endsWith('.jpg')),
|
|
`no image in ${stored.join(', ')}`,
|
|
);
|
|
|
|
const updatedFields = JSON.stringify(update.params);
|
|
for (const filename of stored) assert.ok(updatedFields.includes(filename), filename);
|
|
},
|
|
);
|
|
|
|
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 },
|
|
async () => {
|
|
const { session, overlay } = await useSession();
|
|
await showCue(FIXTURE_CUES[0]);
|
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
|
await overlay.waitFor(
|
|
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
|
|
{ description: 'sidebar to list every fixture cue' },
|
|
);
|
|
|
|
for (const [index, cue] of FIXTURE_CUES.entries()) {
|
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
|
await overlay.waitFor(
|
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
|
|
{ description: `sidebar to highlight cue ${index}` },
|
|
);
|
|
}
|
|
|
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
|
await overlay.waitFor(
|
|
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
|
{ description: 'sidebar to close' },
|
|
);
|
|
},
|
|
);
|
|
|
|
// Runs last: it leaves the lookup popup open over the overlay.
|
|
test(
|
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const { session, overlay } = await useSession();
|
|
await showCue(FIXTURE_CUES[0]);
|
|
const word = await overlay.evaluate<{ x: number; y: number }>(
|
|
`(() => {
|
|
const words = Array.from(document.querySelectorAll('#subtitleRoot .word'));
|
|
const box = words.find((word) => word.textContent === '天気').getBoundingClientRect();
|
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
})()`,
|
|
);
|
|
|
|
// Yomitan's default scan modifier is Shift, and it scans on mouse movement,
|
|
// so approach the word, hold Shift, then move across it.
|
|
const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
|
|
const move = (x: number, modifiers = 0) =>
|
|
overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers });
|
|
await move(word.x - 30);
|
|
await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 });
|
|
await move(word.x - 2, 8);
|
|
await move(word.x, 8);
|
|
|
|
await overlay.waitFor(
|
|
`document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`,
|
|
{ description: 'overlay to mark the Yomitan popup visible' },
|
|
);
|
|
// Yomitan keeps more than one popup frame around, so read whichever
|
|
// one holds the entry instead of trusting target order.
|
|
let seen: string[] = [];
|
|
const entry = await waitUntil(
|
|
async () => {
|
|
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
|
|
target.url.includes('popup.html'),
|
|
);
|
|
seen = [];
|
|
for (const target of targets) {
|
|
// A frame can go away between listing and connecting; skip it.
|
|
const frame = await connectCdpPage(target).catch(() => undefined);
|
|
if (!frame) continue;
|
|
// textContent, not innerText: the entry counts once it is in the DOM,
|
|
// even where a GPU-less runner never lays the frame out.
|
|
const text = await frame.evaluate<string>('document.body?.textContent ?? ""');
|
|
frame.close();
|
|
seen.push(text);
|
|
}
|
|
return seen.find((text) => text.includes('weather'));
|
|
},
|
|
{ description: 'a popup frame to show the fixture dictionary entry' },
|
|
).catch((error: unknown) => {
|
|
const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200));
|
|
throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error });
|
|
});
|
|
await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift });
|
|
assert.match(entry, /天気/);
|
|
},
|
|
);
|