perf(subtitle-prefetch): sweep overlap spans instead of rescanning cues

- Track active cues with a single sorted start/end sweep across boundaries
- Join simultaneous cues in cue-list order to match mpv
- Skip empty or inverted cues
- Add test for multi-cue overlap spans with unsorted cue input
This commit is contained in:
2026-09-29 22:17:10 -07:00
parent e69cd5ea06
commit 42f86b0748
2 changed files with 42 additions and 5 deletions
@@ -22,3 +22,17 @@ test('back-to-back cues yield no overlap lines', () => {
assert.deepEqual(buildOverlapPrefetchCues(cues), []);
});
test('each overlap span joins only the cues active across it, in cue-list order', () => {
const cues: SubtitleCue[] = [
{ startTime: 3, endTime: 8, text: 'C' },
{ startTime: 0, endTime: 6, text: 'A' },
{ startTime: 2, endTime: 4, text: 'B' },
];
assert.deepEqual(buildOverlapPrefetchCues(cues), [
{ startTime: 2, endTime: 3, text: 'A\n\nB' },
{ startTime: 3, endTime: 4, text: 'C\n\nA\n\nB' },
{ startTime: 4, endTime: 6, text: 'C\n\nA' },
]);
});
+28 -5
View File
@@ -13,20 +13,43 @@ export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): Subtitle
const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort(
(a, b) => a - b,
);
const indices = cues.map((_, index) => index);
const byStart = [...indices].sort((a, b) => cues[a]!.startTime - cues[b]!.startTime);
const byEnd = [...indices].sort((a, b) => cues[a]!.endTime - cues[b]!.endTime);
const singleTexts = new Set(cues.map((cue) => cue.text));
const seen = new Set<string>();
const overlapCues: SubtitleCue[] = [];
// Sweep the boundaries once, keeping the cues active over [startTime, endTime). Every
// cue edge is a boundary, so a cue is active for a whole interval or not at all.
const active = new Set<number>();
let nextStart = 0;
let nextEnd = 0;
for (let i = 0; i + 1 < boundaries.length; i += 1) {
const startTime = boundaries[i]!;
const endTime = boundaries[i + 1]!;
const midpoint = (startTime + endTime) / 2;
const active = cues.filter((cue) => cue.startTime <= midpoint && cue.endTime > midpoint);
if (active.length < 2) continue;
for (; nextEnd < byEnd.length && cues[byEnd[nextEnd]!]!.endTime <= startTime; nextEnd += 1) {
active.delete(byEnd[nextEnd]!);
}
for (
;
nextStart < byStart.length && cues[byStart[nextStart]!]!.startTime <= startTime;
nextStart += 1
) {
const index = byStart[nextStart]!;
// Empty or inverted cues are never on screen.
if (cues[index]!.endTime > startTime) active.add(index);
}
if (active.size < 2) continue;
// mpv joins simultaneous cues in cue-list order.
const liveText = [...active]
.sort((a, b) => a - b)
.map((index) => cues[index]!.text)
.join('\n');
const text = resolvePrimarySubtitleText({
liveText: active.map((cue) => cue.text).join('\n'),
currentTimeSec: midpoint,
liveText,
currentTimeSec: (startTime + endTime) / 2,
cues,
});
if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue;