mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Fix mpv tlang and profile parsing
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
72
src/core/services/subtitle-processing-controller.test.ts
Normal file
72
src/core/services/subtitle-processing-controller.test.ts
Normal 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);
|
||||
});
|
||||
96
src/core/services/subtitle-processing-controller.ts
Normal file
96
src/core/services/subtitle-processing-controller.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user