From 42f86b0748c949b19c6eb26e60757585ae20f531 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 29 Sep 2026 22:17:10 -0700 Subject: [PATCH] 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 --- .../subtitle-prefetch-overlaps.test.ts | 14 ++++++++ .../runtime/subtitle-prefetch-overlaps.ts | 33 ++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/subtitle-prefetch-overlaps.test.ts b/src/main/runtime/subtitle-prefetch-overlaps.test.ts index d992df86..adc86525 100644 --- a/src/main/runtime/subtitle-prefetch-overlaps.test.ts +++ b/src/main/runtime/subtitle-prefetch-overlaps.test.ts @@ -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' }, + ]); +}); diff --git a/src/main/runtime/subtitle-prefetch-overlaps.ts b/src/main/runtime/subtitle-prefetch-overlaps.ts index a6254dca..0b5bc03b 100644 --- a/src/main/runtime/subtitle-prefetch-overlaps.ts +++ b/src/main/runtime/subtitle-prefetch-overlaps.ts @@ -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(); 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(); + 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;