mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(jellyfin): subtitle timing, resume progress, and overlay sync
- Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX
This commit is contained in:
@@ -116,6 +116,12 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
export {
|
||||
estimateSubtitleTimingOffset,
|
||||
type SubtitleTimingOffsetOptions,
|
||||
type SubtitleTimingOffsetResult,
|
||||
} from './subtitle-timing-offset';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
|
||||
function statePath(name: string): string {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
|
||||
}
|
||||
|
||||
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: 1.25,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store ignores invalid files and values', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
fs.writeFileSync(filePath, '{');
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: Number.NaN,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
type JellyfinSubtitleDelayStore = {
|
||||
version?: unknown;
|
||||
delays?: unknown;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayParams = {
|
||||
filePath: string;
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
|
||||
delaySeconds: number;
|
||||
};
|
||||
|
||||
function storeKey(itemId: string, streamIndex: number): string {
|
||||
return JSON.stringify([itemId, streamIndex]);
|
||||
}
|
||||
|
||||
function readDelayMap(filePath: string): Record<string, number> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
!parsed.delays ||
|
||||
typeof parsed.delays !== 'object'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const delays: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
delays[key] = value;
|
||||
}
|
||||
}
|
||||
return delays;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
|
||||
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
|
||||
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
|
||||
}
|
||||
|
||||
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
|
||||
if (!Number.isFinite(params.delaySeconds)) return false;
|
||||
try {
|
||||
const delays = readDelayMap(params.filePath);
|
||||
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
|
||||
const dir = path.dirname(params.filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,7 @@ test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
||||
assert.equal(plan.mode, 'direct');
|
||||
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
|
||||
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
|
||||
assert.equal(new URL(plan.url).searchParams.get('StartTimeTicks'), null);
|
||||
assert.equal(plan.subtitleStreamIndex, null);
|
||||
assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
|
||||
} finally {
|
||||
@@ -570,7 +571,7 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
|
||||
const url = new URL(plan.url);
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '6');
|
||||
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), '35000000');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -233,9 +233,6 @@ function createDirectPlayUrl(
|
||||
if (plan.subtitleStreamIndex !== null) {
|
||||
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
|
||||
}
|
||||
if (plan.startTimeTicks > 0) {
|
||||
query.set('StartTimeTicks', String(plan.startTimeTicks));
|
||||
}
|
||||
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
setPendingPauseAtSubEnd: (value: boolean) => void;
|
||||
getPauseAtTime: () => number | null;
|
||||
setPauseAtTime: (value: number | null) => void;
|
||||
autoLoadSecondarySubTrack: () => void;
|
||||
autoLoadSecondarySubTrack: (path: string) => void;
|
||||
setCurrentVideoPath: (value: string) => void;
|
||||
emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => void;
|
||||
@@ -303,7 +303,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
const path = (msg.data as string) || '';
|
||||
deps.setCurrentVideoPath(path);
|
||||
deps.emitMediaPathChange({ path });
|
||||
deps.autoLoadSecondarySubTrack();
|
||||
deps.autoLoadSecondarySubTrack(path);
|
||||
deps.syncCurrentAudioStreamIndex();
|
||||
} else if (msg.name === 'sub-pos') {
|
||||
deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
MpvIpcClientProtocolDeps,
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
} from './mpv';
|
||||
import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol';
|
||||
import {
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
} from './mpv-protocol';
|
||||
|
||||
function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps {
|
||||
return {
|
||||
@@ -93,6 +96,53 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
|
||||
assert.equal(client.currentMediaTitle, null);
|
||||
});
|
||||
|
||||
test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
secondarySub: {
|
||||
autoLoadSecondarySub: true,
|
||||
secondarySubLanguages: ['en'],
|
||||
},
|
||||
}) as any,
|
||||
shouldAutoLoadSecondarySubTrack: () => false,
|
||||
} as any),
|
||||
);
|
||||
(client as any).send = (command: unknown) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
(globalThis as any).setTimeout = (callback: () => void) => {
|
||||
callback();
|
||||
return 0;
|
||||
};
|
||||
|
||||
try {
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'path',
|
||||
data: 'http://pve-main:8096/Videos/item/stream',
|
||||
});
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command?: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'get_property' &&
|
||||
(command as { command: unknown[] }).command[1] === 'track-list' &&
|
||||
(command as { request_id?: number }).request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface MpvIpcClientProtocolDeps {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
}
|
||||
@@ -404,8 +405,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
setPauseAtTime: (value: number | null) => {
|
||||
this.pauseAtTime = value;
|
||||
},
|
||||
autoLoadSecondarySubTrack: () => {
|
||||
this.autoLoadSecondarySubTrack();
|
||||
autoLoadSecondarySubTrack: (path: string) => {
|
||||
this.autoLoadSecondarySubTrack(path);
|
||||
},
|
||||
setCurrentVideoPath: (value: string) => {
|
||||
this.currentVideoPath = value;
|
||||
@@ -429,7 +430,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
};
|
||||
}
|
||||
|
||||
private autoLoadSecondarySubTrack(): void {
|
||||
private autoLoadSecondarySubTrack(path: string): void {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath) return;
|
||||
if (this.deps.shouldAutoLoadSecondarySubTrack?.(normalizedPath) === false) {
|
||||
return;
|
||||
}
|
||||
const config = this.deps.getResolvedConfig();
|
||||
if (!config.secondarySub?.autoLoadSecondarySub) return;
|
||||
const languages = config.secondarySub.secondarySubLanguages;
|
||||
|
||||
@@ -89,6 +89,40 @@ Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||
});
|
||||
|
||||
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
|
||||
const shiftedDelays: number[] = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
'sub-delay': 0.5,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `1
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-2`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
|
||||
assert.deepEqual(shiftedDelays, [2.5]);
|
||||
});
|
||||
|
||||
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
|
||||
@@ -21,6 +21,7 @@ type SubtitleDelayShiftDeps = {
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
onSubtitleDelayShifted?: (delaySeconds: number) => void;
|
||||
};
|
||||
|
||||
function asTrackId(value: unknown): number | null {
|
||||
@@ -175,10 +176,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
|
||||
throw new Error('MPV not connected.');
|
||||
}
|
||||
|
||||
const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([
|
||||
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('sub-start'),
|
||||
client.requestProperty('sub-delay'),
|
||||
]);
|
||||
|
||||
const currentStart =
|
||||
@@ -198,6 +200,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
|
||||
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||
const delta = targetStart - currentStart;
|
||||
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||
const currentDelay =
|
||||
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
|
||||
try {
|
||||
deps.onSubtitleDelayShifted?.(currentDelay + delta);
|
||||
} catch {}
|
||||
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
|
||||
|
||||
function cue(startTime: number) {
|
||||
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
|
||||
}
|
||||
|
||||
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
assert.ok(result.matchCount >= 8);
|
||||
assert.ok(result.meanErrorSeconds <= 0.75);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset favors the early episode timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
|
||||
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
|
||||
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
|
||||
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
|
||||
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
|
||||
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
|
||||
435.602, 440.44, 442.942, 445.445, 449.783,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
|
||||
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
|
||||
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
|
||||
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
|
||||
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
|
||||
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
|
||||
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
|
||||
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
|
||||
|
||||
const result = estimateSubtitleTimingOffset(
|
||||
starts.map(cue),
|
||||
starts.map((start) => cue(start + 0.04)),
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset rejects weak timeline matches', () => {
|
||||
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
|
||||
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
export type SubtitleTimingOffsetResult = {
|
||||
offsetSeconds: number;
|
||||
matchCount: number;
|
||||
meanErrorSeconds: number;
|
||||
maxErrorSeconds: number;
|
||||
};
|
||||
|
||||
export type SubtitleTimingOffsetOptions = {
|
||||
maxCueCount?: number;
|
||||
maxOffsetSeconds?: number;
|
||||
matchThresholdSeconds?: number;
|
||||
maxMeanErrorSeconds?: number;
|
||||
minMatchCount?: number;
|
||||
minMatchRatio?: number;
|
||||
minUsefulOffsetSeconds?: number;
|
||||
};
|
||||
|
||||
type OffsetScore = SubtitleTimingOffsetResult;
|
||||
|
||||
const DEFAULT_MAX_CUE_COUNT = 60;
|
||||
const DEFAULT_MAX_OFFSET_SECONDS = 180;
|
||||
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
|
||||
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
|
||||
const DEFAULT_MIN_MATCH_COUNT = 8;
|
||||
const DEFAULT_MIN_MATCH_RATIO = 0.25;
|
||||
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
|
||||
|
||||
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
|
||||
const starts = cues
|
||||
.map((cue) => cue.startTime)
|
||||
.filter((start) => Number.isFinite(start) && start >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
const deduped: number[] = [];
|
||||
for (const start of starts) {
|
||||
const previous = deduped[deduped.length - 1];
|
||||
if (previous === undefined || Math.abs(start - previous) > 0.05) {
|
||||
deduped.push(start);
|
||||
}
|
||||
if (deduped.length >= maxCueCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function roundToMillis(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function scoreOffset(
|
||||
primaryStarts: number[],
|
||||
referenceStarts: number[],
|
||||
offsetSeconds: number,
|
||||
matchThresholdSeconds: number,
|
||||
): OffsetScore {
|
||||
let primaryIndex = 0;
|
||||
let referenceIndex = 0;
|
||||
let matchCount = 0;
|
||||
let totalErrorSeconds = 0;
|
||||
let maxErrorSeconds = 0;
|
||||
|
||||
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
|
||||
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
|
||||
const reference = referenceStarts[referenceIndex]!;
|
||||
const errorSeconds = Math.abs(shiftedPrimary - reference);
|
||||
if (errorSeconds <= matchThresholdSeconds) {
|
||||
matchCount += 1;
|
||||
totalErrorSeconds += errorSeconds;
|
||||
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
|
||||
primaryIndex += 1;
|
||||
referenceIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shiftedPrimary < reference) {
|
||||
primaryIndex += 1;
|
||||
} else {
|
||||
referenceIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offsetSeconds,
|
||||
matchCount,
|
||||
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
|
||||
maxErrorSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
|
||||
if (current === null) return true;
|
||||
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
|
||||
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
|
||||
return next.meanErrorSeconds < current.meanErrorSeconds;
|
||||
}
|
||||
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
|
||||
}
|
||||
|
||||
export function estimateSubtitleTimingOffset(
|
||||
primaryCues: SubtitleCue[],
|
||||
referenceCues: SubtitleCue[],
|
||||
options: SubtitleTimingOffsetOptions = {},
|
||||
): SubtitleTimingOffsetResult | null {
|
||||
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
|
||||
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
|
||||
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
|
||||
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
|
||||
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
|
||||
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
|
||||
const minUsefulOffsetSeconds =
|
||||
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
|
||||
|
||||
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
|
||||
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
|
||||
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
|
||||
if (comparableCueCount < minMatchCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = new Set<number>();
|
||||
for (const primaryStart of primaryStarts) {
|
||||
for (const referenceStart of referenceStarts) {
|
||||
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
|
||||
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
|
||||
candidates.add(offsetSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best: OffsetScore | null = null;
|
||||
for (const offsetSeconds of candidates) {
|
||||
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
|
||||
continue;
|
||||
}
|
||||
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
|
||||
if (score.matchCount < minMatchCount) {
|
||||
continue;
|
||||
}
|
||||
if (score.matchCount / comparableCueCount < minMatchRatio) {
|
||||
continue;
|
||||
}
|
||||
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
|
||||
continue;
|
||||
}
|
||||
if (isBetterScore(score, best)) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
Reference in New Issue
Block a user