Files
ditto.site/compiler/src/capture/motion.ts
T
Samraaj BathandClaude Fable 5 f16c3aa4e8 Rotator classification: reject element-bearing containers, non-destructive runtime
A progressively-growing feed panel (rows injected after cap tagging)
passed the rotator leaf guard - which only rejected containers with
CAPPED children - and the rotator runtime's textContent writes then
flattened 51 statically-emitted descendants to one text node. Three
layers: emission drops rotators whose IR target has element children;
the capture leaf guard rejects any element child (capped or not); the
DittoMotion runtime saves/restores cloned child nodes via
replaceChildren so even a misclassified rotator can't destroy structure.

425 tests pass (6 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 05:33:06 -07:00

584 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
* element carries a `data-cid-cap` the IR threads through to a cid). Two families that
* the declarative CSS path (per-node `animation-name` + captured `@keyframes`, handled
* entirely in the IR/generator) does NOT cover:
*
* - **WAAPI** (`element.animate(...)`, e.g. Framer Motion): only ever visible via
* `document.getAnimations()`. Captured here as keyframes + timing so a fixed client
* controller can replay it. (Entrance WAAPI that already finished by capture time is
* not observable — persistent/looping WAAPI is; that's the deterministic subset.)
* - **Rotating text**: a JS interval swapping an element's text (shopify "category
* creator → global empire", notion "Ship → Create"). Observed with a MutationObserver
* over a short window; captured as the text cycle + cadence for a fixed controller.
*
* CSS @keyframes/transition motion is intentionally NOT re-captured here — it is fully
* reconstructable from the static evidence already in the IR, which is the most
* deterministic path.
*/
export type WaapiAnim = {
cap: string; // data-cid-cap of the animated element (→ cid at generation)
keyframes: Array<Record<string, string | number>>; // effect.getKeyframes()
duration: number; // ms
delay: number; // ms
easing: string;
iterations: number; // -1 encodes Infinity (JSON-safe)
direction: string;
fill: string;
};
export type RotatorSpec = {
cap: string; // data-cid-cap of the element whose text cycles
texts: string[]; // the observed cycle of trimmed text values (in order)
intervalMs: number; // approximate cadence between swaps
};
export type RevealSpec = {
cap: string; // data-cid-cap of a scroll-revealed element
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
transform: string; // its hidden transform (slide/scale offset), or "none"
transition: string; // the transition to animate the reveal with ("" for the visibility family)
// visibility+entrance-class family (Elementor/WOW/AOS): hidden via `visibility:hidden`
// pre-scroll, revealed by a class swap that applies a keyframe animation. The clone
// re-hides with visibility (JS-applied, so non-JS/SSR still shows content) and replays
// the named animation when scrolled into view.
visibility?: "hidden";
animationName?: string; // entrance @keyframes name in the revealed state (e.g. fadeInUp)
animationDuration?: string; // e.g. "1.25s"
animationDelay?: string; // e.g. "0s"
animationTiming?: string; // e.g. "ease" / "cubic-bezier(...)"
};
export type MarqueeSpec = {
cap: string; // data-cid-cap of the continuously-translating track
axis: "x"; // horizontal marquee (the common case; vertical reserved)
pxPerSec: number; // signed scroll velocity (negative = leftward)
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
};
// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit
// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body —
// module-scope functions do NOT cross the serialization boundary — so any change here must
// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads.
/**
* Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a
* fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which
* is a large jump opposite the travel direction when the track seamlessly restarts.
*/
export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
}
/**
* Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady
* velocity across time; a scroll-settle lerp (still easing toward its scroll-target after
* `scrollIntoView`) decays, so its velocity in a later observation window is a small
* fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass
* only if the later window's speed is BOTH non-trivial AND close to the earlier window's
* (within a relative tolerance) — i.e. it did not decay and did not reverse.
*
* @param window1Deltas per-sample translateX deltas from the first observation window
* @param window2Deltas per-sample translateX deltas from the second (later) window
* @param sampleMs cadence between samples within a window
* @param minPxPerSec minimum sustained |velocity| to be considered moving at all
* @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign
*/
export function classifyVelocitySamples(
window1Deltas: number[],
window2Deltas: number[],
sampleMs: number,
minPxPerSec = 4,
relTol = 0.5,
): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } {
const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs);
const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs);
// The reported velocity is the first window's (measured closest to a clean, post-settle
// marquee; also what the old code reported), kept for output determinism vs. the old path.
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker
// v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed).
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
}
/**
* Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2×
* (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row
* of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose
* shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural
* fallback, an equal consecutive width sequence (some marquees clone then tweak attributes
* so hashes differ but the geometry repeats).
*
* @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths`
* @param widths per-child rounded offsetWidth
*/
export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
// (a) two consecutive children with an identical hash — a literal cloned copy.
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
// (b) structural fallback: the first half's width sequence repeats in the second half
// (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real
// width so a row of zero-width nodes can't spuriously "repeat".
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
}
export type MotionCapture = {
waapi: WaapiAnim[];
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<string, unknown>; // animationData only available in-memory, keyed by LottieSpec.inlineKey
cssAnimated: number; // elements with a computed animation-name (informational)
};
/**
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden and
* animate in when scrolled into view; by the time the settled snapshot is taken (after
* the reveal-settling pass has walked the page) they are already revealed, so their
* hidden state must be sampled BEFORE the first scroll. Records, on `window.__cloneReveal`,
* two candidate families over tagged elements — the set `captureMotion` later confirms
* (kept only if the element ends up visible):
* - **transition** — opacity≈0 with a real opacity/transform transition (the reveal
* animates via the transition already on the element);
* - **visibility** — `visibility:hidden` with a real box (Elementor `.elementor-invisible`,
* WOW/AOS wrappers), revealed by a class swap that APPLIES a keyframe animation. The
* entrance animation only exists post-swap, so `captureMotion` reads it at confirm
* time from the revealed computed style. Only the OUTERMOST hidden element is recorded
* (visibility inherits — descendants are covered by the wrapper's reveal).
* Idempotent; call once at the canonical width, after settle, before the first scroll.
*/
export async function probeReveals(page: Page): Promise<void> {
try {
await page.evaluate(() => {
const out: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> = {};
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
let cs: CSSStyleDeclaration; try { cs = getComputedStyle(el); } catch { continue; }
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
if (cs.visibility === "hidden") {
// visibility family: record only the reveal ROOT (parent not also hidden).
const p = (el as HTMLElement).parentElement;
let parentHidden = false;
try { parentHidden = !!p && getComputedStyle(p).visibility === "hidden"; } catch { /* ignore */ }
if (parentHidden) continue;
out[cap] = { opacity: cs.opacity || "1", transform: "none", transition: "", family: "visibility" };
continue;
}
const op = parseFloat(cs.opacity || "1");
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
const tp = (cs.transitionProperty || "").toLowerCase();
const td = cs.transitionDuration || "0s";
const animates = /opacity|transform|all/.test(tp) && !/^0s(,\s*0s)*$/.test(td);
if (!animates) continue;
out[cap] = {
opacity: cs.opacity || "0",
transform: cs.transform && cs.transform !== "none" ? cs.transform : "none",
transition: cs.transition && cs.transition !== "all 0s ease 0s" ? cs.transition : `opacity ${td}, transform ${td}`,
};
}
(window as unknown as { __cloneReveal?: unknown }).__cloneReveal = out;
});
} catch { /* ignore */ }
}
/**
* Stage 5 (marquees) — rAF-driven continuous tickers. Framer Motion (and similar)
* drive infinite horizontal scrollers by mutating an element's `transform` every frame
* via requestAnimationFrame, so they are invisible to `getAnimations()` (the WAAPI path)
* and have no CSS `@keyframes` (the declarative path). They are also paused while
* off-screen, so the velocity is only observable after scrolling the track into view.
* Detect a track by a translateX that changes steadily in one direction once visible,
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR.
*
* Discriminators (ALL must pass — a candidate is only classified as a marquee if every one
* holds), added to defeat scroll-LINKED easing false positives (a static logo row on a
* scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`,
* which reads as a constant velocity over a single short window):
* 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll;
* a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee).
* 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle
* lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples).
* 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity
* responds to the nudge the motion is scroll-linked, not a self-driven ticker.
* 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an
* equal consecutive width sequence), the shape real marquees use for a seamless loop.
*
* Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the
* few candidates that already pass the cheap synchronous gates (overflow + translated +
* clip + duplication), and the candidate list is capped at 8. Per surviving candidate the
* async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms
* jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size.
*/
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
try {
return await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const txOf = (el: Element): number => { try { return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41; } catch { return 0; } };
const inClip = (el: Element): boolean => {
let p = el.parentElement, depth = 0;
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
return false;
};
// cheap 32-bit string hash (djb2-ish) — deterministic, for the duplication test.
const hashStr = (s: string): number => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; };
// ---- Pure discriminators (mirror of the exported module-scope functions; kept inline
// because module scope does not cross the page.evaluate serialization boundary). ----
const medianVelocityPxPerSec = (deltas: number[], sampleMs: number): number => {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
};
const classifyVelocitySamples = (w1: number[], w2: number[], sampleMs: number, minPxPerSec = 4, relTol = 0.5) => {
const v1 = medianVelocityPxPerSec(w1, sampleMs);
const v2 = medianVelocityPxPerSec(w2, sampleMs);
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 };
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
};
const hasRepeatedChildren = (hashes: number[], widths: number[]): boolean => {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
};
// sample per-child outerHTML hash + width for the duplication test.
const childSignature = (el: Element): { hashes: number[]; widths: number[] } => {
const hashes: number[] = [], widths: number[] = [];
const kids = Array.from(el.children).slice(0, 24) as HTMLElement[];
for (const k of kids) { hashes.push(hashStr(k.outerHTML)); widths.push(Math.round(k.getBoundingClientRect().width)); }
return { hashes, widths };
};
// one observation window: `count` deltas at `sampleMs` cadence.
const sampleWindow = async (el: Element, count: number, sampleMs: number): Promise<number[]> => {
const xs: number[] = [txOf(el)];
for (let i = 0; i < count; i++) { await sleep(sampleMs); xs.push(txOf(el)); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
return dxs;
};
const SAMPLE_MS = 120;
const WINDOW_SAMPLES = 5; // 5 deltas ≈ 600ms per window
const INTER_WINDOW_MS = 1500; // ≥1.5s between windows (discriminator 2)
const JIGGLE_PX = 50; // scroll nudge for the independence test (discriminator 3)
// Candidates: tagged, already translated (paused tickers keep their offset), with
// GENUINE duplicated content inside an overflow-clip viewport, AND real content
// overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous
// so the expensive async windows only run for the few survivors.
const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue;
if (!inClip(el)) continue;
// Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth;
// a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll.
if (el.scrollWidth <= el.clientWidth * 1.35) continue;
// Discriminator 4: genuine repetition, not merely ≥2 distinct children.
const sig = childSignature(el);
if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue;
cand.push(el);
if (cand.length >= 8) break;
}
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>();
for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
// Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
// a real marquee holds constant velocity.
const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
await sleep(INTER_WINDOW_MS);
const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
if (w1.length < 3 || w2.length < 3) continue;
// direction must be consistent within window 1, but for (at most) the one wrap step.
const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue;
const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS);
if (!cls.isMarquee) continue;
const pxPerSec = cls.pxPerSec;
// Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample
// velocity; a self-driven ticker is unaffected, a scroll-linked animation changes.
const baseY = window.scrollY;
const jiggle = async (dy: number): Promise<number> => {
try { window.scrollTo(0, Math.max(0, baseY + dy)); } catch { /* ignore */ }
await sleep(250); // let any scroll-linked easing react and settle at the new offset
const dxs = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
return medianVelocityPxPerSec(dxs, SAMPLE_MS);
};
const vDown = await jiggle(JIGGLE_PX);
const vUp = await jiggle(-JIGGLE_PX);
try { window.scrollTo(0, baseY); } catch { /* ignore */ }
// Scroll-linked motion responds to the nudge (velocity reverses, dies, or spikes as
// it re-lerps to the new target). A real marquee holds ~pxPerSec through both nudges.
const stableUnderJiggle = (v: number): boolean =>
Math.sign(v) === Math.sign(pxPerSec) && Math.abs(v) >= Math.abs(pxPerSec) * 0.5 && Math.abs(v) <= Math.abs(pxPerSec) * 2;
if (!stableUnderJiggle(vDown) || !stableUnderJiggle(vUp)) continue;
const periodPx = Math.round(el.scrollWidth / 2);
if (periodPx < 40) continue;
seen.add(cap);
out.push({ cap, axis: "x", pxPerSec, periodPx });
}
try { window.scrollTo(0, 0); } catch { /* ignore */ }
return out;
});
} catch { return []; }
}
/**
* Stage 5 (scroll-scrub reveals) — scroll-LINKED entrance animations. Some sections
* (framer's "Agents" sticky-pinned panels) drive opacity/transform as a continuous
* function of scroll position rather than a one-way IntersectionObserver reveal, so they
* start only PARTIALLY hidden (opacity ~0.2, a translate offset) and `probeReveals`
* (which requires opacity ≤ 0.05 pre-scroll) never sees them — and the settled snapshot
* catches them frozen MID-scrub (opacity 0.63), which the clone then bakes as a dimmed,
* offset panel. Detect them by sampling each panel's opacity/transform across a full
* scroll pass: a panel that is dimmed/slid at some scroll position but reaches full
* opacity at another is a scroll reveal. Reconstruct as a normal reveal (hide at the
* most-dimmed state, transition to full on view) — the deterministic, gate-reconciled
* path (the validator force-reveals before grading, so gates measure the revealed frame).
*/
async function detectScrubReveals(page: Page): Promise<RevealSpec[]> {
try {
return await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const txOf = (cs: CSSStyleDeclaration): number => { try { return new DOMMatrixReadOnly(cs.transform).m41; } catch { return 0; } };
// Panel-like candidates: tagged, sized, content-bearing, in normal flow.
const cands: HTMLElement[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]")) as HTMLElement[]) {
const r = el.getBoundingClientRect();
if (r.width < 120 || r.height < 60) continue;
const pos = getComputedStyle(el).position;
if (pos === "fixed" || pos === "sticky") continue;
if (!el.querySelector("img, p, h1, h2, h3, span")) continue; // has real content
cands.push(el);
if (cands.length >= 1200) break;
}
const samples = new Map<HTMLElement, Array<{ op: number; tx: number }>>();
const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
const steps = 14;
for (let i = 0; i <= steps; i++) {
window.scrollTo(0, Math.round((maxY * i) / steps));
await sleep(120);
for (const el of cands) {
const cs = getComputedStyle(el);
let a = samples.get(el); if (!a) { a = []; samples.set(el, a); }
a.push({ op: parseFloat(cs.opacity || "1"), tx: txOf(cs) });
}
}
window.scrollTo(0, 0);
const found: Array<{ el: HTMLElement; cap: string; opacity: string; transform: string; transition: string }> = [];
for (const [el, a] of samples) {
const ops = a.map((s) => s.op), absTx = a.map((s) => Math.abs(s.tx));
const maxOp = Math.max(...ops), minOp = Math.min(...ops);
const maxTx = Math.max(...absTx), minTx = Math.min(...absTx);
const opReveal = maxOp > 0.9 && minOp < 0.85; // fades in across scroll
const txReveal = maxOp > 0.9 && maxTx > 6 && minTx < 2; // slides in across scroll
if (!opReveal && !txReveal) continue;
// hidden = the most-dimmed sample (lowest opacity, tie-break largest offset).
let hidden = a[0]!;
for (const s of a) if (s.op < hidden.op - 0.001 || (Math.abs(s.op - hidden.op) <= 0.001 && Math.abs(s.tx) > Math.abs(hidden.tx))) hidden = s;
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
found.push({
el, cap,
opacity: String(Math.max(0, Math.min(hidden.op, 0.5))),
transform: Math.abs(hidden.tx) > 2 ? `translateX(${Math.round(hidden.tx)}px)` : "none",
transition: "opacity 0.6s ease, transform 0.6s ease",
});
}
// Keep only the OUTERMOST scrub per nesting (a panel and its scrubbed children both match).
const outer = found.filter((f) => !found.some((o) => o !== f && o.el.contains(f.el)));
return outer.slice(0, 40).map(({ cap, opacity, transform, transition }) => ({ cap, opacity, transform, transition }));
});
} catch { return []; }
}
export async function captureMotion(page: Page, opts?: { observeMs?: number; log?: (e: Record<string, unknown>) => void }): Promise<MotionCapture> {
const log = opts?.log ?? (() => {});
const observeMs = opts?.observeMs ?? 2600;
try {
const result = await Promise.race([
page.evaluate(async (budget: number) => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ---- WAAPI: pure script-driven animations (exclude CSS animations/transitions,
// which the declarative path reproduces). Capture persistent/looping ones — the
// deterministic subset still alive at capture time. ----
const waapi: Array<{
cap: string; keyframes: Array<Record<string, string | number>>;
duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string;
}> = [];
try {
const anims = (document as Document).getAnimations ? document.getAnimations() : [];
const seen = new Set<string>();
for (const a of anims) {
const ctor = a.constructor.name;
if (ctor === "CSSAnimation" || ctor === "CSSTransition") continue; // declarative path owns these
const eff = a.effect as KeyframeEffect | null;
const target = eff?.target as Element | undefined;
const cap = target?.getAttribute?.("data-cid-cap");
if (!cap || seen.has(cap)) continue;
let kfs: Array<Record<string, string | number>> = [];
try { kfs = (eff!.getKeyframes() as unknown as Array<Record<string, string | number>>); } catch { kfs = []; }
if (!kfs.length) continue;
const t = eff!.getTiming();
const iters = t.iterations === Infinity ? -1 : (typeof t.iterations === "number" ? t.iterations : 1);
seen.add(cap);
waapi.push({
cap,
keyframes: kfs,
duration: typeof t.duration === "number" ? t.duration : 0,
delay: t.delay ?? 0,
easing: String(t.easing ?? "linear"),
iterations: iters,
direction: String(t.direction ?? "normal"),
fill: String(t.fill ?? "none"),
});
}
} catch { /* ignore */ }
// ---- Rotating text: watch for elements whose text content cycles. Record the
// smallest text-bearing element that changes (so we cycle the word, not a whole
// section), the ordered set of values it shows, and the cadence. ----
const changes = new Map<string, { texts: string[]; times: number[] }>();
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
const obs = new MutationObserver((records) => {
for (const rec of records) {
// climb to the nearest element with a data-cid-cap
let el: Node | null = rec.target;
while (el && !(el as Element).getAttribute?.("data-cid-cap")) el = el.parentNode;
const cap = el && (el as Element).getAttribute?.("data-cid-cap");
if (!cap) continue;
const elem = el as Element;
// only track small text-bearing elements (a rotating word/phrase, not a big block).
// Reject ANY element child, capped or not: rows injected at runtime (after cid-cap
// tagging, so uncapped) would otherwise defeat a capped-only check and let a
// multi-element panel pass as a "leaf word".
if (elem.firstElementChild) continue; // has element children → not a leaf word
const txt = norm(elem.textContent || "");
if (!txt || txt.length > 80) continue;
const e = changes.get(cap) ?? { texts: [], times: [] };
if (e.texts[e.texts.length - 1] !== txt) { e.texts.push(txt); e.times.push(performance.now()); }
changes.set(cap, e);
}
});
obs.observe(document.body, { subtree: true, childList: true, characterData: true });
await sleep(budget);
obs.disconnect();
const rotators: Array<{ cap: string; texts: string[]; intervalMs: number }> = [];
for (const [cap, e] of changes) {
// a genuine rotator shows ≥2 distinct values
const distinct = Array.from(new Set(e.texts));
if (distinct.length < 2) continue;
let interval = 0;
if (e.times.length >= 2) {
const gaps: number[] = [];
for (let i = 1; i < e.times.length; i++) gaps.push(e.times[i]! - e.times[i - 1]!);
gaps.sort((a, b) => a - b);
interval = Math.round(gaps[Math.floor(gaps.length / 2)]!);
}
rotators.push({ cap, texts: distinct.slice(0, 12), intervalMs: interval || 2000 });
}
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
const reveals: Array<{
cap: string; opacity: string; transform: string; transition: string;
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
}> = [];
try {
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> }).__cloneReveal || {};
// First value of a comma-joined animation longhand; timing functions carry inner
// commas (cubic-bezier/steps), so take the whole leading function when present.
const first = (v: string): string => (/^\s*(cubic-bezier\([^)]*\)|steps\([^)]*\)|[^,]+)/.exec(v || "")?.[1] ?? "").trim();
for (const cap of Object.keys(probed)) {
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
if (!el) continue;
const cs = getComputedStyle(el);
const p = probed[cap]!;
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue;
if (p.family === "visibility") {
if (cs.visibility === "hidden") continue; // never revealed → genuinely hidden content
// Revealed via class swap. The swap's entrance animation is now in the computed
// style (libraries keep the animated class); record it for replay.
const name = first(cs.animationName || "none");
reveals.push({
cap, opacity: p.opacity, transform: "none", transition: "",
visibility: "hidden",
...(name && name !== "none" ? {
animationName: name,
animationDuration: first(cs.animationDuration) || "1s",
animationDelay: first(cs.animationDelay) || "0s",
animationTiming: first(cs.animationTimingFunction) || "ease",
} : {}),
});
continue;
}
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
reveals.push({ cap, opacity: p.opacity, transform: p.transform, transition: p.transition });
}
} catch { /* ignore */ }
let cssAnimated = 0;
for (const el of Array.from(document.querySelectorAll("*"))) {
try { const an = getComputedStyle(el).animationName; if (an && an !== "none") cssAnimated++; } catch { /* ignore */ }
}
return { waapi, rotators, reveals, cssAnimated };
}, observeMs),
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
// partially hidden). Merge into reveals, preferring the probe-confirmed entry when a cap
// appears in both.
const scrubReveals = await detectScrubReveals(page);
const reveals = [...result.reveals];
const haveCap = new Set(reveals.map((r) => r.cap));
for (const s of scrubReveals) if (!haveCap.has(s.cap)) { reveals.push(s); haveCap.add(s.cap); }
// 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);
// 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: [], lotties: [], lottieInline: {}, cssAnimated: 0 };
}
}