From 048fd8cdad61625e7676120f1b1f445e62f5be6d Mon Sep 17 00:00:00 2001 From: devteamaegis Date: Tue, 30 Jun 2026 00:30:52 -0400 Subject: [PATCH 1/3] feat(compiler): capture and replay Lottie animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lottie-web renders vector animations from a JSON document at runtime. As third-party JS it falls outside the declarative CSS path and the WAAPI / reveal / marquee probes, so clones shipped the empty container and the animation was gone. This adds a Lottie subset to Stage 5 motion: capture the deterministic part — the source JSON (materialized local file, or inline animationData) plus playback config — and emit a fixed DittoLottie client that re-mounts lottie-web on the cid'd container. Capture (compiler/src/capture/lottie.ts): in-page detector covering lottie-web's registry (animationData in memory), /, Elementor data-settings (source_json.url), and generic markup. Keyed by data-cid-cap. motion.ts merges it into MotionCapture; capture.ts registers source URLs as assets so the existing fallback fetch downloads + materializes them to /assets/cloned/lottie. Generate (compiler/src/generate/lottie.ts): buildLottieSpec resolves cap->cid and source URL->materialized local path (or embeds inline animationData), then emits DittoLottie + wire/import helpers, mirroring DittoMotion. app.ts writes the component, wires import+JSX, and injects lottie-web into the generated package.json. Delivery (compiler/src/cli.ts): stripDeliveryDataCids now treats DittoLottie as a runtime consumer, so its container keeps a semantic data-ditto-id anchor instead of being stripped (without this the animation never finds its mount point). Lottie-free clones stay byte-identical. Verified end-to-end: cloning a page with a scrapes the JSON, emits a wired DittoLottie, and lottie-web mounts and plays the captured animation (rendered SVG viewBox matches the source JSON). Unit tests cover buildLottieSpec resolution + drop rules. Co-Authored-By: claude-flow --- compiler/src/capture/capture.ts | 5 + compiler/src/capture/lottie.ts | 230 ++++++++++++++++++++++++++++++++ compiler/src/capture/motion.ts | 13 +- compiler/src/cli.ts | 8 +- compiler/src/generate/app.ts | 23 +++- compiler/src/generate/lottie.ts | 155 +++++++++++++++++++++ compiler/test/lottie.test.ts | 87 ++++++++++++ 7 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 compiler/src/capture/lottie.ts create mode 100644 compiler/src/generate/lottie.ts create mode 100644 compiler/test/lottie.test.ts diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts index 50f9fb9..3412d7e 100644 --- a/compiler/src/capture/capture.ts +++ b/compiler/src/capture/capture.ts @@ -826,6 +826,11 @@ export async function captureSite(opts: { if (opts.motion && vw === canonical) { try { motion = await captureMotion(page, { log }); } catch (e) { log({ event: "motion_error", error: String(e).slice(0, 200) }); } + // Register lottie source JSONs as assets so the asset stage downloads + materializes + // them (the in-page detector only records the URL; the fallback fetch grabs the file). + for (const l of motion?.lotties ?? []) { + if (l.src) recordAsset(l.src, "lottie", null, null, "lottie"); + } } perViewport.push({ diff --git a/compiler/src/capture/lottie.ts b/compiler/src/capture/lottie.ts new file mode 100644 index 0000000..84c7356 --- /dev/null +++ b/compiler/src/capture/lottie.ts @@ -0,0 +1,230 @@ +import type { Page } from "playwright"; + +/** + * Stage 5 motion capture — Lottie subset. + * + * lottie-web (a.k.a. bodymovin) renders vector animations from a JSON document into an + * SVG/canvas/HTML subtree at runtime. It is third-party JavaScript, so the declarative CSS + * path and the WAAPI/reveal/marquee probes in `motion.ts` never reproduce it — the cloned + * page ships the empty container and the animation is simply gone. This probe records the + * deterministic subset we CAN reproduce: the animation's source JSON (the actual + * `animationData` document, or the URL it was fetched from) plus the playback config + * (renderer, loop, autoplay), keyed to the container's `data-cid-cap` so generation can + * re-mount a fixed lottie-web instance on the cloned node. + * + * Detection is layered, most-reliable first, deduped by `data-cid-cap`: + * 1. lottie-web's own registry (`window.lottie.getRegisteredAnimations()` / `bodymovin`) + * — yields the live `AnimationItem`, which carries the parsed `animationData`, its + * source `path`, the renderer type, and loop/autoplay. The gold path: the JSON is + * already in memory, no second fetch required. + * 2. Web components — `` / `` (the `src` attribute). + * 3. Elementor Pro Lottie widgets — `[data-settings]` JSON → `lottie.source_json.url`. + * 4. Generic markup — elements whose class matches /lottie/ carrying a data-* URL. + * + * Output is JSON-safe and threads through the IR exactly like the other motion specs. When + * a source URL exists it is recorded for the asset stage to download + localize; when the + * JSON is only available inline (data-driven init) the `animationData` is stashed directly + * (size-capped) so the pipeline can write it out as a local `.json` asset. + */ + +export type LottieRenderer = "svg" | "canvas" | "html"; + +export type LottieSpec = { + cap: string; // data-cid-cap of the lottie container (→ cid at generation) + via: "registry" | "player" | "elementor" | "attr"; // how it was detected (for diagnostics) + src: string | null; // absolute URL of the source .json, when known (preferred → asset download) + inlineKey: string | null; // key into the returned `inline` map when JSON is only in-memory + renderer: LottieRenderer; + loop: boolean; + autoplay: boolean; + // observed box, so generation can size the re-mounted player even before the JSON loads + width: number; + height: number; +}; + +export type LottieCapture = { + lotties: LottieSpec[]; + // animationData documents only available in-memory (no fetchable URL), keyed by inlineKey. + // The asset stage materializes these to local `.json` files; src-backed specs skip this. + inline: Record; +}; + +const EMPTY: LottieCapture = { lotties: [], inline: {} }; + +/** + * Captures lottie-web animations on the current page. Run at the canonical viewport AFTER + * `tagElements` (so containers carry `data-cid-cap`). lottie can initialize late (deferred + * bundles, `arriving_to_viewport` triggers), so the probe polls briefly within `budgetMs` + * for the registry to populate before falling back to static markup scans. + */ +export async function captureLotties( + page: Page, + opts?: { budgetMs?: number; maxInlineBytes?: number; log?: (e: Record) => void }, +): Promise { + const log = opts?.log ?? (() => {}); + const budgetMs = opts?.budgetMs ?? 2600; + const maxInlineBytes = opts?.maxInlineBytes ?? 3_000_000; // skip absurdly large inline blobs + + try { + const result = await Promise.race([ + page.evaluate( + async ({ budget, maxInline }: { budget: number; maxInline: number }) => { + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const out: Array<{ + cap: string; + via: "registry" | "player" | "elementor" | "attr"; + src: string | null; + inlineKey: string | null; + renderer: "svg" | "canvas" | "html"; + loop: boolean; + autoplay: boolean; + width: number; + height: number; + }> = []; + const inline: Record = {}; + const seen = new Set(); // dedup by cap + let inlineN = 0; + + const abs = (u: string | null | undefined): string | null => { + if (!u || typeof u !== "string") return null; + try { return new URL(u, document.baseURI).href; } catch { return null; } + }; + // climb to the nearest ancestor (or self) carrying a data-cid-cap + const capOf = (node: Element | null): string | null => { + let el: Element | null = node; + while (el && !el.getAttribute?.("data-cid-cap")) el = el.parentElement; + return el?.getAttribute?.("data-cid-cap") ?? null; + }; + const boxOf = (el: Element | null): { width: number; height: number } => { + try { const r = (el as HTMLElement)?.getBoundingClientRect?.(); return { width: Math.round(r?.width ?? 0), height: Math.round(r?.height ?? 0) }; } catch { return { width: 0, height: 0 }; } + }; + const stashInline = (data: unknown): string | null => { + try { + const s = JSON.stringify(data); + if (!s || s.length > maxInline) return null; + const key = "lottie_inline_" + inlineN++; + inline[key] = JSON.parse(s); // ensure JSON-safe, structured-clone friendly + return key; + } catch { return null; } + }; + const push = (rec: { + container: Element | null; via: "registry" | "player" | "elementor" | "attr"; + src: string | null; inlineKey: string | null; + renderer: string | null | undefined; loop: unknown; autoplay: unknown; + }) => { + const cap = capOf(rec.container); + if (!cap || seen.has(cap)) return; + if (!rec.src && !rec.inlineKey) return; // nothing reproducible + seen.add(cap); + const r = (rec.renderer || "svg").toString().toLowerCase(); + const renderer: "svg" | "canvas" | "html" = r === "canvas" ? "canvas" : r === "html" ? "html" : "svg"; + const box = boxOf(rec.container); + out.push({ + cap, via: rec.via, src: rec.src, inlineKey: rec.inlineKey, renderer, + loop: rec.loop === undefined ? true : !!rec.loop, + autoplay: rec.autoplay === undefined ? true : !!rec.autoplay, + width: box.width, height: box.height, + }); + }; + + // ---- 1. lottie-web registry (poll for late init within the budget) ---- + const readRegistry = () => { + const w = window as unknown as { + lottie?: { getRegisteredAnimations?: () => unknown[] }; + bodymovin?: { getRegisteredAnimations?: () => unknown[] }; + }; + const lib = w.lottie || w.bodymovin; + const anims = (lib?.getRegisteredAnimations?.() as Array>) || []; + for (const a of anims) { + const wrapper = (a.wrapper as Element) || ((a.renderer as { svgElement?: Element })?.svgElement) || null; + const container = wrapper instanceof Element ? wrapper : null; + if (!container) continue; + if (seen.has(capOf(container) || "")) continue; + // source: prefer a fetchable URL (path is the directory, fileName the file); + // fall back to the in-memory animationData document. + const path = (a.path as string) || ""; + const fileName = (a.fileName as string) || ""; + let src: string | null = null; + if (path) src = abs(path.endsWith("/") || !fileName ? path + (fileName.endsWith(".json") ? fileName : "data.json") : path + (fileName ? (fileName.includes(".") ? fileName : fileName + ".json") : "")); + let inlineKey: string | null = null; + if (!src && a.animationData) inlineKey = stashInline(a.animationData); + const rendererType = ((a.renderer as { rendererType?: string })?.rendererType) + || (a.renderer && a.renderer.constructor && a.renderer.constructor.name === "CanvasRenderer" ? "canvas" : undefined); + push({ container, via: "registry", src, inlineKey, renderer: rendererType, loop: a.loop, autoplay: a.autoplay }); + } + }; + const deadline = Date.now() + budget; + do { + readRegistry(); + if (out.length) break; // got something from the registry; static scans below still run once + await sleep(200); + } while (Date.now() < deadline); + + // ---- 2. / web components ---- + document.querySelectorAll("lottie-player, dotlottie-player").forEach((el) => { + const src = abs(el.getAttribute("src") || el.getAttribute("data-src")); + let inlineKey: string | null = null; + if (!src) { + try { + const data = (el as unknown as { getLottie?: () => { animationData?: unknown } }).getLottie?.()?.animationData; + if (data) inlineKey = stashInline(data); + } catch { /* ignore */ } + } + push({ + container: el, via: "player", src, inlineKey, + renderer: el.getAttribute("renderer"), + loop: el.hasAttribute("loop") || el.getAttribute("loop") === "true", + autoplay: el.hasAttribute("autoplay") || el.getAttribute("autoplay") === "true", + }); + }); + + // ---- 3. Elementor Pro Lottie widgets ---- + document.querySelectorAll(".elementor-widget-lottie [data-settings], [data-widget_type^='lottie'] [data-settings], .e-lottie__animation").forEach((el) => { + let settingsHost: Element | null = el; + // settings live on the widget root; climb if we matched the inner animation node + if (!settingsHost.getAttribute("data-settings")) settingsHost = el.closest("[data-settings]"); + let src: string | null = null; + let renderer: string | null = null; + let loop: unknown = true; + let autoplay: unknown = true; + try { + const raw = settingsHost?.getAttribute("data-settings"); + if (raw) { + const s = JSON.parse(raw) as Record; + const l = (s.lottie as Record) || s; + const sj = (l.source_json as Record) || {}; + src = abs((sj.url as string) || (l.source_external_url as string) || (l.lottie_url as string)); + renderer = (l.renderer as string) || (s.renderer as string) || null; + if ("loop" in l) loop = (l.loop as string) !== "no" && !!l.loop; + if ("trigger" in l) autoplay = true; // static host: ignore arriving_to_viewport, autoplay + } + } catch { /* ignore */ } + push({ container: el, via: "elementor", src, inlineKey: null, renderer, loop, autoplay }); + }); + + // ---- 4. Generic markup fallback ---- + document.querySelectorAll("[class*='lottie'],[data-animation-type='lottie']").forEach((el) => { + if (seen.has(capOf(el) || "")) return; + const src = abs( + el.getAttribute("data-src") || el.getAttribute("data-animation-path") + || el.getAttribute("data-animation_url") || el.getAttribute("data-lottie") + ); + if (!src) return; + push({ container: el, via: "attr", src, inlineKey: null, renderer: el.getAttribute("data-renderer"), loop: true, autoplay: true }); + }); + + return { lotties: out, inline }; + }, + { budget: budgetMs, maxInline: maxInlineBytes }, + ), + new Promise((resolve) => setTimeout(() => resolve(EMPTY), budgetMs + 1500)), + ]); + + const cap = (result as LottieCapture) || EMPTY; + log({ stage: "lottie", found: cap.lotties.length, inline: Object.keys(cap.inline).length }); + return cap; + } catch (e) { + log({ stage: "lottie", error: String((e as Error)?.message ?? e) }); + return EMPTY; + } +} diff --git a/compiler/src/capture/motion.ts b/compiler/src/capture/motion.ts index 6bbd63f..7019bd8 100644 --- a/compiler/src/capture/motion.ts +++ b/compiler/src/capture/motion.ts @@ -1,4 +1,5 @@ import type { Page } from "playwright"; +import { captureLotties, type LottieSpec } from "./lottie.js"; /** * Stage 5 motion capture. Runs at the canonical viewport AFTER `tagElements` (so every @@ -55,6 +56,8 @@ export type MotionCapture = { rotators: RotatorSpec[]; reveals: RevealSpec[]; // scroll-triggered entrance reveals (start hidden, reveal on view) marquees: MarqueeSpec[]; // rAF-driven continuous tickers (Framer Motion etc.) — not in getAnimations() + lotties: LottieSpec[]; // lottie-web JSON animations (third-party JS the CSS/WAAPI paths can't reproduce) + lottieInline: Record; // animationData only available in-memory, keyed by LottieSpec.inlineKey cssAnimated: number; // elements with a computed animation-name (informational) }; @@ -329,7 +332,7 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log return { waapi, rotators, reveals, cssAnimated }; }, observeMs), - new Promise>((res) => setTimeout(() => res({ waapi: [], rotators: [], reveals: [], cssAnimated: 0 }), observeMs + 4000)), + new Promise>((res) => setTimeout(() => res({ waapi: [], rotators: [], reveals: [], cssAnimated: 0 }), observeMs + 4000)), ]); // Scroll-scrub reveals: scroll-linked panels probeReveals can't see (they start only // partially hidden). Merge into reveals, preferring the probe-confirmed entry when a cap @@ -341,10 +344,12 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log // Marquees run as a separate pass (scrolls each track into view to wake the paused // rAF ticker; kept after the rotator MutationObserver window so it can't add false text rotators). const marquees = await detectMarquees(page); - log({ event: "motion_captured", waapi: result.waapi.length, rotators: result.rotators.length, reveals: reveals.length, marquees: marquees.length, cssAnimated: result.cssAnimated }); - return { ...result, reveals, marquees }; + // Lottie: third-party JSON animations, captured separately (registry + static markup scan). + const lottie = await captureLotties(page, { log }); + log({ event: "motion_captured", waapi: result.waapi.length, rotators: result.rotators.length, reveals: reveals.length, marquees: marquees.length, lotties: lottie.lotties.length, cssAnimated: result.cssAnimated }); + return { ...result, reveals, marquees, lotties: lottie.lotties, lottieInline: lottie.inline }; } catch (e) { log({ event: "motion_error", error: String(e).slice(0, 200) }); - return { waapi: [], rotators: [], reveals: [], marquees: [], cssAnimated: 0 }; + return { waapi: [], rotators: [], reveals: [], marquees: [], lotties: [], lottieInline: {}, cssAnimated: 0 }; } } diff --git a/compiler/src/cli.ts b/compiler/src/cli.ts index 198fa6f..edb95af 100755 --- a/compiler/src/cli.ts +++ b/compiler/src/cli.ts @@ -160,6 +160,8 @@ export function stripDeliveryDataCids(appDir: string): { removed: number; kept: for (const line of text.split("\n")) { if (line.includes(" { const anchor = anchors.get(cid)?.name; if (anchor) { kept++; return ` data-ditto-id="${anchor}"`; } @@ -383,13 +385,13 @@ function pruneUnusedSvgDittoIds(files: string[]): void { } function rewriteRuntimeAnchorQueries(text: string, fileName: string): string { - if (!/^(?:DittoMotion|DittoWire|DropdownMenu|Accordion)\.tsx$/.test(fileName)) return text; + if (!/^(?:DittoMotion|DittoLottie|DittoWire|DropdownMenu|Accordion)\.tsx$/.test(fileName)) return text; let next = text .replace(/\bbyCid\b/g, "byDittoId") .replace(/const byDittoId = \(cid: string\): HTMLElement \| null => document\.querySelector\('\[data-cid="' \+ cid \+ '"\]'\);/g, `const byDittoId = (id: string): HTMLElement | null => document.querySelector('[data-ditto-id="' + id + '"]');`) .replace(/data-cid/g, "data-ditto-id"); - if (fileName === "DittoMotion.tsx") { + if (fileName === "DittoMotion.tsx" || fileName === "DittoLottie.tsx") { next = next .replace(/\bcid: string/g, "anchor: string") .replace(/\.cid\b/g, ".anchor"); diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 2e34bb0..4592e11 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -7,6 +7,7 @@ import { generateCss, RESET_CSS } from "./css.js"; import { generateInteractionCss } from "./interactionCss.js"; import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, accordionJsx, accordionImportPath, ACCORDION_TSX, type AccordionRuntimeSpec, type RuntimeSpec } from "./interactive.js"; import { buildMotionSpec, motionWireJsx, dittoMotionImportPath, motionHasContent, DITTO_MOTION_TSX, type MotionSpec } from "./motion.js"; +import { buildLottieSpec, lottieWireJsx, dittoLottieImportPath, lottieHasContent, DITTO_LOTTIE_TSX, type LottieSpec as LottieRuntimeSpec } from "./lottie.js"; import { buildMenuSpecs, menusJsx, dropdownMenuImportPath, DROPDOWN_MENU_TSX, type RTMenu } from "./menu.js"; import type { AssetGraph } from "../infer/assets.js"; import type { FontGraph } from "../infer/fonts.js"; @@ -2035,15 +2036,17 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe return { files, contentDecls }; } -export function generatePageTsx(ir: IR, assetMap: Map, sourceUrl: string, ctx?: RenderCtx, wires?: RuntimeSpec[], motionSpec?: MotionSpec, menus?: RTMenu[], accordions?: AccordionRuntimeSpec[]): string { +export function generatePageTsx(ir: IR, assetMap: Map, sourceUrl: string, ctx?: RenderCtx, wires?: RuntimeSpec[], motionSpec?: MotionSpec, menus?: RTMenu[], accordions?: AccordionRuntimeSpec[], lottieSpec?: LottieRuntimeSpec): string { // page renders the body's children; the element itself (c0) is rendered // by layout so cid alignment is preserved. const hasWires = !!wires && wires.length > 0; const hasMotion = !!motionSpec && motionHasContent(motionSpec); + const hasLottie = !!lottieSpec && lottieHasContent(lottieSpec); const hasMenus = !!menus && menus.length > 0; const hasAccordions = !!accordions && accordions.length > 0; const wiresBlock = hasWires ? "\n" + wiresJsx(wires!, 3) : ""; const motionBlock = hasMotion ? "\n" + motionWireJsx(motionSpec!, 3) : ""; + const lottieBlock = hasLottie ? "\n" + lottieWireJsx(lottieSpec!, 3) : ""; const menusBlock = hasMenus ? "\n" + menusJsx(menus!, 3) : ""; const accordionBlock = hasAccordions ? "\n" + accordionJsx(accordions!, 3) : ""; // Render the body first so component extraction populates the registry, then import @@ -2061,6 +2064,7 @@ export function generatePageTsx(ir: IR, assetMap: Map, sourceUrl hasWires ? `import DittoWire from "${dittoWireImportPath(0)}";` : "", hasAccordions ? `import Accordion from "${accordionImportPath(0)}";` : "", hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "", + hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "", hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "", ...compImports, ].filter(Boolean).join("\n"); @@ -2068,7 +2072,7 @@ export function generatePageTsx(ir: IR, assetMap: Map, sourceUrl return `${importBlock}export default function Page() { return ( <> -${body}${wiresBlock}${accordionBlock}${motionBlock}${menusBlock} +${body}${wiresBlock}${accordionBlock}${motionBlock}${lottieBlock}${menusBlock} ); } @@ -2365,6 +2369,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: const accordions = runtimeSpecs.filter((s): s is AccordionRuntimeSpec => s.kind === "accordion"); const wires = runtimeSpecs.filter((s) => s.kind !== "accordion"); const motionSpec = buildMotionSpec(ir, input.motion); + const lottieSpec = buildLottieSpec(ir, input.motion, assetGraph); const components = input.components ? buildComponentRegistry(ir, input.primitives, input.recipeReport) : undefined; const linkRewrite = sameOriginRelativeLinkRewrite(sourceUrl); const menus = buildMenuSpecs(ir, input.interaction?.menus, assetMap, sourceUrl, linkRewrite); @@ -2386,7 +2391,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: const sectionPlan = humanize ? planSections(ir, input.recipeReport) : undefined; const sections: SectionRegistry | undefined = sectionPlan && sectionPlan.roots.size > 0 ? { plan: sectionPlan, modules: new Map(), order: [] } : undefined; const svgs: SvgRegistry | undefined = humanize ? { byKey: new Map(), defs: new Map(), order: [], nameCount: new Map() } : undefined; - const pageTsx = generatePageTsx(ir, assetMap, sourceUrl, { primitives: input.primitives, components, linkRewrite, classOf, styleOf, sections, svgs }, wires, motionSpec, menus, accordions); + const pageTsx = generatePageTsx(ir, assetMap, sourceUrl, { primitives: input.primitives, components, linkRewrite, classOf, styleOf, sections, svgs }, wires, motionSpec, menus, accordions, lottieSpec); // Tailwind mode: utilities live in the JSX; ditto.css carries only pseudo-elements + // keyframes + interaction CSS (keyed by [data-cid], since nodes have no c class). // Tailwind mode folds hover/focus into the className as `hover:`/`focus:` variant utilities @@ -2398,7 +2403,8 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: const contentTs = contentModule(components, sectionOut.contentDecls); // Scaffold - writeText(join(appDir, "package.json"), framework === "vite" ? (tw ? PACKAGE_JSON_VITE_TW : PACKAGE_JSON_VITE) : (tw ? PACKAGE_JSON_TW : PACKAGE_JSON)); + const pkgBase = framework === "vite" ? (tw ? PACKAGE_JSON_VITE_TW : PACKAGE_JSON_VITE) : (tw ? PACKAGE_JSON_TW : PACKAGE_JSON); + writeText(join(appDir, "package.json"), lottieHasContent(lottieSpec) ? injectLottieDep(pkgBase) : pkgBase); writeText(join(appDir, "tsconfig.json"), framework === "vite" ? TSCONFIG_JSON_VITE : TSCONFIG_JSON); if (framework === "vite") { rmSync(join(appDir, "next.config.mjs"), { force: true }); @@ -2482,6 +2488,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: if (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX); if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX); if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX); + if (lottieHasContent(lottieSpec)) writeText(join(rootDir, "ditto", "DittoLottie.tsx"), DITTO_LOTTIE_TSX); if (menus.length) writeText(join(rootDir, "ditto", "DropdownMenu.tsx"), DROPDOWN_MENU_TSX); const routeSummary = routeSummaryFromIr(ir, "/", "/", sourceUrl); if (framework === "next") { @@ -2511,6 +2518,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: ...(wires.length ? ["DittoWire"] : []), ...(accordions.length ? ["Accordion"] : []), ...(motionHasContent(motionSpec) ? ["DittoMotion"] : []), + ...(lottieHasContent(lottieSpec) ? ["DittoLottie"] : []), ...(menus.length ? ["DropdownMenu"] : []), ], }); @@ -2518,6 +2526,13 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] }; } +/** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at + * runtime, so it must be installed alongside react/next. Injected only when the page + * actually has lottie content, keeping lottie-free clones byte-identical to before. */ +export function injectLottieDep(pkgJson: string): string { + return pkgJson.replace(/("dependencies":\s*\{\n)/, `$1 "lottie-web": "5.12.2",\n`); +} + export const PACKAGE_JSON = `{ "name": "cloned-app", "version": "0.1.0", diff --git a/compiler/src/generate/lottie.ts b/compiler/src/generate/lottie.ts new file mode 100644 index 0000000..b64c74e --- /dev/null +++ b/compiler/src/generate/lottie.ts @@ -0,0 +1,155 @@ +import type { IR, IRNode } from "../normalize/ir.js"; +import { isTextChild } from "../normalize/ir.js"; +import type { MotionCapture } from "../capture/motion.js"; +import type { AssetGraph } from "../infer/assets.js"; + +/** + * Stage 5 motion controller — Lottie subset. lottie-web renders a JSON document into an + * SVG/canvas subtree at runtime; it is third-party JS, so neither the declarative CSS path + * nor `DittoMotion` reproduce it. This emits one fixed `'use client'` component, + * `DittoLottie`, that re-mounts a lottie-web instance on the cid'd container using the + * captured source — the materialized local `.json` (preferred) or, when the animation was + * only ever in memory, the inline `animationData` embedded in the spec. + * + * Mirrors the DittoMotion contract: it DOES start on mount (the clone replays motion), and + * it honors the validator's measurement hook — when `window.__dittoMotionStopped` is set it + * mounts a single static frame so gates that grade the settled base still see a stable + * picture. The container is cleared before mount so the captured placeholder frame and the + * live animation never stack (the duplicate-render failure mode of naive mirror shims). + */ + +const capToCid = (ir: IR): Map => { + const m = new Map(); + const walk = (n: IRNode): void => { + const cap = n.attrs["data-cid-cap"]; + if (cap !== undefined) m.set(cap, n.id); + for (const c of n.children) if (!isTextChild(c)) walk(c); + }; + walk(ir.root); + return m; +}; + +export type RTLottie = { + cid: string; + renderer: "svg" | "canvas"; + loop: boolean; + autoplay: boolean; + path: string | null; // public-relative URL of the materialized .json (preferred) + animationData: unknown | null; // inline JSON, only when no fetchable URL existed + width: number; + height: number; +}; + +export type LottieSpec = { items: RTLottie[] }; + +/** + * Resolve captured lottie specs (keyed by data-cid-cap, with a source URL or inline-data + * key) to runtime specs keyed by cid, with the URL rewritten to its materialized local + * path. Specs whose node didn't survive pruning, or whose JSON neither downloaded nor has + * inline data, are dropped — only reproducible animations are emitted. + */ +export function buildLottieSpec( + ir: IR, + motion: MotionCapture | undefined, + assetGraph: AssetGraph, + include?: (cid: string) => boolean, +): LottieSpec { + const lotties = motion?.lotties ?? []; + if (!lotties.length) return { items: [] }; + const map = capToCid(ir); + const inline = motion?.lottieInline ?? {}; + const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid)); + const items: RTLottie[] = []; + + for (const s of lotties) { + const cid = map.get(s.cap); + if (!ok(cid)) continue; + + let path: string | null = null; + if (s.src) { + const entry = assetGraph.byUrl.get(s.src); + if (entry && entry.classification === "downloaded" && entry.localPath) path = entry.localPath; + } + let animationData: unknown | null = null; + if (!path && s.inlineKey && Object.prototype.hasOwnProperty.call(inline, s.inlineKey)) { + animationData = inline[s.inlineKey] ?? null; + } + if (!path && animationData == null) continue; // nothing reproducible + + items.push({ + cid, + renderer: s.renderer === "canvas" ? "canvas" : "svg", + loop: s.loop, + autoplay: s.autoplay, + path, + animationData, + width: s.width, + height: s.height, + }); + } + // deterministic order + items.sort((a, b) => a.cid.localeCompare(b.cid)); + return { items }; +} + +export function lottieHasContent(spec: LottieSpec): boolean { + return spec.items.length > 0; +} + +export function dittoLottieImportPath(depth: number): string { + return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DittoLottie"; +} + +export function lottieWireJsx(spec: LottieSpec, indent: number): string { + if (!lottieHasContent(spec)) return ""; + const pad = " ".repeat(indent); + return `${pad}`; +} + +export const DITTO_LOTTIE_TSX = `"use client"; +import { useEffect } from "react"; +import lottie from "lottie-web"; + +type RTLottie = { + cid: string; + renderer: "svg" | "canvas"; + loop: boolean; + autoplay: boolean; + path: string | null; + animationData: unknown | null; + width: number; + height: number; +}; +export type LottieSpec = { items: RTLottie[] }; + +const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]'); + +export default function DittoLottie({ spec }: { spec: LottieSpec }) { + useEffect(() => { + const stopped = (window as any).__dittoMotionStopped === true; + const anims: Array> = []; + for (const it of spec.items) { + const el = byCid(it.cid); + if (!el) continue; + // clear the captured placeholder frame so it never stacks with the live render + el.innerHTML = ""; + try { + const anim = lottie.loadAnimation({ + container: el, + renderer: it.renderer === "canvas" ? "canvas" : "svg", + loop: it.loop, + autoplay: it.autoplay && !stopped, + ...(it.path ? { path: it.path } : { animationData: it.animationData as object }), + rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, + }); + if (stopped) { try { anim.goToAndStop(0, true); } catch {} } + anims.push(anim); + } catch { + /* a single bad animation must not break the page */ + } + } + return () => { for (const a of anims) { try { a.destroy(); } catch {} } }; + }, [spec]); + return null; +} +`; diff --git a/compiler/test/lottie.test.ts b/compiler/test/lottie.test.ts new file mode 100644 index 0000000..7e6e231 --- /dev/null +++ b/compiler/test/lottie.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { IR, IRNode, IRChild } from "../src/normalize/ir.js"; +import type { MotionCapture } from "../src/capture/motion.js"; +import type { AssetGraph, AssetEntry } from "../src/infer/assets.js"; +import { buildLottieSpec, lottieHasContent } from "../src/generate/lottie.js"; + +/** Minimal IRNode — buildLottieSpec only reads id / attrs / children. */ +function node(id: string, attrs: Record, children: IRChild[] = []): IRNode { + return { id, tag: "div", attrs, visibleByVp: {}, bboxByVp: {}, computedByVp: {}, children } as IRNode; +} + +function irWith(root: IRNode): IR { + return { doc: {} as IR["doc"], root } as IR; +} + +function emptyMotion(over: Partial): MotionCapture { + return { waapi: [], rotators: [], reveals: [], marquees: [], lotties: [], lottieInline: {}, cssAnimated: 0, ...over }; +} + +function downloadedGraph(url: string, localPath: string): AssetGraph { + const entry: AssetEntry = { + sourceUrl: url, type: "lottie", classification: "downloaded", + localPath, storedFile: localPath.split("/").pop()!, bytes: 100, reason: null, impact: null, via: ["lottie"], + }; + return { entries: [entry], byUrl: new Map([[url, entry]]) }; +} + +const lottie = (over: Partial): MotionCapture["lotties"][number] => ({ + cap: "cap-1", via: "player", src: null, inlineKey: null, renderer: "svg", loop: true, autoplay: true, width: 240, height: 240, ...over, +}); + +describe("buildLottieSpec", () => { + it("resolves a URL-backed lottie: cap->cid and src->materialized local path", () => { + const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "cap-1" })])); + const motion = emptyMotion({ lotties: [lottie({ src: "https://x/anim.json" })] }); + const graph = downloadedGraph("https://x/anim.json", "/assets/cloned/lottie/abc.json"); + + const spec = buildLottieSpec(ir, motion, graph); + assert.equal(spec.items.length, 1); + const it0 = spec.items[0]!; + assert.equal(it0.cid, "n6"); + assert.equal(it0.path, "/assets/cloned/lottie/abc.json"); + assert.equal(it0.animationData, null); + assert.equal(it0.renderer, "svg"); + assert.equal(it0.loop, true); + assert.ok(lottieHasContent(spec)); + }); + + it("falls back to inline animationData when there is no fetchable URL", () => { + const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "cap-1" })])); + const motion = emptyMotion({ + lotties: [lottie({ src: null, inlineKey: "k0", renderer: "canvas" })], + lottieInline: { k0: { v: "5.7", layers: [] } }, + }); + const graph: AssetGraph = { entries: [], byUrl: new Map() }; + + const spec = buildLottieSpec(ir, motion, graph); + assert.equal(spec.items.length, 1); + assert.equal(spec.items[0]!.path, null); + assert.deepEqual(spec.items[0]!.animationData, { v: "5.7", layers: [] }); + assert.equal(spec.items[0]!.renderer, "canvas"); + }); + + it("drops a lottie whose container node didn't survive into the IR", () => { + const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "other-cap" })])); + const motion = emptyMotion({ lotties: [lottie({ src: "https://x/anim.json" })] }); + const graph = downloadedGraph("https://x/anim.json", "/assets/cloned/lottie/abc.json"); + + assert.equal(buildLottieSpec(ir, motion, graph).items.length, 0); + }); + + it("drops a lottie whose JSON neither downloaded nor has inline data", () => { + const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "cap-1" })])); + const motion = emptyMotion({ lotties: [lottie({ src: "https://x/missing.json" })] }); + const graph: AssetGraph = { entries: [], byUrl: new Map() }; // never downloaded + + const spec = buildLottieSpec(ir, motion, graph); + assert.equal(spec.items.length, 0); + assert.equal(lottieHasContent(spec), false); + }); + + it("returns empty for a capture with no lotties", () => { + const ir = irWith(node("n0", {})); + assert.equal(buildLottieSpec(ir, emptyMotion({}), { entries: [], byUrl: new Map() }).items.length, 0); + }); +}); From 9a89fed365a61c14b3427f8663b531fb5a443a50 Mon Sep 17 00:00:00 2001 From: devteamaegis Date: Tue, 30 Jun 2026 00:52:09 -0400 Subject: [PATCH 2/3] feat(compiler): Lottie in multi-page output + materialize inline animationData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the single-page Lottie support: 1. Multi-page (clone-site). generateSiteApp emitted only DittoWire; it now wires DittoLottie per route exactly like wires — builds the route's lottie spec, emits the import + JSX, writes DittoLottie.tsx once, and injects lottie-web into the site package.json when any route replays an animation (the write moved after the route loop so the dep reflects actual usage). cloneSite now captures motion on full route captures (motion: full) so lotties are observed; light sibling probes skip it. Per-route gating verified: a route with no animation gets no DittoLottie. 2. Inline animationData. When a lottie is only in memory (data-driven init, no fetchable URL), capture now writes the JSON to the assets store as a real, content-hashed .json so it materializes to /assets/cloned/lottie like any source — instead of embedding the blob in the page spec. buildLottieSpec then resolves it to a local path; the spec carries path, not animationData. Verified end-to-end: a 2-page fixture emits DittoLottie only on the animated route with lottie-web added; a data-driven inline fixture materializes fd7c…json and the spec references it by path. Co-Authored-By: claude-flow --- compiler/src/capture/capture.ts | 15 ++++++++++++++- compiler/src/site/cloneSite.ts | 2 +- compiler/src/site/generateSite.ts | 23 ++++++++++++++++++----- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts index 3412d7e..da15fca 100644 --- a/compiler/src/capture/capture.ts +++ b/compiler/src/capture/capture.ts @@ -829,7 +829,20 @@ export async function captureSite(opts: { // Register lottie source JSONs as assets so the asset stage downloads + materializes // them (the in-page detector only records the URL; the fallback fetch grabs the file). for (const l of motion?.lotties ?? []) { - if (l.src) recordAsset(l.src, "lottie", null, null, "lottie"); + if (l.src) { recordAsset(l.src, "lottie", null, null, "lottie"); continue; } + // Inline animationData (no fetchable URL): store it as a real .json asset so it + // materializes to /assets/cloned/lottie like any other source, instead of being + // embedded in the page spec. Content-hashed so output stays deterministic. + if (l.inlineKey && motion?.lottieInline) { + const data = motion.lottieInline[l.inlineKey]; + if (data === undefined) continue; + const buf = Buffer.from(JSON.stringify(data), "utf8"); + const synthUrl = `ditto-inline:/lottie/${sha1_12(buf.toString("utf8"))}.json`; + recordAsset(synthUrl, "lottie", "application/json", 200, "lottie-inline"); + storeBytes(synthUrl, "lottie", buf); + l.src = synthUrl; // now resolves to a local path through the asset graph + l.inlineKey = null; + } } } diff --git a/compiler/src/site/cloneSite.ts b/compiler/src/site/cloneSite.ts index 2803da2..455dd2e 100644 --- a/compiler/src/site/cloneSite.ts +++ b/compiler/src/site/cloneSite.ts @@ -128,7 +128,7 @@ export async function runCloneSite(opts: CloneSiteOptions): Promise {} }); + const capture = await captureSite({ url, outDir: sourceDir, viewports, interactions: opts.interactions && full, motion: full, screenshots, log: () => {} }); const ir = buildIR(sourceDir, viewports); const assetGraph = buildAssetGraph(capture); const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url); diff --git a/compiler/src/site/generateSite.ts b/compiler/src/site/generateSite.ts index 5e15a40..cebb63a 100644 --- a/compiler/src/site/generateSite.ts +++ b/compiler/src/site/generateSite.ts @@ -14,7 +14,8 @@ import { writeText, readJSON, fileExists } from "../util/fsx.js"; import { generateCss, RESET_CSS } from "../generate/css.js"; import { generateInteractionCss } from "../generate/interactionCss.js"; import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js"; -import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js"; +import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js"; +import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js"; import { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js"; import type { InteractionCapture } from "../capture/interactions.js"; import type { IRChild } from "../normalize/ir.js"; @@ -267,7 +268,8 @@ export function generateSiteApp(opts: { }; // Shared scaffold (written once). - writeText(join(appDir, "package.json"), isVite ? (tw ? PACKAGE_JSON_VITE_TW : PACKAGE_JSON_VITE) : (tw ? PACKAGE_JSON_TW : PACKAGE_JSON)); + // package.json is written after the route loop (below) so the lottie-web dependency can be + // added when any route actually replays a Lottie animation. writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON); writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n"); if (isVite) { @@ -325,6 +327,7 @@ export function generateSiteApp(opts: { let assetsCopied = 0; let assetsMissing = 0; let anyWires = false; + let anyLottie = false; const outRoutes: SiteGenResult["routes"] = []; const components = { count: 0, byType: {} as Record }; const materializedAssets = new Set(); @@ -368,6 +371,12 @@ export function generateSiteApp(opts: { const wireImport = wires.length ? `import DittoWire from "${dittoWireImportPath(depth)}";\n` : ""; const wireBody = wires.length ? "\n" + wiresJsx(wires, 3) : ""; if (wires.length) anyWires = true; + // Lottie replay for this route (third-party JSON animations). Same per-route shape as wires. + const lottieSpec = buildLottieSpec(route.ir, route.capture?.motion, route.assetGraph, include); + const hasLottie = lottieHasContent(lottieSpec); + const lottieImport = hasLottie ? `import DittoLottie from "${dittoLottieImportPath(depth)}";\n` : ""; + const lottieBody = hasLottie ? "\n" + lottieWireJsx(lottieSpec, 3) : ""; + if (hasLottie) anyLottie = true; const rKey = routeKey(route.routePath); const routeDir = isVite ? join(appDir, "src", "routes", rKey) @@ -379,8 +388,8 @@ export function generateSiteApp(opts: { const compImport = componentImports(routeReg, 0); // route-local → ./components/Name const dataDecls = componentDataDecls(routeReg); const preBlock = dataDecls ? dataDecls + "\n\n" : ""; - const importLines = [wireImport.trimEnd(), compImport].filter(Boolean).join("\n"); - const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody} + const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), compImport].filter(Boolean).join("\n"); + const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody}${lottieBody} ); } @@ -409,6 +418,10 @@ export function generateSiteApp(opts: { outRoutes.push({ routePath: route.routePath, href, dir, nodeCount: route.ir.doc.nodeCount }); } if (anyWires) writeText(join(appDir, "src", isVite ? "ditto" : join("app", "ditto"), "DittoWire.tsx"), DITTO_WIRE_TSX); + if (anyLottie) writeText(join(appDir, "src", isVite ? "ditto" : join("app", "ditto"), "DittoLottie.tsx"), DITTO_LOTTIE_TSX); + // Deferred package.json — inject lottie-web only when a route replays a Lottie animation. + const sitePkg = isVite ? (tw ? PACKAGE_JSON_VITE_TW : PACKAGE_JSON_VITE) : (tw ? PACKAGE_JSON_TW : PACKAGE_JSON); + writeText(join(appDir, "package.json"), anyLottie ? injectLottieDep(sitePkg) : sitePkg); if (isVite) { writeText(join(appDir, "vite.config.ts"), generateViteConfig(viteEntries)); for (const [rel, body] of seoStaticFiles(seoInventory, seoRoutes)) writeText(join(appDir, "public", rel), body); @@ -442,7 +455,7 @@ export function generateSiteApp(opts: { componentCount: extracted.chrome.length + extracted.routes.reduce((sum, route) => sum + route.components.length, 0), svgCount: 0, hasContentModule: false, - runtimeUtilities: anyWires ? ["DittoWire"] : [], + runtimeUtilities: [...(anyWires ? ["DittoWire"] : []), ...(anyLottie ? ["DittoLottie"] : [])], }); return { routes: outRoutes, assetsCopied, assetsMissing, components, extracted, seoInventory }; From 13ce4b89a1802b0d9ec28f742ae85548542ccf8a Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Fri, 3 Jul 2026 14:03:42 -0700 Subject: [PATCH 3/3] Fix Lottie replay for Next builds and fallback sources --- compiler/src/capture/lottie.ts | 17 +++++++++--- compiler/src/generate/lottie.ts | 48 ++++++++++++++++++--------------- compiler/test/lottie.test.ts | 14 ++++++++++ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/compiler/src/capture/lottie.ts b/compiler/src/capture/lottie.ts index 84c7356..272fc24 100644 --- a/compiler/src/capture/lottie.ts +++ b/compiler/src/capture/lottie.ts @@ -89,6 +89,13 @@ export async function captureLotties( if (!u || typeof u !== "string") return null; try { return new URL(u, document.baseURI).href; } catch { return null; } }; + const registrySrc = (path: string, fileName: string): string | null => { + if (!path) return null; + if (/\.json(?:[?#]|$)/i.test(path)) return abs(path); + if (!fileName) return null; + const base = path.endsWith("/") ? path : path + "/"; + return abs(base + (fileName.includes(".") ? fileName : fileName + ".json")); + }; // climb to the nearest ancestor (or self) carrying a data-cid-cap const capOf = (node: Element | null): string | null => { let el: Element | null = node; @@ -144,10 +151,12 @@ export async function captureLotties( // fall back to the in-memory animationData document. const path = (a.path as string) || ""; const fileName = (a.fileName as string) || ""; - let src: string | null = null; - if (path) src = abs(path.endsWith("/") || !fileName ? path + (fileName.endsWith(".json") ? fileName : "data.json") : path + (fileName ? (fileName.includes(".") ? fileName : fileName + ".json") : "")); - let inlineKey: string | null = null; - if (!src && a.animationData) inlineKey = stashInline(a.animationData); + const src = registrySrc(path, fileName); + // Keep in-memory data as a fallback even when the registry reports a URL: + // some sites expose only a directory-ish path, and guessed `data.json` + // URLs 404. Generation prefers a downloaded local path, then falls back + // to this JSON if the URL is absent or unreachable. + const inlineKey = a.animationData ? stashInline(a.animationData) : null; const rendererType = ((a.renderer as { rendererType?: string })?.rendererType) || (a.renderer && a.renderer.constructor && a.renderer.constructor.name === "CanvasRenderer" ? "canvas" : undefined); push({ container, via: "registry", src, inlineKey, renderer: rendererType, loop: a.loop, autoplay: a.autoplay }); diff --git a/compiler/src/generate/lottie.ts b/compiler/src/generate/lottie.ts index b64c74e..7eeb49d 100644 --- a/compiler/src/generate/lottie.ts +++ b/compiler/src/generate/lottie.ts @@ -108,7 +108,6 @@ export function lottieWireJsx(spec: LottieSpec, indent: number): string { export const DITTO_LOTTIE_TSX = `"use client"; import { useEffect } from "react"; -import lottie from "lottie-web"; type RTLottie = { cid: string; @@ -127,28 +126,33 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data export default function DittoLottie({ spec }: { spec: LottieSpec }) { useEffect(() => { const stopped = (window as any).__dittoMotionStopped === true; - const anims: Array> = []; - for (const it of spec.items) { - const el = byCid(it.cid); - if (!el) continue; - // clear the captured placeholder frame so it never stacks with the live render - el.innerHTML = ""; - try { - const anim = lottie.loadAnimation({ - container: el, - renderer: it.renderer === "canvas" ? "canvas" : "svg", - loop: it.loop, - autoplay: it.autoplay && !stopped, - ...(it.path ? { path: it.path } : { animationData: it.animationData as object }), - rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, - }); - if (stopped) { try { anim.goToAndStop(0, true); } catch {} } - anims.push(anim); - } catch { - /* a single bad animation must not break the page */ + const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = []; + let cancelled = false; + void (async () => { + const lottie = (await import("lottie-web")).default; + if (cancelled) return; + for (const it of spec.items) { + const el = byCid(it.cid); + if (!el) continue; + // clear the captured placeholder frame so it never stacks with the live render + el.innerHTML = ""; + try { + const anim = lottie.loadAnimation({ + container: el, + renderer: it.renderer === "canvas" ? "canvas" : "svg", + loop: it.loop, + autoplay: it.autoplay && !stopped, + ...(it.path ? { path: it.path } : { animationData: it.animationData as object }), + rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, + }); + if (stopped) { try { anim.goToAndStop(0, true); } catch {} } + anims.push(anim); + } catch { + /* a single bad animation must not break the page */ + } } - } - return () => { for (const a of anims) { try { a.destroy(); } catch {} } }; + })().catch(() => {}); + return () => { cancelled = true; for (const a of anims) { try { a.destroy(); } catch {} } }; }, [spec]); return null; } diff --git a/compiler/test/lottie.test.ts b/compiler/test/lottie.test.ts index 7e6e231..1b7c2e4 100644 --- a/compiler/test/lottie.test.ts +++ b/compiler/test/lottie.test.ts @@ -62,6 +62,20 @@ describe("buildLottieSpec", () => { assert.equal(spec.items[0]!.renderer, "canvas"); }); + it("falls back to inline animationData when a source URL was detected but not downloaded", () => { + const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "cap-1" })])); + const motion = emptyMotion({ + lotties: [lottie({ src: "https://x/missing.json", inlineKey: "k0" })], + lottieInline: { k0: { v: "5.7", layers: [] } }, + }); + const graph: AssetGraph = { entries: [], byUrl: new Map() }; + + const spec = buildLottieSpec(ir, motion, graph); + assert.equal(spec.items.length, 1); + assert.equal(spec.items[0]!.path, null); + assert.deepEqual(spec.items[0]!.animationData, { v: "5.7", layers: [] }); + }); + it("drops a lottie whose container node didn't survive into the IR", () => { const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "other-cap" })])); const motion = emptyMotion({ lotties: [lottie({ src: "https://x/anim.json" })] });