diff --git a/src/lib/exporter/timelineSegments.test.ts b/src/lib/exporter/timelineSegments.test.ts index cfd835032..5ac0e3b0c 100644 --- a/src/lib/exporter/timelineSegments.test.ts +++ b/src/lib/exporter/timelineSegments.test.ts @@ -100,6 +100,29 @@ describe("splitBySpeed", () => { ]); }); + it("keeps output disjoint when speed regions overlap", () => { + expect(splitBySpeed(full, [speed(1000, 5000, 2), speed(4000, 7000, 3)])).toEqual([ + { startSec: 0, endSec: 1, speed: 1 }, + { startSec: 1, endSec: 5, speed: 2 }, + { startSec: 5, endSec: 7, speed: 3 }, + { startSec: 7, endSec: 10, speed: 1 }, + ]); + }); + + // Not a variant of the test above: there the two regions merely overlap, here the + // second is fully swallowed by the first. main emitted + // `[0-1 x1, 1-8 x2, 3-5 x3, 5-10 x1]` — the third entry starts BEFORE the second + // ends, so the list stops being ascending and 5-8 ships twice. `decodeAll` walks + // these with a forward-only frame cursor, so that is a backwards seek, not just a + // duplicated stretch. Keep both cases. + it("ignores a later speed region fully covered by the earliest region", () => { + expect(splitBySpeed(full, [speed(1000, 8000, 2), speed(3000, 5000, 3)])).toEqual([ + { startSec: 0, endSec: 1, speed: 1 }, + { startSec: 1, endSec: 8, speed: 2 }, + { startSec: 8, endSec: 10, speed: 1 }, + ]); + }); + it("drops sub-segments narrower than the minimum width", () => { // A speed region ending a sliver before the segment end must not emit a 1x crumb. const result = splitBySpeed(full, [speed(0, 9_999.95, 2)]); diff --git a/src/lib/exporter/timelineSegments.ts b/src/lib/exporter/timelineSegments.ts index c429651dc..d366c4da6 100644 --- a/src/lib/exporter/timelineSegments.ts +++ b/src/lib/exporter/timelineSegments.ts @@ -54,8 +54,14 @@ export function computeKeepSegments( /** * Splits keep-segments by overlapping speed regions, annotating each sub-segment - * with its playback speed multiplier (defaults to 1×). Regions are assumed - * non-overlapping; when they do overlap the earliest-starting one wins. + * with its playback speed multiplier (defaults to 1×). + * + * Overlapping regions are handled rather than assumed away: the earliest-starting + * one keeps the stretch it already covers, and a later region contributes only the + * part past the cursor (nothing at all when it is fully covered). Output is always + * disjoint and ascending — `decodeAll` walks it with a forward-only frame cursor, + * so an overlap would otherwise duplicate source and seek backwards. Same rule as + * the native path's `speed_segments_for_window` in `crates/compositor/src/regions.rs`. */ export function splitBySpeed( segments: TimelineSegment[], @@ -80,8 +86,11 @@ export function splitBySpeed( for (const sr of overlapping) { const srStart = Math.max(sr.startMs / 1000, segment.startSec); const srEnd = Math.min(sr.endMs / 1000, segment.endSec); - if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); - result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); + if (srEnd <= cursor) continue; + const effectiveStart = Math.max(srStart, cursor); + if (cursor < effectiveStart) + result.push({ startSec: cursor, endSec: effectiveStart, speed: 1 }); + result.push({ startSec: effectiveStart, endSec: srEnd, speed: sr.speed }); cursor = srEnd; } if (cursor < segment.endSec)