@@ -174,6 +174,9 @@ export function initSandboxRuntimeModular(): void {
174174 let webAudioReady = false ;
175175 void webAudio . init ( ) . then ( ( ok ) => {
176176 webAudioReady = ok ;
177+ // Warm the decoded-buffer cache as soon as the transport exists (decode
178+ // works on a suspended AudioContext, so no user gesture is needed).
179+ if ( ok ) predecodeAudioClipBuffers ( ) ;
177180 } ) ;
178181 // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
179182 // parser reads back), NOT an animatable property. Register it as a no-op GSAP
@@ -1808,19 +1811,69 @@ export function initSandboxRuntimeModular(): void {
18081811 }
18091812 } ;
18101813
1814+ // Boundary-readiness telemetry: surface playback stalls (`waiting`/`stalled`
1815+ // on a timed media element while the transport plays) so cut-boundary tuning
1816+ // can target the right layer. Bounded per document to avoid diagnostic spam.
1817+ let mediaStallDiagnosticsPosted = 0 ;
1818+ const MAX_MEDIA_STALL_DIAGNOSTICS = 20 ;
1819+ const onMediaStallEvent = ( event : Event ) => {
1820+ const el = event . currentTarget ;
1821+ if ( ! ( el instanceof HTMLMediaElement ) ) return ;
1822+ if ( ! state . isPlaying || state . tornDown ) return ;
1823+ if ( mediaStallDiagnosticsPosted >= MAX_MEDIA_STALL_DIAGNOSTICS ) return ;
1824+ mediaStallDiagnosticsPosted += 1 ;
1825+ let bufferedEnd : number | null = null ;
1826+ try {
1827+ bufferedEnd = el . buffered . length > 0 ? el . buffered . end ( el . buffered . length - 1 ) : null ;
1828+ } catch {
1829+ // buffered ranges unavailable
1830+ }
1831+ postRuntimeMessage ( {
1832+ source : "hf-preview" ,
1833+ type : "diagnostic" ,
1834+ code : "runtime_media_stall" ,
1835+ details : {
1836+ event : event . type ,
1837+ tagName : el . tagName . toLowerCase ( ) ,
1838+ currentSrc : el . currentSrc || null ,
1839+ readyState : el . readyState ,
1840+ networkState : el . networkState ,
1841+ mediaTime : el . currentTime ,
1842+ bufferedEnd,
1843+ compositionTime : state . currentTime ,
1844+ clipStart : Number . parseFloat ( el . dataset . start ?? "" ) || null ,
1845+ } ,
1846+ } ) ;
1847+ } ;
1848+
18111849 const unbindMediaMetadataListeners = ( ) => {
18121850 for ( const mediaEl of metadataBoundMedia ) {
18131851 mediaEl . removeEventListener ( "loadedmetadata" , scheduleMetadataDurationHydration ) ;
18141852 mediaEl . removeEventListener ( "durationchange" , scheduleMetadataDurationHydration ) ;
18151853 mediaEl . removeEventListener ( "loadedmetadata" , onMediaLoadedMetadataForProxy ) ;
18161854 mediaEl . removeEventListener ( "error" , onMediaErrorForProxy ) ;
1855+ mediaEl . removeEventListener ( "waiting" , onMediaStallEvent ) ;
1856+ mediaEl . removeEventListener ( "stalled" , onMediaStallEvent ) ;
18171857 }
18181858 metadataBoundMedia . clear ( ) ;
18191859 } ;
18201860
18211861 const bindMediaMetadataListeners = ( ) => {
18221862 if ( state . tornDown ) return ;
18231863 const mediaEls = Array . from ( document . querySelectorAll ( "video, audio" ) ) as HTMLMediaElement [ ] ;
1864+ // Segment-heavy compositions (editors emit one media element per cut) must
1865+ // not full-preload EVERYTHING at mount: dozens of parallel fetches contend
1866+ // for the connection pool and the EARLY segments buffer late. Above the
1867+ // threshold, far-future timed segments start metadata-only; the sync layer
1868+ // upgrades them to full preload as the playhead approaches (media.ts
1869+ // readiness stages set preload="auto" ~3s ahead).
1870+ const EAGER_PRELOAD_MAX_TIMED_MEDIA = 6 ;
1871+ const STAGED_PRELOAD_HORIZON_SECONDS = 10 ;
1872+ const timedMediaCount = mediaEls . reduce (
1873+ ( count , el ) => count + ( el . hasAttribute ( "data-start" ) ? 1 : 0 ) ,
1874+ 0 ,
1875+ ) ;
1876+ const stagePreload = timedMediaCount > EAGER_PRELOAD_MAX_TIMED_MEDIA ;
18241877 for ( const mediaEl of mediaEls ) {
18251878 if ( metadataBoundMedia . has ( mediaEl ) ) continue ;
18261879 metadataBoundMedia . add ( mediaEl ) ;
@@ -1835,6 +1888,8 @@ export function initSandboxRuntimeModular(): void {
18351888 // for <audio> — all guarded inside mediaProxy.ts itself.
18361889 mediaEl . addEventListener ( "loadedmetadata" , onMediaLoadedMetadataForProxy ) ;
18371890 mediaEl . addEventListener ( "error" , onMediaErrorForProxy ) ;
1891+ mediaEl . addEventListener ( "waiting" , onMediaStallEvent ) ;
1892+ mediaEl . addEventListener ( "stalled" , onMediaStallEvent ) ;
18381893
18391894 // Proactive proxy-fallback trigger: consult the codec map and swap
18401895 // BEFORE the eager load() below, so a known-hostile asset never even
@@ -1845,8 +1900,12 @@ export function initSandboxRuntimeModular(): void {
18451900 // Eagerly preload media data so audio/video is buffered before the user
18461901 // clicks play. Without this, the first play() call fires on un-fetched
18471902 // media, producing silence or choppy audio until the browser caches it.
1848- if ( mediaEl . preload !== "auto" ) {
1849- mediaEl . preload = "auto" ;
1903+ // Exception: staged preload for far-future segments (see above).
1904+ const startAttr = Number . parseFloat ( mediaEl . dataset . start ?? "" ) ;
1905+ const farFuture = Number . isFinite ( startAttr ) && startAttr > STAGED_PRELOAD_HORIZON_SECONDS ;
1906+ const targetPreload = stagePreload && farFuture ? "metadata" : "auto" ;
1907+ if ( mediaEl . preload !== targetPreload ) {
1908+ mediaEl . preload = targetPreload ;
18501909 }
18511910 if ( mediaEl . readyState < HTMLMediaElement . HAVE_FUTURE_DATA ) {
18521911 mediaEl . load ( ) ;
@@ -1861,6 +1920,29 @@ export function initSandboxRuntimeModular(): void {
18611920 }
18621921 } ;
18631922
1923+ // Decode-only warm pass over every timed audio source so WebAudio buffers are
1924+ // ready BEFORE the first play() instead of being decoded lazily inside it.
1925+ // The lazy path costs a full-file fetch+decodeAudioData per source at the
1926+ // moment the user hits play — on a freshly (re)loaded document (every editor
1927+ // edit reloads the preview iframe) that gap plays through the HTMLMedia
1928+ // fallback, heard as an audio dropout. Pre-decoding overlaps that work with
1929+ // document load. `decodeAudioElement` dedupes via its buffer cache and
1930+ // failure blacklist, so repeated calls per element are cheap map hits.
1931+ // Skipped during render capture: the producer mixes audio with ffmpeg from
1932+ // source files and never plays through WebAudio.
1933+ const predecodeAudioClipBuffers = ( ) => {
1934+ if ( state . tornDown || ! webAudioReady ) return ;
1935+ if ( state . nativeMediaSyncDisabled || state . webAudioMediaDisabled ) return ;
1936+ if ( ( window as Window & { __HF_RENDER_CAPTURE_MODE ?: boolean } ) . __HF_RENDER_CAPTURE_MODE ) {
1937+ return ;
1938+ }
1939+ const audioEls = document . querySelectorAll ( "audio[data-start]" ) ;
1940+ for ( const el of audioEls ) {
1941+ if ( ! ( el instanceof HTMLMediaElement ) || ! el . isConnected ) continue ;
1942+ void webAudio . decodeAudioElement ( el ) ;
1943+ }
1944+ } ;
1945+
18641946 const probeAndCacheVolumeKeyframes = ( mediaEl : HTMLMediaElement ) => {
18651947 if ( volumeKeyframeCache . has ( mediaEl ) ) return ;
18661948 probeAndCacheElementVolume (
@@ -2882,6 +2964,9 @@ export function initSandboxRuntimeModular(): void {
28822964 }
28832965 if ( transportTickCount % 30 === 0 ) {
28842966 bindMediaMetadataListeners ( ) ;
2967+ // Also warm decode for audio elements discovered after mount (loaded
2968+ // sub-compositions) or bound before the async WebAudio init resolved.
2969+ predecodeAudioClipBuffers ( ) ;
28852970 }
28862971
28872972 // Sync clock duration with the resolved timeline each tick (catches async
@@ -2933,6 +3018,31 @@ export function initSandboxRuntimeModular(): void {
29333018 break ;
29343019 }
29353020 }
3021+ if ( ! foundActive ) {
3022+ // No audio is mastering the clock (video-only composition, or the
3023+ // audio hasn't reached its window). A buffering ACTIVE video should
3024+ // freeze time the same way buffering audio does above — otherwise
3025+ // the monotonic clock runs ahead while frames are stuck, and the
3026+ // drift correction later hard-seeks (decoder reset) to catch up.
3027+ const videoEls = document . querySelectorAll ( "video[data-start]" ) ;
3028+ for ( const rawEl of videoEls ) {
3029+ if ( ! ( rawEl instanceof HTMLMediaElement ) || ! rawEl . isConnected ) continue ;
3030+ const start = Number . parseFloat ( rawEl . dataset . start ?? "" ) ;
3031+ if ( ! Number . isFinite ( start ) ) continue ;
3032+ const durAttr = Number . parseFloat ( rawEl . dataset . duration ?? "" ) ;
3033+ const end = Number . isFinite ( durAttr ) && durAttr > 0 ? start + durAttr : Infinity ;
3034+ if ( state . currentTime < start || state . currentTime >= end ) continue ;
3035+ if (
3036+ ! rawEl . paused &&
3037+ ! rawEl . error &&
3038+ rawEl . readyState < HTMLMediaElement . HAVE_FUTURE_DATA
3039+ ) {
3040+ clock . attachAudioSource ( { currentTimeSeconds : state . currentTime } ) ;
3041+ foundActive = true ;
3042+ }
3043+ break ;
3044+ }
3045+ }
29363046 if ( ! foundActive && clock . hasAudioSource ( ) ) {
29373047 clock . detachAudioSource ( ) ;
29383048 }
0 commit comments