fix(cli): bound capture runtime stages

This commit is contained in:
Miguel Ángel
2026-07-31 20:30:07 +00:00
parent 1738b5a11f
commit 765a5ae83f
12 changed files with 548 additions and 113 deletions
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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)", () => { describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
it("blocks loopback, private, and metadata IPv4", () => { it("blocks loopback, private, and metadata IPv4", () => {
@@ -127,3 +135,52 @@ describe("toStandaloneSvg — scraped inline SVGs must survive as .svg files", (
expect(toStandaloneSvg("<div>not an svg</div>")).toBe("<div>not an svg</div>"); expect(toStandaloneSvg("<div>not an svg</div>")).toBe("<div>not an svg</div>");
}); });
}); });
describe("downloadAndRewriteFonts — attempt caps", () => {
afterEach(() => vi.unstubAllGlobals());
async function expectFailedFontAttempts(css: string, expectedAttempts: number): Promise<void> {
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 });
}
});
});
+28 -9
View File
@@ -11,6 +11,10 @@ import { createHash } from "node:crypto";
import type { DesignTokens, DownloadedAsset } from "./types.js"; import type { DesignTokens, DownloadedAsset } from "./types.js";
import type { CatalogedAsset } from "./assetCataloger.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. // 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 { function svgContentHashSlug(svgSource: string | Buffer, isLogo: boolean): string {
const hash = createHash("sha1").update(svgSource).digest("hex").slice(0, 8); 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); return outerHTML.replace(original, tag);
} }
// fallow-ignore-next-line complexity
export async function downloadAssets( export async function downloadAssets(
tokens: DesignTokens, tokens: DesignTokens,
outputDir: string, outputDir: string,
catalogedAssets?: CatalogedAsset[], catalogedAssets?: CatalogedAsset[],
faviconLinks?: Array<{ rel: string; href: string }>, faviconLinks?: Array<{ rel: string; href: string }>,
options: DownloadBudgetOptions = {},
): Promise<DownloadedAsset[]> { ): Promise<DownloadedAsset[]> {
const assetsDir = join(outputDir, "assets"); const assetsDir = join(outputDir, "assets");
mkdirSync(assetsDir, { recursive: true }); mkdirSync(assetsDir, { recursive: true });
@@ -82,12 +88,14 @@ export async function downloadAssets(
// 2. Favicon // 2. Favicon
for (const icon of faviconLinks || []) { for (const icon of faviconLinks || []) {
const remainingMs = options.remainingMs?.() ?? 10_000;
if (remainingMs <= 0) break;
if (!icon.href) continue; if (!icon.href) continue;
try { try {
const ext = extname(new URL(icon.href).pathname) || ".ico"; const ext = extname(new URL(icon.href).pathname) || ".ico";
const name = `favicon${ext}`; const name = `favicon${ext}`;
const localPath = `assets/${name}`; const localPath = `assets/${name}`;
const buffer = await fetchBuffer(icon.href); const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs));
if (buffer) { if (buffer) {
writeFileSync(join(outputDir, localPath), buffer); writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: icon.href, localPath, type: "favicon" }); assets.push({ url: icon.href, localPath, type: "favicon" });
@@ -149,13 +157,15 @@ export async function downloadAssets(
let imgIdx = 0; let imgIdx = 0;
const usedNames = new Set<string>(); const usedNames = new Set<string>();
for (let i = 0; i < toDownload.length; i += BATCH_SIZE) { 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 batch = toDownload.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled( const results = await Promise.allSettled(
batch.map(async ({ url, isPoster, catalog }) => { batch.map(async ({ url, isPoster, catalog }) => {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
const pathExt = extname(parsedUrl.pathname); const pathExt = extname(parsedUrl.pathname);
const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg"; 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; if (!buffer) return null;
const isSvg = ext === ".svg" || url.includes(".svg"); const isSvg = ext === ".svg" || url.includes(".svg");
const minSize = isSvg ? 200 : 10000; const minSize = isSvg ? 200 : 10000;
@@ -198,10 +208,12 @@ export async function downloadAssets(
// 4. OG image (if not already downloaded) // 4. OG image (if not already downloaded)
if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) { if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) {
const remainingMs = options.remainingMs?.() ?? 10_000;
try { try {
const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg"; const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg";
const localPath = `assets/og-image${ext}`; 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) { if (buffer && buffer.length > 5000) {
writeFileSync(join(outputDir, localPath), buffer); writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: tokens.ogImage, localPath, type: "image" }); 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. * Download fonts referenced in CSS and rewrite URLs to local paths.
* Returns the modified CSS string with local font paths. * Returns the modified CSS string with local font paths.
*/ */
export async function downloadAndRewriteFonts(css: string, outputDir: string): Promise<string> { // fallow-ignore-next-line complexity
export async function downloadAndRewriteFonts(
css: string,
outputDir: string,
options: DownloadBudgetOptions = {},
): Promise<string> {
const assetsDir = join(outputDir, "assets", "fonts"); const assetsDir = join(outputDir, "assets", "fonts");
mkdirSync(assetsDir, { recursive: true }); mkdirSync(assetsDir, { recursive: true });
@@ -275,10 +292,14 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P
let count = 0; let count = 0;
for (const fontUrl of sortedUrls) { for (const fontUrl of sortedUrls) {
const remainingMs = options.remainingMs?.() ?? 10_000;
if (remainingMs <= 0) break;
if (count >= MAX_TOTAL_FONTS) break; if (count >= MAX_TOTAL_FONTS) break;
const family = getFamilyForUrl(fontUrl); const family = getFamilyForUrl(fontUrl);
const familyCount = familyCounts.get(family) || 0; const familyCount = familyCounts.get(family) || 0;
if (familyCount >= MAX_FONTS_PER_FAMILY) continue; if (familyCount >= MAX_FONTS_PER_FAMILY) continue;
familyCounts.set(family, familyCount + 1);
count++;
try { try {
const urlObj = new URL(fontUrl); const urlObj = new URL(fontUrl);
@@ -286,12 +307,10 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P
const localPath = join(assetsDir, filename); const localPath = join(assetsDir, filename);
const relativePath = `assets/fonts/${filename}`; const relativePath = `assets/fonts/${filename}`;
const buffer = await fetchBuffer(fontUrl); const buffer = await fetchBuffer(fontUrl, Math.min(10_000, remainingMs));
if (buffer) { if (buffer) {
writeFileSync(localPath, buffer); writeFileSync(localPath, buffer);
rewritten = rewritten.split(fontUrl).join(relativePath); rewritten = rewritten.split(fontUrl).join(relativePath);
familyCounts.set(family, familyCount + 1);
count++;
} }
} catch { } catch {
/* skip */ /* skip */
@@ -391,10 +410,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
return null; // too many redirects return null; // too many redirects
} }
async function fetchBuffer(url: string): Promise<Buffer | null> { async function fetchBuffer(url: string, timeoutMs = 10_000): Promise<Buffer | null> {
try { try {
const res = await safeFetch(url, { const res = await safeFetch(url, {
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(timeoutMs),
headers: { "User-Agent": "HyperFrames/1.0" }, headers: { "User-Agent": "HyperFrames/1.0" },
}); });
if (!res || !res.ok) return null; if (!res || !res.ok) return null;
+16 -3
View File
@@ -18,6 +18,8 @@ interface ContactSheetOptions {
quality?: number; quality?: number;
/** Target width per cell in pixels (default: 600) */ /** Target width per cell in pixels (default: 600) */
cellWidth?: number; cellWidth?: number;
/** Cooperative boundary checked before starting each native Sharp page. */
remainingMs?: () => number;
} }
/** /**
@@ -39,6 +41,8 @@ export async function createContactSheet(
cellWidth = 600, cellWidth = 600,
} = opts; } = opts;
if ((opts.remainingMs?.() ?? 1) <= 0) return null;
const files = imagePaths.slice(0, maxImages); const files = imagePaths.slice(0, maxImages);
if (files.length === 0) return null; if (files.length === 0) return null;
@@ -125,6 +129,7 @@ function escapeXml(s: string): string {
* Output files: basePath → base-1.jpg, base-2.jpg, ... * Output files: basePath → base-1.jpg, base-2.jpg, ...
* Returns the list of written file paths (empty if no images). * Returns the list of written file paths (empty if no images).
*/ */
// fallow-ignore-next-line complexity
async function createContactSheetPages( async function createContactSheetPages(
imagePaths: string[], imagePaths: string[],
outputBasePath: string, outputBasePath: string,
@@ -133,7 +138,7 @@ async function createContactSheetPages(
customLabels?: string[], customLabels?: string[],
): Promise<string[]> { ): Promise<string[]> {
if (imagePaths.length === 0) return []; 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 ext = outputBasePath.match(/\.[^.]+$/)?.[0] ?? ".jpg";
const base = outputBasePath.slice(0, -ext.length); const base = outputBasePath.slice(0, -ext.length);
@@ -141,6 +146,7 @@ async function createContactSheetPages(
const results: string[] = []; const results: string[] = [];
for (let p = 0; p < pages; p++) { for (let p = 0; p < pages; p++) {
if ((remainingMs?.() ?? 1) <= 0) break;
const chunk = imagePaths.slice(p * pageSize, (p + 1) * pageSize); const chunk = imagePaths.slice(p * pageSize, (p + 1) * pageSize);
const chunkLabels = customLabels?.slice(p * pageSize, (p + 1) * pageSize); const chunkLabels = customLabels?.slice(p * pageSize, (p + 1) * pageSize);
const outPath = pages === 1 ? outputBasePath : `${base}-${p + 1}${ext}`; const outPath = pages === 1 ? outputBasePath : `${base}-${p + 1}${ext}`;
@@ -170,6 +176,7 @@ async function createContactSheetPages(
export async function createScrollContactSheet( export async function createScrollContactSheet(
screenshotsDir: string, screenshotsDir: string,
outputPath: string, outputPath: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> { ): Promise<string[]> {
if (!existsSync(screenshotsDir)) return []; if (!existsSync(screenshotsDir)) return [];
@@ -189,7 +196,7 @@ export async function createScrollContactSheet(
return createContactSheetPages( return createContactSheetPages(
paths, paths,
outputPath, outputPath,
{ cols: 3, cellWidth: 600, pageSize: 9 }, { cols: 3, cellWidth: 600, pageSize: 9, ...budget },
0, 0,
labels, labels,
); );
@@ -203,6 +210,7 @@ export async function createScrollContactSheet(
export async function createSnapshotContactSheet( export async function createSnapshotContactSheet(
snapshotsDir: string, snapshotsDir: string,
outputPath: string, outputPath: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> { ): Promise<string[]> {
if (!existsSync(snapshotsDir)) return []; if (!existsSync(snapshotsDir)) return [];
@@ -222,7 +230,7 @@ export async function createSnapshotContactSheet(
return createContactSheetPages( return createContactSheetPages(
paths, paths,
outputPath, outputPath,
{ cols: 3, cellWidth: 600, pageSize: 9 }, { cols: 3, cellWidth: 600, pageSize: 9, ...budget },
0, 0,
labels, labels,
); );
@@ -236,6 +244,7 @@ export async function createSnapshotContactSheet(
export async function createAssetContactSheet( export async function createAssetContactSheet(
assetsDir: string, assetsDir: string,
outputPath: string, outputPath: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> { ): Promise<string[]> {
if (!existsSync(assetsDir)) return []; if (!existsSync(assetsDir)) return [];
@@ -254,6 +263,7 @@ export async function createAssetContactSheet(
cellWidth: 480, cellWidth: 480,
labelMode: "filename", labelMode: "filename",
pageSize: 12, pageSize: 12,
...budget,
}); });
} }
@@ -271,6 +281,7 @@ export async function createSvgContactSheet(
svgsDir: string, svgsDir: string,
outputPath: string, outputPath: string,
assetsRootDir?: string, assetsRootDir?: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> { ): Promise<string[]> {
const dirsToScan = [svgsDir, assetsRootDir].filter( const dirsToScan = [svgsDir, assetsRootDir].filter(
(d): d is string => d !== undefined && existsSync(d), (d): d is string => d !== undefined && existsSync(d),
@@ -302,6 +313,7 @@ export async function createSvgContactSheet(
const labels: string[] = []; const labels: string[] = [];
for (let i = 0; i < svgPaths.length; i++) { for (let i = 0; i < svgPaths.length; i++) {
if ((budget.remainingMs?.() ?? 1) <= 0) break;
const svgPath = svgPaths[i]!; const svgPath = svgPaths[i]!;
const tmpPath = join(tmpDir, `.thumb-${i}.png`); const tmpPath = join(tmpDir, `.thumb-${i}.png`);
try { try {
@@ -334,6 +346,7 @@ export async function createSvgContactSheet(
cols: 5, cols: 5,
cellWidth: thumbSize, cellWidth: thumbSize,
pageSize: 15, pageSize: 15,
...budget,
}, },
0, 0,
labels, labels,
+125 -38
View File
@@ -15,6 +15,47 @@ import type sharpType from "sharp";
import type { CatalogedAsset } from "./assetCataloger.js"; import type { CatalogedAsset } from "./assetCataloger.js";
import type { DesignTokens } from "./types.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<T>(
request: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_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, * Detect JS libraries via window globals, DOM fingerprints, script URLs,
* and WebGL shader analysis. * and WebGL shader analysis.
@@ -165,12 +206,15 @@ export async function extractVisibleText(page: Page): Promise<string> {
* Batches requests to stay under free-tier rate limits. * Batches requests to stay under free-tier rate limits.
* Returns a map of filename -> caption string. * Returns a map of filename -> caption string.
*/ */
// fallow-ignore-next-line complexity
export async function captionImagesWithGemini( export async function captionImagesWithGemini(
outputDir: string, outputDir: string,
progress: (stage: string, detail?: string) => void, progress: (stage: string, detail?: string) => void,
warnings: string[], warnings: string[],
options: VisionCaptionOptions = {},
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const geminiCaptions: Record<string, string> = {}; const geminiCaptions: Record<string, string> = {};
if (options.skipVision) return geminiCaptions;
const openRouterKey = process.env.OPENROUTER_API_KEY; const openRouterKey = process.env.OPENROUTER_API_KEY;
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!openRouterKey && !geminiKey) return geminiCaptions; if (!openRouterKey && !geminiKey) return geminiCaptions;
@@ -186,6 +230,7 @@ export async function captionImagesWithGemini(
const model = useOpenRouter const model = useOpenRouter
? process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite" ? process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite"
: process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview"; : process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
const requestTimeoutMs = resolveVisionRequestTimeoutMs();
progress("design", `Captioning images with ${providerName} vision...`); progress("design", `Captioning images with ${providerName} vision...`);
try { try {
@@ -196,31 +241,40 @@ export async function captionImagesWithGemini(
base64: string; base64: string;
prompt: string; prompt: string;
maxTokens: number; maxTokens: number;
timeoutMs: number;
}) => Promise<string>; }) => Promise<string>;
let captionOne: CaptionOne; let captionOne: CaptionOne;
if (openRouterKey) { if (openRouterKey) {
captionOne = async ({ mimeType, base64, prompt, maxTokens }) => { captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => {
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { const res = await runBoundedVisionRequest(
method: "POST", (signal) =>
headers: { fetch("https://openrouter.ai/api/v1/chat/completions", {
Authorization: `Bearer ${openRouterKey}`, method: "POST",
"Content-Type": "application/json", headers: {
}, Authorization: `Bearer ${openRouterKey}`,
body: JSON.stringify({ "Content-Type": "application/json",
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64}` } },
],
}, },
], signal,
max_tokens: maxTokens, 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) { if (!res.ok) {
const detail = await res.text().catch(() => ""); const detail = await res.text().catch(() => "");
throw new Error(`OpenRouter ${res.status} ${res.statusText}: ${detail.slice(0, 200)}`); throw new Error(`OpenRouter ${res.status} ${res.statusText}: ${detail.slice(0, 200)}`);
@@ -235,14 +289,25 @@ export async function captionImagesWithGemini(
if (!geminiKey) return geminiCaptions; if (!geminiKey) return geminiCaptions;
const { GoogleGenAI } = await import("@google/genai"); const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: geminiKey }); const ai = new GoogleGenAI({ apiKey: geminiKey });
captionOne = async ({ mimeType, base64, prompt, maxTokens }) => { captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => {
const response = await ai.models.generateContent({ const response = await runBoundedVisionRequest(
model, (signal) =>
contents: [ ai.models.generateContent({
{ role: "user", parts: [{ inlineData: { mimeType, data: base64 } }, { text: prompt }] }, model,
], contents: [
config: { maxOutputTokens: maxTokens }, {
}); role: "user",
parts: [{ inlineData: { mimeType, data: base64 } }, { text: prompt }],
},
],
config: {
maxOutputTokens: maxTokens,
abortSignal: signal,
httpOptions: { timeout: timeoutMs },
},
}),
timeoutMs,
);
return response.text?.trim() || ""; return response.text?.trim() || "";
}; };
} }
@@ -256,7 +321,27 @@ export async function captionImagesWithGemini(
// on Promise.allSettled so a rate-limited image degrades to "" rather than // on Promise.allSettled so a rate-limited image degrades to "" rather than
// failing the batch. // failing the batch.
const BATCH_SIZE = 20; 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) { 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 batch = imageFiles.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled( const results = await Promise.allSettled(
batch.map(async (file: string) => { batch.map(async (file: string) => {
@@ -273,15 +358,12 @@ export async function captionImagesWithGemini(
prompt: 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.", "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, maxTokens: 500,
timeoutMs,
}); });
return { file, caption }; return { file, caption };
}), }),
); );
for (const result of results) { timedOutCount += collectCaptionResults(results);
if (result.status === "fulfilled" && result.value.caption) {
geminiCaptions[result.value.file] = result.value.caption;
}
}
// Pace requests between batches (paid tier: 2000+ RPM, free tier: rate-limited) // Pace requests between batches (paid tier: 2000+ RPM, free tier: rate-limited)
if (i + BATCH_SIZE < imageFiles.length) { 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 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 const SVG_RENDER_SIZE = 256; // px — enough resolution for Gemini to read wordmarks, small enough to keep payload sub-MB
let svgsSkipped = 0; let svgsSkipped = 0;
for (let i = 0; i < svgFiles.length; i += SVG_BATCH) { 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 batch = svgFiles.slice(i, i + SVG_BATCH);
const results = await Promise.allSettled( const results = await Promise.allSettled(
batch.map(async ({ relPath }) => { 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. " + "If you see a wordmark, READ THE LETTERS LITERALLY — do not guess a brand from context. " +
"Be factual.", "Be factual.",
maxTokens: 300, maxTokens: 300,
timeoutMs,
}); });
return { file: relPath, caption }; return { file: relPath, caption };
}), }),
); );
for (const result of results) { timedOutCount += collectCaptionResults(results);
if (result.status === "fulfilled" && result.value.caption) {
geminiCaptions[result.value.file] = result.value.caption;
}
}
if (i + SVG_BATCH < svgFiles.length) { if (i + SVG_BATCH < svgFiles.length) {
await new Promise((r) => setTimeout(r, 2000)); 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) { } catch (err) {
warnings.push(`${providerName} captioning failed: ${err}`); warnings.push(`${providerName} captioning failed: ${err}`);
} }
+191 -55
View File
@@ -39,10 +39,12 @@ import {
generateAssetDescriptions, generateAssetDescriptions,
} from "./contentExtractor.js"; } from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.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"; export type { CaptureOptions, CaptureResult } from "./types.js";
const DEFAULT_POST_NAVIGATION_BUDGET_MS = 120_000;
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
export async function captureWebsite( export async function captureWebsite(
opts: CaptureOptions, opts: CaptureOptions,
@@ -57,12 +59,49 @@ export async function captureWebsite(
settleTime = 3000, settleTime = 3000,
maxScreenshots: _maxScreenshots = 24, maxScreenshots: _maxScreenshots = 24,
skipAssets = false, skipAssets = false,
skipVision = false,
postNavigationBudgetMs = DEFAULT_POST_NAVIGATION_BUDGET_MS,
onPhase,
} = opts; } = opts;
const warnings: string[] = []; const warnings: string[] = [];
const progress = (stage: string, detail?: string) => { const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail); 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.) // Load .env file from repo root if it exists (for GEMINI_API_KEY, etc.)
loadEnvFile(outputDir); loadEnvFile(outputDir);
@@ -102,6 +141,8 @@ export async function captureWebsite(
// Goal: Catalog animations + take screenshots (with JS rendering) // Goal: Catalog animations + take screenshots (with JS rendering)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
phase("browser", "completed");
phase("navigation", "started");
progress("animations", "Cataloging animations (full JS)..."); progress("animations", "Cataloging animations (full JS)...");
const page1 = await chromeBrowser.newPage(); const page1 = await chromeBrowser.newPage();
@@ -198,7 +239,10 @@ export async function captureWebsite(
// modern SPAs often have persistent WebSocket/analytics connections that // modern SPAs often have persistent WebSocket/analytics connections that
// prevent networkidle0 from ever resolving. // prevent networkidle0 from ever resolving.
await page1.goto(url, { waitUntil: "networkidle2", timeout }); await page1.goto(url, { waitUntil: "networkidle2", timeout });
postNavigationDeadline = Date.now() + budgetMs;
await new Promise((r) => setTimeout(r, settleTime)); 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 // Check if the page loaded real content or an anti-bot challenge
// Use structural detection (DOM elements + cookies), not text regex matching — // 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 // Framer and other modern sites use IntersectionObserver — images only load
// when scrolled into view. We scroll the full page, then wait for all images // when scrolled into view. We scroll the full page, then wait for all images
// to finish loading before proceeding. // 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; var h = document.body.scrollHeight;
for (var y = 0; y < h; y += window.innerHeight * 0.7) { for (var y = 0; y < h; y += window.innerHeight * 0.7) {
if (Date.now() >= lazyLoadDeadline) break;
window.scrollTo(0, y); window.scrollTo(0, y);
await new Promise(function(r) { setTimeout(r, 400); }); await new Promise(function(r) { setTimeout(r, 400); });
} }
// Scroll to very bottom to catch footer lazy-loads // Scroll to very bottom to catch footer lazy-loads
window.scrollTo(0, document.body.scrollHeight); if (Date.now() < lazyLoadDeadline) {
await new Promise(function(r) { setTimeout(r, 800); }); 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 // Wait for all images to finish loading
var imgs = Array.from(document.querySelectorAll('img')); var imgs = Array.from(document.querySelectorAll('img'));
var pending = imgs.filter(function(img) { return !img.complete; }); 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([ await Promise.race([
Promise.all(pending.map(function(img) { Promise.all(pending.map(function(img) {
return new Promise(function(r) { img.onload = r; img.onerror = r; }); 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); window.scrollTo(0, 0);
await new Promise(function(r) { setTimeout(r, 500); });
})()`); })()`);
}
await page1.evaluate(`window.scrollTo(0, 0)`); await page1.evaluate(`window.scrollTo(0, 0)`);
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
@@ -289,12 +340,12 @@ export async function captureWebsite(
/* DOM scan failed — non-critical */ /* DOM scan failed — non-critical */
} }
if (discoveredLotties.length > 0) { if (discoveredLotties.length > 0 && remainingMs() > 0) {
const lottieDir = join(outputDir, "assets", "lottie"); const lottieDir = join(outputDir, "assets", "lottie");
mkdirSync(lottieDir, { recursive: true }); mkdirSync(lottieDir, { recursive: true });
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir); const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is // 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); await renderLottiePreviews(chromeBrowser, lottieDir, outputDir);
progress("lottie", `${savedCount} Lottie animation(s) saved`); progress("lottie", `${savedCount} Lottie animation(s) saved`);
} }
@@ -368,7 +419,7 @@ export async function captureWebsite(
// Capture scroll-position viewport screenshots // Capture scroll-position viewport screenshots
progress("screenshots", "Capturing scroll screenshots..."); progress("screenshots", "Capturing scroll screenshots...");
const { captureScrollScreenshots } = await import("./screenshotCapture.js"); 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`); progress("screenshots", `${screenshots.length} scroll screenshots captured`);
// Catalog all assets (must run before extractHtml which converts img src to data URLs) // 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 <video> element + extract surrounding context // 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. // so Claude Code can SEE what each video shows and WHERE it was used on the page.
try { try {
await captureVideoManifest(page1, outputDir, progress, { const videoBudgetMs = remainingMs();
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling) if (videoBudgetMs > 0) {
sampleMs: 12000, // Layer 2: poll DOM ≤12s so auto-rotating carousels reveal each slide 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,
});
}
} catch { } catch {
/* non-blocking — video manifest is best-effort */ /* non-blocking — video manifest is best-effort */
} }
@@ -459,8 +514,25 @@ export async function captureWebsite(
await page1.close(); await page1.close();
phase("core-extraction", "completed");
// Download fonts and rewrite URLs to local paths // Download fonts and rewrite URLs to local paths
extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir); if (remainingMs() > 0) {
phase("fonts", "started");
extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
remainingMs,
});
phase(
"fonts",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
} else {
warnings.push(
"Capture budget exhausted before font downloads; extracted font tokens were preserved.",
);
phase("fonts", "degraded", "budget-exhausted");
}
// Identify each downloaded font by reading its OpenType name table. // Identify each downloaded font by reading its OpenType name table.
// Modern frameworks hash font filenames; this manifest tells the // Modern frameworks hash font filenames; this manifest tells the
@@ -518,8 +590,23 @@ export async function captureWebsite(
// Download assets — single pass using the catalog for best image quality // Download assets — single pass using the catalog for best image quality
let assets: CaptureResult["assets"] = []; let assets: CaptureResult["assets"] = [];
if (!skipAssets) { if (!skipAssets) {
progress("assets", "Downloading assets..."); if (remainingMs() > 0) {
assets = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks); phase("assets", "started");
progress("assets", "Downloading assets...");
assets = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks, {
remainingMs,
});
phase(
"assets",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
} else {
warnings.push("Capture budget exhausted before asset downloads; extraction continued.");
phase("assets", "degraded", "budget-exhausted");
}
} else {
phase("assets", "degraded", "disabled");
} }
// Join in-section media URLs → downloaded local paths, then re-write // Join in-section media URLs → downloaded local paths, then re-write
@@ -573,7 +660,25 @@ export async function captureWebsite(
// detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing // detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing
// AI-powered image captioning via Gemini (optional — enriches asset descriptions) // AI-powered image captioning via Gemini (optional — enriches asset descriptions)
const geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings); 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");
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
remainingMs,
});
phase(
"vision",
remainingMs() > 0 ? "completed" : "degraded",
remainingMs() > 0 ? undefined : "budget-exhausted",
);
}
// Generate asset descriptions for the AI agent // Generate asset descriptions for the AI agent
progress("design", "Generating asset descriptions..."); progress("design", "Generating asset descriptions...");
@@ -581,8 +686,13 @@ export async function captureWebsite(
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions); const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
if (lines.length > 0) { if (lines.length > 0) {
const hasGeminiKey = !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY); const hasVisionKey = !!(
const header = hasGeminiKey !skipVision &&
(process.env.OPENROUTER_API_KEY ||
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY)
);
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\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file — that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim — many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n"
: "# Asset Descriptions\n\n⚠️ GEMINI_API_KEY not set — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY) and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n"; : "# Asset Descriptions\n\n⚠️ GEMINI_API_KEY not set — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY) and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n";
writeFileSync( writeFileSync(
@@ -592,7 +702,7 @@ export async function captureWebsite(
); );
progress( progress(
"design", "design",
`${lines.length} asset descriptions written${hasGeminiKey ? "" : " (no Gemini key — catalog-fallback mode)"}`, `${lines.length} asset descriptions written${hasVisionKey ? "" : " (no vision provider — catalog-fallback mode)"}`,
); );
} }
} catch { } catch {
@@ -603,51 +713,74 @@ export async function captureWebsite(
// Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually) // Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually)
// All functions return string[] — paginated so every image is covered // All functions return string[] — paginated so every image is covered
try { if (remainingMs() > 0) {
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } = phase("contact-sheets", "started");
await import("./contactSheet.js"); try {
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
await import("./contactSheet.js");
const scrollSheets = await createScrollContactSheet( const contactSheetBudget = { remainingMs };
join(outputDir, "screenshots"),
join(outputDir, "screenshots", "contact-sheet.jpg"),
);
if (scrollSheets.length > 0)
progress(
"design",
`Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
);
const assetsImgDir = join(outputDir, "assets"); const scrollSheets = await createScrollContactSheet(
if (existsSync(assetsImgDir)) { join(outputDir, "screenshots"),
const assetSheets = await createAssetContactSheet( join(outputDir, "screenshots", "contact-sheet.jpg"),
assetsImgDir, contactSheetBudget,
join(outputDir, "assets", "contact-sheet.jpg"),
); );
if (assetSheets.length > 0) if (scrollSheets.length > 0)
progress( progress(
"design", "design",
`Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`, `Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
); );
}
// Scan assets/svgs/ (inline SVGs) AND assets/ root (external SVGs from <img src="*.svg">) const assetsImgDir = join(outputDir, "assets");
// so sites like huly.io that only use external SVGs still get a grid if (existsSync(assetsImgDir)) {
const svgsDir = join(outputDir, "assets", "svgs"); const assetSheets = await createAssetContactSheet(
const assetsRootDir = join(outputDir, "assets"); assetsImgDir,
const svgOutputPath = existsSync(svgsDir) join(outputDir, "assets", "contact-sheet.jpg"),
? join(outputDir, "assets", "svgs", "contact-sheet.jpg") contactSheetBudget,
: join(outputDir, "assets", "contact-sheet-svgs.jpg"); );
const svgSheets = await createSvgContactSheet(svgsDir, svgOutputPath, assetsRootDir); if (assetSheets.length > 0)
if (svgSheets.length > 0) progress(
progress( "design",
"design", `Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`,
`SVG contact sheet generated (${svgSheets.length} page${svgSheets.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,
); );
} catch { if (svgSheets.length > 0)
/* contact sheets are non-critical — agent can still read images individually */ 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) // Generate project scaffold (index.html, meta.json, CLAUDE.md)
phase("scaffold", "started");
await generateProjectScaffold( await generateProjectScaffold(
outputDir, outputDir,
url, url,
@@ -661,8 +794,10 @@ export async function captureWebsite(
warnings, warnings,
detectedLibraries, detectedLibraries,
); );
phase("scaffold", "completed");
progress("done", "Capture complete"); progress("done", "Capture complete");
phase("complete", "completed");
return { return {
ok: true, ok: true,
@@ -675,6 +810,7 @@ export async function captureWebsite(
assets, assets,
animationCatalog, animationCatalog,
warnings, warnings,
lastPhase,
}; };
} finally { } finally {
await chromeBrowser.close(); await chromeBrowser.close();
@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("./index.ts", import.meta.url), "utf-8");
describe("website capture post-navigation budget", () => {
it("bounds the lazy-load scroll loop by the shared remaining budget", () => {
expect(source).toContain("const lazyLoadBudgetMs = Math.min(15_000, remainingMs());");
expect(source).toContain("var lazyLoadDeadline = Date.now() + ${lazyLoadBudgetMs};");
expect(source).toContain("if (Date.now() >= lazyLoadDeadline) break;");
});
});
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { remainingVideoDownloadTimeoutMs } from "./mediaCapture.js";
const source = readFileSync(new URL("./mediaCapture.ts", import.meta.url), "utf-8");
describe("remainingVideoDownloadTimeoutMs", () => {
it("caps a video request to the remaining capture budget", () => {
expect(remainingVideoDownloadTimeoutMs(1_000, 5_000, 4_500)).toBe(1_500);
});
it("returns zero after the aggregate budget is exhausted", () => {
expect(remainingVideoDownloadTimeoutMs(1_000, 5_000, 6_001)).toBe(0);
});
it("retains the existing per-request ceiling when more budget remains", () => {
expect(remainingVideoDownloadTimeoutMs(1_000, 300_000, 2_000)).toBe(120_000);
});
it("checks the aggregate budget before starting each preview/download iteration", () => {
expect(source).toContain(
"if (remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs) <= 0) break;",
);
});
});
+15 -3
View File
@@ -216,6 +216,15 @@ export async function renderLottiePreviews(
const MAX_VIDEO_BYTES = 75 * 1024 * 1024; // 75 MB — hero/demo clips, not full films const MAX_VIDEO_BYTES = 75 * 1024 * 1024; // 75 MB — hero/demo clips, not full films
const DOWNLOADABLE_VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".m4v"]); const DOWNLOADABLE_VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".m4v"]);
const VIDEO_DOWNLOAD_TIMEOUT_MS = 120_000;
export function remainingVideoDownloadTimeoutMs(
budgetStartedAt: number,
budgetMs: number,
now = Date.now(),
): number {
return Math.max(0, Math.min(VIDEO_DOWNLOAD_TIMEOUT_MS, budgetMs - (now - budgetStartedAt)));
}
/** /**
* Download a <video> body to assets/videos/<file>, returning the * Download a <video> body to assets/videos/<file>, returning the
@@ -234,6 +243,7 @@ async function downloadVideoBody(
srcUrl: string, srcUrl: string,
filename: string, filename: string,
videosDir: string, videosDir: string,
timeoutMs: number,
): Promise<string | null> { ): Promise<string | null> {
if (isPrivateUrl(srcUrl)) return null; // cheap pre-check; safeFetch re-checks every hop if (isPrivateUrl(srcUrl)) return null; // cheap pre-check; safeFetch re-checks every hop
let ext = ""; let ext = "";
@@ -247,7 +257,7 @@ async function downloadVideoBody(
// safeFetch resolves redirects manually and re-runs isPrivateUrl on each // safeFetch resolves redirects manually and re-runs isPrivateUrl on each
// Location hop, so a public URL cannot 30x to an internal/metadata host. // Location hop, so a public URL cannot 30x to an internal/metadata host.
const res = await safeFetch(srcUrl, { const res = await safeFetch(srcUrl, {
signal: AbortSignal.timeout(120000), // up to ~75 MB on a slow link; aborts cleanly → still-frame fallback signal: AbortSignal.timeout(timeoutMs), // bounded by both per-request and aggregate capture budgets
headers: { "User-Agent": "HyperFrames/1.0" }, headers: { "User-Agent": "HyperFrames/1.0" },
}); });
if (!res || !res.ok || !res.body) return null; if (!res || !res.ok || !res.body) return null;
@@ -462,6 +472,7 @@ export async function captureVideoManifest(
const dlStart = Date.now(); const dlStart = Date.now();
for (let vi = 0; vi < merged.length && vi < 20; vi++) { for (let vi = 0; vi < merged.length && vi < 20; vi++) {
if (remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs) <= 0) break;
const v = merged[vi]!; const v = merged[vi]!;
let preview: string | undefined; let preview: string | undefined;
@@ -505,9 +516,10 @@ export async function captureVideoManifest(
// direct file. Cumulative budget caps total download time so a throttled // direct file. Cumulative budget caps total download time so a throttled
// host or many large clips can't stall capture — over budget, keep the // host or many large clips can't stall capture — over budget, keep the
// preview (if any) and stop fetching bodies. // preview (if any) and stop fetching bodies.
const downloadTimeoutMs = remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs);
const savedPath = const savedPath =
Date.now() - dlStart < downloadBudgetMs downloadTimeoutMs > 0
? await downloadVideoBody(v.src, v.filename, videoManifestDir) ? await downloadVideoBody(v.src, v.filename, videoManifestDir, downloadTimeoutMs)
: null; : null;
// A network-only video with neither a preview nor a downloaded body carries // A network-only video with neither a preview nor a downloaded body carries
@@ -3,7 +3,12 @@ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Page } from "puppeteer-core"; import type { Page } from "puppeteer-core";
import { captureFullPagePlate, MAX_PLATE_HEIGHT_PX, pngHeight } from "./screenshotCapture.js"; import {
captureFullPagePlate,
captureScrollScreenshots,
MAX_PLATE_HEIGHT_PX,
pngHeight,
} from "./screenshotCapture.js";
// A real 1920x800 PNG header, so the produced-height guard sees something valid. // A real 1920x800 PNG header, so the produced-height guard sees something valid.
function pngBuffer(height: number, width = 1920): Buffer { function pngBuffer(height: number, width = 1920): Buffer {
@@ -104,6 +109,21 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
}); });
}); });
describe("captureScrollScreenshots — capture budget", () => {
it("does not begin page work when the post-navigation budget is exhausted", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-scroll-budget-"));
const evaluate = vi.fn(async () => 1080);
const screenshot = vi.fn(async () => pngBuffer(1080));
const page = { evaluate, screenshot } as unknown as Page;
const files = await captureScrollScreenshots(page, dir, { remainingMs: () => 0 });
expect(files).toEqual([]);
expect(evaluate).not.toHaveBeenCalled();
expect(screenshot).not.toHaveBeenCalled();
});
});
describe("captureFullPagePlate — guards against a silently clipped plate", () => { describe("captureFullPagePlate — guards against a silently clipped plate", () => {
it("measures the height itself, after lazy content has grown the page", async () => { it("measures the height itself, after lazy content has grown the page", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-plate-")); const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
+13 -3
View File
@@ -107,13 +107,20 @@ export async function captureFullPagePlate(
} }
} }
export async function captureScrollScreenshots(page: Page, outputDir: string): Promise<string[]> { // fallow-ignore-next-line complexity
export async function captureScrollScreenshots(
page: Page,
outputDir: string,
budget: { remainingMs?: () => number } = {},
): Promise<string[]> {
const screenshotsDir = join(outputDir, "screenshots"); const screenshotsDir = join(outputDir, "screenshots");
mkdirSync(screenshotsDir, { recursive: true }); mkdirSync(screenshotsDir, { recursive: true });
const MAX_SCREENSHOTS = 20; const MAX_SCREENSHOTS = 20;
const filePaths: string[] = []; const filePaths: string[] = [];
if ((budget.remainingMs?.() ?? 1) <= 0) return filePaths;
try { try {
// Dismiss marketing banners, cookie consents, and popups before scrolling. // Dismiss marketing banners, cookie consents, and popups before scrolling.
// These overlay content and contaminate screenshots with UI that doesn't // These overlay content and contaminate screenshots with UI that doesn't
@@ -220,6 +227,7 @@ export async function captureScrollScreenshots(page: Page, outputDir: string): P
} }
for (let i = 0; i < finalPositions.length; i++) { for (let i = 0; i < finalPositions.length; i++) {
if ((budget.remainingMs?.() ?? 1) <= 0) break;
await page.evaluate(`window.scrollTo(0, ${finalPositions[i]})`); await page.evaluate(`window.scrollTo(0, ${finalPositions[i]})`);
await new Promise((r) => setTimeout(r, 400)); await new Promise((r) => setTimeout(r, 400));
@@ -242,8 +250,10 @@ export async function captureScrollScreenshots(page: Page, outputDir: string): P
// dropped because 1/8 agents read it and the contact sheet covered the same ground — that // dropped because 1/8 agents read it and the contact sheet covered the same ground — that
// was about it as a *comprehension* artifact. The scroll shot is a different consumer: it // was about it as a *comprehension* artifact. The scroll shot is a different consumer: it
// needs one continuous plate, which no set of viewport tiles can substitute for.) // needs one continuous plate, which no set of viewport tiles can substitute for.)
const plate = await captureFullPagePlate(page, screenshotsDir); if ((budget.remainingMs?.() ?? 1) > 0) {
if (plate) filePaths.push(plate); const plate = await captureFullPagePlate(page, screenshotsDir);
if (plate) filePaths.push(plate);
}
} catch { } catch {
/* scroll screenshots are non-critical */ /* scroll screenshots are non-critical */
} }
+28
View File
@@ -9,6 +9,26 @@
// ── Phase 1: Capture ──────────────────────────────────────────────────────── // ── Phase 1: Capture ────────────────────────────────────────────────────────
export type CapturePhase =
| "browser"
| "navigation"
| "core-extraction"
| "fonts"
| "assets"
| "vision"
| "contact-sheets"
| "scaffold"
| "complete";
export interface CapturePhaseProgress {
schema: "hyperframes.capture.phase.v1";
phase: CapturePhase;
status: "started" | "completed" | "degraded";
/** Null before the post-navigation budget begins. */
remainingMs: number | null;
reason?: "budget-exhausted" | "disabled";
}
export interface CaptureOptions { export interface CaptureOptions {
/** URL to capture */ /** URL to capture */
url: string; url: string;
@@ -26,6 +46,12 @@ export interface CaptureOptions {
maxScreenshots?: number; maxScreenshots?: number;
/** Skip asset downloads */ /** Skip asset downloads */
skipAssets?: boolean; skipAssets?: boolean;
/** Skip optional vision captioning */
skipVision?: boolean;
/** Cooperative post-navigation budget in ms (default: 120000). */
postNavigationBudgetMs?: number;
/** Stable, non-sensitive progress records for watchdog diagnostics. */
onPhase?: (event: CapturePhaseProgress) => void;
/** Output JSON for programmatic use */ /** Output JSON for programmatic use */
json?: boolean; json?: boolean;
} }
@@ -51,6 +77,8 @@ export interface CaptureResult {
animationCatalog?: import("./animationCataloger.js").AnimationCatalog; animationCatalog?: import("./animationCataloger.js").AnimationCatalog;
/** Errors/warnings encountered during capture */ /** Errors/warnings encountered during capture */
warnings: string[]; warnings: string[];
/** Final structured phase record emitted by a successful capture. */
lastPhase: CapturePhaseProgress;
} }
export interface ExtractedHtml { export interface ExtractedHtml {
+16
View File
@@ -3,6 +3,14 @@ import { defineCommand } from "citty";
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { Example } from "./_examples.js"; import type { Example } from "./_examples.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { diag } from "../ui/diagnostics.js";
import type { CapturePhaseProgress } from "../capture/types.js";
const CAPTURE_PHASE_PREFIX = "HYPERFRAMES_CAPTURE_PHASE ";
function emitCapturePhase(event: CapturePhaseProgress): void {
diag.notice(`${CAPTURE_PHASE_PREFIX}${JSON.stringify(event)}`);
}
export const examples: Example[] = [ export const examples: Example[] = [
["Capture a website into ./capture/", "hyperframes capture https://stripe.com"], ["Capture a website into ./capture/", "hyperframes capture https://stripe.com"],
@@ -39,6 +47,11 @@ export default defineCommand({
description: "Skip downloading assets (images, SVGs)", description: "Skip downloading assets (images, SVGs)",
default: false, default: false,
}, },
"skip-vision": {
type: "boolean",
description: "Skip optional AI image captioning",
default: false,
},
"max-screenshots": { "max-screenshots": {
type: "string", type: "string",
description: "Maximum screenshots to capture (default: 24)", description: "Maximum screenshots to capture (default: 24)",
@@ -139,11 +152,13 @@ export default defineCommand({
url, url,
outputDir, outputDir,
skipAssets: args["skip-assets"] as boolean, skipAssets: args["skip-assets"] as boolean,
skipVision: args["skip-vision"] as boolean,
maxScreenshots: args["max-screenshots"] maxScreenshots: args["max-screenshots"]
? parseInt(args["max-screenshots"] as string) ? parseInt(args["max-screenshots"] as string)
: undefined, : undefined,
timeout: args.timeout ? parseInt(args.timeout as string) : undefined, timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
json: isJson, json: isJson,
onPhase: emitCapturePhase,
}, },
isJson isJson
? undefined ? undefined
@@ -179,6 +194,7 @@ export default defineCommand({
fontsDetailed: result.tokens.fonts, fontsDetailed: result.tokens.fonts,
animations: result.animationCatalog?.summary, animations: result.animationCatalog?.summary,
warnings: result.warnings, warnings: result.warnings,
lastPhase: result.lastPhase,
}, },
null, null,
2, 2,