mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
- Detect PATH tools and report missing executable settings - Check output directories before model downloads or audio extraction
260 lines
9.5 KiB
TypeScript
260 lines
9.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
|
|
import {
|
|
createSubtitleGenerationRuntime,
|
|
type SubtitleGenerationRuntimeDeps,
|
|
} from './subtitle-generation-runtime';
|
|
|
|
function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
|
|
let mediaPath = '/video/episode.mkv';
|
|
const commands: unknown[][] = [];
|
|
const client = {
|
|
connected: true,
|
|
requestProperty: async (name: string): Promise<unknown> =>
|
|
name === 'path' ? mediaPath : [{ type: 'audio', selected: true, 'ff-index': 3 }],
|
|
request: async (command: unknown[]) => {
|
|
commands.push(command);
|
|
return { error: 'success' };
|
|
},
|
|
};
|
|
const runtime = createSubtitleGenerationRuntime({
|
|
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
|
getModelDirectory: () => '/models',
|
|
getMpvClient: () => client,
|
|
onProgress: () => {},
|
|
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
|
|
resolveTools: async (config) => ({
|
|
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
|
|
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
|
|
whisper: { kind: 'found', path: '/usr/bin/whisper-cli' },
|
|
vad: config.vadModelPath ? { kind: 'found', path: '/usr/bin/vad' } : null,
|
|
}),
|
|
generate: async () => '/video/episode.ja.generated.srt',
|
|
...overrides,
|
|
});
|
|
return {
|
|
runtime,
|
|
client,
|
|
commands,
|
|
changeMedia: () => {
|
|
mediaPath = '/video/next.mkv';
|
|
},
|
|
};
|
|
}
|
|
|
|
test('generation uses the selected audio track and loads the timed SRT with zero delay', async () => {
|
|
const { runtime, commands } = fixture({
|
|
generate: async (input) => {
|
|
assert.equal(input.mediaPath, '/video/episode.mkv');
|
|
assert.equal(input.audioStreamIndex, 3);
|
|
return '/video/generated.srt';
|
|
},
|
|
});
|
|
assert.equal((await runtime.start()).ok, true);
|
|
assert.deepEqual(commands, [
|
|
['sub-add', '/video/generated.srt', 'select', 'Generated Japanese', 'ja'],
|
|
['set_property', 'sub-delay', 0],
|
|
]);
|
|
});
|
|
|
|
test('generation preserves the output without attaching it to a different video', async () => {
|
|
const subject = fixture({
|
|
generate: async () => {
|
|
subject.changeMedia();
|
|
return '/video/generated.srt';
|
|
},
|
|
});
|
|
const result = await subject.runtime.start();
|
|
assert.equal(result.ok, true);
|
|
assert.match(result.message, /Playback changed/);
|
|
assert.deepEqual(subject.commands, []);
|
|
});
|
|
|
|
test('mpv load failure still reports where the generated subtitles were saved', async () => {
|
|
const subject = fixture();
|
|
subject.client.request = async () => ({ error: 'loading failed' });
|
|
const result = await subject.runtime.start();
|
|
assert.equal(result.ok, true);
|
|
assert.match(result.message, /Subtitles saved:.*Could not finish loading/);
|
|
});
|
|
|
|
test('cancellation during the final media check keeps the saved file without loading it', async () => {
|
|
const subject = fixture();
|
|
const requestProperty = subject.client.requestProperty;
|
|
let mediaChecks = 0;
|
|
subject.client.requestProperty = async (name) => {
|
|
if (name === 'path' && ++mediaChecks === 3) subject.runtime.cancel();
|
|
return requestProperty(name);
|
|
};
|
|
const result = await subject.runtime.start();
|
|
assert.equal(result.ok, true);
|
|
assert.match(result.message, /Cancelled after saving/);
|
|
assert.deepEqual(subject.commands, []);
|
|
});
|
|
|
|
test('only one job runs, cancellation reaches the worker, and status retains its result', async () => {
|
|
let signal: AbortSignal | undefined;
|
|
let entered = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
entered = resolve;
|
|
});
|
|
const { runtime } = fixture({
|
|
generate: async (input) => {
|
|
signal = input.signal;
|
|
input.onProgress?.({ stage: 'transcribe', percent: 25, message: 'Working' });
|
|
entered();
|
|
return new Promise((_, reject) =>
|
|
input.signal?.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }),
|
|
);
|
|
},
|
|
});
|
|
const first = runtime.start();
|
|
await started;
|
|
await assert.rejects(runtime.selectModel('medium'), /current operation/);
|
|
assert.equal((await runtime.start()).ok, false);
|
|
assert.equal((await runtime.download()).ok, false);
|
|
const active = await runtime.getStatus();
|
|
assert.equal(active.running, true);
|
|
assert.equal(active.progress?.percent, 25);
|
|
runtime.cancel();
|
|
assert.equal(signal?.aborted, true);
|
|
assert.deepEqual(await first, { ok: false, message: 'Cancelled.' });
|
|
const completed = await runtime.getStatus();
|
|
assert.equal(completed.running, false);
|
|
assert.deepEqual(completed.lastResult, { ok: false, message: 'Cancelled.' });
|
|
});
|
|
|
|
test('external audio cannot silently generate from a different internal track', async () => {
|
|
const subject = fixture({
|
|
generate: async () => {
|
|
assert.fail('must not transcribe');
|
|
},
|
|
});
|
|
subject.client.requestProperty = async (name) =>
|
|
name === 'path'
|
|
? '/video/episode.mkv'
|
|
: [{ type: 'audio', selected: true, external: true, 'ff-index': 0 }];
|
|
const result = await subject.runtime.start();
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.message, /audio track inside/);
|
|
});
|
|
|
|
test('model selection is retained and used for status, download, and generation', async () => {
|
|
const seen: string[] = [];
|
|
const { runtime } = fixture({
|
|
resolveModel: async (config) => ({
|
|
kind: 'missing',
|
|
path: `/models/${config.managedModel}.bin`,
|
|
}),
|
|
download: async ({ config }) => {
|
|
seen.push(`download:${config.managedModel}`);
|
|
return '/models/downloaded.bin';
|
|
},
|
|
generate: async ({ config }) => {
|
|
seen.push(`generate:${config.managedModel}`);
|
|
return '/video/generated.srt';
|
|
},
|
|
});
|
|
const selected = await runtime.selectModel('medium');
|
|
assert.equal(selected.managedModel, 'medium');
|
|
assert.equal(selected.model.path, '/models/medium.bin');
|
|
assert.equal((await runtime.getStatus()).managedModel, 'medium');
|
|
assert.equal((await runtime.download()).ok, true);
|
|
assert.equal((await runtime.start()).ok, true);
|
|
assert.deepEqual(seen, ['download:medium', 'generate:medium']);
|
|
assert.equal(DEFAULT_SUBTITLE_GENERATION_CONFIG.managedModel, 'small');
|
|
});
|
|
|
|
test('external model paths prevent managed selection, including unreadable overrides', async () => {
|
|
const { runtime } = fixture({
|
|
getConfig: () => ({
|
|
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
|
modelPath: '/missing/external.bin',
|
|
}),
|
|
resolveModel: async () => ({
|
|
kind: 'invalid',
|
|
path: '/missing/external.bin',
|
|
message: 'Missing model',
|
|
}),
|
|
});
|
|
assert.equal((await runtime.getStatus()).externalModelPath, '/missing/external.bin');
|
|
await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/);
|
|
});
|
|
|
|
test('status reports the speech detector only while dialogue mode is on', async () => {
|
|
const { runtime } = fixture({
|
|
resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }),
|
|
});
|
|
assert.equal((await runtime.getStatus()).tools.vad, null);
|
|
await runtime.setVadEnabled(true);
|
|
assert.deepEqual((await runtime.getStatus()).tools.vad, { kind: 'found', path: '/usr/bin/vad' });
|
|
});
|
|
|
|
test('speech detection is optional and downloading alone does not enable it', async () => {
|
|
let installed = false;
|
|
const paths: string[] = [];
|
|
const { runtime } = fixture({
|
|
resolveVadModel: async () => ({
|
|
kind: installed ? 'managed' : 'missing',
|
|
path: '/models/ggml-silero-v6.2.0.bin',
|
|
}),
|
|
downloadVad: async () => {
|
|
installed = true;
|
|
return '/models/ggml-silero-v6.2.0.bin';
|
|
},
|
|
generate: async ({ config }) => {
|
|
paths.push(config.vadModelPath);
|
|
return '/video/output.srt';
|
|
},
|
|
});
|
|
assert.equal((await runtime.getStatus()).vad.enabled, false);
|
|
assert.equal((await runtime.start()).ok, true);
|
|
await runtime.setVadEnabled(true);
|
|
assert.equal((await runtime.start()).ok, false);
|
|
await runtime.setVadEnabled(false);
|
|
assert.equal((await runtime.downloadVad()).ok, true);
|
|
assert.equal((await runtime.getStatus()).vad.enabled, false);
|
|
await runtime.setVadEnabled(true);
|
|
assert.equal((await runtime.start()).ok, true);
|
|
await runtime.setVadEnabled(false);
|
|
assert.equal((await runtime.start()).ok, true);
|
|
assert.deepEqual(paths, ['', '/models/ggml-silero-v6.2.0.bin', '']);
|
|
});
|
|
|
|
test('existing external speech model remains the default and survives session toggles', async () => {
|
|
const { runtime } = fixture({
|
|
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/external/vad.bin' }),
|
|
resolveVadModel: async (config) => ({ kind: 'external', path: config.vadModelPath }),
|
|
generate: async ({ config }) => {
|
|
assert.equal(config.vadModelPath, '/external/vad.bin');
|
|
return '/video/output.srt';
|
|
},
|
|
});
|
|
assert.equal((await runtime.getStatus()).vad.enabled, true);
|
|
await runtime.setVadEnabled(false);
|
|
await runtime.setVadEnabled(true);
|
|
assert.equal((await runtime.start()).ok, true);
|
|
});
|
|
|
|
test('speech model downloads share the job lock and cancellation', async () => {
|
|
let enter = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
enter = resolve;
|
|
});
|
|
const { runtime } = fixture({
|
|
downloadVad: async ({ signal }) => {
|
|
enter();
|
|
return new Promise((_, reject) =>
|
|
signal?.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }),
|
|
);
|
|
},
|
|
});
|
|
const download = runtime.downloadVad();
|
|
await started;
|
|
assert.equal((await runtime.start()).ok, false);
|
|
await assert.rejects(runtime.setVadEnabled(true), /current operation/);
|
|
runtime.cancel();
|
|
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
|
|
});
|