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..e152b986d81 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -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); } } 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
+
+
+
+ + + + 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,