fix(subtitles): harden canonical cue recovery

- Avoid argument-count limits when reducing ASS event bounds
- Fall back from invalid canonical mining spans
- Share seek thresholds and reset timing at the exact boundary
This commit is contained in:
2026-08-17 23:30:01 -07:00
parent 39286c2c55
commit 7046a4451f
7 changed files with 40 additions and 24 deletions
+15 -10
View File
@@ -380,14 +380,25 @@ function matchingAssAnimationEvents(options: {
: []; : [];
} }
// Reductions rather than `Math.min(...events)`: one generated line can carry an
// unbounded number of events, and spreading them all as arguments risks the engine's
// argument-count limit.
function earliestStartTime(events: readonly AnnotatedSubtitleCue[], seed = Infinity): number {
return events.reduce((earliest, event) => Math.min(earliest, event.startTime), seed);
}
function latestEndTime(events: readonly AnnotatedSubtitleCue[], seed = -Infinity): number {
return events.reduce((latest, event) => Math.max(latest, event.endTime), seed);
}
function includeCanonicalBoundaryEvents(options: { function includeCanonicalBoundaryEvents(options: {
candidate: AnnotatedSubtitleCue; candidate: AnnotatedSubtitleCue;
group: AssEventGroupIndex; group: AssEventGroupIndex;
animationEvents: readonly AnnotatedSubtitleCue[]; animationEvents: readonly AnnotatedSubtitleCue[];
}): AnnotatedSubtitleCue[] { }): AnnotatedSubtitleCue[] {
const canonicalText = compactCueMatchText(options.candidate); const canonicalText = compactCueMatchText(options.candidate);
const startTime = Math.min(...options.animationEvents.map((event) => event.startTime)); const startTime = earliestStartTime(options.animationEvents);
const endTime = Math.max(...options.animationEvents.map((event) => event.endTime)); const endTime = latestEndTime(options.animationEvents);
return eventsOverlappingWindow( return eventsOverlappingWindow(
options.group, options.group,
startTime - CANONICAL_MATCH_MARGIN_SECONDS, startTime - CANONICAL_MATCH_MARGIN_SECONDS,
@@ -458,14 +469,8 @@ function recoverCanonicalAssEvents({
animationEvents, animationEvents,
}); });
const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])]; const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])];
const animationStartTime = Math.min( const animationStartTime = earliestStartTime(generatedEvents, candidate.startTime);
candidate.startTime, const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
...generatedEvents.map((event) => event.startTime),
);
const animationEndTime = Math.max(
candidate.endTime,
...generatedEvents.map((event) => event.endTime),
);
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime; const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime; const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
const recoveredCue: AnnotatedSubtitleCue = { const recoveredCue: AnnotatedSubtitleCue = {
+11 -2
View File
@@ -1825,12 +1825,21 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
currentTimeSec: Number(appState.mpvClient?.currentTimePos), currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues, cues: appState.activeParsedSubtitleCues,
}); });
if (!canonical) { // Same validity bar as the live capture path: an unusable canonical span must fall
// back rather than hand mining an empty line or an inverted range.
const canonicalText = canonical?.text.trim();
if (
!canonical ||
!canonicalText ||
!Number.isFinite(canonical.startTime) ||
!Number.isFinite(canonical.endTime) ||
canonical.endTime <= canonical.startTime
) {
return captureLiveSubtitleMiningContext(appState.mpvClient); return captureLiveSubtitleMiningContext(appState.mpvClient);
} }
return { return {
source: 'overlay', source: 'overlay',
text: canonical.text, text: canonicalText,
startTime: canonical.startTime, startTime: canonical.startTime,
endTime: canonical.endTime, endTime: canonical.endTime,
capturedAtMs: Date.now(), capturedAtMs: Date.now(),
+1 -7
View File
@@ -1,13 +1,7 @@
import type { createRefreshKnownWordCacheHandler } from './anki-actions'; import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions';
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0]; type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
type PrimarySubtitle = {
text: string;
startTime: number;
endTime: number;
};
export function createBuildUpdateLastCardFromClipboardMainDepsHandler<TAnki>(deps: { export function createBuildUpdateLastCardFromClipboardMainDepsHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki; getAnkiIntegration: () => TAnki;
readClipboardText: () => string; readClipboardText: () => string;
+1 -1
View File
@@ -2,7 +2,7 @@ type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>; refreshKnownWordCache: () => Promise<void>;
}; };
type PrimarySubtitle = { export type PrimarySubtitle = {
text: string; text: string;
startTime: number; startTime: number;
endTime: number; endTime: number;
+2 -1
View File
@@ -4,7 +4,8 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number; 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 { function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) { if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
@@ -486,6 +486,14 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3); 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', () => { test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
+2 -3
View File
@@ -1,5 +1,6 @@
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate'; import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
import { import {
resolveCanonicalPrimarySubtitle, resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText, resolvePrimarySubtitleText,
@@ -108,8 +109,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
// Bumped on track/media changes so an immersion record whose tokenization resolves // Bumped on track/media changes so an immersion record whose tokenization resolves
// after the change is dropped instead of landing in the next session. // after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0; let subtitleSessionEpoch = 0;
// Matches the seek threshold used by the time-pos handler in mpv-main-event-actions.
const BACKWARD_SEEK_TIMING_RESET_SECONDS = 2.5;
let lastTimePosForTimingReset: number | null = null; let lastTimePosForTimingReset: number | null = null;
const canonicalCueKey = (cue: SubtitleCue): string => const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`; `${cue.startTime}|${cue.endTime}|${cue.text}`;
@@ -326,7 +325,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
if ( if (
Number.isFinite(time) && Number.isFinite(time) &&
lastTimePosForTimingReset !== null && lastTimePosForTimingReset !== null &&
time < lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
) { ) {
recordedTimingCanonicalKeys.clear(); recordedTimingCanonicalKeys.clear();
} }