Skip to content

Commit 1790989

Browse files
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.
1 parent 19cc96e commit 1790989

2 files changed

Lines changed: 119 additions & 8 deletions

File tree

‎packages/core/src/runtime/entry.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,59 @@ describe("runtime entry", () => {
455455
expect(loadsOf(later)).toEqual([beforeJump]);
456456
});
457457

458+
it("fetches the clips due soon three at a time, earliest first, so the clip due never queues behind them", async () => {
459+
spyLoad();
460+
servePreview();
461+
const [now, ...due] = videos(mountRoot(), "0", "1.5", "1.1", "1.4", "1.2", "1.3");
462+
463+
await evaluateRuntime();
464+
expect(armed(now, ...due)).toEqual([true, true, true, true, false, false]);
465+
expect(loadsOf(...due)).toEqual([1, 1, 1, 0, 0]);
466+
due[0]!.dispatchEvent(new Event("suspend"));
467+
expect(armed(...due)).toEqual([true, true, true, false, false]);
468+
Object.defineProperty(due[0]!, "readyState", { value: 3 });
469+
due[0]!.dispatchEvent(new Event("canplay"));
470+
expect(armed(...due)).toEqual([true, true, true, true, false]);
471+
due[1]!.dispatchEvent(new Event("error"));
472+
expect(armed(...due)).toEqual([true, true, true, true, true]);
473+
});
474+
475+
it("frees a clip the playhead has passed while it is still downloading, and keeps one that finished", async () => {
476+
spyLoad();
477+
servePreview();
478+
stubPlayback();
479+
const [slow, done] = videos(mountRoot(), "0", "0.5");
480+
Object.defineProperty(slow, "networkState", { value: 2 });
481+
Object.defineProperty(done, "networkState", { value: 1 });
482+
Object.defineProperty(done, "readyState", { value: 4 });
483+
484+
await evaluateRuntime();
485+
window.__player?.play();
486+
expect(armed(slow, done)).toEqual([true, true]);
487+
window.__player?.seek(2.6, { keepPlaying: true });
488+
expect(armed(slow, done)).toEqual([false, true]);
489+
});
490+
491+
it("keeps a passed clip released for the rest of the play instead of refetching it each pass", async () => {
492+
spyLoad();
493+
servePreview();
494+
stubPlayback();
495+
const [slow, next] = videos(mountRoot(), "0", "2");
496+
// A fetch is running whenever preload asks for one.
497+
Object.defineProperty(slow, "networkState", { get: () => (slow.preload === "auto" ? 2 : 1) });
498+
499+
await evaluateRuntime();
500+
window.__player?.play();
501+
window.__player?.seek(2.05, { keepPlaying: true });
502+
expect(armed(slow, next)).toEqual([false, true]);
503+
const loadsAfterRelease = loadsOf(slow)[0];
504+
for (let t = 2.1; t <= 4.0; t += 0.05) {
505+
window.__player?.seek(t, { keepPlaying: true });
506+
expect(slow.preload).toBe("none");
507+
}
508+
expect(loadsOf(slow)[0]).toBe(loadsAfterRelease);
509+
});
510+
458511
// jsdom's media elements cannot play; the runtime only needs play() to settle.
459512
const stubPlayback = () => {
460513
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);

