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:
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-196
|
||||
title: Fix subtitle prefetch cache-key mismatch and active-cue window
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-18 16:05'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts
|
||||
documentation: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate and fix file-backed subtitle annotation latency where prefetch should warm upcoming lines but live playback still tokenizes each subtitle line. Likely causes: cache-key mismatch between parsed cue text and mpv `sub-text`, and priority-window selection skipping the currently active cue during mid-line starts/seeks.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Prefetched subtitle entries are reused when live subtitle text differs only by normalization details such as ASS `\N`, newline collapsing, or surrounding whitespace.
|
||||
- [x] #2 Priority-window selection includes the currently active cue when playback starts or seeks into the middle of a cue.
|
||||
- [x] #3 Regression tests cover the cache-hit normalization path and active-cue priority-window behavior.
|
||||
- [x] #4 Verification covers the touched prefetch/controller lane.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing regression tests in `subtitle-processing-controller.test.ts` and `subtitle-prefetch.test.ts`.
|
||||
2. Normalize cache keys in the subtitle processing controller so prefetch/live paths share keys.
|
||||
3. Adjust prefetch priority-window selection to include the active cue.
|
||||
4. Run targeted tests, then SubMiner verification lane for touched files.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
Normalized subtitle cache keys inside the processing controller so prefetched ASS/VTT/live subtitle text variants reuse the same cache entry, and changed priority-window selection to include the currently active cue based on cue end time. Added regression coverage for both paths and verified the change with the `core` lane.
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: TASK-197
|
||||
title: Eliminate per-line plain subtitle flash on prefetch cache hit
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-18 16:28'
|
||||
labels: []
|
||||
dependencies:
|
||||
- TASK-196
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts
|
||||
documentation: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Remove the remaining small per-line subtitle annotation delay after prefetch warmup by avoiding the unconditional plain-subtitle broadcast on mpv subtitle-change events when a cached annotated payload already exists.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 On a subtitle cache hit, the mpv subtitle-change path can emit annotated subtitle payload synchronously instead of first broadcasting `tokens: null`.
|
||||
- [x] #2 Cache-miss behavior still preserves immediate plain-text subtitle display while async tokenization runs.
|
||||
- [x] #3 Regression tests cover the controller cache-consume path and the mpv subtitle-change handler cache-hit branch.
|
||||
- [x] #4 Verification covers the touched core/runtime lane.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing tests for controller cache consumption and mpv subtitle-change immediate annotated emission.
|
||||
2. Add a controller method that consumes cached subtitle payload synchronously while updating internal latest/emitted state.
|
||||
3. Wire the mpv subtitle-change handler to use the immediate cached payload when present, falling back to the existing plain-text path on misses.
|
||||
4. Run focused tests and the cheapest sufficient verification lane.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
Added `consumeCachedSubtitle` to the subtitle processing controller so cache hits can be claimed synchronously without reprocessing, then wired the mpv subtitle-change handler to emit cached annotated payloads immediately while preserving the existing plain-text fallback for misses. Verified with focused unit tests plus the `runtime-compat` lane.
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: TASK-199
|
||||
title: Forward launcher log level into mpv plugin script opts
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-18 21:16'
|
||||
labels: []
|
||||
dependencies:
|
||||
- TASK-198
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/launcher/mpv.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/launcher/main.test.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.test.ts
|
||||
documentation: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make `subminer --log-level=debug ...` reach the mpv plugin auto-start path by forwarding the launcher log level into `--script-opts`, so plugin-started overlay and texthooker subprocesses inherit debug logging.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Launcher mpv playback includes `subminer-log_level=<level>` in `--script-opts` when a non-info CLI log level is used.
|
||||
- [x] #2 Detached idle mpv launch uses the same script-opt forwarding.
|
||||
- [x] #3 Regression tests cover launcher script-opt forwarding.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add a failing launcher regression test that captures mpv argv and expects `subminer-log_level=debug` inside `--script-opts`.
|
||||
2. Extend the shared script-opt builder to accept launcher log level and emit `subminer-log_level` for non-info runs.
|
||||
3. Reuse that builder in both normal mpv playback and detached idle mpv launch.
|
||||
4. Run focused launcher tests and launcher-plugin verification.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
Forwarded launcher log level into mpv plugin script opts via the shared builder and reused that builder for idle mpv launch. `subminer --log-level=debug ...` now gives the plugin `opts.log_level=debug`, so auto-started overlay and texthooker subprocesses include `--log-level debug` and the tokenizer timing logs can actually appear in the app log.
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
@@ -145,19 +145,25 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
|
||||
});
|
||||
|
||||
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||
title: "Frieren: Beyond Journey's End",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
malId: 1234,
|
||||
introStart: 30.5,
|
||||
introEnd: 62,
|
||||
lookupStatus: 'ready',
|
||||
});
|
||||
const opts = buildSubminerScriptOpts(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
title: "Frieren: Beyond Journey's End",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
malId: 1234,
|
||||
introStart: 30.5,
|
||||
introEnd: 62,
|
||||
lookupStatus: 'ready',
|
||||
},
|
||||
'debug',
|
||||
);
|
||||
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||
assert.match(opts, /subminer-log_level=debug/);
|
||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
||||
assert.match(opts, /subminer-aniskip_season=1/);
|
||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { LogLevel } from './types.js';
|
||||
import { commandExists } from './util.js';
|
||||
|
||||
export type AniSkipLookupStatus =
|
||||
@@ -551,11 +552,15 @@ export function buildSubminerScriptOpts(
|
||||
appPath: string,
|
||||
socketPath: string,
|
||||
aniSkipMetadata: AniSkipMetadata | null,
|
||||
logLevel: LogLevel = 'info',
|
||||
): string {
|
||||
const parts = [
|
||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||
];
|
||||
if (logLevel !== 'info') {
|
||||
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.title) {
|
||||
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
|
||||
}
|
||||
|
||||
@@ -387,6 +387,76 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const binDir = path.join(root, 'bin');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const videoPath = path.join(root, 'movie.mkv');
|
||||
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
||||
const socketPath = path.join(root, 'mpv.sock');
|
||||
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-08T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'mpv'),
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
|
||||
socket_path=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--input-ipc-server=*)
|
||||
socket_path="\${arg#--input-ipc-server=}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||
};
|
||||
const result = runLauncher(['--log-level', 'debug', videoPath], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(
|
||||
fs.readFileSync(mpvArgsPath, 'utf8'),
|
||||
/--script-opts=.*subminer-log_level=debug/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -576,7 +576,7 @@ export async function startMpv(
|
||||
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
|
||||
? await resolveAniSkipMetadataForFile(target)
|
||||
: null;
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel);
|
||||
if (aniSkipMetadata) {
|
||||
log(
|
||||
'debug',
|
||||
@@ -939,9 +939,7 @@ export function launchMpvIdleDetached(
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
mpvArgs.push('--idle=yes');
|
||||
mpvArgs.push(
|
||||
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
|
||||
);
|
||||
mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
||||
|
||||
@@ -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