mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(cli): bound capture runtime stages
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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-"));
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user