mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(core): add module scaffolding and provider registries
This commit is contained in:
21
src/core/action-bus.ts
Normal file
21
src/core/action-bus.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type ActionWithType = { type: string };
|
||||
|
||||
export type ActionHandler<TAction extends ActionWithType> = (
|
||||
action: TAction,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export class ActionBus<TAction extends ActionWithType> {
|
||||
private handlers = new Map<string, ActionHandler<TAction>>();
|
||||
|
||||
register(type: TAction["type"], handler: ActionHandler<TAction>): void {
|
||||
this.handlers.set(type, handler);
|
||||
}
|
||||
|
||||
async dispatch(action: TAction): Promise<void> {
|
||||
const handler = this.handlers.get(action.type);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for action: ${action.type}`);
|
||||
}
|
||||
await handler(action);
|
||||
}
|
||||
}
|
||||
16
src/core/actions.ts
Normal file
16
src/core/actions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type AppAction =
|
||||
| { type: "overlay.toggleVisible" }
|
||||
| { type: "overlay.toggleInvisible" }
|
||||
| { type: "overlay.setVisible"; visible: boolean }
|
||||
| { type: "overlay.setInvisibleVisible"; visible: boolean }
|
||||
| { type: "overlay.openSettings" }
|
||||
| { type: "subtitle.copyCurrent" }
|
||||
| { type: "subtitle.copyMultiplePrompt"; timeoutMs: number }
|
||||
| { type: "anki.mineSentence" }
|
||||
| { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number }
|
||||
| { type: "anki.updateLastCardFromClipboard" }
|
||||
| { type: "anki.markAudioCard" }
|
||||
| { type: "kiku.triggerFieldGrouping" }
|
||||
| { type: "subsync.triggerFromConfig" }
|
||||
| { type: "secondarySub.toggleMode" }
|
||||
| { type: "runtimeOptions.openPalette" };
|
||||
45
src/core/app-context.ts
Normal file
45
src/core/app-context.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadQuery,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuFilesQuery,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchQuery,
|
||||
RuntimeOptionState,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncMode,
|
||||
SubsyncResult,
|
||||
} from "../types";
|
||||
|
||||
export interface RuntimeOptionsModuleContext {
|
||||
getAnkiConfig: () => AnkiConnectConfig;
|
||||
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
onOptionsChanged: (options: RuntimeOptionState[]) => void;
|
||||
}
|
||||
|
||||
export interface AppContext {
|
||||
runtimeOptions?: RuntimeOptionsModuleContext;
|
||||
jimaku?: {
|
||||
getMediaInfo: () => JimakuMediaInfo;
|
||||
searchEntries: (
|
||||
query: JimakuSearchQuery,
|
||||
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
listFiles: (
|
||||
query: JimakuFilesQuery,
|
||||
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||
downloadFile: (
|
||||
query: JimakuDownloadQuery,
|
||||
) => Promise<JimakuDownloadResult>;
|
||||
};
|
||||
subsync?: {
|
||||
getDefaultMode: () => SubsyncMode;
|
||||
openManualPicker: () => Promise<void>;
|
||||
runAuto: () => Promise<SubsyncResult>;
|
||||
runManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
showOsd: (message: string) => void;
|
||||
runWithSpinner: <T>(task: () => Promise<T>, label?: string) => Promise<T>;
|
||||
};
|
||||
}
|
||||
36
src/core/module-registry.ts
Normal file
36
src/core/module-registry.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SubminerModule } from "./module";
|
||||
|
||||
export class ModuleRegistry<TContext = unknown> {
|
||||
private readonly modules: SubminerModule<TContext>[] = [];
|
||||
|
||||
register(module: SubminerModule<TContext>): void {
|
||||
if (this.modules.some((existing) => existing.id === module.id)) {
|
||||
throw new Error(`Module already registered: ${module.id}`);
|
||||
}
|
||||
this.modules.push(module);
|
||||
}
|
||||
|
||||
async initAll(context: TContext): Promise<void> {
|
||||
for (const module of this.modules) {
|
||||
if (module.init) {
|
||||
await module.init(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startAll(): Promise<void> {
|
||||
for (const module of this.modules) {
|
||||
if (module.start) {
|
||||
await module.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
for (const module of [...this.modules].reverse()) {
|
||||
if (module.stop) {
|
||||
await module.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/core/module.ts
Normal file
6
src/core/module.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SubminerModule<TContext = unknown> {
|
||||
id: string;
|
||||
init?: (context: TContext) => void | Promise<void>;
|
||||
start?: () => void | Promise<void>;
|
||||
stop?: () => void | Promise<void>;
|
||||
}
|
||||
61
src/ipc/contract.ts
Normal file
61
src/ipc/contract.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const IPC_CHANNELS = {
|
||||
rendererToMainInvoke: {
|
||||
getOverlayVisibility: "get-overlay-visibility",
|
||||
getVisibleOverlayVisibility: "get-visible-overlay-visibility",
|
||||
getInvisibleOverlayVisibility: "get-invisible-overlay-visibility",
|
||||
getCurrentSubtitle: "get-current-subtitle",
|
||||
getCurrentSubtitleAss: "get-current-subtitle-ass",
|
||||
getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics",
|
||||
getSubtitlePosition: "get-subtitle-position",
|
||||
getSubtitleStyle: "get-subtitle-style",
|
||||
getMecabStatus: "get-mecab-status",
|
||||
getKeybindings: "get-keybindings",
|
||||
getSecondarySubMode: "get-secondary-sub-mode",
|
||||
getCurrentSecondarySub: "get-current-secondary-sub",
|
||||
runSubsyncManual: "subsync:run-manual",
|
||||
getAnkiConnectStatus: "get-anki-connect-status",
|
||||
runtimeOptionsGet: "runtime-options:get",
|
||||
runtimeOptionsSet: "runtime-options:set",
|
||||
runtimeOptionsCycle: "runtime-options:cycle",
|
||||
kikuBuildMergePreview: "kiku:build-merge-preview",
|
||||
jimakuGetMediaInfo: "jimaku:get-media-info",
|
||||
jimakuSearchEntries: "jimaku:search-entries",
|
||||
jimakuListFiles: "jimaku:list-files",
|
||||
jimakuDownloadFile: "jimaku:download-file",
|
||||
},
|
||||
rendererToMainSend: {
|
||||
setIgnoreMouseEvents: "set-ignore-mouse-events",
|
||||
overlayModalClosed: "overlay:modal-closed",
|
||||
openYomitanSettings: "open-yomitan-settings",
|
||||
quitApp: "quit-app",
|
||||
toggleDevTools: "toggle-dev-tools",
|
||||
toggleOverlay: "toggle-overlay",
|
||||
saveSubtitlePosition: "save-subtitle-position",
|
||||
setMecabEnabled: "set-mecab-enabled",
|
||||
mpvCommand: "mpv-command",
|
||||
setAnkiConnectEnabled: "set-anki-connect-enabled",
|
||||
clearAnkiConnectHistory: "clear-anki-connect-history",
|
||||
kikuFieldGroupingRespond: "kiku:field-grouping-respond",
|
||||
},
|
||||
mainToRendererEvent: {
|
||||
subtitleSet: "subtitle:set",
|
||||
mpvSubVisibility: "mpv:subVisibility",
|
||||
subtitlePositionSet: "subtitle-position:set",
|
||||
mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set",
|
||||
subtitleAssSet: "subtitle-ass:set",
|
||||
overlayDebugVisualizationSet: "overlay-debug-visualization:set",
|
||||
secondarySubtitleSet: "secondary-subtitle:set",
|
||||
secondarySubtitleMode: "secondary-subtitle:mode",
|
||||
subsyncOpenManual: "subsync:open-manual",
|
||||
kikuFieldGroupingRequest: "kiku:field-grouping-request",
|
||||
runtimeOptionsChanged: "runtime-options:changed",
|
||||
runtimeOptionsOpen: "runtime-options:open",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type RendererToMainInvokeChannel =
|
||||
(typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke];
|
||||
export type RendererToMainSendChannel =
|
||||
(typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend];
|
||||
export type MainToRendererEventChannel =
|
||||
(typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent];
|
||||
19
src/ipc/main-api.ts
Normal file
19
src/ipc/main-api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import {
|
||||
RendererToMainInvokeChannel,
|
||||
RendererToMainSendChannel,
|
||||
} from "./contract";
|
||||
|
||||
export function onRendererSend(
|
||||
channel: RendererToMainSendChannel,
|
||||
listener: (event: IpcMainEvent, ...args: any[]) => void,
|
||||
): void {
|
||||
ipcMain.on(channel, listener);
|
||||
}
|
||||
|
||||
export function handleRendererInvoke(
|
||||
channel: RendererToMainInvokeChannel,
|
||||
handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown,
|
||||
): void {
|
||||
ipcMain.handle(channel, handler);
|
||||
}
|
||||
27
src/ipc/renderer-api.ts
Normal file
27
src/ipc/renderer-api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from "electron";
|
||||
import {
|
||||
MainToRendererEventChannel,
|
||||
RendererToMainInvokeChannel,
|
||||
RendererToMainSendChannel,
|
||||
} from "./contract";
|
||||
|
||||
export function invokeFromRenderer<T>(
|
||||
channel: RendererToMainInvokeChannel,
|
||||
...args: unknown[]
|
||||
): Promise<T> {
|
||||
return ipcRenderer.invoke(channel, ...args) as Promise<T>;
|
||||
}
|
||||
|
||||
export function sendFromRenderer(
|
||||
channel: RendererToMainSendChannel,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
}
|
||||
|
||||
export function onMainEvent(
|
||||
channel: MainToRendererEventChannel,
|
||||
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
|
||||
): void {
|
||||
ipcRenderer.on(channel, listener);
|
||||
}
|
||||
72
src/modules/jimaku/module.ts
Normal file
72
src/modules/jimaku/module.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { AppContext } from "../../core/app-context";
|
||||
import { SubminerModule } from "../../core/module";
|
||||
import {
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadQuery,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuFilesQuery,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchQuery,
|
||||
} from "../../types";
|
||||
|
||||
export class JimakuModule implements SubminerModule<AppContext> {
|
||||
readonly id = "jimaku";
|
||||
private context: AppContext["jimaku"] | undefined;
|
||||
|
||||
init(context: AppContext): void {
|
||||
if (!context.jimaku) {
|
||||
throw new Error("Jimaku context is missing");
|
||||
}
|
||||
this.context = context.jimaku;
|
||||
}
|
||||
|
||||
getMediaInfo(): JimakuMediaInfo {
|
||||
if (!this.context) {
|
||||
return {
|
||||
title: "",
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: "low",
|
||||
filename: "",
|
||||
rawTitle: "",
|
||||
};
|
||||
}
|
||||
return this.context.getMediaInfo();
|
||||
}
|
||||
|
||||
searchEntries(
|
||||
query: JimakuSearchQuery,
|
||||
): Promise<JimakuApiResponse<JimakuEntry[]>> {
|
||||
if (!this.context) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: { error: "Jimaku module not initialized" },
|
||||
});
|
||||
}
|
||||
return this.context.searchEntries(query);
|
||||
}
|
||||
|
||||
listFiles(
|
||||
query: JimakuFilesQuery,
|
||||
): Promise<JimakuApiResponse<JimakuFileEntry[]>> {
|
||||
if (!this.context) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: { error: "Jimaku module not initialized" },
|
||||
});
|
||||
}
|
||||
return this.context.listFiles(query);
|
||||
}
|
||||
|
||||
downloadFile(query: JimakuDownloadQuery): Promise<JimakuDownloadResult> {
|
||||
if (!this.context) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: { error: "Jimaku module not initialized" },
|
||||
});
|
||||
}
|
||||
return this.context.downloadFile(query);
|
||||
}
|
||||
}
|
||||
61
src/modules/runtime-options/module.ts
Normal file
61
src/modules/runtime-options/module.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AppContext } from "../../core/app-context";
|
||||
import { SubminerModule } from "../../core/module";
|
||||
import { RuntimeOptionsManager } from "../../runtime-options";
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
RuntimeOptionApplyResult,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
} from "../../types";
|
||||
|
||||
export class RuntimeOptionsModule implements SubminerModule<AppContext> {
|
||||
readonly id = "runtime-options";
|
||||
private manager: RuntimeOptionsManager | null = null;
|
||||
|
||||
init(context: AppContext): void {
|
||||
if (!context.runtimeOptions) {
|
||||
throw new Error("Runtime options context is missing");
|
||||
}
|
||||
|
||||
this.manager = new RuntimeOptionsManager(
|
||||
context.runtimeOptions.getAnkiConfig,
|
||||
{
|
||||
applyAnkiPatch: context.runtimeOptions.applyAnkiPatch,
|
||||
onOptionsChanged: context.runtimeOptions.onOptionsChanged,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
listOptions(): RuntimeOptionState[] {
|
||||
return this.manager ? this.manager.listOptions() : [];
|
||||
}
|
||||
|
||||
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
|
||||
return this.manager?.getOptionValue(id);
|
||||
}
|
||||
|
||||
setOptionValue(
|
||||
id: RuntimeOptionId,
|
||||
value: RuntimeOptionValue,
|
||||
): RuntimeOptionApplyResult {
|
||||
if (!this.manager) {
|
||||
return { ok: false, error: "Runtime options manager unavailable" };
|
||||
}
|
||||
return this.manager.setOptionValue(id, value);
|
||||
}
|
||||
|
||||
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
|
||||
if (!this.manager) {
|
||||
return { ok: false, error: "Runtime options manager unavailable" };
|
||||
}
|
||||
return this.manager.cycleOption(id, direction);
|
||||
}
|
||||
|
||||
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
|
||||
if (!this.manager) {
|
||||
return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {};
|
||||
}
|
||||
return this.manager.getEffectiveAnkiConnectConfig(baseConfig);
|
||||
}
|
||||
}
|
||||
78
src/modules/subsync/module.ts
Normal file
78
src/modules/subsync/module.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { AppContext } from "../../core/app-context";
|
||||
import { SubminerModule } from "../../core/module";
|
||||
import { SubsyncManualRunRequest, SubsyncResult } from "../../types";
|
||||
|
||||
export class SubsyncModule implements SubminerModule<AppContext> {
|
||||
readonly id = "subsync";
|
||||
private inProgress = false;
|
||||
private context: AppContext["subsync"] | undefined;
|
||||
|
||||
init(context: AppContext): void {
|
||||
if (!context.subsync) {
|
||||
throw new Error("Subsync context is missing");
|
||||
}
|
||||
this.context = context.subsync;
|
||||
}
|
||||
|
||||
isInProgress(): boolean {
|
||||
return this.inProgress;
|
||||
}
|
||||
|
||||
async triggerFromConfig(): Promise<void> {
|
||||
if (!this.context) {
|
||||
throw new Error("Subsync module not initialized");
|
||||
}
|
||||
|
||||
if (this.inProgress) {
|
||||
this.context.showOsd("Subsync already running");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.context.getDefaultMode() === "manual") {
|
||||
await this.context.openManualPicker();
|
||||
this.context.showOsd("Subsync: choose engine and source");
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgress = true;
|
||||
const result = await this.context.runWithSpinner(
|
||||
() => this.context!.runAuto(),
|
||||
"Subsync: syncing",
|
||||
);
|
||||
this.context.showOsd(result.message);
|
||||
} catch (error) {
|
||||
this.context.showOsd(`Subsync failed: ${(error as Error).message}`);
|
||||
} finally {
|
||||
this.inProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runManual(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
||||
if (!this.context) {
|
||||
return { ok: false, message: "Subsync module not initialized" };
|
||||
}
|
||||
|
||||
if (this.inProgress) {
|
||||
const busy = "Subsync already running";
|
||||
this.context.showOsd(busy);
|
||||
return { ok: false, message: busy };
|
||||
}
|
||||
|
||||
try {
|
||||
this.inProgress = true;
|
||||
const result = await this.context.runWithSpinner(
|
||||
() => this.context!.runManual(request),
|
||||
"Subsync: syncing",
|
||||
);
|
||||
this.context.showOsd(result.message);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = `Subsync failed: ${(error as Error).message}`;
|
||||
this.context.showOsd(message);
|
||||
return { ok: false, message };
|
||||
} finally {
|
||||
this.inProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/subsync/engines.ts
Normal file
95
src/subsync/engines.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export type SubsyncEngine = "alass" | "ffsubsync";
|
||||
|
||||
export interface SubsyncCommandResult {
|
||||
ok: boolean;
|
||||
code: number | null;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SubsyncEngineExecutionContext {
|
||||
referenceFilePath: string;
|
||||
videoPath: string;
|
||||
inputSubtitlePath: string;
|
||||
outputPath: string;
|
||||
audioStreamIndex: number | null;
|
||||
resolveExecutablePath: (
|
||||
configuredPath: string,
|
||||
commandName: string,
|
||||
) => string;
|
||||
resolvedPaths: {
|
||||
alassPath: string;
|
||||
ffsubsyncPath: string;
|
||||
};
|
||||
runCommand: (command: string, args: string[]) => Promise<SubsyncCommandResult>;
|
||||
}
|
||||
|
||||
export interface SubsyncEngineProvider {
|
||||
engine: SubsyncEngine;
|
||||
execute: (
|
||||
context: SubsyncEngineExecutionContext,
|
||||
) => Promise<SubsyncCommandResult>;
|
||||
}
|
||||
|
||||
type SubsyncEngineProviderFactory = () => SubsyncEngineProvider;
|
||||
|
||||
const subsyncEngineProviderFactories = new Map<SubsyncEngine, SubsyncEngineProviderFactory>();
|
||||
|
||||
export function registerSubsyncEngineProvider(
|
||||
engine: SubsyncEngine,
|
||||
factory: SubsyncEngineProviderFactory,
|
||||
): void {
|
||||
if (subsyncEngineProviderFactories.has(engine)) {
|
||||
return;
|
||||
}
|
||||
subsyncEngineProviderFactories.set(engine, factory);
|
||||
}
|
||||
|
||||
export function createSubsyncEngineProvider(
|
||||
engine: SubsyncEngine,
|
||||
): SubsyncEngineProvider | null {
|
||||
const factory = subsyncEngineProviderFactories.get(engine);
|
||||
if (!factory) return null;
|
||||
return factory();
|
||||
}
|
||||
|
||||
function registerDefaultSubsyncEngineProviders(): void {
|
||||
registerSubsyncEngineProvider("alass", () => ({
|
||||
engine: "alass",
|
||||
execute: async (context: SubsyncEngineExecutionContext) => {
|
||||
const alassPath = context.resolveExecutablePath(
|
||||
context.resolvedPaths.alassPath,
|
||||
"alass",
|
||||
);
|
||||
return context.runCommand(alassPath, [
|
||||
context.referenceFilePath,
|
||||
context.inputSubtitlePath,
|
||||
context.outputPath,
|
||||
]);
|
||||
},
|
||||
}));
|
||||
|
||||
registerSubsyncEngineProvider("ffsubsync", () => ({
|
||||
engine: "ffsubsync",
|
||||
execute: async (context: SubsyncEngineExecutionContext) => {
|
||||
const ffsubsyncPath = context.resolveExecutablePath(
|
||||
context.resolvedPaths.ffsubsyncPath,
|
||||
"ffsubsync",
|
||||
);
|
||||
const args = [
|
||||
context.videoPath,
|
||||
"-i",
|
||||
context.inputSubtitlePath,
|
||||
"-o",
|
||||
context.outputPath,
|
||||
];
|
||||
if (context.audioStreamIndex !== null) {
|
||||
args.push("--reference-stream", `0:${context.audioStreamIndex}`);
|
||||
}
|
||||
return context.runCommand(ffsubsyncPath, args);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
registerDefaultSubsyncEngineProviders();
|
||||
46
src/subtitle/pipeline.ts
Normal file
46
src/subtitle/pipeline.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { TokenMergerProvider } from "../token-mergers";
|
||||
import { TokenizerProvider } from "../tokenizers";
|
||||
import { SubtitleData } from "../types";
|
||||
import {
|
||||
normalizeDisplayText,
|
||||
normalizeTokenizerInput,
|
||||
} from "./stages/normalize";
|
||||
import { tokenizeStage } from "./stages/tokenize";
|
||||
import { mergeStage } from "./stages/merge";
|
||||
|
||||
export interface SubtitlePipelineDeps {
|
||||
getTokenizer: () => TokenizerProvider | null;
|
||||
getTokenMerger: () => TokenMergerProvider | null;
|
||||
}
|
||||
|
||||
export class SubtitlePipeline {
|
||||
private readonly deps: SubtitlePipelineDeps;
|
||||
|
||||
constructor(deps: SubtitlePipelineDeps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
async process(text: string): Promise<SubtitleData> {
|
||||
if (!text) {
|
||||
return { text, tokens: null };
|
||||
}
|
||||
|
||||
const displayText = normalizeDisplayText(text);
|
||||
if (!displayText) {
|
||||
return { text, tokens: null };
|
||||
}
|
||||
|
||||
const tokenizeText = normalizeTokenizerInput(displayText);
|
||||
|
||||
try {
|
||||
const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText);
|
||||
const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens);
|
||||
if (!mergedTokens || mergedTokens.length === 0) {
|
||||
return { text: displayText, tokens: null };
|
||||
}
|
||||
return { text: displayText, tokens: mergedTokens };
|
||||
} catch {
|
||||
return { text: displayText, tokens: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/subtitle/stages/merge.ts
Normal file
12
src/subtitle/stages/merge.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TokenMergerProvider } from "../../token-mergers";
|
||||
import { MergedToken, Token } from "../../types";
|
||||
|
||||
export function mergeStage(
|
||||
mergerProvider: TokenMergerProvider | null,
|
||||
tokens: Token[] | null,
|
||||
): MergedToken[] | null {
|
||||
if (!mergerProvider || !tokens || tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return mergerProvider.merge(tokens);
|
||||
}
|
||||
14
src/subtitle/stages/normalize.ts
Normal file
14
src/subtitle/stages/normalize.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function normalizeDisplayText(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\\N/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeTokenizerInput(displayText: string): string {
|
||||
return displayText
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
12
src/subtitle/stages/tokenize.ts
Normal file
12
src/subtitle/stages/tokenize.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TokenizerProvider } from "../../tokenizers";
|
||||
import { Token } from "../../types";
|
||||
|
||||
export async function tokenizeStage(
|
||||
tokenizerProvider: TokenizerProvider | null,
|
||||
input: string,
|
||||
): Promise<Token[] | null> {
|
||||
if (!tokenizerProvider || !input) {
|
||||
return null;
|
||||
}
|
||||
return tokenizerProvider.tokenize(input);
|
||||
}
|
||||
42
src/token-mergers/index.ts
Normal file
42
src/token-mergers/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { mergeTokens as defaultMergeTokens } from "../token-merger";
|
||||
import { MergedToken, Token } from "../types";
|
||||
|
||||
export interface TokenMergerProvider {
|
||||
id: string;
|
||||
merge: (tokens: Token[]) => MergedToken[];
|
||||
}
|
||||
|
||||
type TokenMergerProviderFactory = () => TokenMergerProvider;
|
||||
|
||||
const tokenMergerProviderFactories = new Map<string, TokenMergerProviderFactory>();
|
||||
|
||||
export function registerTokenMergerProvider(
|
||||
id: string,
|
||||
factory: TokenMergerProviderFactory,
|
||||
): void {
|
||||
if (tokenMergerProviderFactories.has(id)) {
|
||||
return;
|
||||
}
|
||||
tokenMergerProviderFactories.set(id, factory);
|
||||
}
|
||||
|
||||
export function getRegisteredTokenMergerProviderIds(): string[] {
|
||||
return Array.from(tokenMergerProviderFactories.keys());
|
||||
}
|
||||
|
||||
export function createTokenMergerProvider(
|
||||
id = "default",
|
||||
): TokenMergerProvider | null {
|
||||
const factory = tokenMergerProviderFactories.get(id);
|
||||
if (!factory) return null;
|
||||
return factory();
|
||||
}
|
||||
|
||||
function registerDefaultTokenMergerProviders(): void {
|
||||
registerTokenMergerProvider("default", () => ({
|
||||
id: "default",
|
||||
merge: (tokens: Token[]) => defaultMergeTokens(tokens),
|
||||
}));
|
||||
}
|
||||
|
||||
registerDefaultTokenMergerProviders();
|
||||
53
src/tokenizers/index.ts
Normal file
53
src/tokenizers/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MecabTokenizer } from "../mecab-tokenizer";
|
||||
import { MecabStatus, Token } from "../types";
|
||||
|
||||
export interface TokenizerProvider {
|
||||
id: string;
|
||||
checkAvailability: () => Promise<boolean>;
|
||||
tokenize: (text: string) => Promise<Token[] | null>;
|
||||
getStatus: () => MecabStatus;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
type TokenizerProviderFactory = () => TokenizerProvider;
|
||||
|
||||
const tokenizerProviderFactories = new Map<string, TokenizerProviderFactory>();
|
||||
|
||||
export function registerTokenizerProvider(
|
||||
id: string,
|
||||
factory: TokenizerProviderFactory,
|
||||
): void {
|
||||
if (tokenizerProviderFactories.has(id)) {
|
||||
return;
|
||||
}
|
||||
tokenizerProviderFactories.set(id, factory);
|
||||
}
|
||||
|
||||
export function getRegisteredTokenizerProviderIds(): string[] {
|
||||
return Array.from(tokenizerProviderFactories.keys());
|
||||
}
|
||||
|
||||
export function createTokenizerProvider(
|
||||
id = "mecab",
|
||||
): TokenizerProvider | null {
|
||||
const factory = tokenizerProviderFactories.get(id);
|
||||
if (!factory) {
|
||||
return null;
|
||||
}
|
||||
return factory();
|
||||
}
|
||||
|
||||
function registerDefaultTokenizerProviders(): void {
|
||||
registerTokenizerProvider("mecab", () => {
|
||||
const mecab = new MecabTokenizer();
|
||||
return {
|
||||
id: "mecab",
|
||||
checkAvailability: () => mecab.checkAvailability(),
|
||||
tokenize: (text: string) => mecab.tokenize(text),
|
||||
getStatus: () => mecab.getStatus(),
|
||||
setEnabled: (enabled: boolean) => mecab.setEnabled(enabled),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
registerDefaultTokenizerProviders();
|
||||
106
src/translators/index.ts
Normal file
106
src/translators/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface TranslationRequest {
|
||||
sentence: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
targetLanguage: string;
|
||||
systemPrompt: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface TranslationProvider {
|
||||
id: string;
|
||||
translate: (request: TranslationRequest) => Promise<string | null>;
|
||||
}
|
||||
|
||||
type TranslationProviderFactory = () => TranslationProvider;
|
||||
|
||||
const translationProviderFactories = new Map<string, TranslationProviderFactory>();
|
||||
|
||||
export function registerTranslationProvider(
|
||||
id: string,
|
||||
factory: TranslationProviderFactory,
|
||||
): void {
|
||||
if (translationProviderFactories.has(id)) {
|
||||
return;
|
||||
}
|
||||
translationProviderFactories.set(id, factory);
|
||||
}
|
||||
|
||||
export function createTranslationProvider(
|
||||
id = "openai-compatible",
|
||||
): TranslationProvider | null {
|
||||
const factory = translationProviderFactories.get(id);
|
||||
if (!factory) return null;
|
||||
return factory();
|
||||
}
|
||||
|
||||
function extractAiText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"type" in item &&
|
||||
(item as { type?: unknown }).type === "text" &&
|
||||
"text" in item &&
|
||||
typeof (item as { text?: unknown }).text === "string"
|
||||
) {
|
||||
parts.push((item as { text: string }).text);
|
||||
}
|
||||
}
|
||||
return parts.join("").trim();
|
||||
}
|
||||
|
||||
function normalizeOpenAiBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
if (/\/v1$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/v1`;
|
||||
}
|
||||
|
||||
function registerDefaultTranslationProviders(): void {
|
||||
registerTranslationProvider("openai-compatible", () => ({
|
||||
id: "openai-compatible",
|
||||
translate: async (request: TranslationRequest): Promise<string | null> => {
|
||||
const response = await axios.post(
|
||||
`${normalizeOpenAiBaseUrl(request.baseUrl)}/chat/completions`,
|
||||
{
|
||||
model: request.model,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `Translate this text to ${request.targetLanguage}:\n\n${request.sentence}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${request.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: request.timeoutMs ?? 15000,
|
||||
},
|
||||
);
|
||||
|
||||
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
|
||||
| { message?: { content?: unknown } }
|
||||
| undefined;
|
||||
const translated = extractAiText(content?.message?.content);
|
||||
return translated || null;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
registerDefaultTranslationProviders();
|
||||
Reference in New Issue
Block a user