From 179098936faaa21fd2bf74dfa80b4e705fdb3c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 24 Oct 2026 00:15:24 +0000 Subject: [PATCH] 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. --- packages/core/src/runtime/entry.test.ts | 53 ++++++++++++++++++ packages/core/src/runtime/init.ts | 74 ++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/packages/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts index 5f64f36305..20c86fe5fe 100644 --- a/packages/core/src/runtime/entry.test.ts +++ b/packages/core/src/runtime/entry.test.ts @@ -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); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d14a2b8e13..e4042fe6fa 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2860,6 +2860,39 @@ export function initSandboxRuntimeModular(): void { const armedFrom = new WeakSet(); // 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(); + const waitingForSlot = new Set(); + 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) => + loopStartSeconds !== null && + clock.isPlaying() && + (visibleAt(el, loopStartSeconds) || + dueSoon(el, visibleAt, loopStartSeconds, RELEASE_LOOKAHEAD_SECONDS)); const mediaStaysDue = ( el: HTMLMediaElement, visibleAt: ReturnType, 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(); // 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)) {