mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 17:16:20 -07:00
feat(subtitles): add local Japanese subtitle generation (#240)
This commit is contained in:
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
||||
export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'subtitle-generation',
|
||||
'jimaku',
|
||||
'tsukihime',
|
||||
'youtube-track-picker',
|
||||
@@ -50,6 +51,14 @@ export const IPC_CHANNELS = {
|
||||
dispatchSessionAction: 'session-action:dispatch',
|
||||
},
|
||||
request: {
|
||||
requestSubtitleGenerationOpen: 'subtitle-generation:open',
|
||||
getSubtitleGenerationStatus: 'subtitle-generation:status',
|
||||
startSubtitleGeneration: 'subtitle-generation:start',
|
||||
selectSubtitleGenerationModel: 'subtitle-generation:select-model',
|
||||
downloadSubtitleGenerationModel: 'subtitle-generation:download',
|
||||
downloadSubtitleGenerationVadModel: 'subtitle-generation:download-vad',
|
||||
setSubtitleGenerationVadEnabled: 'subtitle-generation:set-vad-enabled',
|
||||
cancelSubtitleGeneration: 'subtitle-generation:cancel',
|
||||
getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
|
||||
getCurrentSubtitle: 'get-current-subtitle',
|
||||
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
|
||||
@@ -133,6 +142,8 @@ export const IPC_CHANNELS = {
|
||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||
},
|
||||
event: {
|
||||
subtitleGenerationOpen: 'subtitle-generation:opened',
|
||||
subtitleGenerationProgress: 'subtitle-generation:progress',
|
||||
subtitleSet: 'subtitle:set',
|
||||
overlayPointerRecoveryRequest: 'overlay:pointer-recovery-request',
|
||||
subtitleVisibility: 'mpv:subVisibility',
|
||||
|
||||
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleGeneration',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
'replayCurrentSubtitle',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
SubtitleGenerationTools,
|
||||
} from './subtitle-generation';
|
||||
|
||||
export type SubtitleGenerationResult =
|
||||
| { ok: true; message: string; outputPath?: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export interface SubtitleGenerationStatus {
|
||||
model: SubtitleGenerationModelStatus;
|
||||
vad: { enabled: boolean; model: SubtitleGenerationModelStatus };
|
||||
tools: SubtitleGenerationTools;
|
||||
managedModel: SubtitleGenerationConfig['managedModel'];
|
||||
externalModelPath: string | null;
|
||||
mediaPath: string | null;
|
||||
running: boolean;
|
||||
progress: SubtitleGenerationProgress | null;
|
||||
lastResult: SubtitleGenerationResult | null;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Official multilingual models from whisper.cpp/models/download-ggml-model.sh.
|
||||
// Byte sizes and SHA256: https://huggingface.co/api/models/ggerganov/whisper.cpp/tree/main.
|
||||
const MODEL_CATALOG = {
|
||||
tiny: {
|
||||
id: 'tiny',
|
||||
size: 77691713,
|
||||
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21',
|
||||
description: 'Fastest and lightest; more transcription errors.',
|
||||
},
|
||||
'tiny-q5_1': {
|
||||
id: 'tiny-q5_1',
|
||||
size: 32152673,
|
||||
sha256: '818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7',
|
||||
description:
|
||||
'tiny with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'tiny-q8_0': {
|
||||
id: 'tiny-q8_0',
|
||||
size: 43537433,
|
||||
sha256: 'c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca',
|
||||
description:
|
||||
'tiny with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
base: {
|
||||
id: 'base',
|
||||
size: 147951465,
|
||||
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe',
|
||||
description: 'Fast and lightweight; less accurate than small.',
|
||||
},
|
||||
'base-q5_1': {
|
||||
id: 'base-q5_1',
|
||||
size: 59707625,
|
||||
sha256: '422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898',
|
||||
description:
|
||||
'base with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'base-q8_0': {
|
||||
id: 'base-q8_0',
|
||||
size: 81768585,
|
||||
sha256: 'c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9',
|
||||
description:
|
||||
'base with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
small: {
|
||||
id: 'small',
|
||||
size: 487601967,
|
||||
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b',
|
||||
description: 'Recommended starting point for accuracy and processing time.',
|
||||
},
|
||||
'small-q5_1': {
|
||||
id: 'small-q5_1',
|
||||
size: 190085487,
|
||||
sha256: 'ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb',
|
||||
description:
|
||||
'small with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'small-q8_0': {
|
||||
id: 'small-q8_0',
|
||||
size: 264464607,
|
||||
sha256: '49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f',
|
||||
description:
|
||||
'small with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
medium: {
|
||||
id: 'medium',
|
||||
size: 1533763059,
|
||||
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208',
|
||||
description: 'Prioritizes accuracy over small; takes longer to process.',
|
||||
},
|
||||
'medium-q5_0': {
|
||||
id: 'medium-q5_0',
|
||||
size: 539212467,
|
||||
sha256: '19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f',
|
||||
description:
|
||||
'medium with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'medium-q8_0': {
|
||||
id: 'medium-q8_0',
|
||||
size: 823369779,
|
||||
sha256: '42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502',
|
||||
description:
|
||||
'medium with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v1': {
|
||||
id: 'large-v1',
|
||||
size: 3094623691,
|
||||
sha256: '7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d',
|
||||
description: 'Older large model; high memory use and longer processing.',
|
||||
},
|
||||
'large-v2': {
|
||||
id: 'large-v2',
|
||||
size: 3094623691,
|
||||
sha256: '9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487',
|
||||
description: 'Older large model; high memory use and longer processing.',
|
||||
},
|
||||
'large-v2-q5_0': {
|
||||
id: 'large-v2-q5_0',
|
||||
size: 1080732091,
|
||||
sha256: '3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2',
|
||||
description:
|
||||
'large-v2 with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v2-q8_0': {
|
||||
id: 'large-v2-q8_0',
|
||||
size: 1656129691,
|
||||
sha256: 'fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c',
|
||||
description:
|
||||
'large-v2 with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v3': {
|
||||
id: 'large-v3',
|
||||
size: 3095033483,
|
||||
sha256: '64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2',
|
||||
description: 'Prioritizes accuracy; high memory use and longer processing.',
|
||||
},
|
||||
'large-v3-q5_0': {
|
||||
id: 'large-v3-q5_0',
|
||||
size: 1081140203,
|
||||
sha256: 'd75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1',
|
||||
description:
|
||||
'large-v3 with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v3-turbo': {
|
||||
id: 'large-v3-turbo',
|
||||
size: 1624555275,
|
||||
sha256: '1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69',
|
||||
description: 'Faster large-v3 variant with an accuracy tradeoff; uses more memory than small.',
|
||||
},
|
||||
'large-v3-turbo-q5_0': {
|
||||
id: 'large-v3-turbo-q5_0',
|
||||
size: 574041195,
|
||||
sha256: '394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2',
|
||||
description:
|
||||
'large-v3-turbo with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v3-turbo-q8_0': {
|
||||
id: 'large-v3-turbo-q8_0',
|
||||
size: 874188075,
|
||||
sha256: '317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1',
|
||||
description:
|
||||
'large-v3-turbo with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type SubtitleGenerationModelId = keyof typeof MODEL_CATALOG;
|
||||
|
||||
export const SUBTITLE_GENERATION_MODELS = Object.values(MODEL_CATALOG);
|
||||
|
||||
export const RECOMMENDED_SUBTITLE_GENERATION_MODEL = 'small' satisfies SubtitleGenerationModelId;
|
||||
|
||||
export function isSubtitleGenerationModelId(value: unknown): value is SubtitleGenerationModelId {
|
||||
return typeof value === 'string' && Object.hasOwn(MODEL_CATALOG, value);
|
||||
}
|
||||
|
||||
export function getSubtitleGenerationModel(id: SubtitleGenerationModelId) {
|
||||
return MODEL_CATALOG[id];
|
||||
}
|
||||
|
||||
export function formatSubtitleGenerationModelSize(size: number): string {
|
||||
if (size < 1024 ** 2) return `${Math.ceil(size / 1024)} KiB`;
|
||||
return size >= 1024 ** 3
|
||||
? `${(size / 1024 ** 3).toFixed(1)} GiB`
|
||||
: `${Math.round(size / 1024 ** 2)} MiB`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SUBTITLE_GENERATION_VAD_MODEL = {
|
||||
filename: 'ggml-silero-v6.2.0.bin',
|
||||
size: 885098,
|
||||
sha256: '2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987',
|
||||
url: 'https://huggingface.co/ggml-org/whisper-vad/resolve/9ffd54a1e1ee413ddf265af9913beaf518d1639b/ggml-silero-v6.2.0.bin',
|
||||
} as const;
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
isSubtitleGenerationModelId,
|
||||
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
type SubtitleGenerationModelId,
|
||||
} from './subtitle-generation-model-catalog';
|
||||
|
||||
export interface SubtitleGenerationConfig {
|
||||
whisperPath: string;
|
||||
modelPath: string;
|
||||
managedModel: SubtitleGenerationModelId;
|
||||
threads: number;
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
vadModelPath: string;
|
||||
vadPath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_GENERATION_CONFIG: SubtitleGenerationConfig = {
|
||||
whisperPath: '',
|
||||
modelPath: '',
|
||||
managedModel: RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
threads: 4,
|
||||
ffmpegPath: '',
|
||||
ffprobePath: '',
|
||||
vadModelPath: '',
|
||||
vadPath: '',
|
||||
};
|
||||
|
||||
export interface SubtitleGenerationProgress {
|
||||
stage: 'download' | 'extract' | 'transcribe' | 'write';
|
||||
percent?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type SubtitleGenerationModelStatus =
|
||||
| { kind: 'external' | 'managed'; path: string }
|
||||
| { kind: 'missing'; path: string }
|
||||
| { kind: 'invalid'; path: string; message: string };
|
||||
|
||||
export type SubtitleGenerationToolStatus =
|
||||
| { kind: 'found'; path: string }
|
||||
| { kind: 'missing'; message: string };
|
||||
|
||||
/** Executables generation depends on. `vad` is null unless dialogue mode is on. */
|
||||
export interface SubtitleGenerationTools {
|
||||
ffmpeg: SubtitleGenerationToolStatus;
|
||||
ffprobe: SubtitleGenerationToolStatus;
|
||||
whisper: SubtitleGenerationToolStatus;
|
||||
vad: SubtitleGenerationToolStatus | null;
|
||||
}
|
||||
|
||||
export function missingSubtitleGenerationTools(tools: SubtitleGenerationTools): string[] {
|
||||
return [tools.ffmpeg, tools.ffprobe, tools.whisper, tools.vad].flatMap((tool) =>
|
||||
tool?.kind === 'missing' ? [tool.message] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSubtitleGenerationConfig(
|
||||
value: unknown,
|
||||
onWarning?: (key: string, value: unknown, message: string) => void,
|
||||
): SubtitleGenerationConfig {
|
||||
const result = { ...DEFAULT_SUBTITLE_GENERATION_CONFIG };
|
||||
if (value === undefined) return result;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
onWarning?.('subtitleGeneration', value, 'Expected an object.');
|
||||
return result;
|
||||
}
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
if (
|
||||
key !== 'whisperPath' &&
|
||||
key !== 'modelPath' &&
|
||||
key !== 'ffmpegPath' &&
|
||||
key !== 'ffprobePath' &&
|
||||
key !== 'vadModelPath' &&
|
||||
key !== 'vadPath'
|
||||
)
|
||||
continue;
|
||||
const candidate: unknown = rawValue;
|
||||
if (typeof candidate === 'string') {
|
||||
result[key] = candidate.trim();
|
||||
} else onWarning?.(key, candidate, 'Expected a string.');
|
||||
}
|
||||
if ('managedModel' in value) {
|
||||
if (isSubtitleGenerationModelId(value.managedModel)) {
|
||||
result.managedModel = value.managedModel;
|
||||
} else
|
||||
onWarning?.(
|
||||
'managedModel',
|
||||
value.managedModel,
|
||||
'Expected a supported multilingual Whisper model.',
|
||||
);
|
||||
}
|
||||
if ('threads' in value) {
|
||||
if (
|
||||
typeof value.threads === 'number' &&
|
||||
Number.isInteger(value.threads) &&
|
||||
value.threads >= 1 &&
|
||||
value.threads <= 256
|
||||
) {
|
||||
result.threads = value.threads;
|
||||
} else onWarning?.('threads', value.threads, 'Expected an integer between 1 and 256.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user