Skip to content

Commit b7343e3

Browse files
fix(core): a seek onto a tween's start shows that tween, not an earlier tween's end (heygen-com#4942)
* fix(core): a seek onto a tween's start shows that tween, not the earlier one's end The seek re-render stepped to just below the target and back. A child starting exactly at the target sits at local time 0 both before and after that step, so GSAP skips it going forward and an earlier tween on the same property keeps the frame (fromto edits at the playhead read the from() end, 35-50 px off). One forced silent render at the target walks every child forward in authored order instead, so same-time steps still apply in order. * style(core): format the seek re-render change * fix(core): re-render a seek from just above too, not with a forced render A forced render at the seek time redrew every tween GSAP had ever rendered, including ones that start later, at their recorded start values: after a seek past the end, a later tween's start showed early (style-5-prod, hdr-regression). The re-render now steps silently just above the target, then just below, then to it. The step up makes a child that starts exactly at the target cross its start on the way down, so it renders; tweens that have not started are left as the seek put them, and the last move is still forward, so same-time steps keep their authored order. * fix(core): re-cross only the tweens that start at the seek time The step up past the target (d5c5d79) initialised tweens starting within the next millisecond, so an overwrite: "auto" tween killed the one before it, and it crossed a repeatRefresh boundary sitting on the target. The re-render is back to heygen-com#4911's step from just below. That step skips a keyframes or stagger tween already at its start (GSAP passes no start crossing into the tween's own timeline), so after it each such tween that starts exactly at the target, at any nesting depth, re-crosses its own start alone. No other child moves. * refactor(core): keep the GSAP members the re-cross reads local to the adapter The shared child type gained members that clash with RuntimeTimelineLike in the runtime typecheck. The adapter now types them locally and skips any child that is not a GSAP animation, which also keeps the walk simple. * fix(core): re-cross only forward-playing tweens, by a microsecond GSAP skips paused and reversed children when it renders a timeline, so the re-cross does too: it read a reversed keyframed tween's start instead of its end and reset a paused one. The step past the start shrinks from 1 ms to 1 us, so it cannot start a stagger's next target or cross a short tween's repeat. * fix(core): re-cross only keyframed tweens, by less than GSAP's time step A 1 us step still reached a stagger target 0.5 us in and a 0.4 us repeat, both sizes GSAP can represent. The step is now 2e-8: above GSAP's zero threshold, so the tween leaves its start, but below its 1e-7 time rounding, so the tween's own timeline does not move. Only keyframed tweens are re-crossed; a stagger is left as main renders it. * fix(core): move keyframed tweens across their start before the forward step Re-crossing a keyframed tween after the timeline's forward render made it win over a later same-time sibling (a set after it at the same time showed the keyframe, not the set). The keyframed tweens that start at the target are now moved across their start during the step from below, and the timeline's own forward step renders them with every sibling, in authored order.
1 parent 5561b8c commit b7343e3

3 files changed

Lines changed: 208 additions & 5 deletions

File tree

‎packages/core/src/runtime/adapters/gsap.steps.test.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,157 @@ describe("gsap adapter on a step", () => {
6060
expect(gsap.getProperty(box, "x")).toBe(10);
6161
});
6262
});
63+
64+
describe("gsap adapter at a tween's start", () => {
65+
// An edit at 2 s turns the later tween into keyframes; its first keyframe must win over the from() end.
66+
it.each([0, 1, 2, 2.5, 3])(
67+
"shows the tween that starts at the seek time, seeking from %s s",
68+
(from) => {
69+
const box = document.body.appendChild(document.createElement("div"));
70+
const timeline = gsap.timeline({ paused: true });
71+
timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0);
72+
timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
73+
timeline.totalTime(from, true);
74+
createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({
75+
time: 2,
76+
});
77+
expect(gsap.getProperty(box, "x")).toBe(5);
78+
},
79+
);
80+
81+
it("leaves a tween that starts later alone after a seek past the end", () => {
82+
const box = document.body.appendChild(document.createElement("div"));
83+
const timeline = gsap.timeline({ paused: true });
84+
timeline.to(box, { x: -400, duration: 0.4 }, 4.8);
85+
timeline.to(box, { x: 0, duration: 0.4 }, 16);
86+
const adapter = createGsapAdapter({
87+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
88+
});
89+
for (const time of [20, 0, 4]) adapter.seek({ time });
90+
expect(gsap.getProperty(box, "x")).toBe(0);
91+
});
92+
93+
it.each([
94+
{ order: "keyframes, then a set", x: 42 },
95+
{ order: "a set, then keyframes", x: 5 },
96+
])("keeps the authored order of $order at the seek time", ({ order, x }) => {
97+
const box = document.body.appendChild(document.createElement("div"));
98+
const timeline = gsap.timeline({ paused: true });
99+
timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0);
100+
const keyframes = () =>
101+
timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
102+
const set = () => timeline.set(box, { x: 42 }, 2);
103+
if (order.startsWith("keyframes")) {
104+
keyframes();
105+
set();
106+
} else {
107+
set();
108+
keyframes();
109+
}
110+
const adapter = createGsapAdapter({
111+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
112+
});
113+
for (const from of [0, 1, 2, 2.5, 3]) {
114+
timeline.totalTime(from, true);
115+
adapter.seek({ time: 2 });
116+
expect(gsap.getProperty(box, "x")).toBe(x);
117+
}
118+
});
119+
120+
it("shows a keyframed tween's start inside a nested timeline, seeking onto it twice", () => {
121+
const box = document.body.appendChild(document.createElement("div"));
122+
const timeline = gsap.timeline({ paused: true });
123+
const scene = gsap.timeline();
124+
scene.from(box, { x: -60, duration: 2, ease: "none" }, 0);
125+
scene.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
126+
timeline.add(scene, 0.5);
127+
const adapter = createGsapAdapter({
128+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
129+
});
130+
adapter.seek({ time: 2.5 });
131+
adapter.seek({ time: 2.5 });
132+
expect(gsap.getProperty(box, "x")).toBe(5);
133+
});
134+
135+
it("does not start a tween that begins just after the seek time", () => {
136+
const box = document.body.appendChild(document.createElement("div"));
137+
const timeline = gsap.timeline({ paused: true });
138+
timeline.to(box, { x: 100, duration: 10, ease: "none" }, 0);
139+
timeline.to(box, { x: 200, duration: 1, ease: "none", overwrite: "auto" }, 2.0005);
140+
const adapter = createGsapAdapter({
141+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
142+
});
143+
adapter.seek({ time: 2 });
144+
adapter.seek({ time: 1 });
145+
expect(gsap.getProperty(box, "x")).toBe(10);
146+
});
147+
148+
it("keeps a relative repeatRefresh tween on its iteration at a repeat boundary", () => {
149+
const box = document.body.appendChild(document.createElement("div"));
150+
const timeline = gsap.timeline({ paused: true });
151+
timeline.to(box, { x: "+=10", duration: 1, repeat: 1, repeatRefresh: true, ease: "none" }, 0);
152+
const adapter = createGsapAdapter({
153+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
154+
});
155+
adapter.seek({ time: 1 });
156+
adapter.seek({ time: 1 });
157+
expect(gsap.getProperty(box, "x")).toBe(10);
158+
});
159+
160+
it.each([
161+
{
162+
shape: "a stagger whose next target starts 0.1 us later with overwrite auto",
163+
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
164+
timeline.to(o, { x: 100, duration: 10, ease: "none" }, 0);
165+
timeline.to([{ x: 0 }, o], { x: 200, duration: 1, stagger: 1e-7, overwrite: "auto" }, 2);
166+
},
167+
seeks: [2, 1],
168+
x: 10,
169+
},
170+
{
171+
shape: "a reversed keyframed tween",
172+
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
173+
const tween = gsap.to(o, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 });
174+
timeline.add(tween, 1);
175+
tween.timeScale(-1);
176+
},
177+
seeks: [1],
178+
x: 60,
179+
},
180+
{
181+
shape: "a paused keyframed tween",
182+
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
183+
const tween = gsap.to(o, {
184+
keyframes: { "0%": { x: 5 }, "100%": { x: 60 } },
185+
duration: 1,
186+
ease: "none",
187+
});
188+
timeline.add(tween, 1);
189+
tween.totalTime(0.5).pause();
190+
},
191+
seeks: [1, 1],
192+
x: 32.5,
193+
},
194+
{
195+
shape: "a 0.4 us repeatRefresh keyframed tween",
196+
build: (timeline: gsap.core.Timeline, o: { x: number }) => {
197+
const keyframes = [
198+
{ x: "+=10", duration: 2e-7 },
199+
{ x: "+=10", duration: 2e-7 },
200+
];
201+
timeline.to(o, { keyframes, repeat: 2, repeatRefresh: true, ease: "none" }, 2);
202+
},
203+
seeks: [2, 2],
204+
x: 0,
205+
},
206+
])("leaves $shape as GSAP renders it at its start", ({ build, seeks, x }) => {
207+
const o = { x: 0 };
208+
const timeline = gsap.timeline({ paused: true });
209+
build(timeline, o);
210+
const adapter = createGsapAdapter({
211+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
212+
});
213+
for (const time of seeks) adapter.seek({ time });
214+
expect(o.x).toBeCloseTo(x, 6);
215+
});
216+
});

