mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
feat(anki): add media timing review before card creation (#203)
This commit is contained in:
@@ -46,12 +46,13 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 35);
|
||||
assert.equal(calls.length, 36);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
@@ -60,6 +61,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||
assert.ok(calls.includes('cleanup-remote-media-windows'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
@@ -102,6 +104,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupRemoteMediaWindows: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -76,6 +77,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupYoutubeMediaCache();
|
||||
deps.cleanupRemoteMediaWindows();
|
||||
deps.stopDiscordPresenceService();
|
||||
return Promise.resolve(stopSyncAutoScheduler);
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
@@ -157,6 +158,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
@@ -210,6 +212,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupRemoteMediaWindows: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -148,6 +149,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
|
||||
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.media.normalizeAudio = false;
|
||||
config.ankiConnect.media.mirrorMpvVolume = false;
|
||||
config.ankiConnect.media.reviewTiming = true;
|
||||
const ankiPatches: unknown[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
||||
},
|
||||
config,
|
||||
);
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['ankiConnect.media.reviewTiming'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{ media: { normalizeAudio: false } },
|
||||
{ media: { mirrorMpvVolume: false } },
|
||||
{ media: { reviewTiming: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
|
||||
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
|
||||
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
|
||||
}
|
||||
if (Object.keys(mediaPatch).length > 0) {
|
||||
patch.media = mediaPatch;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const MODAL: OverlayHostedModal = 'media-timing-review';
|
||||
|
||||
export async function openMediaTimingReviewModal(
|
||||
deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
||||
{
|
||||
modal: MODAL,
|
||||
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
|
||||
// premature retry re-sends the payload and reloads the waveform for nothing.
|
||||
timeoutMs: 4_000,
|
||||
retryWarning:
|
||||
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(deps, {
|
||||
channel: IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||
modal: MODAL,
|
||||
payload,
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||
import type {
|
||||
RemoteMediaWindow,
|
||||
RemoteMediaWindowRange,
|
||||
RemoteMediaWindowSource,
|
||||
} from '../../core/services/remote-media-window-cache';
|
||||
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
|
||||
|
||||
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
|
||||
import {
|
||||
buildMediaTimingReviewPayload,
|
||||
collectMediaTimingContextLines,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from './media-timing-review';
|
||||
|
||||
describe('buildMediaTimingReviewPayload', () => {
|
||||
test('starts from the padded range and leaves two seconds to drag on each side', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
{
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
{ reviewId: 'review-1', mediaDuration: 100 },
|
||||
);
|
||||
|
||||
assert.equal(payload.selectionStartTime, 9.5);
|
||||
assert.equal(payload.selectionEndTime, 12.5);
|
||||
assert.equal(payload.timelineStartTime, 7.5);
|
||||
assert.equal(payload.timelineEndTime, 14.5);
|
||||
});
|
||||
|
||||
test('clamps the padded selection and timeline to media bounds', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
{
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 0.2,
|
||||
endTime: 9.8,
|
||||
audioPadding: 1,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
{ reviewId: 'review-2', mediaDuration: 10 },
|
||||
);
|
||||
|
||||
assert.equal(payload.selectionStartTime, 0);
|
||||
assert.equal(payload.selectionEndTime, 10);
|
||||
assert.equal(payload.timelineStartTime, 0);
|
||||
assert.equal(payload.timelineEndTime, 10);
|
||||
});
|
||||
|
||||
test('keeps an uncapped selection when max media duration is disabled', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
{
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 55,
|
||||
audioPadding: 1,
|
||||
maxMediaDuration: 0,
|
||||
},
|
||||
{ reviewId: 'review-unlimited', mediaDuration: 100 },
|
||||
);
|
||||
|
||||
assert.equal(payload.selectionStartTime, 9);
|
||||
assert.equal(payload.selectionEndTime, 56);
|
||||
assert.equal(payload.maxMediaDuration, 0);
|
||||
});
|
||||
});
|
||||
|
||||
async function startActiveMediaTimingReview(
|
||||
options: {
|
||||
maxMediaDuration?: number;
|
||||
decisionTimeoutMs?: number;
|
||||
generateWaveform?: () => Promise<number[]>;
|
||||
play?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const previewCalls: Array<[number, number]> = [];
|
||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||
publishPayload = resolve;
|
||||
});
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: options.generateWaveform ?? (async () => []),
|
||||
decisionTimeoutMs: options.decisionTimeoutMs,
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async (startTime, endTime) => {
|
||||
previewCalls.push([startTime, endTime]);
|
||||
await options.play?.();
|
||||
},
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
publishPayload(payload);
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const pendingDecision = runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: options.maxMediaDuration ?? 30,
|
||||
});
|
||||
|
||||
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
|
||||
}
|
||||
|
||||
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const previewCalls: Array<[number, number]> = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) =>
|
||||
({ pause: false, duration: 100, aid: 2, volume: 60 })[
|
||||
name as 'pause' | 'duration' | 'aid' | 'volume'
|
||||
],
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async (startTime, endTime) => {
|
||||
previewCalls.push([startTime, endTime]);
|
||||
},
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime
|
||||
.previewRange({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: 9.5,
|
||||
endTime: 12.5,
|
||||
})
|
||||
.then(() => {
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
const decision = await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
noteId: 42,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
assert.deepEqual(previewCalls, [[9.5, 12.5]]);
|
||||
});
|
||||
|
||||
test('media timing review analyzes the visible range on the selected audio stream', async () => {
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
currentAudioStreamIndex: 4,
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async (options) => {
|
||||
waveformCalls.push(options);
|
||||
return [0.1, 0.8, 0.2];
|
||||
},
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
const waveform = await runtime.getWaveform({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
});
|
||||
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'use-original' },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(waveformCalls, [
|
||||
{
|
||||
mediaPath: '/video/show.mkv',
|
||||
startTime: 7.5,
|
||||
endTime: 14.5,
|
||||
audioStreamIndex: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const REMOTE_STREAM_URL = 'https://jellyfin.example/Videos/abc/stream?static=true';
|
||||
|
||||
function createWindowStub(options: { fail?: boolean } = {}) {
|
||||
const calls: Array<{ source: RemoteMediaWindowSource; range: RemoteMediaWindowRange }> = [];
|
||||
const acquireMediaWindow = async (
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow> => {
|
||||
calls.push({ source, range });
|
||||
if (options.fail) throw new Error('offline');
|
||||
const windowPath = `/tmp/window-${range.startTime}-${range.endTime}.mkv`;
|
||||
return {
|
||||
path: windowPath,
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
sourcePath: source.path,
|
||||
audioStreamIndex: source.audioStreamIndex ?? null,
|
||||
media: {
|
||||
path: windowPath,
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
return { calls, acquireMediaWindow };
|
||||
}
|
||||
|
||||
function createRemoteReviewRuntime(options: {
|
||||
windowStub: ReturnType<typeof createWindowStub>;
|
||||
waveformCalls: SpeechWaveformOptions[];
|
||||
previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]>;
|
||||
previewPlays: Array<[string, number, number]>;
|
||||
disposed: string[];
|
||||
openModal: (
|
||||
runtime: ReturnType<typeof createMediaTimingReviewRuntime>,
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: REMOTE_STREAM_URL,
|
||||
currentAudioStreamIndex: 2,
|
||||
requestProperty: async (name) =>
|
||||
({ pause: true, duration: 100, aid: 3, volume: 60 })[
|
||||
name as 'pause' | 'duration' | 'aid' | 'volume'
|
||||
] ?? null,
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => REMOTE_STREAM_URL,
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
resolveMediaSource: async () => ({
|
||||
path: REMOTE_STREAM_URL,
|
||||
inputOptions: { reconnect: true },
|
||||
}),
|
||||
acquireMediaWindow: options.windowStub.acquireMediaWindow,
|
||||
generateWaveform: async (waveformOptions) => {
|
||||
options.waveformCalls.push(waveformOptions);
|
||||
return [0.1, 0.8, 0.2];
|
||||
},
|
||||
createPreviewSession: () => {
|
||||
let mediaPath = '';
|
||||
return {
|
||||
start: async (startOptions) => {
|
||||
mediaPath = startOptions.mediaPath;
|
||||
options.previewStarts.push(startOptions);
|
||||
},
|
||||
play: async (startTime, endTime) => {
|
||||
options.previewPlays.push([mediaPath, startTime, endTime]);
|
||||
},
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => {
|
||||
options.disposed.push(mediaPath);
|
||||
},
|
||||
};
|
||||
},
|
||||
openModal: async (payload) => {
|
||||
await options.openModal(runtime, payload);
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
test('media timing review downloads one window of a remote stream for the waveform and preview', async () => {
|
||||
const windowStub = createWindowStub();
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
|
||||
const previewPlays: Array<[string, number, number]> = [];
|
||||
const disposed: string[] = [];
|
||||
const runtime = createRemoteReviewRuntime({
|
||||
windowStub,
|
||||
waveformCalls,
|
||||
previewStarts,
|
||||
previewPlays,
|
||||
disposed,
|
||||
openModal: async (active, payload) => {
|
||||
const waveform = await active.getWaveform({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
});
|
||||
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
|
||||
assert.deepEqual(
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 }),
|
||||
{ ok: true },
|
||||
);
|
||||
active.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
|
||||
assert.deepEqual(windowStub.calls, [
|
||||
{
|
||||
source: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true }, audioStreamIndex: 2 },
|
||||
range: { startTime: 7.5, endTime: 14.5 },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(waveformCalls, [
|
||||
{
|
||||
mediaPath: {
|
||||
path: '/tmp/window-7.5-14.5.mkv',
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
startTime: 7.5,
|
||||
endTime: 14.5,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewStarts, [
|
||||
{
|
||||
mediaPath: '/tmp/window-7.5-14.5.mkv',
|
||||
executablePath: 'mpv',
|
||||
volume: 60,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
|
||||
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']);
|
||||
});
|
||||
|
||||
test('media timing review restarts the preview on a wider window when the timeline grows', async () => {
|
||||
const windowStub = createWindowStub();
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
|
||||
const previewPlays: Array<[string, number, number]> = [];
|
||||
const disposed: string[] = [];
|
||||
const runtime = createRemoteReviewRuntime({
|
||||
windowStub,
|
||||
waveformCalls,
|
||||
previewStarts,
|
||||
previewPlays,
|
||||
disposed,
|
||||
openModal: async (active, payload) => {
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
|
||||
// The user revealed two more seconds before the clip.
|
||||
await active.getWaveform({ reviewId: payload.reviewId, startTime: 5.5, endTime: 14.5 });
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 6, endTime: 12.5 });
|
||||
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
windowStub.calls.map((call) => call.range),
|
||||
[
|
||||
{ startTime: 7.5, endTime: 14.5 },
|
||||
{ startTime: 5.5, endTime: 14.5 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
previewStarts.map((start) => start.mediaPath),
|
||||
['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv'],
|
||||
);
|
||||
assert.deepEqual(previewPlays, [
|
||||
['/tmp/window-7.5-14.5.mkv', 9.5, 12.5],
|
||||
['/tmp/window-5.5-14.5.mkv', 6, 12.5],
|
||||
]);
|
||||
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv']);
|
||||
assert.equal(waveformCalls[0]?.startTime, 5.5);
|
||||
});
|
||||
|
||||
test('media timing review falls back to the remote stream after one failed window download', async () => {
|
||||
const windowStub = createWindowStub({ fail: true });
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
|
||||
const previewPlays: Array<[string, number, number]> = [];
|
||||
const disposed: string[] = [];
|
||||
const runtime = createRemoteReviewRuntime({
|
||||
windowStub,
|
||||
waveformCalls,
|
||||
previewStarts,
|
||||
previewPlays,
|
||||
disposed,
|
||||
openModal: async (active, payload) => {
|
||||
await active.getWaveform({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
});
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
|
||||
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.equal(windowStub.calls.length, 1);
|
||||
assert.deepEqual(waveformCalls, [
|
||||
{
|
||||
mediaPath: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true } },
|
||||
startTime: 7.5,
|
||||
endTime: 14.5,
|
||||
audioStreamIndex: 2,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewStarts, [
|
||||
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
|
||||
]);
|
||||
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
|
||||
});
|
||||
|
||||
test('media timing review never downloads windows for local media', async () => {
|
||||
const windowStub = createWindowStub();
|
||||
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
resolveMediaSource: async () => ({ path: '/video/show.mkv' }),
|
||||
acquireMediaWindow: windowStub.acquireMediaWindow,
|
||||
generateWaveform: async () => [0.1, 0.8, 0.2],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 7.5, endTime: 14.5 });
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.equal(windowStub.calls.length, 0);
|
||||
});
|
||||
|
||||
test('media timing review rejects stale and out-of-range actions before allowing discard', async () => {
|
||||
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
|
||||
maxMediaDuration: 3,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
reviewId: 'stale-review',
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12 },
|
||||
}),
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 14 },
|
||||
}),
|
||||
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 99, endTime: 100.5 },
|
||||
}),
|
||||
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
|
||||
}),
|
||||
{ ok: false, message: 'The combined sentence text is invalid.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
|
||||
{ ok: true },
|
||||
);
|
||||
assert.deepEqual(await pendingDecision, { action: 'discard' });
|
||||
assert.deepEqual(previewCalls, []);
|
||||
});
|
||||
|
||||
test('collectMediaTimingContextLines splits cues around the mined range', () => {
|
||||
const cues = [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
{ text: '', startTime: 4.2, endTime: 4.4 },
|
||||
{ text: '採掘行', startTime: 5, endTime: 7 },
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
];
|
||||
|
||||
const context = collectMediaTimingContextLines({ cues, startTime: 5, endTime: 7 });
|
||||
|
||||
assert.deepEqual(context.previous, [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
]);
|
||||
assert.deepEqual(context.next, [
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('collectMediaTimingContextLines falls back to played history when no cues are loaded', () => {
|
||||
const context = collectMediaTimingContextLines({
|
||||
cues: [],
|
||||
fallbackPrevious: [
|
||||
{ displayText: '前の行', startTime: 1, endTime: 2 },
|
||||
{ displayText: '採掘行', startTime: 5, endTime: 7 },
|
||||
],
|
||||
startTime: 5,
|
||||
endTime: 7,
|
||||
});
|
||||
|
||||
assert.deepEqual(context.previous, [{ text: '前の行', startTime: 1, endTime: 2 }]);
|
||||
assert.deepEqual(context.next, []);
|
||||
});
|
||||
|
||||
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||
|
||||
assert.deepEqual(await pendingDecision, { action: 'use-original' });
|
||||
});
|
||||
|
||||
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async () => null,
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => '',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => {
|
||||
throw new Error('preview unavailable');
|
||||
},
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'use-original' },
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ action: 'use-original' },
|
||||
);
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
test('media timing review restores playback when setup fails after pausing', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => {
|
||||
throw new Error('preview setup failed');
|
||||
},
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async () => true,
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ action: 'use-original' },
|
||||
);
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('disposing an open review settles it with original timing and restores playback', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async () => true,
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
const pending = runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await runtime.dispose();
|
||||
|
||||
assert.deepEqual(await pending, { action: 'use-original' });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
|
||||
const endedReviewIds: string[] = [];
|
||||
const playback: { ended?: () => void } = {};
|
||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||
publishPayload = resolve;
|
||||
});
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: (listener) => {
|
||||
playback.ended = listener;
|
||||
},
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
publishPayload(payload);
|
||||
return true;
|
||||
},
|
||||
onPreviewEnded: (reviewId) => {
|
||||
endedReviewIds.push(reviewId);
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const pendingDecision = runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
const payload = await openedPayload;
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
|
||||
{
|
||||
ok: true,
|
||||
},
|
||||
);
|
||||
assert.ok(playback.ended);
|
||||
playback.ended();
|
||||
assert.deepEqual(endedReviewIds, [payload.reviewId]);
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
playback.ended();
|
||||
assert.deepEqual(endedReviewIds, [payload.reviewId]);
|
||||
});
|
||||
|
||||
test('preview reports a stale review when the review ends during playback', async () => {
|
||||
let endReview: (() => Promise<void>) | null = null;
|
||||
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
|
||||
play: async () => {
|
||||
await endReview?.();
|
||||
},
|
||||
});
|
||||
endReview = () => runtime.dispose();
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
await pendingDecision;
|
||||
});
|
||||
|
||||
test('waveform reports a stale review when the review ends during analysis', async () => {
|
||||
let endReview: (() => Promise<void>) | null = null;
|
||||
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
|
||||
generateWaveform: async () => {
|
||||
await endReview?.();
|
||||
return [0.1, 0.9, 0.2];
|
||||
},
|
||||
});
|
||||
endReview = () => runtime.dispose();
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 }),
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
await pendingDecision;
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewContextLine,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
MediaTimingReviewWaveformRequest,
|
||||
MediaTimingReviewWaveformResult,
|
||||
} from '../../types/anki';
|
||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||
import {
|
||||
isRemoteMediaWindowSourcePath,
|
||||
type RemoteMediaWindow,
|
||||
type RemoteMediaWindowRange,
|
||||
type RemoteMediaWindowSource,
|
||||
} from '../../core/services/remote-media-window-cache';
|
||||
import type { MediaInput, MediaInputOptions } from '../../media-input';
|
||||
|
||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
|
||||
const CONTEXT_LINE_LIMIT = 12;
|
||||
const CONTEXT_LINE_EPSILON_SECONDS = 0.05;
|
||||
|
||||
interface ReviewMpvClient {
|
||||
connected: boolean;
|
||||
currentVideoPath: string;
|
||||
currentAudioStreamIndex?: number | null;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
send: (payload: { command: Array<string | number> }) => void;
|
||||
}
|
||||
|
||||
interface PreviewSession {
|
||||
start(options: {
|
||||
mediaPath: string;
|
||||
executablePath?: string;
|
||||
audioTrackId?: number;
|
||||
volume?: number;
|
||||
absoluteTimestamps?: boolean;
|
||||
}): Promise<void>;
|
||||
play(startTime: number, endTime: number): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Fires when the player reaches the end of the clip started by play(). */
|
||||
onPlaybackEnded(listener: () => void): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface ReviewMediaSource {
|
||||
path: string;
|
||||
inputOptions?: MediaInputOptions;
|
||||
singleResolvedStream?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveReview {
|
||||
payload: MediaTimingReviewOpenPayload;
|
||||
/** What the hidden mpv preview plays when no cached window is available. */
|
||||
mediaPath: string;
|
||||
/** What the waveform reads when no cached window is available. */
|
||||
waveformMedia: MediaInput;
|
||||
audioStreamIndex?: number;
|
||||
/** Remote source to download windows of; null for local media or without a cache. */
|
||||
windowSource: RemoteMediaWindowSource | null;
|
||||
/** Latest window returned for this review; reused while it still covers the request. */
|
||||
window: RemoteMediaWindow | null;
|
||||
windowRequest: (RemoteMediaWindowRange & { promise: Promise<RemoteMediaWindow | null> }) | null;
|
||||
windowFailed: boolean;
|
||||
previewOptions: { executablePath?: string; audioTrackId?: number; volume?: number };
|
||||
preview: { path: string; session: Promise<PreviewSession> } | null;
|
||||
mpvClient: ReviewMpvClient;
|
||||
restorePlayback: boolean;
|
||||
resolve: (decision: MediaTimingReviewDecision) => void;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewRuntimeDeps {
|
||||
getMpvClient: () => ReviewMpvClient | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getMpvExecutablePath: () => string;
|
||||
createPreviewSession: () => PreviewSession;
|
||||
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
||||
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
|
||||
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
|
||||
/** Downloads (or reuses) a local window of a remote source covering the range. */
|
||||
acquireMediaWindow?: (
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
) => Promise<RemoteMediaWindow>;
|
||||
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
decisionTimeoutMs?: number;
|
||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||
/** Tells the modal that the hidden player finished the previewed clip. */
|
||||
onPreviewEnded?: (reviewId: string) => void;
|
||||
showStatus: (message: string) => void;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function booleanProperty(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'yes' || value === 1) return true;
|
||||
if (value === 'no' || value === 0) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
|
||||
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
|
||||
* active track was never parsed) the timing tracker's history still provides the
|
||||
* lines that already played, so only "next" is unavailable.
|
||||
*/
|
||||
export function collectMediaTimingContextLines(options: {
|
||||
cues: readonly { text: string; startTime: number; endTime: number }[];
|
||||
fallbackPrevious?: readonly { displayText: string; startTime: number; endTime: number }[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): { previous: MediaTimingReviewContextLine[]; next: MediaTimingReviewContextLine[] } {
|
||||
const usable = options.cues
|
||||
.filter(
|
||||
(cue) =>
|
||||
cue.text.trim().length > 0 &&
|
||||
Number.isFinite(cue.startTime) &&
|
||||
Number.isFinite(cue.endTime) &&
|
||||
cue.endTime > cue.startTime,
|
||||
)
|
||||
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
|
||||
|
||||
let previous = usable
|
||||
.filter((cue) => cue.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS)
|
||||
.slice(-CONTEXT_LINE_LIMIT)
|
||||
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
|
||||
const next = usable
|
||||
.filter((cue) => cue.startTime >= options.endTime - CONTEXT_LINE_EPSILON_SECONDS)
|
||||
.slice(0, CONTEXT_LINE_LIMIT)
|
||||
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
|
||||
|
||||
if (previous.length === 0 && options.fallbackPrevious) {
|
||||
previous = options.fallbackPrevious
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.displayText.trim().length > 0 &&
|
||||
Number.isFinite(entry.startTime) &&
|
||||
Number.isFinite(entry.endTime) &&
|
||||
entry.endTime > entry.startTime &&
|
||||
entry.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS,
|
||||
)
|
||||
.slice(-CONTEXT_LINE_LIMIT)
|
||||
.map((entry) => ({
|
||||
text: entry.displayText.trim(),
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
}));
|
||||
}
|
||||
return { previous, next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for requests that name a review main has already resolved or disposed (decision
|
||||
* watchdog, overlay teardown, duplicate modal). The renderer closes on it instead of
|
||||
* leaving the user with controls that can never succeed.
|
||||
*/
|
||||
function staleReviewResult(): MediaTimingReviewActionResult {
|
||||
return { ok: false, stale: true, message: 'This timing review is no longer active.' };
|
||||
}
|
||||
|
||||
function isValidMediaTimingRange(
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(startTime) &&
|
||||
Number.isFinite(endTime) &&
|
||||
startTime >= 0 &&
|
||||
endTime > startTime &&
|
||||
(payload.maxMediaDuration <= 0 || endTime - startTime <= payload.maxMediaDuration + 0.001) &&
|
||||
(payload.mediaDuration === undefined || endTime <= payload.mediaDuration + 0.001)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMediaTimingReviewPayload(
|
||||
request: MediaTimingReviewRequest,
|
||||
options: {
|
||||
reviewId: string;
|
||||
mediaDuration?: number;
|
||||
contextLines?: {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
},
|
||||
): MediaTimingReviewOpenPayload {
|
||||
const duration = finiteNumber(options.mediaDuration);
|
||||
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||
const paddedStart = Math.max(0, request.startTime - request.audioPadding);
|
||||
let paddedEnd = Math.min(maxTime, request.endTime + request.audioPadding);
|
||||
const maxMediaDuration = Math.max(0, request.maxMediaDuration);
|
||||
if (maxMediaDuration > 0 && paddedEnd - paddedStart > maxMediaDuration) {
|
||||
paddedEnd = paddedStart + maxMediaDuration;
|
||||
}
|
||||
if (paddedEnd <= paddedStart) {
|
||||
paddedEnd = Math.min(maxTime, paddedStart + 0.1);
|
||||
}
|
||||
|
||||
const timelineStartTime = Math.max(0, paddedStart - INITIAL_TIMELINE_MARGIN_SECONDS);
|
||||
const timelineEndTime = Math.max(
|
||||
paddedEnd,
|
||||
Math.min(maxTime, paddedEnd + INITIAL_TIMELINE_MARGIN_SECONDS),
|
||||
);
|
||||
|
||||
return {
|
||||
reviewId: options.reviewId,
|
||||
kind: request.kind,
|
||||
text: request.text,
|
||||
previousLines: options.contextLines?.previous ?? [],
|
||||
nextLines: options.contextLines?.next ?? [],
|
||||
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
|
||||
originalStartTime: request.startTime,
|
||||
originalEndTime: request.endTime,
|
||||
selectionStartTime: paddedStart,
|
||||
selectionEndTime: paddedEnd,
|
||||
timelineStartTime,
|
||||
timelineEndTime,
|
||||
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
|
||||
maxMediaDuration,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
|
||||
let active: ActiveReview | null = null;
|
||||
let reviewInProgress = false;
|
||||
let pendingPauseRestore: ReviewMpvClient | null = null;
|
||||
|
||||
function restorePendingPlayback(): void {
|
||||
const mpvClient = pendingPauseRestore;
|
||||
pendingPauseRestore = null;
|
||||
if (mpvClient?.connected) {
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWindow(
|
||||
review: ActiveReview,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow | null> {
|
||||
const { windowSource } = review;
|
||||
if (!windowSource || review.windowFailed || !deps.acquireMediaWindow) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const coversRange = (candidate: RemoteMediaWindowRange): boolean =>
|
||||
candidate.startTime <= range.startTime && candidate.endTime >= range.endTime;
|
||||
if (review.window && coversRange(review.window)) return Promise.resolve(review.window);
|
||||
const inFlight = review.windowRequest;
|
||||
if (inFlight && coversRange(inFlight)) return inFlight.promise;
|
||||
|
||||
const request = {
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
promise: Promise.resolve<RemoteMediaWindow | null>(null),
|
||||
};
|
||||
request.promise = deps
|
||||
.acquireMediaWindow(windowSource, { startTime: range.startTime, endTime: range.endTime })
|
||||
.then((window) => {
|
||||
review.window = window;
|
||||
return window;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to the remote source for the rest of this review instead of retrying.
|
||||
review.windowFailed = true;
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (review.windowRequest === request) review.windowRequest = null;
|
||||
});
|
||||
review.windowRequest = request;
|
||||
return request.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview player for the range, restarting it when the range needs a
|
||||
* different file (the first cached window, or a wider one after the timeline grew).
|
||||
*/
|
||||
async function previewFor(
|
||||
review: ActiveReview,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<PreviewSession> {
|
||||
const window = await ensureWindow(review, range);
|
||||
if (active !== review) {
|
||||
// The review ended during the download; do not start a player nobody will dispose.
|
||||
throw new Error('This timing review is no longer active.');
|
||||
}
|
||||
const mediaPath = window?.path ?? review.mediaPath;
|
||||
if (review.preview?.path === mediaPath) return review.preview.session;
|
||||
|
||||
const previous = review.preview;
|
||||
const session = deps.createPreviewSession();
|
||||
session.onPlaybackEnded(() => {
|
||||
if (active === review && review.preview?.session === started) {
|
||||
deps.onPreviewEnded?.(review.payload.reviewId);
|
||||
}
|
||||
});
|
||||
const { audioTrackId, ...previewOptions } = review.previewOptions;
|
||||
const started = session
|
||||
.start({
|
||||
mediaPath,
|
||||
...previewOptions,
|
||||
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
|
||||
...(window
|
||||
? { absoluteTimestamps: true }
|
||||
: audioTrackId !== undefined
|
||||
? { audioTrackId }
|
||||
: {}),
|
||||
})
|
||||
.then(() => session)
|
||||
.catch((error) => {
|
||||
session.dispose();
|
||||
throw error;
|
||||
});
|
||||
review.preview = { path: mediaPath, session: started };
|
||||
void started.catch(() => {});
|
||||
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
|
||||
return started;
|
||||
}
|
||||
|
||||
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
|
||||
if (!mpvClient?.connected || !mediaPath) {
|
||||
deps.showStatus('Timing review unavailable. Using the original subtitle timing.');
|
||||
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 pauseState = booleanProperty(pauseRaw);
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||
|
||||
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
|
||||
try {
|
||||
contextLines = deps.getSubtitleContextLines?.({
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime,
|
||||
});
|
||||
} catch {
|
||||
contextLines = undefined;
|
||||
}
|
||||
const payload = buildMediaTimingReviewPayload(request, {
|
||||
reviewId: randomUUID(),
|
||||
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
||||
...(contextLines ? { contextLines } : {}),
|
||||
});
|
||||
const sourcePath = resolvedSource?.path.trim() || mediaPath;
|
||||
const inputOptions = resolvedSource?.inputOptions;
|
||||
const audioStreamIndex =
|
||||
resolvedSource?.singleResolvedStream || mpvClient.currentAudioStreamIndex == null
|
||||
? undefined
|
||||
: mpvClient.currentAudioStreamIndex;
|
||||
const windowSource: RemoteMediaWindowSource | null =
|
||||
deps.acquireMediaWindow && isRemoteMediaWindowSourcePath(sourcePath)
|
||||
? {
|
||||
path: sourcePath,
|
||||
...(inputOptions ? { inputOptions } : {}),
|
||||
audioStreamIndex: audioStreamIndex ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
|
||||
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
|
||||
resolveDecision = resolve;
|
||||
});
|
||||
const review: ActiveReview = {
|
||||
payload,
|
||||
mediaPath,
|
||||
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
|
||||
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
|
||||
windowSource,
|
||||
window: null,
|
||||
windowRequest: null,
|
||||
windowFailed: false,
|
||||
previewOptions: {
|
||||
executablePath: deps.getMpvExecutablePath(),
|
||||
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
|
||||
volume: finiteNumber(volumeRaw) ?? undefined,
|
||||
},
|
||||
preview: null,
|
||||
mpvClient,
|
||||
restorePlayback: pendingPauseRestore === mpvClient,
|
||||
resolve: resolveDecision,
|
||||
};
|
||||
active = review;
|
||||
pendingPauseRestore = null;
|
||||
// Download the visible timeline once now; the waveform and preview both wait on it.
|
||||
void previewFor(review, {
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
}).catch(() => {});
|
||||
|
||||
const opened = await deps.openModal(payload).catch(() => false);
|
||||
if (!opened) {
|
||||
await cleanupActiveReview();
|
||||
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const decisionWatchdog = setTimeout(
|
||||
() => resolveDecision({ action: 'use-original' }),
|
||||
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
|
||||
);
|
||||
let decision: MediaTimingReviewDecision;
|
||||
try {
|
||||
decision = await decisionPromise;
|
||||
} finally {
|
||||
clearTimeout(decisionWatchdog);
|
||||
}
|
||||
await cleanupActiveReview();
|
||||
return decision;
|
||||
}
|
||||
|
||||
async function requestReview(
|
||||
request: MediaTimingReviewRequest,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (active || reviewInProgress) {
|
||||
deps.showStatus('Finish the current timing review before mining another card.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
reviewInProgress = true;
|
||||
try {
|
||||
return await runReview(request);
|
||||
} catch {
|
||||
await cleanupActiveReview();
|
||||
restorePendingPlayback();
|
||||
deps.showStatus('Timing review failed. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
} finally {
|
||||
reviewInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewRange(
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
): Promise<MediaTimingReviewActionResult> {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
|
||||
return { ok: false, message: 'The selected preview range is invalid.' };
|
||||
}
|
||||
try {
|
||||
const previewSession = await previewFor(current, request);
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
await previewSession.play(request.startTime, request.endTime);
|
||||
// Playback spans the whole clip, so the review can end (watchdog, teardown) while
|
||||
// it runs; reporting success would leave the modal open on a dead review.
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getWaveform(
|
||||
request: MediaTimingReviewWaveformRequest,
|
||||
): Promise<MediaTimingReviewWaveformResult> {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(request.startTime) ||
|
||||
!Number.isFinite(request.endTime) ||
|
||||
request.startTime < 0 ||
|
||||
request.endTime <= request.startTime ||
|
||||
(current.payload.mediaDuration !== undefined &&
|
||||
request.endTime > current.payload.mediaDuration + 0.001)
|
||||
) {
|
||||
return { ok: false, message: 'The waveform range is invalid.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const window = await ensureWindow(current, request);
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
const peaks = await deps.generateWaveform({
|
||||
mediaPath: window?.media ?? current.waveformMedia,
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime,
|
||||
...(!window && current.audioStreamIndex !== undefined
|
||||
? { audioStreamIndex: current.audioStreamIndex }
|
||||
: {}),
|
||||
});
|
||||
// ffmpeg decoding runs long enough for the review to end underneath it.
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
|
||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||
}
|
||||
return { ok: true, peaks };
|
||||
} catch {
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||
}
|
||||
}
|
||||
|
||||
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
|
||||
const current = active;
|
||||
if (!current || reviewId !== current.payload.reviewId) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
try {
|
||||
const previewSession = current.preview ? await current.preview.session : null;
|
||||
await previewSession?.stop();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (request.decision.action === 'confirm') {
|
||||
const { startTime, endTime, text } = 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.' };
|
||||
}
|
||||
}
|
||||
current.resolve(request.decision);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function cleanupActiveReview(): Promise<void> {
|
||||
const current = active;
|
||||
active = null;
|
||||
if (!current) return;
|
||||
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
|
||||
if (current.restorePlayback && current.mpvClient.connected) {
|
||||
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
active?.resolve({ action: 'use-original' });
|
||||
await cleanupActiveReview();
|
||||
restorePendingPlayback();
|
||||
}
|
||||
|
||||
return {
|
||||
requestReview,
|
||||
previewRange,
|
||||
getWaveform,
|
||||
stopPreview,
|
||||
resolveReview,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
} from './primary-subtitle-text';
|
||||
@@ -702,3 +703,37 @@ test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue
|
||||
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitle drops a finished caption row lingering beside a fresh line', () => {
|
||||
// Broadcast captions give each row its own event, and a row of the previous line can
|
||||
// outlive its siblings by a frame. mpv's sub-text still lists it, so the mined line
|
||||
// must come from the parsed cue that is actually running, with that cue's timings.
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:14:30.00,0:14:33.00,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}ずっと 言えなかっ',
|
||||
'Dialogue: 0,0:14:30.00,0:14:33.02,Default,,0,0,0,,{\\pos(232,497)}たが',
|
||||
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,437)}⸨もし お互い',
|
||||
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,497)}本命 受かったら 大学 近いし➡',
|
||||
].join('\n');
|
||||
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
|
||||
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: 'たが\n⸨もし お互い\n本命 受かったら 大学 近いし➡',
|
||||
currentTimeSec: 14 * 60 + 33.05,
|
||||
cues,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
{ ...resolved, cues: resolved?.cues.map((cue) => cue.text) },
|
||||
{
|
||||
text: '⸨もし お互い\n本命 受かったら 大学 近いし➡',
|
||||
startTime: 14 * 60 + 33,
|
||||
endTime: 14 * 60 + 36,
|
||||
cues: ['⸨もし お互い\n本命 受かったら 大学 近いし➡'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -319,6 +319,26 @@ export function resolveRecordedPrimarySubtitleText(options: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed view of the live text with its cue timings: a canonical animation when one
|
||||
* explains the live lines, otherwise the active parsed cues. Null when the parsed cues
|
||||
* cannot account for every live line, in which case callers keep the raw mpv text.
|
||||
*/
|
||||
export function resolvePrimarySubtitle(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): ResolvedPrimarySubtitle | null {
|
||||
const liveText = decodedLiveText(options.liveText, options.cues);
|
||||
if (!liveText.trim()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
@@ -328,13 +348,5 @@ export function resolvePrimarySubtitleText(options: {
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText,
|
||||
currentTimeSec: options.currentTimeSec,
|
||||
cues: options.cues,
|
||||
})?.text ??
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||
removeLiveGlyphFragmentLines(liveText)
|
||||
);
|
||||
return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user