Merge pull request #9 from ion-design/lottie-animation-capture

feat(compiler): capture and replay Lottie animations
This commit is contained in:
Samraaj Bath
2026-07-03 14:05:40 -07:00
committed by GitHub
9 changed files with 569 additions and 17 deletions
+18
View File
@@ -826,6 +826,24 @@ export async function captureSite(opts: {
if (opts.motion && vw === canonical) { if (opts.motion && vw === canonical) {
try { motion = await captureMotion(page, { log }); } try { motion = await captureMotion(page, { log }); }
catch (e) { log({ event: "motion_error", error: String(e).slice(0, 200) }); } 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"); 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;
}
}
} }
perViewport.push({ perViewport.push({
+239
View File
@@ -0,0 +1,239 @@
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 — `<lottie-player>` / `<dotlottie-player>` (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<string, unknown>;
};
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<string, unknown>) => void },
): Promise<LottieCapture> {
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<string, unknown> = {};
const seen = new Set<string>(); // 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; }
};
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;
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<Record<string, unknown>>) || [];
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) || "";
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 });
}
};
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. <lottie-player> / <dotlottie-player> 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<string, unknown>;
const l = (s.lottie as Record<string, unknown>) || s;
const sj = (l.source_json as Record<string, unknown>) || {};
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<LottieCapture>((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;
}
}
+9 -4
View File
@@ -1,4 +1,5 @@
import type { Page } from "playwright"; 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 * Stage 5 motion capture. Runs at the canonical viewport AFTER `tagElements` (so every
@@ -55,6 +56,8 @@ export type MotionCapture = {
rotators: RotatorSpec[]; rotators: RotatorSpec[];
reveals: RevealSpec[]; // scroll-triggered entrance reveals (start hidden, reveal on view) reveals: RevealSpec[]; // scroll-triggered entrance reveals (start hidden, reveal on view)
marquees: MarqueeSpec[]; // rAF-driven continuous tickers (Framer Motion etc.) — not in getAnimations() 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<string, unknown>; // animationData only available in-memory, keyed by LottieSpec.inlineKey
cssAnimated: number; // elements with a computed animation-name (informational) 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 }; return { waapi, rotators, reveals, cssAnimated };
}, observeMs), }, observeMs),
new Promise<Omit<MotionCapture, "marquees">>((res) => setTimeout(() => res({ waapi: [], rotators: [], reveals: [], cssAnimated: 0 }), observeMs + 4000)), new Promise<Omit<MotionCapture, "marquees" | "lotties" | "lottieInline">>((res) => setTimeout(() => res({ waapi: [], rotators: [], reveals: [], cssAnimated: 0 }), observeMs + 4000)),
]); ]);
// Scroll-scrub reveals: scroll-linked panels probeReveals can't see (they start only // 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 // 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 // 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). // rAF ticker; kept after the rotator MutationObserver window so it can't add false text rotators).
const marquees = await detectMarquees(page); 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 }); // Lottie: third-party JSON animations, captured separately (registry + static markup scan).
return { ...result, reveals, marquees }; 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) { } catch (e) {
log({ event: "motion_error", error: String(e).slice(0, 200) }); 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 };
} }
} }
+5 -3
View File
@@ -164,6 +164,8 @@ export function stripDeliveryDataCids(appDir: string): { removed: number; kept:
for (const line of text.split("\n")) { for (const line of text.split("\n")) {
if (line.includes("<DittoMotion")) { if (line.includes("<DittoMotion")) {
for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "motion"); for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "motion");
} else if (line.includes("<DittoLottie")) {
for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "lottie");
} else if (line.includes("<DittoWire") || line.includes("<Accordion")) { } else if (line.includes("<DittoWire") || line.includes("<Accordion")) {
for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "interaction"); for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "interaction");
} else if (line.includes("<DropdownMenu")) { } else if (line.includes("<DropdownMenu")) {
@@ -209,7 +211,7 @@ export function stripDeliveryDataCids(appDir: string): { removed: number; kept:
if (isCodeFile(f)) { if (isCodeFile(f)) {
next = rewriteDeliveryImportsAndMeta(next, componentsWithMetaAnchors); next = rewriteDeliveryImportsAndMeta(next, componentsWithMetaAnchors);
next = rewriteRuntimeAnchorQueries(next, basename(f)); next = rewriteRuntimeAnchorQueries(next, basename(f));
if (/\bDittoMotion\b/.test(next)) next = next.replace(/"cid":/g, '"anchor":'); if (/\bDittoMotion\b|\bDittoLottie\b/.test(next)) next = next.replace(/"cid":/g, '"anchor":');
next = next.replace(/\sdata-cid="([^"]+)"/g, (_full, cid: string) => { next = next.replace(/\sdata-cid="([^"]+)"/g, (_full, cid: string) => {
const anchor = anchors.get(cid)?.name; const anchor = anchors.get(cid)?.name;
if (anchor) { kept++; return ` data-ditto-id="${anchor}"`; } if (anchor) { kept++; return ` data-ditto-id="${anchor}"`; }
@@ -387,13 +389,13 @@ function pruneUnusedSvgDittoIds(files: string[]): void {
} }
function rewriteRuntimeAnchorQueries(text: string, fileName: string): string { 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 let next = text
.replace(/\bbyCid\b/g, "byDittoId") .replace(/\bbyCid\b/g, "byDittoId")
.replace(/const byDittoId = \(cid: string\): HTMLElement \| null => document\.querySelector\('\[data-cid="' \+ cid \+ '"\]'\);/g, .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 + '"]');`) `const byDittoId = (id: string): HTMLElement | null => document.querySelector('[data-ditto-id="' + id + '"]');`)
.replace(/data-cid/g, "data-ditto-id"); .replace(/data-cid/g, "data-ditto-id");
if (fileName === "DittoMotion.tsx") { if (fileName === "DittoMotion.tsx" || fileName === "DittoLottie.tsx") {
next = next next = next
.replace(/\bcid: string/g, "anchor: string") .replace(/\bcid: string/g, "anchor: string")
.replace(/\.cid\b/g, ".anchor"); .replace(/\.cid\b/g, ".anchor");
+19 -4
View File
@@ -7,6 +7,7 @@ import { generateCss, RESET_CSS } from "./css.js";
import { generateInteractionCss } from "./interactionCss.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 { 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 { 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 { buildMenuSpecs, menusJsx, dropdownMenuImportPath, DROPDOWN_MENU_TSX, type RTMenu } from "./menu.js";
import type { AssetGraph } from "../infer/assets.js"; import type { AssetGraph } from "../infer/assets.js";
import type { FontGraph } from "../infer/fonts.js"; import type { FontGraph } from "../infer/fonts.js";
@@ -2035,15 +2036,17 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe
return { files, contentDecls }; 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 // page renders the body's children; the <body> element itself (c0) is rendered
// by layout so cid alignment is preserved. // by layout so cid alignment is preserved.
const hasWires = !!wires && wires.length > 0; const hasWires = !!wires && wires.length > 0;
const hasMotion = !!motionSpec && motionHasContent(motionSpec); const hasMotion = !!motionSpec && motionHasContent(motionSpec);
const hasLottie = !!lottieSpec && lottieHasContent(lottieSpec);
const hasMenus = !!menus && menus.length > 0; const hasMenus = !!menus && menus.length > 0;
const hasAccordions = !!accordions && accordions.length > 0; const hasAccordions = !!accordions && accordions.length > 0;
const wiresBlock = hasWires ? "\n" + wiresJsx(wires!, 3) : ""; const wiresBlock = hasWires ? "\n" + wiresJsx(wires!, 3) : "";
const motionBlock = hasMotion ? "\n" + motionWireJsx(motionSpec!, 3) : ""; const motionBlock = hasMotion ? "\n" + motionWireJsx(motionSpec!, 3) : "";
const lottieBlock = hasLottie ? "\n" + lottieWireJsx(lottieSpec!, 3) : "";
const menusBlock = hasMenus ? "\n" + menusJsx(menus!, 3) : ""; const menusBlock = hasMenus ? "\n" + menusJsx(menus!, 3) : "";
const accordionBlock = hasAccordions ? "\n" + accordionJsx(accordions!, 3) : ""; const accordionBlock = hasAccordions ? "\n" + accordionJsx(accordions!, 3) : "";
// Render the body first so component extraction populates the registry, then import // 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)}";` : "", hasWires ? `import DittoWire from "${dittoWireImportPath(0)}";` : "",
hasAccordions ? `import Accordion from "${accordionImportPath(0)}";` : "", hasAccordions ? `import Accordion from "${accordionImportPath(0)}";` : "",
hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "", hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "",
hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "",
hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "", hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "",
...compImports, ...compImports,
].filter(Boolean).join("\n"); ].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 `${importBlock}export default function Page() {
return ( 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 accordions = runtimeSpecs.filter((s): s is AccordionRuntimeSpec => s.kind === "accordion");
const wires = runtimeSpecs.filter((s) => s.kind !== "accordion"); const wires = runtimeSpecs.filter((s) => s.kind !== "accordion");
const motionSpec = buildMotionSpec(ir, input.motion); 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 components = input.components ? buildComponentRegistry(ir, input.primitives, input.recipeReport) : undefined;
const linkRewrite = sameOriginRelativeLinkRewrite(sourceUrl); const linkRewrite = sameOriginRelativeLinkRewrite(sourceUrl);
const menus = buildMenuSpecs(ir, input.interaction?.menus, assetMap, sourceUrl, linkRewrite); 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 sectionPlan = humanize ? planSections(ir, input.recipeReport) : undefined;
const sections: SectionRegistry | undefined = sectionPlan && sectionPlan.roots.size > 0 ? { plan: sectionPlan, modules: new Map(), order: [] } : 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 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 + // 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). // 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 // 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); const contentTs = contentModule(components, sectionOut.contentDecls);
// Scaffold // 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); writeText(join(appDir, "tsconfig.json"), framework === "vite" ? TSCONFIG_JSON_VITE : TSCONFIG_JSON);
if (framework === "vite") { if (framework === "vite") {
rmSync(join(appDir, "next.config.mjs"), { force: true }); 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 (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX);
if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_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 (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); if (menus.length) writeText(join(rootDir, "ditto", "DropdownMenu.tsx"), DROPDOWN_MENU_TSX);
const routeSummary = routeSummaryFromIr(ir, "/", "/", sourceUrl); const routeSummary = routeSummaryFromIr(ir, "/", "/", sourceUrl);
if (framework === "next") { if (framework === "next") {
@@ -2511,6 +2518,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
...(wires.length ? ["DittoWire"] : []), ...(wires.length ? ["DittoWire"] : []),
...(accordions.length ? ["Accordion"] : []), ...(accordions.length ? ["Accordion"] : []),
...(motionHasContent(motionSpec) ? ["DittoMotion"] : []), ...(motionHasContent(motionSpec) ? ["DittoMotion"] : []),
...(lottieHasContent(lottieSpec) ? ["DittoLottie"] : []),
...(menus.length ? ["DropdownMenu"] : []), ...(menus.length ? ["DropdownMenu"] : []),
], ],
}); });
@@ -2518,6 +2526,13 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] }; 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 = `{ export const PACKAGE_JSON = `{
"name": "cloned-app", "name": "cloned-app",
"version": "0.1.0", "version": "0.1.0",
+159
View File
@@ -0,0 +1,159 @@
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";
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<{ 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 */
}
}
})().catch(() => {});
return () => { cancelled = true; for (const a of anims) { try { a.destroy(); } catch {} } };
}, [spec]);
return null;
}
`;
+1 -1
View File
@@ -130,7 +130,7 @@ export async function runCloneSite(opts: CloneSiteOptions): Promise<CloneSiteRes
// Only capture interactions on full (all-viewport) route captures, never the // Only capture interactions on full (all-viewport) route captures, never the
// light single-viewport sibling probes used for collection confirmation. // light single-viewport sibling probes used for collection confirmation.
const full = viewports.length === REQUIRED_VIEWPORTS.length; const full = viewports.length === REQUIRED_VIEWPORTS.length;
const capture = await captureSite({ url, outDir: sourceDir, viewports, interactions: opts.interactions && full, screenshots, log: () => {} }); const capture = await captureSite({ url, outDir: sourceDir, viewports, interactions: opts.interactions && full, motion: full, screenshots, log: () => {} });
const ir = buildIR(sourceDir, viewports); const ir = buildIR(sourceDir, viewports);
const assetGraph = buildAssetGraph(capture); const assetGraph = buildAssetGraph(capture);
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url); const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
+18 -5
View File
@@ -14,7 +14,8 @@ import { writeText, readJSON, fileExists } from "../util/fsx.js";
import { generateCss, RESET_CSS } from "../generate/css.js"; import { generateCss, RESET_CSS } from "../generate/css.js";
import { generateInteractionCss } from "../generate/interactionCss.js"; import { generateInteractionCss } from "../generate/interactionCss.js";
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.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 { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js";
import type { InteractionCapture } from "../capture/interactions.js"; import type { InteractionCapture } from "../capture/interactions.js";
import type { IRChild } from "../normalize/ir.js"; import type { IRChild } from "../normalize/ir.js";
@@ -267,7 +268,8 @@ export function generateSiteApp(opts: {
}; };
// Shared scaffold (written once). // 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, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON);
writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n"); writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n");
if (isVite) { if (isVite) {
@@ -325,6 +327,7 @@ export function generateSiteApp(opts: {
let assetsCopied = 0; let assetsCopied = 0;
let assetsMissing = 0; let assetsMissing = 0;
let anyWires = false; let anyWires = false;
let anyLottie = false;
const outRoutes: SiteGenResult["routes"] = []; const outRoutes: SiteGenResult["routes"] = [];
const components = { count: 0, byType: {} as Record<string, number> }; const components = { count: 0, byType: {} as Record<string, number> };
const materializedAssets = new Set<string>(); const materializedAssets = new Set<string>();
@@ -368,6 +371,12 @@ export function generateSiteApp(opts: {
const wireImport = wires.length ? `import DittoWire from "${dittoWireImportPath(depth)}";\n` : ""; const wireImport = wires.length ? `import DittoWire from "${dittoWireImportPath(depth)}";\n` : "";
const wireBody = wires.length ? "\n" + wiresJsx(wires, 3) : ""; const wireBody = wires.length ? "\n" + wiresJsx(wires, 3) : "";
if (wires.length) anyWires = true; 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 rKey = routeKey(route.routePath);
const routeDir = isVite const routeDir = isVite
? join(appDir, "src", "routes", rKey) ? join(appDir, "src", "routes", rKey)
@@ -379,8 +388,8 @@ export function generateSiteApp(opts: {
const compImport = componentImports(routeReg, 0); // route-local → ./components/Name const compImport = componentImports(routeReg, 0); // route-local → ./components/Name
const dataDecls = componentDataDecls(routeReg); const dataDecls = componentDataDecls(routeReg);
const preBlock = dataDecls ? dataDecls + "\n\n" : ""; const preBlock = dataDecls ? dataDecls + "\n\n" : "";
const importLines = [wireImport.trimEnd(), compImport].filter(Boolean).join("\n"); 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} 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 }); 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 (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) { if (isVite) {
writeText(join(appDir, "vite.config.ts"), generateViteConfig(viteEntries)); writeText(join(appDir, "vite.config.ts"), generateViteConfig(viteEntries));
for (const [rel, body] of seoStaticFiles(seoInventory, seoRoutes)) writeText(join(appDir, "public", rel), body); 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), componentCount: extracted.chrome.length + extracted.routes.reduce((sum, route) => sum + route.components.length, 0),
svgCount: 0, svgCount: 0,
hasContentModule: false, hasContentModule: false,
runtimeUtilities: anyWires ? ["DittoWire"] : [], runtimeUtilities: [...(anyWires ? ["DittoWire"] : []), ...(anyLottie ? ["DittoLottie"] : [])],
}); });
return { routes: outRoutes, assetsCopied, assetsMissing, components, extracted, seoInventory }; return { routes: outRoutes, assetsCopied, assetsMissing, components, extracted, seoInventory };
+101
View File
@@ -0,0 +1,101 @@
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("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" })] });
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);
});
});