perf(tokenizer): single-pass Yomitan scan with cross-line caching and prefetch fixes (#185)

This commit is contained in:
2026-08-06 21:44:09 -07:00
committed by GitHub
parent 441ecf3c04
commit dbdf578c68
38 changed files with 4073 additions and 1720 deletions
@@ -1,7 +1,7 @@
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export const ANILIST_REQUEST_DELAY_MS = 2000;
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 19;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 20;
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
export const HONORIFIC_SUFFIXES = [
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import { createCharacterNameCandidateLookup } from './name-candidates';
function writeSnapshot(outputDir: string, mediaId: number, entries: Array<[string, string]>): void {
const snapshotsDir = path.join(outputDir, 'snapshots');
fs.mkdirSync(snapshotsDir, { recursive: true });
fs.writeFileSync(
path.join(snapshotsDir, `anilist-${mediaId}.json`),
JSON.stringify({
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId,
mediaTitle: `title-${mediaId}`,
entryCount: entries.length,
updatedAt: 1,
termEntries: entries.map(([term, reading]) => [
term,
reading,
'name main',
'',
100,
[],
0,
'',
]),
images: [],
}),
);
}
function withTempDir<T>(run: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-name-candidates-'));
try {
return run(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('collects terms and readings for the current media', () => {
withTempDir((dir) => {
writeSnapshot(dir, 1, [
['ミナト', 'みなと'],
['湊', 'みなと'],
]);
writeSnapshot(dir, 2, [['カズマ', 'かずま']]);
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => 1,
});
const candidates = lookup.get();
assert.ok(candidates);
assert.deepEqual([...candidates.forms].sort(), ['みなと', 'ミナト', '湊'].sort());
// Deduplicated: both entries share the みなと reading.
assert.equal(candidates.forms.length, 3);
});
});
test('returns null without a media scope so the scanner stays exhaustive', () => {
withTempDir((dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => null,
});
assert.equal(lookup.get(), null);
});
});
test('returns null for a media with no cached snapshot', () => {
withTempDir((dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => 999,
});
assert.equal(lookup.get(), null);
});
});
test('key changes when the snapshot content changes', () => {
withTempDir((dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => 1,
});
const first = lookup.get();
writeSnapshot(dir, 1, [
['ミナト', 'みなと'],
['アクア', 'あくあ'],
]);
lookup.invalidate();
const second = lookup.get();
assert.ok(first && second);
assert.notEqual(first.key, second.key);
assert.equal(second.forms.length, 4);
});
});
// The lookup runs once per subtitle line, so it must not stat the snapshot
// directory every call. Asserted behaviorally: an unannounced on-disk change is
// invisible until the recheck interval elapses, which can only be true if the
// filesystem is not consulted per lookup.
test('does not re-read the snapshot directory on every lookup', () => {
withTempDir((dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
let nowMs = 1_000_000;
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => 1,
now: () => nowMs,
});
assert.equal(lookup.get()?.forms.length, 2);
writeSnapshot(dir, 1, [
['ミナト', 'みなと'],
['アクア', 'あくあ'],
]);
nowMs += 1000;
assert.equal(lookup.get()?.forms.length, 2, 'expected the cached list within the interval');
nowMs += 10_000;
assert.equal(lookup.get()?.forms.length, 4, 'expected a refresh past the interval');
});
});
test('invalidate picks up a snapshot change immediately', () => {
withTempDir((dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
let nowMs = 1_000_000;
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => 1,
now: () => nowMs,
});
assert.equal(lookup.get()?.forms.length, 2);
writeSnapshot(dir, 1, [
['ミナト', 'みなと'],
['アクア', 'あくあ'],
]);
nowMs += 1;
lookup.invalidate();
assert.equal(lookup.get()?.forms.length, 4);
});
});
@@ -0,0 +1,159 @@
import * as fs from 'fs';
import * as path from 'path';
import { readCachedSnapshots } from './cache';
import type { CharacterDictionarySnapshot } from './types';
// Candidate name forms for the greedy name pre-pass in the Yomitan scan
// runtime. The scanner otherwise has to ask the backend at every Japanese
// position, because a character name can start mid-token; knowing which forms
// exist lets it look up only where a name can actually begin.
//
// A form is any string Yomitan could match a character entry by: the term and
// its reading. Both come from the dictionary SubMiner generated, so the pair is
// the complete matchable set for an entry. Callers treat a missing list as
// "scan every position", so a stale or absent snapshot costs speed, never a
// missed name.
function getSnapshotsDir(outputDir: string): string {
return path.join(outputDir, 'snapshots');
}
function collectSnapshotNameForms(snapshot: CharacterDictionarySnapshot): string[] {
const forms = new Set<string>();
for (const entry of snapshot.termEntries) {
const term = typeof entry[0] === 'string' ? entry[0].trim() : '';
if (term) {
forms.add(term);
}
const reading = typeof entry[1] === 'string' ? entry[1].trim() : '';
if (reading) {
forms.add(reading);
}
}
return [...forms];
}
// The signature grows with the size of the dictionary library, and it rides
// along in every per-line scan call, so it is folded into a fixed-width digest
// first. Collisions only matter against the immediately previous signature (the
// runtime compares keys for equality), and FNV-1a over the file list is far
// beyond what that needs.
function digestSnapshotDirectorySignature(signature: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < signature.length; index += 1) {
hash ^= signature.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function getSnapshotDirectorySignature(outputDir: string): string {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return '';
}
const parts: string[] = [];
for (const entry of entries) {
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
continue;
}
try {
const stat = fs.statSync(path.join(getSnapshotsDir(outputDir), entry.name));
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
} catch {
// Ignore files that disappear during a refresh; the next lookup rebuilds.
}
}
return parts.sort().join('|');
}
export interface CharacterNameCandidateSet {
/** Identifies this exact form list, so the scan runtime can cache it. */
key: string;
forms: string[];
}
// This lookup is consulted once per subtitle line, so it must not stat the
// snapshot directory every time. Dictionary writes are rare and always call
// invalidate(), which forces the next lookup to re-read; the interval only
// bounds staleness from changes made behind our back.
const SNAPSHOT_SIGNATURE_RECHECK_INTERVAL_MS = 5000;
export function createCharacterNameCandidateLookup(deps: {
userDataPath?: string;
outputDir?: string;
getCurrentMediaId?: () => number | null | undefined;
now?: () => number;
}): {
get: (mediaId?: number | null) => CharacterNameCandidateSet | null;
invalidate: () => void;
} {
const outputDir =
deps.outputDir ??
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
const now = deps.now ?? (() => Date.now());
let signature: string | null = null;
let lastSignatureCheckAtMs = 0;
let formsByMediaId = new Map<number, string[]>();
function refreshIfNeeded(): void {
if (!outputDir) {
formsByMediaId = new Map<number, string[]>();
signature = '';
return;
}
const nowMs = now();
if (
signature !== null &&
nowMs - lastSignatureCheckAtMs < SNAPSHOT_SIGNATURE_RECHECK_INTERVAL_MS
) {
return;
}
lastSignatureCheckAtMs = nowMs;
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature) {
return;
}
signature = nextSignature;
formsByMediaId = new Map<number, string[]>();
for (const snapshot of readCachedSnapshots(outputDir)) {
const forms = collectSnapshotNameForms(snapshot);
if (forms.length > 0) {
formsByMediaId.set(snapshot.mediaId, forms);
}
}
}
return {
get(mediaId?: number | null): CharacterNameCandidateSet | null {
refreshIfNeeded();
const rawMediaId = mediaId ?? deps.getCurrentMediaId?.() ?? null;
const normalizedMediaId =
typeof rawMediaId === 'number' && Number.isFinite(rawMediaId) && rawMediaId > 0
? Math.floor(rawMediaId)
: null;
// Without a media scope the pre-pass would need every character of every
// cached title, which is both slow to match and pointless: report no
// candidates so the scanner keeps its exhaustive behavior.
if (normalizedMediaId === null) {
return null;
}
const forms = formsByMediaId.get(normalizedMediaId);
if (!forms || forms.length === 0) {
return null;
}
return {
key: `${digestSnapshotDirectorySignature(signature ?? '')}:${normalizedMediaId}`,
forms,
};
},
invalidate(): void {
signature = null;
lastSignatureCheckAtMs = 0;
},
};
}
@@ -1,3 +1,4 @@
import { isHanCodePoint } from '../../core/text/han-code-points';
import { HONORIFIC_SUFFIXES } from './constants';
import type { JapaneseNameParts, NameReadings, ResolvedNameSplits } from './types';
@@ -26,10 +27,12 @@ export function buildReading(term: string): string {
return katakanaToHiragana(compact);
}
// Code points, not code units: a supplementary-plane kanji (𠮷, U+20BB7) is a
// surrogate pair, and reading only the high surrogate would classify a real
// single-character name as non-kanji and drop it.
export function containsKanji(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) {
if (isHanCodePoint(char.codePointAt(0) ?? 0)) {
return true;
}
}
@@ -36,3 +36,134 @@ test('buildNameTerms adds surname honorifics from Japanese localized aliases', (
assert.ok(terms.includes('馬渕さん'));
assert.ok(!terms.includes('송치'));
});
test('buildNameTerms drops the disambiguator letter of a mob character name', () => {
const terms = buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'Joshi A',
nativeName: '女子A',
}),
);
// ア would match every あ〜 in the subtitles; the letter is a disambiguator
// (Girl A / Girl B), not a name.
assert.ok(!terms.includes('ア'));
assert.ok(!terms.includes('アさん'));
assert.ok(terms.includes('女子A'));
assert.ok(terms.includes('ジョシア'));
});
test('buildNameTerms keeps a character whose whole name is one kana', () => {
const terms = buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'A',
nativeName: 'あ',
}),
);
// The mob-label rule only judges parts a name was split into; a name the
// source gives us whole is the character's actual name.
assert.ok(terms.includes('あ'));
assert.ok(terms.includes('あさん'));
// Romanized forms are never lookup targets (the subtitles are Japanese), and
// the single-kana alias "A" transliterates to is dropped as a collision.
assert.ok(!terms.includes('A'));
assert.ok(!terms.includes('ア'));
});
test('buildNameTerms keeps a one-character name written in another script', () => {
const terms = buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'Byeol',
nativeName: '별',
alternativeNames: ['Я'],
}),
);
assert.ok(terms.includes('별'));
assert.ok(terms.includes('별さん'));
assert.ok(terms.includes('Я'));
});
test('buildNameTerms yields nothing for a character whose only name is a bare letter', () => {
// Documented policy rather than an oversight: a romanized name is never a
// term on its own (the subtitles are Japanese), and the single kana a bare
// letter transliterates to would match every あ〜 in the line.
assert.deepEqual(
buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'A',
nativeName: '',
}),
),
[],
);
});
test('buildNameTerms keeps one-character split parts that are not mob labels', () => {
const hangul = buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'Byeol Kim',
nativeName: '별 김',
}),
);
assert.ok(hangul.includes('별'));
assert.ok(hangul.includes('김'));
const middleDot = buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'A Be',
nativeName: 'ア・ベ',
}),
);
assert.ok(middleDot.includes('ア'));
assert.ok(middleDot.includes('ベ'));
});
test('buildNameTerms keeps a single-kanji name part', () => {
// The name is an alias, not the native name, so the parts come from the
// space split rather than from the native-name split.
const terms = buildNameTerms(
characterRecord({
firstNameHint: 'Sora',
lastNameHint: 'Yamada',
fullName: 'Sora Yamada',
nativeName: '',
alternativeNames: ['山田 空'],
}),
);
assert.ok(terms.includes('山田'));
assert.ok(terms.includes('空'));
});
test('buildNameTerms keeps a single supplementary-plane kanji name part', () => {
// 𠮷 (U+20BB7) is a surrogate pair: a code-unit kanji check reads only the
// high surrogate and drops the part as if it were a mob disambiguator.
const terms = buildNameTerms(
characterRecord({
firstNameHint: 'Tsukasa',
lastNameHint: 'Yoshi',
fullName: 'Tsukasa Yoshi',
nativeName: '',
alternativeNames: ['𠮷 司'],
}),
);
assert.ok(terms.includes('𠮷'));
assert.ok(terms.includes('司'));
});
@@ -1,3 +1,4 @@
import { HAN_REGEXP_CLASS_BODY } from '../../core/text/han-code-points';
import { HONORIFIC_SUFFIXES } from './constants';
import {
addRomanizedKanaAliases,
@@ -42,11 +43,29 @@ export function expandRawNameVariants(rawName: string): string[] {
return [...variants];
}
// The label AniList appends to unnamed mob characters: one letter or digit,
// halfwidth or fullwidth (女子A / "Joshi A" / 女子1). Nothing else qualifies —
// a one-character part in any script is a real name part (별 김, ア・ベ, 山田 空).
const SINGLE_LABEL_CHARACTER = /^[0-9A-Za-z\uff10-\uff19\uff21-\uff3a\uff41-\uff5a]$/u;
// Judged on split parts only: a name the source gives us whole in a script the
// subtitles can contain is kept whatever it looks like, because a character
// really can be called あ or 별. (A romanized name is a separate matter: it is
// never a term on its own, only a source of kana aliases. See below.)
function isUsableNameSplitPart(part: string): boolean {
return !SINGLE_LABEL_CHARACTER.test(part);
}
// Kana, Han (shared ranges), and the marks that only ever appear inside a
// Japanese name: iteration marks and the small ka/ke used in place names.
const JAPANESE_NAME_CHARACTERS = new RegExp(
`^[\\u3040-\\u30ff${HAN_REGEXP_CLASS_BODY}\u3005\u3006\u30f5\u30f6\u30fc]+$`,
'u',
);
export function isJapaneseNameSplitCandidate(name: string): boolean {
const compact = name.replace(/[\s\u3000・・·•]/g, '');
return (
containsKanji(compact) && /^[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff々〆ヵヶー]+$/.test(compact)
);
return containsKanji(compact) && JAPANESE_NAME_CHARACTERS.test(compact);
}
function addJapaneseNameParts(
@@ -97,8 +116,11 @@ export function buildNameTerms(
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) {
target.add(split[0]!);
target.add(split[1]!);
for (const part of split) {
if (isUsableNameSplitPart(part)) {
target.add(part);
}
}
}
const splitByMiddleDot = name
@@ -107,7 +129,9 @@ export function buildNameTerms(
.filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) {
target.add(part);
if (isUsableNameSplitPart(part)) {
target.add(part);
}
}
}
@@ -117,7 +141,15 @@ export function buildNameTerms(
}
}
// Romanized names never become terms themselves — the subtitles are Japanese,
// so "Joshi A" would never appear in one — they only contribute the kana a
// Japanese writer would spell them with.
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
// Except when the whole name is one letter: it transliterates to a single
// kana (A → ア) that matches every あ〜 in the subtitles. A character whose
// only recorded name is a bare letter therefore yields no terms at all,
// which is the intended outcome: those are unnamed mob characters.
if ([...alias].length === 1) continue;
base.add(alias);
}
+34 -11
View File
@@ -223,7 +223,7 @@ test('update overlay notification action triggers install flow', () => {
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
});
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
@@ -231,15 +231,19 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
assert.ok(actionBlock);
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
// Restarting the run per line (onSeek) discards in-flight prefetch work;
// only real seeks restart via onTimePosUpdate.
assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\)/);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);'),
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text)'),
);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
// A repeated subtitle emits nothing, so the pause has to be released here or
// prefetching idles until the next distinct line.
assert.match(
actionBlock,
/if \(!subtitleProcessingController\.onSubtitleChange\(text\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
);
});
@@ -489,16 +493,35 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(
actionBlock,
/subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\);/,
/if \(!subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
);
assert.ok(
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
actionBlock.indexOf(
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);',
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)',
),
);
});
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
const source = readMainSource();
const depsBlock = source.match(
/createBuildSubtitleProcessingControllerMainDepsHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
assert.ok(depsBlock);
// A controller emit can be the provisional plain payload sent before the
// scan runs, so it must not release the prefetch pause.
assert.match(
depsBlock,
/emitSubtitle: \(payload\) => emitSubtitlePayload\(payload, \{ resumePrefetch: false \}\),/,
);
assert.match(
depsBlock,
/onProcessingSettled: \(\) => \{\s+subtitlePrefetchService\?\.resume\(\);/,
);
});
test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource();
const setBlock = source.match(
@@ -593,7 +616,7 @@ test('YouTube media cache lifecycle routes through configured status notificatio
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
const source = readMainSource();
const emitBlock = source.match(
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const frequencyOptionsSnapshot = emitBlock?.match(
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
@@ -616,7 +639,7 @@ test('subtitle broadcasts share one frequency options snapshot per emitted paylo
test('annotation upgrades skip the duplicate basic websocket event', () => {
const source = readMainSource();
const emitBlock = source.match(
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(emitBlock);
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
import type { SubtitleData } from '../../types';
import {
createAutoplaySubtitlePrimingRuntime,
setMpvCurrentSecondarySubText,
@@ -42,8 +44,9 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: () => {},
refreshCurrentSubtitle: () => {},
onSubtitleChange: () => true,
refreshCurrentSubtitle: () => true,
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: () => {},
getSubtitlePrefetchService: () => null,
@@ -93,13 +96,25 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
onSubtitleChange: (text) => {
calls.push(`change:${text}`);
return true;
},
refreshCurrentSubtitle: (text) => {
calls.push(`refresh:${text ?? ''}`);
return true;
},
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
emitSubtitlePayload: (payload, options) =>
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
getSubtitlePrefetchService: () => ({
pause: () => calls.push('prefetch:pause'),
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
pause: () => {
calls.push('prefetch:pause');
},
resume: () => {
calls.push('prefetch:resume');
},
}),
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
@@ -120,8 +135,10 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
'request:time-pos',
'set:起動字幕',
'prefetch:pause',
'emit:起動字幕',
'change:起動字幕',
'emit:起動字幕:resume=false',
// Uncached priming refreshes rather than announcing a change, so an
// invalidated-but-unchanged line is still re-tokenized.
'refresh:起動字幕',
]);
});
@@ -151,13 +168,25 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
onSubtitleChange: (text) => {
calls.push(`change:${text}`);
return true;
},
refreshCurrentSubtitle: (text) => {
calls.push(`refresh:${text ?? ''}`);
return true;
},
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
emitSubtitlePayload: (payload, options) =>
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
getSubtitlePrefetchService: () => ({
pause: () => calls.push('prefetch:pause'),
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
pause: () => {
calls.push('prefetch:pause');
},
resume: () => {
calls.push('prefetch:resume');
},
}),
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
@@ -175,7 +204,228 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
'request:sub-text',
'set:起動字幕',
'prefetch:pause',
'emit:起動字幕',
'change:起動字幕',
'emit:起動字幕:resume=false',
// Uncached priming refreshes rather than announcing a change, so an
// invalidated-but-unchanged line is still re-tokenized.
'refresh:起動字幕',
]);
});
// Driven by the real processing controller rather than a stub: the failure this
// covers is a disagreement between the priming path and the controller's own
// staleness rules, which a hand-written stub cannot reproduce.
function createPrimingRuntimeWithRealController(options: {
text: string;
calls: string[];
onTokenize: () => void;
tokenize?: (text: string) => SubtitleData | null | Promise<SubtitleData | null>;
cacheLimit?: number;
}) {
const { text, calls } = options;
let currentSubText = '';
let currentSubtitleData: SubtitleData | null = null;
const mediaPath = '/media/video.mkv';
const prefetchService = {
pause: () => calls.push('prefetch:pause'),
resume: () => calls.push('prefetch:resume'),
};
// Mirrors main.ts emitSubtitlePayload: an emit resumes prefetching unless it
// is explicitly marked as not the end of the work for the line, and every
// controller emit is so marked.
const emitSubtitlePayload = (
payload: SubtitleData,
emitOptions?: { resumePrefetch?: boolean },
): void => {
currentSubtitleData = payload;
calls.push(
emitOptions?.resumePrefetch === false
? `emit-raw:${payload.text}`
: `emit-direct:${payload.text}`,
);
if (emitOptions?.resumePrefetch !== false) {
prefetchService.resume();
}
};
const subtitleProcessingController = createSubtitleProcessingController({
tokenizeSubtitle: async (subtitleText) => {
options.onTokenize();
return options.tokenize ? options.tokenize(subtitleText) : { text: subtitleText, tokens: [] };
},
// main.ts routes controller emits through emitSubtitlePayload with
// resumePrefetch: false, so they never release the pause on their own.
emitSubtitle: (payload) => {
currentSubtitleData = payload;
calls.push(`emit:${payload.text}:tokens=${payload.tokens === null ? 'none' : 'yes'}`);
},
onProcessingSettled: () => {
prefetchService.resume();
},
...(options.cacheLimit === undefined ? {} : { cacheLimit: options.cacheLimit }),
});
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => ({
connected: true,
currentVideoPath: mediaPath,
requestProperty: async (name) => (name === 'sub-text' ? text : null),
}),
setCurrentSubText: (value) => {
currentSubText = value;
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => currentSubtitleData,
getActiveParsedSubtitleCues: () => [],
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController,
emitSubtitlePayload,
getSubtitlePrefetchService: () => prefetchService,
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
logDebug: () => {},
});
return { runtime, subtitleProcessingController, mediaPath };
}
test('primeCurrentSubtitleForAutoplay re-tokenizes text whose cached annotation was invalidated', async () => {
const calls: string[] = [];
let tokenizations = 0;
const text = '起動字幕';
const { runtime, subtitleProcessingController, mediaPath } =
createPrimingRuntimeWithRealController({
text,
calls,
onTokenize: () => {
tokenizations += 1;
},
});
// The line was already tokenized and cached during normal playback.
subtitleProcessingController.onSubtitleChange(text);
await new Promise((resolve) => setTimeout(resolve, 0));
const tokenizationsBeforeInvalidation = tokenizations;
// Mining a card drops every cached tokenization.
subtitleProcessingController.invalidateTokenizationCache();
calls.length = 0;
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
await new Promise((resolve) => setTimeout(resolve, 0));
// The cache miss must schedule fresh work, or the line stays unannotated for
// as long as it is on screen.
assert.equal(
tokenizations,
tokenizationsBeforeInvalidation + 1,
'expected the invalidated subtitle to be tokenized again',
);
assert.ok(
calls.includes(`emit:${text}:tokens=yes`),
`expected an annotated emit, saw ${JSON.stringify(calls)}`,
);
});
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when nothing is scheduled', async () => {
const calls: string[] = [];
const text = '起動字幕';
const { runtime, subtitleProcessingController, mediaPath } =
createPrimingRuntimeWithRealController({
text,
calls,
onTokenize: () => {},
cacheLimit: 1,
});
// Emitted at the current cache generation, then evicted from the one-entry
// cache: priming misses the cache but the controller has nothing to redo, so
// no emit is coming and the pause must be released here.
subtitleProcessingController.onSubtitleChange(text);
await new Promise((resolve) => setTimeout(resolve, 0));
subtitleProcessingController.preCacheTokenization('別の字幕', {
text: '別の字幕',
tokens: [],
});
calls.length = 0;
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`, 'prefetch:resume']);
});
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when tokenization emits nothing', async () => {
const calls: string[] = [];
const text = '起動字幕';
const { runtime, subtitleProcessingController, mediaPath } =
createPrimingRuntimeWithRealController({
text,
calls,
onTokenize: () => {},
// Transient tokenizer failure: the controller falls back to plain text it
// has already shown, so it suppresses the emit entirely.
tokenize: () => null,
});
subtitleProcessingController.onSubtitleChange(text);
await new Promise((resolve) => setTimeout(resolve, 0));
subtitleProcessingController.invalidateTokenizationCache();
calls.length = 0;
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(
!calls.some((call) => call.startsWith('emit:')),
`expected no controller emit, saw ${JSON.stringify(calls)}`,
);
assert.equal(
calls.filter((call) => call === 'prefetch:resume').length,
1,
`expected the prefetch pause to be released, saw ${JSON.stringify(calls)}`,
);
});
test('prefetch stays paused until tokenization of an uncached line completes', async () => {
const calls: string[] = [];
const text = '起動字幕';
let finishTokenization = (): void => {};
const tokenizationGate = new Promise<void>((resolve) => {
finishTokenization = resolve;
});
const { runtime, mediaPath } = createPrimingRuntimeWithRealController({
text,
calls,
onTokenize: () => {},
tokenize: async (subtitleText) => {
await tokenizationGate;
return { text: subtitleText, tokens: [] };
},
});
// Driven through the priming path, which is what takes the pause out.
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
await new Promise((resolve) => setTimeout(resolve, 0));
// Neither the priming emit nor the controller's provisional plain emit may
// release the pause: the expensive scan is still ahead of them and would
// compete with prefetching for the parser.
// One plain payload, not two: priming paints it and tells the controller, so
// the controller goes straight for the tokenized one. And it does not resume
// prefetch, because the expensive scan is still ahead of it.
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`]);
finishTokenization();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
'prefetch:pause',
`emit-raw:${text}`,
`emit:${text}:tokens=yes`,
'prefetch:resume',
]);
});
@@ -17,7 +17,7 @@ type AutoplaySubtitlePrimingMpvClient = {
type AutoplaySubtitlePrimingPrefetchService = {
pause: () => void;
onSeek: (timePos: number) => void;
resume: () => void;
};
export interface AutoplaySubtitlePrimingRuntimeDeps {
@@ -30,10 +30,12 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
subtitleProcessingController: {
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (text: string) => void;
// Both report whether processing is pending; see pausePrefetchUntilProcessed.
onSubtitleChange: (text: string) => boolean;
refreshCurrentSubtitle: (text: string) => boolean;
notePlainSubtitleEmitted: (text: string) => void;
};
emitSubtitlePayload: (payload: SubtitleData) => void;
emitSubtitlePayload: (payload: SubtitleData, options?: { resumePrefetch?: boolean }) => void;
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
getLastObservedTimePos: () => number;
getVisibleOverlayVisible: () => boolean;
@@ -64,6 +66,19 @@ export function setMpvCurrentSecondarySubText(
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
const { subtitleProcessingController, emitSubtitlePayload } = deps;
// Prefetching is paused so the on-screen line gets the parser to itself; the
// resume rides on the controller settling (see onProcessingSettled), not on
// an emit, which a suppressed duplicate or a failed tokenization never sends.
// When the controller reports it has nothing scheduled, no settle is coming
// either, so release the pause here or prefetching idles indefinitely.
function pausePrefetchUntilProcessed(scheduleTokenization: () => boolean): void {
const prefetch = deps.getSubtitlePrefetchService();
prefetch?.pause();
if (!scheduleTokenization()) {
prefetch?.resume();
}
}
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
@@ -104,12 +119,25 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
if (cachedPayload) {
subtitleProcessingController.onSubtitleChange(text);
// This emit resumes prefetching, so no pause is left outstanding.
emitSubtitlePayload(cachedPayload);
return true;
}
emitSubtitlePayload({ text, tokens: null });
subtitleProcessingController.onSubtitleChange(text);
// Provisional raw emit: keep prefetch paused until the processing
// controller is done with this line, and tell it this line has already been
// painted plain so it does not broadcast the same payload again.
emitSubtitlePayload({ text, tokens: null }, { resumePrefetch: false });
subtitleProcessingController.notePlainSubtitleEmitted(text);
// refreshCurrentSubtitle, not onSubtitleChange: the cache miss above can be
// an invalidation (mining a card) on text the controller still holds, and
// onSubtitleChange treats unchanged text as nothing to do, which would
// leave this line permanently unannotated. refreshCurrentSubtitle also
// re-tokenizes for a new cache generation.
if (!subtitleProcessingController.refreshCurrentSubtitle(text)) {
// Nothing scheduled, so no settle is coming to release the pause.
deps.getSubtitlePrefetchService()?.resume();
}
return true;
}
@@ -153,14 +181,12 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
onSubtitleChange: (text) => {
deps.getSubtitlePrefetchService()?.pause();
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
subtitleProcessingController.onSubtitleChange(text);
pausePrefetchUntilProcessed(() => subtitleProcessingController.onSubtitleChange(text));
},
refreshCurrentSubtitle: (text) => {
deps.getSubtitlePrefetchService()?.pause();
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
subtitleProcessingController.refreshCurrentSubtitle(text);
pausePrefetchUntilProcessed(() =>
subtitleProcessingController.refreshCurrentSubtitle(text),
);
},
deferUncachedRefresh: true,
emitSubtitle: (payload) => emitSubtitlePayload(payload),
@@ -204,9 +230,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
if (!text.trim()) {
return;
}
deps.getSubtitlePrefetchService()?.pause();
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
subtitleProcessingController.refreshCurrentSubtitle(text);
pausePrefetchUntilProcessed(() => subtitleProcessingController.refreshCurrentSubtitle(text));
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
}
@@ -53,3 +53,52 @@ test('character dictionary sync completion refreshes subtitle state when diction
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=yes, title=Frieren)',
]);
});
test('character dictionary sync completion drops cached dictionary reads before refreshing', () => {
const calls: string[] = [];
handleCharacterDictionaryAutoSyncComplete(
{
mediaId: 1,
mediaTitle: 'Frieren',
changed: true,
},
{
hasParserWindow: () => true,
invalidateCharacterDictionaryLookups: () => calls.push('invalidate-dictionary-lookups'),
clearParserCaches: () => calls.push('clear-parser'),
invalidateTokenizationCache: () => calls.push('invalidate'),
refreshSubtitlePrefetch: () => calls.push('prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
logInfo: () => {},
},
);
// Must run before the refreshes, or they re-tokenize against the character
// names and images from the previous dictionary build.
assert.equal(calls[0], 'invalidate-dictionary-lookups');
assert.ok(calls.indexOf('invalidate-dictionary-lookups') < calls.indexOf('refresh-subtitle'));
});
test('character dictionary sync completion leaves cached dictionary reads alone when unchanged', () => {
const calls: string[] = [];
handleCharacterDictionaryAutoSyncComplete(
{
mediaId: 1,
mediaTitle: 'Frieren',
changed: false,
},
{
hasParserWindow: () => true,
invalidateCharacterDictionaryLookups: () => calls.push('invalidate-dictionary-lookups'),
clearParserCaches: () => calls.push('clear-parser'),
invalidateTokenizationCache: () => calls.push('invalidate'),
refreshSubtitlePrefetch: () => calls.push('prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
logInfo: () => {},
},
);
assert.deepEqual(calls, []);
});
@@ -7,6 +7,12 @@ export function handleCharacterDictionaryAutoSyncComplete(
deps: {
hasParserWindow: () => boolean;
clearParserCaches: () => void;
/**
* Drops cached reads of the generated dictionary (character images, and the
* name candidates the scanner uses to skip lookups). Runs before the
* refreshes below so they re-tokenize against the new dictionary content.
*/
invalidateCharacterDictionaryLookups?: () => void;
invalidateTokenizationCache: () => void;
refreshSubtitlePrefetch: () => void;
refreshCurrentSubtitle: () => void;
@@ -14,6 +20,7 @@ export function handleCharacterDictionaryAutoSyncComplete(
},
): void {
if (completion.changed) {
deps.invalidateCharacterDictionaryLookups?.();
if (deps.hasParserWindow()) {
deps.clearParserCaches();
}
@@ -225,24 +225,35 @@ export function composeMpvRuntimeHandlers<
}
return tokenizationWarmupInFlight;
};
// Built once and reused for every tokenization: per-call rebuilds create
// fresh closures, which defeats identity-keyed caches downstream (the JLPT
// lookup cache keys on the getJlptLevel function, and the mecab availability
// WeakSet keys on the runtime deps instance).
let cachedTokenizerRuntimeDeps: TTokenizerRuntimeDeps | null = null;
const getTokenizerRuntimeDeps = (): TTokenizerRuntimeDeps => {
if (cachedTokenizerRuntimeDeps) {
return cachedTokenizerRuntimeDeps;
}
const tokenizerMainDeps = buildTokenizerDepsHandler();
const baseOnTokenizationReady = tokenizerMainDeps.onTokenizationReady;
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
if (!shouldWarmupAnnotationDictionaries()) {
baseOnTokenizationReady?.(tokenizedText);
return;
}
markTokenizationPlaybackReady();
baseOnTokenizationReady?.(tokenizedText);
if (!tokenizationWarmupCompleted) {
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
}
};
cachedTokenizerRuntimeDeps = options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps);
return cachedTokenizerRuntimeDeps;
};
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
await ensureTokenizationPrerequisites();
const tokenizerMainDeps = buildTokenizerDepsHandler();
if (shouldWarmupAnnotationDictionaries()) {
const onTokenizationReady = tokenizerMainDeps.onTokenizationReady;
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
markTokenizationPlaybackReady();
onTokenizationReady?.(tokenizedText);
if (!tokenizationWarmupCompleted) {
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
}
};
}
return options.tokenizer.tokenizeSubtitle(
text,
options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps),
);
return options.tokenizer.tokenizeSubtitle(text, getTokenizerRuntimeDeps());
};
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup(
@@ -6,6 +6,7 @@ export function createBuildSubtitleProcessingControllerMainDepsHandler(
return (): SubtitleProcessingControllerDeps => ({
tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text),
emitSubtitle: (payload) => deps.emitSubtitle(payload),
onProcessingSettled: () => deps.onProcessingSettled?.(),
logDebug: deps.logDebug,
now: deps.now,
});
@@ -9,6 +9,9 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getCurrentCharacterDictionaryMediaId?: NonNullable<
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
>;
getCharacterNameCandidates?: NonNullable<
TokenizerDepsRuntimeOptions['getCharacterNameCandidates']
>;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
@@ -84,6 +87,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
}
: {}),
...(deps.getCharacterNameCandidates
? {
getCharacterNameCandidates: () => deps.getCharacterNameCandidates!(),
}
: {}),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),