feat: website capture pipeline + 7-step video production skill (#284)

* feat(cli): add website capture with AI-powered DESIGN.md generation

Adds `hyperframes capture <url>` command that extracts a complete design
system from any website, producing AI-agent-ready output:

- Full-page screenshot (lazy-load aware, nav at top)
- AI-generated DESIGN.md via Claude API (colors, typography, elevation,
  components, do's/don'ts) with programmatic asset catalog (136+ assets
  with HTML context annotations like img[src], css url(), link[rel=preload])
- CSS-purged compositions (87% size reduction via PurgeCSS)
- HTML-prettified compositions (one-tag-per-line for AI readability)
- CLAUDE.md + .cursorrules auto-generated for AI agent instructions
- Asset deduplication (srcset variants) and tracking pixel filtering

* feat(cli): add gemini 3.1 pro, playwright screenshots, replica refinement

- switch to gemini 3.1 pro (gemini-3.1-pro-preview) with claude fallback
- playwright for full-page screenshots (fixes puppeteer gradient/fixed bugs)
- replica refinement loop: generate, screenshot, compare, fix
- extract inline svgs (50 max, 10kb each) to assets/svgs/
- extract visible text in dom order for content accuracy
- detect js libraries (gsap, three.js, scrolltrigger) via globals
- improved asset catalog grouping and naming
- reverse-engineered aura system prompt documentation
- comprehensive session handoff doc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update session handoff with slack research findings

- key finding: team already wants DESIGN.md integration (James, Bin, Vance)
- skills quality matters enormously - must invoke /hyperframes-compose
- eval infrastructure exists (Abhay's dashboards, Teodora's 78-criteria guide)
- templates at templates/ need study before finalizing skill
- session handoff updated with critical next steps

* refactor(cli): simplify capture pipeline, remove replica generator

* feat(capture): add Lottie detection and WebGL shader extraction

Captures Lottie animations via network interception and WebGL shader
source via gl.shaderSource hooking during site crawl. Updates
website-to-hyperframes skill with asset planning guidance, Lottie/shader
reading instructions, and stronger creative direction for scene planning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(capture): clean pipeline + shader-first creative workflow

Capture pipeline:
- Remove dead deps (puppeteer-extra, stealth plugin, duplicate devDeps)
- Remove duplicate generateAgentPrompt() call (first lied about DESIGN.md)
- Remove dead canvas-to-image code in htmlExtractor (post canvas removal)
- Parallelize image downloads (batches of 5 via Promise.allSettled)
- Fix pre-existing TS error (match[1] guard in font downloader)
- Default capture output to captures/<hostname>

Skill creative overhaul:
- Add shader transition selection to creative director step (Step 4)
- Add shader wiring instructions to engineer step (Step 5)
- Replace 4-line energy modifiers with visual vocabulary table
- Strip rigid scene-by-scene templates from video-recipes.md
- Strip example fill data from scene plan tables
- Add "read transition refs before planning" instruction
- Add creative ambition language ("how the hell did they make this")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add skill architecture redesign spec

Comprehensive redesign of website-to-hyperframes skill and capture
pipeline based on code review findings and Claude Code architecture
research. Key changes: remove AI auto-generation, restructure skill
into phases, embed shader boilerplate in scaffold, fix color format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add implementation plan for skill architecture redesign

13-task plan covering: capture pipeline cleanup (remove AI generation,
fix colors to HEX, add asset descriptions, shader-ready scaffold),
skill restructuring (4 phases with artifact gates), and compose skill
Visual Identity Gate upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(capture): remove AI auto-generation and SDK dependencies

* fix(capture): convert extracted colors to HEX format

* refactor(capture): remove AI key path, add asset descriptions generator

* refactor(capture): update agent prompt, remove hasDesignMd, add asset descriptions

* feat(capture): pre-wire shader transitions in index.html scaffold

* chore: remove duplicate visual-styles.md (canonical is in hyperframes/)

* refactor(skill): rewrite website-to-hyperframes as phase-based orchestrator

* feat(skill): add Phase 1 understand reference

* feat(skill): add Phase 2 design reference with full DESIGN.md schema

* feat(skill): add Phase 3 creative direction reference

* feat(skill): add Phase 4 build reference with inline shader example

* feat(skill): upgrade Visual Identity Gate to produce full DESIGN.md

* docs: update CLAUDE.md skill references for phase-based workflow

* fix: address code review findings

- Remove orphaned `false` argument in generateAgentPrompt call (critical:
  was shifting hasLottie, hasShaders, catalogedAssets parameters)
- Add HSL color handling in rgbToHex via temp element resolution
- Remove build artifact commit section from phase-4-build.md
- Fix __GSAP_TIMELINE reference to __timelines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(capture): regex double-escape + simplify scaffold + fix asset descriptions

- Double-escape regex in tokenExtractor template literal (\s→\\s, \d→\\d, \(→\\()
  so browser receives valid regex patterns via page.evaluate()
- Simplify index.html scaffold: scene slots + audio + timeline + comment pointing
  to shader-setup.md reference (no broken inline shader boilerplate)
- Fix asset descriptions: use CatalogedAsset.contexts/notes instead of
  nonexistent htmlContext field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: code review — 16 bugs, 7-step skill rewrite, cleanup

Code fixes:
- snapshot.ts: path traversal guard, browser leak (try/finally), div-by-zero
  for --frames 1, port bind error handling, rAF-based render settle
- index.ts: remove invalid thinkingConfig for gemini-2.5-flash, fix Gemini
  batch/rate-limit comments, fix video preview viewport y-coordinate
- tokenExtractor.ts: remove dead seen[si] dedup code
- gsap.ts: index ALL classes for inline-style transform conflict detection

Skill architecture rewrite (4-phase → 7-step):
- Replace phase-1 through phase-4 with step-1 through step-7
- Add techniques.md (10 visual techniques with code patterns)
- Fix /hyperframes-compose → /hyperframes (skill doesn't exist)
- Fix captures/arc-browser reference → shader-setup.md (file doesn't exist)
- Fix step-7 hardcoded captures/stripe path
- Document Gemini API free/paid rate limits in step-1

Cleanup:
- CLAUDE.md: restore from Stripe-capture overwrite, update 4-phase → 7-step
- .gitignore: add PR #267 skills (hyperframes-animation-map, hyperframes-contrast)
- Delete old phase-*.md, animation-recreation.md, tts-integration.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove dev artifacts, research docs, wrong lockfiles

Remove files that shouldn't ship in this PR:
- docs/research/ (aura analysis, prompt catalogs)
- docs/session-*.md, docs/SESSION-HANDOFF.md (dev notes)
- docs/superpowers/ planning and spec docs
- pnpm-lock.yaml at root and cli (repo uses bun, not pnpm)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(CLAUDE.md): align with main — slim format, add website-to-hyperframes mention

Main PR #283 removed the full skills table from CLAUDE.md and moved it
to AGENTS.md. Align with that decision: use main's slim dev-focused
format, fix pnpm→bun references, add one-line /website-to-hyperframes
pointer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): add capture command to help groups

The capture command was registered in cli.ts but missing from
the help groups, so it wouldn't appear in `hyperframes --help`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format skill reference files (oxfmt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: regenerate bun.lock after rebase

The lockfile was stale after rebasing onto main — bun install
--frozen-lockfile failed in CI because new dependencies (google/genai,
patchright, purgecss) weren't reflected in the lockfile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review comments + improve capture quality

Review fixes (16 comments from jrusso1020 + vanceingalls):
- screenshotCapture: remove Playwright dep, use Puppeteer for all screenshots
- screenshotCapture: dynamic screenshot count based on page height (30% overlap)
- snapshot.ts: fix duration() function-vs-property bug, cross-platform path guard
- htmlExtractor: fix code injection via parameterized evaluate
- index.ts: video preview re-measures position after scroll, .env file loading
- capture.ts: BLOCKED.md on timeout failures
- gsap.ts: 5 inline-style lint tests added (all pass)
- Remove Playwright, patchright deps; @google/genai to optionalDependencies
- Gitignore: generic patterns instead of 20 hardcoded directories
- Remove asset-sourcing.md, video-recipes.md (unused, duplicated guidance)

Capture quality improvements (tested on 10+ websites):
- Color extraction: canvas-based oklch/lab resolver, pixel sampling via
  elementFromPoint, broad sweep for accent colors, gradient/shadow extraction
- Section detection: broadened selectors for div-based layouts, height cap
  to skip page-level wrappers, parent bg walkup for dark sites
- Font downloads: cap 6 per family / 30 total (Cal.com: 306→30)
- CTA detection: text pattern matching + nav context filtering
- Heading text: innerText with whitespace normalization
- Gemini captioning: maxOutputTokens 100→300, .env auto-loading
- .env.example updated with GEMINI_API_KEY docs
- TTS ranking: Kokoro first with Python 3.10+ note

* fix: address PR review comments + improve capture quality

Review round 2 fixes (jrusso1020 + vanceingalls):
- verify/index.ts: add path traversal guard (relative + isAbsolute)
- verify/index.ts: fix sections[i] undefined typecheck error (CI green)
- index.ts: escape Lottie JSON with \u003c to prevent </script> breakout
- step-4-storyboard: fix technique count contradiction (2-3 per beat, not
  across whole video)
- step-6-build: perspective tilt uses gsap.set() instead of CSS transform
  (avoids GSAP overwrite conflict)
- step-1-capture: reorder — command first, Gemini note after (zero-config
  is the default path, API key is optional enhancement)
- step-7-validate: add tsx fallback for snapshot command
- step-3-script: vary hook patterns, don't default to number every time
- assetDownloader: exempt SVGs from 10KB minimum filter (company logos
  like Hubspot/Intel/DHL are 2-6KB; HeyGen capture: 13→75 assets)

Note: adm-zip was NOT removed (reviewer #3) — it's still in
packages/cli/package.json:30. The root package.json had patchright
and purgecss removed, not adm-zip.

Note: ANTHROPIC_API_KEY not restored in .env.example — grep confirms
zero references in the entire codebase. The @anthropic-ai/sdk dependency
was removed earlier in this branch.

* refactor(capture): split index.ts (1175 to 566 lines) into modules

Mechanical extraction, zero logic changes.

New files:
- mediaCapture.ts (345 lines): Lottie preview, video manifest/screenshots
- contentExtractor.ts (314 lines): library detection, text, Gemini, asset descriptions
- scaffolding.ts (135 lines): .env loading, project scaffold generation

Also fixes false-positive BLOCKED.md with structural Cloudflare detection.
Tested on 20 websites, pre/post output identical.

* chore(capture): remove --split flow (splitter, verify, cssPurger, purgecss)

The --split feature auto-generates compositions from captured HTML — a
different approach from the /website-to-hyperframes skill workflow where
agents build compositions from scratch using the storyboard.

No skill file, no step reference, and no test session ever used --split.
Removes 923 lines of unused code + purgecss dependency.

Backed up to ~/Desktop/capture-split-backup/ for reference.

* fix(security): add ssrf protection, lottie injection fix, oom guard

- assetDownloader: add isPrivateUrl() guard blocking private IP ranges
  (127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x), cloud metadata
  endpoints, localhost, and non-HTTP schemes
- mediaCapture: fix Lottie JSON injection by loading shell HTML first
  then passing animation data via parameterized page.evaluate()
- index.ts: check Content-Length header before response.buffer() in
  Lottie network interception to avoid OOM on multi-GB responses

* fix(capture): security fixes, timeout, sub-agent dispatch instructions

Security (from miguel-heygen review):
- assetDownloader: export isPrivateUrl() SSRF guard
- htmlExtractor: add isPrivateUrl check before CSS fetch
- mediaCapture: add isPrivateUrl check before Lottie fetch
- mediaCapture: fix previewPage leak (try/finally)
- mediaCapture: skip Lottie files > 2MB for preview (CDP limit)
- contentExtractor: skip images > 4MB for Gemini captioning
- index.ts: check Content-Length before response.buffer() (OOM guard)
- snapshot.ts: register error handler before server.listen()

Capture improvements:
- Default timeout 30s to 120s (Shopify needs ~90s for Cloudflare)
- step-6-build: sub-agent dispatch template with explicit rules:
  pass file PATHS not contents, use local fonts not Google Fonts,
  verify ../assets/ references after each beat

* fix(capture): catalog before DOM mutation, networkidle2, faster Gemini

Critical: asset cataloger now runs BEFORE extractHtml which converts img
src to data URLs. Framer sites like heykuba.com went from 2 to 78 images.

- networkidle2 instead of networkidle0 (unblocks SPAs with WebSockets)
- Lazy-load wait: scroll to bottom, wait for img.complete
- CSS background-image cataloging for Framer/Webflow
- SVG naming: checks class, id, parent, inner text (not just aria-label)
- Gemini batch 5->20, pause 12s->2s (paid tier: 2000 RPM, ~0.001/img)
- maxOutputTokens 300->500, descriptions sorted captioned-first
- Remove tsx fallback from step-1 (reviewer nit, published CLI has it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ular Kimsanov
2026-04-16 12:03:16 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ebc12f7dc9
commit 87f4c77e2f
35 changed files with 5431 additions and 16 deletions
@@ -0,0 +1,159 @@
/**
* Generate CLAUDE.md (and .cursorrules) for captured website projects.
*
* This file generates a DATA INVENTORY that tells the AI agent what files
* exist and what they contain. The actual workflow lives in the
* /website-to-hyperframes skill — this file points agents there.
*/
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { DesignTokens } from "./types.js";
import type { AnimationCatalog } from "./animationCataloger.js";
import type { CatalogedAsset } from "./assetCataloger.js";
export function generateAgentPrompt(
outputDir: string,
url: string,
tokens: DesignTokens,
animations: AnimationCatalog | undefined,
hasScreenshot: boolean,
hasLottie?: boolean,
hasShaders?: boolean,
catalogedAssets?: CatalogedAsset[],
): void {
const prompt = buildPrompt(
url,
tokens,
animations,
hasScreenshot,
hasLottie,
hasShaders,
catalogedAssets,
);
writeFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
writeFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
}
function buildPrompt(
url: string,
tokens: DesignTokens,
animations: AnimationCatalog | undefined,
hasScreenshot: boolean,
hasLottie?: boolean,
hasShaders?: boolean,
catalogedAssets?: CatalogedAsset[],
): string {
const hostname = new URL(url).hostname.replace(/^www\./, "");
const title = tokens.title || hostname;
const cues = detectImplementationCues(tokens, animations);
const colorSummary = tokens.colors.slice(0, 10).join(", ");
const fontSummary = tokens.fonts.join(", ") || "none detected";
const sectionCount = tokens.sections?.length ?? 0;
const headingCount = tokens.headings?.length ?? 0;
const ctaCount = tokens.ctas?.length ?? 0;
const videoUrls = catalogedAssets
? catalogedAssets
.filter((a) => a.type === "Video" && a.url.startsWith("http"))
.map((a) => a.url)
.filter((u, i, arr) => arr.indexOf(u) === i) // deduplicate
: [];
return `# ${title} — Captured Website
Source: ${url}
## How to Create a Video
Invoke the \`/website-to-hyperframes\` skill. It walks you through the full workflow: read data → create DESIGN.md → plan video → build compositions → lint/validate/preview.
If you don't have the skill installed, run: \`npx skills add heygen-com/hyperframes\`
## What's in This Capture
| File | Contents |
|------|----------|
${hasScreenshot ? "| `screenshots/scroll-*.png` | Viewport screenshots covering the full page (1920x1080 each, 30% overlap). **View scroll-000.png FIRST** (hero section), then scan through the rest to understand the full page. |" : ""}
| \`extracted/tokens.json\` | Design tokens: ${tokens.colors.length} colors, ${tokens.fonts.length} fonts, ${headingCount} headings, ${ctaCount} CTAs, ${sectionCount} sections |
| \`extracted/visible-text.txt\` | All visible text content in DOM order — use exact strings, never paraphrase |
| \`extracted/assets-catalog.json\` | Every asset URL (images, fonts, videos, icons) with HTML context |
| \`extracted/animations.json\` | Animation catalog: ${animations?.summary?.webAnimations ?? 0} web animations, ${animations?.summary?.scrollTargets ?? 0} scroll triggers, ${animations?.summary?.canvases ?? 0} canvases |
| \`assets/svgs/\` | Extracted inline SVGs (logos, icons, illustrations) |
| \`assets/\` | Downloaded images and font files — **Read every image file to see what it contains** |
${hasLottie ? "| `extracted/lottie-manifest.json` | Lottie animations found on this site — read this to see what animations are available (name, dimensions, duration). Embed via `lottie.loadAnimation({ path: 'assets/lottie/animation-0.json' })`. Do NOT read the raw JSON files — they are machine data. |" : ""}
${videoUrls.length > 0 ? "| `extracted/video-manifest.json` | Video manifest: every `<video>` element with its URL, surrounding heading/caption context, and a preview screenshot. **Read this + view each preview image** to understand what each video shows before using it. |" : ""}
${hasShaders ? "| `extracted/shaders.json` | Captured WebGL shader source code (GLSL vertex + fragment shaders) |" : ""}
| \`extracted/asset-descriptions.md\` | One-line description of every downloaded asset — read this first |
> **DESIGN.md does not exist yet.** It will be created when you run the \`/website-to-hyperframes\` workflow. Do not write compositions without it.
## Brand Summary
- **Colors**: ${colorSummary || "see tokens.json"}
- **Fonts**: ${fontSummary}
- **Sections**: ${sectionCount} page sections detected
- **Headings**: ${headingCount} headings extracted
- **CTAs**: ${ctaCount} calls-to-action found
${
cues.length > 0
? `
## Source Patterns Detected
${cues.map((c) => `- ${c}`).join("\n")}
`
: ""
}
## Example Prompts
Try asking:
- "Make me a 15-second social ad from this capture"
- "Create a 30-second product tour video"
- "Turn this into a vertical Instagram reel"
- "Build a feature announcement video highlighting the top 3 features"
`;
}
function detectImplementationCues(
tokens: DesignTokens,
animations: AnimationCatalog | undefined,
): string[] {
const cues: string[] = [];
if (Object.keys(tokens.cssVariables).length > 10) {
cues.push(
"CSS custom properties used extensively — preserve design tokens for colors, spacing, and typography.",
);
}
if (tokens.fonts.length > 0) {
cues.push(
`Typography: ${tokens.fonts.join(", ")}. Match these exact font families and weights.`,
);
}
if (animations?.summary) {
if (animations.summary.scrollTargets > 20) {
cues.push(`${animations.summary.scrollTargets} scroll-triggered animations detected.`);
}
if (animations.summary.webAnimations > 5) {
cues.push(`${animations.summary.webAnimations} active Web Animations detected.`);
}
if (animations.summary.canvases > 0) {
cues.push(`${animations.summary.canvases} Canvas/WebGL elements detected.`);
}
}
const hasMarquee = animations?.cssDeclarations?.some(
(d) =>
d.animation?.name?.toLowerCase().includes("marquee") ||
d.animation?.name?.toLowerCase().includes("scroll"),
);
if (hasMarquee) {
cues.push("Marquee/ticker animation present — preserve continuous scrolling behavior.");
}
return cues;
}
@@ -0,0 +1,232 @@
/**
* 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<Record<string, string | number | null>>;
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<void> {
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<AnimationCatalog> {
// 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,
},
};
}
+363
View File
@@ -0,0 +1,363 @@
/**
* Comprehensive asset cataloger.
*
* Scans rendered HTML and CSS for every referenced asset (images, videos,
* fonts, icons, stylesheets, backgrounds) and records the HTML context
* where each was found (e.g., img[src], css url(), link[rel=preload]).
*
* This is the programmatic Part 1 of DESIGN.md generation — deterministic
* extraction, no AI involved.
*/
import type { Page } from "puppeteer-core";
export interface CatalogedAsset {
url: string;
type: "Image" | "Video" | "Font" | "Icon" | "Background" | "Other";
contexts: string[];
notes?: string;
/** Alt text, figcaption, or aria-label */
description?: string;
/** Nearest heading (h1-h4) text */
nearestHeading?: string;
/** Parent section/container class names */
sectionClasses?: string;
/** Whether the image is above the fold (visible without scrolling) */
aboveFold?: boolean;
}
/**
* Extract all referenced assets from the rendered page with their HTML contexts.
*/
export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
const assets = await page.evaluate(`(() => {
var assetMap = {};
// Extract rich DOM context from any element (heading, section, position)
function getElementContext(el) {
var ctx = {};
// Alt text, aria-label, figcaption
var desc = el.alt || el.getAttribute('aria-label') || el.getAttribute('title') || '';
var fig = el.closest('figure');
if (fig) {
var cap = fig.querySelector('figcaption');
if (cap) desc = desc || cap.textContent.trim().slice(0, 100);
}
var ariaBy = el.getAttribute('aria-describedby');
if (ariaBy) {
var descEl = document.getElementById(ariaBy);
if (descEl) desc = desc || descEl.textContent.trim().slice(0, 100);
}
if (desc) ctx.description = desc.slice(0, 150);
// Nearest heading
var section = el.closest('section, article, header, footer, main, [class*="hero"], [class*="banner"], [class*="feature"]');
if (section) {
var heading = section.querySelector('h1, h2, h3, h4');
if (heading) ctx.nearestHeading = heading.textContent.trim().slice(0, 80);
ctx.sectionClasses = (section.className || '').toString().slice(0, 120);
}
// Above fold?
try {
var rect = el.getBoundingClientRect();
ctx.aboveFold = rect.top < window.innerHeight;
} catch(e) {}
return ctx;
}
function add(url, type, context, notes, richCtx) {
if (!url || url === '' || url.startsWith('data:') || url.startsWith('blob:') || url === 'about:blank') return;
// Normalize URL
try { url = new URL(url, document.baseURI).href; } catch(e) { return; }
// Skip tiny inline data URIs but keep base64 SVGs
if (url.length > 50000) return;
// Filter tracking pixels and analytics
var lurl = url.toLowerCase();
if (lurl.indexOf('analytics.') > -1 || lurl.indexOf('adsct') > -1 || lurl.indexOf('pixel.') > -1 || lurl.indexOf('tracking.') > -1 || lurl.indexOf('pdscrb.') > -1 || lurl.indexOf('doubleclick') > -1 || lurl.indexOf('googlesyndication') > -1 || lurl.indexOf('facebook.com/tr') > -1 || lurl.indexOf('bat.bing') > -1 || lurl.indexOf('clarity.ms') > -1) return;
if (lurl.indexOf('bci=') > -1 && lurl.indexOf('twpid=') > -1) return;
if (lurl.indexOf('cachebust=') > -1 || lurl.indexOf('event_id=') > -1) return;
// Filter CSS fragment references to SVG filter IDs (not real downloadable assets)
if (url.indexOf('.css#') > -1) return;
if (url.indexOf('.css%23') > -1) return;
// Filter same-page fragment references like "https://site.com/#clip-1"
try { var parsed = new URL(url); if (parsed.hash && parsed.pathname.length <= 1) return; } catch(e2) {}
if (!assetMap[url]) {
assetMap[url] = { url: url, type: type, contexts: [], notes: null };
}
var entry = assetMap[url];
if (entry.contexts.indexOf(context) === -1) {
entry.contexts.push(context);
}
if (notes && !entry.notes) {
entry.notes = notes;
}
// Merge rich context (first one wins)
if (richCtx) {
if (richCtx.description && !entry.description) entry.description = richCtx.description;
if (richCtx.nearestHeading && !entry.nearestHeading) entry.nearestHeading = richCtx.nearestHeading;
if (richCtx.sectionClasses && !entry.sectionClasses) entry.sectionClasses = richCtx.sectionClasses;
if (richCtx.aboveFold !== undefined && entry.aboveFold === undefined) entry.aboveFold = richCtx.aboveFold;
}
}
// ── Images: <img src="..."> and <img srcset="..."> ──
document.querySelectorAll('img[src]').forEach(function(img) {
var notes = img.alt || img.getAttribute('aria-label') || null;
var ctx = getElementContext(img);
add(img.src, 'Image', 'img[src]', notes, ctx);
if (img.srcset) {
img.srcset.split(',').forEach(function(entry) {
var u = entry.trim().split(/\\s+/)[0];
if (u) add(u, 'Image', 'img[srcset]', notes, ctx);
});
}
});
// ── Lazy-loaded images: data-src, data-lazy-src, data-original ──
document.querySelectorAll('img[data-src], img[data-lazy-src], img[data-original], [data-background-image]').forEach(function(el) {
var dataSrc = el.getAttribute('data-src') || el.getAttribute('data-lazy-src') || el.getAttribute('data-original') || el.getAttribute('data-background-image');
if (dataSrc) add(dataSrc, 'Image', 'data-src', el.alt || el.getAttribute('aria-label') || null, getElementContext(el));
});
// ── CSS background-image on divs (Framer, Webflow, etc.) ──
document.querySelectorAll('div, section, [class*="hero"], [class*="card"], [class*="image"], [data-framer-background]').forEach(function(el) {
var bg = getComputedStyle(el).backgroundImage;
if (bg && bg !== 'none') {
var match = bg.match(/url\\(["']?(https?:\\/\\/[^"')]+)["']?\\)/);
if (match && match[1]) {
add(match[1], 'Background', 'css url()', el.getAttribute('aria-label') || null, getElementContext(el));
}
}
});
// ── Picture sources: <source srcset="..."> ──
document.querySelectorAll('source[srcset]').forEach(function(src) {
src.srcset.split(',').forEach(function(entry) {
var u = entry.trim().split(/\\s+/)[0];
if (u) add(u, 'Image', 'source[srcset]', null);
});
});
// ── Videos: <video src="..."> and <video poster="..."> ──
document.querySelectorAll('video[src]').forEach(function(v) {
add(v.src, 'Video', 'video[src]', null);
});
document.querySelectorAll('video source[src]').forEach(function(s) {
add(s.src, 'Video', 'video source[src]', null);
});
document.querySelectorAll('video[poster]').forEach(function(v) {
add(v.poster, 'Image', 'video[poster]', null);
});
// ── Links: preload, icon, apple-touch-icon, stylesheet ──
document.querySelectorAll('link[rel]').forEach(function(link) {
var rel = link.rel.toLowerCase();
var href = link.href;
if (!href) return;
if (rel.includes('preload')) {
var asType = link.getAttribute('as') || '';
if (asType === 'font') add(href, 'Font', 'link[rel="preload"]', null);
else if (asType === 'image') add(href, 'Image', 'link[rel="preload"]', null);
else if (asType === 'video') add(href, 'Video', 'link[rel="preload"]', null);
else if (asType === 'style') add(href, 'Other', 'link[rel="preload"]', null);
else add(href, 'Other', 'link[rel="preload"]', null);
}
if (rel.includes('icon')) add(href, 'Icon', 'link[rel="' + rel + '"]', null);
if (rel === 'apple-touch-icon') add(href, 'Icon', 'link[rel="apple-touch-icon"]', null);
});
// ── Meta: og:image, twitter:image ──
document.querySelectorAll('meta[property="og:image"], meta[content][name="twitter:image"]').forEach(function(m) {
var content = m.getAttribute('content');
if (content) {
var prop = m.getAttribute('property') || m.getAttribute('name') || '';
add(content, 'Image', 'meta[' + prop + ']', null);
}
});
// ── CSS url() references from all stylesheets ──
try {
for (var i = 0; i < document.styleSheets.length; i++) {
try {
var sheet = document.styleSheets[i];
var rules = sheet.cssRules || sheet.rules;
if (!rules) continue;
for (var j = 0; j < rules.length; j++) {
var rule = rules[j];
var cssText = rule.cssText || '';
var urlMatches = cssText.match(/url\\(["']?([^"')]+)["']?\\)/g);
if (urlMatches) {
urlMatches.forEach(function(m) {
var u = m.replace(/url\\(["']?/, '').replace(/["']?\\)/, '');
if (u.startsWith('data:')) return;
// Classify by file extension
if (/\\.(woff2?|ttf|otf|eot)$/i.test(u)) {
add(u, 'Font', 'css url()', null);
} else if (/\\.(png|jpg|jpeg|gif|webp|avif|svg)$/i.test(u)) {
add(u, 'Background', 'css url()', null);
} else {
add(u, 'Other', 'css url()', null);
}
});
}
}
} catch(e) { /* cross-origin stylesheet */ }
}
} catch(e) {}
// ── Inline style url() references ──
document.querySelectorAll('[style]').forEach(function(el) {
var style = el.getAttribute('style') || '';
var urlMatches = style.match(/url\\(["']?([^"')]+)["']?\\)/g);
if (urlMatches) {
urlMatches.forEach(function(m) {
var u = m.replace(/url\\(["']?/, '').replace(/["']?\\)/, '');
if (u.startsWith('data:')) return;
if (/\\.(woff2?|ttf|otf|eot)$/i.test(u)) {
add(u, 'Font', 'html inline style url()', null);
} else {
add(u, 'Other', 'html inline style url()', null);
}
});
}
});
return Object.values(assetMap);
})()`);
const raw = (assets as CatalogedAsset[]) || [];
// Deduplicate srcset resolution variants — keep highest resolution per base URL
return deduplicateSrcsetVariants(raw);
}
/**
* Deduplicate Next.js image variants (same image at different w= sizes).
* Keeps the highest resolution version and merges contexts.
*/
function deduplicateSrcsetVariants(assets: CatalogedAsset[]): CatalogedAsset[] {
const byBase = new Map<string, CatalogedAsset>();
for (const a of assets) {
// Extract base URL by stripping w= and q= params from _next/image URLs
let baseKey = a.url;
try {
const u = new URL(a.url);
if (u.pathname.includes("_next/image") || u.searchParams.has("w")) {
u.searchParams.delete("w");
u.searchParams.delete("q");
baseKey = u.toString();
}
} catch {
/* not a valid URL, keep as-is */
}
const existing = byBase.get(baseKey);
if (existing) {
// Merge contexts
for (const ctx of a.contexts) {
if (!existing.contexts.includes(ctx)) {
existing.contexts.push(ctx);
}
}
// Keep notes from whichever has them
if (a.notes && !existing.notes) {
existing.notes = a.notes;
}
// Keep the URL with highest w= value (largest image)
const existingW = getWidthParam(existing.url);
const newW = getWidthParam(a.url);
if (newW > existingW) {
existing.url = a.url;
}
} else {
byBase.set(baseKey, { ...a, contexts: [...a.contexts] });
}
}
return [...byBase.values()];
}
function getWidthParam(url: string): number {
try {
const u = new URL(url);
const w = u.searchParams.get("w");
return w ? parseInt(w) : 0;
} catch {
return 0;
}
}
/**
* Format cataloged assets as markdown for the DESIGN.md Assets section.
* Matches Aura.build's format: grouped by type, named from file paths.
*/
export function formatAssetCatalog(assets: CatalogedAsset[]): string {
if (assets.length === 0) return "No assets detected.\n";
// Group by type
const groups: Record<string, CatalogedAsset[]> = {};
for (const a of assets) {
const group = a.type;
if (!groups[group]) groups[group] = [];
groups[group]!.push(a);
}
const lines: string[] = [];
// Output in order: Fonts, Images, Videos, Icons, Background, Other
const order: CatalogedAsset["type"][] = ["Font", "Image", "Video", "Icon", "Background", "Other"];
for (const type of order) {
const group = groups[type];
if (!group || group.length === 0) continue;
const sectionName =
type === "Font"
? "Fonts"
: type === "Image"
? "Images"
: type === "Video"
? "Videos"
: type === "Icon"
? "Icons"
: type === "Background"
? "Backgrounds"
: "Other";
lines.push(`### ${sectionName}`);
for (const a of group) {
const name = a.notes || deriveAssetName(a.url);
const contexts = a.contexts.join(", ");
lines.push(`- **${name}**: ${a.url} — contexts: ${contexts}`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Derive a human-readable name from a URL's file path.
* E.g., "ConnectBentoBackground.jpg" → "Connect Bento Background"
*/
function deriveAssetName(url: string): string {
try {
const u = new URL(url);
const path = u.pathname;
// Get filename without extension
const filename = path.split("/").pop() || "";
const nameWithoutExt = filename.replace(/\.[^.]+$/, "");
// Remove hash suffixes (e.g., "Sohne.cb178166" → "Sohne")
const cleaned = nameWithoutExt.replace(/\.[a-f0-9]{6,}$/, "");
// Convert camelCase/PascalCase to spaces
const spaced = cleaned
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]/g, " ")
.replace(/\s+/g, " ")
.trim();
return spaced || filename;
} catch {
return "Asset";
}
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Download assets (SVGs, images, favicon, video posters) from extracted tokens + asset catalog.
*
* Single-pass approach: uses the asset catalog (which already deduplicates srcset variants
* and keeps the highest resolution) as the primary source for images. This avoids downloading
* the same image twice at different resolutions.
*/
import { writeFileSync, mkdirSync } from "node:fs";
import { join, extname } from "node:path";
import type { DesignTokens, DownloadedAsset } from "./types.js";
import type { CatalogedAsset } from "./assetCataloger.js";
export async function downloadAssets(
tokens: DesignTokens,
outputDir: string,
catalogedAssets?: CatalogedAsset[],
): Promise<DownloadedAsset[]> {
const assetsDir = join(outputDir, "assets");
mkdirSync(assetsDir, { recursive: true });
const assets: DownloadedAsset[] = [];
const downloadedUrls = new Set<string>();
// 1. ALL inline SVGs — save as files (logos get priority naming)
mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true });
for (let i = 0; i < tokens.svgs.length && i < 30; i++) {
const svg = tokens.svgs[i]!;
if (!svg.outerHTML || svg.outerHTML.length < 50) continue;
const label = svg.label?.replace(/[^a-zA-Z0-9-_ ]/g, "").trim();
const name = label ? slugify(label) + ".svg" : svg.isLogo ? `logo-${i}.svg` : `icon-${i}.svg`;
const localPath = `assets/svgs/${name}`;
try {
writeFileSync(join(outputDir, localPath), svg.outerHTML, "utf-8");
assets.push({ url: "", localPath, type: "svg" });
} catch {
/* skip */
}
}
// 2. Favicon
for (const icon of tokens.icons) {
if (!icon.href) continue;
try {
const ext = extname(new URL(icon.href).pathname) || ".ico";
const name = `favicon${ext}`;
const localPath = `assets/${name}`;
const buffer = await fetchBuffer(icon.href);
if (buffer) {
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: icon.href, localPath, type: "favicon" });
break;
}
} catch {
/* skip */
}
}
// 3. Images — use the catalog as the single source of truth (highest resolution, deduplicated)
// If no catalog available, fall back to tokens.images
const imageUrls: { url: string; isPoster: boolean }[] = [];
if (catalogedAssets && catalogedAssets.length > 0) {
// Use catalog — already deduplicated with highest-res srcset variants
for (const a of catalogedAssets) {
if (a.type !== "Image" && a.type !== "Background") continue;
if (!a.url.startsWith("http")) continue;
// Skip junk
if (a.url.includes("pixel") || a.url.includes("beacon") || a.url.includes("analytics"))
continue;
if (a.url.includes("/favicon")) continue;
// Download images from standard img/video contexts + CSS backgrounds (for hero sections, feature illustrations)
const hasGoodContext = a.contexts.some(
(c) =>
c === "img[src]" ||
c === "img[srcset]" ||
c === "video[poster]" ||
c === "source[srcset]" ||
c === "data-src" ||
c === "css url()",
);
if (!hasGoodContext) continue;
const isPoster = a.contexts.includes("video[poster]");
imageUrls.push({ url: a.url, isPoster });
}
} else {
// Fallback: use tokens.images
for (const img of tokens.images) {
if (img.width > 200 && img.src.startsWith("http")) {
imageUrls.push({ url: img.src, isPoster: false });
}
}
}
// Download all images (no arbitrary cap) — Claude Code needs to see every asset to use them creatively.
// The 10KB minimum size filter handles tracking pixels and tiny icons.
// Pre-filter to deduplicate before downloading.
const toDownload: { url: string; isPoster: boolean; normalized: string }[] = [];
for (const { url, isPoster } of imageUrls) {
const normalized = normalizeUrl(url);
if (downloadedUrls.has(normalized)) continue;
downloadedUrls.add(normalized); // Reserve to prevent duplicates in parallel batches
toDownload.push({ url, isPoster, normalized });
}
// Download in parallel batches of 5
const BATCH_SIZE = 5;
let imgIdx = 0;
for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
const batch = toDownload.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async ({ url, isPoster }) => {
const parsedUrl = new URL(url);
const pathExt = extname(parsedUrl.pathname);
const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg";
const buffer = await fetchBuffer(url);
if (!buffer) return null;
// SVGs are inherently small — don't apply the 10KB minimum to them
const isSvg = ext === ".svg" || url.includes(".svg");
const minSize = isSvg ? 200 : 10000;
if (buffer.length < minSize) return null;
return { url, isPoster, parsedUrl, ext, buffer };
}),
);
for (const result of results) {
if (result.status !== "fulfilled" || !result.value) continue;
const { url, isPoster, parsedUrl, ext, buffer } = result.value;
try {
const prefix = isPoster ? "poster" : "image";
const rawName =
parsedUrl.pathname
.split("/")
.pop()
?.replace(/\.[^.]+$/, "") || "";
const isMeaningful =
rawName.length > 2 &&
rawName.length < 50 &&
!/^[a-f0-9]{8,}$/i.test(rawName) &&
!/^\d+$/.test(rawName) &&
!rawName.includes("_next") &&
!rawName.includes("?");
const slug = isMeaningful ? slugify(rawName) : `${prefix}-${imgIdx}`;
const name = `${slug}${ext}`;
const localPath = `assets/${name}`;
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url, localPath, type: "image" });
imgIdx++;
} catch {
/* skip */
}
}
}
// 4. OG image (if not already downloaded)
if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) {
try {
const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg";
const localPath = `assets/og-image${ext}`;
const buffer = await fetchBuffer(tokens.ogImage);
if (buffer && buffer.length > 5000) {
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: tokens.ogImage, localPath, type: "image" });
}
} catch {
/* skip */
}
}
return assets;
}
/** Normalize URL for deduplication — unwrap Next.js image proxy, strip w/q params */
function normalizeUrl(u: string): string {
try {
const parsed = new URL(u);
if (parsed.pathname.includes("_next/image") && parsed.searchParams.has("url")) {
return decodeURIComponent(parsed.searchParams.get("url")!);
}
parsed.searchParams.delete("w");
parsed.searchParams.delete("q");
parsed.searchParams.delete("dpr");
return parsed.toString();
} catch {
return u;
}
}
/**
* Download fonts referenced in CSS and rewrite URLs to local paths.
* Returns the modified CSS string with local font paths.
*/
export async function downloadAndRewriteFonts(css: string, outputDir: string): Promise<string> {
const assetsDir = join(outputDir, "assets", "fonts");
mkdirSync(assetsDir, { recursive: true });
const fontUrlRegex = /url\(['"]?(https?:\/\/[^'")\s]+\.(?:woff2?|ttf|otf)[^'")\s]*?)['"]?\)/g;
const fontUrls = new Set<string>();
let match;
while ((match = fontUrlRegex.exec(css)) !== null) {
if (match[1]) fontUrls.add(match[1]);
}
if (fontUrls.size === 0) return css;
// Limit font downloads to avoid bloat. Google Fonts serves 20+ unicode-range
// subsets per weight — we only need a few per family for video production.
const MAX_FONTS_PER_FAMILY = 6;
const MAX_TOTAL_FONTS = 30;
const familyCounts = new Map<string, number>();
// Extract font-family from the @font-face rule containing each URL
const getFamilyForUrl = (url: string): string => {
const idx = css.indexOf(url);
if (idx === -1) return "_unknown";
const blockStart = css.lastIndexOf("@font-face", idx);
if (blockStart === -1) return "_unknown";
const blockSlice = css.slice(blockStart, idx);
const familyMatch = blockSlice.match(/font-family\s*:\s*['"]?([^'";}\n]+)/i);
return familyMatch?.[1] ? familyMatch[1].trim().toLowerCase() : "_unknown";
};
// Prioritize Latin subsets over CJK/Arabic/etc unicode ranges
const sortedUrls = Array.from(fontUrls).sort((a, b) => {
const aLatin = /latin|[A-Za-z0-9]{10,}\.woff/.test(a) ? 0 : 1;
const bLatin = /latin|[A-Za-z0-9]{10,}\.woff/.test(b) ? 0 : 1;
return aLatin - bLatin;
});
let rewritten = css;
let count = 0;
for (const fontUrl of sortedUrls) {
if (count >= MAX_TOTAL_FONTS) break;
const family = getFamilyForUrl(fontUrl);
const familyCount = familyCounts.get(family) || 0;
if (familyCount >= MAX_FONTS_PER_FAMILY) continue;
try {
const urlObj = new URL(fontUrl);
const filename = urlObj.pathname.split("/").pop() || `font-${count}.woff2`;
const localPath = join(assetsDir, filename);
const relativePath = `assets/fonts/${filename}`;
const buffer = await fetchBuffer(fontUrl);
if (buffer) {
writeFileSync(localPath, buffer);
rewritten = rewritten.split(fontUrl).join(relativePath);
familyCounts.set(family, familyCount + 1);
count++;
}
} catch {
/* skip */
}
}
return rewritten;
}
/** Block requests to private/internal IP ranges to prevent SSRF */
export function isPrivateUrl(url: string): boolean {
try {
const { hostname } = new URL(url);
// Block cloud metadata, localhost, and private IP ranges
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") return true;
if (hostname === "169.254.169.254") return true; // AWS/GCP metadata
if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true;
// IPv4 private ranges
const parts = hostname.split(".").map(Number);
if (parts.length === 4 && parts.every((p) => !isNaN(p))) {
if (parts[0] === 10) return true; // 10.0.0.0/8
if (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) return true; // 172.16.0.0/12
if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16
if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16 (link-local)
}
// Block non-HTTP(S) schemes
const scheme = new URL(url).protocol;
if (scheme !== "http:" && scheme !== "https:") return true;
return false;
} catch {
return true; // reject unparseable URLs
}
}
async function fetchBuffer(url: string): Promise<Buffer | null> {
try {
if (isPrivateUrl(url)) return null;
const res = await fetch(url, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "HyperFrames/1.0" },
redirect: "follow",
});
if (!res.ok) return null;
// Reject XML/HTML error pages disguised as 200 OK (common with S3/CloudFront)
const ct = res.headers.get("content-type") || "";
if (ct.includes("text/xml") || ct.includes("text/html") || ct.includes("application/xml")) {
return null;
}
const ab = await res.arrayBuffer();
return Buffer.from(ab);
} catch {
return null;
}
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40);
}
@@ -0,0 +1,316 @@
/**
* Content extraction helpers for the website capture pipeline.
*
* Handles library detection, visible text extraction, Gemini captioning,
* and asset description generation.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import { readdirSync, statSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { CatalogedAsset } from "./assetCataloger.js";
import type { DesignTokens } from "./types.js";
/**
* Detect JS libraries via window globals, DOM fingerprints, script URLs,
* and WebGL shader analysis.
*
* Returns a deduplicated list of detected library names.
*/
export async function detectLibraries(
page: Page,
capturedShaders?: Array<{ type: string; source: string }>,
): Promise<string[]> {
let detectedLibraries: string[] = [];
try {
detectedLibraries = (await page.evaluate(`(() => {
var libs = [];
function add(name) { if (libs.indexOf(name) === -1) libs.push(name); }
// 1. Window globals (works for CDN-loaded / non-bundled libraries)
if (typeof window.gsap !== 'undefined' || typeof window.TweenMax !== 'undefined') add('GSAP');
if (typeof window.ScrollTrigger !== 'undefined') add('GSAP ScrollTrigger');
if (typeof window.THREE !== 'undefined') add('Three.js');
if (typeof window.PIXI !== 'undefined') add('PixiJS');
if (typeof window.BABYLON !== 'undefined') add('Babylon.js');
if (typeof window.Lottie !== 'undefined' || typeof window.lottie !== 'undefined') add('Lottie');
if (typeof window.__NEXT_DATA__ !== 'undefined') add('Next.js');
if (typeof window.__NUXT__ !== 'undefined') add('Nuxt');
if (typeof window.Webflow !== 'undefined') add('Webflow');
// 2. DOM fingerprints (survive bundling — most reliable for modern sites)
// Three.js sets data-engine on every canvas it creates
var threeCanvas = document.querySelector('canvas[data-engine*="three"]');
if (threeCanvas) add('Three.js (' + (threeCanvas.getAttribute('data-engine') || '') + ')');
// Babylon.js also sets data-engine
var babylonCanvas = document.querySelector('canvas[data-engine*="Babylon"]');
if (babylonCanvas) add('Babylon.js');
// Lottie web components
if (document.querySelector('dotlottie-wc, lottie-player, dotlottie-player')) add('Lottie');
// Rive
if (document.querySelector('canvas[class*="rive"], rive-canvas')) add('Rive');
// React/Next.js
if (document.getElementById('__next')) add('Next.js');
if (document.getElementById('__nuxt')) add('Nuxt');
if (document.querySelector('[data-reactroot], [data-react-helmet]')) add('React');
// Svelte
if (document.querySelector('[class*="svelte-"]')) add('Svelte');
// Tailwind (utility class detection)
if (document.querySelector('[class*="flex "], [class*="grid "], [class*="px-"], [class*="py-"]')) add('Tailwind CSS');
// Framer Motion
if (document.querySelector('[style*="--framer-"], [data-framer-component-type]')) add('Framer Motion');
// 3. Script URL patterns
document.querySelectorAll('script[src]').forEach(function(s) {
var src = s.src.toLowerCase();
if (src.includes('gsap') || src.includes('tweenmax') || src.includes('greensock')) add('GSAP');
if (src.includes('scrolltrigger')) add('GSAP ScrollTrigger');
if (src.includes('three.module') || src.includes('three.min')) add('Three.js');
if (src.includes('pixi')) add('PixiJS');
if (src.includes('lottie') || src.includes('bodymovin')) add('Lottie');
if (src.includes('framer-motion')) add('Framer Motion');
if (src.includes('anime.min') || src.includes('animejs')) add('Anime.js');
if (src.includes('matter.min') || src.includes('matter-js')) add('Matter.js');
if (src.includes('lenis')) add('Lenis (smooth scroll)');
});
return libs;
})()`)) as string[];
} catch {
// Non-blocking
}
// 4. Shader fingerprinting — infer WebGL framework from captured GLSL
try {
const shaders = capturedShaders || [];
if (shaders.length > 0) {
const allSource = shaders.map((s) => s.source).join("\n");
const add = (name: string) => {
if (!detectedLibraries.includes(name)) detectedLibraries.push(name);
};
add("WebGL");
// Three.js shader fingerprints (built-in uniforms that survive bundling)
if (allSource.includes("modelViewMatrix") && allSource.includes("projectionMatrix"))
add("Three.js (confirmed via shaders)");
// PixiJS shader fingerprints
else if (
allSource.includes("vTextureCoord") &&
allSource.includes("uSampler") &&
!allSource.includes("modelViewMatrix")
)
add("PixiJS (confirmed via shaders)");
// Babylon.js shader fingerprints
else if (allSource.includes("viewProjection") && allSource.includes("world"))
add("Babylon.js (confirmed via shaders)");
}
} catch {
/* non-blocking */
}
return detectedLibraries;
}
/**
* Extract all visible text from the page in DOM order using a TreeWalker.
* Truncates to ~30K chars to avoid blowing up downstream prompts.
*/
export async function extractVisibleText(page: Page): Promise<string> {
let visibleTextContent = "";
try {
visibleTextContent = (await page.evaluate(`(() => {
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
var texts = [];
var node;
while (node = walker.nextNode()) {
var text = (node.textContent || '').trim();
if (text.length < 3) continue;
var el = node.parentElement;
if (!el) continue;
var style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
var tag = el.tagName.toLowerCase();
if (tag === 'script' || tag === 'style' || tag === 'noscript') continue;
texts.push(text);
}
return texts.join('\\n');
})()`)) as string;
// Truncate to ~30K chars to avoid blowing up the prompt
if (visibleTextContent.length > 30000) {
visibleTextContent = visibleTextContent.slice(0, 30000) + "\n[...truncated]";
}
} catch {
// Non-blocking
}
return visibleTextContent;
}
/**
* Caption downloaded images using Gemini vision API.
*
* Batches requests to stay under free-tier rate limits.
* Returns a map of filename -> caption string.
*/
export async function captionImagesWithGemini(
outputDir: string,
progress: (stage: string, detail?: string) => void,
warnings: string[],
): Promise<Record<string, string>> {
const geminiCaptions: Record<string, string> = {};
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!geminiKey) return geminiCaptions;
progress("design", "Captioning images with Gemini vision...");
try {
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: geminiKey });
const imageFiles = readdirSync(join(outputDir, "assets")).filter((f: string) =>
/\.(png|jpg|jpeg|webp|gif)$/i.test(f),
);
// Caption in parallel batches via Gemini vision API.
// Free tier: 5 RPM → batch 5, 12s pause (~$0 but slow)
// Paid tier: 2000 RPM → batch 20, 1s pause (~$0.001/image, fast)
// We try a larger batch first; if rate-limited, fall back to smaller batches.
const model = "gemini-2.5-flash";
const BATCH_SIZE = 20;
for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) {
const batch = imageFiles.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (file: string) => {
const filePath = join(outputDir, "assets", file);
const stat = statSync(filePath);
if (stat.size > 4_000_000) return { file, caption: "" }; // skip images > 4 MB (Gemini inline limit)
const buffer = readFileSync(filePath);
const base64 = buffer.toString("base64");
const ext = file.split(".").pop()?.toLowerCase() || "png";
const mimeType = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
const response = await ai.models.generateContent({
model,
contents: [
{
role: "user",
parts: [
{ inlineData: { mimeType, data: base64 } },
{
text: "Describe this website image in ONE short sentence for a video storyboard. Focus on: what it shows, dominant colors, whether background is light or dark. Be factual, not creative.",
},
],
},
],
config: { maxOutputTokens: 500 },
});
return { file, caption: response.text?.trim() || "" };
}),
);
for (const result of results) {
if (result.status === "fulfilled" && result.value.caption) {
geminiCaptions[result.value.file] = result.value.caption;
}
}
// Pace requests to stay under free tier rate limits (5 RPM for gemini-2.5-flash)
if (i + BATCH_SIZE < imageFiles.length) {
await new Promise((r) => setTimeout(r, 2000)); // 2s pause between batches — paid tier handles 2000 RPM, free tier retries via Promise.allSettled
}
progress(
"design",
`Captioned ${Math.min(i + BATCH_SIZE, imageFiles.length)}/${imageFiles.length} images...`,
);
}
progress("design", `${Object.keys(geminiCaptions).length} images captioned with Gemini`);
} catch (err) {
warnings.push(`Gemini captioning failed: ${err}`);
}
return geminiCaptions;
}
/**
* Generate asset-descriptions.md — one-line descriptions for each downloaded asset.
*
* Returns the description lines (without the markdown header).
*/
export function generateAssetDescriptions(
outputDir: string,
tokens: DesignTokens,
catalogedAssets: CatalogedAsset[],
geminiCaptions: Record<string, string>,
): string[] {
// Sort: Gemini-captioned images first (richest descriptions), then uncaptioned, then SVGs, then fonts
const captionedLines: string[] = [];
const uncaptionedLines: string[] = [];
const svgLines: string[] = [];
const fontLines: string[] = [];
// Describe downloaded images
const assetsPath = join(outputDir, "assets");
try {
for (const file of readdirSync(assetsPath)) {
if (file === "svgs" || file === "fonts" || file === "lottie" || file === "videos") continue;
const filePath = join(assetsPath, file);
const stat = statSync(filePath);
if (!stat.isFile()) continue;
const sizeKb = Math.round(stat.size / 1024);
const catalogMatch = catalogedAssets.find(
(a) => a.url && file.includes(a.url.split("/").pop()?.split("?")[0]?.slice(0, 20) || "___"),
);
const desc = catalogMatch?.description || catalogMatch?.notes || "";
const heading = catalogMatch?.nearestHeading || "";
const section = catalogMatch?.sectionClasses || "";
const aboveFold = catalogMatch?.aboveFold ? "above fold" : "";
const geminiCaption = geminiCaptions[file];
const cleanName = file.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
const parts = [`${file}${sizeKb}KB`];
if (geminiCaption) {
parts.push(geminiCaption);
captionedLines.push(parts.join(", "));
} else {
if (desc) parts.push(`"${desc.slice(0, 80)}"`);
if (heading) parts.push(`section: "${heading.slice(0, 60)}"`);
else if (section) parts.push(`in: ${section.split(" ").slice(0, 3).join(" ")}`);
if (aboveFold) parts.push(aboveFold);
if (!desc && !heading) parts.push(cleanName);
uncaptionedLines.push(parts.join(", "));
}
}
} catch {
/* no assets dir */
}
// Describe SVGs
try {
const svgsPath = join(assetsPath, "svgs");
for (const file of readdirSync(svgsPath)) {
if (!file.endsWith(".svg")) continue;
const svgMatch = tokens.svgs.find(
(s) =>
s.label &&
file.includes(
s.label
.toLowerCase()
.replace(/[^a-z0-9]/g, "-")
.slice(0, 15),
),
);
const label = svgMatch?.label || file.replace(".svg", "").replace(/-/g, " ");
const isLogo = svgMatch?.isLogo || file.includes("logo");
svgLines.push(`svgs/${file}${isLogo ? "logo: " : "icon: "}${label}`);
}
} catch {
/* no svgs dir */
}
// Describe fonts
try {
const fontsPath = join(assetsPath, "fonts");
for (const file of readdirSync(fontsPath)) {
fontLines.push(`fonts/${file} — font file`);
}
} catch {
/* no fonts dir */
}
return [...captionedLines, ...uncaptionedLines, ...svgLines, ...fontLines];
}
+242
View File
@@ -0,0 +1,242 @@
/**
* Extract full-page HTML from a website using Puppeteer CDP.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import type { ExtractedHtml } from "./types.js";
import { isPrivateUrl } from "./assetDownloader.js";
const DEFAULT_SETTLE_TIME = 3000;
export async function extractHtml(
page: Page,
opts: { settleTime?: number } = {},
): Promise<ExtractedHtml> {
const settleTime = opts.settleTime ?? DEFAULT_SETTLE_TIME;
// Step 1: Trigger lazy loading by scrolling through the page
await page.evaluate(`(async () => {
var pageHeight = document.body.scrollHeight;
var viewportH = window.innerHeight;
var step = Math.floor(viewportH * 0.7);
for (var y = 0; y < pageHeight + viewportH; y += step) {
window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 200); });
}
window.scrollTo(0, pageHeight);
await new Promise(function(r) { setTimeout(r, 300); });
window.scrollTo(0, 0);
await new Promise(function(r) { setTimeout(r, 300); });
})()`);
// Re-measure after lazy load
await new Promise((r) => setTimeout(r, settleTime));
// Step 2: Inline external stylesheets
// Fetch CSS from Node.js (bypasses CORS) then inject into page
const stylesheetUrls = (await page.evaluate(`(() => {
return Array.from(document.querySelectorAll('link[rel="stylesheet"][href]')).map(function(l) { return l.href; });
})()`)) as string[];
for (const href of stylesheetUrls) {
try {
if (isPrivateUrl(href)) continue;
const res = await fetch(href, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "Mozilla/5.0" },
});
if (!res.ok) continue;
let css = await res.text();
// Fix relative url() references
css = css.replace(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g, (match: string, url: string) => {
if (url.startsWith("data:") || url.startsWith("http") || url.startsWith("//")) return match;
try {
return `url('${new URL(url, href).href}')`;
} catch {
return match;
}
});
// Add the CSS as a <style> tag in <head> via Puppeteer's addStyleTag
await page.addStyleTag({ content: css });
// Remove the original <link> tag (use parameterized evaluate to avoid injection)
await page.evaluate((targetHref: string) => {
const links = document.querySelectorAll('link[rel="stylesheet"]');
for (const link of links) {
if ((link as HTMLLinkElement).href === targetHref) {
link.remove();
break;
}
}
}, href);
} catch {
/* network error — skip */
}
}
// Step 3: Make URLs absolute and fix HTML entity encoding in src attributes
await page.evaluate(`(() => {
document.querySelectorAll("img[src]").forEach(function(el) {
try {
// getAttribute returns the raw HTML attribute (with &amp;)
// .src returns the resolved URL (with &) — use .src for the correct value
var resolved = el.src;
if (resolved) el.setAttribute("src", resolved);
} catch(e) {}
});
// Fix srcset attributes too (Next.js image optimization)
document.querySelectorAll("img[srcset]").forEach(function(el) {
try {
var srcset = el.getAttribute("srcset") || "";
// Decode &amp; entities in srcset
srcset = srcset.replace(/&amp;/g, "&");
el.setAttribute("srcset", srcset);
} catch(e) {}
});
document.querySelectorAll('[style*="url("]').forEach(function(el) {
el.style.cssText = el.style.cssText.replace(/url\\(['"]?([^'"\\)\\s]+)['"]?\\)/g, function(_, url) {
try { return "url('" + new URL(url, location.href).href + "')"; } catch(e) { return "url('" + url + "')"; }
});
});
})()`);
// Step 3b: Convert cross-origin images to data URLs
// Some CDNs (Contentful, etc.) block direct access but images are already
// loaded in the browser. We convert loaded images to data URLs via canvas.
await page.evaluate(`(async () => {
var imgs = Array.from(document.querySelectorAll("img"));
for (var i = 0; i < imgs.length; i++) {
var img = imgs[i];
try {
if (!img.src || img.src.startsWith("data:")) continue;
if (img.naturalWidth < 10 || img.naturalHeight < 10) continue;
// Only convert cross-origin images (same-origin ones will load fine)
var imgUrl = new URL(img.src);
if (imgUrl.origin === location.origin) continue;
// Try to draw to canvas — will fail if CORS blocks it
var canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
var dataUrl = canvas.toDataURL("image/png");
if (dataUrl.length > 100) {
img.setAttribute("src", dataUrl);
img.removeAttribute("srcset");
}
} catch(e) {
// Canvas CORS failed — try fetch + blob as fallback
try {
var resp = await fetch(img.src, { mode: "cors" });
if (resp.ok) {
var blob = await resp.blob();
var reader = new FileReader();
var dataUrl2 = await new Promise(function(resolve) {
reader.onloadend = function() { resolve(reader.result); };
reader.readAsDataURL(blob);
});
if (dataUrl2 && typeof dataUrl2 === "string" && dataUrl2.length > 100) {
img.setAttribute("src", dataUrl2);
img.removeAttribute("srcset");
}
}
} catch(e2) {
// Both methods failed — image stays as original URL
}
}
}
})()`);
// Step 4: Extract everything
const result = (await page.evaluate(`(() => {
// Capture styles AND scripts from head separately then combine
// Scripts include Three.js, animation libraries that we want to preserve
var styles = Array.from(document.head.querySelectorAll("style")).map(function(s) { return s.outerHTML; }).join("\\n");
var scripts = Array.from(document.head.querySelectorAll("script")).map(function(s) { return s.outerHTML; }).join("\\n");
var headHtml = styles + "\\n" + scripts;
var bodyHtml = document.body.innerHTML;
var cssomRules = [];
for (var i = 0; i < document.styleSheets.length; i++) {
var sheet = document.styleSheets[i];
try {
var ownerNode = sheet.ownerNode;
if (ownerNode && ownerNode.textContent && ownerNode.textContent.trim()) continue;
if (sheet.href) continue;
for (var j = 0; j < sheet.cssRules.length; j++) {
cssomRules.push(sheet.cssRules[j].cssText);
}
} catch(e) {}
}
var htmlEl = document.documentElement;
var attrParts = [];
for (var i = 0; i < htmlEl.attributes.length; i++) {
var attr = htmlEl.attributes[i];
if (attr.name === "lang" || attr.name === "class" || attr.name === "style" || attr.name === "dir" || attr.name.startsWith("data-")) {
attrParts.push(attr.name + '="' + attr.value.replace(/"/g, "&quot;") + '"');
}
}
return {
headHtml: headHtml,
bodyHtml: bodyHtml,
cssomRules: cssomRules.join("\\n"),
htmlAttrs: attrParts.join(" "),
viewportWidth: Math.max(window.innerWidth, document.documentElement.scrollWidth),
viewportHeight: window.innerHeight,
fullPageHeight: document.body.scrollHeight
};
})()`)) as ExtractedHtml;
// Post-process in Node.js (more reliable than browser-side fixing):
// 1. Decode &amp; in image src/srcset attributes
// 2. Make relative image URLs absolute using the page's origin
const pageOrigin = new URL(page.url()).origin;
result.bodyHtml = result.bodyHtml.replace(
/(<img\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
let fixed = url.replace(/&amp;/g, "&");
// Make relative URLs absolute
if (fixed.startsWith("/") && !fixed.startsWith("//")) {
fixed = pageOrigin + fixed;
}
return pre + fixed + post;
},
);
result.bodyHtml = result.bodyHtml.replace(
/(<img\b[^>]*\bsrcset=")([^"]*?)(")/g,
(_match: string, pre: string, urls: string, post: string) => {
const fixed = urls
.replace(/&amp;/g, "&")
.replace(
/(^|,\s*)(\/[^\s,]+)/g,
(_m: string, sep: string, path: string) => sep + pageOrigin + path,
);
return pre + fixed + post;
},
);
// Also fix video src/poster URLs
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
let fixed = url.replace(/&amp;/g, "&");
if (fixed.startsWith("/") && !fixed.startsWith("//")) fixed = pageOrigin + fixed;
return pre + fixed + post;
},
);
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bposter=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
let fixed = url.replace(/&amp;/g, "&");
if (fixed.startsWith("/") && !fixed.startsWith("//")) fixed = pageOrigin + fixed;
return pre + fixed + post;
},
);
return result;
}
+525
View File
@@ -0,0 +1,525 @@
/**
* Website capture orchestrator.
*
* Two-pass capture approach:
* Pass 1: Full page load (all JS) → catalog animations + snapshot canvases
* Pass 2: Framework scripts blocked → extract stable HTML/CSS
*
* This ensures we get both:
* - Rich animation metadata for Claude Code to recreate
* - Stable, renderable HTML that won't crash in Puppeteer
*/
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { extractHtml } from "./htmlExtractor.js";
// captureScreenshots removed — full-page screenshot replaces per-section shots
import { extractTokens } from "./tokenExtractor.js";
import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js";
// briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them
import {
setupAnimationCapture,
startCdpAnimationCapture,
collectAnimationCatalog,
} from "./animationCataloger.js";
import {
saveLottieAnimations,
renderLottiePreviews,
captureVideoManifest,
} from "./mediaCapture.js";
import type { DiscoveredLottie } from "./mediaCapture.js";
import {
detectLibraries,
extractVisibleText,
captionImagesWithGemini,
generateAssetDescriptions,
} from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
import type { CaptureOptions, CaptureResult } from "./types.js";
export type { CaptureOptions, CaptureResult } from "./types.js";
export async function captureWebsite(
opts: CaptureOptions,
onProgress?: (stage: string, detail?: string) => void,
): Promise<CaptureResult> {
const {
url,
outputDir,
viewportWidth = 1920,
viewportHeight = 1080,
timeout = 120000,
settleTime = 3000,
maxScreenshots: _maxScreenshots = 24,
skipAssets = false,
} = opts;
const warnings: string[] = [];
const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail);
};
// Load .env file from repo root if it exists (for GEMINI_API_KEY, etc.)
loadEnvFile(outputDir);
// Create output directories
mkdirSync(join(outputDir, "extracted"), { recursive: true });
mkdirSync(join(outputDir, "screenshots"), { recursive: true });
mkdirSync(join(outputDir, "assets"), { recursive: true });
// Launch browser
progress("browser", "Launching headless Chrome...");
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
"--use-gl=angle",
"--use-angle=swiftshader",
"--disable-blink-features=AutomationControlled",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
`--window-size=${viewportWidth},${viewportHeight}`,
],
});
let animationCatalog: CaptureResult["animationCatalog"];
try {
// ═══════════════════════════════════════════════════════════════
// PASS 1: Full page load — all JS runs
// Goal: Catalog animations + take screenshots (with JS rendering)
// ═══════════════════════════════════════════════════════════════
progress("animations", "Cataloging animations (full JS)...");
const page1 = await chromeBrowser.newPage();
await page1.setViewport({ width: viewportWidth, height: viewportHeight });
await page1.setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
);
// Set up hooks BEFORE navigation
await setupAnimationCapture(page1);
const { cdp, animations: cdpAnims } = await startCdpAnimationCapture(page1);
// Hook WebGL to capture shader source code (GLSL)
// Captured shaders inform Claude Code about the site's visual effects
// and enable reliable library detection (Three.js/PixiJS/Babylon.js uniforms survive bundling)
await page1.evaluateOnNewDocument(`
var origGetContext = HTMLCanvasElement.prototype.getContext;
window.__capturedShaders = [];
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
var ctx = origGetContext.call(this, type, attrs);
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
if (ctx.shaderSource && !ctx.__hfHooked) {
var origShaderSource = ctx.shaderSource.bind(ctx);
ctx.shaderSource = function(shader, source) {
try {
var shaderType = ctx.getShaderParameter(shader, ctx.SHADER_TYPE);
window.__capturedShaders.push({
type: shaderType === ctx.VERTEX_SHADER ? 'vertex' : 'fragment',
source: source.slice(0, 5000)
});
} catch(e) {}
return origShaderSource(shader, source);
};
ctx.__hfHooked = true;
}
}
return ctx;
};
`);
// Intercept network responses to detect Lottie JSON files
const discoveredLotties: DiscoveredLottie[] = [];
page1.on("response", async (response) => {
try {
const responseUrl = response.url();
const contentType = response.headers()["content-type"] || "";
const isJsonUrl = responseUrl.endsWith(".json");
const isLottieUrl = responseUrl.endsWith(".lottie");
const isJson =
contentType.includes("application/json") || contentType.includes("text/plain");
if (isLottieUrl) {
discoveredLotties.push({ url: responseUrl });
return;
}
if (isJsonUrl || isJson) {
// Check Content-Length before downloading to avoid OOM on huge responses
const cl = parseInt(response.headers()["content-length"] || "0", 10);
if (cl > 5_000_000) return;
const buffer = await response.buffer();
if (buffer.length < 100 || buffer.length > 5_000_000) return; // Skip tiny or huge
const text = buffer.toString("utf-8");
const json = JSON.parse(text);
// Validate Lottie structure: must have version, in/out points, layers, dimensions, framerate
if (
json &&
typeof json === "object" &&
["v", "ip", "op", "layers", "w", "h", "fr"].every((k: string) => k in json)
) {
discoveredLotties.push({
url: responseUrl,
data: json,
dimensions: { w: json.w, h: json.h },
frameRate: json.fr,
});
}
}
} catch {
/* not JSON or parse error — skip */
}
});
// Use networkidle2 (allows 2 ongoing connections) instead of networkidle0 —
// modern SPAs often have persistent WebSocket/analytics connections that
// prevent networkidle0 from ever resolving.
await page1.goto(url, { waitUntil: "networkidle2", timeout });
await new Promise((r) => setTimeout(r, settleTime));
// Check if the page loaded real content or an anti-bot challenge
// Use structural detection (DOM elements + cookies), not text regex matching —
// text matching causes false positives on sites that mention "blocked" or "verify" in copy
const pageContentCheck = (await page1.evaluate(`(() => {
var text = (document.body.innerText || "").trim();
var title = document.title || "";
// Structural: Cloudflare Turnstile widget or challenge iframe
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
// Structural: page is almost empty (challenge pages have minimal DOM)
var bodyChildCount = document.body.children.length;
var isMinimalDom = bodyChildCount <= 5 && text.length < 500;
// Title-based: only check title on near-empty pages
var hasChallengeTitle = isMinimalDom && /just a moment|attention required|access denied/i.test(title);
var isChallenged = hasCfTurnstile || hasChallengeTitle;
return { textLength: text.length, title: title, isChallenged: isChallenged, bodyChildCount: bodyChildCount };
})()`)) as { textLength: number; title: string; isChallenged: boolean; bodyChildCount: number };
if (pageContentCheck.isChallenged || pageContentCheck.textLength < 100) {
const reason = pageContentCheck.isChallenged
? "Anti-bot protection detected (Cloudflare challenge or similar)"
: "Page has very little text content (" +
pageContentCheck.textLength +
" chars) — may be blocked or a client-rendered SPA that needs more time";
warnings.push(reason);
progress("warn", reason);
}
// Scroll through page to trigger lazy-loaded images and Lottie animations
// Framer and other modern sites use IntersectionObserver — images only load
// when scrolled into view. We scroll the full page, then wait for all images
// to finish loading before proceeding.
await page1.evaluate(`(async () => {
var h = document.body.scrollHeight;
for (var y = 0; y < h; y += window.innerHeight * 0.7) {
window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 400); });
}
// Scroll to very bottom to catch footer lazy-loads
window.scrollTo(0, document.body.scrollHeight);
await new Promise(function(r) { setTimeout(r, 800); });
// Wait for all images to finish loading
var imgs = Array.from(document.querySelectorAll('img'));
var pending = imgs.filter(function(img) { return !img.complete; });
if (pending.length > 0) {
await Promise.race([
Promise.all(pending.map(function(img) {
return new Promise(function(r) { img.onload = r; img.onerror = r; });
})),
new Promise(function(r) { setTimeout(r, 5000); })
]);
}
window.scrollTo(0, 0);
await new Promise(function(r) { setTimeout(r, 500); });
})()`);
await page1.evaluate(`window.scrollTo(0, 0)`);
await new Promise((r) => setTimeout(r, 300));
// Save discovered Lottie animations
// Also scan DOM for Lottie web components not caught by network interception
try {
const domLotties = await page1.evaluate(`(() => {
var urls = [];
document.querySelectorAll('dotlottie-wc, lottie-player, dotlottie-player').forEach(function(el) {
var src = el.getAttribute('src');
if (src) urls.push(src);
});
// Also check lottie-web registered animations
if (window.lottie && window.lottie.getRegisteredAnimations) {
window.lottie.getRegisteredAnimations().forEach(function(anim) {
if (anim.path) urls.push(anim.path);
});
}
return urls;
})()`);
if (Array.isArray(domLotties)) {
for (const lottieUrl of domLotties) {
if (
typeof lottieUrl === "string" &&
!discoveredLotties.some((l) => l.url === lottieUrl)
) {
discoveredLotties.push({ url: lottieUrl });
}
}
}
} catch {
/* DOM scan failed — non-critical */
}
if (discoveredLotties.length > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
mkdirSync(lottieDir, { recursive: true });
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0) {
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir);
progress("lottie", `${savedCount} Lottie animation(s) saved`);
}
}
// Save captured WebGL shaders (useful context for shader transitions + library detection)
let capturedShaders: Array<{ type: string; source: string }> | undefined;
try {
const shaders = await page1.evaluate(`window.__capturedShaders || []`);
if (Array.isArray(shaders) && shaders.length > 0) {
const seen = new Set<string>();
const unique = (shaders as Array<{ type: string; source: string }>).filter((s) => {
if (seen.has(s.source)) return false;
seen.add(s.source);
return true;
});
capturedShaders = unique;
writeFileSync(
join(outputDir, "extracted", "shaders.json"),
JSON.stringify(unique, null, 2),
"utf-8",
);
progress("shaders", `${unique.length} WebGL shader(s) captured`);
}
} catch {
/* shader extraction failed — non-critical */
}
// ── READ-ONLY phase: extract data from the live DOM before any mutations ──
// extractHtml (below) converts image src to data URLs and removes scripts —
// all read-only operations must run BEFORE it to see the original DOM.
// Extract design tokens
progress("tokens", "Extracting design tokens...");
const tokens = await extractTokens(page1);
writeFileSync(
join(outputDir, "extracted", "tokens.json"),
JSON.stringify(tokens, null, 2),
"utf-8",
);
// Collect animation catalog
progress("animations", "Cataloging animations...");
animationCatalog = await collectAnimationCatalog(page1, cdpAnims, cdp);
// Capture scroll-position viewport screenshots
progress("screenshots", "Capturing scroll screenshots...");
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
const screenshots = await captureScrollScreenshots(page1, outputDir);
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
// Catalog all assets (must run before extractHtml which converts img src to data URLs)
progress("design", "Cataloging assets...");
let catalogedAssets: import("./assetCataloger.js").CatalogedAsset[] = [];
try {
const { catalogAssets } = await import("./assetCataloger.js");
catalogedAssets = await catalogAssets(page1);
progress("design", `${catalogedAssets.length} assets cataloged`);
} catch (err) {
warnings.push(`Asset cataloging failed: ${err}`);
}
// ── MUTATION phase: extractHtml modifies the live DOM (converts images to data URLs) ──
progress("extract", "Extracting HTML & CSS...");
const extracted = await extractHtml(page1, { settleTime: 1000 });
// Strip framework scripts from the extracted body — keep visual library scripts
// IMPORTANT: Use non-greedy matching within individual script tags only
extracted.bodyHtml = extracted.bodyHtml
// Remove __NEXT_DATA__ (has its own ID so safe to target)
.replace(/<script\s+id="__NEXT_DATA__"[^>]*>[\s\S]*?<\/script>/gi, "")
// Remove React hydration markers
.replace(/\s*data-reactroot="[^"]*"/g, "")
.replace(/\s*data-reactroot/g, "");
// Remove Next.js bootstrap scripts individually (match each script tag separately)
extracted.bodyHtml = extracted.bodyHtml.replace(
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
(match: string, content: string) => {
// Only remove if this specific script contains Next.js bootstrap code
if (
content.includes("__next_f") ||
content.includes("self.__next_f") ||
content.includes("__NEXT_LOADED_PAGES__") ||
content.includes("_N_E") ||
content.includes("__NEXT_P")
) {
return "";
}
return match;
},
);
// Strip framework script tags from head (keep styles + visual library scripts)
const FRAMEWORK_SRC_PATTERNS = [
/_next\/static\/chunks\/(main|framework|webpack|pages\/)/,
/_next\/static\/chunks\/app\//,
/_buildManifest\.js/,
/_ssgManifest\.js/,
];
extracted.headHtml = extracted.headHtml.replace(
/<script[^>]*src="([^"]*)"[^>]*><\/script>/gi,
(match: string, src: string) => {
if (FRAMEWORK_SRC_PATTERNS.some((p) => p.test(src))) return "";
return match;
},
);
// Generate video manifest — screenshot each <video> element + extract surrounding context
// so Claude Code can SEE what each video shows and WHERE it was used on the page.
try {
await captureVideoManifest(page1, outputDir, progress);
} catch {
/* non-blocking — video manifest is best-effort */
}
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
const detectedLibraries = await detectLibraries(page1, capturedShaders);
// Extract all visible text in DOM order
const visibleTextContent = await extractVisibleText(page1);
await page1.close();
// Download fonts and rewrite URLs to local paths
extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir);
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
if (animationCatalog) {
// Extract just what's useful: counts, named animations, a few representative keyframed entries
const uniqueAnimNames = new Set<string>();
for (const d of animationCatalog.cssDeclarations || []) {
if (d.animation?.name) uniqueAnimNames.add(d.animation.name);
}
// Keep up to 10 Web Animations that have actual keyframe data (most useful for recreation)
const representativeAnims = (animationCatalog.webAnimations || [])
.filter((a) => a.keyframes && a.keyframes.length > 0)
.slice(0, 10);
const leanCatalog = {
summary: animationCatalog.summary,
namedAnimations: Array.from(uniqueAnimNames),
scrollTriggeredElements: (animationCatalog.scrollTargets || []).length,
representativeAnimations: representativeAnims,
};
writeFileSync(
join(outputDir, "extracted", "animations.json"),
JSON.stringify(leanCatalog, null, 2),
"utf-8",
);
}
// Download assets — single pass using the catalog for best image quality
let assets: CaptureResult["assets"] = [];
if (!skipAssets) {
progress("assets", "Downloading assets...");
assets = await downloadAssets(tokens, outputDir, catalogedAssets);
}
// Save visible text content for AI agent to use
if (visibleTextContent) {
writeFileSync(join(outputDir, "extracted", "visible-text.txt"), visibleTextContent, "utf-8");
}
// Save cataloged assets as JSON for AI agent
if (catalogedAssets.length > 0) {
writeFileSync(
join(outputDir, "extracted", "assets-catalog.json"),
JSON.stringify(catalogedAssets, null, 2),
"utf-8",
);
}
// Save detected libraries
if (detectedLibraries.length > 0) {
writeFileSync(
join(outputDir, "extracted", "detected-libraries.json"),
JSON.stringify(detectedLibraries, null, 2),
"utf-8",
);
}
// AI-powered image captioning via Gemini (optional — enriches asset descriptions)
const geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings);
// Generate asset descriptions for the AI agent
progress("design", "Generating asset descriptions...");
try {
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
if (lines.length > 0) {
writeFileSync(
join(outputDir, "extracted", "asset-descriptions.md"),
"# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\n" +
lines.map((l) => "- " + l).join("\n") +
"\n",
"utf-8",
);
progress("design", `${lines.length} asset descriptions written`);
}
} catch {
/* non-critical */
}
progress("design", "DESIGN.md will be created by your AI agent");
// Generate project scaffold (index.html, meta.json, CLAUDE.md)
await generateProjectScaffold(
outputDir,
url,
tokens,
animationCatalog,
screenshots.length > 0,
discoveredLotties.length > 0,
existsSync(join(outputDir, "extracted", "shaders.json")),
catalogedAssets,
progress,
warnings,
);
progress("done", "Capture complete");
return {
ok: true,
projectDir: outputDir,
url,
title: tokens.title,
extracted,
screenshots,
tokens,
assets,
animationCatalog,
warnings,
};
} finally {
await chromeBrowser.close();
}
}
// visual-style.md and capture-summary.md generators removed — DESIGN.md replaces them
+370
View File
@@ -0,0 +1,370 @@
/**
* Media capture helpers for the website capture pipeline.
*
* Handles Lottie animation preview rendering and video element manifest capture.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Browser, Page } from "puppeteer-core";
import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { isPrivateUrl } from "./assetDownloader.js";
/** Discovered Lottie item from network interception or DOM scan. */
export interface DiscoveredLottie {
url: string;
data?: unknown;
dimensions?: { w: number; h: number };
frameRate?: number;
}
/**
* Download and save discovered Lottie animations to disk.
*
* Handles both plain JSON and dotLottie (.lottie ZIP) formats.
* Deduplicates by content hash. Returns the count of saved files.
*/
export async function saveLottieAnimations(
discoveredLotties: DiscoveredLottie[],
lottieDir: string,
): Promise<number> {
let savedCount = 0;
const savedHashes = new Set<string>(); // Deduplicate by content
for (let li = 0; li < discoveredLotties.length && li < 10; li++) {
const lottieItem = discoveredLotties[li]!;
try {
let jsonData: string | undefined;
if (lottieItem.data) {
// Already have the JSON data from network interception
jsonData = JSON.stringify(lottieItem.data);
} else if (lottieItem.url) {
// SSRF guard — don't fetch private/internal URLs
if (isPrivateUrl(lottieItem.url)) continue;
// Download the file
const res = await fetch(lottieItem.url, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "HyperFrames/1.0" },
});
if (!res.ok) continue;
const buf = Buffer.from(await res.arrayBuffer());
if (lottieItem.url.endsWith(".lottie")) {
// dotLottie is a ZIP — extract the animation JSON
try {
const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip(buf);
const entries = zip.getEntries();
// Look for animation JSON in both v1 (animations/) and v2 (a/) paths
const animEntry = entries.find(
(e) =>
(e.entryName.startsWith("a/") || e.entryName.startsWith("animations/")) &&
e.entryName.endsWith(".json"),
);
if (animEntry) {
jsonData = animEntry.getData().toString("utf-8");
}
} catch {
// adm-zip not available or extraction failed — save raw .lottie
const hash = buf.toString("base64").slice(0, 100);
if (savedHashes.has(hash)) continue;
savedHashes.add(hash);
writeFileSync(join(lottieDir, `animation-${savedCount}.lottie`), buf);
savedCount++;
continue;
}
} else {
// Plain JSON file
jsonData = buf.toString("utf-8");
}
}
if (jsonData) {
// Deduplicate by content hash (first 100 chars of stringified JSON)
const hash = jsonData.slice(0, 200);
if (savedHashes.has(hash)) continue;
savedHashes.add(hash);
// Validate it's actually Lottie
try {
const parsed = JSON.parse(jsonData);
if (!parsed.layers || !parsed.w) continue;
} catch {
continue;
}
writeFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
savedCount++;
}
} catch {
/* skip */
}
}
return savedCount;
}
/**
* Render preview thumbnails for saved Lottie animation JSON files.
*
* Opens each Lottie JSON in a headless Chrome page via lottie-web,
* seeks to ~30% through the animation, and takes a transparent screenshot.
* Writes a lottie-manifest.json with metadata + preview paths.
*/
export async function renderLottiePreviews(
chromeBrowser: Browser,
lottieDir: string,
outputDir: string,
): Promise<void> {
const manifest: Array<{
file: string;
preview: string;
name: string;
width: number;
height: number;
duration: number;
frameRate: number;
layers: number;
}> = [];
const previewDir = join(lottieDir, "previews");
mkdirSync(previewDir, { recursive: true });
for (const file of readdirSync(lottieDir)) {
if (!file.endsWith(".json")) continue;
try {
const raw = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const fr = raw.fr || 30;
const dur = ((raw.op || 0) - (raw.ip || 0)) / fr;
const previewName = file.replace(".json", "-preview.png");
// Render a mid-frame thumbnail using Puppeteer + lottie-web
// Skip huge Lottie files for preview (CDP has a ~256MB message limit)
const fileSize = statSync(join(lottieDir, file)).size;
if (fileSize > 2_000_000) continue;
let previewPage;
try {
previewPage = await chromeBrowser.newPage();
await previewPage.setViewport({ width: 400, height: 400 });
const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3);
// Load the shell page first (no untrusted data in the HTML)
await previewPage.setContent(
`<!DOCTYPE html>
<html><head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<style>*{margin:0;padding:0;background:transparent}#c{width:400px;height:400px}</style>
</head><body><div id="c"></div></body></html>`,
{ waitUntil: "networkidle0", timeout: 10000 },
);
// Pass animation data safely via parameterized evaluate (no string interpolation)
await previewPage.evaluate(
(data: unknown, frame: number) => {
const a = (window as any).lottie.loadAnimation({
container: document.getElementById("c"),
renderer: "svg",
loop: false,
autoplay: false,
animationData: data,
});
a.addEventListener("DOMLoaded", () => {
a.goToAndStop(frame, true);
(window as any).__READY = true;
});
},
animData,
midFrame,
);
await previewPage
.waitForFunction(() => (window as any).__READY === true, { timeout: 5000 })
.catch(() => {});
await previewPage.screenshot({
path: join(previewDir, previewName),
type: "png",
omitBackground: true,
});
} catch {
/* preview rendering failed — non-critical */
} finally {
await previewPage?.close().catch(() => {});
}
manifest.push({
file: `assets/lottie/${file}`,
preview: `assets/lottie/previews/${previewName}`,
name: raw.nm || file,
width: raw.w || 0,
height: raw.h || 0,
duration: Math.round(dur * 10) / 10,
frameRate: fr,
layers: (raw.layers || []).length,
});
} catch {
/* skip */
}
}
if (manifest.length > 0) {
writeFileSync(
join(outputDir, "extracted", "lottie-manifest.json"),
JSON.stringify(manifest, null, 2),
"utf-8",
);
}
}
/**
* Capture video element manifest screenshot each <video> element and
* extract surrounding context (heading, caption, aria-label).
*
* Writes video-manifest.json and preview screenshots to assets/videos/previews/.
*/
export async function captureVideoManifest(
page: Page,
outputDir: string,
progress: (stage: string, detail?: string) => void,
): Promise<void> {
const videoElements = (await page.evaluate(`(() => {
var videos = Array.from(document.querySelectorAll('video'));
return videos.map(function(v) {
var src = v.src || v.currentSrc || (v.querySelector('source') ? v.querySelector('source').src : '');
if (!src || !src.startsWith('http')) return null;
// Get bounding box for screenshot
var rect = v.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return null;
// Nearest heading above the video
var heading = '';
var el = v;
for (var i = 0; i < 8; i++) {
el = el.parentElement;
if (!el) break;
var h = el.querySelector('h1,h2,h3,h4');
if (h) { heading = h.textContent.trim().slice(0, 100); break; }
}
// Nearest paragraph/caption text
var caption = '';
el = v;
for (var j = 0; j < 5; j++) {
el = el.parentElement;
if (!el) break;
var p = el.querySelector('p,figcaption,[class*="caption"],[class*="desc"]');
if (p) { caption = p.textContent.trim().slice(0, 200); break; }
}
// aria-label on video or wrapper
var ariaLabel = v.getAttribute('aria-label') || v.getAttribute('title') || '';
var wrapper = v.parentElement;
if (!ariaLabel && wrapper) ariaLabel = wrapper.getAttribute('aria-label') || '';
return {
src: src,
width: Math.round(rect.width),
height: Math.round(rect.height),
top: Math.round(rect.top),
left: Math.round(rect.left),
heading: heading,
caption: caption,
ariaLabel: ariaLabel,
filename: src.split('/').pop().split('?')[0],
};
}).filter(Boolean);
})()`)) as Array<{
src: string;
width: number;
height: number;
top: number;
left: number;
heading: string;
caption: string;
ariaLabel: string;
filename: string;
}>;
// Deduplicate by src
const seenSrcs = new Set<string>();
const uniqueVideos = videoElements.filter((v) => {
if (seenSrcs.has(v.src)) return false;
seenSrcs.add(v.src);
return true;
});
if (uniqueVideos.length > 0) {
const videoManifestDir = join(outputDir, "assets", "videos");
mkdirSync(videoManifestDir, { recursive: true });
const previewDir = join(videoManifestDir, "previews");
mkdirSync(previewDir, { recursive: true });
const videoManifest: Array<{
index: number;
url: string;
filename: string;
width: number;
height: number;
heading: string;
caption: string;
ariaLabel: string;
preview: string;
}> = [];
for (let vi = 0; vi < uniqueVideos.length && vi < 20; vi++) {
const v = uniqueVideos[vi]!;
const previewName = `video-${vi}-preview.png`;
const previewPath = join(previewDir, previewName);
// Screenshot the video element to get a visible frame
try {
// Scroll to the video element so it's in the viewport
await page.evaluate(`window.scrollTo(0, ${Math.max(0, v.top - 100)})`);
await new Promise((r) => setTimeout(r, 300));
// Re-measure position after scroll (layout may have shifted)
const rect = (await page.evaluate((fn) => {
const vid = [...document.querySelectorAll("video")].find((x) =>
(x.src || x.currentSrc || "").includes(fn),
);
if (!vid) return null;
// Seek to 0.1s and wait for a frame to decode
vid.currentTime = 0.1;
return vid.getBoundingClientRect().toJSON();
}, v.filename)) as { x: number; y: number; width: number; height: number } | null;
if (!rect || rect.width < 10) continue;
await new Promise((r) => setTimeout(r, 200)); // let decoder settle
await page.screenshot({
path: previewPath,
clip: {
x: Math.max(0, rect.x),
y: Math.max(0, rect.y),
width: Math.min(rect.width, 1920),
height: Math.min(rect.height, 1080),
},
});
} catch {
/* preview failed — non-critical */
}
videoManifest.push({
index: vi,
url: v.src,
filename: v.filename,
width: v.width,
height: v.height,
heading: v.heading,
caption: v.caption,
ariaLabel: v.ariaLabel,
preview: `assets/videos/previews/${previewName}`,
});
}
if (videoManifest.length > 0) {
writeFileSync(
join(outputDir, "extracted", "video-manifest.json"),
JSON.stringify(videoManifest, null, 2),
"utf-8",
);
progress("design", `${videoManifest.length} video previews captured`);
}
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Project scaffolding helpers for the website capture pipeline.
*
* Handles .env file loading and HyperFrames project scaffold generation
* (index.html, meta.json, CLAUDE.md).
*/
import { existsSync, writeFileSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { CatalogedAsset } from "./assetCataloger.js";
import type { CaptureResult, DesignTokens } from "./types.js";
/**
* Load .env file by walking up from startDir (up to 5 levels).
* Sets process.env keys that are not already set. Best-effort never throws.
*/
export function loadEnvFile(startDir: string): void {
try {
let dir = resolve(startDir);
for (let i = 0; i < 5; i++) {
const envPath = resolve(dir, ".env");
try {
const envContent = readFileSync(envPath, "utf-8");
for (const line of envContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed
.slice(eq + 1)
.trim()
.replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = val;
}
break;
} catch {
dir = resolve(dir, "..");
}
}
} catch {
/* .env loading is best-effort */
}
}
/**
* Generate the project scaffold files: index.html, meta.json, and CLAUDE.md.
*
* Only creates files that don't already exist (index.html, meta.json).
* Always generates CLAUDE.md via agentPromptGenerator.
*/
export async function generateProjectScaffold(
outputDir: string,
url: string,
tokens: DesignTokens,
animationCatalog: CaptureResult["animationCatalog"],
hasScreenshots: boolean,
hasLotties: boolean,
hasShaders: boolean,
catalogedAssets: CatalogedAsset[],
progress: (stage: string, detail?: string) => void,
warnings: string[],
): Promise<void> {
// Ensure capture output is a valid HyperFrames project (index.html + meta.json)
const indexPath = join(outputDir, "index.html");
const metaPath = join(outputDir, "meta.json");
if (!existsSync(indexPath)) {
writeFileSync(
indexPath,
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
</style>
</head>
<body>
<!-- Root composition wrapper — AGENT: update data-duration to match total video length -->
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="28">
<!-- SCENE SLOTS — AGENT: adjust count, durations, and IDs to match your scene plan -->
<div id="scene-1" data-composition-src="compositions/scene-1.html" data-start="0" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
<div id="scene-2" data-composition-src="compositions/scene-2.html" data-start="7" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
<div id="scene-3" data-composition-src="compositions/scene-3.html" data-start="14" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
<div id="scene-4" data-composition-src="compositions/scene-4.html" data-start="21" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
<!-- NARRATION — AGENT: update src after generating TTS -->
<audio id="narration" data-start="0" data-duration="28" data-track-index="0" data-volume="1" src="narration.wav"></audio>
<!-- CAPTIONS (optional — only add if user requests captions/subtitles) -->
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body>
</html>
`,
"utf-8",
);
}
if (!existsSync(metaPath)) {
const hostname = new URL(url).hostname.replace(/^www\./, "");
writeFileSync(
metaPath,
JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2),
"utf-8",
);
}
// Generate CLAUDE.md + .cursorrules (AI agent instructions — always, regardless of API keys)
try {
const { generateAgentPrompt } = await import("./agentPromptGenerator.js");
generateAgentPrompt(
outputDir,
url,
tokens,
animationCatalog,
hasScreenshots,
hasLotties,
hasShaders,
catalogedAssets,
);
progress("agent", "CLAUDE.md generated");
} catch (err) {
warnings.push(`CLAUDE.md generation failed: ${err}`);
}
}
@@ -0,0 +1,82 @@
/**
* Screenshot capture for the website capture pipeline.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
/**
* Capture viewport screenshots covering the entire page height.
*
* Scrolls down the page in viewport-sized steps (with slight overlap),
* taking a 1920x1080 screenshot at each position. The number of screenshots
* depends on the page height short pages get fewer, long pages get more.
* Capped at 20 to avoid excessive output on extremely long pages.
*
* Unlike the old section-tiling approach, this does NOT disable sticky/fixed
* elements screenshots show the page in its natural browsing state with
* scroll-triggered animations fired.
*/
export async function captureScrollScreenshots(page: Page, outputDir: string): Promise<string[]> {
const screenshotsDir = join(outputDir, "screenshots");
mkdirSync(screenshotsDir, { recursive: true });
const MAX_SCREENSHOTS = 20;
const filePaths: string[] = [];
try {
const scrollHeight = (await page.evaluate(
`Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`,
)) as number;
const viewportHeight = (await page.evaluate(`window.innerHeight`)) as number;
// Calculate scroll positions: step by 70% of viewport (30% overlap between shots)
const step = Math.floor(viewportHeight * 0.7);
const positions: number[] = [0];
for (let y = step; y < scrollHeight - viewportHeight; y += step) {
positions.push(y);
}
// Always include the bottom of the page
const lastPos = Math.max(0, scrollHeight - viewportHeight);
if (positions[positions.length - 1] !== lastPos) {
positions.push(lastPos);
}
// Downsample if too many positions
let finalPositions = positions;
if (positions.length > MAX_SCREENSHOTS) {
finalPositions = [positions[0]!];
const stride = (positions.length - 1) / (MAX_SCREENSHOTS - 1);
for (let i = 1; i < MAX_SCREENSHOTS - 1; i++) {
finalPositions.push(positions[Math.round(i * stride)]!);
}
finalPositions.push(positions[positions.length - 1]!);
}
for (let i = 0; i < finalPositions.length; i++) {
await page.evaluate(`window.scrollTo(0, ${finalPositions[i]})`);
await new Promise((r) => setTimeout(r, 400));
const pct = Math.round(
(finalPositions[i]! / Math.max(1, scrollHeight - viewportHeight)) * 100,
);
const filename = `scroll-${String(Math.min(pct, 100)).padStart(3, "0")}.png`;
const filePath = join(screenshotsDir, filename);
const buffer = await page.screenshot({ type: "png" });
writeFileSync(filePath, buffer);
filePaths.push(`screenshots/${filename}`);
}
// Reset scroll
await page.evaluate(`window.scrollTo(0, 0)`);
await new Promise((r) => setTimeout(r, 200));
} catch {
/* scroll screenshots are non-critical */
}
return filePaths;
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Extract design tokens from a rendered page.
*
* All page.evaluate() calls use string expressions to avoid
* tsx/esbuild __name injection (see esbuild issue #1031).
*/
import type { Page } from "puppeteer-core";
import type { DesignTokens } from "./types.js";
// The entire extraction runs as a single string-based evaluate
// to avoid tsx __name injection into the browser context.
const EXTRACT_SCRIPT = `(() => {
var isVisible = (el) => {
var s = getComputedStyle(el);
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0" && el.getBoundingClientRect().height > 0;
};
// 1. CSS custom properties from :root
var cssVariables = {};
for (var i = 0; i < document.styleSheets.length; i++) {
try {
var rules = document.styleSheets[i].cssRules;
for (var j = 0; j < rules.length; j++) {
if (rules[j].selectorText === ":root") {
for (var k = 0; k < rules[j].style.length; k++) {
var prop = rules[j].style[k];
if (prop.startsWith("--")) {
cssVariables[prop] = rules[j].style.getPropertyValue(prop).trim();
}
}
}
}
} catch(e) {}
}
// 2. Meta
var title = document.title || "";
var descEl = document.querySelector('meta[name="description"]') || document.querySelector('meta[property="og:description"]');
var description = descEl ? descEl.content : "";
var ogImgEl = document.querySelector('meta[property="og:image"]');
var ogImage = ogImgEl ? ogImgEl.content : undefined;
// 3. Fonts
var fontSet = {};
var fontSamples = [document.body, document.querySelector("h1"), document.querySelector("h2"), document.querySelector("p"), document.querySelector("button")].filter(Boolean);
for (var fi = 0; fi < fontSamples.length; fi++) {
var family = getComputedStyle(fontSamples[fi]).fontFamily.split(",")[0].replace(/['"]/g, "").trim();
if (family && ["serif","sans-serif","monospace","cursive"].indexOf(family) === -1) fontSet[family] = true;
}
// 4. Colors — hybrid: DOM computed styles + visual pixel sampling
var colorSet = {};
function addColor(c, weight) {
if (!c || c === "rgba(0, 0, 0, 0)" || c === "transparent" || c === "inherit" || c === "initial" || c === "currentcolor") return;
var hex = rgbToHex(c);
if (hex) colorSet[hex] = (colorSet[hex] || 0) + (weight || 1);
}
function rgbToHex(color) {
if (!color) return null;
if (color.startsWith('#')) return (color.length === 4
? '#' + color[1]+color[1] + color[2]+color[2] + color[3]+color[3]
: color).toUpperCase();
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/);
if (!m) {
// Handle color(srgb ...) format
var cm = color.match(/color\\(srgb\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)/);
if (cm) {
m = [null, Math.round(parseFloat(cm[1])*255), Math.round(parseFloat(cm[2])*255), Math.round(parseFloat(cm[3])*255)];
} else {
// Handle modern color functions (oklch, oklab, lch, lab, hsl, color-mix)
// Use a 1x1 canvas to resolve ANY CSS color to RGB — this works even when
// getComputedStyle returns the color in its original color space (Chrome 131+)
if (/oklch|oklab|lch|lab|hsla?|color-mix|color\\(/.test(color)) {
try {
var cvs = document.createElement('canvas');
cvs.width = 1; cvs.height = 1;
var ctx2d = cvs.getContext('2d');
if (ctx2d) {
ctx2d.fillStyle = color;
ctx2d.fillRect(0, 0, 1, 1);
var px = ctx2d.getImageData(0, 0, 1, 1).data;
if (px[3] > 0) return '#' + ((1<<24) + (px[0]<<16) + (px[1]<<8) + px[2]).toString(16).slice(1).toUpperCase();
}
} catch(e2) {}
// Fallback: temp element approach
var tmp = document.createElement('div');
tmp.style.color = color;
document.body.appendChild(tmp);
var resolved = getComputedStyle(tmp).color;
document.body.removeChild(tmp);
if (resolved !== color) return rgbToHex(resolved);
return null;
}
return null;
}
}
return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase();
}
// 4a. Sample DOM elements (text colors, borders, branded elements)
var colorCandidates = Array.from(document.querySelectorAll(
"body, header, nav, main, footer, section, " +
"h1, h2, h3, h4, h5, h6, " +
"a, button, [role='button'], " +
"[class*='hero'], [class*='cta'], [class*='btn'], [class*='card'], " +
"[class*='badge'], [class*='tag'], [class*='accent'], [class*='highlight']"
)).slice(0, 200);
for (var ci = 0; ci < colorCandidates.length; ci++) {
try {
var cs = getComputedStyle(colorCandidates[ci]);
addColor(cs.backgroundColor);
addColor(cs.color);
addColor(cs.borderColor);
addColor(cs.outlineColor);
// Extract colors from gradients in background-image
var bgImg = cs.backgroundImage;
if (bgImg && bgImg !== 'none') {
var gradColors = bgImg.match(/(?:#[0-9a-fA-F]{3,8}|rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|hsla?\\([^)]+\\)|lab\\([^)]+\\))/g);
if (gradColors) gradColors.forEach(function(gc) { addColor(gc); });
}
// Extract colors from box-shadow
var shadow = cs.boxShadow;
if (shadow && shadow !== 'none') {
var shadowColors = shadow.match(/(?:#[0-9a-fA-F]{3,8}|rgba?\\([^)]+\\))/g);
if (shadowColors) shadowColors.forEach(function(sc) { addColor(sc); });
}
} catch(e) {}
}
// 4b. Explicitly sample html/body backgrounds (the dominant canvas color)
// These often define the site's light/dark character
try {
var htmlBg = getComputedStyle(document.documentElement).backgroundColor;
var bodyBg = getComputedStyle(document.body).backgroundColor;
addColor(htmlBg, 10);
addColor(bodyBg, 10);
// Also check the background shorthand which may contain gradients
var bodyBgFull = getComputedStyle(document.body).background;
var gradColors = bodyBgFull.match(/(?:#[0-9a-fA-F]{3,8}|rgba?\\([^)]+\\)|oklch\\([^)]+\\)|hsla?\\([^)]+\\))/g);
if (gradColors) gradColors.forEach(function(gc) { addColor(gc, 8); });
} catch(e) {}
// 4c. Visual pixel sampling — sample what the user actually SEES
// Walk a grid of points across the viewport and read background + text color
var vpW = window.innerWidth;
var vpH = window.innerHeight;
var gridCols = 6;
var gridRows = 5;
for (var gy = 0; gy < gridRows; gy++) {
for (var gx = 0; gx < gridCols; gx++) {
try {
var px = Math.round((gx + 0.5) * vpW / gridCols);
var py = Math.round((gy + 0.5) * vpH / gridRows);
var elAt = document.elementFromPoint(px, py);
if (elAt) {
var elStyle = getComputedStyle(elAt);
addColor(elStyle.color, 2);
var bgc = elStyle.backgroundColor;
// Walk up parents until we find a non-transparent background
var bgWalker = elAt;
while (bgWalker && (!bgc || bgc === "rgba(0, 0, 0, 0)" || bgc === "transparent")) {
bgWalker = bgWalker.parentElement;
if (bgWalker) bgc = getComputedStyle(bgWalker).backgroundColor;
}
addColor(bgc, 3);
}
} catch(e) {}
}
}
// 4c2. Broad sweep — find ANY element with a non-white/non-transparent background
// This catches colored blocks that the grid might miss (code blocks, banners, cards)
var allEls = document.querySelectorAll('*');
var colorSweepCount = 0;
for (var si = 0; si < allEls.length && colorSweepCount < 500; si++) {
try {
var elCs = getComputedStyle(allEls[si]);
var elBg = elCs.backgroundColor;
if (elBg && elBg !== "rgba(0, 0, 0, 0)" && elBg !== "transparent") {
var hex = rgbToHex(elBg);
if (hex && hex !== "#FFFFFF" && hex !== "#000000") {
addColor(elBg, 1);
}
}
colorSweepCount++;
} catch(e) {}
}
// 4d. Resolve CSS custom properties from :root to actual color values
var rootStyle = getComputedStyle(document.documentElement);
var rootProps = Object.keys(cssVariables);
for (var ri = 0; ri < rootProps.length; ri++) {
var val = rootStyle.getPropertyValue(rootProps[ri]).trim();
if (val && /^(#|rgb|hsl|oklch|oklab|lch|lab|color)/.test(val)) {
addColor(val);
}
}
// 5. Headings
var headingEls = Array.from(document.querySelectorAll("h1, h2, h3, h4")).slice(0, 20);
var headings = headingEls.filter(isVisible).map(function(h) {
var s = getComputedStyle(h);
return { level: parseInt(h.tagName[1]), text: (h.innerText || h.textContent || "").trim().replace(/\\s+/g, ' ').slice(0, 200), fontSize: s.fontSize, fontWeight: s.fontWeight, color: rgbToHex(s.color) || s.color };
});
// 6. Paragraphs
var paragraphs = Array.from(document.querySelectorAll("p")).slice(0, 10).map(function(p) { return (p.textContent || "").trim().slice(0, 300); }).filter(function(t) { return t.length > 20; });
// 7. CTAs — match by class AND by text content patterns
// Conservative class selectors (avoid nav links with "action" or "start" in class)
var ctaSelectors = 'a[class*="btn"], a[class*="button"], a[class*="cta"], button[class*="primary"], button[class*="cta"], [role="button"]';
var ctaEls = Array.from(document.querySelectorAll(ctaSelectors));
// Filter out nav links (common false positives)
ctaEls = ctaEls.filter(function(el) {
return !el.closest('nav, [role="navigation"], [class*="nav"], [class*="menu"], [class*="dropdown"]');
});
// Also find links/buttons by text content (catches CTAs without class hints)
// Require short text (real CTAs are concise) and exclude nav context
var ctaTextPatterns = /^(get started|sign up|start free|try (it )?free|start (a )?trial|book a demo|request (a )?demo|contact (us|sales)|start for free|create account|register now)$/i;
var allButtons = Array.from(document.querySelectorAll('a, button'));
for (var bi = 0; bi < allButtons.length && ctaEls.length < 20; bi++) {
var btnText = (allButtons[bi].textContent || "").trim();
if (btnText.length > 30) continue;
if (allButtons[bi].closest('nav, [role="navigation"], [class*="nav"], [class*="menu"]')) continue;
if (ctaTextPatterns.test(btnText) && ctaEls.indexOf(allButtons[bi]) === -1) {
ctaEls.push(allButtons[bi]);
}
}
ctaEls = ctaEls.slice(0, 10);
var ctas = ctaEls.filter(isVisible).map(function(c) { return { text: (c.textContent || "").trim().slice(0, 60), href: c.href || undefined }; }).filter(function(c) { return c.text.length > 1; });
// 8. SVGs
var svgEls = Array.from(document.querySelectorAll("svg"));
var svgs = svgEls.map(function(svg) {
var label = svg.getAttribute("aria-label") || svg.getAttribute("title") || svg.getAttribute("alt");
// Try harder to find a name: check class, id, parent context, inner text
if (!label) {
// Extract meaningful class name, skipping utility classes (tailwind, size, color)
var svgClasses = (svg.getAttribute("class") || "").split(/\\s+/);
var utilityPattern = /^(w-|h-|p-|m-|text-|bg-|border-|flex|grid|block|hidden|inline|absolute|relative|transition|duration|rotate|scale|opacity|group|sm:|md:|lg:|xl:)/;
for (var ci = 0; ci < svgClasses.length; ci++) {
var cls = svgClasses[ci];
if (cls.length > 3 && cls.length < 40 && !utilityPattern.test(cls) && cls !== "lucide") {
label = cls;
break;
}
}
}
if (!label) {
var svgId = svg.getAttribute("id") || "";
if (svgId && svgId.length > 2 && svgId.length < 40) label = svgId;
}
if (!label) {
// Check parent element for clues
var parent = svg.closest("[class*='icon'], [class*='logo'], [class*='nav'], [class*='btn'], [class*='social']");
if (parent) {
var parentClass = (parent.getAttribute("class") || "").split(" ").find(function(c) { return c.length > 3 && c.length < 30; });
if (parentClass) label = parentClass;
}
}
if (!label) {
// Check for text content inside the SVG (e.g. <text>NeetCode</text>)
var textEl = svg.querySelector("text");
if (textEl && textEl.textContent && textEl.textContent.trim().length > 1 && textEl.textContent.trim().length < 30) {
label = textEl.textContent.trim();
}
}
var w = svg.getAttribute("width");
// Keep SVGs that have a label OR are at least 16px wide OR are inside a logo/brand context
var inLogoContext = svg.closest('[class*="logo"], [class*="brand"], [class*="partner"], [class*="customer"], [class*="marquee"]') !== null;
if (!label && !inLogoContext && (!w || parseInt(w) < 16)) return null;
return {
label: label || undefined,
viewBox: svg.getAttribute("viewBox") || undefined,
outerHTML: svg.outerHTML.slice(0, 10000),
isLogo: (label && label.toLowerCase().indexOf("logo") !== -1) || svg.closest('[class*="logo"], [class*="brand"], [class*="home"], [class*="marquee"], [class*="partner"], [class*="customer"]') !== null
};
}).filter(Boolean).slice(0, 50);
// 9. Images
var imgEls = Array.from(document.querySelectorAll("img[src]")).filter(function(img) { return img.naturalWidth > 200 && isVisible(img); }).slice(0, 15);
var images = imgEls.map(function(img) { return { src: img.src, alt: img.alt || "", width: img.naturalWidth, height: img.naturalHeight }; });
// 10. Icons
var iconEls = Array.from(document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]'));
var icons = iconEls.map(function(l) { return { rel: l.rel, href: l.href }; });
// 11. Sections — find large visual blocks regardless of HTML tag
var sectionResults = [];
// Start with semantic elements, then fall back to large direct children of body/main
var candidates = Array.from(document.querySelectorAll(
'section, main > div, main > section, article, ' +
'body > div > div, body > main > div, body > div, ' +
'[class*="hero"], [class*="Hero"], [class*="section"], [class*="Section"], ' +
'[class*="container"], [class*="wrapper"], [class*="block"], ' +
'[id*="section"], [id*="hero"], footer, [role="region"], [role="banner"]'
));
// Deduplicate (a div can match multiple selectors)
var seenEls = new Set();
candidates = candidates.filter(function(el) {
if (seenEls.has(el)) return false;
seenEls.add(el);
return true;
});
for (var si = 0; si < candidates.length; si++) {
var el = candidates[si];
var rect = el.getBoundingClientRect();
if (rect.height < 200 || rect.width < 400 || !isVisible(el)) continue;
// Skip page-level wrappers (a single div wrapping the entire page is not a section)
var pageHeight = document.body.scrollHeight || document.documentElement.scrollHeight;
if (rect.height > pageHeight * 0.8) continue;
var y = rect.top + window.scrollY;
var heading = el.querySelector("h1, h2, h3, h4");
var headingText = heading ? (heading.innerText || heading.textContent || "").trim().replace(/\\s+/g, ' ').slice(0, 80) : "";
var classes = (el.className || "").toString().toLowerCase();
var type = "content";
if (y < 200 || classes.indexOf("hero") !== -1) type = "hero";
else if (el.tagName === "FOOTER" || classes.indexOf("footer") !== -1) type = "footer";
else if (classes.indexOf("cta") !== -1) type = "cta";
else if (classes.indexOf("logo") !== -1 || classes.indexOf("customer") !== -1) type = "logos";
else if (classes.indexOf("testimonial") !== -1 || classes.indexOf("quote") !== -1) type = "testimonials";
else if (classes.indexOf("feature") !== -1 || classes.indexOf("section") !== -1) type = "features";
var selector = el.id ? "#" + el.id : el.tagName.toLowerCase();
var sectionBg = getComputedStyle(el).backgroundColor;
// Walk up DOM to find nearest non-transparent background (don't default to white)
if (!sectionBg || sectionBg === "rgba(0, 0, 0, 0)" || sectionBg === "transparent") {
var bgWalker = el.parentElement;
while (bgWalker) {
var parentBg = getComputedStyle(bgWalker).backgroundColor;
if (parentBg && parentBg !== "rgba(0, 0, 0, 0)" && parentBg !== "transparent") {
sectionBg = parentBg;
break;
}
bgWalker = bgWalker.parentElement;
}
if (!sectionBg || sectionBg === "rgba(0, 0, 0, 0)" || sectionBg === "transparent") sectionBg = "#FFFFFF";
}
sectionBg = rgbToHex(sectionBg) || sectionBg;
sectionResults.push({ selector: selector, type: type, y: Math.round(y), height: Math.round(rect.height), heading: headingText, backgroundColor: sectionBg });
}
sectionResults.sort(function(a, b) { return a.y - b.y; });
var filtered = sectionResults.filter(function(s, i) { return i === 0 || Math.abs(s.y - sectionResults[i-1].y) > 100; });
return {
title: title, description: description, ogImage: ogImage,
cssVariables: cssVariables, fonts: Object.keys(fontSet), colors: Object.keys(colorSet).sort(function(a,b) { return colorSet[b] - colorSet[a]; }).slice(0, 20),
headings: headings, paragraphs: paragraphs, ctas: ctas,
svgs: svgs, images: images, icons: icons, sections: filtered
};
})()`;
export async function extractTokens(page: Page): Promise<DesignTokens> {
return page.evaluate(EXTRACT_SCRIPT) as Promise<DesignTokens>;
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Types for the website capture pipeline.
*
* Phase 1: Capture Extract HTML, CSS, screenshots, tokens, assets from a URL
* Phase 2: Split Decompose into per-section sub-compositions
* Phase 3: Verify Validate each section renders correctly
* Phase 4: Scaffold Assemble standard HyperFrames project
*/
// ── Phase 1: Capture ────────────────────────────────────────────────────────
export interface CaptureOptions {
/** URL to capture */
url: string;
/** Output directory */
outputDir: string;
/** Viewport width (default: 1920) */
viewportWidth?: number;
/** Viewport height (default: 1080) */
viewportHeight?: number;
/** Page load timeout in ms (default: 120000) */
timeout?: number;
/** Extra wait after load for JS to settle (default: 3000) */
settleTime?: number;
/** Maximum screenshots to take (default: 24) */
maxScreenshots?: number;
/** Skip asset downloads */
skipAssets?: boolean;
/** Output JSON for programmatic use */
json?: boolean;
}
export interface CaptureResult {
/** Whether capture completed successfully */
ok: boolean;
/** Project output directory */
projectDir: string;
/** Source URL */
url: string;
/** Page title */
title: string;
/** Extracted HTML data */
extracted: ExtractedHtml;
/** Screenshot file paths (relative to projectDir) */
screenshots: string[];
/** Design tokens extracted from the page */
tokens: DesignTokens;
/** Downloaded asset paths (relative to projectDir) */
assets: DownloadedAsset[];
/** Animation catalog (captured during full-JS page load) */
animationCatalog?: import("./animationCataloger.js").AnimationCatalog;
/** Errors/warnings encountered during capture */
warnings: string[];
}
export interface ExtractedHtml {
/** All <style> tags from <head> (after stylesheet inlining) */
headHtml: string;
/** Full document.body.innerHTML */
bodyHtml: string;
/** CSS-in-JS rules from document.styleSheets (CSSOM) */
cssomRules: string;
/** <html> element attributes (class, data-theme, style, lang) */
htmlAttrs: string;
/** Original viewport width during capture */
viewportWidth: number;
/** Original viewport height during capture */
viewportHeight: number;
/** Full page scroll height */
fullPageHeight: number;
}
// ── Design Tokens ───────────────────────────────────────────────────────────
export interface DesignTokens {
/** Page title */
title: string;
/** Meta description */
description: string;
/** OG image URL */
ogImage?: string;
/** CSS custom properties from :root */
cssVariables: Record<string, string>;
/** Font families in use */
fonts: string[];
/** Extracted colors (background, text, accent) */
colors: string[];
/** Headings with text and basic styles */
headings: Array<{
level: number;
text: string;
fontSize: string;
fontWeight: string;
color: string;
}>;
/** Paragraph text (first 10) */
paragraphs: string[];
/** CTA button/link text */
ctas: Array<{ text: string; href?: string }>;
/** SVG elements with labels */
svgs: Array<{
label?: string;
viewBox?: string;
outerHTML: string;
isLogo: boolean;
}>;
/** Large images on the page */
images: Array<{
src: string;
alt: string;
width: number;
height: number;
}>;
/** Favicon/icon URLs */
icons: Array<{ rel: string; href: string }>;
/** Detected page sections with bounding rects */
sections: Array<{
selector: string;
type: string;
y: number;
height: number;
heading: string;
backgroundColor?: string;
}>;
}
// ── Assets ──────────────────────────────────────────────────────────────────
export interface DownloadedAsset {
/** Original URL */
url: string;
/** Local file path (relative to projectDir) */
localPath: string;
/** Asset type */
type: "svg" | "image" | "favicon";
}
+2
View File
@@ -43,6 +43,8 @@ const subCommands = {
skills: () => import("./commands/skills.js").then((m) => m.default),
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
};
const main = defineCommand({
+169
View File
@@ -0,0 +1,169 @@
import { defineCommand } from "citty";
import { resolve } from "node:path";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
["Capture a website", "hyperframes capture https://stripe.com"],
["Capture to a specific directory", "hyperframes capture https://linear.app -o linear-video"],
["JSON output for AI agents", "hyperframes capture https://example.com --json"],
];
export default defineCommand({
meta: {
name: "capture",
description: "Capture a website as editable HyperFrames components",
},
args: {
url: {
type: "positional",
description: "Website URL to capture",
required: true,
},
output: {
type: "string",
description: "Output directory name",
alias: "o",
},
"skip-assets": {
type: "boolean",
description: "Skip downloading assets (images, SVGs)",
default: false,
},
"max-screenshots": {
type: "string",
description: "Maximum screenshots to capture (default: 24)",
},
timeout: {
type: "string",
description: "Page load timeout in ms (default: 120000)",
},
json: {
type: "boolean",
description: "Output JSON (for AI agents / programmatic use)",
default: false,
},
},
async run({ args }) {
const url = args.url as string;
// Validate URL
try {
new URL(url);
} catch {
console.error(`Invalid URL: ${url}`);
process.exit(1);
}
// Determine output directory — default to captures/<hostname> to keep repo root clean
let outputName = args.output as string | undefined;
if (!outputName) {
const hostname = new URL(url).hostname.replace(/^www\./, "");
outputName = `captures/${hostname.replace(/\./g, "-")}`;
}
const outputDir = resolve(outputName);
const isJson = args.json as boolean;
if (!isJson) {
const { c } = await import("../ui/colors.js");
console.log();
console.log(c.dim("◆") + " Capturing " + c.bold(url));
console.log();
}
const { captureWebsite } = await import("../capture/index.js");
try {
const result = await captureWebsite(
{
url,
outputDir,
skipAssets: args["skip-assets"] as boolean,
maxScreenshots: args["max-screenshots"]
? parseInt(args["max-screenshots"] as string)
: undefined,
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
json: isJson,
},
isJson
? undefined
: (stage: string, detail?: string) => {
const stages: Record<string, string> = {
browser: " Launching browser...",
navigate: " Loading page...",
extract: " Extracting HTML & CSS...",
tokens: " Extracting design tokens...",
screenshots: " Capturing screenshots...",
assets: " Downloading assets...",
style: " Generating visual style...",
done: " Done",
};
const label = stages[stage] || ` ${stage}`;
console.log(detail ? `${label} ${detail}` : label);
},
);
if (isJson) {
// Output structured JSON for Claude Code / programmatic use
console.log(
JSON.stringify(
{
ok: result.ok,
projectDir: result.projectDir,
url: result.url,
title: result.title,
screenshots: result.screenshots.length,
assets: result.assets.length,
detectedSections: result.tokens.sections.length,
fonts: result.tokens.fonts,
animations: result.animationCatalog?.summary,
warnings: result.warnings,
},
null,
2,
),
);
} else {
const { c } = await import("../ui/colors.js");
console.log();
console.log(c.success("◇") + ` Captured ${c.bold(result.title)}${c.dim(outputDir)}`);
console.log();
console.log(` ${c.dim("Screenshots:")} ${result.screenshots.length}`);
console.log(` ${c.dim("Assets:")} ${result.assets.length}`);
console.log(` ${c.dim("Sections:")} ${result.tokens.sections.length}`);
console.log(` ${c.dim("Fonts:")} ${result.tokens.fonts.join(", ")}`);
if (result.warnings.length > 0) {
console.log();
for (const w of result.warnings) {
console.log(` ${c.warn("⚠")} ${w}`);
}
}
console.log();
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// Write BLOCKED.md so the user/agent knows the capture failed
try {
const { mkdirSync, writeFileSync } = await import("node:fs");
mkdirSync(outputDir, { recursive: true });
const isTimeout = /timeout|timed out/i.test(errMsg);
const reason = isTimeout
? "Page navigation timed out — the site may be blocking headless browsers or requires authentication."
: `Capture failed: ${errMsg}`;
writeFileSync(
`${outputDir}/BLOCKED.md`,
`# Capture Failed\n\n${reason}\n\nURL: ${url}\n\n## What to try\n\n- Re-run with a longer timeout: \`--timeout 60000\`\n- The site may block headless browsers (anti-bot protection)\n- Try capturing a different page on the same domain\n`,
"utf-8",
);
} catch {
/* best-effort */
}
if (isJson) {
console.log(JSON.stringify({ ok: false, error: errMsg }));
} else {
console.error(`\n ✗ Capture failed: ${errMsg}\n`);
}
process.exit(1);
}
},
});
+284
View File
@@ -0,0 +1,284 @@
import { defineCommand } from "citty";
import { existsSync, readFileSync, mkdirSync } from "node:fs";
import { resolve, join, dirname, relative, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject } from "../utils/project.js";
import { c } from "../ui/colors.js";
import type { Example } from "./_examples.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const examples: Example[] = [
["Capture 5 key frames from a composition", "snapshot captures/stripe"],
["Capture 10 evenly-spaced frames", "snapshot captures/stripe --frames 10"],
];
/**
* Render key frames from a composition as PNG screenshots.
* The agent can Read these to verify its output visually.
*/
async function captureSnapshots(
projectDir: string,
opts: { frames?: number; timeout?: number; at?: number[] },
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
const numFrames = opts.frames ?? 5;
// 1. Bundle
let html = await bundleToSingleHtml(projectDir);
// Inject local runtime if available
const runtimePath = resolve(
__dirname,
"..",
"..",
"..",
"core",
"dist",
"hyperframe.runtime.iife.js",
);
if (existsSync(runtimePath)) {
const runtimeSource = readFileSync(runtimePath, "utf-8");
html = html.replace(
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
);
}
// 2. Start minimal file server
const { createServer } = await import("node:http");
const { getMimeType } = await import("@hyperframes/core/studio-api");
const server = createServer((req, res) => {
const url = req.url ?? "/";
if (url === "/" || url === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(html);
return;
}
const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, ""));
const rel = relative(projectDir, filePath);
if (rel.startsWith("..") || isAbsolute(rel)) {
res.writeHead(403);
res.end();
return;
}
if (existsSync(filePath)) {
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
res.end(readFileSync(filePath));
return;
}
res.writeHead(404);
res.end();
});
const port = await new Promise<number>((resolvePort, rejectPort) => {
server.on("error", rejectPort); // register before listen to catch sync bind errors
server.listen(0, () => {
const addr = server.address();
const p = typeof addr === "object" && addr ? addr.port : 0;
if (!p) rejectPort(new Error("Failed to bind local HTTP server"));
else resolvePort(p);
});
});
const savedPaths: string[] = [];
try {
// 3. Launch headless Chrome
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: [
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--enable-webgl",
"--use-gl=angle",
"--use-angle=swiftshader",
],
});
try {
const page = await chromeBrowser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(`http://127.0.0.1:${port}/`, {
waitUntil: "domcontentloaded",
timeout: 10000,
});
// Wait for runtime to initialize and sub-compositions to load
const timeoutMs = opts.timeout ?? 5000;
await page
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
timeout: timeoutMs,
})
.catch(() => {});
// Wait for sub-compositions to be mounted by the runtime
// (they're fetched and injected asynchronously via data-composition-src)
await page
.waitForFunction(
() => {
const tls = (window as any).__timelines;
if (!tls) return false;
const keys = Object.keys(tls);
// Wait until at least one sub-composition timeline is registered
// (not counting "main" or empty registrations)
return keys.length >= 2 || keys.some((k) => k !== "main");
},
{ timeout: timeoutMs },
)
.catch(() => {});
// Extra settle time for media, fonts, and animations to initialize
await new Promise((r) => setTimeout(r, 1500));
// Get composition duration
const duration = await page.evaluate(() => {
const win = window as any;
const pd = win.__player?.duration;
if (pd != null) return typeof pd === "function" ? pd() : pd;
const root = document.querySelector("[data-composition-id][data-duration]");
if (root) return parseFloat(root.getAttribute("data-duration") ?? "0");
const tls = win.__timelines;
if (tls) {
for (const key in tls) {
const d = tls[key]?.duration;
if (d != null) return typeof d === "function" ? d() : d;
}
}
return 0;
});
if (duration <= 0 && !opts.at?.length) {
return [];
}
// Calculate seek positions — explicit timestamps or evenly spaced
const positions: number[] = opts.at?.length
? opts.at
: numFrames === 1
? [duration / 2]
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
// Create output directory
const snapshotDir = join(projectDir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
// Seek and capture each frame
for (let i = 0; i < positions.length; i++) {
const time = positions[i]!;
await page.evaluate((t: number) => {
const win = window as any;
if (win.__player?.seek) {
win.__player.seek(t);
} else {
const tls = win.__timelines;
if (tls) {
for (const key in tls) {
if (tls[key]?.seek) {
tls[key].pause();
tls[key].seek(t);
}
}
}
}
}, time);
// Wait for rendering to settle after seek
await page.evaluate(
() =>
new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))),
);
await new Promise((r) => setTimeout(r, 200));
const timeLabel = opts.at?.length
? `${time.toFixed(1)}s`
: `${Math.round((time / duration) * 100)}pct`;
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
const framePath = join(snapshotDir, filename);
await page.screenshot({ path: framePath, type: "png" });
savedPaths.push(`snapshots/${filename}`);
}
} finally {
await chromeBrowser.close();
}
} finally {
server.close();
}
return savedPaths;
}
export default defineCommand({
meta: {
name: "snapshot",
description: "Capture key frames from a composition as PNG screenshots for visual verification",
},
args: {
dir: {
type: "positional",
description: "Project directory",
required: false,
},
frames: {
type: "string",
description: "Number of evenly-spaced frames to capture (default: 5)",
default: "5",
},
at: {
type: "string",
description: "Comma-separated timestamps in seconds (e.g., --at 3.0,10.5,18.0)",
},
timeout: {
type: "string",
description: "Ms to wait for runtime to initialize (default: 5000)",
default: "5000",
},
},
async run({ args }) {
const project = resolveProject(args.dir);
const frames = parseInt(args.frames as string, 10) || 5;
const timeout = parseInt(args.timeout as string, 10) || 5000;
const atTimestamps = args.at
? String(args.at)
.split(",")
.map((s) => parseFloat(s.trim()))
.filter((n) => !isNaN(n))
: undefined;
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
: `${frames} frames`;
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}`);
try {
const paths = await captureSnapshots(project.dir, { frames, timeout, at: atTimestamps });
if (paths.length === 0) {
console.log(
`\n${c.error("✗")} Could not determine composition duration — no frames captured`,
);
process.exit(1);
}
console.log(`\n${c.success("◇")} ${paths.length} snapshots saved to snapshots/`);
for (const p of paths) {
console.log(` ${p}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`);
process.exit(1);
}
},
});
+2
View File
@@ -21,6 +21,7 @@ const GROUPS: Group[] = [
commands: [
["init", "Scaffold a new composition project"],
["add", "Install a block or component from the registry"],
["capture", "Capture a website for video production"],
["catalog", "Browse and install blocks and components"],
["preview", "Start the studio for previewing compositions"],
["render", "Render a composition to MP4 or WebM"],
@@ -30,6 +31,7 @@ const GROUPS: Group[] = [
title: "Project",
commands: [
["lint", "Validate a composition for common mistakes"],
["snapshot", "Capture key frames as PNG screenshots for visual verification"],
["info", "Print project metadata"],
["compositions", "List all compositions in a project"],
["docs", "View inline documentation in the terminal"],
+2 -1
View File
@@ -128,7 +128,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
);
return html;
} catch {
} catch (err) {
console.error("[studio] Bundle failed:", err);
return null;
}
},