‎packages/core/src/runtime/init.ts‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2860,6 +2860,39 @@ export function initSandboxRuntimeModular(): void {
28602860
const armedFrom = new WeakSet<HTMLMediaElement>();
28612861
// An untracked clip (NaN) shares a track with nothing, as in the timeline payload.
28622862
const hostOf = (el: Element) => el.parentElement?.closest("[data-composition-id]");
2863+
// Clips due soon fetch a few at a time, earliest first: a fast cut sequence has dozens inside the
2864+
// look-ahead, and fetching them all queues the clip that is due behind clips that are not.
2865+
const PREFETCH_SLOTS = 3;
2866+
const prefetching = new Set<HTMLMediaElement>();
2867+
const waitingForSlot = new Set<HTMLMediaElement>();
2868+
const PREFETCH_SETTLED_EVENTS = ["canplay", "suspend", "error"];
2869+
const endPrefetch = (el: HTMLMediaElement) => {
2870+
for (const type of PREFETCH_SETTLED_EVENTS) el.removeEventListener(type, onPrefetchSettled);
2871+
return prefetching.delete(el);
2872+
};
2873+
const onPrefetchSettled = (event: Event) => {
2874+
const el = event.currentTarget as HTMLMediaElement;
2875+
// A load() queues a suspend of the stopped fetch before the new one starts; only a playable clip is done.
2876+
if (event.type !== "error" && el.readyState < 3) return;
2877+
endPrefetch(el);
2878+
fillPrefetchSlots();
2879+
};
2880+
const fillPrefetchSlots = () => {
2881+
const earliestFirst = Array.from(waitingForSlot)
2882+
.filter((el) => el.isConnected)
2883+
.sort((a, b) => resolveAbsoluteMediaStartSeconds(a) - resolveAbsoluteMediaStartSeconds(b));
2884+
for (const el of earliestFirst) {
2885+
if (prefetching.size >= PREFETCH_SLOTS) break;
2886+
waitingForSlot.delete(el);
2887+
syncTimedElementVisibility(state.currentTime, [el]);
2888+
}
2889+
};
2890+
const startPrefetch = (el: HTMLMediaElement) => {
2891+
preloadMedia(el);
2892+
if (prefetching.has(el) || el.readyState >= 3) return;
2893+
prefetching.add(el);
2894+
for (const type of PREFETCH_SETTLED_EVENTS) el.addEventListener(type, onPrefetchSettled);
2895+
};
28632896
const armNextOnTrack = (el: HTMLMediaElement) => {
28642897
const track = parseAuthoredTrack(el, Number.NaN);
28652898
const clips = buildRuntimeMediaCache(
@@ -2881,10 +2914,17 @@ export function initSandboxRuntimeModular(): void {
28812914
armedBy.set(next.el, el);
28822915
if (mediaNearPlayhead.get(next.el) !== true) {
28832916
mediaNearPlayhead.set(next.el, true);
2884-
preloadMedia(next.el);
2917+
waitingForSlot.delete(next.el);
2918+
startPrefetch(next.el);
28852919
}
28862920
};
2887-
const preloadNearPlayhead = (el: HTMLMediaElement, visible: boolean, upcoming: boolean) => {
2921+
const preloadNearPlayhead = (
2922+
el: HTMLMediaElement,
2923+
visible: boolean,
2924+
upcoming: boolean,
2925+
time: number,
2926+
forLoop: boolean,
2927+
) => {
28882928
const playing = clock.isPlaying();
28892929
if (!(visible && playing)) armedFrom.delete(el);
28902930
else if (!armedFrom.has(el)) {
@@ -2893,8 +2933,21 @@ export function initSandboxRuntimeModular(): void {
28932933
}
28942934
// An arm lasts while the clip that set it plays on screen, so a jump away drops it.
28952935
const armer = armedBy.get(el);
2896-
const near = visible || upcoming || (armer !== undefined && armedFrom.has(armer));
2936+
const armed = armer !== undefined && armedFrom.has(armer);
28972937
const decided = mediaNearPlayhead.get(el);
2938+
const unslotted = !visible && !armed && decided !== true && prefetching.size >= PREFETCH_SLOTS;
2939+
if (unslotted && upcoming) waitingForSlot.add(el);
2940+
else waitingForSlot.delete(el);
2941+
// While playing, a clip that has passed is not fetched again, and one still downloading is released:
2942+
// either only takes bandwidth from the next ones. A loaded clip stays for a scrub back.
2943+
const passed =
2944+
!visible &&
2945+
!forLoop &&
2946+
playing &&
2947+
resolveAbsoluteMediaStartSeconds(el) + (resolveDurationForElement(el) ?? Number.NaN) <= time;
2948+
const spent = passed && (decided !== true || (el.networkState === 2 && el.readyState < 4));
2949+
const near = visible || armed || (upcoming && !unslotted && !spent);
2950+
if ((visible || !near) && endPrefetch(el)) queueMicrotask(fillPrefetchSlots);
28982951
if (decided === near) return;
28992952
mediaNearPlayhead.set(el, near);
29002953
if (!near) {
@@ -2905,21 +2958,24 @@ export function initSandboxRuntimeModular(): void {
29052958
}
29062959
} else if (!visible || decided === undefined) {
29072960
// Not a clip a jump lands on: the media sync arms that one, and load() would undo its seek.
2908-
preloadMedia(el);
2961+
if (visible) preloadMedia(el);
2962+
else startPrefetch(el);
29092963
}
29102964
};
29112965
// Held past the look-ahead so a scrub across its edge does not refetch, and while playing when
29122966
// due at the loop start.
2967+
const dueAtLoopStart = (el: HTMLMediaElement, visibleAt: ReturnType<typeof timedVisibilityAt>) =>
2968+
loopStartSeconds !== null &&
2969+
clock.isPlaying() &&
2970+
(visibleAt(el, loopStartSeconds) ||
2971+
dueSoon(el, visibleAt, loopStartSeconds, RELEASE_LOOKAHEAD_SECONDS));
29132972
const mediaStaysDue = (
29142973
el: HTMLMediaElement,
29152974
visibleAt: ReturnType<typeof timedVisibilityAt>,
29162975
t: number,
29172976
) =>
29182977
(mediaNearPlayhead.get(el) === true && dueSoon(el, visibleAt, t, RELEASE_LOOKAHEAD_SECONDS)) ||
2919-
(loopStartSeconds !== null &&
2920-
clock.isPlaying() &&
2921-
(visibleAt(el, loopStartSeconds) ||
2922-
dueSoon(el, visibleAt, loopStartSeconds, RELEASE_LOOKAHEAD_SECONDS)));
2978+
dueAtLoopStart(el, visibleAt);
29232979

29242980
const visibilityDecided = new WeakMap<Element, boolean>();
29252981
// 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 {
30543110
rawNode,
30553111
isVisibleNow,
30563112
upcoming || mediaStaysDue(rawNode, visibleAt, currentTime),
3113+
currentTime,
3114+
dueAtLoopStart(rawNode, visibleAt),
30573115
);
30583116
if (!isMediaElement(rawNode) && !isImageElement(rawNode)) decidedTimedClip = true;
30593117
if (isVideoElement(rawNode) || isImageElement(rawNode)) {

0 commit comments

Comments
 (0)