fix(cli): propagate live capture budget

This commit is contained in:
Miguel Ángel
2026-07-31 20:30:07 +00:00
parent 45aead84c9
commit 49091e6142
11 changed files with 393 additions and 57 deletions
@@ -2,7 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { captionImagesWithGemini } from "./contentExtractor.js"; import {
captionImagesWithGemini,
resolveVisionPhaseCompletion,
type VisionCaptionOutcome,
} from "./contentExtractor.js";
const { generateContentMock } = vi.hoisted(() => ({ const { generateContentMock } = vi.hoisted(() => ({
generateContentMock: vi.fn(), generateContentMock: vi.fn(),
@@ -151,9 +155,20 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
); );
const warnings: string[] = []; const warnings: string[] = [];
const captions = await captionImagesWithGemini(dir, () => {}, warnings); let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
});
expect(captions).toEqual({ "success.png": "Successful image." }); expect(captions).toEqual({ "success.png": "Successful image." });
expect(outcome).toEqual({ timedOutRequests: 1, budgetExhausted: false });
if (!outcome) throw new Error("Expected vision caption outcome");
expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({
status: "degraded",
reason: "request-timeout",
});
}); });
it("does not call a configured provider when vision is skipped", async () => { it("does not call a configured provider when vision is skipped", async () => {
+44 -5
View File
@@ -17,9 +17,30 @@ import type { DesignTokens } from "./types.js";
const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000;
export interface VisionCaptionOutcome {
timedOutRequests: number;
budgetExhausted: boolean;
}
interface VisionCaptionOptions { interface VisionCaptionOptions {
skipVision?: boolean; skipVision?: boolean;
remainingMs?: () => number; remainingMs?: () => number;
onOutcome?: (outcome: VisionCaptionOutcome) => void;
}
export function resolveVisionPhaseCompletion(
outcome: VisionCaptionOutcome,
remainingMs: number,
):
| { status: "completed" }
| { status: "degraded"; reason: "budget-exhausted" | "request-timeout" } {
if (outcome.budgetExhausted || remainingMs <= 0) {
return { status: "degraded", reason: "budget-exhausted" };
}
if (outcome.timedOutRequests > 0) {
return { status: "degraded", reason: "request-timeout" };
}
return { status: "completed" };
} }
class VisionRequestTimeoutError extends Error { class VisionRequestTimeoutError extends Error {
@@ -214,10 +235,21 @@ export async function captionImagesWithGemini(
options: VisionCaptionOptions = {}, 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; let timedOutCount = 0;
let budgetExhausted = false;
const reportOutcome = (): void => {
options.onOutcome?.({ timedOutRequests: timedOutCount, budgetExhausted });
};
if (options.skipVision) {
reportOutcome();
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) {
reportOutcome();
return geminiCaptions;
}
// OpenRouter takes priority when both keys are set — it's the explicit opt-in // OpenRouter takes priority when both keys are set — it's the explicit opt-in
// for users without Google access. Both providers satisfy the same // for users without Google access. Both providers satisfy the same
@@ -321,7 +353,6 @@ 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 = ( const collectCaptionResults = (
results: PromiseSettledResult<{ file: string; caption: string }>[], results: PromiseSettledResult<{ file: string; caption: string }>[],
): number => { ): number => {
@@ -340,7 +371,10 @@ export async function captionImagesWithGemini(
}; };
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; const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY;
if (remainingMs <= 0) break; if (remainingMs <= 0) {
budgetExhausted = true;
break;
}
const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); 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(
@@ -404,6 +438,7 @@ export async function captionImagesWithGemini(
`Skipped ${svgFiles.length} SVG caption(s): sharp could not load (${(err as Error).message}). ` + `Skipped ${svgFiles.length} SVG caption(s): sharp could not load (${(err as Error).message}). ` +
`Reinstall with optional dependencies enabled (e.g. \`npm i sharp\`) to caption SVG assets.`, `Reinstall with optional dependencies enabled (e.g. \`npm i sharp\`) to caption SVG assets.`,
); );
reportOutcome();
return geminiCaptions; return geminiCaptions;
} }
progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`); progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`);
@@ -412,7 +447,10 @@ export async function captionImagesWithGemini(
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; const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY;
if (remainingMs <= 0) break; if (remainingMs <= 0) {
budgetExhausted = true;
break;
}
const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); 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(
@@ -489,6 +527,7 @@ export async function captionImagesWithGemini(
warnings.push(`${providerName} captioning failed: ${err}`); warnings.push(`${providerName} captioning failed: ${err}`);
} }
reportOutcome();
return geminiCaptions; return geminiCaptions;
} }
+16 -4
View File
@@ -37,7 +37,9 @@ import {
extractVisibleText, extractVisibleText,
captionImagesWithGemini, captionImagesWithGemini,
generateAssetDescriptions, generateAssetDescriptions,
resolveVisionPhaseCompletion,
} from "./contentExtractor.js"; } from "./contentExtractor.js";
import type { VisionCaptionOutcome } from "./contentExtractor.js";
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js"; import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js"; import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js";
@@ -343,10 +345,11 @@ export async function captureWebsite(
if (discoveredLotties.length > 0 && remainingMs() > 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 lottieBudget = { remainingMs };
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
// 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 && remainingMs() > 0) { if (savedCount > 0 && remainingMs() > 0) {
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir); await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
progress("lottie", `${savedCount} Lottie animation(s) saved`); progress("lottie", `${savedCount} Lottie animation(s) saved`);
} }
} }
@@ -494,6 +497,7 @@ export async function captureWebsite(
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling) networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget
downloadBudgetMs: videoBudgetMs, downloadBudgetMs: videoBudgetMs,
remainingMs,
}); });
} }
} catch { } catch {
@@ -670,13 +674,21 @@ export async function captureWebsite(
phase("vision", "degraded", "budget-exhausted"); phase("vision", "degraded", "budget-exhausted");
} else { } else {
phase("vision", "started"); phase("vision", "started");
let visionOutcome: VisionCaptionOutcome = {
timedOutRequests: 0,
budgetExhausted: false,
};
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, { geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
remainingMs, remainingMs,
onOutcome: (outcome) => {
visionOutcome = outcome;
},
}); });
const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs());
phase( phase(
"vision", "vision",
remainingMs() > 0 ? "completed" : "degraded", completion.status,
remainingMs() > 0 ? undefined : "budget-exhausted", completion.status === "degraded" ? completion.reason : undefined,
); );
} }
@@ -1,12 +0,0 @@
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;");
});
});
+123 -10
View File
@@ -1,8 +1,127 @@
import { readFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { tmpdir } from "node:os";
import { remainingVideoDownloadTimeoutMs } from "./mediaCapture.js"; import { join } from "node:path";
import type { Browser, Page } from "puppeteer-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
captureVideoManifest,
remainingVideoDownloadTimeoutMs,
renderLottiePreviews,
saveLottieAnimations,
} from "./mediaCapture.js";
const source = readFileSync(new URL("./mediaCapture.ts", import.meta.url), "utf-8"); const tempDirs: string[] = [];
function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-media-budget-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
tempDirs.length = 0;
});
describe("Lottie capture budget", () => {
it("does not start another Lottie fetch after the live budget expires", async () => {
const dir = tempDir();
let remainingMs = 10_000;
const fetchMock = vi.fn(async () => {
remainingMs = 0;
return new Response(JSON.stringify({ w: 100, h: 100, layers: [] }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const saved = await saveLottieAnimations(
[{ url: "https://one.example/a.json" }, { url: "https://two.example/b.json" }],
dir,
{ remainingMs: () => remainingMs },
);
expect(saved).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not open another Lottie preview page after the live budget expires", async () => {
const dir = tempDir();
const lottieDir = join(dir, "assets", "lottie");
mkdirSync(join(dir, "extracted"), { recursive: true });
mkdirSync(lottieDir, { recursive: true });
const lottie = JSON.stringify({ w: 100, h: 100, fr: 30, ip: 0, op: 30, layers: [] });
writeFileSync(join(lottieDir, "a.json"), lottie);
writeFileSync(join(lottieDir, "b.json"), lottie);
let remainingMs = 10_000;
const previewPage = {
setViewport: vi.fn(async () => undefined),
setContent: vi.fn(async () => undefined),
evaluate: vi.fn(async () => undefined),
waitForFunction: vi.fn(async () => undefined),
screenshot: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
};
const newPage = vi.fn(async () => {
remainingMs = 0;
return previewPage;
});
const browser = { newPage } as unknown as Browser;
await renderLottiePreviews(browser, lottieDir, dir, { remainingMs: () => remainingMs });
expect(newPage).toHaveBeenCalledTimes(1);
expect(previewPage.setViewport).not.toHaveBeenCalled();
expect(previewPage.screenshot).not.toHaveBeenCalled();
});
});
describe("video capture live budget", () => {
it("does not preview or download when the budget expires during DOM sampling", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const dir = tempDir();
mkdirSync(join(dir, "extracted"), { recursive: true });
const descriptor = {
src: "https://video.example/hero.mp4",
filename: "hero.mp4",
width: 640,
height: 360,
sourceWidth: 640,
sourceHeight: 360,
top: 0,
left: 0,
heading: "Hero",
caption: "Demo",
ariaLabel: "",
};
const screenshot = vi.fn(async () => Buffer.from("preview"));
const evaluate = vi.fn(async (expression: unknown) =>
typeof expression === "function" ? { x: 0, y: 0, width: 640, height: 360 } : [descriptor],
);
const page = { evaluate, screenshot } as unknown as Page;
const fetchMock = vi.fn(
async () =>
new Response(Buffer.alloc(2048), {
status: 200,
headers: { "content-type": "video/mp4" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const capture = captureVideoManifest(page, dir, () => {}, {
sampleMs: 10_000,
downloadBudgetMs: 10_000,
remainingMs: () => Math.max(0, 1_000 - Date.now()),
});
await vi.runAllTimersAsync();
await capture;
expect(screenshot).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("remainingVideoDownloadTimeoutMs", () => { describe("remainingVideoDownloadTimeoutMs", () => {
it("caps a video request to the remaining capture budget", () => { it("caps a video request to the remaining capture budget", () => {
@@ -16,10 +135,4 @@ describe("remainingVideoDownloadTimeoutMs", () => {
it("retains the existing per-request ceiling when more budget remains", () => { it("retains the existing per-request ceiling when more budget remains", () => {
expect(remainingVideoDownloadTimeoutMs(1_000, 300_000, 2_000)).toBe(120_000); 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;",
);
});
}); });
+62 -22
View File
@@ -20,6 +20,14 @@ export interface DiscoveredLottie {
frameRate?: number; frameRate?: number;
} }
interface RemainingBudget {
remainingMs?: () => number;
}
function liveRemainingMs(budget: RemainingBudget, fallbackMs: number): number {
return budget.remainingMs?.() ?? fallbackMs;
}
/** /**
* Download and save discovered Lottie animations to disk. * Download and save discovered Lottie animations to disk.
* *
@@ -30,11 +38,13 @@ export interface DiscoveredLottie {
export async function saveLottieAnimations( export async function saveLottieAnimations(
discoveredLotties: DiscoveredLottie[], discoveredLotties: DiscoveredLottie[],
lottieDir: string, lottieDir: string,
budget: RemainingBudget = {},
): Promise<number> { ): Promise<number> {
let savedCount = 0; let savedCount = 0;
const savedHashes = new Set<string>(); // Deduplicate by content const savedHashes = new Set<string>(); // Deduplicate by content
for (let li = 0; li < discoveredLotties.length && li < 10; li++) { for (let li = 0; li < discoveredLotties.length && li < 10; li++) {
if (liveRemainingMs(budget, 10_000) <= 0) break;
const lottieItem = discoveredLotties[li]!; const lottieItem = discoveredLotties[li]!;
try { try {
let jsonData: string | undefined; let jsonData: string | undefined;
@@ -43,9 +53,11 @@ export async function saveLottieAnimations(
// Already have the JSON data from network interception // Already have the JSON data from network interception
jsonData = JSON.stringify(lottieItem.data); jsonData = JSON.stringify(lottieItem.data);
} else if (lottieItem.url) { } else if (lottieItem.url) {
const requestTimeoutMs = Math.min(10_000, liveRemainingMs(budget, 10_000));
if (requestTimeoutMs <= 0) break;
// SSRF guard — safeFetch re-checks the denylist on every redirect hop // SSRF guard — safeFetch re-checks the denylist on every redirect hop
const res = await safeFetch(lottieItem.url, { const res = await safeFetch(lottieItem.url, {
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(requestTimeoutMs),
headers: { "User-Agent": "HyperFrames/1.0" }, headers: { "User-Agent": "HyperFrames/1.0" },
}); });
if (!res || !res.ok) continue; if (!res || !res.ok) continue;
@@ -117,6 +129,7 @@ export async function renderLottiePreviews(
chromeBrowser: Browser, chromeBrowser: Browser,
lottieDir: string, lottieDir: string,
outputDir: string, outputDir: string,
budget: RemainingBudget = {},
): Promise<void> { ): Promise<void> {
const manifest: Array<{ const manifest: Array<{
file: string; file: string;
@@ -133,6 +146,7 @@ export async function renderLottiePreviews(
for (const file of readdirSync(lottieDir)) { for (const file of readdirSync(lottieDir)) {
if (!file.endsWith(".json")) continue; if (!file.endsWith(".json")) continue;
if (liveRemainingMs(budget, 1) <= 0) break;
try { try {
const raw = JSON.parse(readFileSync(join(lottieDir, file), "utf-8")); const raw = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const fr = raw.fr || 30; const fr = raw.fr || 30;
@@ -146,7 +160,9 @@ export async function renderLottiePreviews(
let previewPage; let previewPage;
try { try {
if (liveRemainingMs(budget, 1) <= 0) break;
previewPage = await chromeBrowser.newPage(); previewPage = await chromeBrowser.newPage();
if (liveRemainingMs(budget, 1) <= 0) break;
await previewPage.setViewport({ width: 400, height: 400 }); await previewPage.setViewport({ width: 400, height: 400 });
const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8")); const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3); const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3);
@@ -180,11 +196,13 @@ export async function renderLottiePreviews(
await previewPage await previewPage
.waitForFunction(() => (window as any).__READY === true, { timeout: 5000 }) .waitForFunction(() => (window as any).__READY === true, { timeout: 5000 })
.catch(() => {}); .catch(() => {});
await previewPage.screenshot({ if (liveRemainingMs(budget, 1) > 0) {
path: join(previewDir, previewName), await previewPage.screenshot({
type: "png", path: join(previewDir, previewName),
omitBackground: true, type: "png",
}); omitBackground: true,
});
}
} catch { } catch {
/* preview rendering failed — non-critical */ /* preview rendering failed — non-critical */
} finally { } finally {
@@ -362,11 +380,12 @@ async function sampleVideoDom(
page: Page, page: Page,
budgetMs: number, budgetMs: number,
netSet: Set<string>, netSet: Set<string>,
budget: RemainingBudget,
): Promise<VideoDescriptor[]> { ): Promise<VideoDescriptor[]> {
const seen = new Map<string, VideoDescriptor>(); const seen = new Map<string, VideoDescriptor>();
const start = Date.now(); const start = Date.now();
let stale = 0; let stale = 0;
while (Date.now() - start < budgetMs && stale < 3) { while (Date.now() - start < budgetMs && stale < 3 && liveRemainingMs(budget, 1) > 0) {
let grew = false; let grew = false;
const netBefore = netSet.size; const netBefore = netSet.size;
for (const d of await scanVideoDom(page)) { for (const d of await scanVideoDom(page)) {
@@ -377,7 +396,12 @@ async function sampleVideoDom(
} }
if (netSet.size > netBefore) grew = true; if (netSet.size > netBefore) grew = true;
stale = grew ? 0 : stale + 1; stale = grew ? 0 : stale + 1;
await new Promise((r) => setTimeout(r, 2000)); const waitMs = Math.min(
2000,
Math.max(0, budgetMs - (Date.now() - start)),
Math.max(0, liveRemainingMs(budget, 2000)),
);
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
} }
return [...seen.values()]; return [...seen.values()];
} }
@@ -407,7 +431,12 @@ export async function captureVideoManifest(
page: Page, page: Page,
outputDir: string, outputDir: string,
progress: (stage: string, detail?: string) => void, progress: (stage: string, detail?: string) => void,
opts?: { networkVideoUrls?: Set<string>; sampleMs?: number; downloadBudgetMs?: number }, opts?: {
networkVideoUrls?: Set<string>;
sampleMs?: number;
downloadBudgetMs?: number;
remainingMs?: () => number;
},
): Promise<void> { ): Promise<void> {
const netSet = opts?.networkVideoUrls ?? new Set<string>(); const netSet = opts?.networkVideoUrls ?? new Set<string>();
const sampleMs = opts?.sampleMs ?? 0; const sampleMs = opts?.sampleMs ?? 0;
@@ -416,7 +445,9 @@ export async function captureVideoManifest(
// DOM scan, optionally sampled over time (Layer 2) when videos are present. // DOM scan, optionally sampled over time (Layer 2) when videos are present.
const initial = await scanVideoDom(page); const initial = await scanVideoDom(page);
const domVideos = const domVideos =
initial.length > 0 && sampleMs > 0 ? await sampleVideoDom(page, sampleMs, netSet) : initial; initial.length > 0 && sampleMs > 0
? await sampleVideoDom(page, sampleMs, netSet, opts ?? {})
: initial;
// Merge DOM (rich) + network-only (thin, Layer 1), deduped by download // Merge DOM (rich) + network-only (thin, Layer 1), deduped by download
// filename so a clip seen in both lands once. netSet is read here — AFTER // filename so a clip seen in both lands once. netSet is read here — AFTER
@@ -472,7 +503,11 @@ 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 iterationRemainingMs = liveRemainingMs(
opts ?? {},
remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs),
);
if (iterationRemainingMs <= 0) break;
const v = merged[vi]!; const v = merged[vi]!;
let preview: string | undefined; let preview: string | undefined;
@@ -496,16 +531,18 @@ export async function captureVideoManifest(
}, v.filename)) as { x: number; y: number; width: number; height: number } | null; }, v.filename)) as { x: number; y: number; width: number; height: number } | null;
if (rect && rect.width >= 10) { if (rect && rect.width >= 10) {
await new Promise((r) => setTimeout(r, 200)); // let decoder settle await new Promise((r) => setTimeout(r, 200)); // let decoder settle
await page.screenshot({ if (liveRemainingMs(opts ?? {}, 1) > 0) {
path: join(previewDir, previewName), await page.screenshot({
clip: { path: join(previewDir, previewName),
x: Math.max(0, rect.x), clip: {
y: Math.max(0, rect.y), x: Math.max(0, rect.x),
width: Math.min(rect.width, 1920), y: Math.max(0, rect.y),
height: Math.min(rect.height, 1080), width: Math.min(rect.width, 1920),
}, height: Math.min(rect.height, 1080),
}); },
preview = `assets/videos/previews/${previewName}`; });
preview = `assets/videos/previews/${previewName}`;
}
} }
} catch { } catch {
/* preview failed — non-critical */ /* preview failed — non-critical */
@@ -516,7 +553,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 downloadTimeoutMs = Math.min(
remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs),
liveRemainingMs(opts ?? {}, VIDEO_DOWNLOAD_TIMEOUT_MS),
);
const savedPath = const savedPath =
downloadTimeoutMs > 0 downloadTimeoutMs > 0
? await downloadVideoBody(v.src, v.filename, videoManifestDir, downloadTimeoutMs) ? await downloadVideoBody(v.src, v.filename, videoManifestDir, downloadTimeoutMs)
@@ -122,6 +122,61 @@ describe("captureScrollScreenshots — capture budget", () => {
expect(evaluate).not.toHaveBeenCalled(); expect(evaluate).not.toHaveBeenCalled();
expect(screenshot).not.toHaveBeenCalled(); expect(screenshot).not.toHaveBeenCalled();
}); });
it("re-checks the budget after settling and before each viewport screenshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const dir = mkdtempSync(join(tmpdir(), "hf-scroll-expiring-budget-"));
const evaluate = vi.fn(async (expression: unknown) => {
const source = String(expression);
if (source.includes("Math.max(document.body.scrollHeight")) return 1080;
if (source === "window.innerHeight") return 1080;
return undefined;
});
const screenshot = vi.fn(async () => pngBuffer(1080));
const page = { evaluate, screenshot } as unknown as Page;
try {
const capture = captureScrollScreenshots(page, dir, {
remainingMs: () => Math.max(0, 600 - Date.now()),
});
await vi.runAllTimersAsync();
const files = await capture;
expect(files).toEqual([]);
expect(screenshot).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("captureFullPagePlate — capture budget", () => {
it("re-checks the budget immediately before the full-page screenshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const dir = mkdtempSync(join(tmpdir(), "hf-plate-expiring-budget-"));
const screenshot = vi.fn(async () => pngBuffer(8000));
const evaluate = vi.fn(async (expression: unknown) => {
if (String(expression).includes("scrollHeight")) {
vi.setSystemTime(100);
return 8000;
}
return undefined;
});
const page = { evaluate, screenshot } as unknown as Page;
try {
const plate = await captureFullPagePlate(page, dir, {
remainingMs: () => Math.max(0, 50 - Date.now()),
});
expect(plate).toBeNull();
expect(screenshot).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
}); });
describe("captureFullPagePlate — guards against a silently clipped plate", () => { describe("captureFullPagePlate — guards against a silently clipped plate", () => {
@@ -59,6 +59,7 @@ export function pngHeight(buf: Uint8Array): number | null {
export async function captureFullPagePlate( export async function captureFullPagePlate(
page: Page, page: Page,
screenshotsDir: string, screenshotsDir: string,
budget: { remainingMs?: () => number } = {},
): Promise<string | null> { ): Promise<string | null> {
// Record the inline value before overwriting so the page is handed back unchanged — the // Record the inline value before overwriting so the page is handed back unchanged — the
// caller keeps using it (asset extraction, DOM reads) after this returns. // caller keeps using it (asset extraction, DOM reads) after this returns.
@@ -81,6 +82,7 @@ export async function captureFullPagePlate(
`Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`, `Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`,
)) as number; )) as number;
if (docHeight > MAX_PLATE_HEIGHT_PX) return null; if (docHeight > MAX_PLATE_HEIGHT_PX) return null;
if ((budget.remainingMs?.() ?? 1) <= 0) return null;
const buffer = await page.screenshot({ type: "png", fullPage: true }); const buffer = await page.screenshot({ type: "png", fullPage: true });
// Confirm what Chrome produced instead of trusting the measurement: the capture itself can // Confirm what Chrome produced instead of trusting the measurement: the capture itself can
@@ -236,6 +238,7 @@ export async function captureScrollScreenshots(
); );
const filename = `scroll-${String(Math.min(pct, 100)).padStart(3, "0")}.png`; const filename = `scroll-${String(Math.min(pct, 100)).padStart(3, "0")}.png`;
const filePath = join(screenshotsDir, filename); const filePath = join(screenshotsDir, filename);
if ((budget.remainingMs?.() ?? 1) <= 0) break;
const buffer = await page.screenshot({ type: "png" }); const buffer = await page.screenshot({ type: "png" });
writeFileSync(filePath, buffer); writeFileSync(filePath, buffer);
filePaths.push(`screenshots/${filename}`); filePaths.push(`screenshots/${filename}`);
@@ -251,7 +254,7 @@ export async function captureScrollScreenshots(
// 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.)
if ((budget.remainingMs?.() ?? 1) > 0) { if ((budget.remainingMs?.() ?? 1) > 0) {
const plate = await captureFullPagePlate(page, screenshotsDir); const plate = await captureFullPagePlate(page, screenshotsDir, budget);
if (plate) filePaths.push(plate); if (plate) filePaths.push(plate);
} }
} catch { } catch {
+1 -1
View File
@@ -26,7 +26,7 @@ export interface CapturePhaseProgress {
status: "started" | "completed" | "degraded"; status: "started" | "completed" | "degraded";
/** Null before the post-navigation budget begins. */ /** Null before the post-navigation budget begins. */
remainingMs: number | null; remainingMs: number | null;
reason?: "budget-exhausted" | "disabled"; reason?: "budget-exhausted" | "disabled" | "request-timeout";
} }
export interface CaptureOptions { export interface CaptureOptions {
+53
View File
@@ -52,6 +52,15 @@ describe("capture command — vision control", () => {
}); });
}); });
it("declares --capture-budget separately from navigation --timeout", () => {
const captureBudgetArg = captureCommand.args
? Reflect.get(captureCommand.args, "capture-budget")
: undefined;
expect(captureBudgetArg).toMatchObject({ type: "string" });
expect(captureBudgetArg?.description.toLowerCase()).toContain("post-navigation");
expect(captureBudgetArg?.description).toContain("--timeout");
});
it("plumbs --skip-vision into capture options", async () => { it("plumbs --skip-vision into capture options", async () => {
vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
@@ -72,6 +81,50 @@ describe("capture command — vision control", () => {
); );
}); });
it("plumbs a positive --capture-budget into the post-navigation budget", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
await captureCommand.run!({
args: {
url: "https://example.com",
output: "/tmp/hf-capture-budget-test",
"skip-assets": false,
"skip-vision": false,
"capture-budget": "45000",
json: true,
},
} as never);
expect(captureWebsiteMock).toHaveBeenCalledWith(
expect.objectContaining({ postNavigationBudgetMs: 45_000 }),
undefined,
);
});
it.each(["0", "-1", "Infinity", "not-a-number"])(
"rejects invalid --capture-budget %s before capture starts",
async (captureBudget) => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
captureCommand.run!({
args: {
url: "https://example.com",
output: "/tmp/hf-invalid-capture-budget-test",
"skip-assets": false,
"skip-vision": false,
"capture-budget": captureBudget,
json: true,
},
} as never),
).rejects.toBeInstanceOf(CliRuntimeError);
expect(captureWebsiteMock).not.toHaveBeenCalled();
},
);
it("emits a versioned phase record without the captured URL", async () => { it("emits a versioned phase record without the captured URL", async () => {
vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {}); const error = vi.spyOn(console, "error").mockImplementation(() => {});
+18
View File
@@ -12,6 +12,16 @@ function emitCapturePhase(event: CapturePhaseProgress): void {
diag.notice(`${CAPTURE_PHASE_PREFIX}${JSON.stringify(event)}`); diag.notice(`${CAPTURE_PHASE_PREFIX}${JSON.stringify(event)}`);
} }
function parseCaptureBudget(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
console.error("--capture-budget must be a positive finite number of milliseconds.");
failCommand();
}
return parsed;
}
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"],
["Capture to a different directory", "hyperframes capture https://linear.app -o linear-video"], ["Capture to a different directory", "hyperframes capture https://linear.app -o linear-video"],
@@ -60,6 +70,11 @@ export default defineCommand({
type: "string", type: "string",
description: "Page load timeout in ms (default: 120000)", description: "Page load timeout in ms (default: 120000)",
}, },
"capture-budget": {
type: "string",
description:
"Post-navigation capture budget in ms (default: 120000); separate from page-load --timeout",
},
json: { json: {
type: "boolean", type: "boolean",
description: "Output JSON (for AI agents / programmatic use)", description: "Output JSON (for AI agents / programmatic use)",
@@ -112,6 +127,8 @@ export default defineCommand({
failCommand(); failCommand();
} }
const captureBudgetMs = parseCaptureBudget(args["capture-budget"] as string | undefined);
const isDefaultOutput = !args.output; const isDefaultOutput = !args.output;
let outputName = (args.output as string | undefined) ?? "capture"; let outputName = (args.output as string | undefined) ?? "capture";
let outputDir = resolve(outputName); let outputDir = resolve(outputName);
@@ -157,6 +174,7 @@ export default defineCommand({
? 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,
postNavigationBudgetMs: captureBudgetMs,
json: isJson, json: isJson,
onPhase: emitCapturePhase, onPhase: emitCapturePhase,
}, },