feat: use cached annotations on subtitle change and skip pre-warmed cues (#97)

This commit is contained in:
2026-05-28 00:50:41 -07:00
committed by GitHub
parent d33009d4a3
commit eed0a6a243
10 changed files with 239 additions and 7 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Improved subtitle annotation prefetching so cached colored annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
+48
View File
@@ -19,6 +19,7 @@ interface IntegrationTestContext {
function createIntegrationTestContext( function createIntegrationTestContext(
options: { options: {
highlightEnabled?: boolean; highlightEnabled?: boolean;
nPlusOneEnabled?: boolean;
onFindNotes?: () => Promise<number[]>; onFindNotes?: () => Promise<number[]>;
onNotesInfo?: () => Promise<unknown[]>; onNotesInfo?: () => Promise<unknown[]>;
stateDirPrefix?: string; stateDirPrefix?: string;
@@ -59,6 +60,12 @@ function createIntegrationTestContext(
knownWords: { knownWords: {
highlightEnabled: options.highlightEnabled ?? true, highlightEnabled: options.highlightEnabled ?? true,
}, },
nPlusOne:
options.nPlusOneEnabled === undefined
? undefined
: {
enabled: options.nPlusOneEnabled,
},
}, },
{} as never, {} as never,
{} as never, {} as never,
@@ -161,6 +168,47 @@ test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () =>
} }
}); });
test('AnkiIntegration.refreshKnownWordCache notifies annotation cache listeners', async () => {
const ctx = createIntegrationTestContext({
stateDirPrefix: 'subminer-anki-integration-refresh-notify-',
});
let notifications = 0;
try {
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
notifications += 1;
});
await ctx.integration.refreshKnownWordCache();
assert.equal(notifications, 1);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration.refreshKnownWordCache notifies when n+1 is enabled without highlights', async () => {
const ctx = createIntegrationTestContext({
highlightEnabled: false,
nPlusOneEnabled: true,
stateDirPrefix: 'subminer-anki-integration-nplusone-notify-',
});
let notifications = 0;
try {
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
notifications += 1;
});
await ctx.integration.refreshKnownWordCache();
assert.equal(ctx.calls.findNotes, 1);
assert.equal(notifications, 1);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => { test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => {
const ctx = createIntegrationTestContext({ const ctx = createIntegrationTestContext({
highlightEnabled: false, highlightEnabled: false,
+8 -2
View File
@@ -526,7 +526,9 @@ export class AnkiIntegration {
} }
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return this.config.knownWords?.highlightEnabled === true; return (
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
);
} }
private getConfiguredAnkiTags(): string[] { private getConfiguredAnkiTags(): string[] {
@@ -549,7 +551,11 @@ export class AnkiIntegration {
} }
async refreshKnownWordCache(): Promise<void> { async refreshKnownWordCache(): Promise<void> {
return this.knownWordCache.refresh(true); const shouldNotify = this.isKnownWordCacheEnabled();
await this.knownWordCache.refresh(true);
if (shouldNotify) {
this.notifyKnownWordCacheUpdated();
}
} }
private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void { private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
@@ -242,3 +242,59 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause'); assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause');
}); });
test('prefetch service skips cues already present in tokenization cache', async () => {
const cues = makeCues(5);
const tokenizedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizedTexts.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {},
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
isCacheFull: () => false,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.ok(!tokenizedTexts.includes('line-0'));
assert.ok(!tokenizedTexts.includes('line-1'));
assert.ok(tokenizedTexts.includes('line-2'));
});
test('prefetch service deduplicates repeated cue text within a run', async () => {
const cues: SubtitleCue[] = [
{ startTime: 0, endTime: 1, text: 'same' },
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 2, endTime: 3, text: 'other' },
];
const tokenizedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizedTexts.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.deepEqual(tokenizedTexts.filter((text) => text === 'same'), ['same']);
assert.ok(tokenizedTexts.includes('other'));
});
+16 -3
View File
@@ -1,10 +1,12 @@
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types'; import type { SubtitleCue } from '../../types';
import { normalizeSubtitleCacheKey } from './subtitle-processing-controller';
export interface SubtitlePrefetchServiceDeps { export interface SubtitlePrefetchServiceDeps {
cues: SubtitleCue[]; cues: SubtitleCue[];
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
priorityWindowSize?: number; priorityWindowSize?: number;
} }
@@ -58,6 +60,7 @@ export function createSubtitlePrefetchService(
async function tokenizeCueList( async function tokenizeCueList(
cuesToProcess: SubtitleCue[], cuesToProcess: SubtitleCue[],
runId: number, runId: number,
warmedKeys: Set<string>,
options: { allowWhenCacheFull?: boolean } = {}, options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> { ): Promise<void> {
for (const cue of cuesToProcess) { for (const cue of cuesToProcess) {
@@ -78,6 +81,15 @@ export function createSubtitlePrefetchService(
return; return;
} }
const cacheKey = normalizeSubtitleCacheKey(cue.text);
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
if (cacheKey) {
warmedKeys.add(cacheKey);
}
continue;
}
warmedKeys.add(cacheKey);
try { try {
const result = await deps.tokenizeSubtitle(cue.text); const result = await deps.tokenizeSubtitle(cue.text);
if (result && !stopped && runId === currentRunId) { if (result && !stopped && runId === currentRunId) {
@@ -94,10 +106,11 @@ export function createSubtitlePrefetchService(
async function startPrefetching(currentTimeSeconds: number, runId: number): Promise<void> { async function startPrefetching(currentTimeSeconds: number, runId: number): Promise<void> {
const cues = deps.cues; const cues = deps.cues;
const warmedKeys = new Set<string>();
// Phase 1: Priority window // Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true }); await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -108,7 +121,7 @@ export function createSubtitlePrefetchService(
const remainingCues = cues.filter( const remainingCues = cues.filter(
(cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text), (cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text),
); );
await tokenizeCueList(remainingCues, runId); await tokenizeCueList(remainingCues, runId, warmedKeys);
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -118,7 +131,7 @@ export function createSubtitlePrefetchService(
const earlierCues = cues.filter( const earlierCues = cues.filter(
(cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text), (cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text),
); );
await tokenizeCueList(earlierCues, runId); await tokenizeCueList(earlierCues, runId, warmedKeys);
} }
return { return {
@@ -236,6 +236,31 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing
assert.deepEqual(emitted, []); assert.deepEqual(emitted, []);
}); });
test('hasCachedSubtitle checks prefetched entries without consuming them', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.preCacheTokenization('猫\\Nです', { text: '猫\nです', tokens: [] });
assert.equal(controller.hasCachedSubtitle('猫\nです'), true);
controller.onSubtitleChange('猫\nです');
await flushMicrotasks();
assert.equal(tokenizeCalls, 0);
assert.deepEqual(emitted, [{ text: '猫\nです', tokens: [] }]);
controller.invalidateTokenizationCache();
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
});
test('isCacheFull returns false when cache is below limit', () => { test('isCacheFull returns false when cache is below limit', () => {
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: null }), tokenizeSubtitle: async (text) => ({ text, tokens: null }),
@@ -13,10 +13,11 @@ export interface SubtitleProcessingController {
invalidateTokenizationCache: () => void; invalidateTokenizationCache: () => void;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null; consumeCachedSubtitle: (text: string) => SubtitleData | null;
hasCachedSubtitle: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
} }
function normalizeSubtitleCacheKey(text: string): string { export function normalizeSubtitleCacheKey(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim(); return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim();
} }
@@ -152,6 +153,9 @@ export function createSubtitleProcessingController(
refreshRequested = false; refreshRequested = false;
return cached; return cached;
}, },
hasCachedSubtitle: (text: string) => {
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
},
isCacheFull: () => { isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
}, },
+16 -1
View File
@@ -1754,10 +1754,17 @@ function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
} }
appState.currentSubText = text; appState.currentSubText = text;
subtitlePrefetchService?.pause();
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
if (cachedPayload) {
subtitleProcessingController.onSubtitleChange(text);
emitSubtitlePayload(cachedPayload);
return true;
}
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null }); const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
appState.currentSubtitleData = rawPayload; appState.currentSubtitleData = rawPayload;
broadcastToOverlayWindows('subtitle:set', rawPayload); broadcastToOverlayWindows('subtitle:set', rawPayload);
subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
return true; return true;
} }
@@ -1834,6 +1841,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
preCacheTokenization: (text, data) => { preCacheTokenization: (text, data) => {
subtitleProcessingController.preCacheTokenization(text, data); subtitleProcessingController.preCacheTokenization(text, data);
}, },
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
isCacheFull: () => subtitleProcessingController.isCacheFull(), isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
@@ -4219,6 +4227,12 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
appState.immersionTracker?.recordCardsMined(count, noteIds); appState.immersionTracker?.recordCardsMined(count, noteIds);
}; };
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause();
}
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}; };
let hasAttemptedImmersionTrackerStartup = false; let hasAttemptedImmersionTrackerStartup = false;
@@ -4603,6 +4617,7 @@ const {
}, },
onSubtitleChange: (text) => { onSubtitleChange: (text) => {
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
}, },
refreshDiscordPresence: () => { refreshDiscordPresence: () => {
+59
View File
@@ -89,6 +89,65 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
); );
}); });
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);'),
);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
);
});
test('autoplay subtitle prime prefers cached annotated payload before raw fallback', () => {
const source = readMainSource();
const actionBlock = source.match(
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const cachedPayload = subtitleProcessingController\.consumeCachedSubtitle\(text\);/,
);
assert.match(actionBlock, /if \(cachedPayload\) \{/);
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
assert.match(actionBlock, /const rawPayload = withCurrentSubtitleTiming\(\{ text, tokens: null \}\);/);
assert.ok(
actionBlock.indexOf('consumeCachedSubtitle(text)') <
actionBlock.indexOf('withCurrentSubtitleTiming({ text, tokens: null })'),
);
});
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitleProcessingController\.invalidateTokenizationCache\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(
actionBlock,
/subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\);/,
);
assert.ok(
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
actionBlock.indexOf('subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);'),
);
});
test('manual visible overlay changes notify mpv plugin visibility state', () => { test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource(); const source = readMainSource();
const setBlock = source.match( const setBlock = source.match(
@@ -13,6 +13,7 @@ export interface SubtitlePrefetchInitControllerDeps {
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
@@ -67,6 +68,7 @@ export function createSubtitlePrefetchInitController(
cues, cues,
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
isCacheFull: () => deps.isCacheFull(), isCacheFull: () => deps.isCacheFull(),
}); });