mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 00:15:27 -07:00
fix(subtitles): preserve lyric selection and deduplicate secondary text
- Seek sidebar selections past overlapping karaoke exit spans - Collapse duplicate long ASS lines and omit reconstructed sign grids
This commit is contained in:
@@ -52,6 +52,7 @@ export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime
|
||||
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||
export {
|
||||
resolveSanitizedSubtitleSeekCommand,
|
||||
subtitleCueListSeekTime,
|
||||
subtitleCueSeekTime,
|
||||
} from './subtitle-cue-navigation';
|
||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const MIN_FLATTENED_DUPLICATE_LENGTH = 16;
|
||||
const TERMINAL_SENTENCE_PUNCTUATION = /[.!?。!?…⋯]+$/gu;
|
||||
|
||||
/**
|
||||
* Identifies long lines that become duplicates when positioned ASS events are
|
||||
* flattened into the secondary subtitle bar. Short dialogue stays distinct.
|
||||
*/
|
||||
export function flattenedSecondarySubtitleLineIdentity(text: string): string | null {
|
||||
const identity = text
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/gu, '')
|
||||
.replace(TERMINAL_SENTENCE_PUNCTUATION, '');
|
||||
|
||||
return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveSanitizedSubtitleSeekCommand,
|
||||
subtitleCueListSeekTime,
|
||||
subtitleCueSeekTime,
|
||||
} from './subtitle-cue-navigation';
|
||||
|
||||
@@ -85,3 +86,23 @@ test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () =>
|
||||
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08);
|
||||
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03);
|
||||
});
|
||||
|
||||
test('sidebar cue selection clears an overlapping previous lyric', () => {
|
||||
const cues = [
|
||||
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
|
||||
{ startTime: 3, endTime: 5, text: 'selected lyric' },
|
||||
];
|
||||
|
||||
assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48);
|
||||
});
|
||||
|
||||
test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => {
|
||||
const cues = [
|
||||
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
|
||||
{ startTime: 3, endTime: 3.2, text: 'selected lyric' },
|
||||
];
|
||||
|
||||
const seekTime = subtitleCueListSeekTime(cues, cues[1]!);
|
||||
assert.ok(seekTime >= 3.19);
|
||||
assert.ok(seekTime < cues[1]!.endTime);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,36 @@ export function subtitleCueSeekTime(cue: SubtitleCue): number {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a stable point inside a selected cue. Karaoke lines can overlap while the
|
||||
* previous line animates out, so a sidebar selection should clear that overlap when
|
||||
* the selected cue has enough time remaining.
|
||||
*/
|
||||
export function subtitleCueListSeekTime(
|
||||
cues: readonly SubtitleCue[],
|
||||
selectedCue: SubtitleCue,
|
||||
): number {
|
||||
const groups = groupCueBoundaries(cues);
|
||||
const selectedGroupIndex = groups.findIndex(
|
||||
(group) =>
|
||||
selectedCue.startTime >= group.startTime &&
|
||||
selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS,
|
||||
);
|
||||
const previousGroupEndTime =
|
||||
selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined;
|
||||
if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) {
|
||||
return subtitleCueSeekTime(selectedCue);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
selectedCue.startTime,
|
||||
Math.min(
|
||||
selectedCue.endTime - CUE_END_GUARD_SECONDS,
|
||||
previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can
|
||||
* contain hundreds of subtitle events for one visible line, while the parsed list has
|
||||
|
||||
@@ -10,6 +10,7 @@ import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedu
|
||||
|
||||
export type AssCueLayout =
|
||||
| { kind: 'positioned'; sourceOrder: number; y: number }
|
||||
| { kind: 'fragment-grid'; sourceOrder: number }
|
||||
| { kind: 'source-order'; sourceOrder: number };
|
||||
|
||||
export interface SubtitleCue {
|
||||
@@ -210,6 +211,7 @@ const MIN_FRAGMENT_LINE_EVENTS = 8;
|
||||
const MIN_FRAGMENT_LINE_PARTS = 4;
|
||||
const MAX_FRAGMENT_MEDIAN_LENGTH = 4;
|
||||
const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2;
|
||||
const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48;
|
||||
|
||||
function parseAssTimestamp(raw: string): number | null {
|
||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||
@@ -385,6 +387,30 @@ interface AssFragmentPart {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function reconstructedAssFragmentLayout(
|
||||
parts: readonly AssFragmentPart[],
|
||||
owner: AnnotatedSubtitleCue,
|
||||
): AssCueLayout | undefined {
|
||||
let positionedPartCount = 0;
|
||||
let minimumY = Infinity;
|
||||
let maximumY = -Infinity;
|
||||
for (const part of parts) {
|
||||
const layout = part.cue.assLayout;
|
||||
if (layout?.kind !== 'positioned') continue;
|
||||
positionedPartCount += 1;
|
||||
minimumY = Math.min(minimumY, layout.y);
|
||||
maximumY = Math.max(maximumY, layout.y);
|
||||
}
|
||||
|
||||
if (
|
||||
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
|
||||
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
|
||||
) {
|
||||
return { kind: 'fragment-grid', sourceOrder: owner.order };
|
||||
}
|
||||
return owner.assLayout;
|
||||
}
|
||||
|
||||
interface AssFragmentTimingCluster {
|
||||
events: AnnotatedSubtitleCue[];
|
||||
minStartTime: number;
|
||||
@@ -515,6 +541,7 @@ function reconstructAssFragmentLine(
|
||||
source: 'reconstructed-ass',
|
||||
animationStartTime,
|
||||
animationEndTime,
|
||||
assLayout: reconstructedAssFragmentLayout(parts, owner),
|
||||
overrides: [],
|
||||
overrideSignature: '',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user