From 765a5ae83f31b01b65426f34a3c80a47330456a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 31 Jul 2026 19:29:07 +0000 Subject: [PATCH] fix(cli): bound capture runtime stages --- .../cli/src/capture/assetDownloader.test.ts | 59 ++++- packages/cli/src/capture/assetDownloader.ts | 37 ++- packages/cli/src/capture/contactSheet.ts | 19 +- packages/cli/src/capture/contentExtractor.ts | 163 +++++++++--- packages/cli/src/capture/index.ts | 246 ++++++++++++++---- packages/cli/src/capture/indexBudget.test.ts | 12 + packages/cli/src/capture/mediaCapture.test.ts | 25 ++ packages/cli/src/capture/mediaCapture.ts | 18 +- .../cli/src/capture/screenshotCapture.test.ts | 22 +- packages/cli/src/capture/screenshotCapture.ts | 16 +- packages/cli/src/capture/types.ts | 28 ++ packages/cli/src/commands/capture.ts | 16 ++ 12 files changed, 548 insertions(+), 113 deletions(-) create mode 100644 packages/cli/src/capture/indexBudget.test.ts create mode 100644 packages/cli/src/capture/mediaCapture.test.ts diff --git a/packages/cli/src/capture/assetDownloader.test.ts b/packages/cli/src/capture/assetDownloader.test.ts index fa9da1c65d..4028da90c8 100644 --- a/packages/cli/src/capture/assetDownloader.test.ts +++ b/packages/cli/src/capture/assetDownloader.test.ts @@ -1,5 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { isPrivateUrl, safeFetch, toStandaloneSvg } from "./assetDownloader.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + downloadAndRewriteFonts, + isPrivateUrl, + safeFetch, + toStandaloneSvg, +} from "./assetDownloader.js"; describe("isPrivateUrl — SSRF denylist (security: F-003)", () => { it("blocks loopback, private, and metadata IPv4", () => { @@ -127,3 +135,52 @@ describe("toStandaloneSvg — scraped inline SVGs must survive as .svg files", ( expect(toStandaloneSvg("
not an svg
")).toBe("
not an svg
"); }); }); + +describe("downloadAndRewriteFonts — attempt caps", () => { + afterEach(() => vi.unstubAllGlobals()); + + async function expectFailedFontAttempts(css: string, expectedAttempts: number): Promise { + const dir = mkdtempSync(join(tmpdir(), "hf-font-attempts-")); + const fetchMock = vi.fn(async () => new Response("failed", { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + + try { + await downloadAndRewriteFonts(css, dir); + expect(fetchMock).toHaveBeenCalledTimes(expectedAttempts); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("counts failed requests toward the global 30-font cap", async () => { + const css = Array.from( + { length: 35 }, + (_, i) => + `@font-face { font-family: Family${i}; src: url(https://fonts${i}.example/font-${i}.woff2); }`, + ).join("\n"); + await expectFailedFontAttempts(css, 30); + }); + + it("counts failed requests toward the six-attempt per-family cap", async () => { + const css = Array.from( + { length: 10 }, + (_, i) => + `@font-face { font-family: Shared; src: url(https://fonts.example/font-${i}.woff2); }`, + ).join("\n"); + await expectFailedFontAttempts(css, 6); + }); + + it("does not start a font request after the capture budget is exhausted", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-font-budget-")); + const css = "@font-face { font-family: Budget; src: url(https://fonts.example/budget.woff2); }"; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + try { + await downloadAndRewriteFonts(css, dir, { remainingMs: () => 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index 67b2c0f750..9e783b9348 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -11,6 +11,10 @@ import { createHash } from "node:crypto"; import type { DesignTokens, DownloadedAsset } from "./types.js"; import type { CatalogedAsset } from "./assetCataloger.js"; +interface DownloadBudgetOptions { + remainingMs?: () => number; +} + // SVGs: hash-of-bytes filename so it can't drift from content; label-derived names mis-assigned brands. function svgContentHashSlug(svgSource: string | Buffer, isLogo: boolean): string { const hash = createHash("sha1").update(svgSource).digest("hex").slice(0, 8); @@ -43,11 +47,13 @@ export function toStandaloneSvg(outerHTML: string): string { return outerHTML.replace(original, tag); } +// fallow-ignore-next-line complexity export async function downloadAssets( tokens: DesignTokens, outputDir: string, catalogedAssets?: CatalogedAsset[], faviconLinks?: Array<{ rel: string; href: string }>, + options: DownloadBudgetOptions = {}, ): Promise { const assetsDir = join(outputDir, "assets"); mkdirSync(assetsDir, { recursive: true }); @@ -82,12 +88,14 @@ export async function downloadAssets( // 2. Favicon for (const icon of faviconLinks || []) { + const remainingMs = options.remainingMs?.() ?? 10_000; + if (remainingMs <= 0) break; if (!icon.href) continue; try { const ext = extname(new URL(icon.href).pathname) || ".ico"; const name = `favicon${ext}`; const localPath = `assets/${name}`; - const buffer = await fetchBuffer(icon.href); + const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs)); if (buffer) { writeFileSync(join(outputDir, localPath), buffer); assets.push({ url: icon.href, localPath, type: "favicon" }); @@ -149,13 +157,15 @@ export async function downloadAssets( let imgIdx = 0; const usedNames = new Set(); for (let i = 0; i < toDownload.length; i += BATCH_SIZE) { + const remainingMs = options.remainingMs?.() ?? 10_000; + if (remainingMs <= 0) break; const batch = toDownload.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( batch.map(async ({ url, isPoster, catalog }) => { const parsedUrl = new URL(url); const pathExt = extname(parsedUrl.pathname); const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg"; - const buffer = await fetchBuffer(url); + const buffer = await fetchBuffer(url, Math.min(10_000, remainingMs)); if (!buffer) return null; const isSvg = ext === ".svg" || url.includes(".svg"); const minSize = isSvg ? 200 : 10000; @@ -198,10 +208,12 @@ export async function downloadAssets( // 4. OG image (if not already downloaded) if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) { + const remainingMs = options.remainingMs?.() ?? 10_000; try { const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg"; const localPath = `assets/og-image${ext}`; - const buffer = await fetchBuffer(tokens.ogImage); + const buffer = + remainingMs > 0 ? await fetchBuffer(tokens.ogImage, Math.min(10_000, remainingMs)) : null; if (buffer && buffer.length > 5000) { writeFileSync(join(outputDir, localPath), buffer); assets.push({ url: tokens.ogImage, localPath, type: "image" }); @@ -234,7 +246,12 @@ function normalizeUrl(u: string): string { * Download fonts referenced in CSS and rewrite URLs to local paths. * Returns the modified CSS string with local font paths. */ -export async function downloadAndRewriteFonts(css: string, outputDir: string): Promise { +// fallow-ignore-next-line complexity +export async function downloadAndRewriteFonts( + css: string, + outputDir: string, + options: DownloadBudgetOptions = {}, +): Promise { const assetsDir = join(outputDir, "assets", "fonts"); mkdirSync(assetsDir, { recursive: true }); @@ -275,10 +292,14 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P let count = 0; for (const fontUrl of sortedUrls) { + const remainingMs = options.remainingMs?.() ?? 10_000; + if (remainingMs <= 0) break; if (count >= MAX_TOTAL_FONTS) break; const family = getFamilyForUrl(fontUrl); const familyCount = familyCounts.get(family) || 0; if (familyCount >= MAX_FONTS_PER_FAMILY) continue; + familyCounts.set(family, familyCount + 1); + count++; try { const urlObj = new URL(fontUrl); @@ -286,12 +307,10 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P const localPath = join(assetsDir, filename); const relativePath = `assets/fonts/${filename}`; - const buffer = await fetchBuffer(fontUrl); + const buffer = await fetchBuffer(fontUrl, Math.min(10_000, remainingMs)); if (buffer) { writeFileSync(localPath, buffer); rewritten = rewritten.split(fontUrl).join(relativePath); - familyCounts.set(family, familyCount + 1); - count++; } } catch { /* skip */ @@ -391,10 +410,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise { +async function fetchBuffer(url: string, timeoutMs = 10_000): Promise { try { const res = await safeFetch(url, { - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(timeoutMs), headers: { "User-Agent": "HyperFrames/1.0" }, }); if (!res || !res.ok) return null; diff --git a/packages/cli/src/capture/contactSheet.ts b/packages/cli/src/capture/contactSheet.ts index 6289c84494..ffb5b37a22 100644 --- a/packages/cli/src/capture/contactSheet.ts +++ b/packages/cli/src/capture/contactSheet.ts @@ -18,6 +18,8 @@ interface ContactSheetOptions { quality?: number; /** Target width per cell in pixels (default: 600) */ cellWidth?: number; + /** Cooperative boundary checked before starting each native Sharp page. */ + remainingMs?: () => number; } /** @@ -39,6 +41,8 @@ export async function createContactSheet( cellWidth = 600, } = opts; + if ((opts.remainingMs?.() ?? 1) <= 0) return null; + const files = imagePaths.slice(0, maxImages); if (files.length === 0) return null; @@ -125,6 +129,7 @@ function escapeXml(s: string): string { * Output files: basePath → base-1.jpg, base-2.jpg, ... * Returns the list of written file paths (empty if no images). */ +// fallow-ignore-next-line complexity async function createContactSheetPages( imagePaths: string[], outputBasePath: string, @@ -133,7 +138,7 @@ async function createContactSheetPages( customLabels?: string[], ): Promise { if (imagePaths.length === 0) return []; - const { pageSize = imagePaths.length, ...sheetOpts } = opts; + const { pageSize = imagePaths.length, remainingMs, ...sheetOpts } = opts; const ext = outputBasePath.match(/\.[^.]+$/)?.[0] ?? ".jpg"; const base = outputBasePath.slice(0, -ext.length); @@ -141,6 +146,7 @@ async function createContactSheetPages( const results: string[] = []; for (let p = 0; p < pages; p++) { + if ((remainingMs?.() ?? 1) <= 0) break; const chunk = imagePaths.slice(p * pageSize, (p + 1) * pageSize); const chunkLabels = customLabels?.slice(p * pageSize, (p + 1) * pageSize); const outPath = pages === 1 ? outputBasePath : `${base}-${p + 1}${ext}`; @@ -170,6 +176,7 @@ async function createContactSheetPages( export async function createScrollContactSheet( screenshotsDir: string, outputPath: string, + budget: Pick = {}, ): Promise { if (!existsSync(screenshotsDir)) return []; @@ -189,7 +196,7 @@ export async function createScrollContactSheet( return createContactSheetPages( paths, outputPath, - { cols: 3, cellWidth: 600, pageSize: 9 }, + { cols: 3, cellWidth: 600, pageSize: 9, ...budget }, 0, labels, ); @@ -203,6 +210,7 @@ export async function createScrollContactSheet( export async function createSnapshotContactSheet( snapshotsDir: string, outputPath: string, + budget: Pick = {}, ): Promise { if (!existsSync(snapshotsDir)) return []; @@ -222,7 +230,7 @@ export async function createSnapshotContactSheet( return createContactSheetPages( paths, outputPath, - { cols: 3, cellWidth: 600, pageSize: 9 }, + { cols: 3, cellWidth: 600, pageSize: 9, ...budget }, 0, labels, ); @@ -236,6 +244,7 @@ export async function createSnapshotContactSheet( export async function createAssetContactSheet( assetsDir: string, outputPath: string, + budget: Pick = {}, ): Promise { if (!existsSync(assetsDir)) return []; @@ -254,6 +263,7 @@ export async function createAssetContactSheet( cellWidth: 480, labelMode: "filename", pageSize: 12, + ...budget, }); } @@ -271,6 +281,7 @@ export async function createSvgContactSheet( svgsDir: string, outputPath: string, assetsRootDir?: string, + budget: Pick = {}, ): Promise { const dirsToScan = [svgsDir, assetsRootDir].filter( (d): d is string => d !== undefined && existsSync(d), @@ -302,6 +313,7 @@ export async function createSvgContactSheet( const labels: string[] = []; for (let i = 0; i < svgPaths.length; i++) { + if ((budget.remainingMs?.() ?? 1) <= 0) break; const svgPath = svgPaths[i]!; const tmpPath = join(tmpDir, `.thumb-${i}.png`); try { @@ -334,6 +346,7 @@ export async function createSvgContactSheet( cols: 5, cellWidth: thumbSize, pageSize: 15, + ...budget, }, 0, labels, diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 2caee86e48..824ee01788 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -15,6 +15,47 @@ import type sharpType from "sharp"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { DesignTokens } from "./types.js"; +const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000; + +interface VisionCaptionOptions { + skipVision?: boolean; + remainingMs?: () => number; +} + +class VisionRequestTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Vision request timed out after ${timeoutMs}ms`); + this.name = "VisionRequestTimeoutError"; + } +} + +function resolveVisionRequestTimeoutMs(): number { + const configured = Number(process.env.HYPERFRAMES_VISION_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_VISION_REQUEST_TIMEOUT_MS; +} + +async function runBoundedVisionRequest( + request: (signal: AbortSignal) => Promise, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new VisionRequestTimeoutError(timeoutMs)); + controller.abort(); + }, timeoutMs); + }); + + try { + return await Promise.race([request(controller.signal), timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + /** * Detect JS libraries via window globals, DOM fingerprints, script URLs, * and WebGL shader analysis. @@ -165,12 +206,15 @@ export async function extractVisibleText(page: Page): Promise { * Batches requests to stay under free-tier rate limits. * Returns a map of filename -> caption string. */ +// fallow-ignore-next-line complexity export async function captionImagesWithGemini( outputDir: string, progress: (stage: string, detail?: string) => void, warnings: string[], + options: VisionCaptionOptions = {}, ): Promise> { const geminiCaptions: Record = {}; + if (options.skipVision) return geminiCaptions; const openRouterKey = process.env.OPENROUTER_API_KEY; const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (!openRouterKey && !geminiKey) return geminiCaptions; @@ -186,6 +230,7 @@ export async function captionImagesWithGemini( const model = useOpenRouter ? process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite" : process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview"; + const requestTimeoutMs = resolveVisionRequestTimeoutMs(); progress("design", `Captioning images with ${providerName} vision...`); try { @@ -196,31 +241,40 @@ export async function captionImagesWithGemini( base64: string; prompt: string; maxTokens: number; + timeoutMs: number; }) => Promise; let captionOne: CaptionOne; if (openRouterKey) { - captionOne = async ({ mimeType, base64, prompt, maxTokens }) => { - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - Authorization: `Bearer ${openRouterKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model, - messages: [ - { - role: "user", - content: [ - { type: "text", text: prompt }, - { type: "image_url", image_url: { url: `data:${mimeType};base64,${base64}` } }, - ], + captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => { + const res = await runBoundedVisionRequest( + (signal) => + fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${openRouterKey}`, + "Content-Type": "application/json", }, - ], - max_tokens: maxTokens, - }), - }); + signal, + body: JSON.stringify({ + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: prompt }, + { + type: "image_url", + image_url: { url: `data:${mimeType};base64,${base64}` }, + }, + ], + }, + ], + max_tokens: maxTokens, + }), + }), + timeoutMs, + ); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`OpenRouter ${res.status} ${res.statusText}: ${detail.slice(0, 200)}`); @@ -235,14 +289,25 @@ export async function captionImagesWithGemini( if (!geminiKey) return geminiCaptions; const { GoogleGenAI } = await import("@google/genai"); const ai = new GoogleGenAI({ apiKey: geminiKey }); - captionOne = async ({ mimeType, base64, prompt, maxTokens }) => { - const response = await ai.models.generateContent({ - model, - contents: [ - { role: "user", parts: [{ inlineData: { mimeType, data: base64 } }, { text: prompt }] }, - ], - config: { maxOutputTokens: maxTokens }, - }); + captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => { + const response = await runBoundedVisionRequest( + (signal) => + ai.models.generateContent({ + model, + contents: [ + { + role: "user", + parts: [{ inlineData: { mimeType, data: base64 } }, { text: prompt }], + }, + ], + config: { + maxOutputTokens: maxTokens, + abortSignal: signal, + httpOptions: { timeout: timeoutMs }, + }, + }), + timeoutMs, + ); return response.text?.trim() || ""; }; } @@ -256,7 +321,27 @@ export async function captionImagesWithGemini( // on Promise.allSettled so a rate-limited image degrades to "" rather than // failing the batch. const BATCH_SIZE = 20; + let timedOutCount = 0; + const collectCaptionResults = ( + results: PromiseSettledResult<{ file: string; caption: string }>[], + ): number => { + let timeouts = 0; + for (const result of results) { + if (result.status === "fulfilled" && result.value.caption) { + geminiCaptions[result.value.file] = result.value.caption; + } else if ( + result.status === "rejected" && + result.reason instanceof VisionRequestTimeoutError + ) { + timeouts++; + } + } + return timeouts; + }; for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) { + const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY; + if (remainingMs <= 0) break; + const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); const batch = imageFiles.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( batch.map(async (file: string) => { @@ -273,15 +358,12 @@ export async function captionImagesWithGemini( prompt: "Describe this website image in ONE short sentence for a video storyboard. Focus on: what it shows, dominant colors, whether background is light or dark. Be factual, not creative.", maxTokens: 500, + timeoutMs, }); return { file, caption }; }), ); - for (const result of results) { - if (result.status === "fulfilled" && result.value.caption) { - geminiCaptions[result.value.file] = result.value.caption; - } - } + timedOutCount += collectCaptionResults(results); // Pace requests between batches (paid tier: 2000+ RPM, free tier: rate-limited) if (i + BATCH_SIZE < imageFiles.length) { await new Promise((r) => setTimeout(r, 2000)); // 2s pause between batches — paid tier handles 2000 RPM, free tier retries via Promise.allSettled @@ -329,6 +411,9 @@ export async function captionImagesWithGemini( const SVG_RENDER_SIZE = 256; // px — enough resolution for Gemini to read wordmarks, small enough to keep payload sub-MB let svgsSkipped = 0; for (let i = 0; i < svgFiles.length; i += SVG_BATCH) { + const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY; + if (remainingMs <= 0) break; + const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); const batch = svgFiles.slice(i, i + SVG_BATCH); const results = await Promise.allSettled( batch.map(async ({ relPath }) => { @@ -373,15 +458,12 @@ export async function captionImagesWithGemini( "If you see a wordmark, READ THE LETTERS LITERALLY — do not guess a brand from context. " + "Be factual.", maxTokens: 300, + timeoutMs, }); return { file: relPath, caption }; }), ); - for (const result of results) { - if (result.status === "fulfilled" && result.value.caption) { - geminiCaptions[result.value.file] = result.value.caption; - } - } + timedOutCount += collectCaptionResults(results); if (i + SVG_BATCH < svgFiles.length) { await new Promise((r) => setTimeout(r, 2000)); } @@ -398,6 +480,11 @@ export async function captionImagesWithGemini( ); } } + if (timedOutCount > 0) { + warnings.push( + `${providerName} vision timed out for ${timedOutCount} asset(s); captions omitted.`, + ); + } } catch (err) { warnings.push(`${providerName} captioning failed: ${err}`); } diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index fe58a972f4..8b90164bd0 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -39,10 +39,12 @@ import { generateAssetDescriptions, } from "./contentExtractor.js"; import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js"; -import type { CaptureOptions, CaptureResult } from "./types.js"; +import type { CaptureOptions, CapturePhase, CapturePhaseProgress, 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, @@ -57,12 +59,49 @@ export async function captureWebsite( settleTime = 3000, maxScreenshots: _maxScreenshots = 24, skipAssets = false, + skipVision = false, + postNavigationBudgetMs = DEFAULT_POST_NAVIGATION_BUDGET_MS, + onPhase, } = opts; 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); @@ -102,6 +141,8 @@ export async function captureWebsite( // 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(); @@ -198,7 +239,10 @@ export async function captureWebsite( // modern SPAs often have persistent WebSocket/analytics connections that // prevent networkidle0 from ever resolving. await page1.goto(url, { waitUntil: "networkidle2", timeout }); + postNavigationDeadline = Date.now() + budgetMs; await new Promise((r) => setTimeout(r, settleTime)); + phase("navigation", "completed"); + phase("core-extraction", "started"); // Check if the page loaded real content or an anti-bot challenge // Use structural detection (DOM elements + cookies), not text regex matching — @@ -231,29 +275,36 @@ export async function captureWebsite( // Framer and other modern sites use IntersectionObserver — images only load // when scrolled into view. We scroll the full page, then wait for all images // to finish loading before proceeding. - await page1.evaluate(`(async () => { + const lazyLoadBudgetMs = Math.min(15_000, remainingMs()); + if (lazyLoadBudgetMs > 0) { + await page1.evaluate(`(async () => { + var lazyLoadDeadline = Date.now() + ${lazyLoadBudgetMs}; var h = document.body.scrollHeight; for (var y = 0; y < h; y += window.innerHeight * 0.7) { + if (Date.now() >= lazyLoadDeadline) break; window.scrollTo(0, y); await new Promise(function(r) { setTimeout(r, 400); }); } // Scroll to very bottom to catch footer lazy-loads - window.scrollTo(0, document.body.scrollHeight); - await new Promise(function(r) { setTimeout(r, 800); }); + if (Date.now() < lazyLoadDeadline) { + window.scrollTo(0, document.body.scrollHeight); + await new Promise(function(r) { setTimeout(r, Math.min(800, Math.max(0, lazyLoadDeadline - Date.now()))); }); + } // Wait for all images to finish loading var imgs = Array.from(document.querySelectorAll('img')); var pending = imgs.filter(function(img) { return !img.complete; }); - if (pending.length > 0) { + var imageWaitMs = Math.min(5000, Math.max(0, lazyLoadDeadline - Date.now())); + if (pending.length > 0 && imageWaitMs > 0) { await Promise.race([ Promise.all(pending.map(function(img) { return new Promise(function(r) { img.onload = r; img.onerror = r; }); })), - new Promise(function(r) { setTimeout(r, 5000); }) + new Promise(function(r) { setTimeout(r, imageWaitMs); }) ]); } window.scrollTo(0, 0); - await new Promise(function(r) { setTimeout(r, 500); }); })()`); + } await page1.evaluate(`window.scrollTo(0, 0)`); await new Promise((r) => setTimeout(r, 300)); @@ -289,12 +340,12 @@ export async function captureWebsite( /* DOM scan failed — non-critical */ } - if (discoveredLotties.length > 0) { + if (discoveredLotties.length > 0 && remainingMs() > 0) { const lottieDir = join(outputDir, "assets", "lottie"); mkdirSync(lottieDir, { recursive: true }); const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir); // Generate manifest + preview thumbnails so the agent can SEE what each animation is - if (savedCount > 0) { + if (savedCount > 0 && remainingMs() > 0) { await renderLottiePreviews(chromeBrowser, lottieDir, outputDir); progress("lottie", `${savedCount} Lottie animation(s) saved`); } @@ -368,7 +419,7 @@ export async function captureWebsite( // Capture scroll-position viewport screenshots progress("screenshots", "Capturing scroll screenshots..."); const { captureScrollScreenshots } = await import("./screenshotCapture.js"); - const screenshots = await captureScrollScreenshots(page1, outputDir); + const screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs }); progress("screenshots", `${screenshots.length} scroll screenshots captured`); // Catalog all assets (must run before extractHtml which converts img src to data URLs) @@ -437,10 +488,14 @@ export async function captureWebsite( // Generate video manifest — screenshot each