From 7046a4451fd422bac7101a79fa2b31d0b30d72cd Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 17 Aug 2026 23:30:01 -0700 Subject: [PATCH] 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 --- src/core/services/subtitle-cue-parser.ts | 25 +++++++++++-------- src/main.ts | 13 ++++++++-- src/main/runtime/anki-actions-main-deps.ts | 8 +----- src/main/runtime/anki-actions.ts | 2 +- src/main/runtime/mpv-main-event-actions.ts | 3 ++- .../runtime/mpv-main-event-main-deps.test.ts | 8 ++++++ src/main/runtime/mpv-main-event-main-deps.ts | 5 ++-- 7 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 37673711..99c970f3 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -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: { candidate: AnnotatedSubtitleCue; group: AssEventGroupIndex; animationEvents: readonly AnnotatedSubtitleCue[]; }): AnnotatedSubtitleCue[] { const canonicalText = compactCueMatchText(options.candidate); - const startTime = Math.min(...options.animationEvents.map((event) => event.startTime)); - const endTime = Math.max(...options.animationEvents.map((event) => event.endTime)); + const startTime = earliestStartTime(options.animationEvents); + const endTime = latestEndTime(options.animationEvents); return eventsOverlappingWindow( options.group, startTime - CANONICAL_MATCH_MARGIN_SECONDS, @@ -458,14 +469,8 @@ function recoverCanonicalAssEvents({ animationEvents, }); const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])]; - const animationStartTime = Math.min( - candidate.startTime, - ...generatedEvents.map((event) => event.startTime), - ); - const animationEndTime = Math.max( - candidate.endTime, - ...generatedEvents.map((event) => event.endTime), - ); + const animationStartTime = earliestStartTime(generatedEvents, candidate.startTime); + const animationEndTime = latestEndTime(generatedEvents, candidate.endTime); const startTime = kind === 'comment' ? candidate.startTime : animationStartTime; const endTime = kind === 'comment' ? candidate.endTime : animationEndTime; const recoveredCue: AnnotatedSubtitleCue = { diff --git a/src/main.ts b/src/main.ts index 29fb51ad..82232b43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1825,12 +1825,21 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n currentTimeSec: Number(appState.mpvClient?.currentTimePos), 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 { source: 'overlay', - text: canonical.text, + text: canonicalText, startTime: canonical.startTime, endTime: canonical.endTime, capturedAtMs: Date.now(), diff --git a/src/main/runtime/anki-actions-main-deps.ts b/src/main/runtime/anki-actions-main-deps.ts index dc916daa..5bc8c35b 100644 --- a/src/main/runtime/anki-actions-main-deps.ts +++ b/src/main/runtime/anki-actions-main-deps.ts @@ -1,13 +1,7 @@ -import type { createRefreshKnownWordCacheHandler } from './anki-actions'; +import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions'; type RefreshKnownWordCacheMainDeps = Parameters[0]; -type PrimarySubtitle = { - text: string; - startTime: number; - endTime: number; -}; - export function createBuildUpdateLastCardFromClipboardMainDepsHandler(deps: { getAnkiIntegration: () => TAnki; readClipboardText: () => string; diff --git a/src/main/runtime/anki-actions.ts b/src/main/runtime/anki-actions.ts index 7d440d13..2c5c18c2 100644 --- a/src/main/runtime/anki-actions.ts +++ b/src/main/runtime/anki-actions.ts @@ -2,7 +2,7 @@ type AnkiIntegrationLike = { refreshKnownWordCache: () => Promise; }; -type PrimarySubtitle = { +export type PrimarySubtitle = { text: string; startTime: number; endTime: number; diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 2c3cd98b..3e304a7e 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -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)) { diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 199d5121..00addc98 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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.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', () => { diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 30428b13..15260dbe 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -1,5 +1,6 @@ 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, @@ -108,8 +109,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { // 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; - // 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; const canonicalCueKey = (cue: SubtitleCue): string => `${cue.startTime}|${cue.endTime}|${cue.text}`; @@ -326,7 +325,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { if ( Number.isFinite(time) && lastTimePosForTimingReset !== null && - time < lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS + time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS ) { recordedTimingCanonicalKeys.clear(); }