mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user