fix(studio): clamp loopEnd to duration so RAF boundary stays reachable

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.
This commit is contained in:
Miguel Ángel
2026-05-25 19:18:46 -04:00
parent 1aaf89ce70
commit 07d14553c9
2 changed files with 25 additions and 14 deletions
@@ -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(
@@ -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;