mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18:22:42 -08:00
feat(subtitles): add no-jump subtitle-delay shift commands
This commit is contained in:
@@ -10,6 +10,7 @@ export {
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from './overlay-shortcut';
|
||||
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
||||
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
||||
export {
|
||||
copyCurrentSubtitle,
|
||||
|
||||
@@ -13,6 +13,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
},
|
||||
triggerSubsyncFromConfig: () => {
|
||||
calls.push('subsync');
|
||||
@@ -30,6 +32,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
mpvPlayNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
mpvSendCommand: (command) => {
|
||||
sentCommands.push(command);
|
||||
},
|
||||
@@ -68,6 +73,21 @@ test('handleMpvCommandFromIpc emits osd for secondary subtitle track keybinding
|
||||
assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
||||
assert.deepEqual(calls, ['shift:next']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
isMpvConnected: () => false,
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||
REPLAY_SUBTITLE: string;
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||
};
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -19,6 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
showMpvOsd: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
mpvSendCommand: (command: (string | number)[]) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
@@ -46,6 +49,9 @@ function resolveProxyCommandOsd(command: (string | number)[]): string | null {
|
||||
if (property === 'secondary-sid') {
|
||||
return 'Secondary subtitle track: ${secondary-sid}';
|
||||
}
|
||||
if (property === 'sub-delay') {
|
||||
return 'Subtitle delay: ${sub-delay}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -64,6 +70,20 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
||||
) {
|
||||
const direction =
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START
|
||||
? 'next'
|
||||
: 'previous';
|
||||
options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => {
|
||||
options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (!options.hasRuntimeOptionsManager()) return;
|
||||
const [, idToken, directionToken] = first.split(':');
|
||||
|
||||
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
|
||||
function createMpvClient(props: Record<string, unknown>) {
|
||||
return {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => props[name],
|
||||
};
|
||||
}
|
||||
|
||||
test('shift subtitle delay to next cue using active external srt track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const osd: string[] = [];
|
||||
let loadCount = 0;
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => {
|
||||
loadCount += 1;
|
||||
return `1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-2
|
||||
|
||||
3
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-3`;
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
await handler('next');
|
||||
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(commands.length, 2);
|
||||
const delta = commands[0]?.[2];
|
||||
assert.equal(commands[0]?.[0], 'add');
|
||||
assert.equal(commands[0]?.[1], 'sub-delay');
|
||||
assert.equal(typeof delta, 'number');
|
||||
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
test('shift subtitle delay to previous cue using active external ass track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 4,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.ass',
|
||||
},
|
||||
],
|
||||
sid: 4,
|
||||
'sub-start': 2.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `[Events]
|
||||
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
|
||||
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
|
||||
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler('previous');
|
||||
|
||||
const delta = commands[0]?.[2];
|
||||
assert.equal(typeof delta, 'number');
|
||||
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||
});
|
||||
|
||||
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.vtt',
|
||||
},
|
||||
],
|
||||
sid: 1,
|
||||
'sub-start': 5.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `WEBVTT
|
||||
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
line-1
|
||||
|
||||
00:00:03.000 --> 00:00:04.000
|
||||
line-2
|
||||
|
||||
00:00:05.000 --> 00:00:06.000
|
||||
line-3`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
|
||||
});
|
||||
201
src/core/services/subtitle-delay-shift.ts
Normal file
201
src/core/services/subtitle-delay-shift.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
type SubtitleDelayShiftDirection = 'next' | 'previous';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
external?: unknown;
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
type SubtitleCueCacheEntry = {
|
||||
starts: number[];
|
||||
};
|
||||
|
||||
type SubtitleDelayShiftDeps = {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
};
|
||||
|
||||
function asTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
if (Number.isInteger(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSrtOrVttStartTimes(content: string): number[] {
|
||||
const starts: number[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(
|
||||
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const hours = Number(match[1] || 0);
|
||||
const minutes = Number(match[2] || 0);
|
||||
const seconds = Number(match[3] || 0);
|
||||
const millis = Number(String(match[4]).padEnd(3, '0'));
|
||||
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function parseAssStartTimes(content: string): number[] {
|
||||
const starts: number[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/);
|
||||
if (!match) continue;
|
||||
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
|
||||
if (secondsRaw === undefined) continue;
|
||||
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
|
||||
const hours = Number(hoursRaw);
|
||||
const minutes = Number(minutesRaw);
|
||||
const wholeSeconds = Number(wholeSecondsRaw);
|
||||
const fraction = Number(`0.${fractionRaw}`);
|
||||
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function normalizeCueStarts(starts: number[]): number[] {
|
||||
const sorted = starts
|
||||
.filter((value) => Number.isFinite(value) && value >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const deduped: number[] = [sorted[0]!];
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const current = sorted[i]!;
|
||||
const previous = deduped[deduped.length - 1]!;
|
||||
if (Math.abs(current - previous) > 0.0005) {
|
||||
deduped.push(current);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function parseCueStarts(content: string, source: string): number[] {
|
||||
const normalizedSource = source.toLowerCase().split('?')[0] || '';
|
||||
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
|
||||
const parseAssLike = () => parseAssStartTimes(content);
|
||||
|
||||
let starts: number[] = [];
|
||||
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
|
||||
starts = parseAssLike();
|
||||
if (starts.length === 0) {
|
||||
starts = parseSrtLike();
|
||||
}
|
||||
} else {
|
||||
starts = parseSrtLike();
|
||||
if (starts.length === 0) {
|
||||
starts = parseAssLike();
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeCueStarts(starts);
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
|
||||
const sid = asTrackId(sidRaw);
|
||||
if (sid === null) {
|
||||
throw new Error('No active subtitle track selected.');
|
||||
}
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
throw new Error('Could not inspect subtitle track list.');
|
||||
}
|
||||
|
||||
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const track = entry as MpvSubtitleTrackLike;
|
||||
return track.type === 'sub' && asTrackId(track.id) === sid;
|
||||
});
|
||||
|
||||
if (!activeTrack) {
|
||||
throw new Error('No active subtitle track found in mpv track list.');
|
||||
}
|
||||
if (activeTrack.external !== true) {
|
||||
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
|
||||
}
|
||||
|
||||
const source =
|
||||
typeof activeTrack['external-filename'] === 'string'
|
||||
? activeTrack['external-filename'].trim()
|
||||
: '';
|
||||
if (!source) {
|
||||
throw new Error('Active subtitle track has no external subtitle source path.');
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function findAdjacentCueStart(
|
||||
starts: number[],
|
||||
currentStart: number,
|
||||
direction: SubtitleDelayShiftDirection,
|
||||
): number {
|
||||
const epsilon = 0.0005;
|
||||
if (direction === 'next') {
|
||||
const target = starts.find((value) => value > currentStart + epsilon);
|
||||
if (target === undefined) {
|
||||
throw new Error('No next subtitle cue found for active subtitle source.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
for (let index = starts.length - 1; index >= 0; index -= 1) {
|
||||
const value = starts[index]!;
|
||||
if (value < currentStart - epsilon) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error('No previous subtitle cue found for active subtitle source.');
|
||||
}
|
||||
|
||||
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
|
||||
const cueCache = new Map<string, SubtitleCueCacheEntry>();
|
||||
|
||||
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client || !client.connected) {
|
||||
throw new Error('MPV not connected.');
|
||||
}
|
||||
|
||||
const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('sub-start'),
|
||||
]);
|
||||
|
||||
const currentStart =
|
||||
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
|
||||
if (currentStart === null) {
|
||||
throw new Error('Current subtitle start time is unavailable.');
|
||||
}
|
||||
|
||||
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
|
||||
let cueStarts = cueCache.get(source)?.starts;
|
||||
if (!cueStarts) {
|
||||
const content = await deps.loadSubtitleSourceText(source);
|
||||
cueStarts = parseCueStarts(content, source);
|
||||
cueCache.set(source, { starts: cueStarts });
|
||||
}
|
||||
|
||||
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||
const delta = targetStart - currentStart;
|
||||
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user