mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
co-authored by
Claude Opus 4.6
parent
ebc12f7dc9
commit
87f4c77e2f
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 &)
|
||||
// .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 & entities in srcset
|
||||
srcset = srcset.replace(/&/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, """) + '"');
|
||||
}
|
||||
}
|
||||
|
||||
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 & 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(/&/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(/&/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(/&/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(/&/g, "&");
|
||||
if (fixed.startsWith("/") && !fixed.startsWith("//")) fixed = pageOrigin + fixed;
|
||||
return pre + fixed + post;
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user