From 048fd8cdad61625e7676120f1b1f445e62f5be6d Mon Sep 17 00:00:00 2001 From: devteamaegis Date: Tue, 30 Jun 2026 00:30:52 -0400 Subject: [PATCH] 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); + }); +});