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>
88 lines
4.0 KiB
TypeScript
88 lines
4.0 KiB
TypeScript
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);
|
|
});
|
|
});
|