mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user