mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user