mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
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:
@@ -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
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user