@@ -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