diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index cf04da95cc..68c1591be0 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -8,6 +8,7 @@ import { } from "./media"; import type { RuntimeMediaClip } from "./media"; import { resolveNaturalMediaTimelineDuration } from "./playbackRate"; +import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch"; import { sourceTimeAt } from "../speedRamp"; import type { HfAutomationLane } from "../audioAutomation"; @@ -704,6 +705,43 @@ describe("syncRuntimeMedia", () => { }); }); + describe("video seek completion", () => { + afterEach(() => resetSeekDispatchState()); + + function seekColdVideo(): RuntimeMediaClip { + const clip = createMockClip({ start: 7.1, end: 18.24 }); + Object.defineProperty(clip.el, "seeking", { value: true, configurable: true }); + syncRuntimeMedia({ clips: [clip], timeSeconds: 12, playing: false, playbackRate: 1 }); + return clip; + } + + async function barrierSettled(barrier: Promise): Promise { + let settled = false; + void barrier.then(() => (settled = true)); + await new Promise((resolve) => setTimeout(resolve, 0)); + return settled; + } + + it("holds the seek-completion barrier until a seeking video lands its frame", async () => { + const clip = seekColdVideo(); + expect(clip.el.currentTime).toBeCloseTo(4.9); + const barrier = waitForSeekCompletion(); + expect(await barrierSettled(barrier)).toBe(false); + clip.el.dispatchEvent(new Event("seeked")); + expect(await barrierSettled(barrier)).toBe(true); + }); + + it.each(["error", "emptied", "abort"])( + "releases the barrier when the seek ends in %s", + async (type) => { + const clip = seekColdVideo(); + const barrier = waitForSeekCompletion(); + clip.el.dispatchEvent(new Event(type)); + expect(await barrierSettled(barrier)).toBe(true); + }, + ); + }); + describe("play() storm guard (unplayable elements)", () => { it("does not play() an element with a media error", () => { const clip = createMockClip({ start: 0, end: 10 }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 77e0bb611b..587eab1e4f 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -8,6 +8,7 @@ import { rateAt, sourceTimeAt, timeAtSourceTime, type RateSpec } from "../speedR import { clampAudioGain } from "../audioGain.js"; import { isMemberGroupHidden } from "../audioGroups.js"; import { findInjectedRenderFrame } from "./renderFrameSibling.js"; +import { registerSeekCompletion } from "./adapters/seek-dispatch.js"; export { readElementPlaybackRate, readElementRateSpec, @@ -18,6 +19,22 @@ export function readElementPlaybackStart(el: Element): number { return readMediaStart(el); } +const SEEK_END_EVENTS = ["seeked", "error", "emptied", "abort"] as const; + +// A seeking video still paints its previous frame; frame captures wait on the seek barrier until it lands. +function holdSeekBarrierUntilVideoLands(el: HTMLMediaElement): void { + if (el.tagName !== "VIDEO" || !el.seeking) return; + registerSeekCompletion( + new Promise((resolve) => { + const done = () => { + for (const type of SEEK_END_EVENTS) el.removeEventListener(type, done); + resolve(); + }; + for (const type of SEEK_END_EVENTS) el.addEventListener(type, done); + }), + ); +} + /** * Resolve a media element's timeline window without conflating a video's * authored display slot with the amount of source left to decode. @@ -502,6 +519,7 @@ export function syncRuntimeMedia(params: { swallow("runtime.media.site3", err); } } + holdSeekBarrierUntilVideoLands(el); } playRequested.delete(el); }