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:
@@ -0,0 +1,10 @@
|
|||||||
|
type: changed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Subtitle tokenization no longer runs a duplicate full `parseText` pass per line: the termsFind scanner walk is now the only tokenizer and emits its own hoverable filler runs for unmatched text (parseText is kept only as an error fallback). This roughly halves the dictionary work per line.
|
||||||
|
- The Yomitan scanning helpers are now installed once per parser window (`__subminerYomitanScan`) instead of re-shipping and re-parsing a ~500-line script for every subtitle line; each line only evaluates a tiny call.
|
||||||
|
- termsFind lookups are cached across subtitle lines in a window-persistent LRU keyed by substring, so repeated particles and verb forms stop costing backend round trips. The cache invalidates on dictionary/settings changes and window reloads.
|
||||||
|
- The scanner walk now skips lookups at punctuation and whitespace positions (latin letters and digits still look up, e.g. Tシャツ) and caps the shrinking-window retry ladder at four extra lookups per position.
|
||||||
|
- Tokenizer runtime dependencies are built once instead of per line, fixing a JLPT lookup cache that never hit (it was keyed on a per-call closure identity and leaked a Map per line) and a `which mecab` availability check that re-ran synchronously on every line when MeCab is absent.
|
||||||
|
- Subtitle changes no longer restart the prefetch run per line (which discarded in-flight tokenization work); prefetch now only pauses for the live line and restarts on real seeks, cache invalidation, or option changes. Prefetch also stays paused across a provisional raw-subtitle emit and resumes only after the tokenized payload lands, so it never competes with the on-screen line for the parser window.
|
||||||
|
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||||
@@ -2934,44 +2934,12 @@ test('tokenizeSubtitle preserves Yomitan compound token when MeCab components ar
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (script.includes('parseText')) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: 'scanning-parser',
|
|
||||||
index: 0,
|
|
||||||
content: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: '取り組んで',
|
|
||||||
reading: 'とりくんで',
|
|
||||||
headwords: [[{ term: '取り組む' }]],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: 'もらいます',
|
|
||||||
reading: 'もらいます',
|
|
||||||
headwords: [[{ term: 'もらう' }]],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
surface: '取り',
|
surface: '取り組んで',
|
||||||
reading: 'とり',
|
reading: 'とりくんで',
|
||||||
headword: '取る',
|
headword: '取り組む',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
endPos: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
surface: '組んで',
|
|
||||||
reading: 'くんで',
|
|
||||||
headword: '組む',
|
|
||||||
startPos: 2,
|
|
||||||
endPos: 5,
|
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(
|
async function parseWithYomitanInternalParser(
|
||||||
text: string,
|
text: string,
|
||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
options: TokenizerAnnotationOptions,
|
options: TokenizerAnnotationOptions,
|
||||||
|
stageTimings?: TokenizationStageTimings,
|
||||||
): Promise<MergedToken[] | null> {
|
): Promise<MergedToken[] | null> {
|
||||||
|
const scanStartedAtMs = Date.now();
|
||||||
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
||||||
includeNameMatchMetadata: options.nameMatchEnabled,
|
includeNameMatchMetadata: options.nameMatchEnabled,
|
||||||
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
||||||
});
|
});
|
||||||
|
if (stageTimings) {
|
||||||
|
stageTimings.scanMs = Date.now() - scanStartedAtMs;
|
||||||
|
}
|
||||||
if (!selectedTokens || selectedTokens.length === 0) {
|
if (!selectedTokens || selectedTokens.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -757,6 +771,7 @@ async function parseWithYomitanInternalParser(
|
|||||||
|
|
||||||
const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
|
const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
|
||||||
? (async () => {
|
? (async () => {
|
||||||
|
const frequencyStartedAtMs = Date.now();
|
||||||
const frequencyMatchMode = options.frequencyMatchMode;
|
const frequencyMatchMode = options.frequencyMatchMode;
|
||||||
const termReadingList = buildYomitanFrequencyTermReadingList(
|
const termReadingList = buildYomitanFrequencyTermReadingList(
|
||||||
normalizedSelectedTokens,
|
normalizedSelectedTokens,
|
||||||
@@ -767,12 +782,17 @@ async function parseWithYomitanInternalParser(
|
|||||||
deps,
|
deps,
|
||||||
logger,
|
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() });
|
: Promise.resolve({ byPair: new Map(), byTerm: new Map() });
|
||||||
|
|
||||||
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
||||||
? (async () => {
|
? (async () => {
|
||||||
|
const mecabStartedAtMs = Date.now();
|
||||||
try {
|
try {
|
||||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||||
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
||||||
@@ -786,6 +806,10 @@ async function parseWithYomitanInternalParser(
|
|||||||
`textLength=${text.length}`,
|
`textLength=${text.length}`,
|
||||||
);
|
);
|
||||||
return normalizedSelectedTokens;
|
return normalizedSelectedTokens;
|
||||||
|
} finally {
|
||||||
|
if (stageTimings) {
|
||||||
|
stageTimings.mecabMs = Date.now() - mecabStartedAtMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
: Promise.resolve(normalizedSelectedTokens);
|
: Promise.resolve(normalizedSelectedTokens);
|
||||||
@@ -876,15 +900,35 @@ export async function tokenizeSubtitle(
|
|||||||
const annotationOptions = getAnnotationOptions(deps);
|
const annotationOptions = getAnnotationOptions(deps);
|
||||||
annotationOptions.sourceText = tokenizeText;
|
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) {
|
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||||
|
const annotateStartedAtMs = Date.now();
|
||||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||||
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
||||||
|
stageTimings.annotateMs = Date.now() - annotateStartedAtMs;
|
||||||
|
logStageTimings(renderedTokens.length);
|
||||||
return {
|
return {
|
||||||
text: displayText,
|
text: displayText,
|
||||||
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logStageTimings(0);
|
||||||
return { text: displayText, tokens: null };
|
return { text: displayText, tokens: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,8 +366,11 @@ export function createReplayMessageStore(messages: GoldenRecordedMessage[]): Rep
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runInjectedScriptInVm(script: string, store: ReplayMessageStore): Promise<unknown> {
|
// One persistent context per fixture, matching the real parser window: the
|
||||||
return await vm.runInNewContext(script, {
|
// 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: {
|
chrome: {
|
||||||
runtime: {
|
runtime: {
|
||||||
lastError: null,
|
lastError: null,
|
||||||
@@ -393,6 +396,7 @@ async function runInjectedScriptInVm(script: string, store: ReplayMessageStore):
|
|||||||
Set,
|
Set,
|
||||||
String,
|
String,
|
||||||
});
|
});
|
||||||
|
return async (script: string) => await vm.runInContext(script, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
|
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
|
||||||
@@ -400,13 +404,14 @@ export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServ
|
|||||||
const scriptResults = new Map(
|
const scriptResults = new Map(
|
||||||
fixture.recording.scripts.map((entry) => [entry.sha256, entry] as const),
|
fixture.recording.scripts.map((entry) => [entry.sha256, entry] as const),
|
||||||
);
|
);
|
||||||
|
const runInjectedScriptInVm = createInjectedScriptVm(store);
|
||||||
|
|
||||||
const parserWindow = {
|
const parserWindow = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
webContents: {
|
webContents: {
|
||||||
executeJavaScript: async (script: string) => {
|
executeJavaScript: async (script: string) => {
|
||||||
try {
|
try {
|
||||||
return await runInjectedScriptInVm(script, store);
|
return await runInjectedScriptInVm(script);
|
||||||
} catch (vmError) {
|
} catch (vmError) {
|
||||||
const recorded = scriptResults.get(hashInjectedScript(script));
|
const recorded = scriptResults.get(hashInjectedScript(script));
|
||||||
if (recorded) {
|
if (recorded) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,13 @@ const yomitanFrequencyCacheByWindow = new WeakMap<
|
|||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
Map<string, YomitanTermFrequency[]>
|
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> {
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value && typeof value === 'object');
|
return Boolean(value && typeof value === 'object');
|
||||||
@@ -99,6 +106,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
|||||||
typeof entry.startPos === 'number' &&
|
typeof entry.startPos === 'number' &&
|
||||||
typeof entry.endPos === 'number' &&
|
typeof entry.endPos === 'number' &&
|
||||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||||
|
(entry.isUnparsedRun === undefined || typeof entry.isUnparsedRun === 'boolean') &&
|
||||||
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
||||||
(entry.wordClasses === undefined ||
|
(entry.wordClasses === undefined ||
|
||||||
(Array.isArray(entry.wordClasses) &&
|
(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
|
// Maps a parse-selected token to the scanner-token shape carried out of the
|
||||||
// parser runtime. Shared by both selectYomitanParseTokens fallback paths so the
|
// parser runtime, used by the parseText fallback path when the in-window
|
||||||
// projected fields stay in sync as the shape changes.
|
// scanner is unavailable.
|
||||||
function toYomitanScanToken(token: {
|
function toYomitanScanToken(token: {
|
||||||
surface: string;
|
surface: string;
|
||||||
reading: 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 {
|
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||||
return `${term}\u0000${reading ?? ''}`;
|
return `${term}\u0000${reading ?? ''}`;
|
||||||
}
|
}
|
||||||
@@ -208,6 +152,7 @@ function getWindowFrequencyCache(window: BrowserWindow): Map<string, YomitanTerm
|
|||||||
function clearWindowCaches(window: BrowserWindow): void {
|
function clearWindowCaches(window: BrowserWindow): void {
|
||||||
yomitanProfileMetadataByWindow.delete(window);
|
yomitanProfileMetadataByWindow.delete(window);
|
||||||
yomitanFrequencyCacheByWindow.delete(window);
|
yomitanFrequencyCacheByWindow.delete(window);
|
||||||
|
yomitanScanCacheEpochByWindow.set(window, getYomitanScanCacheEpoch(window) + 1);
|
||||||
}
|
}
|
||||||
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
||||||
clearWindowCaches(window);
|
clearWindowCaches(window);
|
||||||
@@ -704,6 +649,10 @@ async function ensureYomitanParserWindow(
|
|||||||
if (readyPromise) {
|
if (readyPromise) {
|
||||||
await 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;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1362,58 +1311,144 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function buildYomitanScanningScript(
|
// Bump whenever the install script below changes so already-loaded parser
|
||||||
text: string,
|
// windows re-install the new scan runtime instead of running the stale one.
|
||||||
profileIndex: number,
|
const YOMITAN_SCAN_RUNTIME_VERSION = 1;
|
||||||
scanLength: number,
|
const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||||||
includeNameMatchMetadata: boolean,
|
|
||||||
greedyNameScanEnabled: boolean,
|
interface YomitanScanRequestParams {
|
||||||
currentCharacterDictionaryMediaId: number | null,
|
text: string;
|
||||||
dictionaryPriorityByName: Record<string, number>,
|
profileIndex: number;
|
||||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
|
scanLength: number;
|
||||||
): string {
|
includeNameMatchMetadata: boolean;
|
||||||
return `
|
greedyNameScanEnabled: boolean;
|
||||||
(async () => {
|
currentCharacterDictionaryMediaId: number | null;
|
||||||
const invoke = (action, params) =>
|
dictionaryPriorityByName: Record<string, number>;
|
||||||
new Promise((resolve, reject) => {
|
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
||||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
cacheEpoch: number;
|
||||||
if (chrome.runtime.lastError) {
|
}
|
||||||
reject(new Error(chrome.runtime.lastError.message));
|
|
||||||
return;
|
// 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
|
||||||
if (!response || typeof response !== "object") {
|
// the cross-line termsFind cache. Each subtitle line then only evaluates a tiny
|
||||||
reject(new Error("Invalid response from Yomitan backend"));
|
// call into globalThis.__subminerYomitanScan.
|
||||||
return;
|
const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||||||
}
|
(() => {
|
||||||
if (response.error) {
|
if (globalThis.__subminerYomitanScanVersion === ${YOMITAN_SCAN_RUNTIME_VERSION}) {
|
||||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
return true;
|
||||||
return;
|
}
|
||||||
}
|
const invoke = (action, params) =>
|
||||||
resolve(response.result);
|
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}
|
${YOMITAN_SCANNING_HELPERS}
|
||||||
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
const CAPTION_OPENING_BRACKETS = new Set(["(", "(", "[", "[", "{", "{", "「", "『", "【", "〈", "《", "≪", "<", "<"]);
|
||||||
const greedyNameScanEnabled = ${greedyNameScanEnabled ? 'true' : 'false'};
|
function shouldEmitUnparsedRunAsToken(runText) {
|
||||||
const currentCharacterDictionaryMediaId = ${
|
if (!/[\p{L}\p{N}]/u.test(runText)) { return false; }
|
||||||
currentCharacterDictionaryMediaId !== null
|
const firstChar = Array.from(runText.trim())[0];
|
||||||
? String(currentCharacterDictionaryMediaId)
|
return firstChar !== undefined && !CAPTION_OPENING_BRACKETS.has(firstChar);
|
||||||
: 'null'
|
}
|
||||||
};
|
function isLookupWorthyCodePoint(codePoint) {
|
||||||
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
|
if (isCodePointJapanese(codePoint)) { return true; }
|
||||||
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
|
return /[\p{L}\p{N}]/u.test(String.fromCodePoint(codePoint));
|
||||||
const text = ${JSON.stringify(text)};
|
}
|
||||||
|
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 details = {matchType: "exact", deinflect: true};
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
const termsFindCache = new Map();
|
|
||||||
async function termsFindAt(position, windowLength) {
|
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 substring = text.substring(position, position + windowLength);
|
||||||
const result = await invoke("termsFind", { text: substring, details, optionsContext: { index: ${profileIndex} } });
|
const cacheKey = profileIndex + " | ||||||