feat(compiler): capture and replay Lottie animations
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), <lottie-player>/<dotlottie-player>, 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 <lottie-player> 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 <ruv@ruv.net>
This commit is contained in:
co-authored by
claude-flow
parent
7d0e85ddb4
commit
048fd8cdad
@@ -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<string, string>, sourceUrl: string, ctx?: RenderCtx, wires?: RuntimeSpec[], motionSpec?: MotionSpec, menus?: RTMenu[], accordions?: AccordionRuntimeSpec[]): string {
|
||||
export function generatePageTsx(ir: IR, assetMap: Map<string, string>, sourceUrl: string, ctx?: RenderCtx, wires?: RuntimeSpec[], motionSpec?: MotionSpec, menus?: RTMenu[], accordions?: AccordionRuntimeSpec[], lottieSpec?: LottieRuntimeSpec): string {
|
||||
// page renders the body's children; the <body> 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<string, string>, 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<string, string>, 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<id> 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",
|
||||
|
||||
@@ -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<string, string> => {
|
||||
const m = new Map<string, string>();
|
||||
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}<DittoLottie spec={${JSON.stringify(spec)}} />`;
|
||||
}
|
||||
|
||||
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<ReturnType<typeof lottie.loadAnimation>> = [];
|
||||
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;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user