mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
fix(subtitles): recover canonical lines from ASS animation (#207)
This commit is contained in:
1 parent
14c0086520
commit
273652f781
26 files changed
+1844
-124
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user