From 5ff63c4575e3b006fa9c0b43eb60d1316b14181c Mon Sep 17 00:00:00 2001 From: James Date: Tue, 13 Oct 2026 00:48:48 +0000 Subject: [PATCH 1/2] 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) --- .../src/runtime/adapters/gsap.steps.test.ts | 49 +++++++++++ packages/core/src/runtime/adapters/gsap.ts | 29 ++++-- .../gsap-redraw-start-and-overlap/meta.json | 13 +++ .../output/compiled.html | 3 + .../output/output.mp4 | 3 + .../src/index.html | 88 +++++++++++++++++++ 6 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 packages/producer/tests/gsap-redraw-start-and-overlap/meta.json create mode 100644 packages/producer/tests/gsap-redraw-start-and-overlap/output/compiled.html create mode 100644 packages/producer/tests/gsap-redraw-start-and-overlap/output/output.mp4 create mode 100644 packages/producer/tests/gsap-redraw-start-and-overlap/src/index.html diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 297c8f8cc75..68a29206598 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -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); + } + }); +}); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 7a3695f43de..51215fb3b9f 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -6,7 +6,8 @@ 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 the from-values of a tween starting at or just + * below `t`, so both are redone. */ export function rerenderGsapTimelineAt( timeline: { @@ -32,8 +33,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 +107,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 +130,23 @@ function primedAtItsStart(tween: GsapAnimation): boolean { return Boolean(tween.vars?.keyframes || tween._startAt); } -function primeTweensStartingAt(children: unknown[], time: number): void { +// `stepBelow`: how far below `time` the redraw stepped, in these children's time. +function primeTweensStartingAt(children: unknown[], time: number, stepBelow: 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, stepBelow * child.timeScale()); + } +} + +function primeStartedTween(child: GsapAnimation, local: number, stepBelow: number): void { + // The step down crossed this start and put the from-values back, and the step up does not reapply them: a fromTo + // starting 0.7 ms before a 30 fps frame lost its from-only opacity on that frame and every later one. + 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); } } diff --git a/packages/producer/tests/gsap-redraw-start-and-overlap/meta.json b/packages/producer/tests/gsap-redraw-start-and-overlap/meta.json new file mode 100644 index 00000000000..b822e39142f --- /dev/null +++ b/packages/producer/tests/gsap-redraw-start-and-overlap/meta.json @@ -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 + } +} diff --git a/packages/producer/tests/gsap-redraw-start-and-overlap/output/compiled.html b/packages/producer/tests/gsap-redraw-start-and-overlap/output/compiled.html new file mode 100644 index 00000000000..84a9fe0f441 --- /dev/null +++ b/packages/producer/tests/gsap-redraw-start-and-overlap/output/compiled.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26b283172c09db23296a6c56ad874777128f717f4c0fdc04f929d3f71c780233 +size 250278 diff --git a/packages/producer/tests/gsap-redraw-start-and-overlap/output/output.mp4 b/packages/producer/tests/gsap-redraw-start-and-overlap/output/output.mp4 new file mode 100644 index 00000000000..24c44809189 --- /dev/null +++ b/packages/producer/tests/gsap-redraw-start-and-overlap/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f2dd349ba5649286def84013001f6045d1d80279a333682f76bed188331d569 +size 51844 diff --git a/packages/producer/tests/gsap-redraw-start-and-overlap/src/index.html b/packages/producer/tests/gsap-redraw-start-and-overlap/src/index.html new file mode 100644 index 00000000000..e2c33620686 --- /dev/null +++ b/packages/producer/tests/gsap-redraw-start-and-overlap/src/index.html @@ -0,0 +1,88 @@ + + + + + + + + + +
+
+
DRIFT
+
+
+
+ + + + From 6c7f1b01db799e44f1002e43fb41704dd08200b6 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 13 Oct 2026 03:01:27 +0000 Subject: [PATCH 2/2] fix(core): keep the redraw's comment share and schedule the new fixture Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/runtime/adapters/gsap.ts | 14 +++++++------- packages/producer/tests/shard-schedule.json | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 51215fb3b9f..e152b986d81 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -6,8 +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 the from-values of a tween starting at or just - * below `t`, 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: { @@ -130,18 +129,19 @@ function primedAtItsStart(tween: GsapAnimation): boolean { return Boolean(tween.vars?.keyframes || tween._startAt); } -// `stepBelow`: how far below `time` the redraw stepped, in these children's time. -function primeTweensStartingAt(children: unknown[], time: number, stepBelow: 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; - if (local > 0) primeStartedTween(child, local, stepBelow * child.timeScale()); + if (local > 0) primeStartedTween(child, local, stepBelowInTheirTime * child.timeScale()); } } function primeStartedTween(child: GsapAnimation, local: number, stepBelow: number): void { - // The step down crossed this start and put the from-values back, and the step up does not reapply them: a fromTo - // starting 0.7 ms before a 30 fps frame lost its from-only opacity on that frame and every later one. if (local < stepBelow && child._startAt) child._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true); const nested = child.getChildren ? child : child.timeline; diff --git a/packages/producer/tests/shard-schedule.json b/packages/producer/tests/shard-schedule.json index b33adbe59f3..afe53de4223 100644 --- a/packages/producer/tests/shard-schedule.json +++ b/packages/producer/tests/shard-schedule.json @@ -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,