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

This commit is contained in:
2026-08-18 00:30:48 -07:00
committed by GitHub
parent 14c0086520
commit 273652f781
26 changed files with 1844 additions and 124 deletions
@@ -64,11 +64,17 @@ test('anki action main deps builders map callbacks', async () => {
const mine = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
getMpvClient: () => ({ connected: true }),
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
showMpvOsd: (text) => calls.push(`mine:${text}`),
mineSentenceCardCore: async () => true,
recordCardsMined: (count) => calls.push(`cards:${count}`),
})();
assert.deepEqual(mine.getMpvClient(), { connected: true });
assert.deepEqual(mine.getPrimarySubtitle?.(), {
text: '正式な字幕',
startTime: 1,
endTime: 3,
});
mine.showMpvOsd('m');
await mine.mineSentenceCardCore({
ankiIntegration: { enabled: true },
+7 -1
View File
@@ -1,4 +1,4 @@
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions';
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
@@ -72,10 +72,12 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
getPrimarySubtitle?: () => PrimarySubtitle | null;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
@@ -83,10 +85,14 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
getMpvClient: () => deps.getMpvClient(),
...(deps.getPrimarySubtitle
? { getPrimarySubtitle: () => deps.getPrimarySubtitle?.() ?? null }
: {}),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => deps.mineSentenceCardCore(options),
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
+17
View File
@@ -87,3 +87,20 @@ test('mine sentence handler records mined cards only when core returns true', as
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
});
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => ({}),
getMpvClient: () => ({}),
getPrimarySubtitle: () => primarySubtitle,
showMpvOsd: () => {},
mineSentenceCardCore: async (options) => {
assert.equal(options.primarySubtitle, primarySubtitle);
return true;
},
recordCardsMined: () => {},
});
await mineSentenceCard();
});
+10
View File
@@ -2,6 +2,12 @@ type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>;
};
export type PrimarySubtitle = {
text: string;
startTime: number;
endTime: number;
};
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
@@ -69,18 +75,22 @@ export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
getPrimarySubtitle?: () => PrimarySubtitle | null;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) {
return async (): Promise<void> => {
const primarySubtitle = deps.getPrimarySubtitle?.();
const created = await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(),
...(primarySubtitle ? { primarySubtitle } : {}),
showMpvOsd: deps.showMpvOsd,
});
if (created) {
@@ -1,6 +1,7 @@
import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
@@ -141,6 +142,16 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return true;
}
function resolveLivePrimarySubtitleText(text: string): string {
const client = deps.getMpvClient();
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({
liveText: text,
currentTimeSec,
cues: deps.getActiveParsedSubtitleCues(),
});
}
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
@@ -155,7 +166,8 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
);
return null;
});
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
const text = resolveLivePrimarySubtitleText(liveText);
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
return;
}
@@ -175,6 +187,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => deps.getMpvClient(),
resolvePrimarySubtitleText: (text) => resolveLivePrimarySubtitleText(text),
setCurrentSubText: (text) => {
deps.setCurrentSubText(text);
},
@@ -46,6 +46,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
getMpvClient: () => CurrentSubtitleMpvClient | null;
setCurrentSubText: (text: string) => void;
resolvePrimarySubtitleText?: (text: string) => string;
getCurrentSubtitleData: () => SubtitleData | null;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
@@ -73,7 +74,8 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
return;
}
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
const text = deps.resolvePrimarySubtitleText?.(liveText) ?? liveText;
deps.setCurrentSubText(text);
const primeSecondarySubtitle = async (): Promise<void> => {
@@ -26,6 +26,29 @@ test('subtitle change handler updates state and forwards uncached text without r
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
});
test('subtitle change handler consistently forwards resolved canonical text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
resolveSubtitleText: () => '今 手にある物差しでは',
setCurrentSubText: (text) => calls.push(`set:${text}`),
getImmediateSubtitlePayload: (text) => {
calls.push(`lookup:${text}`);
return null;
},
broadcastSubtitle: () => {},
onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => {},
});
handler({ text: '今今今手手手ににに' });
assert.deepEqual(calls, [
'set:今 手にある物差しでは',
'lookup:今 手にある物差しでは',
'process:今 手にある物差しでは',
]);
});
test('subtitle change handler clears immediately for empty subtitle text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
+5 -2
View File
@@ -4,7 +4,8 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
@@ -14,6 +15,7 @@ function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): bo
}
export function createHandleMpvSubtitleChangeHandler(deps: {
resolveSubtitleText?: (text: string) => string;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
@@ -22,7 +24,8 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
refreshDiscordPresence: () => void;
logDebug?: (message: string) => void;
}) {
return ({ text }: { text: string }): void => {
return ({ text: liveText }: { text: string }): void => {
const text = deps.resolveSubtitleText?.(liveText) ?? liveText;
deps.setCurrentSubText(text);
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
@@ -43,6 +43,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void;
resolveSubtitleText?: (text: string) => string;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void;
@@ -117,6 +118,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
resolveSubtitleText: deps.resolveSubtitleText,
setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: deps.emitImmediateSubtitle
@@ -387,3 +387,170 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
assert.deepEqual(recordedStarts.slice(-1), [20]);
});
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: {
recordSubtitleLine: (text: string, start: number, end: number) =>
immersion.push({ text, start, end }),
},
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass',
},
{
startTime: 3,
endTime: 6,
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
],
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('手', 0.86, 1.56);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Concurrent dialogue during the song is not part of the animation: it must be
// recorded as itself -- without the fragment lines beside it -- and must not cause
// the song line to be recorded again when the animation frames resume.
assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手');
handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2);
handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9);
handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0);
assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]);
assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]);
// Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A).
// Every recorded cue is remembered, so each authored line still records exactly once.
handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4);
handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7);
handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4);
handlers.recordSubtitleTiming('手にある', 3.5, 3.7);
assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
// A backward seek means the user is rewatching: the timing history (a viewing log)
// records the revisited line again, while immersion stays once-per-media.
handlers.onTimePosUpdate?.(30);
handlers.onTimePosUpdate?.(2);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3);
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
// handler's own `>=` boundary.
handlers.onTimePosUpdate?.(4.5);
handlers.onTimePosUpdate?.(2);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
const appState = {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: { recordSubtitleLine: () => {} },
subtitleTimingTracker: { recordSubtitle: () => {} },
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass' as const,
},
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
activeParsedSubtitleSource: 'track-a.ass' as string | null,
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
};
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
// The new track's cues arrive only after an async re-parse; until then, the old
// track's canonical lyric must not replace the new track's live text.
handlers.onSubtitleTrackChange?.(2);
assert.deepEqual(appState.activeParsedSubtitleCues, []);
assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
});
+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,
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => {
// mpv renders each simultaneously active ASS event on its own sub-text line.
const text = resolvePrimarySubtitleText({
liveText: '今\n今\n今\n手\n手\n手\nにある\nにある\nにある',
currentTimeSec: 2,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '今 手にある');
});
test('resolvePrimarySubtitleText preserves live text outside canonical cue timing', () => {
const text = resolvePrimarySubtitleText({
liveText: '通常の会話',
currentTimeSec: 8,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '通常の会話');
});
test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of the animation', () => {
// An insert song's canonical window can overlap real dialogue on the same track.
const text = resolvePrimarySubtitleText({
liveText: '普通のセリフ\n今\n手にある',
currentTimeSec: 2,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '普通のセリフ\n今\n手にある');
});
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
const text = resolvePrimarySubtitleText({
liveText: '次のセリフ',
currentTimeSec: 4.1,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '次のセリフ');
});
test('resolvePrimarySubtitleText survives overlapping frames of consecutive karaoke lines', () => {
// Near a line boundary the previous line's exit frames and the next line's entrance
// frames render together; neither line alone explains every live segment.
const cues = [
{ startTime: 1.2, endTime: 3.8, text: '今 手にある', source: 'canonical-ass' as const },
{ startTime: 3.8, endTime: 6.4, text: '物差しでは', source: 'canonical-ass' as const },
];
assert.equal(
resolvePrimarySubtitleText({
liveText: '手にある\n物差し\nでは',
currentTimeSec: 3.6,
cues,
}),
'今 手にある',
);
assert.equal(
resolvePrimarySubtitleText({
liveText: '手にある\n物差し\nでは',
currentTimeSec: 3.9,
cues,
}),
'物差しでは',
);
});
test('resolvePrimarySubtitleText combines simultaneous canonical cues in source order', () => {
const text = resolvePrimarySubtitleText({
liveText: 'fir\nst\nsecond',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'first', source: 'canonical-ass' },
{ startTime: 1.5, endTime: 2.5, text: 'second', source: 'canonical-ass' },
],
});
assert.equal(text, 'first\nsecond');
});
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
const cue = {
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
};
const resolved = resolveCanonicalPrimarySubtitle({
liveText: '今\n手にある',
currentTimeSec: 0.8,
cues: [cue],
});
assert.deepEqual(resolved, {
text: '今 手にある',
startTime: 1.2,
endTime: 3.8,
cues: [cue],
});
});
test('resolveCanonicalPrimarySubtitle covers exit frames that outlive the authored timing', () => {
// Real generated animations keep exit fragments on screen well past the authored
// comment window; the recorded animation envelope is what makes them resolvable.
const cue = {
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
animationStartTime: 0.8,
animationEndTime: 5.6,
};
const resolved = resolveCanonicalPrimarySubtitle({
liveText: '今\n手にある',
currentTimeSec: 5.4,
cues: [cue],
});
assert.deepEqual(resolved, {
text: '今 手にある',
startTime: 1.2,
endTime: 3.8,
cues: [cue],
});
});
test('resolvePrimarySubtitleText handles late exit frames overlapping the next active line', () => {
// The previous line's exit fragments can persist more than a second into the next
// authored line. The next line supplies the text; the previous line's envelope
// explains its lingering fragments.
const cues = [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
animationStartTime: 0.8,
animationEndTime: 5.6,
},
{
startTime: 3.8,
endTime: 6.4,
text: '物差しでは',
source: 'canonical-ass' as const,
animationStartTime: 3.4,
animationEndTime: 7.0,
},
];
assert.equal(
resolvePrimarySubtitleText({
liveText: '手にある\n手にある\n物差し\nでは',
currentTimeSec: 5.2,
cues,
}),
'物差しでは',
);
});
test('resolveCanonicalPrimarySubtitle rejects unrelated live text at the animation edge', () => {
const resolved = resolveCanonicalPrimarySubtitle({
liveText: '次のセリフ',
currentTimeSec: 4.1,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(resolved, null);
});
test('stripCanonicalFragmentLines drops fragment lines but keeps concurrent dialogue', () => {
const cues = [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
},
];
assert.equal(
stripCanonicalFragmentLines({
liveText: '普通のセリフ\n今\n手にある',
currentTimeSec: 2,
cues,
}),
'普通のセリフ',
);
// No canonical cue nearby: nothing to strip.
assert.equal(
stripCanonicalFragmentLines({ liveText: '普通のセリフ\n今', currentTimeSec: 30, cues }),
'普通のセリフ\n今',
);
// Everything matched (defensive): return the input rather than empty text.
assert.equal(
stripCanonicalFragmentLines({ liveText: '今\n手にある', currentTimeSec: 2, cues }),
'今\n手にある',
);
});
test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the nearest', () => {
// In the gap between two authored spans, the next line sits closer in time while only
// the previous line's exit fragments are on screen: the fragments decide.
const cues = [
{
startTime: 1,
endTime: 3,
text: '今 手にある',
source: 'canonical-ass' as const,
animationStartTime: 0.6,
animationEndTime: 3.9,
},
{
startTime: 4,
endTime: 6,
text: '物差しでは',
source: 'canonical-ass' as const,
animationStartTime: 3.5,
animationEndTime: 6.4,
},
];
assert.equal(
resolveCanonicalPrimarySubtitle({ liveText: '手にある', currentTimeSec: 3.8, cues })?.text,
'今 手にある',
);
// Fragments of both lines in the gap: both envelopes cover the moment (distance 0),
// and the earlier line wins the tie while it is still animating out.
assert.equal(
resolveCanonicalPrimarySubtitle({ liveText: '手にある\n物差し', currentTimeSec: 3.8, cues })
?.text,
'今 手にある',
);
});
+160
View File
@@ -0,0 +1,160 @@
import type { SubtitleCue } from '../../types';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
// entrance/exit frames actually run past the authored timing.
const CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS = 1;
export interface ResolvedPrimarySubtitle {
text: string;
startTime: number;
endTime: number;
/** The canonical cues behind `text`, for consumers that record lines individually. */
cues: SubtitleCue[];
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
return {
start: cue.animationStartTime ?? cue.startTime,
end: cue.animationEndTime ?? cue.endTime,
};
}
function nearbyCanonicalCues(
cues: readonly SubtitleCue[] | null | undefined,
currentTimeSec: number,
): SubtitleCue[] {
return (cues ?? []).filter((cue) => {
if (cue.source !== 'canonical-ass') {
return false;
}
const span = animationSpan(cue);
return (
span.end >= currentTimeSec - CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS &&
span.start <= currentTimeSec + CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS
);
});
}
function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
}
/**
* mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
* while a generated animation plays every live line is a contiguous piece of the
* authored text. A line that is not -- concurrent dialogue during an insert song, or a
* fresh line starting just after the animation ended -- proves the live text is not this
* animation, and substituting the canonical line would swallow real dialogue.
*/
function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean {
const compactCues = cues.map((cue) => compactWhitespace(cue.text));
const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean);
return (
segments.length > 0 &&
segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment)))
);
}
export function resolveCanonicalPrimarySubtitle(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
if (!Number.isFinite(options.currentTimeSec)) {
return null;
}
// Consecutive karaoke lines overlap: one line's exit frames are still on screen while
// the next line's entrance frames appear. The fragment check therefore runs against
// every canonical cue whose animation envelope reaches the current time, while only
// the active (or single nearest) cue supplies the displayed text.
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
const active = nearby.filter(
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
);
const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean);
const selected =
active.length > 0
? active
: nearby
// Between authored spans, proximity alone can pick the wrong neighbor: the
// next line can sit closer while only the previous line's exit fragments are
// on screen. Only cues that explain at least one live line may be selected.
.filter((cue) => {
const cueText = compactWhitespace(cue.text);
return liveSegments.some((segment) => cueText.includes(segment));
})
.map((cue) => {
const span = animationSpan(cue);
const distance =
options.currentTimeSec < span.start
? span.start - options.currentTimeSec
: Math.max(0, options.currentTimeSec - span.end);
return { cue, distance };
})
.sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime)
.slice(0, 1)
.map(({ cue }) => cue);
if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
};
}
/**
* Live text with generated-animation fragment lines removed. Recording paths use this
* when full canonical substitution declined -- concurrent dialogue during an insert
* song: the dialogue is worth recording, the glyph fragments beside it are not. Returns
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
*/
export function stripCanonicalFragmentLines(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!Number.isFinite(options.currentTimeSec)) {
return options.liveText;
}
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
if (nearby.length === 0) {
return options.liveText;
}
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
const kept = options.liveText.split('\n').filter((line) => {
const compact = compactWhitespace(line);
return compact && !compactCues.some((cueText) => cueText.includes(compact));
});
return kept.length > 0 ? kept.join('\n') : options.liveText;
}
export function resolvePrimarySubtitleText(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!options.liveText.trim()) {
return options.liveText;
}
return (
resolveCanonicalPrimarySubtitle({
liveText: options.liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ?? options.liveText
);
}