mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183)
This commit is contained in:
@@ -74,7 +74,6 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
|
||||
preCacheTokenization: (text, data) => {
|
||||
cached.set(text, data);
|
||||
},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -91,32 +90,38 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
|
||||
assert.ok(cached.has('line-2'));
|
||||
});
|
||||
|
||||
test('prefetch service stops when cache is full', async () => {
|
||||
test('prefetch service warms every cue even when the cache evicts along the way', async () => {
|
||||
const cues = makeCues(20);
|
||||
let tokenizeCalls = 0;
|
||||
let cacheSize = 0;
|
||||
const tokenized: string[] = [];
|
||||
// Stand-in for the LRU: only the last 5 entries survive, so later cues evict earlier ones.
|
||||
const cache = new Set<string>();
|
||||
|
||||
const service = createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
tokenized.push(text);
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {
|
||||
cacheSize += 1;
|
||||
preCacheTokenization: (text) => {
|
||||
cache.add(text);
|
||||
while (cache.size > 5) {
|
||||
const oldest = cache.values().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
},
|
||||
isCacheFull: () => cacheSize >= 5,
|
||||
hasCachedTokenization: (text) => cache.has(text),
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
service.start(0);
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
await flushMicrotasks();
|
||||
}
|
||||
service.stop();
|
||||
|
||||
// Should have stopped at 5 (cache full), not tokenized all 20
|
||||
assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`);
|
||||
assert.equal(tokenized.length, 20, `Expected all 20 cues warmed, got ${tokenized.length}`);
|
||||
assert.equal(new Set(tokenized).size, 20, 'Each cue is tokenized at most once per run');
|
||||
});
|
||||
|
||||
test('prefetch service can be stopped mid-flight', async () => {
|
||||
@@ -130,7 +135,6 @@ test('prefetch service can be stopped mid-flight', async () => {
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -159,7 +163,6 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -183,7 +186,7 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
||||
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
||||
});
|
||||
|
||||
test('prefetch service still warms the priority window when cache is full', async () => {
|
||||
test('prefetch service warms the priority window ahead of the rest of the file', async () => {
|
||||
const cues = makeCues(20);
|
||||
const cachedTexts: string[] = [];
|
||||
|
||||
@@ -193,7 +196,6 @@ test('prefetch service still warms the priority window when cache is full', asyn
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
isCacheFull: () => true,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -217,7 +219,6 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -255,7 +256,6 @@ test('prefetch service skips cues already present in tokenization cache', async
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -285,7 +285,6 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
isCacheFull: () => boolean;
|
||||
priorityWindowSize?: number;
|
||||
}
|
||||
|
||||
@@ -57,11 +56,14 @@ export function createSubtitlePrefetchService(
|
||||
let paused = false;
|
||||
let currentRunId = 0;
|
||||
|
||||
// A run is a single bounded pass over one file's cues, deduped by `warmedKeys` and by
|
||||
// `hasCachedTokenization`, so the worst case is one tokenization per cue. The cache is
|
||||
// an LRU and bounds its own memory, so a full cache is not a reason to stop warming;
|
||||
// stopping there used to leave the tail of longer media permanently uncached.
|
||||
async function tokenizeCueList(
|
||||
cuesToProcess: SubtitleCue[],
|
||||
runId: number,
|
||||
warmedKeys: Set<string>,
|
||||
options: { allowWhenCacheFull?: boolean } = {},
|
||||
): Promise<void> {
|
||||
for (const cue of cuesToProcess) {
|
||||
if (stopped || runId !== currentRunId) {
|
||||
@@ -77,10 +79,6 @@ export function createSubtitlePrefetchService(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = normalizeSubtitleCacheKey(cue.text);
|
||||
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
|
||||
if (cacheKey) {
|
||||
@@ -110,7 +108,7 @@ export function createSubtitlePrefetchService(
|
||||
|
||||
// Phase 1: Priority window
|
||||
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
|
||||
await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
|
||||
await tokenizeCueList(priorityCues, runId, warmedKeys);
|
||||
|
||||
if (stopped || runId !== currentRunId) {
|
||||
return;
|
||||
|
||||
@@ -308,25 +308,54 @@ test('hasCachedSubtitle checks prefetched entries without consuming them', async
|
||||
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
|
||||
});
|
||||
|
||||
test('isCacheFull returns false when cache is below limit', () => {
|
||||
test('cache keeps every entry while below the limit', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
cacheLimit: 8,
|
||||
});
|
||||
|
||||
assert.equal(controller.isCacheFull(), false);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 8 }, (_, i) => controller.hasCachedSubtitle(`line-${i}`)),
|
||||
Array.from({ length: 8 }, () => true),
|
||||
);
|
||||
});
|
||||
|
||||
test('isCacheFull returns true when cache reaches limit', async () => {
|
||||
test('cache evicts least recently used entries once the limit is reached', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
cacheLimit: 3,
|
||||
});
|
||||
|
||||
for (const line of ['a', 'b', 'c']) {
|
||||
controller.preCacheTokenization(line, { text: line, tokens: [] });
|
||||
}
|
||||
// Touching 'a' makes 'b' the eviction candidate.
|
||||
controller.consumeCachedSubtitle('a');
|
||||
controller.preCacheTokenization('d', { text: 'd', tokens: [] });
|
||||
|
||||
assert.equal(controller.hasCachedSubtitle('b'), false);
|
||||
assert.deepEqual(
|
||||
['a', 'c', 'd'].map((line) => controller.hasCachedSubtitle(line)),
|
||||
[true, true, true],
|
||||
);
|
||||
});
|
||||
|
||||
test('default cache limit covers a full-length title without evicting', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
});
|
||||
|
||||
// Fill cache to the 256 limit
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||
}
|
||||
|
||||
assert.equal(controller.isCacheFull(), true);
|
||||
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
||||
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,17 @@ export interface SubtitleProcessingControllerDeps {
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
cacheLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure memory bound on the LRU, not a coverage limit: prefetching runs to the end of a
|
||||
* file regardless of cache pressure. Sized to hold a feature-length title (a 24-minute
|
||||
* episode runs 300-400 lines, a 2-hour film ~2000) plus room for lines that repeat across
|
||||
* episodes of a series, so openings and endings stay warm between titles.
|
||||
*/
|
||||
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
|
||||
|
||||
export interface SubtitleProcessingController {
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||
@@ -14,7 +23,6 @@ export interface SubtitleProcessingController {
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
hasCachedSubtitle: (text: string) => boolean;
|
||||
isCacheFull: () => boolean;
|
||||
}
|
||||
|
||||
export function normalizeSubtitleCacheKey(text: string): string {
|
||||
@@ -24,7 +32,10 @@ export function normalizeSubtitleCacheKey(text: string): string {
|
||||
export function createSubtitleProcessingController(
|
||||
deps: SubtitleProcessingControllerDeps,
|
||||
): SubtitleProcessingController {
|
||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
|
||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT =
|
||||
deps.cacheLimit && deps.cacheLimit > 0
|
||||
? deps.cacheLimit
|
||||
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||
let latestText = '';
|
||||
let lastEmittedText = '';
|
||||
let cacheGeneration = 0;
|
||||
@@ -174,8 +185,5 @@ export function createSubtitleProcessingController(
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
|
||||
},
|
||||
isCacheFull: () => {
|
||||
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user