From fe4dacc1e7050528b4e79d1b2317be77fb70f072 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 4 Aug 2026 01:55:52 -0700 Subject: [PATCH] fix(overlay): show plain subtitle line immediately on tokenization cache miss (#184) --- changes/subtitle-plain-line-on-cache-miss.md | 4 + docs-site/websocket-texthooker-api.md | 4 +- docs/architecture/subtitle-overlay-priming.md | 23 +- src/core/services/index.ts | 6 +- ...ubtitle-processing-controller-race.test.ts | 159 ++++++++++++++ .../subtitle-processing-controller.test.ts | 196 +++++++++++++++++- .../subtitle-processing-controller.ts | 42 +++- src/core/services/subtitle-ws.test.ts | 35 ++++ src/core/services/subtitle-ws.ts | 14 ++ src/main.ts | 7 +- src/main/main-wiring.test.ts | 29 +++ 11 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 changes/subtitle-plain-line-on-cache-miss.md create mode 100644 src/core/services/subtitle-processing-controller-race.test.ts diff --git a/changes/subtitle-plain-line-on-cache-miss.md b/changes/subtitle-plain-line-on-cache-miss.md new file mode 100644 index 00000000..d0f45ebf --- /dev/null +++ b/changes/subtitle-plain-line-on-cache-miss.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Subtitle lines no longer wait for tokenization to finish before appearing, even when the previous line is still being processed. On a tokenization cache miss, the plain line is shown immediately at its cue time and upgrades in place once tokens and annotations are ready; stale results cannot replace newer cues, and the basic plain-text websocket no longer receives a duplicate event for the annotation-only upgrade. A failed tokenization is no longer cached as the plain line, so a repeated line gets another chance at annotations instead of staying plain for the rest of the session. diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index ed0b3e08..9756bf3e 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as - **Client auth:** none - **Reconnects:** client-managed -When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. +When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream. #### Message shape @@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer. +On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload. + #### Message shape ```json diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 64e1ccdb..13f6a98b 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -3,7 +3,7 @@ # Subtitle Overlay Priming Status: active -Last verified: 2026-06-14 +Last verified: 2026-08-04 Owner: Kyle Yasuda Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated @@ -47,18 +47,31 @@ subtitles do not draw. `emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles. 6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and `emitSubtitle(cachedPayload)`, then prime secondary subtitles. -7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final - payload. +7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload + synchronously, then replaces it with the tokenized payload when ready. In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching `subtitleProcessingController` method. This gives the visible overlay priority over background prefetch work and re-centers prefetch around the live playback time. +## Live Cue Delivery + +- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so + live work does not contend for Yomitan state. +- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty + clear payload is emitted immediately. The older tokenization result is dropped before it can + replace the current cue. +- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text + or character images, but cue visibility does not wait for that work. + ## Emitted State -- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal - annotated subtitle payload to overlay windows and subtitle websocket listeners. +- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation + websocket listeners receive both the immediate plain cue and its later annotation upgrade. +- The basic subtitle websocket receives the immediate plain cue only. Because its serialized + payload discards annotations, the later upgrade would be an identical duplicate and is skipped + when text and cue timing match. - Secondary priming reads mpv `secondary-sub-text`, stores it in `mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows. - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 8baa7c24..149d7248 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -1,5 +1,9 @@ export { Texthooker } from './texthooker'; -export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws'; +export { + hasMpvWebsocketPlugin, + isSubtitleAnnotationUpgrade, + SubtitleWebSocket, +} from './subtitle-ws'; export { registerGlobalShortcuts } from './shortcut'; export { createIpcDepsRuntime, registerIpcHandlers } from './ipc'; export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback'; diff --git a/src/core/services/subtitle-processing-controller-race.test.ts b/src/core/services/subtitle-processing-controller-race.test.ts new file mode 100644 index 00000000..571e4218 --- /dev/null +++ b/src/core/services/subtitle-processing-controller-race.test.ts @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { SubtitleData } from '../../types'; +import { createSubtitleProcessingController } from './subtitle-processing-controller'; + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('new subtitle emits plain immediately without parallel tokenization or a stale overwrite', async () => { + const emitted: SubtitleData[] = []; + const resolvers = new Map void>(); + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => + await new Promise((resolve) => { + resolvers.set(text, resolve); + }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('first'); + controller.onSubtitleChange('second'); + + assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, + { text: 'second', tokens: null }, + ]); + assert.equal(resolvers.has('second'), false); + + const resolveFirst = resolvers.get('first'); + assert.ok(resolveFirst); + resolveFirst({ text: 'first', tokens: [] }); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, + { text: 'second', tokens: null }, + ]); + assert.equal(resolvers.has('second'), true); + + const resolveSecond = resolvers.get('second'); + assert.ok(resolveSecond); + resolveSecond({ text: 'second', tokens: [] }); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, + { text: 'second', tokens: null }, + { text: 'second', tokens: [] }, + ]); +}); + +test('subtitle clears immediately while previous tokenization remains pending', async () => { + const emitted: SubtitleData[] = []; + let resolveTokenization: ((value: SubtitleData | null) => void) | undefined; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async () => + await new Promise((resolve) => { + resolveTokenization = resolve; + }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('first'); + controller.onSubtitleChange(''); + + assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, + { text: '', tokens: null }, + ]); + + assert.ok(resolveTokenization); + resolveTokenization({ text: 'first', tokens: [] }); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, + { text: '', tokens: null }, + ]); +}); + +test('returning to an uncached completed line emits it while another line is pending', async () => { + const emitted: SubtitleData[] = []; + let resolvePending: ((value: SubtitleData | null) => void) | undefined; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + if (text === 'A') { + return { text, tokens: [] }; + } + return await new Promise((resolve) => { + resolvePending = resolve; + }); + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('A'); + await flushMicrotasks(); + controller.invalidateTokenizationCache(); + controller.onSubtitleChange('B'); + controller.onSubtitleChange('A'); + + assert.deepEqual(emitted.at(-1), { text: 'A', tokens: null }); + assert.ok(resolvePending); + resolvePending({ text: 'B', tokens: [] }); +}); + +test('ABA subtitle changes reuse the matching first tokenization only after A is current again', async () => { + const emitted: SubtitleData[] = []; + const tokenizeCalls: string[] = []; + const resolvers: Array<(value: SubtitleData | null) => void> = []; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls.push(text); + return await new Promise((resolve) => { + resolvers.push(resolve); + }); + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('A'); + controller.onSubtitleChange('B'); + controller.onSubtitleChange('A'); + const resolveFirst = resolvers[0]; + assert.ok(resolveFirst); + resolveFirst({ text: 'A', tokens: [{ value: 1 } as never] }); + await flushMicrotasks(); + + assert.deepEqual(tokenizeCalls, ['A']); + assert.deepEqual(emitted, [ + { text: 'A', tokens: null }, + { text: 'B', tokens: null }, + { text: 'A', tokens: null }, + { text: 'A', tokens: [{ value: 1 } as never] }, + ]); +}); + +test('cached next subtitle does not downgrade to plain while processing is busy', async () => { + const emitted: SubtitleData[] = []; + let resolveTokenization: ((value: SubtitleData | null) => void) | undefined; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => + await new Promise((resolve) => { + resolveTokenization = () => resolve({ text, tokens: [] }); + }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.preCacheTokenization('cached', { text: 'cached', tokens: [] }); + controller.onSubtitleChange('pending'); + controller.onSubtitleChange('cached'); + + assert.deepEqual(emitted, [{ text: 'pending', tokens: null }]); + assert.ok(resolveTokenization); + resolveTokenization({ text: 'pending', tokens: [] }); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: 'pending', tokens: null }, + { text: 'cached', tokens: [] }, + ]); +}); diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index a7214dd5..ec35c170 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -7,18 +7,138 @@ function flushMicrotasks(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } -test('subtitle processing emits tokenized payload when tokenization succeeds', async () => { +test('subtitle processing emits plain payload immediately on cache miss, then tokenized payload', async () => { const emitted: SubtitleData[] = []; const controller = createSubtitleProcessingController({ tokenizeSubtitle: async (text) => ({ text, tokens: [] }), emitSubtitle: (payload) => emitted.push(payload), }); + controller.onSubtitleChange('字幕'); + assert.deepEqual(emitted, [{ text: '字幕', tokens: null }]); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: '字幕', tokens: null }, + { text: '字幕', tokens: [] }, + ]); +}); + +test('cache invalidation during pending tokenization does not re-emit the plain payload', async () => { + const emitted: SubtitleData[] = []; + const resolvers: Array<(value: SubtitleData | null) => void> = []; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => + await new Promise((resolve) => { + resolvers.push(() => resolve({ text, tokens: [{ value: resolvers.length } as never] })); + }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('行'); + assert.deepEqual(emitted, [{ text: '行', tokens: null }]); + + controller.invalidateTokenizationCache(); + resolvers[0]?.({ text: '行', tokens: [] }); + await flushMicrotasks(); + // Retry for the new generation is now pending; still no duplicate plain emit. + assert.deepEqual(emitted, [{ text: '行', tokens: null }]); + + resolvers[1]?.({ text: '行', tokens: [] }); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: '行', tokens: null }, + { text: '行', tokens: [{ value: 2 } as never] }, + ]); +}); + +test('failed refresh does not downgrade an already emitted tokenized subtitle', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + if (tokenizeCalls > 1) { + throw new Error('tokenizer gone'); + } + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('行'); + await flushMicrotasks(); + controller.invalidateTokenizationCache(); + controller.refreshCurrentSubtitle(); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 2); + assert.deepEqual(emitted, [ + { text: '行', tokens: null }, + { text: '行', tokens: [] }, + ]); +}); + +test('null-tokenization refresh does not downgrade an already emitted tokenized subtitle', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return tokenizeCalls > 1 ? null : { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('行'); + await flushMicrotasks(); + controller.invalidateTokenizationCache(); + controller.refreshCurrentSubtitle(); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 2); + assert.deepEqual(emitted, [ + { text: '行', tokens: null }, + { text: '行', tokens: [] }, + ]); +}); + +test('subtitle processing does not emit plain payload for cached lines', async () => { + const emitted: SubtitleData[] = []; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.preCacheTokenization('字幕', { text: '字幕', tokens: [] }); controller.onSubtitleChange('字幕'); await flushMicrotasks(); assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]); }); +test('subtitle processing shows plain line while tokenization is still pending', async () => { + const emitted: SubtitleData[] = []; + let resolveTokenization: ((value: SubtitleData | null) => void) | undefined; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => + await new Promise((resolve) => { + resolveTokenization = () => resolve({ text, tokens: [] }); + }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('遅い行'); + await flushMicrotasks(); + assert.deepEqual(emitted, [{ text: '遅い行', tokens: null }]); + + assert.ok(resolveTokenization); + resolveTokenization({ text: '遅い行', tokens: [] }); + await flushMicrotasks(); + assert.deepEqual(emitted, [ + { text: '遅い行', tokens: null }, + { text: '遅い行', tokens: [] }, + ]); +}); + test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => { const emitted: SubtitleData[] = []; let firstResolve: ((value: SubtitleData | null) => void) | undefined; @@ -41,7 +161,11 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle await flushMicrotasks(); await flushMicrotasks(); - assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]); + assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, + { text: 'second', tokens: null }, + { text: 'second', tokens: [] }, + ]); }); test('subtitle processing skips duplicate subtitle emission', async () => { @@ -60,7 +184,10 @@ test('subtitle processing skips duplicate subtitle emission', async () => { controller.onSubtitleChange('same'); await flushMicrotasks(); - assert.equal(emitted.length, 1); + assert.deepEqual(emitted, [ + { text: 'same', tokens: null }, + { text: 'same', tokens: [] }, + ]); assert.equal(tokenizeCalls, 1); }); @@ -84,7 +211,9 @@ test('subtitle processing reuses cached tokenization for repeated subtitle text' assert.equal(tokenizeCalls, 2); assert.deepEqual(emitted, [ + { text: 'first', tokens: null }, { text: 'first', tokens: [] }, + { text: 'second', tokens: null }, { text: 'second', tokens: [] }, { text: 'first', tokens: [] }, ]); @@ -100,7 +229,48 @@ test('subtitle processing falls back to plain subtitle when tokenization returns controller.onSubtitleChange('fallback'); await flushMicrotasks(); + assert.deepEqual( + emitted, + [{ text: 'fallback', tokens: null }], + 'plain payload should not be re-emitted when tokenization yields nothing new', + ); +}); + +test('null tokenization is not cached and a later cue retries tokenization', async () => { + const emitted: SubtitleData[] = []; + const callsByText = new Map(); + let failNext = true; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + callsByText.set(text, (callsByText.get(text) ?? 0) + 1); + if (text === 'fallback' && failNext) { + failNext = false; + return null; + } + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('fallback'); + await flushMicrotasks(); + + assert.equal(callsByText.get('fallback'), 1); + assert.equal( + controller.hasCachedSubtitle('fallback'), + false, + 'plain fallback must not be cached when tokenization yields nothing', + ); assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]); + + controller.onSubtitleChange('other'); + await flushMicrotasks(); + controller.onSubtitleChange('fallback'); + await flushMicrotasks(); + + assert.equal(callsByText.get('fallback'), 2, 'later cue should retry tokenization'); + assert.equal(controller.hasCachedSubtitle('fallback'), true); + assert.deepEqual(emitted.at(-1), { text: 'fallback', tokens: [] }); }); test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => { @@ -120,7 +290,10 @@ test('subtitle processing ignores duplicate current subtitle refresh without cac await flushMicrotasks(); assert.equal(tokenizeCalls, 1); - assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]); + assert.deepEqual(emitted, [ + { text: 'same', tokens: null }, + { text: 'same', tokens: [] }, + ]); }); test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => { @@ -146,7 +319,10 @@ test('subtitle processing coalesces refresh requests while current subtitle is p await flushMicrotasks(); assert.equal(tokenizeCalls, 1); - assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]); + assert.deepEqual(emitted, [ + { text: 'same', tokens: null }, + { text: 'same', tokens: [] }, + ]); }); test('subtitle processing refresh re-tokenizes after cache invalidation', async () => { @@ -168,6 +344,7 @@ test('subtitle processing refresh re-tokenizes after cache invalidation', async assert.equal(tokenizeCalls, 2); assert.deepEqual(emitted, [ + { text: 'same', tokens: null }, { text: 'same', tokens: [{ value: 1 } as never] }, { text: 'same', tokens: [{ value: 2 } as never] }, ]); @@ -183,7 +360,10 @@ test('subtitle processing refresh can use explicit text override', async () => { controller.refreshCurrentSubtitle('initial'); await flushMicrotasks(); - assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]); + assert.deepEqual(emitted, [ + { text: 'initial', tokens: null }, + { text: 'initial', tokens: [] }, + ]); }); test('subtitle processing cache invalidation only affects future subtitle events', async () => { @@ -205,10 +385,10 @@ test('subtitle processing cache invalidation only affects future subtitle events await flushMicrotasks(); assert.equal(callsByText.get('same'), 1); - assert.equal(emitted.length, 3); + assert.equal(emitted.length, 5); controller.invalidateTokenizationCache(); - assert.equal(emitted.length, 3); + assert.equal(emitted.length, 5); controller.onSubtitleChange('different'); await flushMicrotasks(); diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index 8a70538b..cc1a6cf5 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -38,6 +38,9 @@ export function createSubtitleProcessingController( : DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT; let latestText = ''; let lastEmittedText = ''; + // Tracks the latest provisional plain emit across rapid changes and loop retries + // so the same line is never shown plain twice. + let lastPlainEmittedText: string | null = null; let cacheGeneration = 0; let lastEmittedGeneration = 0; let processing = false; @@ -81,9 +84,12 @@ export function createSubtitleProcessingController( const startedAtMs = now(); if (!text.trim()) { - deps.emitSubtitle({ text, tokens: null }); + if (lastPlainEmittedText !== text) { + deps.emitSubtitle({ text, tokens: null }); + } lastEmittedText = text; lastEmittedGeneration = generation; + lastPlainEmittedText = null; break; } @@ -93,11 +99,25 @@ export function createSubtitleProcessingController( if (cachedTokenized) { output = cachedTokenized; } else { + // Cache miss: show the plain line on time; the tokenized payload + // upgrades it once ready. Skipped on refreshes of an already + // emitted line so downstream consumers never see a downgrade. + if (text !== lastEmittedText && text !== lastPlainEmittedText) { + deps.emitSubtitle({ text, tokens: null }); + lastPlainEmittedText = text; + } const tokenized = await deps.tokenizeSubtitle(text); + // A null result is a transient tokenizer failure, not a verdict on + // the line: caching the plain fallback would pin it untokenized for + // every later occurrence. if (tokenized) { output = tokenized; + // A result computed before an invalidation must not repopulate the + // fresh cache, or the retry below would serve the stale entry. + if (generation === cacheGeneration) { + setCachedTokenization(text, tokenized); + } } - setCachedTokenization(text, output); } } catch (error) { deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`); @@ -118,9 +138,16 @@ export function createSubtitleProcessingController( continue; } - deps.emitSubtitle(output); + // An untokenized result adds nothing when this line was already shown, + // either provisionally or as an earlier full emit (failed refresh) — + // emitting it would duplicate or downgrade what is on screen. + const plainAlreadyShown = lastPlainEmittedText === text || lastEmittedText === text; + if (!(output.tokens === null && output.text === text && plainAlreadyShown)) { + deps.emitSubtitle(output); + } lastEmittedText = text; lastEmittedGeneration = generation; + lastPlainEmittedText = null; deps.logDebug?.( `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`, ); @@ -147,6 +174,14 @@ export function createSubtitleProcessingController( return; } latestText = text; + if ( + processing && + text !== lastPlainEmittedText && + !tokenizationCache.has(normalizeSubtitleCacheKey(text)) + ) { + deps.emitSubtitle({ text, tokens: null }); + lastPlainEmittedText = text; + } processLatest(); }, refreshCurrentSubtitle: (textOverride?: string) => { @@ -180,6 +215,7 @@ export function createSubtitleProcessingController( latestText = text; lastEmittedText = text; lastEmittedGeneration = cacheGeneration; + lastPlainEmittedText = null; return cached; }, hasCachedSubtitle: (text: string) => { diff --git a/src/core/services/subtitle-ws.test.ts b/src/core/services/subtitle-ws.test.ts index b8a1312a..0375e31b 100644 --- a/src/core/services/subtitle-ws.test.ts +++ b/src/core/services/subtitle-ws.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + isSubtitleAnnotationUpgrade, serializeInitialSubtitleWebsocketMessage, serializeSubtitleMarkup, serializeSubtitleWebsocketMessage, @@ -13,6 +14,40 @@ const frequencyOptions = { mode: 'banded' as const, }; +test('annotation upgrade requires matching text and cue timing', () => { + const current: SubtitleData = { + text: '字幕', + tokens: null, + startTime: 10, + endTime: 12, + }; + + assert.equal( + isSubtitleAnnotationUpgrade(current, { + ...current, + tokens: [], + }), + true, + ); + assert.equal( + isSubtitleAnnotationUpgrade(current, { + ...current, + tokens: [], + startTime: 11, + }), + false, + ); + assert.equal( + isSubtitleAnnotationUpgrade(current, { + ...current, + text: '次の字幕', + tokens: [], + }), + false, + ); + assert.equal(isSubtitleAnnotationUpgrade(current, current), false); +}); + test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => { const payload: SubtitleData = { text: 'a < b\nx & y', diff --git a/src/core/services/subtitle-ws.ts b/src/core/services/subtitle-ws.ts index b8cb48ed..e9a57dbf 100644 --- a/src/core/services/subtitle-ws.ts +++ b/src/core/services/subtitle-ws.ts @@ -20,6 +20,20 @@ export type SubtitleWebsocketFrequencyOptions = { export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated'; +export function isSubtitleAnnotationUpgrade( + current: SubtitleData | null, + next: SubtitleData, +): boolean { + return ( + current !== null && + current.tokens === null && + next.tokens !== null && + current.text === next.text && + current.startTime === next.startTime && + current.endTime === next.endTime + ); +} + type SubtitleWebsocketMessageOptions = { payloadMode?: SubtitleWebsocketPayloadMode; }; diff --git a/src/main.ts b/src/main.ts index 854a33b1..9b28f23f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -295,6 +295,7 @@ import { importYomitanDictionaryFromZip, initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, initializeOverlayRuntime as initializeOverlayRuntimeCore, + isSubtitleAnnotationUpgrade, isOverlayWindowContentReady, jellyfinTicksToSecondsRuntime, listJellyfinItemsRuntime, @@ -1817,6 +1818,8 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { } function emitSubtitlePayload(payload: SubtitleData): void { const timedPayload = withCurrentSubtitleTiming(payload); + const currentSubtitleData = appState.currentSubtitleData; + const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload); const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; const frequencyOptions = { enabled: frequencyDictionary.enabled, @@ -1825,7 +1828,9 @@ function emitSubtitlePayload(payload: SubtitleData): void { }; appState.currentSubtitleData = timedPayload; overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload); - subtitleWsService.broadcast(timedPayload, frequencyOptions); + if (!isAnnotationUpgrade) { + subtitleWsService.broadcast(timedPayload, frequencyOptions); + } annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions); autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true }); subtitlePrefetchService?.resume(); diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index db3f96da..60358e1d 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -613,6 +613,35 @@ 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 \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(emitBlock); + assert.match( + emitBlock, + /const isAnnotationUpgrade = isSubtitleAnnotationUpgrade\(currentSubtitleData, timedPayload\);/, + ); + assert.match( + emitBlock, + /if \(!isAnnotationUpgrade\) \{\s+subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);\s+\}/, + ); + assert.equal( + (emitBlock.match(/overlayManager\.broadcastToOverlayWindows\('subtitle:set'/g) ?? []).length, + 1, + ); + assert.equal( + ( + emitBlock.match( + /annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\)/g, + ) ?? [] + ).length, + 1, + ); +}); + test('websocket frequency options callbacks each read one configuration snapshot', () => { const source = readMainSource(); const subtitleBlock = source.match(