Files
heygen-com--hyperframes/packages/cli/src/capture/index.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

682 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { extractDesignStyles } from "./designStyleExtractor.js";
import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js";
import { extractFontMetadata } from "./fontMetadataExtractor.js";
import { normalizeErrorMessage } from "../utils/errorMessage.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";
// fallow-ignore-next-line complexity
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[] = [];
// Layer 1 (passive video discovery): every direct-video URL the page fetches
// over the whole session (load / scroll / carousel rotation), independent of
// whether a <video> for it exists at snapshot time. captureVideoManifest
// downloads these (guarded) and merges them into the manifest.
const discoveredVideoUrls = new Set<string>();
// fallow-ignore-next-line complexity
page1.on("response", async (response) => {
try {
const responseUrl = response.url();
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
discoveredVideoUrls.add(responseUrl);
}
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);
// Save tokens.json without SVG outerHTML (kept in memory for asset downloader)
const tokensForDisk = {
...tokens,
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
};
writeFileSync(
join(outputDir, "extracted", "tokens.json"),
JSON.stringify(tokensForDisk, null, 2),
"utf-8",
);
// Extract computed design styles (typography, buttons, cards, spacing, shadows)
progress("style", "Extracting design styles...");
try {
const designStyles = await extractDesignStyles(page1);
writeFileSync(
join(outputDir, "extracted", "design-styles.json"),
JSON.stringify(designStyles, null, 2),
"utf-8",
);
progress(
"tokens",
`${designStyles.typography.length} typography roles, ${designStyles.buttons.length} button styles, ${designStyles.shadows.length} shadow values extracted`,
);
} catch (err) {
const errMsg =
err instanceof Error ? `${err.message}\n${err.stack}` : normalizeErrorMessage(err);
console.error(` ⚠ Design style extraction failed: ${errMsg}`);
warnings.push(`Design style extraction failed: ${errMsg}`);
}
// 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`);
if (catalogedAssets.length === 0) {
warnings.push(
"Asset catalog is empty — no images will be downloaded. The page may use non-standard image loading.",
);
}
} catch (err) {
warnings.push(`Asset cataloging failed (no images will be downloaded): ${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,
// fallow-ignore-next-line complexity
(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, {
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
sampleMs: 12000, // Layer 2: poll DOM ≤12s so auto-rotating carousels reveal each slide
});
} 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);
// Extract favicon links before closing page (removed from tokens to reduce noise)
const faviconLinks = (await page1.evaluate(`(() => {
var iconEls = Array.from(document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]'));
return iconEls.map(function(l) { return { rel: l.rel, href: l.href }; });
})()`)) as Array<{ rel: string; href: string }>;
await page1.close();
// Download fonts and rewrite URLs to local paths
extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir);
// Identify each downloaded font by reading its OpenType name table.
// Modern frameworks hash font filenames; this manifest tells the
// downstream pipeline (DESIGN.md authoring, beat sub-agents) which file
// belongs to which family without guessing from filename patterns.
try {
const fontsManifest = extractFontMetadata(
join(outputDir, "assets", "fonts"),
join(outputDir, "extracted", "fonts-manifest.json"),
);
if (fontsManifest.families.length > 0) {
const summary = fontsManifest.families
.map((f) => `${f.family}${f.variable ? " (variable)" : ""} × ${f.fileCount}`)
.join(", ");
console.log(`Font metadata extracted: ${summary}`);
if (fontsManifest.unidentified.length > 0) {
console.warn(
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
);
}
}
} catch (err) {
console.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
}
// 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, faviconLinks);
}
// Join in-section media URLs → downloaded local paths, then re-write
// tokens.json. Downstream page recreation MUST reference local files:
// remote URLs fail at render time (hotlink/CORS 403, no egress in
// Docker/Lambda, frame-timing blanks for not-yet-loaded images).
if (assets.length && Array.isArray(tokens.sections)) {
const base = (u: string): string => u.split(/[#?]/)[0] ?? u;
const localByUrl = new Map<string, string>();
for (const a of assets) {
if (!a.url || !a.localPath) continue;
localByUrl.set(a.url, a.localPath);
localByUrl.set(base(a.url), a.localPath);
}
for (const sec of tokens.sections) {
const local: string[] = [];
for (const u of sec.assetUrls || []) {
const hit = localByUrl.get(u) || localByUrl.get(base(u));
if (hit && !local.includes(hit)) local.push(hit);
}
if (local.length) sec.assets = local;
}
const tokensForDisk2 = {
...tokens,
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
};
writeFileSync(
join(outputDir, "extracted", "tokens.json"),
JSON.stringify(tokensForDisk2, null, 2),
"utf-8",
);
}
// Persist a self-contained page recreation (extracted/page.html) as the
// high-fidelity structural reference for the page-card rebuild. NOT a
// composition — kept under extracted/ so the producer (which discovers
// compositions by index.html) never picks it up. Images are already inlined
// as data URLs by extractHtml, so it renders standalone.
try {
const pageHtml = `<!doctype html>\n<html ${extracted.htmlAttrs || ""}>\n<head>\n${extracted.headHtml}\n</head>\n<body>\n${extracted.bodyHtml}\n</body>\n</html>\n`;
writeFileSync(join(outputDir, "extracted", "page.html"), pageHtml, "utf-8");
} catch (err) {
warnings.push(`page.html write failed: ${err}`);
}
// Save visible text content for AI agent to use
if (visibleTextContent) {
writeFileSync(join(outputDir, "extracted", "visible-text.txt"), visibleTextContent, "utf-8");
}
// detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing
// 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) {
const hasGeminiKey = !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
const header = hasGeminiKey
? "# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file — that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim — many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n"
: "# Asset Descriptions\n\n⚠️ GEMINI_API_KEY not set — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY) and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n";
writeFileSync(
join(outputDir, "extracted", "asset-descriptions.md"),
header + lines.map((l) => "- " + l).join("\n") + "\n",
"utf-8",
);
progress(
"design",
`${lines.length} asset descriptions written${hasGeminiKey ? "" : " (no Gemini key — catalog-fallback mode)"}`,
);
}
} catch {
/* non-critical */
}
progress("design", "DESIGN.md will be created by your AI agent");
// Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually)
// All functions return string[] — paginated so every image is covered
try {
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
await import("./contactSheet.js");
const scrollSheets = await createScrollContactSheet(
join(outputDir, "screenshots"),
join(outputDir, "screenshots", "contact-sheet.jpg"),
);
if (scrollSheets.length > 0)
progress(
"design",
`Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
);
const assetsImgDir = join(outputDir, "assets");
if (existsSync(assetsImgDir)) {
const assetSheets = await createAssetContactSheet(
assetsImgDir,
join(outputDir, "assets", "contact-sheet.jpg"),
);
if (assetSheets.length > 0)
progress(
"design",
`Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`,
);
}
// Scan assets/svgs/ (inline SVGs) AND assets/ root (external SVGs from <img src="*.svg">)
// so sites like huly.io that only use external SVGs still get a grid
const svgsDir = join(outputDir, "assets", "svgs");
const assetsRootDir = join(outputDir, "assets");
const svgOutputPath = existsSync(svgsDir)
? join(outputDir, "assets", "svgs", "contact-sheet.jpg")
: join(outputDir, "assets", "contact-sheet-svgs.jpg");
const svgSheets = await createSvgContactSheet(svgsDir, svgOutputPath, assetsRootDir);
if (svgSheets.length > 0)
progress(
"design",
`SVG contact sheet generated (${svgSheets.length} page${svgSheets.length > 1 ? "s" : ""})`,
);
} catch {
/* contact sheets are non-critical — agent can still read images individually */
}
// 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,
detectedLibraries,
);
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