Skip to content

Commit 861f955

Browse files
committed
fix(core): a root timeline binds at the playhead before its duration is known
The bind restore sat inside the positive-duration branch, so a root with no resolved duration yet stayed at 0 now that discovery no longer re-seeks a bound GSAP root.
1 parent e899e03 commit 861f955

2 files changed

Lines changed: 68 additions & 53 deletions

File tree

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

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2964,43 +2964,58 @@ describe("initSandboxRuntimeModular", () => {
29642964
vi.useRealTimers();
29652965
});
29662966

2967-
it("shows the playhead's frame when a seek-only root binds late during a paused drag", () => {
2968-
const raf = createManualRaf();
2969-
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
2970-
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
2971-
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
2972-
document.body.innerHTML = `
2973-
<div data-composition-id="main" data-root="true" data-duration="10" data-width="1920" data-height="1080">
2974-
<div id="sibling"></div>
2975-
<div id="dragged" data-hf-studio-manual-edit-gesture="tok-1"></div>
2976-
</div>
2977-
`;
2978-
window.__hfTimelinesBuilding = true;
2979-
initSandboxRuntimeModular();
2980-
window.__player?.seek(5);
2981-
2982-
const tl = gsap
2983-
.timeline({ paused: true })
2984-
.to("#sibling", { x: 100, duration: 10, ease: "none" });
2985-
const seekOnly: RuntimeTimelineLike = {
2986-
play: () => tl.play(),
2987-
pause: () => tl.pause(),
2988-
seek: (time, suppressEvents) => tl.seek(time ?? 0, suppressEvents),
2989-
time: () => tl.time(),
2990-
duration: () => tl.duration(),
2991-
add: (child, at) => tl.add(child as unknown as gsap.core.Timeline, at),
2992-
paused: (paused) => tl.paused(paused),
2993-
set: (target, vars, at) => tl.set(target, vars, at),
2994-
};
2995-
window.__timelines = { main: seekOnly };
2996-
window.__hfTimelinesBuilding = false;
2997-
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
2998-
raf.step(16);
2999-
raf.step(16);
2967+
// Discovery no longer re-seeks a bound GSAP root, so binding alone must land it on the playhead.
2968+
it.each([
2969+
{ root: "seek-only", declared: ' data-duration="10"', reported: 10 },
2970+
{ root: "seek-only", declared: "", reported: 0 },
2971+
{ root: "totalTime", declared: "", reported: 0 },
2972+
])(
2973+
"shows the playhead's frame when a $root root (duration $reported) binds late during a paused drag",
2974+
({ root, declared, reported }) => {
2975+
const raf = createManualRaf();
2976+
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
2977+
window.requestAnimationFrame =
2978+
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
2979+
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
2980+
document.body.innerHTML = `
2981+
<div data-composition-id="main" data-root="true"${declared} data-width="1920" data-height="1080">
2982+
<div id="sibling"></div>
2983+
<div id="dragged" data-hf-studio-manual-edit-gesture="tok-1"></div>
2984+
</div>
2985+
`;
2986+
window.__hfTimelinesBuilding = true;
2987+
initSandboxRuntimeModular();
2988+
window.__player?.seek(5);
2989+
2990+
const fired = vi.fn();
2991+
const tl = gsap
2992+
.timeline({ paused: true })
2993+
.to("#sibling", { x: 100, duration: 10, ease: "none" })
2994+
.call(fired, [], 2);
2995+
const facade: RuntimeTimelineLike = {
2996+
play: () => tl.play(),
2997+
pause: () => tl.pause(),
2998+
seek: (time, suppressEvents) => tl.seek(time ?? 0, suppressEvents),
2999+
time: () => tl.time(),
3000+
duration: () => reported,
3001+
add: (child, at) => tl.add(child as unknown as gsap.core.Timeline, at),
3002+
paused: (paused) => tl.paused(paused),
3003+
set: (target, vars, at) => tl.set(target, vars, at),
3004+
...(root === "totalTime" && {
3005+
totalTime: (time, suppressEvents) => tl.totalTime(time ?? 0, suppressEvents),
3006+
}),
3007+
};
3008+
window.__timelines = { main: facade };
3009+
window.__hfTimelinesBuilding = false;
3010+
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
3011+
raf.step(16);
3012+
raf.step(16);
30003013

3001-
expect(tl.time()).toBeCloseTo(5);
3002-
expect(gsap.getProperty("#sibling", "x")).toBeCloseTo(50);
3003-
});
3014+
expect(tl.time()).toBeCloseTo(5);
3015+
expect(gsap.getProperty("#sibling", "x")).toBeCloseTo(50);
3016+
expect(fired).toHaveBeenCalledTimes(1);
3017+
},
3018+
);
30043019

30053020
it("shows pip video at global start time even when host composition starts late", () => {
30063021
// Regression: resolveStartForElement used to add the host composition's start on top of

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

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1923,30 +1923,30 @@ export function initSandboxRuntimeModular(): void {
19231923
pauseTimelineIfPossible(state.capturedTimeline);
19241924
}
19251925
}
1926+
// Seek to the prior playhead (state.currentTime) so a rebind after a user
1927+
// scrub or soft-reload restore doesn't snap back to 0, whether or not the
1928+
// duration is known yet: discovery no longer re-seeks a bound root.
1929+
const seekTime = Math.max(0, state.currentTime || 0);
1930+
if (typeof state.capturedTimeline.totalTime === "function") {
1931+
// GSAP won't render tl.set() at position 0 when the paused timeline
1932+
// starts there — play/pause/seek/totalTime are all no-ops at the
1933+
// creation position. Force the set to render by cycling progress past
1934+
// 0 (when the timeline implements it).
1935+
if (typeof state.capturedTimeline.progress === "function") {
1936+
state.capturedTimeline.progress(0.0001, true);
1937+
}
1938+
state.capturedTimeline.totalTime(seekTime, false);
1939+
} else {
1940+
state.capturedTimeline.seek(seekTime, false);
1941+
}
1942+
pauseTimelineIfPossible(state.capturedTimeline);
19261943
if (boundDuration > 0) {
19271944
try {
19281945
clock.setDuration(boundDuration);
19291946
} catch {
19301947
// clock not yet initialized — duration will be set during TransportClock setup
19311948
}
19321949

1933-
// Seek to the prior playhead (state.currentTime) so a rebind after a user
1934-
// scrub or soft-reload restore doesn't snap back to 0.
1935-
const seekTime = Math.max(0, state.currentTime || 0);
1936-
if (typeof state.capturedTimeline.totalTime === "function") {
1937-
// GSAP won't render tl.set() at position 0 when the paused timeline
1938-
// starts there — play/pause/seek/totalTime are all no-ops at the
1939-
// creation position. Force the set to render by cycling progress past
1940-
// 0 (when the timeline implements it).
1941-
if (typeof state.capturedTimeline.progress === "function") {
1942-
state.capturedTimeline.progress(0.0001, true);
1943-
}
1944-
state.capturedTimeline.totalTime(seekTime, false);
1945-
} else {
1946-
state.capturedTimeline.seek(seekTime, false);
1947-
}
1948-
pauseTimelineIfPossible(state.capturedTimeline);
1949-
19501950
// GSAP bakes the CSS `translate` into style.transform on seek.
19511951
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
19521952
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook

0 commit comments

Comments
 (0)