feat: expand subtitle and media tracking workflows

- Add reference-guided subtitle timing, frame picking, live-action Jimaku search, and YouTube library kinds
- Harden Jellyfin media identity handling and live settings feedback
- Refresh user-facing documentation and changelog fragments
This commit is contained in:
2026-09-20 20:10:03 -07:00
139 changed files with 4511 additions and 389 deletions
+2
View File
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
getMediaTimingReviewFrame?: IpcDepsRuntimeOptions['getMediaTimingReviewFrame'];
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
@@ -266,6 +267,7 @@ export function createMainIpcRuntimeServiceDeps(
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
previewMediaTimingReview: params.previewMediaTimingReview,
getMediaTimingReviewFrame: params.getMediaTimingReviewFrame,
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
resolveMediaTimingReview: params.resolveMediaTimingReview,
+7 -5
View File
@@ -1,4 +1,5 @@
import { updateCurrentMediaPath } from '../core/services';
import { sanitizeMediaTitle } from '../shared/media-identity';
import type { SubtitlePosition } from '../types';
@@ -52,17 +53,18 @@ export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeS
updateCurrentMediaTitle(mediaTitle: unknown): void {
if (typeof mediaTitle === 'string') {
const sanitized = mediaTitle.trim();
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
const sanitized = sanitizeMediaTitle(mediaTitle);
if (mediaTitle.trim() && !sanitized) return;
deps.setCurrentMediaTitle(sanitized);
return;
}
deps.setCurrentMediaTitle(null);
},
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle()
: mediaPath;
return mediaPath && deps.isRemoteMediaPath(mediaPath)
? sanitizeMediaTitle(deps.getCurrentMediaTitle())
: sanitizeMediaTitle(mediaPath);
},
};
}
@@ -9,6 +9,10 @@ import {
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
assert.equal(
buildAnilistAttemptKey('https://example.com/Videos/item/stream?api_key=test-secret', 3),
'jellyfin://example.com/item/item::3',
);
});
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
@@ -17,6 +21,49 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
assert.deepEqual(Array.from(set), ['b', 'c']);
});
test('post-watch rejects empty media identities before attempted keys or update side effects', async () => {
for (const mediaKey of [
' ',
'stream?api_key=secret',
'stream%3Fapi_key%3Dsecret',
'https://[invalid',
]) {
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
const calls: string[] = [];
const unexpected = () => assert.fail('invalid identity reached update side effects');
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: unexpected,
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: unexpected,
processNextAnilistRetryUpdate: unexpected,
refreshAnilistClientSecretState: unexpected,
enqueueRetry: unexpected,
markRetryFailure: unexpected,
markRetrySuccess: unexpected,
refreshRetryQueueState: unexpected,
updateAnilistPostWatchProgress: unexpected,
rememberAttemptedUpdateKey: unexpected,
showMpvOsd: unexpected,
logInfo: unexpected,
logWarn: unexpected,
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler({ force: true });
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
}
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
@@ -335,6 +382,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
assert.ok(attemptKey);
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
+5 -2
View File
@@ -1,4 +1,5 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { toMediaIdentityPath } from '../../shared/media-identity';
type AnilistGuess = {
title: string;
@@ -31,8 +32,9 @@ type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string | null {
const identity = toMediaIdentityPath(mediaKey);
return identity ? `${identity}::${episode}` : null;
}
export function rememberAnilistAttemptedUpdateKey(
@@ -214,6 +216,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (!attemptKey) return;
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
@@ -278,6 +278,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
onEndFile: deps.onPlaybackEndFile,
readProperty: deps.readMpvProperty,
wait,
isCurrent,
})
: null;
@@ -5,9 +5,91 @@ import os from 'node:os';
import path from 'node:path';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { resolveConfig } from '../../config/resolve';
import { buildConfigSettingsRegistry } from '../../config/settings/registry';
import type { RawConfig } from '../../types/config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
test('settings saves report live changes and only the sections that actually need restart', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-live-'));
const configPath = path.join(dir, 'config.jsonc');
let rawConfig: RawConfig = {};
let resolvedConfig = resolveConfig(rawConfig).resolved;
const applied: string[][] = [];
const runtime = createConfigSettingsRuntime({
fields: buildConfigSettingsRegistry(DEFAULT_CONFIG),
getConfigPath: () => configPath,
getRawConfig: () => rawConfig,
getConfig: () => resolvedConfig,
getWarnings: () => [],
reloadConfigStrict: () => {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const result = resolveConfig(rawConfig);
resolvedConfig = result.resolved;
return { ok: true, config: resolvedConfig, warnings: result.warnings, path: configPath };
},
onHotReloadApplied: (diff) => {
applied.push(diff.hotReloadFields);
},
getSettingsWindow: () => null,
setSettingsWindow: () => {},
createSettingsWindow: () => {
throw new Error('Save must not open a window');
},
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () => {
throw new Error('Save must not query Anki');
},
ipcMain: { handle: () => {} },
ipcChannels: IPC_CHANNELS.request,
});
try {
const live = runtime.savePatch({
operations: [
{ op: 'set', path: 'notifications.overlayPosition', value: 'top' },
{
op: 'set',
path: 'subtitleGeneration.threads',
value: DEFAULT_CONFIG.subtitleGeneration.threads + 1,
},
],
});
assert.equal(live.ok, true);
assert.deepEqual(live.restartRequiredFields, []);
assert.deepEqual(live.restartRequiredSections, []);
assert.deepEqual(
new Set(live.hotReloadFields),
new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']),
);
assert.deepEqual(applied, [live.hotReloadFields]);
const mixed = runtime.savePatch({
operations: [
{ op: 'set', path: 'ankiConnect.deck', value: 'Mining' },
{ op: 'set', path: 'ankiConnect.url', value: 'http://127.0.0.1:9999' },
],
});
assert.equal(mixed.ok, true);
assert.deepEqual(mixed.hotReloadFields, ['ankiConnect.deck']);
assert.deepEqual(mixed.restartRequiredSections, ['AnkiConnect']);
const reset = runtime.savePatch({
operations: [
{ op: 'reset', path: 'notifications.overlayPosition' },
{ op: 'reset', path: 'subtitleGeneration.threads' },
],
});
assert.equal(reset.ok, true);
assert.deepEqual(reset.restartRequiredSections, []);
assert.deepEqual(new Set(reset.hotReloadFields), new Set(live.hotReloadFields));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const runtime = createConfigSettingsRuntime({
+3 -1
View File
@@ -1,3 +1,5 @@
import { toMediaIdentityPath } from '../../shared/media-identity';
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
@@ -119,7 +121,7 @@ export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${toMediaIdentityPath(mediaState.path)}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
@@ -103,6 +103,7 @@ test('playback handler drives mpv commands and playback state', async () => {
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['script-message', 'subminer-managed-subtitles-loading'],
['set_property', 'force-media-title', 'Episode 1'],
[
'loadfile',
'https://stream.example/video.m3u8',
@@ -110,7 +111,6 @@ test('playback handler drives mpv commands and playback state', async () => {
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
],
['set_property', 'force-media-title', 'Episode 1'],
]);
assert.equal(scheduled.length, 0);
assert.equal(
@@ -437,6 +437,8 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title');
assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
});
+2 -1
View File
@@ -221,11 +221,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
// Set mpv's title before loadfile can emit a URL-derived media-title event.
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
await awaitBestEffortPlaybackHook(() =>
deps.preloadExternalSubtitles({
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
createMediaTimingReviewRuntime,
type MediaTimingReviewRuntimeDeps,
} from './media-timing-review';
async function start(
options: Partial<MediaTimingReviewRuntimeDeps> = {},
screenshotEnabled = true,
) {
let opened!: (payload: MediaTimingReviewOpenPayload) => void;
const payloadPromise = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
opened = resolve;
});
const frames: MediaTimingFrameOptions[] = [];
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video.mkv',
send: () => {},
requestProperty: async (name) => (name === 'duration' ? 100 : true),
}),
getCurrentMediaPath: () => '/video.mkv',
getMpvExecutablePath: () => '',
createPreviewSession: () => ({
start: async () => {},
play: async () => {},
stop: async () => {},
onPlaybackEnded: () => {},
dispose: () => {},
}),
generateWaveform: async () => [0, 1],
resolveVideoSource: async () => ({ path: '/video.mkv' }),
generateFrame: async (options) => {
frames.push(options);
return { dataUrl: 'data:image/jpeg;base64,AA==', timestamp: options.timestamp };
},
openModal: async (payload) => {
opened(payload);
return true;
},
showStatus: () => {},
...options,
});
const decision = runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
screenshotEnabled,
});
return { runtime, decision, payload: await payloadPromise, frames };
}
test('review accepts a screenshot outside the audio range and validates media bounds', async () => {
const { runtime, decision, payload, frames } = await start();
for (const timestamp of [NaN, Infinity, -1, 100]) {
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp })).ok, false);
assert.equal(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: timestamp },
}).ok,
false,
);
}
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 13 })).ok, true);
assert.equal(frames[0]?.timestamp, 13);
assert.equal(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: 13 },
}).ok,
true,
);
assert.deepEqual(await decision, {
action: 'confirm',
startTime: 10,
endTime: 11,
screenshotTime: 13,
});
});
test('screenshot preview reuses the remote audio window and its absolute timestamps', async () => {
let downloads = 0;
const remote = 'https://example.test/video';
const { runtime, payload, decision, frames } = await start({
resolveMediaSource: async () => ({ path: remote }),
resolveVideoSource: async () => ({ path: remote }),
acquireMediaWindow: async () => {
downloads += 1;
return {
path: '/window.mkv',
sourcePath: remote,
startTime: 7,
endTime: 15,
audioStreamIndex: null,
media: { path: '/window.mkv', absoluteTimestamps: true },
};
},
});
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 });
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, true);
assert.equal(downloads, 1);
assert.deepEqual(frames[0]?.media, { path: '/window.mkv', absoluteTimestamps: true });
await runtime.dispose();
await decision;
});
test('split video streams never read an audio-only cache as a video source', async () => {
const video = {
path: 'https://example.test/video',
inputOptions: { headers: { 'X-Test': 'value' } },
};
const { runtime, payload, decision, frames } = await start({
resolveMediaSource: async () => ({ path: 'https://example.test/audio' }),
resolveVideoSource: async () => video,
});
await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 });
assert.deepEqual(frames[0]?.media, video);
await runtime.dispose();
await decision;
});
test('disabled screenshots and missing video inputs do not trigger extraction', async () => {
for (const [enabled, options] of [
[false, {}],
[true, { resolveVideoSource: async () => null }],
] as const) {
const { runtime, payload, decision, frames } = await start(options, enabled);
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, false);
assert.equal(frames.length, 0);
await runtime.dispose();
await decision;
}
});
test('closing a review invalidates in-flight frame results and clears the frame index', async () => {
let finish!: (value: { dataUrl: string; timestamp: number }) => void;
let clearCount = 0;
const { runtime, payload, decision } = await start({
generateFrame: () =>
new Promise((resolve) => {
finish = resolve;
}),
clearFrameCache: () => {
clearCount += 1;
},
});
const frame = runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 });
await runtime.dispose();
finish({ dataUrl: 'data:image/jpeg;base64,AA==', timestamp: 11 });
assert.equal((await frame).stale, true);
await decision;
assert.equal(clearCount, 1);
});
+89 -8
View File
@@ -7,10 +7,13 @@ import type {
MediaTimingReviewPreviewRequest,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
MediaTimingReviewFrameResult,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
isRemoteMediaWindowSourcePath,
type RemoteMediaWindow,
@@ -59,6 +62,8 @@ interface ActiveReview {
mediaPath: string;
/** What the waveform reads when no cached window is available. */
waveformMedia: MediaInput;
videoSource: ReviewMediaSource | null;
frameInFlight: boolean;
audioStreamIndex?: number;
/** Remote source to download windows of; null for local media or without a cache. */
windowSource: RemoteMediaWindowSource | null;
@@ -81,6 +86,11 @@ export interface MediaTimingReviewRuntimeDeps {
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
resolveVideoSource?: () => Promise<ReviewMediaSource | null>;
generateFrame?: (
options: MediaTimingFrameOptions,
) => Promise<{ dataUrl: string; timestamp: number }>;
clearFrameCache?: () => void;
/** Downloads (or reuses) a local window of a remote source covering the range. */
acquireMediaWindow?: (
source: RemoteMediaWindowSource,
@@ -227,6 +237,9 @@ export function buildMediaTimingReviewPayload(
timelineEndTime,
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
maxMediaDuration,
...(request.screenshotEnabled !== undefined
? { screenshotEnabled: request.screenshotEnabled }
: {}),
};
}
@@ -335,13 +348,15 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
]);
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
pendingPauseRestore = pauseState === false ? mpvClient : null;
@@ -383,6 +398,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
payload,
mediaPath,
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
videoSource,
frameInFlight: false,
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
windowSource,
window: null,
@@ -527,6 +544,58 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
async function getFrame(
request: MediaTimingReviewFrameRequest,
): Promise<MediaTimingReviewFrameResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) return staleReviewResult();
if (!current.payload.screenshotEnabled || !deps.generateFrame || !current.videoSource) {
return { ok: false, message: 'Screenshot preview is unavailable for this media.' };
}
if (
!Number.isFinite(request.timestamp) ||
request.timestamp < 0 ||
(current.payload.mediaDuration !== undefined &&
request.timestamp >= current.payload.mediaDuration) ||
(request.direction !== undefined && request.direction !== -1 && request.direction !== 1)
) {
return { ok: false, message: 'The screenshot time is invalid.' };
}
if (current.frameInFlight)
return { ok: false, message: 'A screenshot preview is already loading.' };
current.frameInFlight = true;
try {
// Reuse the audio window only when it contains this same video source (not split streams).
const range = {
startTime: Math.max(0, Math.min(current.payload.timelineStartTime, request.timestamp - 2)),
endTime: Math.min(
current.payload.mediaDuration ?? Infinity,
Math.max(current.payload.timelineEndTime, request.timestamp + 2),
),
};
const window =
current.windowSource?.path === current.videoSource.path
? await ensureWindow(current, range)
: null;
if (active !== current) return staleReviewResult();
const frame = await deps.generateFrame({
media: window?.media ?? current.videoSource,
timestamp: request.timestamp,
...(request.direction !== undefined ? { direction: request.direction } : {}),
});
if (active !== current) return staleReviewResult();
return { ok: true, ...frame };
} catch {
if (active !== current) return staleReviewResult();
return {
ok: false,
message: 'Screenshot preview unavailable. Try another time or reset to the midpoint.',
};
} finally {
current.frameInFlight = false;
}
}
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || reviewId !== current.payload.reviewId) {
@@ -550,13 +619,23 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return staleReviewResult();
}
if (request.decision.action === 'confirm') {
const { startTime, endTime, text } = request.decision;
const { startTime, endTime, text, screenshotTime } = request.decision;
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
return { ok: false, message: 'The selected timing range is invalid.' };
}
if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) {
return { ok: false, message: 'The combined sentence text is invalid.' };
}
if (
screenshotTime !== undefined &&
(!current.payload.screenshotEnabled ||
!Number.isFinite(screenshotTime) ||
screenshotTime < 0 ||
(current.payload.mediaDuration !== undefined &&
screenshotTime >= current.payload.mediaDuration))
) {
return { ok: false, message: 'The screenshot time is invalid.' };
}
}
current.resolve(request.decision);
return { ok: true };
@@ -566,6 +645,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const current = active;
active = null;
if (!current) return;
deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
@@ -582,6 +662,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
requestReview,
previewRange,
getWaveform,
getFrame,
stopPreview,
resolveReview,
dispose,
@@ -23,6 +23,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
getModelDirectory: () => '/models',
getMpvClient: () => client,
onProgress: () => {},
detectAcceleration: async () => ({ kind: 'unavailable' }),
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
resolveTools: async (config) => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
@@ -71,6 +72,27 @@ test('generation preserves the output without attaching it to a different video'
assert.deepEqual(subject.commands, []);
});
test('generation selects loaded dialogue references and excludes the signs track', async () => {
const subject = fixture({
generate: async (input) => {
assert.deepEqual(input.references, [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
]);
return '/video/generated.srt';
},
});
const request = subject.client.requestProperty;
subject.client.requestProperty = async (name) =>
name === 'track-list'
? [
{ type: 'audio', selected: true, 'ff-index': 3 },
{ type: 'sub', lang: 'eng', title: 'Signs & Songs', 'ff-index': 4 },
{ type: 'sub', lang: 'eng', title: 'English Full', 'ff-index': 5 },
]
: request(name);
assert.equal((await subject.runtime.start()).ok, true);
});
test('mpv load failure still reports where the generated subtitles were saved', async () => {
const subject = fixture();
subject.client.request = async () => ({ error: 'loading failed' });
@@ -182,6 +204,40 @@ test('external model paths prevent managed selection, including unreadable overr
await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/);
});
test('CUDA recommendations preserve selected and configured models and follow the Whisper path', async () => {
let whisperPath = '/cuda/whisper-cli';
const checked: string[] = [];
const subject = fixture({
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, managedModel: 'medium' }),
resolveTools: async () => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'found', path: whisperPath },
vad: null,
}),
detectAcceleration: async (whisper) => {
assert.equal(whisper.kind, 'found');
if (whisper.kind !== 'found') throw new Error('Expected a Whisper executable');
checked.push(whisper.path);
return whisper.path.startsWith('/cuda/')
? { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' }
: { kind: 'unavailable' };
},
});
const initial = await subject.runtime.getStatus();
assert.deepEqual(initial.acceleration, { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' });
assert.equal(initial.managedModel, 'medium');
const selected = await subject.runtime.selectModel('small');
assert.equal(selected.managedModel, 'small');
assert.equal(selected.acceleration.kind, 'nvidia-cuda');
assert.deepEqual(checked, ['/cuda/whisper-cli']);
whisperPath = '/cpu/whisper-cli';
const changed = await subject.runtime.getStatus();
assert.equal(changed.acceleration.kind, 'unavailable');
assert.equal(changed.managedModel, 'small');
assert.deepEqual(checked, ['/cuda/whisper-cli', '/cpu/whisper-cli']);
});
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' }),
@@ -1,4 +1,6 @@
import path from 'node:path';
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import {
downloadSubtitleGenerationVadModel,
@@ -35,6 +37,7 @@ export interface SubtitleGenerationRuntimeDeps {
download?: typeof downloadSubtitleGenerationModel;
resolveModel?: typeof resolveSubtitleGenerationModel;
resolveTools?: typeof resolveSubtitleGenerationTools;
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
}
@@ -80,6 +83,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
let accelerationCheck:
| {
path: string;
expires: number;
result: ReturnType<typeof detectSubtitleGenerationAcceleration>;
}
| undefined;
function getConfig(): SubtitleGenerationConfig {
const config = deps.getConfig();
return {
@@ -127,6 +137,20 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
async function getStatus(): Promise<SubtitleGenerationStatus> {
const config = getConfig();
const tools = await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config);
const whisperPath = tools.whisper.kind === 'found' ? tools.whisper.path : '';
if (
!accelerationCheck ||
accelerationCheck.path !== whisperPath ||
(!controller && Date.now() >= accelerationCheck.expires)
) {
accelerationCheck = {
path: whisperPath,
expires: Date.now() + 30_000,
result: (deps.detectAcceleration ?? detectSubtitleGenerationAcceleration)(tools.whisper),
};
}
const acceleration = await accelerationCheck.result;
const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)(
config,
deps.getModelDirectory(),
@@ -142,7 +166,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
),
},
// Session toggles decide whether the speech detector executable is required.
tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config),
tools,
acceleration,
managedModel: config.managedModel,
externalModelPath: config.modelPath.trim() || null,
mediaPath,
@@ -214,7 +239,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.');
const audioStreamIndex = selectedAudioIndex(await client.requestProperty('track-list'));
const tracks = await client.requestProperty('track-list');
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
client.requestProperty(name),
);
if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted();
@@ -223,6 +252,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
modelDirectory: deps.getModelDirectory(),
mediaPath,
audioStreamIndex,
references,
onProgress: report,
signal,
});