fix(subtitles): release prefetch pause on repeated subtitle events

onSubtitleChange paused prefetching unconditionally, but the processing
controller returns early when the text matches what it already has. Nothing
is tokenized, so nothing is emitted, so the resume that rides on the emit
never fires and prefetching idles for the rest of the cue. The reachable
trigger is a repeat arriving after a cache invalidation, such as mining a card
while the same line is still on screen.

The controller now reports whether it scheduled processing and the caller
resumes when it did not, so every pause has a matching resume.

Also harden the character-name candidate prefilter: Yomitan collapses emphatic
sequences before matching, so a stretched spelling still resolves to its entry
(ミナァァト matches ミナト). The candidate match now skips small kana and
prolonged marks, which only widens the probe set and so cannot drop a name.
This commit is contained in:
2026-08-03 23:37:04 -07:00
parent b0a2ce6e8a
commit f43674cc39
7 changed files with 161 additions and 7 deletions
@@ -9,4 +9,5 @@ area: subtitles
- 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. - 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. - Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
- Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens. - Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens.
- Subtitle prefetching no longer stays paused for the rest of a cue when the same subtitle text is reported twice and there is nothing to tokenize.
- Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name. - Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name.
@@ -539,3 +539,22 @@ test('default cache limit covers a full-length title without evicting', () => {
assert.equal(controller.hasCachedSubtitle('line-0'), true); assert.equal(controller.hasCachedSubtitle('line-0'), true);
assert.equal(controller.hasCachedSubtitle('line-1999'), true); assert.equal(controller.hasCachedSubtitle('line-1999'), true);
}); });
test('onSubtitleChange reports whether processing was scheduled', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
// New text schedules work, so an emit (and anything gated on it) will follow.
assert.equal(controller.onSubtitleChange('字幕'), true);
await flushMicrotasks();
// A repeat emits nothing, so callers must not wait on an emit that is never
// coming (subtitle prefetching would stay paused for the rest of the cue).
const emittedCount = emitted.length;
assert.equal(controller.onSubtitleChange('字幕'), false);
await flushMicrotasks();
assert.equal(emitted.length, emittedCount);
});
@@ -17,7 +17,12 @@ export interface SubtitleProcessingControllerDeps {
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500; export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
export interface SubtitleProcessingController { export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void; /**
* Returns whether the text was new and processing was scheduled. A false
* return means nothing will be emitted for this event, which callers that
* gate work on the emit (such as pausing subtitle prefetching) need to know.
*/
onSubtitleChange: (text: string) => boolean;
refreshCurrentSubtitle: (textOverride?: string) => void; refreshCurrentSubtitle: (textOverride?: string) => void;
invalidateTokenizationCache: () => void; invalidateTokenizationCache: () => void;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
@@ -171,7 +176,7 @@ export function createSubtitleProcessingController(
return { return {
onSubtitleChange: (text: string) => { onSubtitleChange: (text: string) => {
if (text === latestText) { if (text === latestText) {
return; return false;
} }
latestText = text; latestText = text;
if ( if (
@@ -183,6 +188,7 @@ export function createSubtitleProcessingController(
lastPlainEmittedText = text; lastPlainEmittedText = text;
} }
processLatest(); processLatest();
return true;
}, },
refreshCurrentSubtitle: (textOverride?: string) => { refreshCurrentSubtitle: (textOverride?: string) => {
if (typeof textOverride === 'string') { if (typeof textOverride === 'string') {
@@ -2788,3 +2788,100 @@ test('addYomitanNoteViaSearch sanitizes invalid payload note ids while keeping v
duplicateNoteIds: [18, 7], duplicateNoteIds: [18, 7],
}); });
}); });
test('requestYomitanScanTokens still finds an emphatically elongated name a longer generic match would swallow', async () => {
// Yomitan collapses emphatic sequences, so ミナァァト resolves to the ミナト
// entry. The generic word とミナ starts earlier and would swallow the name
// unless the pre-pass reserves it, so this only passes when the candidate
// prefilter still treats the elongated spelling as a possible name start.
const deps = createScanDeps((action, params) => {
if (action === 'optionsGetFull') {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [
{ name: 'JMdict', enabled: true, id: 0 },
{ name: 'SubMiner Character Dictionary (AniList 1)', enabled: true, id: 1 },
],
},
},
],
};
}
if (action === 'getDictionaryInfo') {
return [];
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (text.startsWith('とミナ')) {
return {
originalTextLength: 3,
dictionaryEntries: [
{
headwords: [
{
term: 'トミナ',
reading: 'とみな',
sources: [{ originalText: 'とミナ', isPrimary: true, matchType: 'exact' }],
},
],
definitions: [{ dictionary: 'JMdict' }],
},
],
};
}
if (text.startsWith('ミナァァト')) {
return {
originalTextLength: 5,
dictionaryEntries: [
{
headwords: [
{
term: 'ミナト',
reading: 'みなと',
sources: [{ originalText: 'ミナァァト', isPrimary: true, matchType: 'exact' }],
},
],
definitions: [{ dictionary: 'SubMiner Character Dictionary (AniList 1)' }],
},
],
};
}
if (text.startsWith('と')) {
return {
originalTextLength: 1,
dictionaryEntries: [
{
headwords: [
{
term: 'と',
reading: 'と',
sources: [{ originalText: 'と', isPrimary: true, matchType: 'exact' }],
},
],
definitions: [{ dictionary: 'JMdict' }],
},
],
};
}
return { originalTextLength: 0, dictionaryEntries: [] };
});
const result = await requestYomitanScanTokens(
'とミナァァト',
deps,
{ error: () => undefined },
{
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] },
},
);
const nameToken = result?.find((token) => token.isNameMatch === true);
assert.ok(nameToken, 'expected the elongated name to be reserved by the pre-pass');
assert.equal(nameToken?.headword, 'ミナト');
assert.equal(nameToken?.startPos, 1);
});
@@ -495,7 +495,7 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
// Bump whenever the install script below changes so already-loaded parser // Bump whenever the install script below changes so already-loaded parser
// windows re-install the new scan runtime instead of running the stale one. // windows re-install the new scan runtime instead of running the stale one.
export const YOMITAN_SCAN_RUNTIME_VERSION = 3; export const YOMITAN_SCAN_RUNTIME_VERSION = 4;
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__'; export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
export interface YomitanScanRequestParams { export interface YomitanScanRequestParams {
@@ -731,13 +731,33 @@ ${YOMITAN_SCANNING_HELPERS}
? nameCandidateIndex ? nameCandidateIndex
: null; : null;
const normalizedText = activeNameCandidateIndex ? convertKatakanaToHiragana(text) : ""; const normalizedText = activeNameCandidateIndex ? convertKatakanaToHiragana(text) : "";
// Yomitan collapses emphatic sequences before matching (すっっごーーい →
// すごい), so a stretched name still resolves to its entry. Skipping these
// characters keeps such spellings candidates; the filter only ever grows
// the probe set, so a false positive costs one lookup, never a name.
const EMPHATIC_SKIP_CHARS = new Set(["ぁ", "ぃ", "ぅ", "ぇ", "ぉ", "っ", "ゃ", "ゅ", "ょ", "ー"]);
function matchesCandidateFormAt(form, position) {
let textIndex = position;
for (let formIndex = 0; formIndex < form.length; formIndex += 1) {
while (
textIndex < normalizedText.length &&
normalizedText[textIndex] !== form[formIndex] &&
EMPHATIC_SKIP_CHARS.has(normalizedText[textIndex])
) {
textIndex += 1;
}
if (normalizedText[textIndex] !== form[formIndex]) { return false; }
textIndex += 1;
}
return true;
}
function couldNameStartAt(position, codePoint) { function couldNameStartAt(position, codePoint) {
if (!activeNameCandidateIndex) { return true; } if (!activeNameCandidateIndex) { return true; }
if (isHalfwidthKatakanaCodePoint(codePoint)) { return true; } if (isHalfwidthKatakanaCodePoint(codePoint)) { return true; }
const bucket = activeNameCandidateIndex.byFirstChar.get(normalizedText[position]); const bucket = activeNameCandidateIndex.byFirstChar.get(normalizedText[position]);
if (!bucket) { return false; } if (!bucket) { return false; }
for (const form of bucket) { for (const form of bucket) {
if (normalizedText.startsWith(form, position)) { return true; } if (matchesCandidateFormAt(form, position)) { return true; }
} }
return false; return false;
} }
+6 -1
View File
@@ -4388,7 +4388,12 @@ const {
// Pause only; restarting the prefetch run here would discard in-flight // Pause only; restarting the prefetch run here would discard in-flight
// tokenization work on every line. Real seeks restart via onTimePosUpdate. // tokenization work on every line. Real seeks restart via onTimePosUpdate.
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text); if (!subtitleProcessingController.onSubtitleChange(text)) {
// Repeat of the current text: nothing will be tokenized, so no emit is
// coming to release the pause. Resume now instead of idling prefetch
// for the rest of the cue.
subtitlePrefetchService?.resume();
}
}, },
refreshDiscordPresence: () => { refreshDiscordPresence: () => {
discordPresenceRuntime.publishDiscordPresence(); discordPresenceRuntime.publishDiscordPresence();
+8 -2
View File
@@ -234,10 +234,16 @@ test('subtitle change pauses prefetch without restarting its run before tokenizi
// Restarting the run per line (onSeek) discards in-flight prefetch work; // Restarting the run per line (onSeek) discards in-flight prefetch work;
// only real seeks restart via onTimePosUpdate. // only real seeks restart via onTimePosUpdate.
assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/); assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/); assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\)/);
assert.ok( assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.pause();') < actionBlock.indexOf('subtitlePrefetchService?.pause();') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'), 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\(\);/,
); );
}); });