fix: reduce prefetched subtitle annotation delay

This commit is contained in:
2026-03-18 23:47:33 -07:00
parent 7a0d7a488b
commit 4d96ebf5c0
16 changed files with 389 additions and 47 deletions

View File

@@ -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));
}

View File

@@ -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;
}

View File

@@ -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 }),

View File

@@ -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;
},