fix(subtitles): recover canonical lines from ASS animation (#207)

This commit is contained in:
sudacode authored and GitHub committed 2026-08-18 00:30:48 -07:00
1 parent 14c0086520
commit 273652f781
26 files changed
+1844 -124

No files matched your search

+147 -33
View File
@@ -1,5 +1,11 @@
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
@@ -36,6 +42,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
} | null;
activeParsedSubtitleCues?: SubtitleCue[] | null;
/** Cache key of the source the cues were parsed from; cleared with the cues. */
activeParsedSubtitleSource?: string | null;
currentMediaPath?: string | null;
currentSubText: string;
currentSubAssText: string;
@@ -93,6 +101,38 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
const immersionLineDedupGate = createSubtitleLineDedupGate({
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
});
// One seen-set per consumer: canonical cues overlap, so live samples resolve to
// shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just
// the previous sample -- keeps each authored line recorded exactly once per source.
const recordedImmersionCanonicalKeys = new Set<string>();
const recordedTimingCanonicalKeys = new Set<string>();
// Bumped on track/media changes so an immersion record whose tokenization resolves
// after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0;
let lastTimePosForTimingReset: number | null = null;
const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`;
const resetSubtitleDeduplication = (): void => {
immersionLineDedupGate.reset();
recordedImmersionCanonicalKeys.clear();
recordedTimingCanonicalKeys.clear();
subtitleSessionEpoch += 1;
lastTimePosForTimingReset = null;
};
const resolveCanonicalSample = (liveText: string, startSec: number) =>
resolveCanonicalPrimarySubtitle({
liveText,
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
});
// When substitution declined because dialogue shares the screen with a song, record
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
stripCanonicalFragmentLines({
liveText,
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
});
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
Boolean(
deps.appState.initialArgs?.managedPlayback ||
@@ -111,45 +151,99 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
quitApp: () => deps.quitApp(),
resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({
liveText,
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
cues: deps.appState.activeParsedSubtitleCues,
}),
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
const tracker = deps.appState.immersionTracker;
if (!tracker?.recordSubtitleLine) {
return;
}
const recordLine = (lineText: string, startSec: number, endSec: number): void => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens =
deps.appState.currentSubtitleData?.text === lineText
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText);
return;
}
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
return;
}
const epochAtRecord = subtitleSessionEpoch;
void deps
.tokenizeSubtitleForImmersion(lineText)
.then((payload) => {
if (subtitleSessionEpoch !== epochAtRecord) {
return;
}
tracker.recordSubtitleLine?.(
lineText,
startSec,
endSec,
payload?.tokens ?? null,
secondaryText,
);
})
.catch(() => {
if (subtitleSessionEpoch !== epochAtRecord) {
return;
}
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
});
};
const canonical = resolveCanonicalSample(text, start);
if (canonical) {
for (const cue of canonical.cues) {
const key = canonicalCueKey(cue);
if (recordedImmersionCanonicalKeys.has(key)) {
continue;
}
recordedImmersionCanonicalKeys.add(key);
recordLine(cue.text, cue.startTime, cue.endTime);
}
return;
}
text = stripFragmentsForRecording(text, start);
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
return;
}
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens =
deps.appState.currentSubtitleData?.text === text
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
return;
}
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
return;
}
void deps
.tokenizeSubtitleForImmersion(text)
.then((payload) => {
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
})
.catch(() => {
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
});
recordLine(text, start, end);
},
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
text,
start,
end,
deps.appState.mpvClient?.currentSecondarySubText || undefined,
),
recordSubtitleTiming: (text: string, start: number, end: number) => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
stripFragmentsForRecording(text, start),
start,
end,
secondaryText,
);
return;
}
for (const cue of canonical.cues) {
const key = canonicalCueKey(cue);
if (recordedTimingCanonicalKeys.has(key)) {
continue;
}
recordedTimingCanonicalKeys.add(key);
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
cue.text,
cue.startTime,
cue.endTime,
secondaryText,
);
}
},
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) =>
@@ -170,7 +264,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
? (message: string) => deps.logSubtitleProcessingDebug!(message)
: undefined,
onSubtitleTrackChange: (sid: number | null) => {
immersionLineDedupGate.reset();
resetSubtitleDeduplication();
// The replacement track's cues arrive only after an async re-read and re-parse.
// Clearing synchronously keeps the previous track's canonical cues from
// substituting into, or recording against, the new track's live text. The source
// key is cleared with the cues so cue-list consumers (the sidebar snapshot)
// re-parse on demand instead of trusting the stale pairing.
deps.appState.activeParsedSubtitleCues = [];
deps.appState.activeParsedSubtitleSource = null;
deps.onSubtitleTrackChange?.(sid);
},
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
@@ -185,7 +286,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => {
immersionLineDedupGate.reset();
resetSubtitleDeduplication();
deps.updateCurrentMediaPath(path);
},
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
@@ -217,9 +318,22 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
onTimePosUpdate: deps.onTimePosUpdate
? (time: number) => deps.onTimePosUpdate!(time)
: undefined,
onTimePosUpdate: (time: number) => {
// Timing history is a viewing log: after a real backward seek, a rewatched
// canonical line should enter it again. Immersion stats keep their
// once-per-media deduplication and are not reset here.
if (
Number.isFinite(time) &&
lastTimePosForTimingReset !== null &&
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
) {
recordedTimingCanonicalKeys.clear();
}
if (Number.isFinite(time)) {
lastTimePosForTimingReset = time;
}
deps.onTimePosUpdate?.(time);
},
onFullscreenChange: deps.onFullscreenChange
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
: undefined,