fix(cli): bound capture runs and degrade WebGL navigation (#4215)

* fix(cli): bound capture runs and degrade WebGL navigation

* fix(cli): keep partial capture on deadline

* fix(cli): persist partial capture artifacts

* fix(cli): reduce capture phase complexity

* chore(cli): tighten capture comments

* fix(cli): restore post-capture phase calls

* fix(cli): parse extracted scripts before filtering

* fix(cli): remove next data through dom filter

* refactor(cli): move capture attempt into module

* refactor(cli): extract capture phases into modules

* refactor(cli): split navigation checks

* refactor(cli): deduplicate capture phase modules

* fix(cli): preserve bounded capture output on deadline

* fix(cli): secure capture artifact writes

* fix(cli): close remaining capture file alerts

* fix(cli): mark deadline captures unsuccessful

* refactor(cli): centralize capture phase schema

* style(cli): format capture watchdog test

* docs(cli): condense capture attempt header
This commit is contained in:
Miguel Ángel
2026-09-21 02:52:54 -04:00
committed by GitHub
parent c797f54542
commit 952e9228b5
27 changed files with 1884 additions and 938 deletions
+2
View File
@@ -419,6 +419,8 @@
// as a direct dep to guarantee installation even when transitive
// resolution fails (corrupted cache, dedup edge cases).
"debug",
// Loaded through the optional package loader for Gemini vision captioning.
"@google/genai",
"puppeteer",
"puppeteer-core",
"esbuild",
@@ -10,7 +10,8 @@
* product-launch-video skill — this file points agents there.
*/
import { writeFileSync, readdirSync, existsSync } from "node:fs";
import { readdirSync, existsSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
import type { DesignTokens } from "./types.js";
import type { AnimationCatalog } from "./animationCataloger.js";
@@ -52,9 +53,9 @@ export function generateAgentPrompt(
_detectedLibraries?: string[],
): void {
const prompt = buildPrompt(outputDir, url, tokens, hasScreenshot, hasLottie, hasShaders);
writeFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
writeFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
writeFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
writeCaptureFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
writeCaptureFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
writeCaptureFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
}
// fallow-ignore-next-line complexity
+8 -7
View File
@@ -6,7 +6,8 @@
*/
import { isBlockedNetworkHost } from "@hyperframes/engine";
import { writeFileSync, mkdirSync } from "node:fs";
import { mkdirSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join, extname } from "node:path";
import { createHash } from "node:crypto";
import type { DesignTokens, DownloadedAsset } from "./types.js";
@@ -206,7 +207,7 @@ async function fetchAndInspectIcon(
if (!ext) return null;
const file = `assets/${stem}${ext}`;
writeFileSync(join(outputDir, file), buffer);
writeCaptureFileSync(join(outputDir, file), buffer);
const verdict = await classifyIcon(buffer, ext);
return {
buffer,
@@ -235,7 +236,7 @@ function promoteHeadline(
if (!chosen) return null;
const file = `assets/favicon${extname(chosen.file)}`;
writeFileSync(join(outputDir, file), bytesByFile.get(chosen.file)!);
writeCaptureFileSync(join(outputDir, file), bytesByFile.get(chosen.file)!);
manifest.headline = {
file,
source: chosen.file,
@@ -346,7 +347,7 @@ export async function downloadAssets(
const name = `${finalSlug}.svg`;
const localPath = `assets/svgs/${name}`;
try {
writeFileSync(join(outputDir, localPath), svgFile, "utf-8");
writeCaptureFileSync(join(outputDir, localPath), svgFile, "utf-8");
assets.push({ url: "", localPath, type: "svg" });
} catch {
drops.unavailable++;
@@ -472,7 +473,7 @@ export async function downloadAssets(
const name = `${slug}${ext}`;
usedNames.add(slug);
const localPath = `assets/${name}`;
writeFileSync(join(outputDir, localPath), buffer);
writeCaptureFileSync(join(outputDir, localPath), buffer);
assets.push({ url, localPath, type: "image" });
imgIdx++;
} catch {
@@ -501,7 +502,7 @@ export async function downloadAssets(
drops["size-floor"]++;
} else {
const localPath = `assets/og-image${ext}`;
writeFileSync(join(outputDir, localPath), buffer);
writeCaptureFileSync(join(outputDir, localPath), buffer);
assets.push({ url: tokens.ogImage, localPath, type: "image" });
}
}
@@ -614,7 +615,7 @@ export async function downloadAndRewriteFonts(
const filename = captureFontFilename(fontUrl, extension, usedFontNames);
const localPath = join(assetsDir, filename);
const relativePath = `assets/fonts/${filename}`;
writeFileSync(localPath, buffer);
writeCaptureFileSync(localPath, buffer);
rewritten = rewritten.split(fontUrl).join(relativePath);
} else {
drops.unavailable++;
@@ -0,0 +1,17 @@
export function captureBrowserArgs(
disableWebgl: boolean,
viewportWidth: number,
viewportHeight: number,
): string[] {
return [
"--no-sandbox",
"--disable-dev-shm-usage",
...(disableWebgl
? ["--disable-gpu"]
: ["--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}`,
];
}
+333
View File
@@ -0,0 +1,333 @@
import { LottieDiscovery } from "./lottieDiscovery.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
/**
* Two-pass capture: full load catalogs animations, then scripts are blocked to extract stable HTML.
* - Rich animation metadata for Claude Code to recreate
*/
import { mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { noDrops } from "./assetDownloader.js";
import type { IconCandidate } from "./faviconRanker.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
import { setupAnimationCapture, startCdpAnimationCapture } from "./animationCataloger.js";
import type { DiscoveredLottie } from "./mediaCapture.js";
import { detectLibraries } from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
import { captureProtocolTimeoutMs } from "./captureTimeout.js";
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
import type {
CaptureOptions,
CapturePhase,
CapturePhaseProgress,
CaptureResult,
DesignTokens,
ExtractedHtml,
} from "./types.js";
import type { CaptureWatchdog } from "./captureWatchdog.js";
import { captureBrowserArgs } from "./browserLaunchArgs.js";
import type { PartialCaptureState } from "./partialCapture.js";
import { runNavigationChecks } from "./navigationPhase.js";
import { runCoreExtraction } from "./coreExtractionPhase.js";
import { runPostExtraction } from "./postExtractionPhase.js";
const DEFAULT_POST_NAVIGATION_BUDGET_MS = 120_000;
export async function captureWebsiteAttempt(
opts: CaptureOptions,
onProgress: ((stage: string, detail?: string) => void) | undefined,
disableWebgl: boolean,
watchdog: CaptureWatchdog,
state: PartialCaptureState,
): Promise<CaptureResult> {
const {
url,
outputDir,
viewportWidth = 1920,
viewportHeight = 1080,
timeout = 120000,
settleTime = 3000,
maxScreenshots: _maxScreenshots = 24,
skipAssets = false,
skipVision = false,
postNavigationBudgetMs = DEFAULT_POST_NAVIGATION_BUDGET_MS,
onPhase,
} = opts;
const downloadByteBudget = createCaptureDownloadBudget();
const warnings: string[] = [...state.warnings];
state.warnings = warnings;
const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail);
};
const budgetMs =
Number.isFinite(postNavigationBudgetMs) && postNavigationBudgetMs > 0
? postNavigationBudgetMs
: DEFAULT_POST_NAVIGATION_BUDGET_MS;
let postNavigationDeadline: number | undefined;
const remainingMs = (): number =>
postNavigationDeadline === undefined
? budgetMs
: Math.max(0, postNavigationDeadline - Date.now());
const canWrite = (): boolean => !watchdog.expired();
let lastPhase: CapturePhaseProgress = {
schema: CAPTURE_PHASE_SCHEMA,
phase: "browser",
status: "started",
remainingMs: null,
};
const phase = (
name: CapturePhase,
status: CapturePhaseProgress["status"],
reason?: CapturePhaseProgress["reason"],
): void => {
if (watchdog.expired()) return;
const remaining = postNavigationDeadline === undefined ? null : remainingMs();
lastPhase = reason
? {
schema: CAPTURE_PHASE_SCHEMA,
phase: name,
status,
remainingMs: remaining,
reason,
}
: { schema: CAPTURE_PHASE_SCHEMA, phase: name, status, remainingMs: remaining };
onPhase?.(lastPhase);
};
phase("browser", "started");
// 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,
protocolTimeout: captureProtocolTimeoutMs(timeout, budgetMs),
args: captureBrowserArgs(disableWebgl, viewportWidth, viewportHeight),
});
watchdog.registerBrowser(chromeBrowser);
let animationCatalog: CaptureResult["animationCatalog"];
let tokens: DesignTokens = state.tokens;
let extracted: ExtractedHtml = state.extracted;
let screenshots: string[] = [];
let catalogedAssets: import("./assetCataloger.js").CatalogedAsset[] = [];
let capturedShaders: Array<{ type: string; source: string }> | undefined;
let visibleTextContent = "";
let faviconLinks: IconCandidate[] = [];
let detectedLibraries: Awaited<ReturnType<typeof detectLibraries>> = [];
let assets: CaptureResult["assets"] = [];
let dropped = noDrops();
let fontDrops = noDrops();
let httpStatus: number | null = null;
let pageContentCheck = {
textLength: 0,
title: "",
hasChallengeElement: false,
bodyChildCount: Number.POSITIVE_INFINITY,
};
let contentCheckTimedOut = false;
try {
// ═══════════════════════════════════════════════════════════════
// PASS 1: Full page load — all JS runs
// Goal: Catalog animations + take screenshots (with JS rendering)
// ═══════════════════════════════════════════════════════════════
phase("browser", "completed");
phase("navigation", "started");
progress("animations", "Cataloging animations (full JS)...");
const page1 = await chromeBrowser.newPage();
await page1.setViewport({ width: viewportWidth, height: viewportHeight });
await page1.setUserAgent(CAPTURE_USER_AGENT);
// 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')) {
window.__hfWebglSeen = true;
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[] = [];
const lottieDiscovery = new LottieDiscovery();
// 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>();
page1.on("response", (response) => {
try {
const responseUrl = response.url();
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
discoveredVideoUrls.add(responseUrl);
}
lottieDiscovery.collect(response);
} catch {
/* not JSON or parse error — skip */
}
});
const navigationResult = await runNavigationChecks({
page1,
url,
timeout,
settleTime,
budgetMs,
warnings,
progress,
remainingMs,
pageContentCheck,
contentCheckTimedOut,
postNavigationDeadline,
outputDir,
phase,
httpStatus,
canWrite,
});
({ pageContentCheck, contentCheckTimedOut, httpStatus, postNavigationDeadline } =
navigationResult);
const coreResult = await runCoreExtraction({
page1,
chromeBrowser,
cdp,
cdpAnims,
state,
outputDir,
warnings,
progress,
remainingMs,
pageContentCheck,
contentCheckTimedOut,
discoveredLotties,
lottieDiscovery,
discoveredVideoUrls,
animationCatalog,
capturedShaders,
catalogedAssets,
detectedLibraries,
visibleTextContent,
faviconLinks,
tokens,
extracted,
screenshots,
downloadByteBudget,
canWrite,
});
({
animationCatalog,
capturedShaders,
catalogedAssets,
detectedLibraries,
visibleTextContent,
faviconLinks,
tokens,
extracted,
screenshots,
} = coreResult);
phase("core-extraction", "completed");
const postResult = await runPostExtraction({
state,
outputDir,
warnings,
progress,
remainingMs,
phase,
animationCatalog,
catalogedAssets,
visibleTextContent,
faviconLinks,
tokens,
extracted,
skipAssets,
skipVision,
downloadByteBudget,
assets,
dropped,
fontDrops,
canWrite,
});
({ assets, dropped, fontDrops, extracted, tokens, animationCatalog } = postResult);
// Generate project scaffold (index.html, meta.json, CLAUDE.md)
phase("scaffold", "started");
if (!watchdog.expired()) {
await generateProjectScaffold(
outputDir,
url,
tokens,
animationCatalog,
screenshots.length > 0,
discoveredLotties.length > 0,
existsSync(join(outputDir, "extracted", "shaders.json")),
catalogedAssets,
progress,
warnings,
detectedLibraries,
);
}
phase("scaffold", "completed");
progress("done", "Capture complete");
phase("complete", "completed");
return {
ok: true,
projectDir: outputDir,
url,
httpStatus,
title: tokens.title,
extracted,
screenshots,
tokens,
assets,
dropped,
animationCatalog,
warnings,
lastPhase,
};
} finally {
watchdog.unregisterBrowser(chromeBrowser);
await chromeBrowser.close();
}
}
@@ -0,0 +1,9 @@
export class NavigationDeadlineError extends Error {
constructor(
readonly cause: unknown,
readonly webglObserved: boolean,
) {
super("capture navigation timed out");
this.name = "NavigationDeadlineError";
}
}
+10
View File
@@ -0,0 +1,10 @@
import { writeFileSync } from "node:fs";
export function writeCaptureFileSync(
path: string,
data: string | NodeJS.ArrayBufferView,
options?: Parameters<typeof writeFileSync>[2],
): void {
const normalized = typeof options === "string" ? { encoding: options } : options;
writeFileSync(path, data, { ...normalized, mode: 0o600 });
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCaptureWatchdog, parseCaptureDeadline, runWithWatchdog } from "./captureWatchdog.js";
describe("parseCaptureDeadline", () => {
it.each([
[undefined, undefined],
["", undefined],
["0", undefined],
["1.5", undefined],
["240000", 240000],
])("parses %s as %s", (raw, expected) => expect(parseCaptureDeadline(raw)).toBe(expected));
});
describe("createCaptureWatchdog", () => {
afterEach(() => vi.useRealTimers());
it("closes the registered browser and resolves the deadline signal", async () => {
vi.useFakeTimers();
const close = vi.fn().mockResolvedValue(undefined);
const watchdog = createCaptureWatchdog(1000);
watchdog.registerBrowser({ close });
await vi.advanceTimersByTimeAsync(1000);
await expect(watchdog.promise).resolves.toBeUndefined();
expect(close).toHaveBeenCalledOnce();
expect(watchdog.expired()).toBe(true);
watchdog.dispose();
});
it("observes a losing attempt rejection instead of leaving it unhandled", async () => {
vi.useFakeTimers();
const watchdog = createCaptureWatchdog(1);
const work = new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(new Error("browser closed")), 10);
});
const resultPromise = runWithWatchdog(work, watchdog.promise);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toEqual({ kind: "deadline" });
await vi.advanceTimersByTimeAsync(9);
watchdog.dispose();
});
});
@@ -0,0 +1,71 @@
export interface CaptureWatchdogBrowser {
close(): Promise<void>;
}
export interface CaptureWatchdog {
readonly expired: () => boolean;
readonly promise: Promise<void>;
readonly registerBrowser: (browser: CaptureWatchdogBrowser) => void;
readonly unregisterBrowser: (browser: CaptureWatchdogBrowser) => void;
readonly dispose: () => void;
}
export type CaptureAttempt<T> =
| { kind: "complete"; result: T }
| { kind: "error"; error: unknown }
| { kind: "deadline" };
export async function runWithWatchdog<T>(
work: Promise<T>,
deadline: Promise<void>,
): Promise<CaptureAttempt<T>> {
const observedWork = work.then(
(result): CaptureAttempt<T> => ({ kind: "complete", result }),
(error): CaptureAttempt<T> => ({ kind: "error", error }),
);
return await Promise.race([
observedWork,
deadline.then((): CaptureAttempt<T> => ({ kind: "deadline" })),
]);
}
export function createCaptureWatchdog(deadlineMs: number | undefined): CaptureWatchdog {
let browser: CaptureWatchdogBrowser | undefined;
let didExpire = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let resolveDeadline: () => void = () => {};
const promise = new Promise<void>((resolve) => {
resolveDeadline = resolve;
});
if (deadlineMs !== undefined) {
timer = setTimeout(() => {
didExpire = true;
resolveDeadline();
void browser?.close().catch(() => undefined);
}, deadlineMs);
}
return {
expired: () => didExpire,
promise,
registerBrowser: (nextBrowser) => {
browser = nextBrowser;
if (didExpire) void browser.close().catch(() => undefined);
},
unregisterBrowser: (closedBrowser) => {
if (browser === closedBrowser) browser = undefined;
},
dispose: () => {
if (timer !== undefined) clearTimeout(timer);
},
};
}
export function parseCaptureDeadline(raw: string | undefined): number | undefined {
if (raw === undefined || raw === "") return undefined;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
return parsed;
}
+3 -2
View File
@@ -6,7 +6,8 @@
*/
import sharp, { type OverlayOptions } from "sharp";
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
import { readdirSync, readFileSync, unlinkSync, existsSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join, extname, basename, dirname } from "node:path";
interface ContactSheetOptions {
@@ -326,7 +327,7 @@ export async function createSvgContactSheet(
.flatten({ background: { r: 245, g: 245, b: 245 } })
.png()
.toBuffer();
writeFileSync(tmpPath, thumb);
writeCaptureFileSync(tmpPath, thumb);
tmpPaths.push(tmpPath);
labels.push(svgFileNames[i]!.replace(".svg", ""));
} catch {
+1 -1
View File
@@ -463,7 +463,7 @@ export async function captionImagesWithGemini(
const results = await Promise.allSettled(
batch.map(async (file: string) => {
const filePath = join(outputDir, "assets", file);
const fd = openSync(filePath, "r");
const fd = openSync(filePath, "r", 0o600);
let buffer: Buffer;
try {
const stat = fstatSync(fd);
@@ -0,0 +1,392 @@
import type { Browser, Page } from "puppeteer-core";
import type { LottieDiscovery } from "./lottieDiscovery.js";
import type { DiscoveredLottie } from "./mediaCapture.js";
import type { CatalogedAsset } from "./assetCataloger.js";
import { serializeTokensForCapture } from "./partialCapture.js";
import type { PartialCaptureState } from "./partialCapture.js";
import type { CaptureResult, DesignTokens, ExtractedHtml } from "./types.js";
import type { IconCandidate } from "./faviconRanker.js";
import { startCdpAnimationCapture } from "./animationCataloger.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
import { detectLibraries } from "./contentExtractor.js";
import { mkdirSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
import { extractHtml } from "./htmlExtractor.js";
import { extractTokens } from "./tokenExtractor.js";
import { extractDesignStyles } from "./designStyleExtractor.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { collectAnimationCatalog } from "./animationCataloger.js";
import {
saveLottieAnimations,
renderLottiePreviews,
captureVideoManifest,
} from "./mediaCapture.js";
import { extractVisibleText } from "./contentExtractor.js";
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
import { filterExtractedScripts } from "./filterExtractedScripts.js";
export interface CoreExtractionInput {
page1: Page;
chromeBrowser: Browser;
cdp: Awaited<ReturnType<typeof startCdpAnimationCapture>>["cdp"];
cdpAnims: Awaited<ReturnType<typeof startCdpAnimationCapture>>["animations"];
state: PartialCaptureState;
outputDir: string;
warnings: string[];
progress: (stage: string, detail?: string) => void;
remainingMs: () => number;
pageContentCheck: {
textLength: number;
title: string;
hasChallengeElement: boolean;
bodyChildCount: number;
};
contentCheckTimedOut: boolean;
discoveredLotties: DiscoveredLottie[];
lottieDiscovery: LottieDiscovery;
discoveredVideoUrls: Set<string>;
animationCatalog: CaptureResult["animationCatalog"];
capturedShaders: Array<{ type: string; source: string }> | undefined;
catalogedAssets: CatalogedAsset[];
detectedLibraries: Awaited<ReturnType<typeof detectLibraries>>;
visibleTextContent: string;
faviconLinks: IconCandidate[];
tokens: DesignTokens;
extracted: ExtractedHtml;
screenshots: string[];
downloadByteBudget: ReturnType<typeof createCaptureDownloadBudget>;
canWrite: () => boolean;
}
export interface CoreExtractionResult {
animationCatalog: CaptureResult["animationCatalog"];
capturedShaders: Array<{ type: string; source: string }> | undefined;
catalogedAssets: CatalogedAsset[];
detectedLibraries: Awaited<ReturnType<typeof detectLibraries>>;
visibleTextContent: string;
faviconLinks: IconCandidate[];
tokens: DesignTokens;
extracted: ExtractedHtml;
screenshots: string[];
}
export async function runCoreExtraction(input: CoreExtractionInput): Promise<CoreExtractionResult> {
let {
page1,
chromeBrowser,
cdp,
cdpAnims,
state,
outputDir,
warnings,
progress,
remainingMs,
pageContentCheck,
contentCheckTimedOut,
discoveredLotties,
lottieDiscovery,
discoveredVideoUrls,
animationCatalog,
capturedShaders,
catalogedAssets,
detectedLibraries,
visibleTextContent,
faviconLinks,
tokens,
extracted,
screenshots,
downloadByteBudget,
canWrite,
} = input;
const runLazyAndLottie = async (): Promise<void> => {
if (!contentCheckTimedOut && pageContentCheck.textLength < 100) {
const reason =
"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);
}
const lazyLoadBudgetMs = Math.min(15_000, remainingMs());
const lazyScroll = await lazyScrollForCapture(page1, lazyLoadBudgetMs, {
onWarning: (message) => {
warnings.push(message);
progress("warn", message);
},
});
if (lazyScroll.timedOut && !lazyScroll.degraded) {
const message = `lazy-scroll stopped after ${lazyScroll.steps} steps (budget ${lazyLoadBudgetMs}ms)`;
warnings.push(message);
progress("warn", message);
}
await new Promise((r) => setTimeout(r, 300));
};
await runLazyAndLottie();
const runAnimationDiscovery = async (): Promise<void> => {
const scanDomLotties = async (): Promise<void> => {
// 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: { url: string }) => l.url === lottieUrl)
) {
discoveredLotties.push({ url: lottieUrl });
}
}
}
} catch {
/* DOM scan failed — non-critical */
}
};
await scanDomLotties();
const collectNetworkLotties = async (): Promise<void> => {
for (const found of await lottieDiscovery.run(downloadByteBudget, remainingMs)) {
const existing = discoveredLotties.findIndex(
(item: { url: string }) => item.url === found.url,
);
if (existing < 0) discoveredLotties.push(found);
else discoveredLotties[existing] = found;
}
};
await collectNetworkLotties();
const saveDiscoveredLotties = async (): Promise<void> => {
if (discoveredLotties.length > 0 && remainingMs() > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
if (!canWrite()) return;
mkdirSync(lottieDir, { recursive: true });
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0 && remainingMs() > 0 && canWrite()) {
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
progress("lottie", `${savedCount} Lottie animation(s) saved`);
}
}
};
await saveDiscoveredLotties();
};
await runAnimationDiscovery();
// Save captured WebGL shaders (useful context for shader transitions + library detection)
const runShaderCapture = async (): Promise<void> => {
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;
if (canWrite()) {
writeCaptureFileSync(
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 */
}
};
await runShaderCapture();
const runTokenExtraction = async (): Promise<void> => {
// Extract DOM data before extractHtml mutates image URLs and removes scripts.
// Extract design tokens
progress("tokens", "Extracting design tokens...");
tokens = await extractTokens(page1);
state.tokens = tokens;
// Save tokens.json without SVG outerHTML (kept in memory for asset downloader)
if (canWrite()) {
writeCaptureFileSync(
join(outputDir, "extracted", "tokens.json"),
serializeTokensForCapture(tokens),
"utf-8",
);
}
// Extract computed design styles (typography, buttons, cards, spacing, shadows)
progress("style", "Extracting design styles...");
try {
const designStyles = await extractDesignStyles(page1);
state.designStyles = designStyles;
if (canWrite()) {
writeCaptureFileSync(
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}`);
}
};
await runTokenExtraction();
const runAnimationCapture = async (): Promise<void> => {
progress("animations", "Cataloging animations...");
try {
const animationOutcome = await collectAnimationCatalog(page1, cdpAnims, cdp, {
scrollBudgetMs: Math.min(8_000, remainingMs()),
evaluateBudgetMs: Math.min(15_000, remainingMs()),
});
animationCatalog = animationOutcome.catalog;
if (animationOutcome.timedOut) {
const message =
"animation catalog evaluate timed out; continuing without animation catalog";
warnings.push(message);
progress("warn", message);
}
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
const message = "animation catalog evaluate timed out; continuing without animation catalog";
warnings.push(message);
progress("warn", message);
try {
await cdp.send("Animation.disable");
} catch {
/* ignore */
}
}
progress("screenshots", "Capturing scroll screenshots...");
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
try {
if (!canWrite()) return;
screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs });
state.screenshots = screenshots;
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
const message = "scroll screenshots timed out; continuing without screenshots";
warnings.push(message);
progress("warn", message);
}
};
await runAnimationCapture();
const runHtmlExtraction = async (): Promise<void> => {
// Catalog all assets (must run before extractHtml which converts img src to data URLs)
progress("design", "Cataloging assets...");
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...");
extracted = await extractHtml(page1, { settleTime: 1000 });
state.extracted = extracted;
// 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 React hydration markers
.replace(/\s*data-reactroot="[^"]*"/g, "")
.replace(/\s*data-reactroot/g, "");
const filteredScripts = filterExtractedScripts(extracted.bodyHtml, extracted.headHtml);
extracted.bodyHtml = filteredScripts.bodyHtml;
extracted.headHtml = filteredScripts.headHtml;
// 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 {
const videoBudgetMs = remainingMs();
if (videoBudgetMs > 0 && canWrite()) {
await captureVideoManifest(page1, outputDir, progress, {
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget
downloadBudgetMs: videoBudgetMs,
remainingMs,
});
}
} catch {
/* non-blocking — video manifest is best-effort */
}
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
detectedLibraries = await detectLibraries(page1, capturedShaders);
// Extract all visible text in DOM order
visibleTextContent = await extractVisibleText(page1);
// Extract favicon links before closing page (removed from tokens to reduce noise)
// `sizes` and `type` are the only evidence of icon quality: page.html on disk does not
// keep the <link> tags, and the bytes are only fetched for the candidate that wins, so
// dropping these attributes here makes the choice unrecoverable downstream.
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,
sizes: l.getAttribute('sizes'),
type: l.getAttribute('type'),
};
});
})()`)) as IconCandidate[];
await page1.close();
};
await runHtmlExtraction();
return {
animationCatalog,
capturedShaders,
catalogedAssets,
detectedLibraries,
visibleTextContent,
faviconLinks,
tokens,
extracted,
screenshots,
};
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { filterExtractedScripts } from "./filterExtractedScripts.js";
describe("filterExtractedScripts", () => {
it("removes framework scripts while preserving surrounding markup", () => {
const result = filterExtractedScripts(
"<main><h1>Welcome</h1><script>self.__next_f.push([])</script></main>",
'<style>body{color:red}</style><script src="/_next/static/chunks/main.js"></script>',
);
expect(result.bodyHtml).toContain("<main><h1>Welcome</h1></main>");
expect(result.headHtml).toContain("<style>body{color:red}</style>");
expect(result.bodyHtml).not.toContain("__next_f");
expect(result.headHtml).not.toContain("/_next/static/chunks/main.js");
});
});
@@ -0,0 +1,39 @@
import { parseHTML } from "linkedom";
const FRAMEWORK_SRC_PATTERNS = [
/_next\/static\/chunks\/(main|framework|webpack|pages\/)/,
/_next\/static\/chunks\/app\//,
/_buildManifest\.js/,
/_ssgManifest\.js/,
];
const NEXT_BOOTSTRAP_MARKERS = [
"__next_f",
"self.__next_f",
"__NEXT_LOADED_PAGES__",
"_N_E",
"__NEXT_P",
];
function removeMatchingScripts(source: string, shouldRemove: (script: Element) => boolean): string {
const rootId = "__hf_root";
const { document } = parseHTML(`<div id="${rootId}">${source}</div>`);
for (const script of document.querySelectorAll("script")) {
if (script.id === "__NEXT_DATA__" || shouldRemove(script)) script.remove();
}
return document.getElementById(rootId)?.innerHTML ?? "";
}
export function filterExtractedScripts(
bodyHtml: string,
headHtml: string,
): { bodyHtml: string; headHtml: string } {
const body = removeMatchingScripts(bodyHtml, (script) =>
NEXT_BOOTSTRAP_MARKERS.some((marker) => script.textContent?.includes(marker)),
);
const head = removeMatchingScripts(headHtml, (script) => {
const src = script.getAttribute("src") ?? "";
return FRAMEWORK_SRC_PATTERNS.some((pattern) => pattern.test(src));
});
return { bodyHtml: body, headHtml: head };
}
@@ -17,7 +17,8 @@
* aggregation. See FontsManifest type for shape.
*/
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
import * as fontkit from "fontkit";
import type { Font, FontCollection } from "fontkit";
@@ -139,7 +140,7 @@ export function extractFontMetadata(fontsDir: string, outputPath: string): Fonts
},
};
writeFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
writeCaptureFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
return manifest;
}
+45 -908
View File
@@ -1,924 +1,61 @@
import { LottieDiscovery } from "./lottieDiscovery.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
/**
* 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,
mergeDrops,
noDrops,
totalDrops,
} from "./assetDownloader.js";
import type { IconCandidate } from "./faviconRanker.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
import { extractFontMetadata } from "./fontMetadataExtractor.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { diag } from "../ui/diagnostics.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,
resolveVisionPhaseCompletion,
} from "./contentExtractor.js";
import type { VisionCaptionOutcome } from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
import { detectBlockedPage } from "./pageBlockDetection.js";
import { writeResponseRecord } from "./responseRecord.js";
import { navigateForCapture } from "./navigateForCapture.js";
import {
captureProtocolTimeoutMs,
isDegradableEvaluateTimeoutError,
withRemainingBudget,
} from "./captureTimeout.js";
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js";
import { createCaptureWatchdog, runWithWatchdog } from "./captureWatchdog.js";
import { createPartialCaptureState, writePartialCaptureBundle } from "./partialCapture.js";
import type { PartialCaptureState } from "./partialCapture.js";
import { captureWebsiteAttempt } from "./captureAttempt.js";
import { NavigationDeadlineError } from "./captureErrors.js";
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
import type { CaptureOptions, CaptureResult } from "./types.js";
export type { CaptureOptions, CaptureResult } from "./types.js";
const DEFAULT_POST_NAVIGATION_BUDGET_MS = 120_000;
// 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,
skipVision = false,
postNavigationBudgetMs = DEFAULT_POST_NAVIGATION_BUDGET_MS,
onPhase,
} = opts;
const downloadByteBudget = createCaptureDownloadBudget();
const warnings: string[] = [];
const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail);
};
const budgetMs =
Number.isFinite(postNavigationBudgetMs) && postNavigationBudgetMs > 0
? postNavigationBudgetMs
: DEFAULT_POST_NAVIGATION_BUDGET_MS;
let postNavigationDeadline: number | undefined;
const remainingMs = (): number =>
postNavigationDeadline === undefined
? budgetMs
: Math.max(0, postNavigationDeadline - Date.now());
let lastPhase: CapturePhaseProgress = {
schema: "hyperframes.capture.phase.v1",
phase: "browser",
status: "started",
remainingMs: null,
};
const phase = (
name: CapturePhase,
status: CapturePhaseProgress["status"],
reason?: CapturePhaseProgress["reason"],
): void => {
const remaining = postNavigationDeadline === undefined ? null : remainingMs();
lastPhase = reason
? {
schema: "hyperframes.capture.phase.v1",
phase: name,
status,
remainingMs: remaining,
reason,
}
: { schema: "hyperframes.capture.phase.v1", phase: name, status, remainingMs: remaining };
onPhase?.(lastPhase);
};
phase("browser", "started");
// 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,
protocolTimeout: captureProtocolTimeoutMs(timeout, budgetMs),
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"];
const watchdog = createCaptureWatchdog(opts.captureDeadlineMs);
const state = createPartialCaptureState(opts);
const attempt = captureWebsiteAttempt(opts, onProgress, false, watchdog, state);
try {
// ═══════════════════════════════════════════════════════════════
// PASS 1: Full page load — all JS runs
// Goal: Catalog animations + take screenshots (with JS rendering)
// ═══════════════════════════════════════════════════════════════
phase("browser", "completed");
phase("navigation", "started");
progress("animations", "Cataloging animations (full JS)...");
const page1 = await chromeBrowser.newPage();
await page1.setViewport({ width: viewportWidth, height: viewportHeight });
await page1.setUserAgent(CAPTURE_USER_AGENT);
// 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[] = [];
const lottieDiscovery = new LottieDiscovery();
// 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", (response) => {
try {
const responseUrl = response.url();
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
discoveredVideoUrls.add(responseUrl);
}
lottieDiscovery.collect(response);
} catch {
/* not JSON or parse error — skip */
}
const first = await runWithWatchdog(attempt, watchdog.promise);
if (first.kind === "deadline") return deadlineResult(opts, state);
if (first.kind === "error") throw first.error;
return first.result;
} catch (err) {
if (!(err instanceof NavigationDeadlineError) || watchdog.expired() || !err.webglObserved) {
throw err;
}
onProgress?.("warn", "Navigation timed out; retrying once with WebGL disabled");
state.warnings.push("Navigation timed out; retrying once with WebGL disabled");
opts.onPhase?.({
schema: CAPTURE_PHASE_SCHEMA,
phase: "navigation",
status: "degraded",
remainingMs: null,
reason: "webgl-disabled-retry",
});
const navigation = await navigateForCapture(page1, url, timeout);
const navigationResponse = navigation.response;
if (navigation.fellBackFromNetworkIdle) {
warnings.push(
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continued with domcontentloaded`,
);
progress(
"warn",
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continuing with domcontentloaded`,
);
}
postNavigationDeadline = Date.now() + budgetMs;
await new Promise((r) => setTimeout(r, settleTime));
let pageContentCheck: {
textLength: number;
title: string;
hasChallengeElement: boolean;
bodyChildCount: number;
} = {
textLength: 0,
title: "",
hasChallengeElement: false,
bodyChildCount: Number.POSITIVE_INFINITY,
};
let contentCheckTimedOut = false;
try {
pageContentCheck = (await withRemainingBudget(
page1.evaluate(`(() => {
var text = (document.body && document.body.innerText || "").trim();
var title = document.title || "";
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
var bodyChildCount = document.body ? document.body.children.length : 0;
return { textLength: text.length, title: title, hasChallengeElement: hasCfTurnstile, bodyChildCount: bodyChildCount };
})()`),
Math.min(5_000, remainingMs()),
"content-check",
)) as typeof pageContentCheck;
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
contentCheckTimedOut = true;
const message =
"post-navigation content check timed out; continuing with HTTP-status blocked-page detection only";
warnings.push(message);
progress("warn", message);
}
// Persisted before the blocked-page check, so a capture that reaches navigation always leaves
// a record of what the server said. That makes the file's ABSENCE mean "capture never got a
// response", which is a third state distinct from a status of 404 and from a status of null.
const httpStatus = navigationResponse?.status() ?? null;
writeResponseRecord(join(outputDir, "extracted"), { status: httpStatus });
const blockedReason = detectBlockedPage({
httpStatus,
...(contentCheckTimedOut
? {
title: "",
textLength: 0,
bodyChildCount: 0,
hasChallengeElement: false,
}
: pageContentCheck),
});
if (blockedReason) {
phase("navigation", "degraded", "blocked");
throw new Error(blockedReason);
}
phase("navigation", "completed");
phase("core-extraction", "started");
if (!contentCheckTimedOut && pageContentCheck.textLength < 100) {
const reason =
"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);
}
const lazyLoadBudgetMs = Math.min(15_000, remainingMs());
const lazyScroll = await lazyScrollForCapture(page1, lazyLoadBudgetMs, {
onWarning: (message) => {
warnings.push(message);
progress("warn", message);
},
});
if (lazyScroll.timedOut && !lazyScroll.degraded) {
const message = `lazy-scroll stopped after ${lazyScroll.steps} steps (budget ${lazyLoadBudgetMs}ms)`;
warnings.push(message);
progress("warn", message);
}
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 */
}
for (const found of await lottieDiscovery.run(downloadByteBudget, remainingMs)) {
const existing = discoveredLotties.findIndex((item) => item.url === found.url);
if (existing < 0) discoveredLotties.push(found);
else discoveredLotties[existing] = found;
}
if (discoveredLotties.length > 0 && remainingMs() > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
mkdirSync(lottieDir, { recursive: true });
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0 && remainingMs() > 0) {
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
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}`);
}
progress("animations", "Cataloging animations...");
try {
const animationOutcome = await collectAnimationCatalog(page1, cdpAnims, cdp, {
scrollBudgetMs: Math.min(8_000, remainingMs()),
evaluateBudgetMs: Math.min(15_000, remainingMs()),
});
animationCatalog = animationOutcome.catalog;
if (animationOutcome.timedOut) {
const message =
"animation catalog evaluate timed out; continuing without animation catalog";
warnings.push(message);
progress("warn", message);
}
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
const message = "animation catalog evaluate timed out; continuing without animation catalog";
warnings.push(message);
progress("warn", message);
try {
await cdp.send("Animation.disable");
} catch {
/* ignore */
}
}
progress("screenshots", "Capturing scroll screenshots...");
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
let screenshots: string[] = [];
try {
screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs });
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) {
throw err;
}
const message = "scroll screenshots timed out; continuing without screenshots";
warnings.push(message);
progress("warn", message);
}
// 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 {
const videoBudgetMs = remainingMs();
if (videoBudgetMs > 0) {
await captureVideoManifest(page1, outputDir, progress, {
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget
downloadBudgetMs: videoBudgetMs,
remainingMs,
});
}
} 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)
// `sizes` and `type` are the only evidence of icon quality: page.html on disk does not
// keep the <link> tags, and the bytes are only fetched for the candidate that wins, so
// dropping these attributes here makes the choice unrecoverable downstream.
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,
sizes: l.getAttribute('sizes'),
type: l.getAttribute('type'),
};
});
})()`)) as IconCandidate[];
await page1.close();
phase("core-extraction", "completed");
// Download fonts and rewrite URLs to local paths.
//
// Called even with the budget already gone, which is the point: its own loop is the only
// thing that knows how many faces the page declared, so letting it run and record
// `budget-exhausted` for every one of them replaces a warning string that could only ever
// say "some". A zero budget means it breaks on the first url, so this costs no network.
phase("fonts", "started");
const fontPass = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
remainingMs,
byteBudget: downloadByteBudget,
});
extracted.headHtml = fontPass.css;
phase(
"fonts",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
// 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(", ");
// stderr (via diag): `capture --json` writes its envelope to stdout, so
// these progress/advisory lines must not land there.
diag.notice(`Font metadata extracted: ${summary}`);
if (fontsManifest.unidentified.length > 0) {
diag.warn(
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
);
}
}
} catch (err) {
diag.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"] = [];
let assetDrops = noDrops();
if (!skipAssets) {
// Called even with the budget already gone, for the reason the font pass is: the loop that
// skips an asset is the only thing that can say how many it skipped.
phase("assets", "started");
progress("assets", "Downloading assets...");
const assetPass = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks, {
remainingMs,
byteBudget: downloadByteBudget,
});
assets = assetPass.assets;
assetDrops = assetPass.drops;
// Which icons the site declared, what each one is, and why one became favicon.<ext>.
// The brand-kit consumer needs the shape to decide which tile an icon belongs in; the
// reason is what stops a substituted headline from being silent again.
writeFileSync(
join(outputDir, "extracted", "icons-manifest.json"),
JSON.stringify(assetPass.icons, null, 2),
"utf-8",
);
phase(
"assets",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
} else {
phase("assets", "degraded", "disabled");
}
// One capture-wide tally, summed from the two passes that own the drops. The warning is
// DERIVED from it rather than written alongside it, so the prose and the number cannot
// disagree the way two separately-authored budget strings could.
const dropped = mergeDrops(fontPass.drops, assetDrops);
const droppedTotal = totalDrops(dropped);
if (droppedTotal > 0) {
const breakdown = Object.entries(dropped)
.filter(([, n]) => n > 0)
.map(([reason, n]) => `${n} ${reason}`)
.join(", ");
warnings.push(
`${droppedTotal} referenced asset(s) are not in this capture (${breakdown}). ` +
"A thin capture with no drops is a thin page; this one was truncated.",
);
}
// 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)
let geminiCaptions: Record<string, string> = {};
if (skipVision) {
phase("vision", "degraded", "disabled");
} else if (remainingMs() <= 0) {
warnings.push(
"Capture budget exhausted before vision captioning; catalog descriptions were preserved.",
);
phase("vision", "degraded", "budget-exhausted");
} else {
phase("vision", "started");
let visionOutcome: VisionCaptionOutcome = {
timedOutRequests: 0,
failedRequests: 0,
budgetExhausted: false,
};
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
remainingMs,
onOutcome: (outcome) => {
visionOutcome = outcome;
},
});
const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs());
phase(
"vision",
completion.status,
completion.status === "degraded" ? completion.reason : undefined,
);
}
// Generate asset descriptions for the AI agent
progress("design", "Generating asset descriptions...");
try {
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
if (lines.length > 0) {
// Mirrors the provider gate in contentExtractor: Vertex needs a project AND a service
// account, and is the configuration a server deployment actually has. Without it here the
// header claimed "GEMINI_API_KEY not set — descriptions are catalog-derived" on a capture
// whose captions Vertex had just generated, and that header is read downstream.
const hasVisionKey = !!(
!skipVision &&
(process.env.OPENROUTER_API_KEY ||
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY ||
(process.env.HYPERFRAMES_VERTEX_PROJECT_ID &&
process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT))
);
const header = hasVisionKey
? "# 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⚠️ No vision credentials — 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), or HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, 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${hasVisionKey ? "" : " (no vision provider — 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
if (remainingMs() > 0) {
phase("contact-sheets", "started");
try {
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
await import("./contactSheet.js");
const contactSheetBudget = { remainingMs };
const scrollSheets = await createScrollContactSheet(
join(outputDir, "screenshots"),
join(outputDir, "screenshots", "contact-sheet.jpg"),
contactSheetBudget,
);
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"),
contactSheetBudget,
);
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,
contactSheetBudget,
);
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 */
}
phase(
"contact-sheets",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
} else {
warnings.push(
"Capture budget exhausted before contact sheets; source images were preserved.",
);
phase("contact-sheets", "degraded", "budget-exhausted");
}
// Generate project scaffold (index.html, meta.json, CLAUDE.md)
phase("scaffold", "started");
await generateProjectScaffold(
outputDir,
url,
tokens,
animationCatalog,
screenshots.length > 0,
discoveredLotties.length > 0,
existsSync(join(outputDir, "extracted", "shaders.json")),
catalogedAssets,
progress,
warnings,
detectedLibraries,
);
phase("scaffold", "completed");
progress("done", "Capture complete");
phase("complete", "completed");
const retry = captureWebsiteAttempt(opts, onProgress, true, watchdog, state);
const second = await runWithWatchdog(retry, watchdog.promise);
if (second.kind === "deadline") return deadlineResult(opts, state);
if (second.kind === "error") throw second.error;
return {
ok: true,
projectDir: outputDir,
url,
httpStatus,
title: tokens.title,
extracted,
screenshots,
tokens,
assets,
dropped,
animationCatalog,
warnings,
lastPhase,
...second.result,
warnings: [...new Set([...state.warnings, ...second.result.warnings])],
};
} finally {
await chromeBrowser.close();
watchdog.dispose();
}
}
function deadlineResult(opts: CaptureOptions, state: PartialCaptureState): CaptureResult {
const lastPhase = {
schema: CAPTURE_PHASE_SCHEMA,
phase: "complete" as const,
status: "degraded" as const,
remainingMs: null,
reason: "deadline" as const,
};
opts.onPhase?.(lastPhase);
return writePartialCaptureBundle(opts, state, lastPhase);
}
// visual-style.md and capture-summary.md generators removed — DESIGN.md replaces them
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { createPartialCaptureState } from "./partialCapture.js";
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
import type { CaptureOptions, CaptureResult } from "./types.js";
import { captureBrowserArgs } from "./browserLaunchArgs.js";
import { NavigationDeadlineError } from "./captureErrors.js";
const { captureWebsiteAttemptMock } = vi.hoisted(() => ({
captureWebsiteAttemptMock: vi.fn(),
}));
vi.mock("./captureAttempt.js", () => ({
captureWebsiteAttempt: captureWebsiteAttemptMock,
}));
describe("capture navigation retry launch", () => {
it("uses the normal WebGL launch for the first attempt", () => {
const args = captureBrowserArgs(false, 1920, 1080);
expect(args).toContain("--enable-webgl");
expect(args).toContain("--use-angle=swiftshader");
expect(args).not.toContain("--disable-gpu");
});
it("uses only the reduced GPU launch for the retry", () => {
const args = captureBrowserArgs(true, 1920, 1080);
expect(args).toContain("--disable-gpu");
expect(args).not.toContain("--enable-webgl");
expect(args).not.toContain("--use-angle=swiftshader");
expect(args).not.toContain("--use-gl=angle");
});
it("retries a WebGL navigation deadline and carries the degradation warning into the result", async () => {
const options: CaptureOptions = {
url: "https://example.com",
outputDir: "/tmp/hf-capture-test",
};
const partial = createPartialCaptureState(options);
const result: CaptureResult = {
ok: true,
projectDir: options.outputDir,
url: options.url,
httpStatus: 200,
title: "Example",
extracted: partial.extracted,
screenshots: [],
tokens: partial.tokens,
assets: [],
dropped: partial.dropped,
warnings: [],
lastPhase: {
schema: CAPTURE_PHASE_SCHEMA,
phase: "complete",
status: "completed",
remainingMs: 1,
},
};
captureWebsiteAttemptMock
.mockRejectedValueOnce(new NavigationDeadlineError(new Error("timeout"), true))
.mockResolvedValueOnce(result);
const phases: string[] = [];
const { captureWebsite } = await import("./index.js");
const actual = await captureWebsite({
...options,
onPhase: (event) => phases.push(event.reason ?? event.phase),
});
expect(captureWebsiteAttemptMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining(options),
undefined,
true,
expect.anything(),
expect.anything(),
);
expect(actual.warnings).toContain("Navigation timed out; retrying once with WebGL disabled");
expect(phases).toContain("webgl-disabled-retry");
});
it("does not retry a navigation deadline without observed WebGL", async () => {
const options: CaptureOptions = {
url: "https://example.com",
outputDir: "/tmp/hf-capture-test-no-webgl",
};
captureWebsiteAttemptMock.mockReset();
captureWebsiteAttemptMock.mockRejectedValueOnce(
new NavigationDeadlineError(new Error("timeout"), false),
);
const { captureWebsite } = await import("./index.js");
await expect(captureWebsite(options)).rejects.toThrow("capture navigation timed out");
expect(captureWebsiteAttemptMock).toHaveBeenCalledOnce();
});
});
+6 -5
View File
@@ -8,7 +8,8 @@
*/
import type { Browser, Page } from "puppeteer-core";
import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs";
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join, extname } from "node:path";
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
@@ -94,7 +95,7 @@ export async function saveLottieAnimations(
if (!validLottieJson(jsonData)) continue;
writeFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
writeCaptureFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
savedCount++;
}
} catch {
@@ -214,7 +215,7 @@ export async function renderLottiePreviews(
}
}
if (manifest.length > 0) {
writeFileSync(
writeCaptureFileSync(
join(outputDir, "extracted", "lottie-manifest.json"),
JSON.stringify(manifest, null, 2),
"utf-8",
@@ -283,7 +284,7 @@ async function downloadVideoBody(
}
if (total < 1024) return null; // too small to be a real video (likely an error blob)
const safe = /\.[a-z0-9]+$/i.test(filename) ? filename.replace(/[^\w.-]/g, "_") : `video${ext}`;
writeFileSync(join(videosDir, safe), Buffer.concat(chunks));
writeCaptureFileSync(join(videosDir, safe), Buffer.concat(chunks));
return `assets/videos/${safe}`;
} catch {
return null;
@@ -573,7 +574,7 @@ export async function captureVideoManifest(
}
if (videoManifest.length > 0) {
writeFileSync(
writeCaptureFileSync(
join(outputDir, "extracted", "video-manifest.json"),
JSON.stringify(videoManifest, null, 2),
"utf-8",
+141
View File
@@ -0,0 +1,141 @@
import { NavigationDeadlineError } from "./captureErrors.js";
import { detectBlockedPage } from "./pageBlockDetection.js";
import { navigateForCapture } from "./navigateForCapture.js";
import {
isDegradableEvaluateTimeoutError,
isNavigationTimeoutError,
withRemainingBudget,
} from "./captureTimeout.js";
import { writeResponseRecord } from "./responseRecord.js";
import { join } from "node:path";
import type { CapturePhase, CapturePhaseProgress } from "./types.js";
import type { Page } from "puppeteer-core";
type ContentCheck = {
textLength: number;
title: string;
hasChallengeElement: boolean;
bodyChildCount: number;
};
export interface NavigationPhaseInput {
page1: Page;
url: string;
timeout: number;
settleTime: number;
budgetMs: number;
warnings: string[];
progress: (stage: string, detail?: string) => void;
remainingMs: () => number;
pageContentCheck: ContentCheck;
contentCheckTimedOut: boolean;
postNavigationDeadline?: number;
outputDir: string;
phase: (
name: CapturePhase,
status: CapturePhaseProgress["status"],
reason?: CapturePhaseProgress["reason"],
) => void;
httpStatus: number | null;
canWrite: () => boolean;
}
async function navigatePage(context: NavigationPhaseInput) {
let navigation;
try {
navigation = await navigateForCapture(context.page1, context.url, context.timeout);
} catch (err) {
if (isNavigationTimeoutError(err)) {
let webglObserved = false;
try {
webglObserved = Boolean(await context.page1.evaluate("Boolean(window.__hfWebglSeen)"));
} catch {
/* the browser may already be closing */
}
throw new NavigationDeadlineError(err, webglObserved);
}
throw err;
}
if (navigation.fellBackFromNetworkIdle) {
context.warnings.push(
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continued with domcontentloaded`,
);
context.progress(
"warn",
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continuing with domcontentloaded`,
);
}
context.postNavigationDeadline = Date.now() + context.budgetMs;
await new Promise((resolve) => setTimeout(resolve, context.settleTime));
return navigation;
}
async function checkPageContent(context: NavigationPhaseInput): Promise<ContentCheck> {
try {
return (await withRemainingBudget(
context.page1.evaluate(`(() => {
var text = (document.body && document.body.innerText || "").trim();
var title = document.title || "";
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
var bodyChildCount = document.body ? document.body.children.length : 0;
return { textLength: text.length, title: title, hasChallengeElement: hasCfTurnstile, bodyChildCount: bodyChildCount };
})()`),
Math.min(5_000, context.remainingMs()),
"content-check",
)) as ContentCheck;
} catch (err) {
if (!isDegradableEvaluateTimeoutError(err)) throw err;
context.contentCheckTimedOut = true;
const message =
"post-navigation content check timed out; continuing with HTTP-status blocked-page detection only";
context.warnings.push(message);
context.progress("warn", message);
return context.pageContentCheck;
}
}
function persistNavigationResult(
context: NavigationPhaseInput,
navigationResponse: unknown,
pageContentCheck: ContentCheck,
): void {
context.httpStatus = navigationResponse
? (navigationResponse as { status: () => number }).status()
: null;
if (context.canWrite()) {
writeResponseRecord(join(context.outputDir, "extracted"), { status: context.httpStatus });
}
const blockedReason = detectBlockedPage({
httpStatus: context.httpStatus,
...(context.contentCheckTimedOut
? { title: "", textLength: 0, bodyChildCount: 0, hasChallengeElement: false }
: pageContentCheck),
});
if (blockedReason) {
context.phase("navigation", "degraded", "blocked");
throw new Error(blockedReason);
}
}
export interface NavigationPhaseResult {
pageContentCheck: ContentCheck;
contentCheckTimedOut: boolean;
httpStatus: number | null;
postNavigationDeadline?: number;
}
export async function runNavigationChecks(
context: NavigationPhaseInput,
): Promise<NavigationPhaseResult> {
const navigation = await navigatePage(context);
const pageContentCheck = await checkPageContent(context);
persistNavigationResult(context, navigation.response, pageContentCheck);
context.phase("navigation", "completed");
context.phase("core-extraction", "started");
return {
pageContentCheck,
contentCheckTimedOut: context.contentCheckTimedOut,
httpStatus: context.httpStatus,
postNavigationDeadline: context.postNavigationDeadline,
};
}
@@ -0,0 +1,48 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createPartialCaptureState, writePartialCaptureBundle } from "./partialCapture.js";
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0))
rmSync(directory, { recursive: true, force: true });
});
describe("writePartialCaptureBundle", () => {
it("writes tokens collected before the deadline", () => {
const outputDir = mkdtempSync(join(tmpdir(), "hyperframes-partial-capture-"));
temporaryDirectories.push(outputDir);
const opts = { url: "https://example.com", outputDir };
const state = createPartialCaptureState(opts);
state.tokens.title = "Collected before deadline";
state.tokens.colors = ["#ABCDEF"];
const result = writePartialCaptureBundle(opts, state, {
schema: CAPTURE_PHASE_SCHEMA,
phase: "complete",
status: "degraded",
remainingMs: null,
reason: "deadline",
});
expect(result.ok).toBe(false);
expect(result.lastPhase.reason).toBe("deadline");
const tokensPath = join(outputDir, "extracted", "tokens.json");
expect(existsSync(tokensPath)).toBe(true);
expect(JSON.parse(readFileSync(tokensPath, "utf8"))).toMatchObject({
title: "Collected before deadline",
colors: ["#ABCDEF"],
});
expect(
JSON.parse(readFileSync(join(outputDir, "extracted", "design-styles.json"), "utf8")),
).toEqual(state.designStyles);
expect(readFileSync(join(outputDir, "extracted", "page.html"), "utf8")).toBe(state.pageHtml);
expect(JSON.parse(readFileSync(join(outputDir, "meta.json"), "utf8"))).toMatchObject({
partial: true,
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { mkdirSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
import { noDrops } from "./assetDownloader.js";
import type {
CaptureOptions,
CaptureResult,
DesignStyles,
DesignTokens,
ExtractedHtml,
} from "./types.js";
export function serializeTokensForCapture(tokens: DesignTokens): string {
const tokensForDisk = {
...tokens,
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
};
return JSON.stringify(tokensForDisk, null, 2);
}
export interface PartialCaptureState {
tokens: DesignTokens;
designStyles: DesignStyles;
extracted: ExtractedHtml;
pageHtml: string;
screenshots: string[];
assets: CaptureResult["assets"];
dropped: CaptureResult["dropped"];
warnings: string[];
}
export function createPartialCaptureState(opts: CaptureOptions): PartialCaptureState {
return {
tokens: {
title: "",
description: "",
cssVariables: {},
fonts: [],
colors: [],
headings: [],
ctas: [],
svgs: [],
sections: [],
},
designStyles: {
typography: [],
spacing: { observed: [], baseUnit: 0 },
radius: [],
shadows: [],
buttons: [],
cards: [],
nav: null,
},
extracted: {
headHtml: "",
bodyHtml: "",
cssomRules: "",
htmlAttrs: "",
viewportWidth: opts.viewportWidth ?? 1920,
viewportHeight: opts.viewportHeight ?? 1080,
fullPageHeight: 0,
},
pageHtml: "<!doctype html>\n<html><head></head><body></body></html>\n",
screenshots: [],
assets: [],
dropped: noDrops(),
warnings: [],
};
}
export function writePartialCaptureBundle(
opts: CaptureOptions,
state: PartialCaptureState,
lastPhase: CaptureResult["lastPhase"],
): CaptureResult {
const hostname = new URL(opts.url).hostname.replace(/^www\./, "");
mkdirSync(opts.outputDir, { recursive: true });
const extractedDir = join(opts.outputDir, "extracted");
mkdirSync(extractedDir, { recursive: true });
writeCaptureFileSync(join(extractedDir, "tokens.json"), serializeTokensForCapture(state.tokens));
writeCaptureFileSync(
join(extractedDir, "design-styles.json"),
JSON.stringify(state.designStyles, null, 2),
);
writeCaptureFileSync(join(extractedDir, "page.html"), state.pageHtml);
const metaPath = join(opts.outputDir, "meta.json");
try {
writeCaptureFileSync(
metaPath,
JSON.stringify({ id: hostname + "-video", name: hostname, partial: true }, null, 2),
{ flag: "wx" },
);
} catch (err) {
const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
if (code !== "EEXIST") throw err;
}
return {
ok: false,
projectDir: opts.outputDir,
url: opts.url,
httpStatus: null,
title: state.tokens.title,
extracted: state.extracted,
screenshots: state.screenshots,
tokens: state.tokens,
assets: state.assets,
dropped: state.dropped,
warnings: [
...state.warnings,
"Capture deadline reached; returning the partial capture bundle.",
],
lastPhase,
};
}
@@ -0,0 +1,441 @@
import { existsSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
import {
downloadAssets,
downloadAndRewriteFonts,
mergeDrops,
noDrops,
totalDrops,
} from "./assetDownloader.js";
import { extractFontMetadata } from "./fontMetadataExtractor.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { diag } from "../ui/diagnostics.js";
import { serializeTokensForCapture } from "./partialCapture.js";
import type { PartialCaptureState } from "./partialCapture.js";
import type {
CapturePhase,
CapturePhaseProgress,
CaptureResult,
DesignTokens,
ExtractedHtml,
} from "./types.js";
import type { CatalogedAsset } from "./assetCataloger.js";
import type { IconCandidate } from "./faviconRanker.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
import {
captionImagesWithGemini,
generateAssetDescriptions,
resolveVisionPhaseCompletion,
} from "./contentExtractor.js";
import type { VisionCaptionOutcome } from "./contentExtractor.js";
function hasVisionCredentials(skipVision: boolean): boolean {
return Boolean(
!skipVision &&
(process.env.OPENROUTER_API_KEY ||
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY ||
(process.env.HYPERFRAMES_VERTEX_PROJECT_ID &&
process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT)),
);
}
function writeAssetDescriptionsFile(outputDir: string, lines: string[], header: string): void {
writeCaptureFileSync(
join(outputDir, "extracted", "asset-descriptions.md"),
header + lines.map((line) => `- ${line}`).join("\n") + "\n",
"utf-8",
);
}
export interface PostExtractionInput {
state: PartialCaptureState;
outputDir: string;
warnings: string[];
progress: (stage: string, detail?: string) => void;
remainingMs: () => number;
phase: (
name: CapturePhase,
status: CapturePhaseProgress["status"],
reason?: CapturePhaseProgress["reason"],
) => void;
animationCatalog: CaptureResult["animationCatalog"];
catalogedAssets: CatalogedAsset[];
visibleTextContent: string;
faviconLinks: IconCandidate[];
tokens: DesignTokens;
extracted: ExtractedHtml;
skipAssets: boolean;
skipVision: boolean;
downloadByteBudget: ReturnType<typeof createCaptureDownloadBudget>;
assets: CaptureResult["assets"];
dropped: CaptureResult["dropped"];
fontDrops: CaptureResult["dropped"];
canWrite: () => boolean;
}
export interface PostExtractionResult {
assets: CaptureResult["assets"];
dropped: CaptureResult["dropped"];
fontDrops: CaptureResult["dropped"];
extracted: ExtractedHtml;
tokens: DesignTokens;
animationCatalog: CaptureResult["animationCatalog"];
}
export async function runPostExtraction(input: PostExtractionInput): Promise<PostExtractionResult> {
let {
state,
outputDir,
warnings,
progress,
remainingMs,
phase,
animationCatalog,
catalogedAssets,
visibleTextContent,
faviconLinks,
tokens,
extracted,
skipAssets,
skipVision,
downloadByteBudget,
assets,
dropped,
fontDrops,
canWrite,
} = input;
const runFontExtraction = async (): Promise<void> => {
if (!canWrite()) return;
// Download fonts and preserve per-font budget exhaustion in the capture tally.
phase("fonts", "started");
const fontPass = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
remainingMs,
byteBudget: downloadByteBudget,
});
fontDrops = fontPass.drops;
extracted.headHtml = fontPass.css;
phase(
"fonts",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
// 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(", ");
// stderr (via diag): `capture --json` writes its envelope to stdout, so
// these progress/advisory lines must not land there.
diag.notice(`Font metadata extracted: ${summary}`);
if (fontsManifest.unidentified.length > 0) {
diag.warn(
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
);
}
}
} catch (err) {
diag.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
}
};
await runFontExtraction();
const runAnimationCatalog = async (): Promise<void> => {
// 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: { keyframes?: unknown[] }) => a.keyframes && a.keyframes.length > 0)
.slice(0, 10);
const leanCatalog = {
summary: animationCatalog.summary,
namedAnimations: Array.from(uniqueAnimNames),
scrollTriggeredElements: (animationCatalog.scrollTargets || []).length,
representativeAnimations: representativeAnims,
};
if (canWrite()) {
writeCaptureFileSync(
join(outputDir, "extracted", "animations.json"),
JSON.stringify(leanCatalog, null, 2),
"utf-8",
);
}
}
};
await runAnimationCatalog();
const runAssetDownload = async (): Promise<void> => {
// Download assets — single pass using the catalog for best image quality
let assetDrops = noDrops();
const runAssetFetch = async (): Promise<void> => {
if (!skipAssets && canWrite()) {
// Called even with the budget already gone, for the reason the font pass is: the loop that
// skips an asset is the only thing that can say how many it skipped.
phase("assets", "started");
progress("assets", "Downloading assets...");
const assetPass = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks, {
remainingMs,
byteBudget: downloadByteBudget,
});
assets = assetPass.assets;
assetDrops = assetPass.drops;
state.assets = assets;
// Which icons the site declared, what each one is, and why one became favicon.<ext>.
// The brand-kit consumer needs the shape to decide which tile an icon belongs in; the
// reason is what stops a substituted headline from being silent again.
if (canWrite()) {
writeCaptureFileSync(
join(outputDir, "extracted", "icons-manifest.json"),
JSON.stringify(assetPass.icons, null, 2),
"utf-8",
);
}
phase(
"assets",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
} else {
phase("assets", "degraded", "disabled");
}
};
await runAssetFetch();
const recordAssetDrops = (): void => {
// One capture-wide tally, summed from the two passes that own the drops. The warning is
// DERIVED from it rather than written alongside it, so the prose and the number cannot
// disagree the way two separately-authored budget strings could.
dropped = mergeDrops(fontDrops, assetDrops);
state.dropped = dropped;
const droppedTotal = totalDrops(dropped);
if (droppedTotal > 0) {
const breakdown = Object.entries(dropped)
.filter(([, n]) => typeof n === "number" && n > 0)
.map(([reason, n]) => `${n} ${reason}`)
.join(", ");
warnings.push(
`${droppedTotal} referenced asset(s) are not in this capture (${breakdown}). ` +
"A thin capture with no drops is a thin page; this one was truncated.",
);
}
};
recordAssetDrops();
const rewriteTokenAssets = (): void => {
// 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).
const base = (u: string): string => u.split(/[#?]/)[0] ?? u;
const localByUrl = new Map<string, string>();
const collectLocalAssetPaths = (): void => {
for (const a of assets) {
if (!a.url || !a.localPath) continue;
localByUrl.set(a.url, a.localPath);
localByUrl.set(base(a.url), a.localPath);
}
};
collectLocalAssetPaths();
const applyLocalAssetPaths = (): void => {
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;
}
};
applyLocalAssetPaths();
if (assets.length && Array.isArray(tokens.sections)) {
if (canWrite()) {
writeCaptureFileSync(
join(outputDir, "extracted", "tokens.json"),
serializeTokensForCapture(tokens),
"utf-8",
);
}
}
};
await rewriteTokenAssets();
};
await runAssetDownload();
// Persist a self-contained page recreation under extracted/, with images already inlined.
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`;
state.pageHtml = pageHtml;
if (canWrite())
writeCaptureFileSync(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) {
if (canWrite()) {
writeCaptureFileSync(
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)
let geminiCaptions: Record<string, string> = {};
const runVision = async (): Promise<void> => {
if (skipVision) {
phase("vision", "degraded", "disabled");
} else if (remainingMs() <= 0 || !canWrite()) {
warnings.push(
"Capture budget exhausted before vision captioning; catalog descriptions were preserved.",
);
phase("vision", "degraded", "budget-exhausted");
} else {
phase("vision", "started");
let visionOutcome: VisionCaptionOutcome = {
timedOutRequests: 0,
failedRequests: 0,
budgetExhausted: false,
};
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
remainingMs,
onOutcome: (outcome) => {
visionOutcome = outcome;
},
});
const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs());
phase(
"vision",
completion.status,
completion.status === "degraded" ? completion.reason : undefined,
);
}
};
await runVision();
// Generate asset descriptions for the AI agent
const runAssetDescriptions = (): void => {
progress("design", "Generating asset descriptions...");
try {
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
if (lines.length === 0 || !canWrite()) return;
const hasVisionKey = hasVisionCredentials(skipVision);
const header = hasVisionKey
? "# 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⚠️ No vision credentials — 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), or HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, 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";
writeAssetDescriptionsFile(outputDir, lines, header);
progress(
"design",
`${lines.length} asset descriptions written${hasVisionKey ? "" : " (no vision provider — catalog-fallback mode)"}`,
);
} catch {
/* non-critical */
}
};
runAssetDescriptions();
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
const runContactSheets = async (): Promise<void> => {
if (remainingMs() > 0 && canWrite()) {
phase("contact-sheets", "started");
try {
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
await import("./contactSheet.js");
const contactSheetBudget = { remainingMs };
const runScrollContactSheet = async (): Promise<void> => {
const scrollSheets = await createScrollContactSheet(
join(outputDir, "screenshots"),
join(outputDir, "screenshots", "contact-sheet.jpg"),
contactSheetBudget,
);
if (scrollSheets.length > 0)
progress(
"design",
`Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
);
};
await runScrollContactSheet();
const runAssetContactSheet = async (): Promise<void> => {
const assetsImgDir = join(outputDir, "assets");
if (existsSync(assetsImgDir)) {
const assetSheets = await createAssetContactSheet(
assetsImgDir,
join(outputDir, "assets", "contact-sheet.jpg"),
contactSheetBudget,
);
if (assetSheets.length > 0)
progress(
"design",
`Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`,
);
}
};
await runAssetContactSheet();
const runSvgContactSheet = async (): Promise<void> => {
// 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,
contactSheetBudget,
);
if (svgSheets.length > 0)
progress(
"design",
`SVG contact sheet generated (${svgSheets.length} page${svgSheets.length > 1 ? "s" : ""})`,
);
};
await runSvgContactSheet();
} catch {
/* contact sheets are non-critical — agent can still read images individually */
}
phase(
"contact-sheets",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
} else {
warnings.push(
"Capture budget exhausted before contact sheets; source images were preserved.",
);
phase("contact-sheets", "degraded", "budget-exhausted");
}
};
await runContactSheets();
return { assets, dropped, fontDrops, extracted, tokens, animationCatalog };
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { writeFileSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
/**
@@ -24,7 +24,7 @@ export interface CaptureResponseRecord {
/** Writes the record into an already-created `extracted/` directory. */
export function writeResponseRecord(extractedDir: string, record: CaptureResponseRecord): void {
writeFileSync(
writeCaptureFileSync(
join(extractedDir, RESPONSE_RECORD_FILENAME),
JSON.stringify(record, null, 2),
"utf-8",
@@ -7,8 +7,9 @@
import type { Page } from "puppeteer-core";
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
import { writeFileSync, mkdirSync } from "node:fs";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { writeCaptureFileSync } from "./captureFile.js";
/**
* Capture viewport screenshots covering the entire page height.
@@ -92,7 +93,7 @@ export async function captureFullPagePlate(
// than something silently wrong.
const produced = pngHeight(buffer);
if (produced != null && produced > MAX_PLATE_HEIGHT_PX) return null;
writeFileSync(join(screenshotsDir, "full-page.png"), buffer);
writeCaptureFileSync(join(screenshotsDir, "full-page.png"), buffer);
return "screenshots/full-page.png";
} finally {
// A page that broke mid-capture will fail this too; letting that escape would replace the
@@ -245,7 +246,7 @@ export async function captureScrollScreenshots(
const filePath = join(screenshotsDir, filename);
if ((budget.remainingMs?.() ?? 1) <= 0) break;
const buffer = await page.screenshot({ type: "png" });
writeFileSync(filePath, buffer);
writeCaptureFileSync(filePath, buffer);
filePaths.push(`screenshots/${filename}`);
}
+8 -2
View File
@@ -20,8 +20,10 @@ export type CapturePhase =
| "scaffold"
| "complete";
export const CAPTURE_PHASE_SCHEMA = "hyperframes.capture.phase.v1" as const;
export interface CapturePhaseProgress {
schema: "hyperframes.capture.phase.v1";
schema: typeof CAPTURE_PHASE_SCHEMA;
phase: CapturePhase;
status: "started" | "completed" | "degraded";
/** Null before the post-navigation budget begins. */
@@ -32,7 +34,9 @@ export interface CapturePhaseProgress {
| "request-timeout"
| "provider-error"
| "internal-error"
| "blocked";
| "blocked"
| "webgl-disabled-retry"
| "deadline";
}
export interface CaptureOptions {
@@ -56,6 +60,8 @@ export interface CaptureOptions {
skipVision?: boolean;
/** Cooperative post-navigation budget in ms (default: 120000). */
postNavigationBudgetMs?: number;
/** Optional hard wall-clock deadline for the complete capture run. */
captureDeadlineMs?: number;
/** Stable, non-sensitive progress records for watchdog diagnostics. */
onPhase?: (event: CapturePhaseProgress) => void;
/** Output JSON for programmatic use */
+28 -2
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CliRuntimeError } from "../utils/commandResult.js";
import { CAPTURE_PHASE_SCHEMA } from "../capture/types.js";
const { captureWebsiteMock } = vi.hoisted(() => ({
captureWebsiteMock: vi.fn(async (options: unknown) => {
@@ -10,7 +11,7 @@ const { captureWebsiteMock } = vi.hoisted(() => ({
const onPhase = Reflect.get(options, "onPhase");
if (typeof onPhase === "function") {
onPhase({
schema: "hyperframes.capture.phase.v1",
schema: CAPTURE_PHASE_SCHEMA,
phase: "vision",
status: "degraded",
remainingMs: 0,
@@ -85,6 +86,31 @@ describe("capture command — vision control", () => {
);
});
it("plumbs the optional whole-capture deadline from the environment", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS = "240000";
try {
await captureCommand.run!({
args: {
url: "https://example.com",
output: "/tmp/hf-capture-deadline-test",
"skip-assets": false,
"skip-vision": false,
json: true,
},
} as never);
expect(captureWebsiteMock).toHaveBeenCalledWith(
expect.objectContaining({ captureDeadlineMs: 240_000 }),
undefined,
);
} finally {
delete process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS;
}
});
it.each([
["1", 1],
["45000", 45_000],
@@ -156,7 +182,7 @@ describe("capture command — vision control", () => {
if (typeof line !== "string") throw new Error("Expected capture phase diagnostic");
const event = JSON.parse(line.slice("HYPERFRAMES_CAPTURE_PHASE ".length));
expect(event).toEqual({
schema: "hyperframes.capture.phase.v1",
schema: CAPTURE_PHASE_SCHEMA,
phase: "vision",
status: "degraded",
remainingMs: 0,
+2
View File
@@ -5,6 +5,7 @@ import type { Example } from "./_examples.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { diag } from "../ui/diagnostics.js";
import type { CapturePhaseProgress } from "../capture/types.js";
import { parseCaptureDeadline } from "../capture/captureWatchdog.js";
const CAPTURE_PHASE_PREFIX = "HYPERFRAMES_CAPTURE_PHASE ";
@@ -175,6 +176,7 @@ export default defineCommand({
: undefined,
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
postNavigationBudgetMs: captureBudgetMs,
captureDeadlineMs: parseCaptureDeadline(process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS),
json: isJson,
onPhase: emitCapturePhase,
},