mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
feat(subtitles): add local Japanese subtitle generation
- Generate Japanese SRTs through the overlay modal or launcher - Support managed whisper.cpp models and optional dialogue detection
This commit is contained in:
@@ -5,6 +5,7 @@ type ControllerInteractionModalState = {
|
||||
kikuModalOpen: boolean;
|
||||
runtimeOptionsModalOpen: boolean;
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleGenerationModalOpen?: boolean;
|
||||
youtubePickerModalOpen: boolean;
|
||||
sessionHelpModalOpen: boolean;
|
||||
subtitleSidebarModalOpen: boolean;
|
||||
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
Boolean(state.subtitleGenerationModalOpen) ||
|
||||
state.youtubePickerModalOpen ||
|
||||
state.sessionHelpModalOpen
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -1580,6 +1581,28 @@ test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: Ctrl+Shift+G dispatches subtitle generation with the sidebar closed', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openSubtitleGeneration',
|
||||
originalKey: 'Ctrl+Shift+G',
|
||||
key: { code: 'KeyG', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleGeneration',
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true });
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'openSubtitleGeneration', payload: undefined },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export function createKeyboardHandlers(
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1079,6 +1080,11 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (ctx.state.subtitleGenerationModalOpen) {
|
||||
options.handleSubtitleGenerationKeydown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||
options.handleMediaTimingReviewKeydown(e);
|
||||
return;
|
||||
|
||||
@@ -634,6 +634,126 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="subtitleGenerationModal"
|
||||
class="modal hidden"
|
||||
aria-hidden="true"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subtitleGenerationTitle"
|
||||
>
|
||||
<div class="modal-content subtitle-generation-content">
|
||||
<div class="modal-header">
|
||||
<div id="subtitleGenerationTitle" class="modal-title">Generate Japanese subtitles</div>
|
||||
<button id="subtitleGenerationClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-generation-body">
|
||||
<p class="subtitle-generation-intro">
|
||||
Turn the current audio track into timed Japanese subtitles. Audio stays on this
|
||||
device.
|
||||
</p>
|
||||
<div class="subtitle-generation-detail">
|
||||
<span class="subtitle-generation-label">Current media</span>
|
||||
<div id="subtitleGenerationMedia">Checking current media...</div>
|
||||
</div>
|
||||
<div class="subtitle-generation-detail">
|
||||
<span class="subtitle-generation-label">Speech model</span>
|
||||
<div id="subtitleGenerationModel">Checking local models...</div>
|
||||
<div
|
||||
id="subtitleGenerationModelPicker"
|
||||
class="subtitle-generation-model-picker hidden"
|
||||
>
|
||||
<label for="subtitleGenerationModelSelect">Model</label>
|
||||
<select
|
||||
id="subtitleGenerationModelSelect"
|
||||
aria-describedby="subtitleGenerationModelDescription subtitleGenerationModelRecommendation"
|
||||
></select>
|
||||
<p id="subtitleGenerationModelDescription" class="subtitle-generation-hint"></p>
|
||||
<p id="subtitleGenerationModelRecommendation" class="subtitle-generation-hint">
|
||||
Start with small for a balance of accuracy and CPU time. Larger models need more
|
||||
memory; speed depends on your hardware. Sizes shown are downloads.
|
||||
</p>
|
||||
<p class="subtitle-generation-hint">
|
||||
This choice lasts for this SubMiner session. Set the default model in Settings.
|
||||
</p>
|
||||
</div>
|
||||
<p class="subtitle-generation-hint">
|
||||
Use your own model in Settings → Integrations → Japanese Subtitle Generation → Model
|
||||
Path.
|
||||
</p>
|
||||
<button
|
||||
id="subtitleGenerationDownload"
|
||||
class="kiku-cancel-button hidden"
|
||||
type="button"
|
||||
>
|
||||
Download model
|
||||
</button>
|
||||
</div>
|
||||
<div class="subtitle-generation-detail">
|
||||
<label class="subtitle-generation-vad-toggle" for="subtitleGenerationVadEnabled">
|
||||
<input
|
||||
id="subtitleGenerationVadEnabled"
|
||||
type="checkbox"
|
||||
aria-describedby="subtitleGenerationVadHint"
|
||||
/>
|
||||
Prioritize dialogue <span class="subtitle-generation-hint">Optional</span>
|
||||
</label>
|
||||
<div id="subtitleGenerationVadModel" class="subtitle-generation-hint"></div>
|
||||
<p id="subtitleGenerationVadHint" class="subtitle-generation-hint">
|
||||
Uses a small Silero model to transcribe separate speech passages. May skip songs,
|
||||
quiet speech, and dialogue under music. Requires whisper.cpp's speech detector.
|
||||
</p>
|
||||
<p class="subtitle-generation-hint">
|
||||
Applies for this session. Set VAD Model Path in Settings to enable it by default.
|
||||
</p>
|
||||
<button
|
||||
id="subtitleGenerationVadDownload"
|
||||
class="kiku-cancel-button hidden"
|
||||
type="button"
|
||||
>
|
||||
Download Silero
|
||||
</button>
|
||||
</div>
|
||||
<div id="subtitleGenerationActivity" class="subtitle-generation-activity hidden">
|
||||
<div class="subtitle-generation-progress-heading">
|
||||
<span id="subtitleGenerationStage">Preparing</span>
|
||||
<span id="subtitleGenerationPercent"></span>
|
||||
</div>
|
||||
<progress
|
||||
id="subtitleGenerationProgress"
|
||||
max="100"
|
||||
aria-label="Subtitle generation progress"
|
||||
></progress>
|
||||
</div>
|
||||
<div
|
||||
id="subtitleGenerationStatus"
|
||||
class="runtime-options-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="subtitle-generation-actions">
|
||||
<button id="subtitleGenerationRefresh" class="kiku-cancel-button" type="button">
|
||||
Check again
|
||||
</button>
|
||||
<button id="subtitleGenerationCancel" class="kiku-cancel-button hidden" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
id="subtitleGenerationStart"
|
||||
class="kiku-confirm-button"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
Generate subtitles
|
||||
</button>
|
||||
</div>
|
||||
<p class="subtitle-generation-hint">
|
||||
The generated SRT will be saved locally and loaded into the player. You can close this
|
||||
window while it runs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -734,6 +854,13 @@
|
||||
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-sidebar-body">
|
||||
<button
|
||||
id="subtitleGenerationOpen"
|
||||
class="kiku-cancel-button subtitle-generation-open"
|
||||
type="button"
|
||||
>
|
||||
Generate Japanese subtitles
|
||||
</button>
|
||||
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
|
||||
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
|
||||
</div>
|
||||
|
||||
@@ -225,6 +225,8 @@ function describeSessionAction(
|
||||
return 'Open jimaku';
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openSubtitleGeneration':
|
||||
return 'Generate Japanese subtitles';
|
||||
case 'openYoutubePicker':
|
||||
return 'Open YouTube subtitle picker';
|
||||
case 'openPlaylistBrowser':
|
||||
@@ -266,6 +268,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
case 'openJimaku':
|
||||
case 'openTsukihime':
|
||||
case 'openCharacterDictionaryManager':
|
||||
case 'openSubtitleGeneration':
|
||||
case 'openControllerSelect':
|
||||
case 'openControllerDebug':
|
||||
case 'openYoutubePicker':
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
describeGenerationModel,
|
||||
describeGenerationProgress,
|
||||
describeGenerationVad,
|
||||
} from './subtitle-generation-view';
|
||||
|
||||
test('only a missing managed model offers a download', () => {
|
||||
assert.deepEqual(describeGenerationModel({ kind: 'missing', path: '/models/small.bin' }), {
|
||||
ready: false,
|
||||
download: true,
|
||||
text: 'Download a speech model to get started.',
|
||||
});
|
||||
for (const kind of ['managed', 'external'] as const) {
|
||||
const model = describeGenerationModel({ kind, path: '/models/ggml-small.bin' });
|
||||
assert.equal(model.ready, true);
|
||||
assert.equal(model.download, false);
|
||||
}
|
||||
assert.deepEqual(
|
||||
describeGenerationModel({
|
||||
kind: 'invalid',
|
||||
path: '/missing/model.bin',
|
||||
message: 'Configured model does not exist.',
|
||||
}),
|
||||
{
|
||||
ready: false,
|
||||
download: false,
|
||||
text: 'Configured model does not exist.',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generation progress distinguishes measured work from indeterminate stages', () => {
|
||||
assert.deepEqual(
|
||||
describeGenerationProgress({ stage: 'transcribe', percent: 42.8, message: 'Transcribing' }),
|
||||
{
|
||||
stage: 'Recognizing Japanese speech',
|
||||
percent: 42.8,
|
||||
label: '42%',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(describeGenerationProgress({ stage: 'extract', message: 'Extracting audio' }), {
|
||||
stage: 'Preparing audio',
|
||||
percent: null,
|
||||
label: 'Working...',
|
||||
});
|
||||
assert.equal(
|
||||
describeGenerationProgress({ stage: 'download', percent: 0, message: '' }).label,
|
||||
'0%',
|
||||
);
|
||||
assert.equal(
|
||||
describeGenerationProgress({ stage: 'download', percent: Number.NaN, message: '' }).percent,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
describeGenerationProgress({ stage: 'write', percent: 120, message: '' }).percent,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
test('optional speech detection only gates generation when selected', () => {
|
||||
const missing = { kind: 'missing', path: '/vad.bin' } as const;
|
||||
assert.equal(describeGenerationVad({ enabled: false, model: missing }).ready, true);
|
||||
assert.equal(describeGenerationVad({ enabled: false, model: missing }).download, false);
|
||||
assert.equal(describeGenerationVad({ enabled: true, model: missing }).ready, false);
|
||||
assert.equal(describeGenerationVad({ enabled: true, model: missing }).download, true);
|
||||
assert.equal(
|
||||
describeGenerationVad({ enabled: true, model: { kind: 'managed', path: '/vad.bin' } }).ready,
|
||||
true,
|
||||
);
|
||||
const invalid = { kind: 'invalid', path: '/vad.bin', message: 'Cannot read model' } as const;
|
||||
assert.equal(describeGenerationVad({ enabled: true, model: invalid }).download, false);
|
||||
assert.equal(describeGenerationVad({ enabled: false, model: invalid }).ready, true);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
} from '../../shared/subtitle-generation';
|
||||
import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc';
|
||||
|
||||
export function describeGenerationVad(vad: SubtitleGenerationStatus['vad']) {
|
||||
const model = describeGenerationModel(vad.model);
|
||||
return {
|
||||
ready: !vad.enabled || model.ready,
|
||||
download: vad.enabled && model.download,
|
||||
text: !vad.enabled
|
||||
? 'Optional. Generate from the full audio when unchecked.'
|
||||
: vad.model.kind === 'missing'
|
||||
? 'Download Silero to prioritize spoken dialogue.'
|
||||
: vad.model.kind === 'invalid'
|
||||
? vad.model.message
|
||||
: vad.model.kind === 'external'
|
||||
? `Your speech detection model: ${vad.model.path}`
|
||||
: 'Silero speech detection model installed.',
|
||||
};
|
||||
}
|
||||
|
||||
export function describeGenerationModel(model: SubtitleGenerationModelStatus) {
|
||||
switch (model.kind) {
|
||||
case 'external':
|
||||
return { ready: true, download: false, text: `Your model: ${model.path}` };
|
||||
case 'managed':
|
||||
return { ready: true, download: false, text: `SubMiner model: ${model.path}` };
|
||||
case 'missing':
|
||||
return { ready: false, download: true, text: 'Download a speech model to get started.' };
|
||||
case 'invalid':
|
||||
return { ready: false, download: false, text: model.message };
|
||||
default: {
|
||||
const exhaustive: never = model;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STAGE_LABELS = {
|
||||
download: 'Downloading speech model',
|
||||
extract: 'Preparing audio',
|
||||
transcribe: 'Recognizing Japanese speech',
|
||||
write: 'Saving subtitles',
|
||||
} satisfies Record<SubtitleGenerationProgress['stage'], string>;
|
||||
|
||||
export function describeGenerationProgress(progress: SubtitleGenerationProgress | null) {
|
||||
const raw = progress?.percent;
|
||||
const percent =
|
||||
raw !== undefined && Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : null;
|
||||
return {
|
||||
stage: progress ? STAGE_LABELS[progress.stage] : 'Preparing',
|
||||
percent,
|
||||
label: percent === null ? 'Working...' : `${Math.floor(percent)}%`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||
import {
|
||||
SUBTITLE_GENERATION_MODELS,
|
||||
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
formatSubtitleGenerationModelSize,
|
||||
getSubtitleGenerationModel,
|
||||
isSubtitleGenerationModelId,
|
||||
} from '../../shared/subtitle-generation-model-catalog';
|
||||
import type {
|
||||
SubtitleGenerationResult,
|
||||
SubtitleGenerationStatus,
|
||||
} from '../../shared/subtitle-generation-ipc';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
import {
|
||||
describeGenerationModel,
|
||||
describeGenerationProgress,
|
||||
describeGenerationVad,
|
||||
} from './subtitle-generation-view';
|
||||
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
|
||||
|
||||
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!(node instanceof constructor)) throw new Error(`Missing subtitle generation element: ${id}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createSubtitleGenerationModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
const dom = {
|
||||
modal: element('subtitleGenerationModal', HTMLDivElement),
|
||||
close: element('subtitleGenerationClose', HTMLButtonElement),
|
||||
open: element('subtitleGenerationOpen', HTMLButtonElement),
|
||||
media: element('subtitleGenerationMedia', HTMLDivElement),
|
||||
model: element('subtitleGenerationModel', HTMLDivElement),
|
||||
modelPicker: element('subtitleGenerationModelPicker', HTMLDivElement),
|
||||
modelSelect: element('subtitleGenerationModelSelect', HTMLSelectElement),
|
||||
modelDescription: element('subtitleGenerationModelDescription', HTMLParagraphElement),
|
||||
download: element('subtitleGenerationDownload', HTMLButtonElement),
|
||||
vadEnabled: element('subtitleGenerationVadEnabled', HTMLInputElement),
|
||||
vadModel: element('subtitleGenerationVadModel', HTMLDivElement),
|
||||
vadDownload: element('subtitleGenerationVadDownload', HTMLButtonElement),
|
||||
activity: element('subtitleGenerationActivity', HTMLDivElement),
|
||||
stage: element('subtitleGenerationStage', HTMLSpanElement),
|
||||
percent: element('subtitleGenerationPercent', HTMLSpanElement),
|
||||
progress: element('subtitleGenerationProgress', HTMLProgressElement),
|
||||
status: element('subtitleGenerationStatus', HTMLDivElement),
|
||||
refresh: element('subtitleGenerationRefresh', HTMLButtonElement),
|
||||
cancel: element('subtitleGenerationCancel', HTMLButtonElement),
|
||||
start: element('subtitleGenerationStart', HTMLButtonElement),
|
||||
};
|
||||
let snapshot: SubtitleGenerationStatus | null = null;
|
||||
let progress: SubtitleGenerationProgress | null = null;
|
||||
let result: SubtitleGenerationResult | null = null;
|
||||
let pending = false;
|
||||
let cancelling = false;
|
||||
let checking = false;
|
||||
let error: string | null = null;
|
||||
let priorFocus: Element | null = null;
|
||||
let poll: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
for (const model of SUBTITLE_GENERATION_MODELS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : '';
|
||||
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
|
||||
dom.modelSelect.append(option);
|
||||
}
|
||||
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.subtitleGenerationModalOpen,
|
||||
getModalRoot: () => dom.modal,
|
||||
getPreferredFocusTargets: () => [dom.modelSelect, dom.download, dom.start],
|
||||
getFallbackFocusTarget: () => dom.close,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
const busy = pending || Boolean(snapshot?.running);
|
||||
const model = snapshot ? describeGenerationModel(snapshot.model) : null;
|
||||
const vad = snapshot ? describeGenerationVad(snapshot.vad) : null;
|
||||
const readyMessage = !snapshot?.mediaPath
|
||||
? 'Open local media to generate subtitles.'
|
||||
: !model?.ready
|
||||
? 'Set up a speech model to continue.'
|
||||
: !vad?.ready
|
||||
? 'Download the speech detection model or uncheck Prioritize dialogue.'
|
||||
: 'Ready when you are.';
|
||||
dom.media.textContent = snapshot?.mediaPath ?? 'Open a local media file in the player first.';
|
||||
dom.model.textContent = model?.text ?? 'Checking local models...';
|
||||
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
|
||||
dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath);
|
||||
if (snapshot) {
|
||||
dom.modelSelect.value = snapshot.managedModel;
|
||||
dom.modelDescription.textContent = getSubtitleGenerationModel(
|
||||
snapshot.managedModel,
|
||||
).description;
|
||||
}
|
||||
dom.download.classList.toggle('hidden', !model?.download);
|
||||
dom.download.textContent = snapshot
|
||||
? `Download ${snapshot.managedModel} model`
|
||||
: 'Download model';
|
||||
dom.download.disabled = busy || checking;
|
||||
if (!checking) dom.vadEnabled.checked = snapshot?.vad.enabled ?? false;
|
||||
dom.vadEnabled.disabled = busy || checking || !snapshot;
|
||||
dom.vadModel.textContent = vad?.text ?? 'Checking speech detection...';
|
||||
dom.vadDownload.classList.toggle('hidden', !vad?.download);
|
||||
dom.vadDownload.textContent = `Download Silero · ${formatSubtitleGenerationModelSize(SUBTITLE_GENERATION_VAD_MODEL.size)}`;
|
||||
dom.vadDownload.disabled = busy || checking;
|
||||
dom.start.disabled = busy || checking || !model?.ready || !vad?.ready || !snapshot?.mediaPath;
|
||||
dom.refresh.disabled = busy || checking;
|
||||
dom.cancel.classList.toggle('hidden', !busy);
|
||||
dom.cancel.disabled = cancelling;
|
||||
dom.cancel.textContent = cancelling ? 'Cancelling...' : 'Cancel';
|
||||
dom.activity.classList.toggle('hidden', !busy);
|
||||
const activity = describeGenerationProgress(progress);
|
||||
dom.stage.textContent = activity.stage;
|
||||
dom.percent.textContent = activity.label;
|
||||
if (activity.percent === null) dom.progress.removeAttribute('value');
|
||||
else dom.progress.value = activity.percent;
|
||||
dom.status.classList.toggle('error', Boolean(error || (!busy && result && !result.ok)));
|
||||
dom.status.textContent =
|
||||
error ??
|
||||
(busy
|
||||
? cancelling
|
||||
? 'Stopping the current operation...'
|
||||
: (progress?.message ?? 'Starting...')
|
||||
: (result?.message ?? (checking ? 'Checking local setup...' : readyMessage)));
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (poll !== null) clearTimeout(poll);
|
||||
poll = null;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
error = null;
|
||||
render();
|
||||
try {
|
||||
snapshot = await window.electronAPI.getSubtitleGenerationStatus();
|
||||
if (!pending) {
|
||||
progress = snapshot.progress;
|
||||
result = snapshot.lastResult ?? result;
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not check subtitle generation setup.';
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
stopPolling();
|
||||
if (ctx.state.subtitleGenerationModalOpen && snapshot?.running && !pending) {
|
||||
poll = setTimeout(() => void refresh(), 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action: 'download' | 'download-vad' | 'generate'): Promise<void> {
|
||||
if (pending || snapshot?.running || checking || !snapshot) return;
|
||||
const model = describeGenerationModel(snapshot.model);
|
||||
const vad = describeGenerationVad(snapshot.vad);
|
||||
if (
|
||||
action === 'download'
|
||||
? !model.download
|
||||
: action === 'download-vad'
|
||||
? !vad.download
|
||||
: !model.ready || !vad.ready || !snapshot.mediaPath
|
||||
)
|
||||
return;
|
||||
pending = true;
|
||||
result = null;
|
||||
error = null;
|
||||
progress = { stage: action === 'generate' ? 'extract' : 'download', message: 'Starting...' };
|
||||
render();
|
||||
try {
|
||||
result = await (action === 'download'
|
||||
? window.electronAPI.downloadSubtitleGenerationModel()
|
||||
: action === 'download-vad'
|
||||
? window.electronAPI.downloadSubtitleGenerationVadModel()
|
||||
: window.electronAPI.startSubtitleGeneration());
|
||||
} catch (cause) {
|
||||
result = {
|
||||
ok: false,
|
||||
message: cause instanceof Error ? cause.message : 'Subtitle generation failed.',
|
||||
};
|
||||
} finally {
|
||||
pending = false;
|
||||
cancelling = false;
|
||||
// Recheck model availability after downloads and recover controls after errors.
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(): Promise<void> {
|
||||
const model = dom.modelSelect.value;
|
||||
if (pending || snapshot?.running || checking || !isSubtitleGenerationModelId(model)) return;
|
||||
checking = true;
|
||||
error = null;
|
||||
render();
|
||||
try {
|
||||
snapshot = await window.electronAPI.selectSubtitleGenerationModel(model);
|
||||
result = snapshot.lastResult;
|
||||
progress = snapshot.progress;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not select the model.';
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVad(): Promise<void> {
|
||||
if (pending || snapshot?.running || checking || !snapshot) return;
|
||||
const enabled = dom.vadEnabled.checked;
|
||||
checking = true;
|
||||
error = null;
|
||||
render();
|
||||
try {
|
||||
snapshot = await window.electronAPI.setSubtitleGenerationVadEnabled(enabled);
|
||||
result = snapshot.lastResult;
|
||||
progress = snapshot.progress;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not change speech detection.';
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(): Promise<void> {
|
||||
if (cancelling) return;
|
||||
cancelling = true;
|
||||
render();
|
||||
try {
|
||||
await window.electronAPI.cancelSubtitleGeneration();
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not cancel the operation.';
|
||||
} finally {
|
||||
cancelling = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
if (ctx.state.subtitleGenerationModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
ctx.state.subtitleGenerationModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
dom.modal.classList.remove('hidden');
|
||||
dom.modal.setAttribute('aria-hidden', 'false');
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
focus.attach();
|
||||
focus.focusFallbackTarget();
|
||||
window.electronAPI.notifyOverlayModalOpened('subtitle-generation');
|
||||
void refresh();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (!ctx.state.subtitleGenerationModalOpen) return;
|
||||
ctx.state.subtitleGenerationModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
dom.modal.classList.add('hidden');
|
||||
dom.modal.setAttribute('aria-hidden', 'true');
|
||||
focus.detach();
|
||||
stopPolling();
|
||||
window.electronAPI.notifyOverlayModalClosed('subtitle-generation');
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected)
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
if (!options.modalStateReader.isAnyModalOpen()) syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!ctx.state.subtitleGenerationModalOpen) return false;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
dom.close.addEventListener('click', close);
|
||||
dom.open.addEventListener('click', () => {
|
||||
void window.electronAPI
|
||||
.requestSubtitleGenerationOpen()
|
||||
.then((opened) => {
|
||||
if (!opened)
|
||||
ctx.dom.subtitleSidebarStatus.textContent = 'Could not open subtitle generation.';
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
ctx.dom.subtitleSidebarStatus.textContent =
|
||||
cause instanceof Error ? cause.message : 'Could not open subtitle generation.';
|
||||
});
|
||||
});
|
||||
dom.download.addEventListener('click', () => void run('download'));
|
||||
dom.vadDownload.addEventListener('click', () => void run('download-vad'));
|
||||
dom.vadEnabled.addEventListener('change', () => void selectVad());
|
||||
dom.modelSelect.addEventListener('change', () => void selectModel());
|
||||
dom.start.addEventListener('click', () => void run('generate'));
|
||||
dom.cancel.addEventListener('click', () => void cancel());
|
||||
dom.refresh.addEventListener('click', () => void refresh());
|
||||
unsubscribe = window.electronAPI.onSubtitleGenerationProgress((update) => {
|
||||
progress = update;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
stopPolling();
|
||||
focus.detach();
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
return { open, close, handleKeydown, wireDomEvents, dispose };
|
||||
}
|
||||
@@ -113,6 +113,20 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
|
||||
});
|
||||
|
||||
test('findActiveSubtitleCueIndex follows playback through empty subtitle gaps', () => {
|
||||
const cues = [
|
||||
{ startTime: 0, endTime: 2, text: 'first' },
|
||||
{ startTime: 100, endTime: 102, text: 'later' },
|
||||
{ startTime: 105, endTime: 107, text: 'next' },
|
||||
];
|
||||
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'later', startTime: 100 }, 101, 1), 1);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: '', startTime: 0 }, 103, 1), 2);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'next', startTime: 105 }, 105, 2), 2);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: '', startTime: 0 }, 108, 2), -1);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'first', startTime: 0 }, 0, 2), 0);
|
||||
});
|
||||
|
||||
test('subtitle sidebar mining context resolves selected row cue timing', () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
Element?: unknown;
|
||||
|
||||
@@ -116,8 +116,12 @@ export function findActiveSubtitleCueIndex(
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The mpv client maps cleared sub-start to zero. Empty text has no active cue timing.
|
||||
const hasCurrentTiming =
|
||||
typeof current?.startTime === 'number' && Number.isFinite(current.startTime);
|
||||
current !== null &&
|
||||
normalizeCueText(current.text).length > 0 &&
|
||||
typeof current.startTime === 'number' &&
|
||||
Number.isFinite(current.startTime);
|
||||
|
||||
if (hasCurrentTiming) {
|
||||
const timingMatch = cues.findIndex(
|
||||
|
||||
@@ -11,6 +11,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
state.subtitleGenerationModalOpen ||
|
||||
state.sessionHelpModalOpen,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import { isControllerInteractionBlocked } from './controller-interaction-blockin
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
||||
import { createPositioningController } from './positioning.js';
|
||||
@@ -79,6 +80,12 @@ const ctx = {
|
||||
};
|
||||
|
||||
const modalDescriptors = [
|
||||
{
|
||||
id: 'subtitle-generation',
|
||||
isOpen: () => ctx.state.subtitleGenerationModalOpen,
|
||||
close: () => subtitleGenerationModal.close(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'controller-select',
|
||||
isOpen: () => ctx.state.controllerSelectModalOpen,
|
||||
@@ -212,6 +219,10 @@ const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleGenerationModal = createSubtitleGenerationModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -280,6 +291,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||
@@ -532,6 +544,9 @@ const recovery = createRendererRecoveryController({
|
||||
registerRendererGlobalErrorHandlers(window, recovery);
|
||||
|
||||
function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.onSubtitleGenerationOpen(() => {
|
||||
runGuarded('subtitle-generation:open', () => subtitleGenerationModal.open());
|
||||
});
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuarded('runtime-options:open', () => {
|
||||
runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
@@ -832,6 +847,7 @@ async function init(): Promise<void> {
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
subtitleGenerationModal.wireDomEvents();
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
@@ -839,6 +855,7 @@ async function init(): Promise<void> {
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
subtitleGenerationModal.dispose();
|
||||
subtitleSidebarModal.disposeDomEvents();
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export type RendererState = {
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleGenerationModalOpen: boolean;
|
||||
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
@@ -219,6 +220,7 @@ export function createRendererState(): RendererState {
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subtitleGenerationModalOpen: false,
|
||||
subsyncSubtitleTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
|
||||
@@ -2976,6 +2976,138 @@ iframe[id^='yomitan-popup'],
|
||||
width: min(560px, 92%);
|
||||
}
|
||||
|
||||
.subtitle-generation-content {
|
||||
width: min(580px, 92%);
|
||||
max-height: 92%;
|
||||
border-top: 3px solid var(--ctp-green);
|
||||
}
|
||||
|
||||
.subtitle-generation-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--ctp-surface1) transparent;
|
||||
}
|
||||
|
||||
.subtitle-generation-body > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subtitle-generation-intro {
|
||||
margin: 0;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.subtitle-generation-detail {
|
||||
padding: 12px 14px;
|
||||
border-left: 2px solid var(--ctp-surface1);
|
||||
background: rgba(24, 25, 38, 0.35);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subtitle-generation-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ctp-green);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle-generation-hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker select:focus-visible {
|
||||
outline: 2px solid var(--ctp-green);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker select:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.subtitle-generation-detail button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.subtitle-generation-vad-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtitle-generation-vad-toggle input {
|
||||
accent-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.subtitle-generation-vad-toggle .subtitle-generation-hint {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
|
||||
.subtitle-generation-progress-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subtitle-generation-activity progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
accent-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.subtitle-generation-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subtitle-generation-actions button:disabled,
|
||||
.subtitle-generation-detail button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.subtitle-generation-open {
|
||||
align-self: flex-start;
|
||||
margin: 0 16px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subsync-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user