From eed0a6a24363f4578db089ccdf921e2be3c2ae1a Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 28 May 2026 00:50:41 -0700 Subject: [PATCH] feat: use cached annotations on subtitle change and skip pre-warmed cues (#97) --- changes/subtitle-prefetch-annotations.md | 4 ++ src/anki-integration.test.ts | 48 +++++++++++++++ src/anki-integration.ts | 10 +++- src/core/services/subtitle-prefetch.test.ts | 56 ++++++++++++++++++ src/core/services/subtitle-prefetch.ts | 19 +++++- .../subtitle-processing-controller.test.ts | 25 ++++++++ .../subtitle-processing-controller.ts | 6 +- src/main.ts | 17 +++++- src/main/main-wiring.test.ts | 59 +++++++++++++++++++ src/main/runtime/subtitle-prefetch-init.ts | 2 + 10 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 changes/subtitle-prefetch-annotations.md diff --git a/changes/subtitle-prefetch-annotations.md b/changes/subtitle-prefetch-annotations.md new file mode 100644 index 00000000..c1b92de9 --- /dev/null +++ b/changes/subtitle-prefetch-annotations.md @@ -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. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 04ffe9d9..03bf4b37 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -19,6 +19,7 @@ interface IntegrationTestContext { function createIntegrationTestContext( options: { highlightEnabled?: boolean; + nPlusOneEnabled?: boolean; onFindNotes?: () => Promise; onNotesInfo?: () => Promise; stateDirPrefix?: string; @@ -59,6 +60,12 @@ function createIntegrationTestContext( knownWords: { highlightEnabled: options.highlightEnabled ?? true, }, + nPlusOne: + options.nPlusOneEnabled === undefined + ? undefined + : { + enabled: options.nPlusOneEnabled, + }, }, {} 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 () => { const ctx = createIntegrationTestContext({ highlightEnabled: false, diff --git a/src/anki-integration.ts b/src/anki-integration.ts index ae6fc039..5a890c5c 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -526,7 +526,9 @@ export class AnkiIntegration { } private isKnownWordCacheEnabled(): boolean { - return this.config.knownWords?.highlightEnabled === true; + return ( + this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true + ); } private getConfiguredAnkiTags(): string[] { @@ -549,7 +551,11 @@ export class AnkiIntegration { } async refreshKnownWordCache(): Promise { - return this.knownWordCache.refresh(true); + const shouldNotify = this.isKnownWordCacheEnabled(); + await this.knownWordCache.refresh(true); + if (shouldNotify) { + this.notifyKnownWordCacheUpdated(); + } } private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void { diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index d0530569..ad1fe018 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -242,3 +242,59 @@ test('prefetch service pause/resume halts and continues tokenization', async () 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')); +}); diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts index 9641b350..c8e0174d 100644 --- a/src/core/services/subtitle-prefetch.ts +++ b/src/core/services/subtitle-prefetch.ts @@ -1,10 +1,12 @@ import type { SubtitleData } from '../../types'; import type { SubtitleCue } from '../../types'; +import { normalizeSubtitleCacheKey } from './subtitle-processing-controller'; export interface SubtitlePrefetchServiceDeps { cues: SubtitleCue[]; tokenizeSubtitle: (text: string) => Promise; preCacheTokenization: (text: string, data: SubtitleData) => void; + hasCachedTokenization?: (text: string) => boolean; isCacheFull: () => boolean; priorityWindowSize?: number; } @@ -58,6 +60,7 @@ export function createSubtitlePrefetchService( async function tokenizeCueList( cuesToProcess: SubtitleCue[], runId: number, + warmedKeys: Set, options: { allowWhenCacheFull?: boolean } = {}, ): Promise { for (const cue of cuesToProcess) { @@ -78,6 +81,15 @@ export function createSubtitlePrefetchService( 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 { const result = await deps.tokenizeSubtitle(cue.text); if (result && !stopped && runId === currentRunId) { @@ -94,10 +106,11 @@ export function createSubtitlePrefetchService( async function startPrefetching(currentTimeSeconds: number, runId: number): Promise { const cues = deps.cues; + const warmedKeys = new Set(); // Phase 1: Priority window const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); - await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true }); + await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true }); if (stopped || runId !== currentRunId) { return; @@ -108,7 +121,7 @@ export function createSubtitlePrefetchService( const remainingCues = cues.filter( (cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text), ); - await tokenizeCueList(remainingCues, runId); + await tokenizeCueList(remainingCues, runId, warmedKeys); if (stopped || runId !== currentRunId) { return; @@ -118,7 +131,7 @@ export function createSubtitlePrefetchService( const earlierCues = cues.filter( (cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text), ); - await tokenizeCueList(earlierCues, runId); + await tokenizeCueList(earlierCues, runId, warmedKeys); } return { diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index ef1e1652..dcb91e16 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -236,6 +236,31 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing 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', () => { const controller = createSubtitleProcessingController({ tokenizeSubtitle: async (text) => ({ text, tokens: null }), diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index 6bb1628a..5e02594e 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -13,10 +13,11 @@ export interface SubtitleProcessingController { invalidateTokenizationCache: () => void; preCacheTokenization: (text: string, data: SubtitleData) => void; consumeCachedSubtitle: (text: string) => SubtitleData | null; + hasCachedSubtitle: (text: string) => 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(); } @@ -152,6 +153,9 @@ export function createSubtitleProcessingController( refreshRequested = false; return cached; }, + hasCachedSubtitle: (text: string) => { + return tokenizationCache.has(normalizeSubtitleCacheKey(text)); + }, isCacheFull: () => { return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; }, diff --git a/src/main.ts b/src/main.ts index a7ede551..76af6abb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1754,10 +1754,17 @@ function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean { } 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 }); appState.currentSubtitleData = rawPayload; broadcastToOverlayWindows('subtitle:set', rawPayload); - subtitlePrefetchService?.pause(); subtitleProcessingController.onSubtitleChange(text); return true; } @@ -1834,6 +1841,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ preCacheTokenization: (text, data) => { subtitleProcessingController.preCacheTokenization(text, data); }, + hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text), isCacheFull: () => subtitleProcessingController.isCacheFull(), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), @@ -4219,6 +4227,12 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { appState.immersionTracker?.recordCardsMined(count, noteIds); }; const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { + const hasCurrentSubtitle = appState.currentSubText.trim().length > 0; + if (hasCurrentSubtitle) { + subtitlePrefetchService?.pause(); + } + subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); }; let hasAttemptedImmersionTrackerStartup = false; @@ -4603,6 +4617,7 @@ const { }, onSubtitleChange: (text) => { subtitlePrefetchService?.pause(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitleProcessingController.onSubtitleChange(text); }, refreshDiscordPresence: () => { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 08bf882f..4e688b1d 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -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*\{(?[\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 \{(?[\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 => \{(?[\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', () => { const source = readMainSource(); const setBlock = source.match( diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts index 42b717ef..62291067 100644 --- a/src/main/runtime/subtitle-prefetch-init.ts +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -13,6 +13,7 @@ export interface SubtitlePrefetchInitControllerDeps { createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; tokenizeSubtitle: (text: string) => Promise; preCacheTokenization: (text: string, data: SubtitleData) => void; + hasCachedTokenization?: (text: string) => boolean; isCacheFull: () => boolean; logInfo: (message: string) => void; logWarn: (message: string) => void; @@ -67,6 +68,7 @@ export function createSubtitlePrefetchInitController( cues, tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), + hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false, isCacheFull: () => deps.isCacheFull(), });