fix(core): a render keeps a fromTo's from-values when a frame lands just after its start (#5506)

* fix(core): a render keeps a fromTo's from-values when a frame lands just after its start

The frame redraw steps to 1 ms below the frame and back. When a tween starts
inside that 1 ms, the step down reverts its from-values and the step up does
not reapply them, so a wipe bar whose opacity comes only from its from-vars was
never drawn. Reapply them for every tween whose start the step crossed, as
#5125 does for a tween starting exactly on the frame.

Adds the gsap-redraw-start-and-overlap producer fixture, which also pins that a
slow drift resumes after a short move on the same property, as in playback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(core): keep the redraw's comment share and schedule the new fixture

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-13 04:00:45 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1b60a40eff
commit 51e0323af1
7 changed files with 178 additions and 8 deletions
@@ -482,3 +482,52 @@ describe("re-rendering leaves the rest as the seek left it", () => {
expect(timeline.duration()).toBe(20);
});
});
describe("gsap adapter just after a tween's start", () => {
// Starts 0.5 ms before a frame, so the redraw's step down to 1 ms below that frame crosses its start.
it("keeps a fromTo's from-only opacity on every frame of a sweep that a later set hides", () => {
const bar = document.body.appendChild(document.createElement("div"));
bar.style.opacity = "0";
const timeline = gsap.timeline({ paused: true });
timeline.fromTo(
bar,
{ x: 1920, opacity: 1 },
{ x: -320, duration: 0.34, ease: "power2.inOut", immediateRender: false },
0.4995,
);
timeline.set(bar, { opacity: 0 }, 0.85);
timeline.set({}, {}, 2);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
const shown: string[] = [];
for (let frame = 0; frame <= 60; frame++) {
adapter.seek({ time: frame / 30 });
if (frame / 30 > 0.49 && frame / 30 < 0.84) shown.push(bar.style.opacity);
}
expect(new Set(shown)).toEqual(new Set(["1"]));
expect(bar.style.opacity).toBe("0");
});
// A slow drift with a short move on the same property inside it: the drift resumes after the move, as in playback.
it("renders overlapping tweens on one property as playback does", () => {
const film = () => {
const box = { y: 0 };
const timeline = gsap.timeline({ paused: true });
timeline.to(box, { y: 100, duration: 4, ease: "none" }, 0);
timeline.to(box, { y: 300, duration: 0.5, ease: "none" }, 1);
timeline.set({}, {}, 5);
return { box, timeline };
};
const played = film();
const seeked = film();
const adapter = createGsapAdapter({
getTimeline: () => seeked.timeline as unknown as RuntimeTimelineLike,
});
for (let frame = 0; frame <= 150; frame++) {
played.timeline.totalTime(frame / 30);
adapter.seek({ time: frame / 30 });
expect(seeked.box.y, `frame ${frame}`).toBeCloseTo(played.box.y, 6);
}
});
});
+21 -8
View File
@@ -6,7 +6,7 @@ type GsapAdapterDeps = {
/**
* Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored
* order. That step skips a keyframed tween at its start and undoes a tween's from-values there, so both are redone.
* order. That step skips a keyframed tween at its start and undoes from-values of tweens starting within it: redo both.
*/
export function rerenderGsapTimelineAt(
timeline: {
@@ -32,8 +32,9 @@ export function rerenderGsapTimelineAt(
);
for (const [child] of skipped) child._ts = 0;
try {
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
primeTweensStartingAt(children, t);
const below = t >= REDRAW_STEP;
timeline.totalTime(below ? t - REDRAW_STEP : t + REDRAW_STEP, true);
primeTweensStartingAt(children, t, below ? REDRAW_STEP : 0);
timeline.totalTime(t, true);
} finally {
for (const [child, timeScale] of skipped) child._ts = timeScale;
@@ -105,6 +106,8 @@ type GsapAnimation = {
const BELOW_GSAP_TIME_RESOLUTION = 2e-8;
const REDRAW_STEP = 0.001;
const playsForward = (value: unknown): value is GsapAnimation => {
const animation = value as GsapAnimation | null;
return (
@@ -126,14 +129,24 @@ function primedAtItsStart(tween: GsapAnimation): boolean {
return Boolean(tween.vars?.keyframes || tween._startAt);
}
function primeTweensStartingAt(children: unknown[], time: number): void {
function primeTweensStartingAt(
children: unknown[],
time: number,
stepBelowInTheirTime: number,
): void {
for (const child of children.filter(playsForward)) {
const local = (time - child.startTime()) * child.timeScale();
if (Math.abs(local) < 1e-9 && primedAtItsStart(child)) continue;
const nested = child.getChildren ? child : child.timeline;
if (nested?.getChildren && local > 0 && local <= child.totalDuration()) {
primeTweensStartingAt(nested.getChildren(false, true, true), local);
}
if (local > 0) primeStartedTween(child, local, stepBelowInTheirTime * child.timeScale());
}
}
function primeStartedTween(child: GsapAnimation, local: number, stepBelow: number): void {
if (local < stepBelow && child._startAt)
child._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true);
const nested = child.getChildren ? child : child.timeline;
if (nested?.getChildren && local <= child.totalDuration()) {
primeTweensStartingAt(nested.getChildren(false, true, true), local, stepBelow);
}
}
@@ -0,0 +1,13 @@
{
"name": "gsap-redraw-start-and-overlap",
"description": "Regression guard for the per-frame GSAP redraw. A wipe bar's fromTo starts 0.7 ms before a frame and only its from-vars make it visible: it must be drawn on every frame of its sweep. A slow drift with a short move on the same property inside it must resume after the move, as in playback.",
"tags": ["regression", "render-compat", "gsap"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:26b283172c09db23296a6c56ad874777128f717f4c0fdc04f929d3f71c780233
size 250278
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f2dd349ba5649286def84013001f6045d1d80279a333682f76bed188331d569
size 51844
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #c9cfdc;
font-family: sans-serif;
}
#main {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #c9cfdc;
}
.clip {
position: absolute;
inset: 0;
}
#word {
position: absolute;
left: 0;
top: 380px;
width: 1920px;
text-align: center;
font-size: 240px;
line-height: 1;
font-weight: 900;
color: #06081a;
}
#bar {
position: absolute;
left: 0;
top: 0;
width: 260px;
height: 1080px;
background: #d8ff4a;
opacity: 0;
}
</style>
</head>
<body>
<div
id="main"
data-composition-id="gsap-redraw-start-and-overlap"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="2.5"
>
<div id="scene" class="clip" data-start="0" data-duration="2.5">
<div id="word">DRIFT</div>
<div id="bar"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// A slow drift with a short move inside it: after the move ends at 1.3 s the drift resumes, as in playback.
tl.to("#word", { y: 200, duration: 2.5, ease: "none" }, 0);
tl.to("#word", { y: -250, duration: 0.5, ease: "none" }, 0.8);
// Starts 0.7 ms before frame 45 (1.5 s); only the from-vars make the bar visible.
tl.fromTo(
"#bar",
{ x: -260, opacity: 1 },
{ x: 1980, duration: 0.4, ease: "none", immediateRender: false },
1.4993
);
tl.set("#bar", { opacity: 0 }, 1.95);
window.__timelines["gsap-redraw-start-and-overlap"] = tl;
</script>
</body>
</html>
@@ -21,6 +21,7 @@
"dogs-captions": 524,
"font-variant-numeric": 7,
"gsap-letters-render-compat": 9,
"gsap-redraw-start-and-overlap": 6,
"hdr-hlg-regression": 125,
"hdr-regression": 207,
"iframe-render-compat": 11,