fix(core): replay ended audio after backward seeks (#3383)

This commit is contained in:
James Russo
2026-08-20 20:51:30 -07:00
committed by GitHub
parent efc2e1964a
commit 477e09642b
2 changed files with 160 additions and 13 deletions
+120 -2
View File
@@ -326,8 +326,11 @@ describe("syncRuntimeMedia", () => {
});
}
function createMockClip(overrides?: Partial<RuntimeMediaClip>): RuntimeMediaClip {
const el = document.createElement("video") as HTMLVideoElement;
function createMockClip(
overrides?: Partial<RuntimeMediaClip>,
mediaType: "audio" | "video" = "video",
): RuntimeMediaClip {
const el = document.createElement(mediaType);
document.body.appendChild(el);
Object.defineProperty(el, "paused", { value: true, writable: true, configurable: true });
el.play = vi.fn(() => Promise.resolve());
@@ -744,6 +747,97 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.play).toHaveBeenCalledTimes(1);
});
it("seeks an ended audio clip backward into its playable source", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", {
value: 0.55,
writable: true,
configurable: true,
});
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.12, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0);
expect(clip.el.play).toHaveBeenCalledTimes(1);
});
it("does not restart ended audio past its source duration", () => {
const clip = createMockClip(
{ start: 3.12, end: 4.12, duration: 1, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", {
value: 0.55,
writable: true,
configurable: true,
});
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.8, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0.55);
expect(clip.el.play).not.toHaveBeenCalled();
});
it("does not replay an audio tail when native EOF leads the runtime clock", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "paused", { value: false, writable: true });
Object.defineProperty(clip.el, "currentTime", { value: 0.53, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.65, playing: true, playbackRate: 1 });
clip.el.currentTime = 0.55;
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 3.66,
playing: true,
playbackRate: 1,
forceSync: true,
});
syncRuntimeMedia({
clips: [clip],
timeSeconds: 3.665,
playing: true,
playbackRate: 1,
forceSync: true,
});
expect(clip.el.currentTime).toBe(0.55);
expect(clip.el.play).not.toHaveBeenCalled();
});
it("rewinds ended audio after a backward seek within the active clip", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", { value: 0.5, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.62, playing: true, playbackRate: 1 });
vi.mocked(clip.el.play).mockClear();
clip.el.currentTime = 0.55;
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 3.12,
playing: true,
playbackRate: 1,
forceSync: true,
});
expect(clip.el.currentTime).toBe(0);
expect(clip.el.play).toHaveBeenCalledTimes(1);
});
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 });
@@ -1024,6 +1118,30 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.currentTime).toBe(3);
});
it("rewinds stale short audio on its first tick after re-entry", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", { value: 0.49, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.12, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0);
});
it("does not force cold audio forward on its first active tick", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.61, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0);
});
it("sets per-element playbackRate × global rate", () => {
const clip = createMockClip({ start: 0, end: 10, playbackRate: 0.5 });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 2 });
+40 -11
View File
@@ -115,6 +115,10 @@ export function refreshRuntimeMediaCache(params?: {
// a scrub (where offset jumps in one tick). Cleared when a clip becomes
// inactive so the next activation gets a hard resync on its first tick.
const lastOffset = new WeakMap<HTMLMediaElement, number>();
// Desired source time from the previous active tick. Unlike `forceSync`, which
// also covers play/pause and rate changes, a decrease here identifies an actual
// backward transport seek within an audio clip.
const lastRelativeTime = new WeakMap<HTMLMediaElement, number>();
const strictDriftSamples = new WeakMap<HTMLMediaElement, number>();
@@ -167,6 +171,7 @@ function clampVolume(volume: number): number {
*/
export function evictMediaSyncState(el: HTMLMediaElement): void {
lastOffset.delete(el);
lastRelativeTime.delete(el);
strictDriftSamples.delete(el);
seekLoadRetried.delete(el);
lastRuntimeAppliedVolume.delete(el);
@@ -176,6 +181,7 @@ export function evictMediaSyncState(el: HTMLMediaElement): void {
export function hasMediaSyncStateForTest(el: HTMLMediaElement): boolean {
return (
lastOffset.has(el) ||
lastRelativeTime.has(el) ||
strictDriftSamples.has(el) ||
seekLoadRetried.has(el) ||
lastRuntimeAppliedVolume.has(el)
@@ -234,21 +240,29 @@ export function syncRuntimeMedia(params: {
if (isHeldVideoTail && clip.sourceDuration != null) {
relTime = clip.sourceDuration;
}
const canSeekEndedVideoBackward =
isNonLoopVideo &&
const previousRelativeTime = lastRelativeTime.get(el);
const audioReenteredAfterBackwardSeek =
el.tagName === "AUDIO" &&
(previousRelativeTime === undefined || relTime < previousRelativeTime - 0.04);
const canSeekEndedMediaBackward =
!clip.loop &&
clip.sourceDuration != null &&
relTime >= clip.mediaStart &&
relTime < clip.sourceDuration;
// Audio that ended naturally stays silent. A non-loop video remains an
// active visual through its authored window: tail seeks clamp to the final
// frame, and backward seeks can re-enter playable source without depending
// on the browser having reset `ended` first.
relTime < clip.sourceDuration &&
(isNonLoopVideo || audioReenteredAfterBackwardSeek);
// Ended media can re-enter playable source after a backward timeline seek
// without depending on the browser having reset `ended` first. Audio needs
// a fresh activation or a measured backward transport seek so ordinary EOF
// and non-seek force-sync transitions cannot replay its tail. A non-loop
// video additionally remains an active visual through
// its authored window, with tail seeks clamped to the final frame.
const isActive =
params.timeSeconds >= clip.start &&
params.timeSeconds < clip.end &&
relTime >= 0 &&
(!el.ended || clip.loop || isHeldVideoTail || canSeekEndedVideoBackward);
(!el.ended || clip.loop || isHeldVideoTail || canSeekEndedMediaBackward);
if (isActive) {
lastRelativeTime.set(el, relTime);
// Loop wrapping: when media reaches end, restart from mediaStart
if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {
const loopLength = clip.sourceDuration - clip.mediaStart;
@@ -365,9 +379,17 @@ export function syncRuntimeMedia(params: {
const firstTickOfClip = prevOffset === undefined;
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
const catastrophicDrift = drift > 3;
// A short audio clip can leave its native element paused just before EOF.
// When the timeline re-enters the clip, rewind stale forward state at the
// strict threshold; do not force cold audio forward while it buffers.
const staleAudioOnFirstTick =
el.tagName === "AUDIO" &&
firstTickOfClip &&
currentElTime - relTime > STRICT_DRIFT_THRESHOLD;
const hardSync =
(isHeldVideoTail && drift > 0.001) ||
(el.ended && canSeekEndedVideoBackward && drift > 0.001) ||
(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
@@ -465,9 +487,16 @@ export function syncRuntimeMedia(params: {
}
continue;
}
// Clip left its active window — drop the offset baseline so the next
// activation (e.g. re-entering a sub-composition) gets a hard resync.
// Drop drift state when the element is not playable. If native audio EOF
// arrived slightly before the authored boundary, preserve only its desired
// source time while the transport remains inside that boundary. Otherwise
// the next poll would mistake the cleared baseline for a fresh activation
// and replay the tail. A real backward seek still decreases relTime, and a
// true outside-window transition clears every baseline as before.
const remainsInsideAuthoredWindow =
params.timeSeconds >= clip.start && params.timeSeconds < clip.end;
evictMediaSyncState(el);
if (remainsInsideAuthoredWindow) lastRelativeTime.set(el, relTime);
if (!el.paused) el.pause();
}
}