Skip to content

Commit 75cd47c

Browse files
authored
fix(runtime): stop re-seeking paused timelines
1 parent 2f7229f commit 75cd47c

2 files changed

Lines changed: 86 additions & 3 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3030,13 +3030,76 @@ describe("initSandboxRuntimeModular", () => {
30303030
expect(seekTimes.length).toBeGreaterThan(beforePlaying);
30313031
player?.pause();
30323032

3033-
// (3) Paused + marker cleared (drop/cancel) → the per-frame re-seek resumes.
3033+
// (3) Paused + marker cleared (drop/cancel) → one reconciliation seek runs.
30343034
document.getElementById("dragged")?.removeAttribute("data-hf-studio-manual-edit-gesture");
30353035
const beforeResume = seekTimes.length;
30363036
raf.step(16);
30373037
expect(seekTimes.length).toBeGreaterThan(beforeResume);
30383038
});
30393039

3040+
it("does not re-seek an unchanged paused timeline on every animation frame", () => {
3041+
const raf = createManualRaf();
3042+
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
3043+
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
3044+
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
3045+
3046+
const seekTimes: number[] = [];
3047+
const tl = createMockTimeline(5);
3048+
const origTotalTime = tl.totalTime;
3049+
tl.totalTime = ((time: number, ...rest: unknown[]) => {
3050+
seekTimes.push(time);
3051+
(origTotalTime as Function).call(tl, time, ...rest);
3052+
}) as RuntimeTimelineLike["totalTime"];
3053+
3054+
document.body.innerHTML = `
3055+
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
3056+
`;
3057+
window.__timelines = { root: tl };
3058+
initSandboxRuntimeModular();
3059+
3060+
// The first transport frame reconciles the initial timeline at the paused playhead.
3061+
raf.step(16);
3062+
const afterInitialFrame = seekTimes.length;
3063+
expect(afterInitialFrame).toBeGreaterThan(0);
3064+
3065+
// No time or timeline change means there is no new frame to render.
3066+
raf.step(16);
3067+
raf.step(16);
3068+
raf.step(16);
3069+
expect(seekTimes.length).toBe(afterInitialFrame);
3070+
3071+
// An explicit paused seek still renders immediately, then settles again after the transport
3072+
// records the new playhead on its next frame.
3073+
window.__player?.seek(2);
3074+
expect(seekTimes.some((time) => time === 2)).toBe(true);
3075+
raf.step(16);
3076+
const afterPausedSeek = seekTimes.length;
3077+
raf.step(16);
3078+
expect(seekTimes.length).toBe(afterPausedSeek);
3079+
3080+
// A runtime-data rebuild can replace the timeline without moving the paused playhead. The
3081+
// identity check must render that new object once instead of treating it as the old frame.
3082+
const replacementSeekTimes: number[] = [];
3083+
const replacement = createMockTimeline(5);
3084+
const replacementTotalTime = replacement.totalTime;
3085+
replacement.totalTime = ((time: number, ...rest: unknown[]) => {
3086+
replacementSeekTimes.push(time);
3087+
(replacementTotalTime as Function).call(replacement, time, ...rest);
3088+
}) as RuntimeTimelineLike["totalTime"];
3089+
window.__timelines = { root: replacement };
3090+
window.__hfForceTimelineRebind?.();
3091+
raf.step(16);
3092+
expect(replacementSeekTimes.length).toBeGreaterThan(0);
3093+
const afterReplacementFrame = replacementSeekTimes.length;
3094+
raf.step(16);
3095+
expect(replacementSeekTimes.length).toBe(afterReplacementFrame);
3096+
3097+
// Playback still traverses the timeline every frame.
3098+
window.__player?.play();
3099+
raf.step(16);
3100+
expect(replacementSeekTimes.length).toBeGreaterThan(afterReplacementFrame);
3101+
});
3102+
30403103
it("redraws animated grading from the transport clock only during playback", () => {
30413104
const raf = createManualRaf();
30423105
vi.spyOn(performance, "now").mockImplementation(() => raf.now());

packages/core/src/runtime/init.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2809,6 +2809,13 @@ export function initSandboxRuntimeModular(): void {
28092809
}
28102810
let transportTickCount = 0;
28112811
let inTransportTick = false;
2812+
// A paused transport has no new frame to render. Re-seeking the same GSAP timeline at the
2813+
// same time on every rAF is not merely redundant: one picker can embed several paused
2814+
// players, multiplying full timeline traversal and style invalidation across every iframe.
2815+
// Keep enough identity to render once when time or the asynchronously-bound timeline changes.
2816+
let lastTransportSeekTime = Number.NaN;
2817+
let lastTransportSeekTimeline: RuntimeTimelineLike | null = null;
2818+
let pausedSeekDeferredByManualGesture = false;
28122819

28132820
const seekRuntimeTimeline = (
28142821
timeline: RuntimeTimelineLike,
@@ -3129,10 +3136,23 @@ export function initSandboxRuntimeModular(): void {
31293136
// skipping the re-seek is a no-op for every other element; it resumes
31303137
// the frame the gesture marker clears (drop/cancel). Playback is never
31313138
// affected — the seek runs whenever the clock is playing.
3132-
if (clock.isPlaying() || !hasActiveStudioManualEditGesture()) {
3139+
const isPlaying = clock.isPlaying();
3140+
const manualEditOwnsPausedFrame = !isPlaying && hasActiveStudioManualEditGesture();
3141+
if (manualEditOwnsPausedFrame) {
3142+
// Force one reconciliation after drop/cancel even though the playhead did not move.
3143+
pausedSeekDeferredByManualGesture = true;
3144+
} else if (
3145+
isPlaying ||
3146+
pausedSeekDeferredByManualGesture ||
3147+
t !== lastTransportSeekTime ||
3148+
state.capturedTimeline !== lastTransportSeekTimeline
3149+
) {
31333150
seekTimelineAndAdapters(t);
3151+
lastTransportSeekTime = t;
3152+
lastTransportSeekTimeline = state.capturedTimeline;
3153+
if (!isPlaying) pausedSeekDeferredByManualGesture = false;
31343154
}
3135-
if (clock.isPlaying()) {
3155+
if (isPlaying) {
31363156
colorGrading.redrawAnimated();
31373157
}
31383158

0 commit comments

Comments
 (0)