mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 03:36:52 +00:00
fix(core): preview fetches only the clips due next, and drops passed clips still downloading (#6160)
* fix(core): preview fetches only the clips due next, and drops passed clips still downloading A fast-cut film started a fetch per clip inside the look-ahead and let passed clips finish. * fix(core): a passed clip stays released for the rest of the play It was refetched each pass inside the look-back window.
This commit is contained in:
@@ -455,6 +455,59 @@ describe("runtime entry", () => {
|
||||
expect(loadsOf(later)).toEqual([beforeJump]);
|
||||
});
|
||||
|
||||
it("fetches the clips due soon three at a time, earliest first, so the clip due never queues behind them", async () => {
|
||||
spyLoad();
|
||||
servePreview();
|
||||
const [now, ...due] = videos(mountRoot(), "0", "1.5", "1.1", "1.4", "1.2", "1.3");
|
||||
|
||||
await evaluateRuntime();
|
||||
expect(armed(now, ...due)).toEqual([true, true, true, true, false, false]);
|
||||
expect(loadsOf(...due)).toEqual([1, 1, 1, 0, 0]);
|
||||
due[0]!.dispatchEvent(new Event("suspend"));
|
||||
expect(armed(...due)).toEqual([true, true, true, false, false]);
|
||||
Object.defineProperty(due[0]!, "readyState", { value: 3 });
|
||||
due[0]!.dispatchEvent(new Event("canplay"));
|
||||
expect(armed(...due)).toEqual([true, true, true, true, false]);
|
||||
due[1]!.dispatchEvent(new Event("error"));
|
||||
expect(armed(...due)).toEqual([true, true, true, true, true]);
|
||||
});
|
||||
|
||||
it("frees a clip the playhead has passed while it is still downloading, and keeps one that finished", async () => {
|
||||
spyLoad();
|
||||
servePreview();
|
||||
stubPlayback();
|
||||
const [slow, done] = videos(mountRoot(), "0", "0.5");
|
||||
Object.defineProperty(slow, "networkState", { value: 2 });
|
||||
Object.defineProperty(done, "networkState", { value: 1 });
|
||||
Object.defineProperty(done, "readyState", { value: 4 });
|
||||
|
||||
await evaluateRuntime();
|
||||
window.__player?.play();
|
||||
expect(armed(slow, done)).toEqual([true, true]);
|
||||
window.__player?.seek(2.6, { keepPlaying: true });
|
||||
expect(armed(slow, done)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("keeps a passed clip released for the rest of the play instead of refetching it each pass", async () => {
|
||||
spyLoad();
|
||||
servePreview();
|
||||
stubPlayback();
|
||||
const [slow, next] = videos(mountRoot(), "0", "2");
|
||||
// A fetch is running whenever preload asks for one.
|
||||
Object.defineProperty(slow, "networkState", { get: () => (slow.preload === "auto" ? 2 : 1) });
|
||||
|
||||
await evaluateRuntime();
|
||||
window.__player?.play();
|
||||
window.__player?.seek(2.05, { keepPlaying: true });
|
||||
expect(armed(slow, next)).toEqual([false, true]);
|
||||
const loadsAfterRelease = loadsOf(slow)[0];
|
||||
for (let t = 2.1; t <= 4.0; t += 0.05) {
|
||||
window.__player?.seek(t, { keepPlaying: true });
|
||||
expect(slow.preload).toBe("none");
|
||||
}
|
||||
expect(loadsOf(slow)[0]).toBe(loadsAfterRelease);
|
||||
});
|
||||
|
||||
// jsdom's media elements cannot play; the runtime only needs play() to settle.
|
||||
const stubPlayback = () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
|
||||
|
||||
@@ -2860,6 +2860,39 @@ export function initSandboxRuntimeModular(): void {
|
||||
const armedFrom = new WeakSet<HTMLMediaElement>();
|
||||
// An untracked clip (NaN) shares a track with nothing, as in the timeline payload.
|
||||
const hostOf = (el: Element) => el.parentElement?.closest("[data-composition-id]");
|
||||
// Clips due soon fetch a few at a time, earliest first: a fast cut sequence has dozens inside the
|
||||
// look-ahead, and fetching them all queues the clip that is due behind clips that are not.
|
||||
const PREFETCH_SLOTS = 3;
|
||||
const prefetching = new Set<HTMLMediaElement>();
|
||||
const waitingForSlot = new Set<HTMLMediaElement>();
|
||||
const PREFETCH_SETTLED_EVENTS = ["canplay", "suspend", "error"];
|
||||
const endPrefetch = (el: HTMLMediaElement) => {
|
||||
for (const type of PREFETCH_SETTLED_EVENTS) el.removeEventListener(type, onPrefetchSettled);
|
||||
return prefetching.delete(el);
|
||||
};
|
||||
const onPrefetchSettled = (event: Event) => {
|
||||
const el = event.currentTarget as HTMLMediaElement;
|
||||
// A load() queues a suspend of the stopped fetch before the new one starts; only a playable clip is done.
|
||||
if (event.type !== "error" && el.readyState < 3) return;
|
||||
endPrefetch(el);
|
||||
fillPrefetchSlots();
|
||||
};
|
||||
const fillPrefetchSlots = () => {
|
||||
const earliestFirst = Array.from(waitingForSlot)
|
||||
.filter((el) => el.isConnected)
|
||||
.sort((a, b) => resolveAbsoluteMediaStartSeconds(a) - resolveAbsoluteMediaStartSeconds(b));
|
||||
for (const el of earliestFirst) {
|
||||
if (prefetching.size >= PREFETCH_SLOTS) break;
|
||||
waitingForSlot.delete(el);
|
||||
syncTimedElementVisibility(state.currentTime, [el]);
|
||||
}
|
||||
};
|
||||
const startPrefetch = (el: HTMLMediaElement) => {
|
||||
preloadMedia(el);
|
||||
if (prefetching.has(el) || el.readyState >= 3) return;
|
||||
prefetching.add(el);
|
||||
for (const type of PREFETCH_SETTLED_EVENTS) el.addEventListener(type, onPrefetchSettled);
|
||||
};
|
||||
const armNextOnTrack = (el: HTMLMediaElement) => {
|
||||
const track = parseAuthoredTrack(el, Number.NaN);
|
||||
const clips = buildRuntimeMediaCache(
|
||||
@@ -2881,10 +2914,17 @@ export function initSandboxRuntimeModular(): void {
|
||||
armedBy.set(next.el, el);
|
||||
if (mediaNearPlayhead.get(next.el) !== true) {
|
||||
mediaNearPlayhead.set(next.el, true);
|
||||
preloadMedia(next.el);
|
||||
waitingForSlot.delete(next.el);
|
||||
startPrefetch(next.el);
|
||||
}
|
||||
};
|
||||
const preloadNearPlayhead = (el: HTMLMediaElement, visible: boolean, upcoming: boolean) => {
|
||||
const preloadNearPlayhead = (
|
||||
el: HTMLMediaElement,
|
||||
visible: boolean,
|
||||
upcoming: boolean,
|
||||
time: number,
|
||||
forLoop: boolean,
|
||||
) => {
|
||||
const playing = clock.isPlaying();
|
||||
if (!(visible && playing)) armedFrom.delete(el);
|
||||
else if (!armedFrom.has(el)) {
|
||||
@@ -2893,8 +2933,21 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
// An arm lasts while the clip that set it plays on screen, so a jump away drops it.
|
||||
const armer = armedBy.get(el);
|
||||
const near = visible || upcoming || (armer !== undefined && armedFrom.has(armer));
|
||||
const armed = armer !== undefined && armedFrom.has(armer);
|
||||
const decided = mediaNearPlayhead.get(el);
|
||||
const unslotted = !visible && !armed && decided !== true && prefetching.size >= PREFETCH_SLOTS;
|
||||
if (unslotted && upcoming) waitingForSlot.add(el);
|
||||
else waitingForSlot.delete(el);
|
||||
// While playing, a clip that has passed is not fetched again, and one still downloading is released:
|
||||
// either only takes bandwidth from the next ones. A loaded clip stays for a scrub back.
|
||||
const passed =
|
||||
!visible &&
|
||||
!forLoop &&
|
||||
playing &&
|
||||
resolveAbsoluteMediaStartSeconds(el) + (resolveDurationForElement(el) ?? Number.NaN) <= time;
|
||||
const spent = passed && (decided !== true || (el.networkState === 2 && el.readyState < 4));
|
||||
const near = visible || armed || (upcoming && !unslotted && !spent);
|
||||
if ((visible || !near) && endPrefetch(el)) queueMicrotask(fillPrefetchSlots);
|
||||
if (decided === near) return;
|
||||
mediaNearPlayhead.set(el, near);
|
||||
if (!near) {
|
||||
@@ -2905,21 +2958,24 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
} else if (!visible || decided === undefined) {
|
||||
// Not a clip a jump lands on: the media sync arms that one, and load() would undo its seek.
|
||||
preloadMedia(el);
|
||||
if (visible) preloadMedia(el);
|
||||
else startPrefetch(el);
|
||||
}
|
||||
};
|
||||
// Held past the look-ahead so a scrub across its edge does not refetch, and while playing when
|
||||
// due at the loop start.
|
||||
const dueAtLoopStart = (el: HTMLMediaElement, visibleAt: ReturnType<typeof timedVisibilityAt>) =>
|
||||
loopStartSeconds !== null &&
|
||||
clock.isPlaying() &&
|
||||
(visibleAt(el, loopStartSeconds) ||
|
||||
dueSoon(el, visibleAt, loopStartSeconds, RELEASE_LOOKAHEAD_SECONDS));
|
||||
const mediaStaysDue = (
|
||||
el: HTMLMediaElement,
|
||||
visibleAt: ReturnType<typeof timedVisibilityAt>,
|
||||
t: number,
|
||||
) =>
|
||||
(mediaNearPlayhead.get(el) === true && dueSoon(el, visibleAt, t, RELEASE_LOOKAHEAD_SECONDS)) ||
|
||||
(loopStartSeconds !== null &&
|
||||
clock.isPlaying() &&
|
||||
(visibleAt(el, loopStartSeconds) ||
|
||||
dueSoon(el, visibleAt, loopStartSeconds, RELEASE_LOOKAHEAD_SECONDS)));
|
||||
dueAtLoopStart(el, visibleAt);
|
||||
|
||||
const visibilityDecided = new WeakMap<Element, boolean>();
|
||||
// Every time at which an element's visibility, upcoming or preload decision can flip: its own window
|
||||
@@ -3054,6 +3110,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
rawNode,
|
||||
isVisibleNow,
|
||||
upcoming || mediaStaysDue(rawNode, visibleAt, currentTime),
|
||||
currentTime,
|
||||
dueAtLoopStart(rawNode, visibleAt),
|
||||
);
|
||||
if (!isMediaElement(rawNode) && !isImageElement(rawNode)) decidedTimedClip = true;
|
||||
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
|
||||
|
||||
Reference in New Issue
Block a user