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.
This commit is contained in:
Miguel Ángel
2026-06-04 18:50:32 -04:00
committed by GitHub
parent 9679503158
commit faa3f588fb
2 changed files with 41 additions and 1 deletions
+33
View File
@@ -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 });
+8 -1
View File
@@ -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) {