mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
import { resolveMediaGenerationInput } from '../../anki-integration/media-source';
|
|
import {
|
|
captureLiveSubtitleMiningContext,
|
|
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
|
createFieldGroupingOverlayRuntime,
|
|
handleMineSentenceDigit as handleMineSentenceDigitCore,
|
|
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
|
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
|
|
mineSentenceCard as mineSentenceCardCore,
|
|
shiftSubtitleRangeToVideoClock,
|
|
toSubtitleClock,
|
|
triggerFieldGrouping as triggerFieldGroupingCore,
|
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
|
type createOverlayManager,
|
|
} from '../../core/services';
|
|
import { createMediaTimingFrameExtractor } from '../../core/services/media-timing-frame';
|
|
import { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
|
|
import { generateSpeechWaveform } from '../../core/services/media-timing-waveform';
|
|
import { getSharedRemoteMediaWindowCache } from '../../core/services/remote-media-window-cache';
|
|
import type { Logger } from '../../logger';
|
|
import type { PluginMinedNote, PluginMiningProvenance } from '../../plugin-api';
|
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
|
import type { ResolvedConfig, SubtitleMiningContext } from '../../types';
|
|
import type { OverlayModalRuntime } from '../overlay-runtime';
|
|
import {
|
|
createCopyCurrentSubtitleHandler,
|
|
createHandleMineSentenceDigitHandler,
|
|
createHandleMultiCopyDigitHandler,
|
|
createMarkLastCardAsAudioCardHandler,
|
|
createMineSentenceCardHandler,
|
|
createRefreshKnownWordCacheHandler,
|
|
createTriggerFieldGroupingHandler,
|
|
createUpdateLastCardFromClipboardHandler,
|
|
} from '../runtime/domains/mining';
|
|
import {
|
|
createGetFieldGroupingResolverHandler,
|
|
createSetFieldGroupingResolverHandler,
|
|
} from '../runtime/domains/overlay';
|
|
import {
|
|
collectMediaTimingContextLines,
|
|
createMediaTimingReviewRuntime,
|
|
} from '../runtime/media-timing-review';
|
|
import { openMediaTimingReviewModal } from '../runtime/media-timing-review-open';
|
|
import { findMpvExecutablePath } from '../runtime/mpv-process';
|
|
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
|
import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text';
|
|
import type { AppState } from '../state';
|
|
import type { OverlayModalsAssembly } from './overlay-modals';
|
|
import type { CliSlice, IpcSlice } from './slices';
|
|
import type { YoutubeAssembly } from './youtube';
|
|
|
|
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
|
type AnkiIntegrationLike = NonNullable<AppState['ankiIntegration']>;
|
|
|
|
/**
|
|
* What card mining needs from the rest of the app. Siblings built after this assembly in
|
|
* `main.ts` are reached through callbacks.
|
|
*/
|
|
export interface MiningAssemblyDeps {
|
|
appState: Pick<
|
|
AppState,
|
|
| 'mpvClient'
|
|
| 'currentMediaPath'
|
|
| 'activeParsedSubtitleCues'
|
|
| 'ankiIntegration'
|
|
| 'subtitleTimingTracker'
|
|
| 'fieldGroupingResolver'
|
|
| 'fieldGroupingResolverSequence'
|
|
>;
|
|
getConfig: () => ResolvedConfig;
|
|
logger: Pick<Logger, 'warn' | 'error'>;
|
|
overlayManager: Pick<
|
|
OverlayManager,
|
|
'getMainWindow' | 'getModalWindow' | 'getVisibleOverlayVisible'
|
|
>;
|
|
overlayModalRuntime: Pick<
|
|
OverlayModalRuntime,
|
|
| 'getRestoreVisibleOverlayOnModalClose'
|
|
| 'waitForModalOpen'
|
|
| 'handleOverlayModalClosed'
|
|
| 'sendToActiveOverlayWindow'
|
|
>;
|
|
youtube: Pick<
|
|
YoutubeAssembly,
|
|
'getCachedMediaPathForCurrentPlayback' | 'shouldRequireMediaCacheForCurrentPlayback'
|
|
>;
|
|
/** Electron's clipboard, injected so the assembly loads under `bun test`. */
|
|
clipboard: { readText: () => string; writeText: (text: string) => void };
|
|
getOverlayNotifications: () => Pick<
|
|
ReturnType<typeof createOverlayNotificationsRuntime>,
|
|
'showConfiguredStatusNotification' | 'showConfiguredPlaybackFeedback'
|
|
>;
|
|
getOverlayModals: () => Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
|
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
|
ensureOverlayStartupPrereqs: () => void;
|
|
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
|
/** Callbacks the Anki integration reports back through once mining finishes. */
|
|
ankiIntegrationCallbacks: {
|
|
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
|
getMiningProvenance: () => PluginMiningProvenance;
|
|
onCardsReady: (noteIds: number[], notes: PluginMinedNote[]) => void;
|
|
onKnownWordCacheUpdated: () => void;
|
|
generateSentenceFurigana: (text: string, highlightedText?: string) => Promise<string | null>;
|
|
};
|
|
}
|
|
|
|
export type MiningAssembly = ReturnType<typeof createMiningAssembly>;
|
|
|
|
/**
|
|
* Owns card mining: the subtitle context captured for the next card, the mine/copy/update
|
|
* handlers behind shortcuts, CLI and session actions, field grouping, and the media timing
|
|
* review the Anki integration opens before writing media.
|
|
*/
|
|
export function createMiningAssembly(deps: MiningAssemblyDeps) {
|
|
const { appState, logger, overlayManager, overlayModalRuntime } = deps;
|
|
const showStatus = (text: string) =>
|
|
deps.getOverlayNotifications().showConfiguredStatusNotification(text);
|
|
const showPlaybackFeedback = (text: string) =>
|
|
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text);
|
|
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
|
|
|
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
|
|
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
|
// event, so a finished caption row lingering beside a fresh line would end up on the
|
|
// card. The parsed view also carries the cue's own timings for the clip range.
|
|
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
|
const resolved = resolvePrimarySubtitle({
|
|
liveText: appState.mpvClient?.currentSubText ?? '',
|
|
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
|
cues: appState.activeParsedSubtitleCues,
|
|
});
|
|
// Same validity bar as the live capture path: an unusable resolved span must fall
|
|
// back rather than hand mining an empty line or an inverted range.
|
|
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
|
|
if (
|
|
!resolved ||
|
|
!resolvedText ||
|
|
!Number.isFinite(resolved.startTime) ||
|
|
!Number.isFinite(resolved.endTime) ||
|
|
resolved.endTime <= resolved.startTime
|
|
) {
|
|
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
|
}
|
|
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
|
{
|
|
source: 'overlay',
|
|
text: resolvedText,
|
|
startTime: resolved.startTime,
|
|
endTime: resolved.endTime,
|
|
capturedAtMs: Date.now(),
|
|
},
|
|
subDelay,
|
|
);
|
|
}
|
|
|
|
const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => {
|
|
pendingSubtitleMiningContext = context;
|
|
};
|
|
/** Hands the recorded context to exactly one card, then forgets it. */
|
|
const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => {
|
|
const context = pendingSubtitleMiningContext;
|
|
pendingSubtitleMiningContext = null;
|
|
return context;
|
|
};
|
|
|
|
const getFieldGroupingResolver = createGetFieldGroupingResolverHandler({
|
|
getResolver: () => appState.fieldGroupingResolver,
|
|
});
|
|
|
|
const setFieldGroupingResolver = createSetFieldGroupingResolverHandler({
|
|
setResolver: (resolver) => {
|
|
appState.fieldGroupingResolver = resolver;
|
|
},
|
|
nextSequence: () => {
|
|
appState.fieldGroupingResolverSequence += 1;
|
|
return appState.fieldGroupingResolverSequence;
|
|
},
|
|
getSequence: () => appState.fieldGroupingResolverSequence,
|
|
});
|
|
|
|
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHostedModal>({
|
|
getMainWindow: () => overlayManager.getMainWindow(),
|
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
|
setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible),
|
|
getResolver: () => getFieldGroupingResolver(),
|
|
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
|
getRestoreVisibleOverlayOnModalClose: () =>
|
|
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
|
// These are optional on the runtime options, so a missing forward compiles silently, but
|
|
// dropping them leaves the field grouping modal with no modal-open ack/retry, no teardown on
|
|
// failure, and no warn logging.
|
|
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
|
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
|
logWarn: (message) => logger.warn(message),
|
|
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
|
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
|
deps.ensureOverlayWindowsReadyForVisibilityActions(),
|
|
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
|
|
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
});
|
|
|
|
const sendToOverlayWindows = (channel: string, payload: unknown) => {
|
|
// The review may live in either overlay window; the renderer ignores foreign review ids.
|
|
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
|
if (window && !window.isDestroyed()) {
|
|
window.webContents.send(channel, payload);
|
|
}
|
|
}
|
|
};
|
|
const getMediaGenerationOptions = () => ({
|
|
getCachedMediaPath: (currentVideoPath: string, kind: 'audio' | 'video') =>
|
|
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
|
|
remoteCacheMode: deps.youtube.shouldRequireMediaCacheForCurrentPlayback()
|
|
? ('required' as const)
|
|
: ('optional' as const),
|
|
});
|
|
|
|
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
|
|
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
|
getMpvClient: () => appState.mpvClient,
|
|
getCurrentMediaPath: () =>
|
|
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
|
// An invalid configured path is passed through so the preview fails instead of using PATH mpv.
|
|
getMpvExecutablePath: () => {
|
|
const configuredPath = deps.getConfig().mpv.executablePath;
|
|
return findMpvExecutablePath(configuredPath) || configuredPath;
|
|
},
|
|
createPreviewSession: () => new MediaTimingPreviewSession(),
|
|
generateWaveform: (options) => generateSpeechWaveform(options),
|
|
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
|
|
clearFrameCache: () => mediaTimingFrameExtractor.clear(),
|
|
resolveVideoSource: () =>
|
|
resolveMediaGenerationInput(appState.mpvClient, 'video', getMediaGenerationOptions()),
|
|
resolveMediaSource: async () => {
|
|
const resolved = await resolveMediaGenerationInput(
|
|
appState.mpvClient,
|
|
'audio',
|
|
getMediaGenerationOptions(),
|
|
);
|
|
return resolved
|
|
? {
|
|
path: resolved.path,
|
|
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
|
|
singleResolvedStream: resolved.singleResolvedStream,
|
|
}
|
|
: null;
|
|
},
|
|
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
|
getSubtitleContextLines: (range) =>
|
|
collectMediaTimingContextLines({
|
|
// Review ranges and tracker entries are on the video clock; parsed cues are not.
|
|
cues: appState.activeParsedSubtitleCues.map((cue) =>
|
|
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
|
|
),
|
|
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
|
startTime: range.startTime,
|
|
endTime: range.endTime,
|
|
}),
|
|
openModal: (payload, signal) =>
|
|
openMediaTimingReviewModal(
|
|
deps.getOverlayModals().createOverlayHostedModalOpenDeps(),
|
|
payload,
|
|
signal,
|
|
),
|
|
onPreviewEnded: (reviewId) => {
|
|
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
|
|
},
|
|
onPreviewPosition: (position) => {
|
|
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
|
|
},
|
|
showStatus: (message) =>
|
|
deps
|
|
.getOverlayNotifications()
|
|
.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
|
});
|
|
|
|
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
|
|
getAnkiIntegration: () => appState.ankiIntegration,
|
|
readClipboardText: () => deps.clipboard.readText(),
|
|
showMpvOsd: showStatus,
|
|
updateLastCardFromClipboardCore,
|
|
});
|
|
|
|
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
|
|
getAnkiIntegration: () => appState.ankiIntegration,
|
|
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
|
});
|
|
|
|
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
|
getAnkiIntegration: () => appState.ankiIntegration,
|
|
showMpvOsd: showStatus,
|
|
triggerFieldGroupingCore,
|
|
});
|
|
|
|
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
|
|
getAnkiIntegration: () => appState.ankiIntegration,
|
|
showMpvOsd: showStatus,
|
|
markLastCardAsAudioCardCore,
|
|
});
|
|
|
|
const mineSentenceCard = createMineSentenceCardHandler({
|
|
getAnkiIntegration: () => appState.ankiIntegration,
|
|
getMpvClient: () => appState.mpvClient,
|
|
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
|
showMpvOsd: showStatus,
|
|
mineSentenceCardCore,
|
|
});
|
|
|
|
const copySubtitleCount = createHandleMultiCopyDigitHandler({
|
|
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
|
writeClipboardText: (text) => deps.clipboard.writeText(text),
|
|
showMpvOsd: showPlaybackFeedback,
|
|
handleMultiCopyDigitCore,
|
|
});
|
|
|
|
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
|
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
|
writeClipboardText: (text) => deps.clipboard.writeText(text),
|
|
showMpvOsd: showStatus,
|
|
copyCurrentSubtitleCore,
|
|
});
|
|
|
|
const mineSentenceCount = createHandleMineSentenceDigitHandler({
|
|
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
|
getAnkiIntegration: () => appState.ankiIntegration,
|
|
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
|
|
showMpvOsd: showStatus,
|
|
logError: (message, err) => {
|
|
logger.error(message, err);
|
|
},
|
|
handleMineSentenceDigitCore,
|
|
});
|
|
|
|
/** Points a freshly created Anki integration at mining, stats, plugins and furigana. */
|
|
function attachToAnkiIntegration(integration: AnkiIntegrationLike): void {
|
|
const callbacks = deps.ankiIntegrationCallbacks;
|
|
integration.setRecordCardsMinedCallback(callbacks.recordCardsMined);
|
|
integration.setMiningProvenanceProvider(callbacks.getMiningProvenance);
|
|
integration.setCardsReadyCallback(callbacks.onCardsReady);
|
|
integration.setKnownWordCacheUpdatedCallback(callbacks.onKnownWordCacheUpdated);
|
|
integration.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
|
integration.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
|
integration.setSentenceFuriganaGenerator(callbacks.generateSentenceFurigana);
|
|
}
|
|
|
|
const ipc: IpcSlice<
|
|
| 'recordSubtitleMiningContext'
|
|
| 'previewMediaTimingReview'
|
|
| 'getMediaTimingReviewFrame'
|
|
| 'getMediaTimingReviewWaveform'
|
|
| 'stopMediaTimingReviewPreview'
|
|
| 'resolveMediaTimingReview'
|
|
> = {
|
|
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
|
|
// live mpv sub timings at lookup time so media generation clips the mined line even
|
|
// when extraction finishes long after playback has moved on.
|
|
recordSubtitleMiningContext: (context) =>
|
|
recordSubtitleMiningContext(
|
|
context
|
|
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
|
: captureCurrentPrimarySubtitleMiningContext(),
|
|
),
|
|
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
|
getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request),
|
|
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
|
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
|
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
|
};
|
|
|
|
const cli: CliSlice<
|
|
| 'copyCurrentSubtitle'
|
|
| 'mineSentenceCard'
|
|
| 'updateLastCardFromClipboard'
|
|
| 'refreshKnownWordCache'
|
|
| 'triggerFieldGrouping'
|
|
| 'markLastCardAsAudioCard'
|
|
> = {
|
|
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
|
mineSentenceCard: () => mineSentenceCard(),
|
|
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
|
refreshKnownWordCache: () => refreshKnownWordCache(),
|
|
triggerFieldGrouping: () => triggerFieldGrouping(),
|
|
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
|
};
|
|
|
|
return {
|
|
ipc,
|
|
cli,
|
|
captureCurrentPrimarySubtitleMiningContext,
|
|
recordSubtitleMiningContext,
|
|
getFieldGroupingResolver,
|
|
setFieldGroupingResolver,
|
|
createFieldGroupingCallback: fieldGroupingOverlayRuntime.createFieldGroupingCallback,
|
|
mediaTimingReviewRuntime,
|
|
attachToAnkiIntegration,
|
|
updateLastCardFromClipboard,
|
|
refreshKnownWordCache,
|
|
triggerFieldGrouping,
|
|
markLastCardAsAudioCard,
|
|
mineSentenceCard,
|
|
mineSentenceCount,
|
|
copyCurrentSubtitle,
|
|
copySubtitleCount,
|
|
};
|
|
}
|