From 07d14553c93ab54c2ab9c4915bccedf0324ad849 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] 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;