/** * Catalog all animations on a rendered page. * * Captures: * 1. Web Animations API — active animations with full keyframes + timing * 2. CSS animation/transition declarations via getComputedStyle * 3. IntersectionObserver targets (scroll-triggered elements) * 4. CDP Animation domain events * * The catalog is saved as animations.json and gives Claude Code * everything needed to recreate animations in GSAP. * * NOTE: Must be used on a page with ALL scripts running (not stripped). * Call setupAnimationCapture() BEFORE page.goto() for IO patching. * Call collectAnimationCatalog() AFTER page has loaded and settled. */ import type { Page, CDPSession } from "puppeteer-core"; export interface AnimationCatalog { /** Active animations via document.getAnimations() — includes keyframes */ webAnimations: WebAnimationEntry[]; /** Elements with CSS animation/transition properties declared */ cssDeclarations: CssAnimationEntry[]; /** Elements being watched by IntersectionObserver (scroll triggers) */ scrollTargets: ScrollTarget[]; /** CDP Animation domain events captured during page lifecycle */ cdpAnimations: CdpAnimationEntry[]; /** Total counts summary */ summary: { webAnimations: number; cssDeclarations: number; scrollTargets: number; cdpAnimations: number; canvases: number; }; } export interface WebAnimationEntry { type: string; playState: string; animationName?: string; targetSelector?: string; targetRect?: { x: number; y: number; width: number; height: number }; keyframes?: Array>; timing?: { duration: number; delay: number; iterations: number; easing: string; direction: string; }; } export interface CssAnimationEntry { selector: string; animation?: { name: string; duration: string; easing: string }; transition?: { property: string; duration: string }; } export interface ScrollTarget { selector: string; rect: { top: number; height: number; width: number }; } export interface CdpAnimationEntry { id: string; name: string; type: string; duration?: number; delay?: number; } /** * Set up animation capture hooks BEFORE navigating to the page. * This patches IntersectionObserver to track scroll-triggered elements. */ export async function setupAnimationCapture(page: Page): Promise { await page.evaluateOnNewDocument(` window.__hf_io_targets = []; var OrigIO = window.IntersectionObserver; window.IntersectionObserver = function(callback, options) { var observer = new OrigIO(callback, options); var origObserve = observer.observe.bind(observer); observer.observe = function(target) { var sel = target.id ? '#' + target.id : target.tagName.toLowerCase(); if (target.className && typeof target.className === 'string') { var cls = Array.from(target.classList).slice(0, 2).join('.'); if (cls) sel += '.' + cls; } try { var rect = target.getBoundingClientRect(); window.__hf_io_targets.push({ selector: sel, rect: { top: Math.round(rect.top + window.scrollY), height: Math.round(rect.height), width: Math.round(rect.width) } }); } catch(e) {} return origObserve(target); }; return observer; }; window.IntersectionObserver.prototype = OrigIO.prototype; `); } /** * Start CDP Animation domain listener. * Returns the CDPSession and a reference to the captured array. */ export async function startCdpAnimationCapture( page: Page, ): Promise<{ cdp: CDPSession; animations: CdpAnimationEntry[] }> { const cdp = await page.createCDPSession(); await cdp.send("Animation.enable"); const animations: CdpAnimationEntry[] = []; cdp.on("Animation.animationStarted", (event: any) => { animations.push({ id: event.animation.id, name: event.animation.name || "", type: event.animation.type, duration: event.animation.source?.duration, delay: event.animation.source?.delay, }); }); return { cdp, animations }; } /** * Collect the full animation catalog after page has loaded and settled. * Should be called after scrolling through the page to trigger all animations. */ export async function collectAnimationCatalog( page: Page, cdpAnimations: CdpAnimationEntry[], cdp: CDPSession, ): Promise { // Scroll through page to trigger scroll-based animations await page.evaluate(`(async () => { var height = document.body.scrollHeight; for (var y = 0; y < height; y += window.innerHeight * 0.5) { window.scrollTo(0, y); await new Promise(function(r) { setTimeout(r, 400); }); } window.scrollTo(0, 0); await new Promise(function(r) { setTimeout(r, 1000); }); })()`); // Collect from Web Animations API + computed styles + IO targets const result = (await page.evaluate(`(() => { var webAnimations = []; var cssDeclarations = []; // 1. Web Animations API try { var anims = document.getAnimations(); webAnimations = anims.map(function(anim) { var r = { type: anim.constructor.name, playState: anim.playState, animationName: anim.animationName || null }; var effect = anim.effect; if (effect && effect.target) { var t = effect.target; r.targetSelector = t.id ? '#' + t.id : t.tagName.toLowerCase(); if (t.className && typeof t.className === 'string') { var cls = Array.from(t.classList).slice(0, 3).join('.'); if (cls) r.targetSelector += '.' + cls; } try { r.targetRect = t.getBoundingClientRect().toJSON(); } catch(e) {} } if (effect && typeof effect.getKeyframes === 'function') { try { r.keyframes = effect.getKeyframes(); } catch(e) {} } if (effect && typeof effect.getComputedTiming === 'function') { try { var timing = effect.getComputedTiming(); r.timing = { duration: timing.duration, delay: timing.delay, iterations: timing.iterations, easing: timing.easing, direction: timing.direction }; } catch(e) {} } return r; }); } catch(e) {} // 2. CSS animation/transition scan var allEls = document.querySelectorAll('*'); for (var i = 0; i < allEls.length && i < 5000; i++) { var el = allEls[i]; try { var cs = getComputedStyle(el); var hasAnim = cs.animationName && cs.animationName !== 'none'; var hasTrans = cs.transitionProperty && cs.transitionProperty !== 'all' && cs.transitionProperty !== 'none' && cs.transitionDuration !== '0s'; if (hasAnim || hasTrans) { var sel = el.id ? '#' + el.id : el.tagName.toLowerCase(); if (el.className && typeof el.className === 'string') { var cls = Array.from(el.classList).slice(0, 2).join('.'); if (cls) sel += '.' + cls; } var entry = { selector: sel }; if (hasAnim) entry.animation = { name: cs.animationName, duration: cs.animationDuration, easing: cs.animationTimingFunction }; if (hasTrans) entry.transition = { property: cs.transitionProperty, duration: cs.transitionDuration }; cssDeclarations.push(entry); } } catch(e) {} } // 3. IO targets (collected by monkey-patch) var scrollTargets = (window.__hf_io_targets || []).map(function(t) { return { selector: t.selector, rect: t.rect }; }); // 4. Canvas summary var canvasCount = document.querySelectorAll('canvas').length; return { webAnimations: webAnimations, cssDeclarations: cssDeclarations, scrollTargets: scrollTargets, canvasCount: canvasCount }; })()`)) as any; // Stop CDP listener await cdp.send("Animation.disable"); return { webAnimations: result.webAnimations, cssDeclarations: result.cssDeclarations, scrollTargets: result.scrollTargets, cdpAnimations, summary: { webAnimations: result.webAnimations.length, cssDeclarations: result.cssDeclarations.length, scrollTargets: result.scrollTargets.length, cdpAnimations: cdpAnimations.length, canvases: result.canvasCount, }, }; }