mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-04 19:21:33 -07:00
perf(tokenizer): single-pass Yomitan scan with install-once runtime and cross-line cache
- drop the duplicate parseText full parse per line; the termsFind scanner walk is now authoritative and emits its own unparsed filler runs (parseText kept only as error fallback) - install scan helpers once per parser window (__subminerYomitanScan) instead of re-shipping ~500 lines of script per subtitle line - persist termsFind results across lines in a window-scoped LRU keyed by substring, invalidated via a cache epoch on dictionary/settings changes - skip lookups at punctuation/whitespace positions and cap the shrinking-window retry ladder at 4 lookups per position - build tokenizer runtime deps once (JLPT lookup cache never hit before; mecab availability check ran per line) - stop restarting the prefetch run on every subtitle change; resume prefetch only after the tokenized payload lands, not on provisional raw emits - add per-stage debug timings (scanMs/mecabMs/frequencyMs/annotateMs)
This commit is contained in:
@@ -2934,44 +2934,12 @@ test('tokenizeSubtitle preserves Yomitan compound token when MeCab components ar
|
||||
return [];
|
||||
}
|
||||
|
||||
if (script.includes('parseText')) {
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '取り組んで',
|
||||
reading: 'とりくんで',
|
||||
headwords: [[{ term: '取り組む' }]],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'もらいます',
|
||||
reading: 'もらいます',
|
||||
headwords: [[{ term: 'もらう' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
surface: '取り',
|
||||
reading: 'とり',
|
||||
headword: '取る',
|
||||
surface: '取り組んで',
|
||||
reading: 'とりくんで',
|
||||
headword: '取り組む',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
},
|
||||
{
|
||||
surface: '組んで',
|
||||
reading: 'くんで',
|
||||
headword: '組む',
|
||||
startPos: 2,
|
||||
endPos: 5,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -716,15 +716,29 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
|
||||
};
|
||||
}
|
||||
|
||||
// Per-line stage durations for the pipeline debug log; every field is filled in
|
||||
// by the stage that awaits the corresponding work.
|
||||
interface TokenizationStageTimings {
|
||||
scanMs?: number;
|
||||
mecabMs?: number;
|
||||
frequencyMs?: number;
|
||||
annotateMs?: number;
|
||||
}
|
||||
|
||||
async function parseWithYomitanInternalParser(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
stageTimings?: TokenizationStageTimings,
|
||||
): Promise<MergedToken[] | null> {
|
||||
const scanStartedAtMs = Date.now();
|
||||
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
||||
includeNameMatchMetadata: options.nameMatchEnabled,
|
||||
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
||||
});
|
||||
if (stageTimings) {
|
||||
stageTimings.scanMs = Date.now() - scanStartedAtMs;
|
||||
}
|
||||
if (!selectedTokens || selectedTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -757,6 +771,7 @@ async function parseWithYomitanInternalParser(
|
||||
|
||||
const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
|
||||
? (async () => {
|
||||
const frequencyStartedAtMs = Date.now();
|
||||
const frequencyMatchMode = options.frequencyMatchMode;
|
||||
const termReadingList = buildYomitanFrequencyTermReadingList(
|
||||
normalizedSelectedTokens,
|
||||
@@ -767,12 +782,17 @@ async function parseWithYomitanInternalParser(
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return buildYomitanFrequencyIndex(yomitanFrequencies);
|
||||
const frequencyIndex = buildYomitanFrequencyIndex(yomitanFrequencies);
|
||||
if (stageTimings) {
|
||||
stageTimings.frequencyMs = Date.now() - frequencyStartedAtMs;
|
||||
}
|
||||
return frequencyIndex;
|
||||
})()
|
||||
: Promise.resolve({ byPair: new Map(), byTerm: new Map() });
|
||||
|
||||
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
||||
? (async () => {
|
||||
const mecabStartedAtMs = Date.now();
|
||||
try {
|
||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
||||
@@ -786,6 +806,10 @@ async function parseWithYomitanInternalParser(
|
||||
`textLength=${text.length}`,
|
||||
);
|
||||
return normalizedSelectedTokens;
|
||||
} finally {
|
||||
if (stageTimings) {
|
||||
stageTimings.mecabMs = Date.now() - mecabStartedAtMs;
|
||||
}
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(normalizedSelectedTokens);
|
||||
@@ -876,15 +900,35 @@ export async function tokenizeSubtitle(
|
||||
const annotationOptions = getAnnotationOptions(deps);
|
||||
annotationOptions.sourceText = tokenizeText;
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||
const stageTimings: TokenizationStageTimings = {};
|
||||
const startedAtMs = Date.now();
|
||||
const logStageTimings = (tokenCount: number): void => {
|
||||
logger.debug(
|
||||
`Subtitle tokenization stages; textLength=${tokenizeText.length}, tokenCount=${tokenCount}, ` +
|
||||
`scanMs=${stageTimings.scanMs ?? '-'}, mecabMs=${stageTimings.mecabMs ?? '-'}, ` +
|
||||
`frequencyMs=${stageTimings.frequencyMs ?? '-'}, annotateMs=${stageTimings.annotateMs ?? '-'}, ` +
|
||||
`totalMs=${Date.now() - startedAtMs}`,
|
||||
);
|
||||
};
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(
|
||||
tokenizeText,
|
||||
deps,
|
||||
annotationOptions,
|
||||
stageTimings,
|
||||
);
|
||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||
const annotateStartedAtMs = Date.now();
|
||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
||||
stageTimings.annotateMs = Date.now() - annotateStartedAtMs;
|
||||
logStageTimings(renderedTokens.length);
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
||||
};
|
||||
}
|
||||
|
||||
logStageTimings(0);
|
||||
return { text: displayText, tokens: null };
|
||||
}
|
||||
|
||||
@@ -366,8 +366,11 @@ export function createReplayMessageStore(messages: GoldenRecordedMessage[]): Rep
|
||||
};
|
||||
}
|
||||
|
||||
async function runInjectedScriptInVm(script: string, store: ReplayMessageStore): Promise<unknown> {
|
||||
return await vm.runInNewContext(script, {
|
||||
// One persistent context per fixture, matching the real parser window: the
|
||||
// scan runtime installs itself once into globalThis and later per-line call
|
||||
// scripts reuse it.
|
||||
function createInjectedScriptVm(store: ReplayMessageStore): (script: string) => Promise<unknown> {
|
||||
const context = vm.createContext({
|
||||
chrome: {
|
||||
runtime: {
|
||||
lastError: null,
|
||||
@@ -393,6 +396,7 @@ async function runInjectedScriptInVm(script: string, store: ReplayMessageStore):
|
||||
Set,
|
||||
String,
|
||||
});
|
||||
return async (script: string) => await vm.runInContext(script, context);
|
||||
}
|
||||
|
||||
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
|
||||
@@ -400,13 +404,14 @@ export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServ
|
||||
const scriptResults = new Map(
|
||||
fixture.recording.scripts.map((entry) => [entry.sha256, entry] as const),
|
||||
);
|
||||
const runInjectedScriptInVm = createInjectedScriptVm(store);
|
||||
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
try {
|
||||
return await runInjectedScriptInVm(script, store);
|
||||
return await runInjectedScriptInVm(script);
|
||||
} catch (vmError) {
|
||||
const recorded = scriptResults.get(hashInjectedScript(script));
|
||||
if (recorded) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,13 @@ const yomitanFrequencyCacheByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
Map<string, YomitanTermFrequency[]>
|
||||
>();
|
||||
// Epoch passed with every scan request; the in-window termsFind cache clears
|
||||
// itself when the epoch changes (dictionary imports, settings changes).
|
||||
const yomitanScanCacheEpochByWindow = new WeakMap<BrowserWindow, number>();
|
||||
|
||||
function getYomitanScanCacheEpoch(window: BrowserWindow): number {
|
||||
return yomitanScanCacheEpochByWindow.get(window) ?? 0;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
@@ -99,6 +106,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
typeof entry.startPos === 'number' &&
|
||||
typeof entry.endPos === 'number' &&
|
||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||
(entry.isUnparsedRun === undefined || typeof entry.isUnparsedRun === 'boolean') &&
|
||||
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
||||
(entry.wordClasses === undefined ||
|
||||
(Array.isArray(entry.wordClasses) &&
|
||||
@@ -107,13 +115,9 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
);
|
||||
}
|
||||
|
||||
function scanTokenSpanKey(token: YomitanScanToken): string {
|
||||
return `${token.startPos}:${token.endPos}:${token.surface}`;
|
||||
}
|
||||
|
||||
// Maps a parse-selected token to the scanner-token shape carried out of the
|
||||
// parser runtime. Shared by both selectYomitanParseTokens fallback paths so the
|
||||
// projected fields stay in sync as the shape changes.
|
||||
// parser runtime, used by the parseText fallback path when the in-window
|
||||
// scanner is unavailable.
|
||||
function toYomitanScanToken(token: {
|
||||
surface: string;
|
||||
reading: string;
|
||||
@@ -132,66 +136,6 @@ function toYomitanScanToken(token: {
|
||||
};
|
||||
}
|
||||
|
||||
// parseText segmentation is authoritative (it emits filler chunks for text the
|
||||
// termsFind scanner skips), but only the termsFind scanner carries annotation
|
||||
// metadata (isNameMatch, frequencyRank, headwordReading, wordClasses). Graft
|
||||
// scanner tokens onto the parseText segmentation per matching span so one
|
||||
// unmatched chunk degrades only itself instead of dropping the whole line's
|
||||
// metadata.
|
||||
//
|
||||
// Exception: character-name tokens. The greedy name scan can re-segment text
|
||||
// around a name (e.g. とヨータ → と + ヨータ instead of とヨー + タ), so
|
||||
// parseText segmentation cannot be authoritative there. Each name span is
|
||||
// expanded until it aligns with token boundaries in both segmentations, then
|
||||
// the parse tokens inside are replaced with the scanner tokens.
|
||||
function mergeScannerTokensIntoParseTokens(
|
||||
parseScanTokens: YomitanScanToken[],
|
||||
scannerTokens: YomitanScanToken[],
|
||||
): YomitanScanToken[] {
|
||||
const scannerTokensBySpan = new Map<string, YomitanScanToken>();
|
||||
for (const token of scannerTokens) {
|
||||
scannerTokensBySpan.set(scanTokenSpanKey(token), token);
|
||||
}
|
||||
const graftedTokens = parseScanTokens.map(
|
||||
(token) => scannerTokensBySpan.get(scanTokenSpanKey(token)) ?? token,
|
||||
);
|
||||
|
||||
const nameTokens = scannerTokens.filter((token) => token.isNameMatch === true);
|
||||
if (nameTokens.length === 0) {
|
||||
return graftedTokens;
|
||||
}
|
||||
|
||||
const regions = nameTokens.map((token) => ({ start: token.startPos, end: token.endPos }));
|
||||
const allTokens = [...parseScanTokens, ...scannerTokens];
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const region of regions) {
|
||||
for (const token of allTokens) {
|
||||
const overlaps = token.startPos < region.end && token.endPos > region.start;
|
||||
const extendsBeyond = token.startPos < region.start || token.endPos > region.end;
|
||||
if (overlaps && extendsBeyond) {
|
||||
region.start = Math.min(region.start, token.startPos);
|
||||
region.end = Math.max(region.end, token.endPos);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isInsideNameRegion = (token: YomitanScanToken): boolean =>
|
||||
regions.some((region) => token.startPos >= region.start && token.endPos <= region.end);
|
||||
|
||||
const merged = graftedTokens.filter((token) => !isInsideNameRegion(token));
|
||||
for (const token of scannerTokens) {
|
||||
if (isInsideNameRegion(token)) {
|
||||
merged.push(token);
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.startPos - b.startPos || a.endPos - b.endPos);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||
return `${term}\u0000${reading ?? ''}`;
|
||||
}
|
||||
@@ -208,6 +152,7 @@ function getWindowFrequencyCache(window: BrowserWindow): Map<string, YomitanTerm
|
||||
function clearWindowCaches(window: BrowserWindow): void {
|
||||
yomitanProfileMetadataByWindow.delete(window);
|
||||
yomitanFrequencyCacheByWindow.delete(window);
|
||||
yomitanScanCacheEpochByWindow.set(window, getYomitanScanCacheEpoch(window) + 1);
|
||||
}
|
||||
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
||||
clearWindowCaches(window);
|
||||
@@ -704,6 +649,10 @@ async function ensureYomitanParserWindow(
|
||||
if (readyPromise) {
|
||||
await readyPromise;
|
||||
}
|
||||
// Eagerly install the scan runtime so the first subtitle line does not
|
||||
// pay the install round trip; failures fall back to the per-request
|
||||
// install-and-retry path.
|
||||
await installYomitanScanRuntime(parserWindow).catch(() => {});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -1362,58 +1311,144 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
}
|
||||
`;
|
||||
|
||||
function buildYomitanScanningScript(
|
||||
text: string,
|
||||
profileIndex: number,
|
||||
scanLength: number,
|
||||
includeNameMatchMetadata: boolean,
|
||||
greedyNameScanEnabled: boolean,
|
||||
currentCharacterDictionaryMediaId: number | null,
|
||||
dictionaryPriorityByName: Record<string, number>,
|
||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
|
||||
): string {
|
||||
return `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
// Bump whenever the install script below changes so already-loaded parser
|
||||
// windows re-install the new scan runtime instead of running the stale one.
|
||||
const YOMITAN_SCAN_RUNTIME_VERSION = 1;
|
||||
const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||||
|
||||
interface YomitanScanRequestParams {
|
||||
text: string;
|
||||
profileIndex: number;
|
||||
scanLength: number;
|
||||
includeNameMatchMetadata: boolean;
|
||||
greedyNameScanEnabled: boolean;
|
||||
currentCharacterDictionaryMediaId: number | null;
|
||||
dictionaryPriorityByName: Record<string, number>;
|
||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
||||
cacheEpoch: number;
|
||||
}
|
||||
|
||||
// Installed once per parser window (and re-installed after in-page reloads):
|
||||
// keeps V8 from re-parsing the helper bundle on every subtitle line, and hosts
|
||||
// the cross-line termsFind cache. Each subtitle line then only evaluates a tiny
|
||||
// call into globalThis.__subminerYomitanScan.
|
||||
const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||||
(() => {
|
||||
if (globalThis.__subminerYomitanScanVersion === ${YOMITAN_SCAN_RUNTIME_VERSION}) {
|
||||
return true;
|
||||
}
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
// Cross-line termsFind LRU keyed by profile + substring: subtitle lines
|
||||
// repeat particles and inflections constantly, so most lookups hit here.
|
||||
// Entries hold in-flight promises so concurrent identical lookups dedupe.
|
||||
const termsFindCache = new Map();
|
||||
const TERMS_FIND_CACHE_LIMIT = 2000;
|
||||
let termsFindCacheEpoch = -1;
|
||||
const MAX_SHRINKING_WINDOW_RETRY_LOOKUPS = 4;
|
||||
globalThis.__subminerYomitanScanVersion = ${YOMITAN_SCAN_RUNTIME_VERSION};
|
||||
globalThis.__subminerYomitanScan = async (scanParams) => {
|
||||
const {
|
||||
text,
|
||||
profileIndex,
|
||||
scanLength,
|
||||
includeNameMatchMetadata,
|
||||
greedyNameScanEnabled,
|
||||
currentCharacterDictionaryMediaId,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName,
|
||||
cacheEpoch
|
||||
} = scanParams;
|
||||
if (cacheEpoch !== termsFindCacheEpoch) {
|
||||
termsFindCache.clear();
|
||||
termsFindCacheEpoch = cacheEpoch;
|
||||
}
|
||||
${YOMITAN_SCANNING_HELPERS}
|
||||
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
||||
const greedyNameScanEnabled = ${greedyNameScanEnabled ? 'true' : 'false'};
|
||||
const currentCharacterDictionaryMediaId = ${
|
||||
currentCharacterDictionaryMediaId !== null
|
||||
? String(currentCharacterDictionaryMediaId)
|
||||
: 'null'
|
||||
};
|
||||
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
|
||||
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
|
||||
const text = ${JSON.stringify(text)};
|
||||
const CAPTION_OPENING_BRACKETS = new Set(["(", "(", "[", "[", "{", "{", "「", "『", "【", "〈", "《", "≪", "<", "<"]);
|
||||
function shouldEmitUnparsedRunAsToken(runText) {
|
||||
if (!/[\p{L}\p{N}]/u.test(runText)) { return false; }
|
||||
const firstChar = Array.from(runText.trim())[0];
|
||||
return firstChar !== undefined && !CAPTION_OPENING_BRACKETS.has(firstChar);
|
||||
}
|
||||
function isLookupWorthyCodePoint(codePoint) {
|
||||
if (isCodePointJapanese(codePoint)) { return true; }
|
||||
return /[\p{L}\p{N}]/u.test(String.fromCodePoint(codePoint));
|
||||
}
|
||||
function isKanaOnlyRunText(runText) {
|
||||
const chars = Array.from(runText);
|
||||
return chars.length > 0 && chars.every((char) => isCodePointKana(char.codePointAt(0)));
|
||||
}
|
||||
const details = {matchType: "exact", deinflect: true};
|
||||
const tokens = [];
|
||||
const termsFindCache = new Map();
|
||||
async function termsFindAt(position, windowLength) {
|
||||
const cacheKey = position + ":" + windowLength;
|
||||
const cached = termsFindCache.get(cacheKey);
|
||||
if (cached) { return cached; }
|
||||
const substring = text.substring(position, position + windowLength);
|
||||
const result = await invoke("termsFind", { text: substring, details, optionsContext: { index: ${profileIndex} } });
|
||||
termsFindCache.set(cacheKey, result);
|
||||
return result;
|
||||
const cacheKey = profileIndex + " | ||||