diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index a359bd9d9e..27f6ddbd65 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -474,6 +474,106 @@ describe("studio manual edits", () => { expect(frames).toHaveLength(0); }); + it("does not reapply every frame while the player is paused over unpaused scene timelines", () => { + const window = new Window(); + const frames: FrameRequestCallback[] = []; + let playing = false; + const previewWindow = window as unknown as Parameters< + typeof installStudioManualEditSeekReapply + >[0] & { + __player: Record; + __timelines: Record>; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + }; + previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }; + previewWindow.__player = { + play: () => { + playing = true; + }, + isPlaying: () => playing, + }; + // A GSAP child of a paused master: not paused itself, inactive, time left. + const master = { paused: () => true }; + previewWindow.__timelines = { + scene0: { + parent: master, + play: () => {}, + paused: () => false, + isActive: () => false, + time: () => 0, + duration: () => 5, + }, + }; + + expect(installStudioManualEditSeekReapply(previewWindow, () => {})).toBe(true); + expect(frames).toHaveLength(0); + + (previewWindow.__player.play as () => void)(); + expect(frames).toHaveLength(1); + playing = false; + frames.shift()?.(16); + expect(frames).toHaveLength(0); + }); + + it("tolerates a timeline whose parent chain loops", () => { + const window = new Window(); + const previewWindow = window as unknown as Parameters< + typeof installStudioManualEditSeekReapply + >[0] & { + __timelines: Record>; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + }; + previewWindow.requestAnimationFrame = () => 1; + const loop: Record = { paused: () => false }; + loop.parent = loop; + previewWindow.__timelines = { + scene0: { + parent: loop, + play: () => {}, + paused: () => false, + time: () => 0, + duration: () => 5, + }, + }; + + expect(() => installStudioManualEditSeekReapply(previewWindow, () => {})).not.toThrow(); + }); + + it("keeps reapplying while Studio drives a timeline directly and the player reads idle", () => { + const window = new Window(); + const frames: FrameRequestCallback[] = []; + let paused = true; + const previewWindow = window as unknown as Parameters< + typeof installStudioManualEditSeekReapply + >[0] & { + __player: Record; + __timeline: Record; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + }; + previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }; + previewWindow.__player = { play: () => {}, isPlaying: () => false }; + previewWindow.__timeline = { + play: () => { + paused = false; + }, + paused: () => paused, + isActive: () => !paused, + time: () => 1, + duration: () => 5, + }; + + expect(installStudioManualEditSeekReapply(previewWindow, () => {})).toBe(true); + (previewWindow.__timeline.play as () => void)(); + frames.shift()?.(16); + expect(frames).toHaveLength(1); + }); + it("stops playback reapply after an unpaused timeline has completed", () => { const window = new Window(); const frames: FrameRequestCallback[] = []; diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 464e451fdb..7d826d7395 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -120,6 +120,21 @@ function hasRemainingTimelineTime(owner: Record): boolean { return time < duration; } +function hasPausedAncestor(owner: Record): boolean { + let ancestor = owner.parent; + for (let depth = 0; depth < 32 && ancestor && typeof ancestor === "object"; depth++) { + const node = ancestor as Record; + const paused = node.paused; + try { + if (typeof paused === "function" && paused.call(node)) return true; + } catch { + return false; + } + ancestor = node.parent; + } + return false; +} + function isTimelinePlaying(owner: Record | undefined): boolean { if (!owner) return false; const isPlaying = owner.isPlaying; @@ -148,6 +163,8 @@ function isTimelinePlaying(owner: Record | undefined): boolean } } + // A GSAP child of a paused timeline reads unpaused with time left; only its ancestors know. + if (hasPausedAncestor(owner)) return false; return hasRemainingTimelineTime(owner); }