‎packages/core/src/runtime/adapters/gsap.ts‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,57 @@ type GsapAdapterDeps = {
55
};
66

77
/**
8-
* Re-renders a timeline already moved to `t`, silently. It arrives from just below, since GSAP
9-
* applies same-time zero-duration steps in authored order only going forward; at 0 it comes from above.
8+
* Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored
9+
* order. That step skips a keyframed tween already at its start, so each one is first moved across its start alone.
1010
*/
1111
export function rerenderGsapTimelineAt(
12-
timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown },
12+
timeline: {
13+
totalTime: (time: number, suppressEvents?: boolean) => unknown;
14+
getChildren?: RuntimeTimelineLike["getChildren"];
15+
},
1316
t: number,
1417
): void {
1518
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
19+
primeKeyframedTweensStartingAt(timeline.getChildren?.(false, true, true) ?? [], t);
1620
timeline.totalTime(t, true);
1721
}
1822

23+
type GsapAnimation = {
24+
startTime: () => number;
25+
timeScale: () => number;
26+
totalDuration: () => number;
27+
paused: () => boolean;
28+
render: (totalTime: number, suppressEvents: boolean) => unknown;
29+
vars?: { keyframes?: unknown };
30+
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[];
31+
};
32+
33+
const BELOW_GSAP_TIME_RESOLUTION = 2e-8;
34+
35+
const playsForward = (value: unknown): value is GsapAnimation => {
36+
const animation = value as GsapAnimation | null;
37+
return (
38+
typeof animation?.render === "function" &&
39+
typeof animation.startTime === "function" &&
40+
typeof animation.paused === "function" &&
41+
typeof animation.timeScale === "function" &&
42+
animation.timeScale() > 0 &&
43+
!animation.paused()
44+
);
45+
};
46+
47+
function primeKeyframedTweensStartingAt(children: unknown[], time: number): void {
48+
for (const child of children.filter(playsForward)) {
49+
const local = (time - child.startTime()) * child.timeScale();
50+
if (Math.abs(local) < 1e-9 && child.vars?.keyframes) {
51+
child.render(BELOW_GSAP_TIME_RESOLUTION, true);
52+
child.render(-BELOW_GSAP_TIME_RESOLUTION, true);
53+
} else if (child.getChildren && local > 0 && local <= child.totalDuration()) {
54+
primeKeyframedTweensStartingAt(child.getChildren(false, true, true), local);
55+
}
56+
}
57+
}
58+
1959
export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter {
2060
return {
2161
name: "gsap",
@@ -28,7 +68,13 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
2868
const suppressEvents = ctx.suppressEvents === true;
2969
if (typeof timeline.totalTime === "function") {
3070
timeline.totalTime(safeTime, suppressEvents);
31-
rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime);
71+
rerenderGsapTimelineAt(
72+
{
73+
totalTime: timeline.totalTime.bind(timeline),
74+
getChildren: timeline.getChildren?.bind(timeline),
75+
},
76+
safeTime,
77+
);
3278
} else {
3379
timeline.seek(safeTime, suppressEvents);
3480
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4348,7 +4348,10 @@ export function initSandboxRuntimeModular(): void {
43484348
tl.totalTime(tlSeekTime, suppressEvents);
43494349
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
43504350
// The first seek is the only eventful one; the re-render only refreshes styles.
4351-
rerenderGsapTimelineAt({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime);
4351+
rerenderGsapTimelineAt(
4352+
{ totalTime: tl.totalTime.bind(tl), getChildren: tl.getChildren?.bind(tl) },
4353+
tlSeekTime,
4354+
);
43524355
}
43534356
} else {
43544357
tl.seek(tlSeekTime, suppressEvents);

0 commit comments

Comments
 (0)