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:
devteamaegis
2026-06-30 00:30:52 -04:00
co-authored by claude-flow
parent 7d0e85ddb4
commit 048fd8cdad
7 changed files with 510 additions and 11 deletions
+87
View File
@@ -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<string, string>, 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>): 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]>): 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);
});
});