Fix mpv tlang and profile parsing

This commit is contained in:
2026-02-18 19:04:24 -08:00
parent f299f2a19e
commit d1aeb3b754
18 changed files with 537 additions and 88 deletions

View File

@@ -34,10 +34,18 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarmSubtitleDictionaries');
},
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
handleInitialArgs: () => calls.push('handleInitialArgs'),
logDebug: (message) => calls.push(`debug:${message}`),
now: () => 1000,
...overrides,
};
return { deps, calls };
@@ -51,7 +59,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.includes('startJellyfinRemoteSession'));
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
});
@@ -63,10 +71,10 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
await runAppReadyRuntime(deps);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
assert.ok(calls.includes('createMecabTokenizerAndCheck'));
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(
calls.includes('initializeOverlayRuntime') ||
calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'),
@@ -116,3 +124,28 @@ test('runAppReadyRuntime applies config logging level during app-ready', async (
await runAppReadyRuntime(deps);
assert.ok(calls.includes('setLogLevel:warn:config'));
});
test('runAppReadyRuntime does not await background warmups', async () => {
const calls: string[] = [];
let releaseWarmup: (() => void) | undefined;
const warmupGate = new Promise<void>((resolve) => {
releaseWarmup = resolve;
});
const { deps } = makeDeps({
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
void warmupGate.then(() => {
calls.push('warmupDone');
});
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
});
await runAppReadyRuntime(deps);
assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
assert.equal(calls.includes('warmupDone'), false);
assert.ok(releaseWarmup);
releaseWarmup();
});

View File

@@ -30,6 +30,7 @@ export {
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {

View File

@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,

View File

@@ -12,6 +12,7 @@ export interface IpcServiceDeps {
toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
quitApp: () => void;
toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
@@ -122,6 +124,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
toggleVisibleOverlay: options.toggleVisibleOverlay,
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
getSubtitlePosition: options.getSubtitlePosition,
@@ -220,6 +223,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return await deps.tokenizeCurrentSubtitle();
});
ipcMain.handle('get-current-subtitle-raw', () => {
return deps.getCurrentSubtitleRaw();
});
ipcMain.handle('get-current-subtitle-ass', () => {
return deps.getCurrentSubtitleAss();
});

View File

@@ -106,10 +106,14 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
initializeOverlayRuntime: () => void;
handleInitialArgs: () => void;
logDebug?: (message: string) => void;
now?: () => number;
}
export function getInitialInvisibleOverlayVisibility(
@@ -143,9 +147,12 @@ export function isAutoUpdateEnabledRuntime(
}
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
deps.logDebug?.('App-ready critical path started.');
deps.loadSubtitlePosition();
deps.resolveKeybindings();
await deps.createMecabTokenizerAndCheck();
deps.createMpvClient();
deps.reloadConfig();
@@ -178,10 +185,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else {
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
}
await deps.loadYomitanExtension();
if (deps.startJellyfinRemoteSession) {
await deps.startJellyfinRemoteSession();
}
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
@@ -192,4 +195,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
}
deps.handleInitialArgs();
deps.startBackgroundWarmups();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
}

View File

@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubtitleProcessingController } from './subtitle-processing-controller';
import type { SubtitleData } from '../../types';
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('subtitle processing emits plain subtitle immediately before tokenized payload', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('字幕');
assert.deepEqual(emitted[0], { text: '字幕', tokens: null });
await flushMicrotasks();
assert.deepEqual(emitted[1], { text: '字幕', tokens: [] });
});
test('subtitle processing drops stale tokenization and delivers latest subtitle only', async () => {
const emitted: SubtitleData[] = [];
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
if (text === 'first') {
return await new Promise<SubtitleData | null>((resolve) => {
firstResolve = resolve;
});
}
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('first');
controller.onSubtitleChange('second');
assert.ok(firstResolve);
firstResolve({ text: 'first', tokens: [] });
await flushMicrotasks();
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] },
]);
});
test('subtitle processing skips duplicate plain subtitle emission', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.onSubtitleChange('same');
await flushMicrotasks();
const plainEmits = emitted.filter((entry) => entry.tokens === null);
assert.equal(plainEmits.length, 1);
assert.equal(tokenizeCalls, 1);
});

View File

@@ -0,0 +1,96 @@
import type { SubtitleData } from '../../types';
export interface SubtitleProcessingControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
emitSubtitle: (payload: SubtitleData) => void;
logDebug?: (message: string) => void;
now?: () => number;
}
export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void;
}
export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController {
let latestText = '';
let lastPlainText = '';
let processing = false;
let staleDropCount = 0;
const now = deps.now ?? (() => Date.now());
const emitPlainSubtitle = (text: string): void => {
if (text === lastPlainText) {
return;
}
lastPlainText = text;
deps.emitSubtitle({ text, tokens: null });
};
const processLatest = (): void => {
if (processing) {
return;
}
processing = true;
void (async () => {
while (true) {
const text = latestText;
if (!text.trim()) {
break;
}
const startedAtMs = now();
let tokenized: SubtitleData | null = null;
try {
tokenized = await deps.tokenizeSubtitle(text);
} catch (error) {
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
}
if (latestText !== text) {
staleDropCount += 1;
deps.logDebug?.(
`Dropped stale subtitle tokenization result; dropped=${staleDropCount}, elapsed=${now() - startedAtMs}ms`,
);
continue;
}
if (tokenized) {
deps.emitSubtitle(tokenized);
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
}
break;
}
})()
.catch((error) => {
deps.logDebug?.(`Subtitle processing loop failed: ${(error as Error).message}`);
})
.finally(() => {
processing = false;
if (latestText !== lastPlainText) {
processLatest();
}
});
};
return {
onSubtitleChange: (text: string) => {
if (text === latestText) {
return;
}
const plainStartedAtMs = now();
latestText = text;
emitPlainSubtitle(text);
deps.logDebug?.(`Subtitle plain emit completed in ${now() - plainStartedAtMs}ms`);
if (!text.trim()) {
return;
}
processLatest();
},
};
}