fix(core): frame captures wait for a seeking video to land its frame (#4406)

* fix(core): frame captures wait for a seeking video to land its frame

A paused seek set video.currentTime and returned; the seek-completion barrier resolved while the
video was still seeking, so a capture right after the first seek on a fresh preview painted the
previous frame. The media sync now joins each seeking video to that barrier until seeked, error,
emptied or abort.

* style(core): format the seek barrier test
This commit is contained in:
Miguel Ángel
2026-09-24 07:07:59 -04:00
committed by GitHub
parent 115332f4f6
commit 261af22c16
2 changed files with 56 additions and 0 deletions
+38
View File
@@ -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<void>): Promise<boolean> {
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 });
+18
View File
@@ -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<void>((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);
}