From faa3f588fbc37b3c1d55e3b3e96bbda39ab05ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 18:50:32 -0400 Subject: [PATCH] fix(runtime): don't restart non-loop media that has naturally ended (#1203) * fix(runtime): don't restart non-loop media that has naturally ended When a media element's authored data-duration exceeds the actual file length, el.ended becomes true at the file's natural end while the clip is still considered 'active' (timeSeconds < clip.end). The runtime was calling el.play() on the ended element every rAF tick, resetting currentTime to 0 and causing audible stutter for the overshoot duration. Fix: treat el.ended as inactive for non-loop clips. The element sits silently until the composition ends. el.ended resets to false on any seek, so scrubbing backward correctly resumes playback. Reproducer: bg-music WAV is 60s but data-duration='68.6' (composition duration). Last 8.6s: rapid play->clamp->end->play cycle at 60fps. * test(runtime): add seek-recovery contract test for el.ended guard Adds a third test case pinning the seek-recovery property called out in the PR body: a clip that went silent at t=62 (el.ended=true) should resume playing after a backward seek resets el.ended to false. --- packages/core/src/runtime/media.test.ts | 33 +++++++++++++++++++++++++ packages/core/src/runtime/media.ts | 9 ++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 3231e2691..77eae50e2 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -320,6 +320,39 @@ describe("syncRuntimeMedia", () => { expect(clip.el.pause).toHaveBeenCalled(); }); + it("does not restart a non-loop clip that has naturally ended before the clip's end time", () => { + // Reproduces: bg-music WAV is 60s but data-duration="68.6" (composition duration). + // At t=62 the file has ended; without this guard the runtime calls el.play() every + // rAF tick, resetting currentTime to 0 and causing audible stutter for 8.6s. + const clip = createMockClip({ start: 0, end: 68.6, loop: false }); + Object.defineProperty(clip.el, "paused", { value: true, writable: true }); + Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true }); + syncRuntimeMedia({ clips: [clip], timeSeconds: 62, playing: true, playbackRate: 1 }); + expect(clip.el.play).not.toHaveBeenCalled(); + }); + + it("does restart a loop clip that has naturally ended while still within its active window", () => { + const clip = createMockClip({ start: 0, end: 68.6, loop: true, sourceDuration: 60 }); + Object.defineProperty(clip.el, "paused", { value: true, writable: true }); + Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true }); + syncRuntimeMedia({ clips: [clip], timeSeconds: 62, playing: true, playbackRate: 1 }); + expect(clip.el.play).toHaveBeenCalled(); + }); + + it("resumes a previously-ended non-loop clip after a backward seek resets el.ended", () => { + // el.ended resets to false when the browser processes a seek (per WHATWG spec). + // This test pins the contract: silent at t=62 (ended), playable again at t=30 (seeked back). + const clip = createMockClip({ start: 0, end: 68.6, loop: false }); + Object.defineProperty(clip.el, "paused", { value: true, writable: true }); + Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true }); + syncRuntimeMedia({ clips: [clip], timeSeconds: 62, playing: true, playbackRate: 1 }); + expect(clip.el.play).not.toHaveBeenCalled(); + // Simulate backward seek: browser resets ended to false before committing new currentTime + Object.defineProperty(clip.el, "ended", { value: false, writable: true, configurable: true }); + syncRuntimeMedia({ clips: [clip], timeSeconds: 30, playing: true, playbackRate: 1 }); + expect(clip.el.play).toHaveBeenCalledTimes(1); + }); + it("sets volume when clip has volume", () => { const clip = createMockClip({ start: 0, end: 10, volume: 0.7 }); syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: false, playbackRate: 1 }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 9fe2fae1d..55658554b 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -159,8 +159,15 @@ export function syncRuntimeMedia(params: { const { el } = clip; if (!el.isConnected) continue; let relTime = (params.timeSeconds - clip.start) * clip.playbackRate + clip.mediaStart; + // An ended non-loop element has played its file to natural completion. + // Don't restart it — if the authored duration extends past the file's + // actual length, the element sits silently until the composition ends. + // (el.ended resets to false when the user scrubs back, so seeks work.) const isActive = - params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0; + params.timeSeconds >= clip.start && + params.timeSeconds < clip.end && + relTime >= 0 && + (!el.ended || clip.loop); if (isActive) { // Loop wrapping: when media reaches end, restart from mediaStart if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {