From 0f48a322080af8b48bb5c7efca95a8fd3050d3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 10 May 2026 19:48:01 +0000 Subject: [PATCH 1/2] fix(runtime): comprehensive audio stutter fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that together caused audio play/stop/play/stop stutter during transport-driven playback: 1. seekRuntimeTimeline called timeline.pause() before every totalTime() seek, 60x per second. GSAP cascades pause to media elements on every frame. Fix: restore original inline seek for the captured timeline (totalTime without pause). The timeline is already paused once in player.play(). seekRuntimeTimeline with pause() remains only for standalone child timelines. 2. player.play() removed the !tl guard, allowing play without a captured timeline. But getSafeTimelineDurationSeconds(null) returns 0, so the clock has no duration → immediately reaches end → stops → restarts. Fix: when no timeline provides duration, fall back to the root composition element's data-duration attribute. 3. Audio source attachment added networkState guard that could cause the clock to flicker between audio-source and monotonic timing on transient media states. Fix: keep !rawEl.error guard (prevents errored audio from freezing the clock) but drop the networkState check. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/runtime/init.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cc5471c3e..141feb7f0 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1796,11 +1796,7 @@ export function initSandboxRuntimeModular(): void { if (!rawEl.paused) { clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart }); foundActive = true; - } else if ( - !rawEl.error && - rawEl.networkState !== HTMLMediaElement.NETWORK_NO_SOURCE && - rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA - ) { + } else if (!rawEl.error && rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { // Audio is buffering — freeze visuals at last known position // instead of falling through to monotonic (which runs ahead). clock.attachAudioSource({ currentTimeSeconds: state.currentTime }); @@ -1887,6 +1883,10 @@ export function initSandboxRuntimeModular(): void { state.currentTime = 0; seekTimelineAndAdapters(0); } + } else { + const rootEl = resolveRootCompositionElement(); + const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0); + if (declaredDur > 0) clock.setDuration(declaredDur); } if (tl) tl.pause(); if (!clock.play()) return; From 8fc3dfb370ea9fd0ec442af4306d8d03d762bacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 10 May 2026 13:40:45 -0700 Subject: [PATCH 2/2] fix(runtime): skip drift corrections on playing video elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeking a playing video resets the browser's decoder pipeline, causing a ~150ms freeze while it re-buffers. During that freeze the monotonic clock advances, drift grows, and strict sync fires another seek — creating a perpetual stutter loop (176 seek events / 8s observed on the apple-presentation composition). Skip strict and force drift corrections for playing video elements; only hard sync (>0.5s catastrophic drift) warrants the decoder-reset cost. Audio elements are unaffected and retain the full correction tiers. Also propagate the asset-loading overlay state to the timeline so controls are disabled during "Preparing preview assets", matching the existing behavior for the initial composition loading overlay. --- packages/core/src/runtime/media.ts | 18 ++++++++++++++++-- .../studio/src/player/components/Player.tsx | 4 ++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 11d770659..5d76dbc94 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -198,13 +198,26 @@ export function syncRuntimeMedia(params: { const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5; const catastrophicDrift = drift > 3; const hardSync = drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift); + // Playing video elements use the browser's native decoder pipeline for + // timing. Seeking a playing video resets the decoder, causing a ~150ms + // freeze while it re-buffers — during which the monotonic clock advances, + // creating a perpetual seek→freeze→drift→seek stutter loop. Skip strict + // and force sync for playing videos; only hard sync (>0.5s) warrants + // the decoder-reset cost. + const isPlayingVideo = el.tagName === "VIDEO" && !el.paused; // Only apply strict sync when offset has stabilized (not growing). // During initial buffering, offset grows ~16ms/tick as the timeline // advances while media stays at 0. Accumulated drift from pause/play // toggling shows up as a stable, non-zero offset (delta near 0). const offsetStabilized = prevOffset !== undefined && Math.abs(offset - prevOffset) < 0.004; let strictSync = false; - if (!hardSync && !firstTickOfClip && offsetStabilized && drift > STRICT_DRIFT_THRESHOLD) { + if ( + !isPlayingVideo && + !hardSync && + !firstTickOfClip && + offsetStabilized && + drift > STRICT_DRIFT_THRESHOLD + ) { const samples = (strictDriftSamples.get(el) ?? 0) + 1; strictDriftSamples.set(el, samples); if (samples >= STRICT_REQUIRED_SAMPLES) { @@ -214,7 +227,8 @@ export function syncRuntimeMedia(params: { } else if (drift <= STRICT_DRIFT_THRESHOLD) { strictDriftSamples.set(el, 0); } - if (hardSync || strictSync || (params.forceSync && drift > 0.02)) { + const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02; + if (hardSync || strictSync || forceSync) { try { el.currentTime = relTime; } catch (err) { diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index f47c04416..3f1a6f661 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -273,8 +273,8 @@ export const Player = forwardRef( assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay; useEffect(() => { - onCompositionLoadingChange?.(showCompositionOverlay); - }, [onCompositionLoadingChange, showCompositionOverlay]); + onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay); + }, [onCompositionLoadingChange, showCompositionOverlay, showAssetOverlay]); return (