diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 68c1591be0..1b22ff2663 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -1628,6 +1628,25 @@ describe("syncRuntimeMedia", () => { expect(clip.el.currentTime).toBe(5); }); + // A seek while playing pauses and syncs in one pass, before the video element has paused. + it("a seek that pauses mid-playback lands a lagging playing video on the new time", () => { + const clip = createMockClip({ start: 3.85, end: 5.6, duration: 1.75 }); + Object.defineProperty(clip.el, "paused", { value: false, writable: true }); + Object.defineProperty(clip.el, "currentTime", { value: 0.031, writable: true }); + syncRuntimeMedia({ clips: [clip], timeSeconds: 4.109, playing: true, playbackRate: 1 }); + expect(clip.el.currentTime).toBe(0.031); + + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 4.4, + playing: false, + playbackRate: 1, + forceSync: true, + }); + expect(clip.el.currentTime).toBeCloseTo(0.55, 5); + expect(clip.el.pause).toHaveBeenCalled(); + }); + it("mutes when either outputMuted OR userMuted is true (OR invariant)", () => { // Explicit validation of the combined-flag contract: setting one to // false while the other is true must keep the element muted. diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 587eab1e4f..ff8e1277ea 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -461,13 +461,12 @@ export function syncRuntimeMedia(params: { (el.ended && canSeekEndedMediaBackward && drift > 0.001) || staleAudioOnFirstTick || (drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift)); - // Playing video elements use the browser's native decoder pipeline for - // timing. Seeking a playing video resets the decoder, causing a ~150ms - // freeze while it re-buffers — during which the monotonic clock advances, - // creating a perpetual seek→freeze→drift→seek stutter loop. Skip strict - // and force sync for playing videos; only hard sync (>0.5s) warrants - // the decoder-reset cost. - const isPlayingVideo = el.tagName === "VIDEO" && !el.paused; + // Playing videos use the browser's decoder for timing. Seeking one resets the decoder: a + // ~150ms freeze while it re-buffers, as the monotonic clock advances, which loops into a + // seek→freeze→drift→seek stutter. So a playing video skips strict and force sync; only hard + // sync (>0.5s) warrants the decoder-reset cost. A paused transport pauses this video below, + // so a seek that pauses mid-playback still lands it. + const isPlayingVideo = el.tagName === "VIDEO" && !el.paused && params.playing; // Only apply strict sync when offset has stabilized (not growing). // During initial buffering, offset grows ~16ms/tick as the timeline // advances while media stays at 0. Accumulated drift from pause/play