From 113b72d0ae1d048d8a8893bd5f4438a4d8746c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 25 May 2026 19:01:49 -0400 Subject: [PATCH 1/2] fix(studio): clamp playhead to composition duration in RAF loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The studio player's RAF loop in useTimelinePlayer notified the playhead position via liveTime.notify(time) before checking the duration limit. When adapter.getTime() returned a value past the composition's data-duration (due to timing drift or delayed duration calculation), the playhead would visually overshoot — showing e.g. 0:19 on a 0:10 composition. The web player component already had this clamping (playback-state.ts line 42, direct-timeline-clock.ts line 56), but the studio player's forward loop was missing it. Fix: clamp time to dur before notifying, matching the pattern already used in the web player: Math.min(rawTime, dur) when dur > 0. --- packages/studio/src/player/hooks/useTimelinePlayer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index f503e8299..10d830670 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -226,8 +226,9 @@ export function useTimelinePlayer() { const tick = () => { const adapter = getAdapter(); if (adapter) { - const time = adapter.getTime(); + const rawTime = adapter.getTime(); const dur = adapter.getDuration(); + const time = dur > 0 ? Math.min(rawTime, dur) : rawTime; liveTime.notify(time); // direct DOM updates, no React re-render const { inPoint, outPoint } = usePlayerStore.getState(); const rawLoopEnd = outPoint !== null ? outPoint : dur; From 0897d3ef1c368a7b97e81fd130d4baa781fad98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 25 May 2026 23:15:55 +0000 Subject: [PATCH 2/2] fix(studio): clamp loopEnd to duration so RAF boundary stays reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When outPoint exceeds composition duration, rawLoopEnd > dur makes the time >= loopEnd branch unreachable after the playhead clamp — the player ticks forever. Clamp rawLoopEnd to dur in both forward and backward RAF loops, matching the seek() clamping. Add test for the boundary behavior. Trim blank lines to satisfy 600-line filesize gate. --- .../player/hooks/useTimelinePlayer.test.ts | 23 +++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 16 ++----------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts index f83ea6ef4..cf2419dc4 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts @@ -196,6 +196,29 @@ describe("createStaticSeekPlaybackAdapter", () => { expect(adapter.getDuration()).toBe(82); }); + it("clamps time at the duration boundary during RAF tick", () => { + const clock = createManualAnimationClock(); + const renderedTimes: number[] = []; + const adapter = createStaticSeekPlaybackAdapter( + { + getTime: () => 0, + renderSeek: (time: number) => { + renderedTimes.push(time); + }, + }, + 2, + clock, + ); + + adapter.seek(0); + adapter.play(); + clock.step(3_000); + + expect(adapter.getTime()).toBe(2); + expect(adapter.isPlaying()).toBe(false); + expect(renderedTimes).toEqual([0, 2]); + }); + it("pauses old adapter before replacing with new duration", () => { const clock = createManualAnimationClock(); const adapter = createStaticSeekPlaybackAdapter( diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 10d830670..e48e92fb4 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -231,7 +231,7 @@ export function useTimelinePlayer() { const time = dur > 0 ? Math.min(rawTime, dur) : rawTime; liveTime.notify(time); // direct DOM updates, no React re-render const { inPoint, outPoint } = usePlayerStore.getState(); - const rawLoopEnd = outPoint !== null ? outPoint : dur; + const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur; const rawLoopStart = inPoint !== null ? inPoint : 0; const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur; const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0; @@ -259,7 +259,6 @@ export function useTimelinePlayer() { const stopRAFLoop = useCallback(() => { cancelAnimationFrame(rafRef.current); }, []); - const applyPlaybackRate = useCallback((rate: number) => { const iframe = iframeRef.current; if (!iframe) return; @@ -281,7 +280,6 @@ export function useTimelinePlayer() { console.warn("[useTimelinePlayer] Could not set playback rate (cross-origin)", err); } }, []); - const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => { const { audioMuted, playbackRate } = usePlayerStore.getState(); const effectivePlaybackRate = playbackRateOverride ?? playbackRate; @@ -290,7 +288,6 @@ export function useTimelinePlayer() { shouldMutePreviewAudio(audioMuted, effectivePlaybackRate), ); }, []); - const play = useCallback(() => { stopRAFLoop(); stopReverseLoop(); @@ -314,7 +311,6 @@ export function useTimelinePlayer() { stopRAFLoop, stopReverseLoop, ]); - const playBackward = useCallback( (rate: number) => { stopRAFLoop(); @@ -335,7 +331,7 @@ export function useTimelinePlayer() { const elapsed = ((now - startedAt) / 1000) * speed; let nextTime = startTime - elapsed; const { inPoint, outPoint } = usePlayerStore.getState(); - const rawLoopEnd = outPoint !== null ? outPoint : duration; + const rawLoopEnd = outPoint !== null ? Math.min(outPoint, duration) : duration; const rawLoopStart = inPoint !== null ? inPoint : 0; const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : duration; const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0; @@ -374,7 +370,6 @@ export function useTimelinePlayer() { stopReverseLoop, ], ); - const pause = useCallback(() => { stopReverseLoop(); const adapter = getAdapter(); @@ -386,7 +381,6 @@ export function useTimelinePlayer() { shuttleSpeedIndexRef.current = 0; stopRAFLoop(); }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]); - const seek = useCallback( (time: number, options?: { keepPlaying?: boolean }) => { // Reverse shuttle is always stopped: the RAF reverse tick can't survive @@ -432,7 +426,6 @@ export function useTimelinePlayer() { } }); }, [seek]); - const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } = usePlaybackKeyboard({ iframeRef, @@ -461,7 +454,6 @@ export function useTimelinePlayer() { attachIframeShortcutListeners, applyPreviewAudioState, }); - const saveSeekPosition = useCallback(() => { const adapter = getAdapter(); pendingSeekRef.current = adapter @@ -472,19 +464,15 @@ export function useTimelinePlayer() { stopReverseLoop(); setIsPlaying(false); }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]); - const refreshPlayer = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; - saveSeekPosition(); - const src = iframe.src; const url = new URL(src, window.location.origin); url.searchParams.set("_t", String(Date.now())); iframe.src = url.toString(); }, [saveSeekPosition]); - const getAdapterRef = useRef(getAdapter); getAdapterRef.current = getAdapter;