mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: reduce prefetched subtitle annotation delay
This commit is contained in:
@@ -17,18 +17,19 @@ test('computePriorityWindow returns next N cues from current position', () => {
|
||||
const window = computePriorityWindow(cues, 12.0, 5);
|
||||
|
||||
assert.equal(window.length, 5);
|
||||
// Position 12.0 falls during cue 2, so the window starts at cue 3 (startTime >= 12.0).
|
||||
assert.equal(window[0]!.text, 'line-3');
|
||||
assert.equal(window[4]!.text, 'line-7');
|
||||
// Position 12.0 falls during cue 2, so the active cue should be warmed first.
|
||||
assert.equal(window[0]!.text, 'line-2');
|
||||
assert.equal(window[4]!.text, 'line-6');
|
||||
});
|
||||
|
||||
test('computePriorityWindow clamps to remaining cues at end of file', () => {
|
||||
const cues = makeCues(5);
|
||||
const window = computePriorityWindow(cues, 18.0, 10);
|
||||
|
||||
// Position 18.0 is during cue 3 (start=15). Only cue 4 is ahead.
|
||||
assert.equal(window.length, 1);
|
||||
assert.equal(window[0]!.text, 'line-4');
|
||||
// Position 18.0 is during cue 3 (start=15), so cue 3 and cue 4 remain.
|
||||
assert.equal(window.length, 2);
|
||||
assert.equal(window[0]!.text, 'line-3');
|
||||
assert.equal(window[1]!.text, 'line-4');
|
||||
});
|
||||
|
||||
test('computePriorityWindow returns empty when past all cues', () => {
|
||||
@@ -45,6 +46,16 @@ test('computePriorityWindow at position 0 returns first N cues', () => {
|
||||
assert.equal(window[0]!.text, 'line-0');
|
||||
});
|
||||
|
||||
test('computePriorityWindow includes the active cue when current position is mid-line', () => {
|
||||
const cues = makeCues(20);
|
||||
const window = computePriorityWindow(cues, 18.0, 3);
|
||||
|
||||
assert.equal(window.length, 3);
|
||||
assert.equal(window[0]!.text, 'line-3');
|
||||
assert.equal(window[1]!.text, 'line-4');
|
||||
assert.equal(window[2]!.text, 'line-5');
|
||||
});
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ export function computePriorityWindow(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the first cue whose start time is >= current position.
|
||||
// This includes cues that start exactly at the current time (they haven't
|
||||
// been displayed yet and should be prefetched).
|
||||
// Find the first cue whose end time is after the current position.
|
||||
// This includes the currently active cue when playback starts or seeks
|
||||
// mid-line, while still skipping cues that have already finished.
|
||||
let startIndex = -1;
|
||||
for (let i = 0; i < cues.length; i += 1) {
|
||||
if (cues[i]!.startTime >= currentTimeSeconds) {
|
||||
if (cues[i]!.endTime > currentTimeSeconds) {
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -190,6 +190,48 @@ test('preCacheTokenization stores entry that is returned on next subtitle change
|
||||
assert.deepEqual(emitted, [{ text: '予め', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('preCacheTokenization reuses normalized subtitle text across ASS linebreak variants', 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: [] });
|
||||
controller.onSubtitleChange('一行目\n二行目');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 0, 'should not call tokenize when normalized text matches');
|
||||
assert.deepEqual(emitted, [{ text: '一行目\n二行目', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing same line', 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: [] });
|
||||
|
||||
const immediate = controller.consumeCachedSubtitle('猫\nです');
|
||||
assert.deepEqual(immediate, { text: '猫\nです', tokens: [] });
|
||||
|
||||
controller.onSubtitleChange('猫\nです');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 0, 'same cached subtitle should not reprocess after immediate consume');
|
||||
assert.deepEqual(emitted, []);
|
||||
});
|
||||
|
||||
test('isCacheFull returns false when cache is below limit', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
|
||||
|
||||
@@ -12,9 +12,14 @@ export interface SubtitleProcessingController {
|
||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
isCacheFull: () => boolean;
|
||||
}
|
||||
|
||||
function normalizeSubtitleCacheKey(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
export function createSubtitleProcessingController(
|
||||
deps: SubtitleProcessingControllerDeps,
|
||||
): SubtitleProcessingController {
|
||||
@@ -28,18 +33,19 @@ export function createSubtitleProcessingController(
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
const getCachedTokenization = (text: string): SubtitleData | null => {
|
||||
const cached = tokenizationCache.get(text);
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tokenizationCache.delete(text);
|
||||
tokenizationCache.set(text, cached);
|
||||
tokenizationCache.delete(cacheKey);
|
||||
tokenizationCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
};
|
||||
|
||||
const setCachedTokenization = (text: string, payload: SubtitleData): void => {
|
||||
tokenizationCache.set(text, payload);
|
||||
tokenizationCache.set(normalizeSubtitleCacheKey(text), payload);
|
||||
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
|
||||
const firstKey = tokenizationCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
@@ -135,6 +141,17 @@ export function createSubtitleProcessingController(
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => {
|
||||
setCachedTokenization(text, data);
|
||||
},
|
||||
consumeCachedSubtitle: (text: string) => {
|
||||
const cached = getCachedTokenization(text);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
latestText = text;
|
||||
lastEmittedText = text;
|
||||
refreshRequested = false;
|
||||
return cached;
|
||||
},
|
||||
isCacheFull: () => {
|
||||
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||
},
|
||||
|
||||
35
src/main.ts
35
src/main.ts
@@ -1135,25 +1135,26 @@ function maybeSignalPluginAutoplayReady(
|
||||
|
||||
let appTray: Tray | null = null;
|
||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||
appState.currentSubtitleData = payload;
|
||||
broadcastToOverlayWindows('subtitle:set', payload);
|
||||
subtitleWsService.broadcast(payload, {
|
||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
annotationSubtitleWsService.broadcast(payload, {
|
||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
subtitlePrefetchService?.resume();
|
||||
}
|
||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||
tokenizeSubtitle: async (text: string) =>
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
|
||||
emitSubtitle: (payload) => {
|
||||
appState.currentSubtitleData = payload;
|
||||
broadcastToOverlayWindows('subtitle:set', payload);
|
||||
subtitleWsService.broadcast(payload, {
|
||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
annotationSubtitleWsService.broadcast(payload, {
|
||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
subtitlePrefetchService?.resume();
|
||||
},
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
logDebug: (message) => {
|
||||
logger.debug(`[subtitle-processing] ${message}`);
|
||||
},
|
||||
@@ -3135,6 +3136,10 @@ const {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||
emitImmediateSubtitle: (payload) => {
|
||||
emitSubtitlePayload(payload);
|
||||
},
|
||||
onSubtitleChange: (text) => {
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
|
||||
@@ -16,6 +16,7 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: () => null,
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
@@ -25,6 +26,35 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler broadcasts cached annotated payload immediately when available', () => {
|
||||
const payloads: Array<{ text: string; tokens: unknown[] | null }> = [];
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => {
|
||||
calls.push(`lookup:${text}`);
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
broadcastSubtitle: (payload) => {
|
||||
payloads.push(payload);
|
||||
calls.push(`broadcast:${payload.tokens === null ? 'plain' : 'annotated'}`);
|
||||
},
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ text: 'line' });
|
||||
|
||||
assert.deepEqual(payloads, [{ text: 'line', tokens: [] }]);
|
||||
assert.deepEqual(calls, [
|
||||
'set:line',
|
||||
'lookup:line',
|
||||
'broadcast:annotated',
|
||||
'process:line',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle ass change handler updates state and broadcasts', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleAssChangeHandler({
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
deps.setCurrentSubText(text);
|
||||
deps.broadcastSubtitle({ text, tokens: null });
|
||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||
if (immediatePayload) {
|
||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||
} else {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
deps.onSubtitleChange(text);
|
||||
deps.refreshDiscordPresence();
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
import {
|
||||
createBindMpvClientEventHandlers,
|
||||
createHandleMpvConnectionChangeHandler,
|
||||
@@ -35,7 +36,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
|
||||
setCurrentSubText: (text: string) => void;
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
|
||||
@@ -89,6 +92,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
});
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
|
||||
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
|
||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
|
||||
@@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
@@ -102,7 +104,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
setCurrentSubText: (text: string) => {
|
||||
deps.appState.currentSubText = text;
|
||||
},
|
||||
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
|
||||
getImmediateSubtitlePayload: deps.getImmediateSubtitlePayload
|
||||
? (text: string) => deps.getImmediateSubtitlePayload!(text)
|
||||
: undefined,
|
||||
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
||||
? (payload: SubtitleData) => deps.emitImmediateSubtitle!(payload)
|
||||
: undefined,
|
||||
broadcastSubtitle: (payload: SubtitleData) =>
|
||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||
|
||||
Reference in New Issue
Block a user