mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-04 07:21:32 -07:00
fix(subtitles): re-annotate invalidated text during autoplay priming
Autoplay priming called onSubtitleChange after a cache miss, which only asks whether the text is new. When the miss came from an invalidation (mining a card while the line is on screen) the text was unchanged, so nothing was scheduled and the line stayed unannotated for as long as it was displayed. refreshCurrentSubtitle checks the cache generation as well, so it re-tokenizes for the new generation; the resume fallback is kept for the case where it genuinely has nothing to do. refreshCurrentSubtitle also returned false for empty text while a run was in flight, even though that run goes on to emit the empty subtitle. It now reports the pending emit so callers do not release the prefetch pause early. The priming tests now drive the real subtitle processing controller instead of a stub. The previous stub encoded the wrong assumption about unchanged text and so could not catch either bug.
This commit is contained in:
@@ -11,4 +11,5 @@ area: subtitles
|
||||
- Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens.
|
||||
- Subtitle prefetching no longer stays paused for the rest of a cue when the same subtitle text is reported twice and there is nothing to tokenize. This covers the startup and overlay priming paths as well as ordinary subtitle changes.
|
||||
- Character name and image lookups are now refreshed centrally whenever a character dictionary sync changes its content, so a newly added name can no longer be skipped by a stale candidate list.
|
||||
- A subtitle that was on screen when its annotations were invalidated (by mining a card, for example) is now re-annotated instead of staying plain for the rest of the line.
|
||||
- Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name.
|
||||
|
||||
@@ -558,3 +558,47 @@ test('onSubtitleChange reports whether processing was scheduled', async () => {
|
||||
await flushMicrotasks();
|
||||
assert.equal(emitted.length, emittedCount);
|
||||
});
|
||||
|
||||
test('refreshCurrentSubtitle reports the empty-text emit that an in-flight run will deliver', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolveFirst: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === '字幕') {
|
||||
return await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('字幕');
|
||||
await flushMicrotasks();
|
||||
|
||||
// Clearing the subtitle while tokenization is in flight: the running loop
|
||||
// picks the empty text up and emits it, so callers gated on that emit (the
|
||||
// prefetch pause) must be told one is coming.
|
||||
assert.equal(controller.refreshCurrentSubtitle(''), true);
|
||||
|
||||
resolveFirst?.({ text: '字幕', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(
|
||||
emitted.map((payload) => payload.text),
|
||||
[''],
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshCurrentSubtitle reports no emit for empty text when nothing is running', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
assert.equal(controller.refreshCurrentSubtitle(''), false);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, []);
|
||||
});
|
||||
|
||||
@@ -197,7 +197,9 @@ export function createSubtitleProcessingController(
|
||||
latestText = textOverride;
|
||||
}
|
||||
if (!latestText.trim()) {
|
||||
return false;
|
||||
// A run in flight will pick this up and emit the empty subtitle, so
|
||||
// the caller is still waiting on an emit.
|
||||
return processing;
|
||||
}
|
||||
if (processing) {
|
||||
return true;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import {
|
||||
createAutoplaySubtitlePrimingRuntime,
|
||||
setMpvCurrentSecondarySubText,
|
||||
@@ -132,7 +134,9 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
|
||||
'set:起動字幕',
|
||||
'prefetch:pause',
|
||||
'emit:起動字幕:resume=false',
|
||||
'change:起動字幕',
|
||||
// Uncached priming refreshes rather than announcing a change, so an
|
||||
// invalidated-but-unchanged line is still re-tokenized.
|
||||
'refresh:起動字幕',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -198,53 +202,63 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
||||
'set:起動字幕',
|
||||
'prefetch:pause',
|
||||
'emit:起動字幕:resume=false',
|
||||
'change:起動字幕',
|
||||
// Uncached priming refreshes rather than announcing a change, so an
|
||||
// invalidated-but-unchanged line is still re-tokenized.
|
||||
'refresh:起動字幕',
|
||||
]);
|
||||
});
|
||||
|
||||
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when no tokenization is scheduled', async () => {
|
||||
const calls: string[] = [];
|
||||
// Driven by the real processing controller rather than a stub: the failure this
|
||||
// covers is a disagreement between the priming path and the controller's own
|
||||
// staleness rules, which a hand-written stub cannot reproduce.
|
||||
function createPrimingRuntimeWithRealController(options: {
|
||||
text: string;
|
||||
calls: string[];
|
||||
onTokenize: () => void;
|
||||
cacheLimit?: number;
|
||||
}) {
|
||||
const { text, calls } = options;
|
||||
let currentSubText = '';
|
||||
let currentSubtitleData: SubtitleData | null = null;
|
||||
const mediaPath = '/media/video.mkv';
|
||||
|
||||
const subtitleProcessingController = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (subtitleText) => {
|
||||
options.onTokenize();
|
||||
return { text: subtitleText, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => {
|
||||
currentSubtitleData = payload;
|
||||
calls.push(`emit:${payload.text}:tokens=${payload.tokens === null ? 'none' : 'yes'}`);
|
||||
},
|
||||
...(options.cacheLimit === undefined ? {} : { cacheLimit: options.cacheLimit }),
|
||||
});
|
||||
|
||||
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||
getCurrentMediaPath: () => mediaPath,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: mediaPath,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'sub-text') return '起動字幕';
|
||||
return null;
|
||||
},
|
||||
requestProperty: async (name) => (name === 'sub-text' ? text : null),
|
||||
}),
|
||||
setCurrentSubText: (text) => {
|
||||
currentSubText = text;
|
||||
setCurrentSubText: (value) => {
|
||||
currentSubText = value;
|
||||
},
|
||||
getCurrentSubText: () => currentSubText,
|
||||
getCurrentSubtitleData: () => null,
|
||||
getCurrentSubtitleData: () => currentSubtitleData,
|
||||
getActiveParsedSubtitleCues: () => [],
|
||||
setActiveParsedSubtitleMediaPath: () => {},
|
||||
subtitleProcessingController: {
|
||||
// The tokenization cache was invalidated (for example by mining a card),
|
||||
// so the cached payload is gone...
|
||||
consumeCachedSubtitle: () => null,
|
||||
// ...but the controller still holds this text, so it schedules nothing
|
||||
// and no emit will arrive to release the pause.
|
||||
onSubtitleChange: (text) => {
|
||||
calls.push(`change:${text}`);
|
||||
return false;
|
||||
},
|
||||
refreshCurrentSubtitle: () => true,
|
||||
subtitleProcessingController,
|
||||
emitSubtitlePayload: (payload, emitOptions) => {
|
||||
if (emitOptions?.resumePrefetch === false) {
|
||||
calls.push(`emit-raw:${payload.text}`);
|
||||
return;
|
||||
}
|
||||
calls.push(`emit-direct:${payload.text}`);
|
||||
},
|
||||
emitSubtitlePayload: (payload, options) =>
|
||||
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
|
||||
getSubtitlePrefetchService: () => ({
|
||||
pause: () => {
|
||||
calls.push('prefetch:pause');
|
||||
},
|
||||
resume: () => {
|
||||
calls.push('prefetch:resume');
|
||||
},
|
||||
pause: () => calls.push('prefetch:pause'),
|
||||
resume: () => calls.push('prefetch:resume'),
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -254,12 +268,71 @@ test('primeCurrentSubtitleForAutoplay releases the prefetch pause when no tokeni
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
return { runtime, subtitleProcessingController, mediaPath };
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'prefetch:pause',
|
||||
'emit:起動字幕:resume=false',
|
||||
'change:起動字幕',
|
||||
'prefetch:resume',
|
||||
]);
|
||||
test('primeCurrentSubtitleForAutoplay re-tokenizes text whose cached annotation was invalidated', async () => {
|
||||
const calls: string[] = [];
|
||||
let tokenizations = 0;
|
||||
const text = '起動字幕';
|
||||
const { runtime, subtitleProcessingController, mediaPath } =
|
||||
createPrimingRuntimeWithRealController({
|
||||
text,
|
||||
calls,
|
||||
onTokenize: () => {
|
||||
tokenizations += 1;
|
||||
},
|
||||
});
|
||||
|
||||
// The line was already tokenized and cached during normal playback.
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const tokenizationsBeforeInvalidation = tokenizations;
|
||||
|
||||
// Mining a card drops every cached tokenization.
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
calls.length = 0;
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The cache miss must schedule fresh work, or the line stays unannotated for
|
||||
// as long as it is on screen.
|
||||
assert.equal(
|
||||
tokenizations,
|
||||
tokenizationsBeforeInvalidation + 1,
|
||||
'expected the invalidated subtitle to be tokenized again',
|
||||
);
|
||||
assert.ok(
|
||||
calls.includes(`emit:${text}:tokens=yes`),
|
||||
`expected an annotated emit, saw ${JSON.stringify(calls)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when nothing is scheduled', async () => {
|
||||
const calls: string[] = [];
|
||||
const text = '起動字幕';
|
||||
const { runtime, subtitleProcessingController, mediaPath } =
|
||||
createPrimingRuntimeWithRealController({
|
||||
text,
|
||||
calls,
|
||||
onTokenize: () => {},
|
||||
cacheLimit: 1,
|
||||
});
|
||||
|
||||
// Emitted at the current cache generation, then evicted from the one-entry
|
||||
// cache: priming misses the cache but the controller has nothing to redo, so
|
||||
// no emit is coming and the pause must be released here.
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
subtitleProcessingController.preCacheTokenization('別の字幕', {
|
||||
text: '別の字幕',
|
||||
tokens: [],
|
||||
});
|
||||
calls.length = 0;
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`, 'prefetch:resume']);
|
||||
});
|
||||
|
||||
@@ -125,9 +125,13 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
// Provisional raw emit: keep prefetch paused until the tokenized payload
|
||||
// for this line is delivered by the processing controller.
|
||||
emitSubtitlePayload({ text, tokens: null }, { resumePrefetch: false });
|
||||
if (!subtitleProcessingController.onSubtitleChange(text)) {
|
||||
// Cache miss on text the controller already holds (it was invalidated
|
||||
// under us): nothing will be tokenized, so no emit is coming.
|
||||
// refreshCurrentSubtitle, not onSubtitleChange: the cache miss above can be
|
||||
// an invalidation (mining a card) on text the controller still holds, and
|
||||
// onSubtitleChange treats unchanged text as nothing to do, which would
|
||||
// leave this line permanently unannotated. refreshCurrentSubtitle also
|
||||
// re-tokenizes for a new cache generation.
|
||||
if (!subtitleProcessingController.refreshCurrentSubtitle(text)) {
|
||||
// Nothing scheduled, so no emit is coming to release the pause.
|
||||
deps.getSubtitlePrefetchService()?.resume();
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user