mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 07:21:34 -07:00
fix(overlay): show plain subtitle line immediately on tokenization cache miss (#184)
This commit is contained in:
@@ -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.
|
||||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
|||||||
- **Client auth:** none
|
- **Client auth:** none
|
||||||
- **Reconnects:** client-managed
|
- **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
|
#### 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.
|
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
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-06-14
|
Last verified: 2026-08-04
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
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.
|
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||||
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||||
payload.
|
synchronously, then replaces it with the tokenized payload when ready.
|
||||||
|
|
||||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||||
prefetch work and re-centers prefetch around the live playback time.
|
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
|
## Emitted State
|
||||||
|
|
||||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||||
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
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
|
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
`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
|
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export { Texthooker } from './texthooker';
|
export { Texthooker } from './texthooker';
|
||||||
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
|
export {
|
||||||
|
hasMpvWebsocketPlugin,
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
|
SubtitleWebSocket,
|
||||||
|
} from './subtitle-ws';
|
||||||
export { registerGlobalShortcuts } from './shortcut';
|
export { registerGlobalShortcuts } from './shortcut';
|
||||||
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
||||||
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<string, (value: SubtitleData | null) => void>();
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) =>
|
||||||
|
await new Promise<SubtitleData | null>((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<SubtitleData | null>((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<SubtitleData | null>((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<SubtitleData | null>((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<SubtitleData | null>((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: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -7,18 +7,138 @@ function flushMicrotasks(): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
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 emitted: SubtitleData[] = [];
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
emitSubtitle: (payload) => emitted.push(payload),
|
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<SubtitleData | null>((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('字幕');
|
controller.onSubtitleChange('字幕');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
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<SubtitleData | null>((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 () => {
|
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
|
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();
|
||||||
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 () => {
|
test('subtitle processing skips duplicate subtitle emission', async () => {
|
||||||
@@ -60,7 +184,10 @@ test('subtitle processing skips duplicate subtitle emission', async () => {
|
|||||||
controller.onSubtitleChange('same');
|
controller.onSubtitleChange('same');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(emitted.length, 1);
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'same', tokens: null },
|
||||||
|
{ text: 'same', tokens: [] },
|
||||||
|
]);
|
||||||
assert.equal(tokenizeCalls, 1);
|
assert.equal(tokenizeCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +211,9 @@ test('subtitle processing reuses cached tokenization for repeated subtitle text'
|
|||||||
|
|
||||||
assert.equal(tokenizeCalls, 2);
|
assert.equal(tokenizeCalls, 2);
|
||||||
assert.deepEqual(emitted, [
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
{ text: 'first', tokens: [] },
|
{ text: 'first', tokens: [] },
|
||||||
|
{ text: 'second', tokens: null },
|
||||||
{ text: 'second', tokens: [] },
|
{ text: 'second', tokens: [] },
|
||||||
{ text: 'first', tokens: [] },
|
{ text: 'first', tokens: [] },
|
||||||
]);
|
]);
|
||||||
@@ -100,7 +229,48 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
|
|||||||
controller.onSubtitleChange('fallback');
|
controller.onSubtitleChange('fallback');
|
||||||
await flushMicrotasks();
|
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<string, number>();
|
||||||
|
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 }]);
|
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 () => {
|
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();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(tokenizeCalls, 1);
|
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 () => {
|
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();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(tokenizeCalls, 1);
|
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 () => {
|
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.equal(tokenizeCalls, 2);
|
||||||
assert.deepEqual(emitted, [
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'same', tokens: null },
|
||||||
{ text: 'same', tokens: [{ value: 1 } as never] },
|
{ text: 'same', tokens: [{ value: 1 } as never] },
|
||||||
{ text: 'same', tokens: [{ value: 2 } 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');
|
controller.refreshCurrentSubtitle('initial');
|
||||||
await flushMicrotasks();
|
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 () => {
|
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();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(callsByText.get('same'), 1);
|
assert.equal(callsByText.get('same'), 1);
|
||||||
assert.equal(emitted.length, 3);
|
assert.equal(emitted.length, 5);
|
||||||
|
|
||||||
controller.invalidateTokenizationCache();
|
controller.invalidateTokenizationCache();
|
||||||
assert.equal(emitted.length, 3);
|
assert.equal(emitted.length, 5);
|
||||||
|
|
||||||
controller.onSubtitleChange('different');
|
controller.onSubtitleChange('different');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export function createSubtitleProcessingController(
|
|||||||
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||||
let latestText = '';
|
let latestText = '';
|
||||||
let lastEmittedText = '';
|
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 cacheGeneration = 0;
|
||||||
let lastEmittedGeneration = 0;
|
let lastEmittedGeneration = 0;
|
||||||
let processing = false;
|
let processing = false;
|
||||||
@@ -81,9 +84,12 @@ export function createSubtitleProcessingController(
|
|||||||
const startedAtMs = now();
|
const startedAtMs = now();
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
|
if (lastPlainEmittedText !== text) {
|
||||||
deps.emitSubtitle({ text, tokens: null });
|
deps.emitSubtitle({ text, tokens: null });
|
||||||
|
}
|
||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = generation;
|
lastEmittedGeneration = generation;
|
||||||
|
lastPlainEmittedText = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +99,25 @@ export function createSubtitleProcessingController(
|
|||||||
if (cachedTokenized) {
|
if (cachedTokenized) {
|
||||||
output = cachedTokenized;
|
output = cachedTokenized;
|
||||||
} else {
|
} 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);
|
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) {
|
if (tokenized) {
|
||||||
output = 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) {
|
} catch (error) {
|
||||||
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
||||||
@@ -118,9 +138,16 @@ export function createSubtitleProcessingController(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
deps.emitSubtitle(output);
|
||||||
|
}
|
||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = generation;
|
lastEmittedGeneration = generation;
|
||||||
|
lastPlainEmittedText = null;
|
||||||
deps.logDebug?.(
|
deps.logDebug?.(
|
||||||
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
||||||
);
|
);
|
||||||
@@ -147,6 +174,14 @@ export function createSubtitleProcessingController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
latestText = text;
|
latestText = text;
|
||||||
|
if (
|
||||||
|
processing &&
|
||||||
|
text !== lastPlainEmittedText &&
|
||||||
|
!tokenizationCache.has(normalizeSubtitleCacheKey(text))
|
||||||
|
) {
|
||||||
|
deps.emitSubtitle({ text, tokens: null });
|
||||||
|
lastPlainEmittedText = text;
|
||||||
|
}
|
||||||
processLatest();
|
processLatest();
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => {
|
refreshCurrentSubtitle: (textOverride?: string) => {
|
||||||
@@ -180,6 +215,7 @@ export function createSubtitleProcessingController(
|
|||||||
latestText = text;
|
latestText = text;
|
||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = cacheGeneration;
|
lastEmittedGeneration = cacheGeneration;
|
||||||
|
lastPlainEmittedText = null;
|
||||||
return cached;
|
return cached;
|
||||||
},
|
},
|
||||||
hasCachedSubtitle: (text: string) => {
|
hasCachedSubtitle: (text: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
serializeInitialSubtitleWebsocketMessage,
|
serializeInitialSubtitleWebsocketMessage,
|
||||||
serializeSubtitleMarkup,
|
serializeSubtitleMarkup,
|
||||||
serializeSubtitleWebsocketMessage,
|
serializeSubtitleWebsocketMessage,
|
||||||
@@ -13,6 +14,40 @@ const frequencyOptions = {
|
|||||||
mode: 'banded' as const,
|
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', () => {
|
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
|
||||||
const payload: SubtitleData = {
|
const payload: SubtitleData = {
|
||||||
text: 'a < b\nx & y',
|
text: 'a < b\nx & y',
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ export type SubtitleWebsocketFrequencyOptions = {
|
|||||||
|
|
||||||
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
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 = {
|
type SubtitleWebsocketMessageOptions = {
|
||||||
payloadMode?: SubtitleWebsocketPayloadMode;
|
payloadMode?: SubtitleWebsocketPayloadMode;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ import {
|
|||||||
importYomitanDictionaryFromZip,
|
importYomitanDictionaryFromZip,
|
||||||
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
isOverlayWindowContentReady,
|
isOverlayWindowContentReady,
|
||||||
jellyfinTicksToSecondsRuntime,
|
jellyfinTicksToSecondsRuntime,
|
||||||
listJellyfinItemsRuntime,
|
listJellyfinItemsRuntime,
|
||||||
@@ -1817,6 +1818,8 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
|||||||
}
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
|
const currentSubtitleData = appState.currentSubtitleData;
|
||||||
|
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
|
||||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||||
const frequencyOptions = {
|
const frequencyOptions = {
|
||||||
enabled: frequencyDictionary.enabled,
|
enabled: frequencyDictionary.enabled,
|
||||||
@@ -1825,7 +1828,9 @@ function emitSubtitlePayload(payload: SubtitleData): void {
|
|||||||
};
|
};
|
||||||
appState.currentSubtitleData = timedPayload;
|
appState.currentSubtitleData = timedPayload;
|
||||||
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
|
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
|
||||||
|
if (!isAnnotationUpgrade) {
|
||||||
subtitleWsService.broadcast(timedPayload, frequencyOptions);
|
subtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||||
|
}
|
||||||
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
||||||
subtitlePrefetchService?.resume();
|
subtitlePrefetchService?.resume();
|
||||||
|
|||||||
@@ -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 \{(?<body>[\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', () => {
|
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const subtitleBlock = source.match(
|
const subtitleBlock = source.match(
|
||||||
|
|||||||
Reference in New Issue
Block a user