mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-26 12:15:26 -07:00
fix(subtitles): recover canonical lines from ASS animation (#207)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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手にある');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
'今 手にある',
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user