From ad87313ee2f04b598d88111e1dc0ee4f1bc33ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 25 Sep 2026 04:43:49 +0000 Subject: [PATCH] fix(core): a Lottie animation in a mounted composition plays from that composition's start (#4471) --- .../core/src/runtime/adapters/lottie.test.ts | 32 +++++++++++++++++++ packages/core/src/runtime/adapters/lottie.ts | 19 +++++++++-- packages/core/src/runtime/init.ts | 4 ++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/adapters/lottie.test.ts b/packages/core/src/runtime/adapters/lottie.test.ts index 880377bef9..3697c060fd 100644 --- a/packages/core/src/runtime/adapters/lottie.test.ts +++ b/packages/core/src/runtime/adapters/lottie.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createLottieAdapter } from "./lottie"; +import { createRuntimeStartTimeResolver } from "../startResolver"; const lottieWindow = window as Window & { lottie?: { @@ -84,6 +85,37 @@ describe("lottie adapter", () => { }); describe("seek", () => { + function mountedAt(start: string) { + document.body.innerHTML = `
`; + const { resolveStartForElement } = createRuntimeStartTimeResolver({}); + const adapter = createLottieAdapter({ + resolveStartSeconds: (el) => resolveStartForElement(el, 0), + }); + return { adapter, player: document.getElementById("player")! }; + } + + it("seeks a player in a mounted composition to the time since that composition started", () => { + const { adapter, player } = mountedAt("3"); + const anim = { ...createLottieWebAnim(), wrapper: player }; + const dot = { ...createDotLottiePlayer({ totalFrames: 60, frameRate: 30 }), canvas: player }; + lottieWindow.__hfLottie = [anim, dot]; + adapter.seek({ time: 4 }); + expect(anim.goToAndStop).toHaveBeenCalledWith(1000, false); + expect(dot.setCurrentRawFrameValue).toHaveBeenCalledWith(30); + adapter.seek({ time: 1 }); + expect(anim.goToAndStop).toHaveBeenLastCalledWith(0, false); + document.body.innerHTML = ""; + }); + + it("keeps page time for a player in a composition that starts at 0", () => { + const { adapter, player } = mountedAt("0"); + const anim = { ...createLottieWebAnim(), wrapper: player }; + lottieWindow.__hfLottie = [anim]; + adapter.seek({ time: 2 }); + expect(anim.goToAndStop).toHaveBeenCalledWith(2000, false); + document.body.innerHTML = ""; + }); + it("seeks lottie-web with goToAndStop in ms", () => { const anim = createLottieWebAnim(); lottieWindow.__hfLottie = [anim]; diff --git a/packages/core/src/runtime/adapters/lottie.ts b/packages/core/src/runtime/adapters/lottie.ts index f1edf2490a..3f7c3c2d8c 100644 --- a/packages/core/src/runtime/adapters/lottie.ts +++ b/packages/core/src/runtime/adapters/lottie.ts @@ -49,7 +49,19 @@ import { swallow } from "../diagnostics"; * via the global `lottie` object, so compositions that call * `lottie.loadAnimation(...)` without manually registering still work. */ -export function createLottieAdapter(): RuntimeDeterministicAdapter { +export function createLottieAdapter(params?: { + resolveStartSeconds?: (element: Element) => number; +}): RuntimeDeterministicAdapter { + const secondsIntoComposition = (anim: unknown, pageTime: number): number => { + const el = isLottieWebAnimation(anim) ? anim.wrapper : (anim as DotLottiePlayer).canvas; + const composition = + el && typeof (el as Element).closest === "function" + ? (el as Element).closest("[data-composition-id]") + : null; + if (!composition || !params?.resolveStartSeconds) return pageTime; + return Math.max(0, pageTime - params.resolveStartSeconds(composition)); + }; + return { name: "lottie", @@ -78,12 +90,13 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter { }, seek: (ctx) => { - const time = Math.max(0, Number(ctx.time) || 0); + const pageTime = Math.max(0, Number(ctx.time) || 0); const instances = (window as LottieWindow).__hfLottie; if (!instances || instances.length === 0) return; for (const anim of instances) { try { + const time = secondsIntoComposition(anim, pageTime); const loops = anim.loop === true; if (isLottieWebAnimation(anim)) { // lottie-web: AnimationItem @@ -245,6 +258,7 @@ interface LottieWebAnimation { totalFrames: number; frameRate: number; loop?: boolean | number; + wrapper?: unknown; } interface LottieWebGlobal { @@ -261,6 +275,7 @@ interface DotLottiePlayer { frameRate?: number; duration?: number; loop?: boolean; + canvas?: unknown; } interface LottieWindow extends Window { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2dfef746c7..1de8bd4859 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3269,7 +3269,9 @@ export function initSandboxRuntimeModular(): void { resolveStartSeconds: (element) => resolveStartForElement(element, 0), }), createAnimeJsAdapter(), - createLottieAdapter(), + createLottieAdapter({ + resolveStartSeconds: (element) => resolveStartForElement(element, 0), + }), createThreeAdapter(), createMapboxAdapter(), createLeafletAdapter(),