mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-08 17:16:18 -07:00
fix(mining): copy multi-line subtitles backward from current line (#231)
This commit is contained in:
@@ -244,6 +244,35 @@ test('handleMultiCopyDigit copies available history and reports truncation', ()
|
||||
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
|
||||
});
|
||||
|
||||
test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => {
|
||||
const copied: string[] = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('A', 1, 2);
|
||||
tracker.recordSubtitle('B', 3, 4);
|
||||
tracker.recordSubtitle('C', 5, 6);
|
||||
tracker.recordSubtitle('B', 3, 4);
|
||||
|
||||
const deps = {
|
||||
subtitleTimingTracker: tracker,
|
||||
writeClipboardText: (text: string) => copied.push(text),
|
||||
showMpvOsd: () => {},
|
||||
};
|
||||
|
||||
handleMultiCopyDigit(1, deps);
|
||||
handleMultiCopyDigit(2, deps);
|
||||
|
||||
assert.deepEqual(copied, ['B', 'A\n\nB']);
|
||||
assert.deepEqual(tracker.getRecentEntries(2), [
|
||||
{ displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined },
|
||||
{ displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
@@ -344,6 +373,22 @@ test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle timing history preserves adjacent repeated text with distinct timings', () => {
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('same', 1, 2);
|
||||
tracker.recordSubtitle('same', 3, 4);
|
||||
|
||||
assert.deepEqual(tracker.getRecentEntries(2), [
|
||||
{ displayText: 'same', startTime: 1, endTime: 2, secondaryText: undefined },
|
||||
{ displayText: 'same', startTime: 3, endTime: 4, secondaryText: undefined },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
@@ -503,14 +503,21 @@ 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);
|
||||
// Jumping back to a brief previous line moves time-pos by less than the general
|
||||
// seek threshold. It is still a backward seek, so the revisited line records
|
||||
// again; otherwise multi-line copy would keep treating the later line as current.
|
||||
handlers.onTimePosUpdate?.(3.9);
|
||||
handlers.onTimePosUpdate?.(2.9);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
|
||||
// Tiny time-pos jitter is not a seek and must not re-record the line.
|
||||
handlers.onTimePosUpdate?.(3.0);
|
||||
handlers.onTimePosUpdate?.(2.9);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
assert.equal(timing.length, 5);
|
||||
|
||||
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
|
||||
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
|
||||
assert.equal(immersion.length, 3);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,
|
||||
@@ -115,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
// after the change is dropped instead of landing in the next session.
|
||||
let subtitleSessionEpoch = 0;
|
||||
let lastTimePosForTimingReset: number | null = null;
|
||||
// Small margin so time-pos jitter is not mistaken for a backward seek.
|
||||
const BACKWARD_SEEK_TIMING_RESET_SECONDS = 0.25;
|
||||
const canonicalCueKey = (cue: SubtitleCue): string =>
|
||||
`${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||
const resetSubtitleDeduplication = (): void => {
|
||||
@@ -344,13 +345,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||
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.
|
||||
// Timing history is a viewing log: after any backward seek, a rewatched canonical
|
||||
// line should enter it again so multi-line copy treats it as the current line.
|
||||
// Playback never moves time-pos backward on its own, so even a short jump to a
|
||||
// brief previous line counts. 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
|
||||
time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS
|
||||
) {
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
}
|
||||
|
||||
@@ -67,8 +67,13 @@ export class SubtitleTimingTracker {
|
||||
|
||||
// Check for duplicate of most recent entry (deduplicate adjacent repeats)
|
||||
const lastEntry = this.history[this.history.length - 1];
|
||||
if (lastEntry && lastEntry.timingKey === timingKey) {
|
||||
// Update timing to most recent occurrence
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.timingKey === timingKey &&
|
||||
lastEntry.startTime === startTime &&
|
||||
lastEntry.endTime === endTime
|
||||
) {
|
||||
// Refresh metadata for repeated notifications of the same subtitle event.
|
||||
lastEntry.startTime = startTime;
|
||||
lastEntry.endTime = endTime;
|
||||
lastEntry.secondaryText = displaySecondaryText;
|
||||
@@ -107,28 +112,20 @@ export class SubtitleTimingTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks in chronological order.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Get recent subtitle blocks in timeline order.
|
||||
* Returns up to `count` known subtitle events ending at the current event.
|
||||
* Blocks preserve internal line breaks and are joined with blank lines.
|
||||
*/
|
||||
getRecentBlocks(count: number): string[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
||||
return this.getRecentTimelineEntries(count).map((entry) => entry.displayText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks with their original event timings.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Get recent subtitle blocks with their original event timings in timeline order.
|
||||
* Returns up to `count` known subtitle events ending at the current event.
|
||||
*/
|
||||
getRecentEntries(count: number): SubtitleTimingBlock[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => ({
|
||||
return this.getRecentTimelineEntries(count).map((entry) => ({
|
||||
displayText: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
@@ -144,6 +141,43 @@ export class SubtitleTimingTracker {
|
||||
return lastEntry ? lastEntry.displayText : null;
|
||||
}
|
||||
|
||||
private getRecentTimelineEntries(count: number): HistoryEntry[] {
|
||||
if (count <= 0) return [];
|
||||
|
||||
const currentEntry = this.history[this.history.length - 1];
|
||||
if (!currentEntry) return [];
|
||||
|
||||
const timelineEntries: HistoryEntry[] = [];
|
||||
for (const entry of this.history) {
|
||||
const existingIndex = timelineEntries.findIndex((candidate) =>
|
||||
this.isSameSubtitleEvent(candidate, entry),
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
timelineEntries.push(entry);
|
||||
} else {
|
||||
timelineEntries[existingIndex] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
timelineEntries.sort(
|
||||
(left, right) => left.startTime - right.startTime || left.endTime - right.endTime,
|
||||
);
|
||||
const currentIndex = timelineEntries.findIndex((entry) =>
|
||||
this.isSameSubtitleEvent(entry, currentEntry),
|
||||
);
|
||||
if (currentIndex === -1) return [];
|
||||
|
||||
return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
|
||||
}
|
||||
|
||||
private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean {
|
||||
return (
|
||||
left.timingKey === right.timingKey &&
|
||||
left.startTime === right.startTime &&
|
||||
left.endTime === right.endTime
|
||||
);
|
||||
}
|
||||
|
||||
private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
|
||||
let bestMatch: TimingEntry | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
Reference in New Issue
Block a user