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:
Miguel Ángel
2026-10-24 00:15:24 +00:00
committed by GitHub
parent 19cc96e3f2
commit 179098936f
2 changed files with 119 additions and 8 deletions
+53
View File
@@ -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);
+66 -8
View File
@@ -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)) {