mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 07:40:06 +00:00
fix(cli): propagate live capture budget
This commit is contained in:
@@ -2,7 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { captionImagesWithGemini } from "./contentExtractor.js";
|
||||
import {
|
||||
captionImagesWithGemini,
|
||||
resolveVisionPhaseCompletion,
|
||||
type VisionCaptionOutcome,
|
||||
} from "./contentExtractor.js";
|
||||
|
||||
const { generateContentMock } = vi.hoisted(() => ({
|
||||
generateContentMock: vi.fn(),
|
||||
@@ -151,9 +155,20 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
|
||||
);
|
||||
|
||||
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(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 () => {
|
||||
|
||||
@@ -17,9 +17,30 @@ import type { DesignTokens } from "./types.js";
|
||||
|
||||
const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface VisionCaptionOutcome {
|
||||
timedOutRequests: number;
|
||||
budgetExhausted: boolean;
|
||||
}
|
||||
|
||||
interface VisionCaptionOptions {
|
||||
skipVision?: boolean;
|
||||
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 {
|
||||
@@ -214,10 +235,21 @@ export async function captionImagesWithGemini(
|
||||
options: VisionCaptionOptions = {},
|
||||
): Promise<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 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
|
||||
// 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
|
||||
// failing the batch.
|
||||
const BATCH_SIZE = 20;
|
||||
let timedOutCount = 0;
|
||||
const collectCaptionResults = (
|
||||
results: PromiseSettledResult<{ file: string; caption: string }>[],
|
||||
): number => {
|
||||
@@ -340,7 +371,10 @@ export async function captionImagesWithGemini(
|
||||
};
|
||||
for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) {
|
||||
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 batch = imageFiles.slice(i, i + BATCH_SIZE);
|
||||
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}). ` +
|
||||
`Reinstall with optional dependencies enabled (e.g. \`npm i sharp\`) to caption SVG assets.`,
|
||||
);
|
||||
reportOutcome();
|
||||
return geminiCaptions;
|
||||
}
|
||||
progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`);
|
||||
@@ -412,7 +447,10 @@ export async function captionImagesWithGemini(
|
||||
let svgsSkipped = 0;
|
||||
for (let i = 0; i < svgFiles.length; i += SVG_BATCH) {
|
||||
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 batch = svgFiles.slice(i, i + SVG_BATCH);
|
||||
const results = await Promise.allSettled(
|
||||
@@ -489,6 +527,7 @@ export async function captionImagesWithGemini(
|
||||
warnings.push(`${providerName} captioning failed: ${err}`);
|
||||
}
|
||||
|
||||
reportOutcome();
|
||||
return geminiCaptions;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ import {
|
||||
extractVisibleText,
|
||||
captionImagesWithGemini,
|
||||
generateAssetDescriptions,
|
||||
resolveVisionPhaseCompletion,
|
||||
} from "./contentExtractor.js";
|
||||
import type { VisionCaptionOutcome } from "./contentExtractor.js";
|
||||
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
|
||||
import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js";
|
||||
|
||||
@@ -343,10 +345,11 @@ export async function captureWebsite(
|
||||
if (discoveredLotties.length > 0 && remainingMs() > 0) {
|
||||
const lottieDir = join(outputDir, "assets", "lottie");
|
||||
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
|
||||
if (savedCount > 0 && remainingMs() > 0) {
|
||||
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir);
|
||||
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
|
||||
progress("lottie", `${savedCount} Lottie animation(s) saved`);
|
||||
}
|
||||
}
|
||||
@@ -494,6 +497,7 @@ export async function captureWebsite(
|
||||
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
|
||||
sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget
|
||||
downloadBudgetMs: videoBudgetMs,
|
||||
remainingMs,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -670,13 +674,21 @@ export async function captureWebsite(
|
||||
phase("vision", "degraded", "budget-exhausted");
|
||||
} else {
|
||||
phase("vision", "started");
|
||||
let visionOutcome: VisionCaptionOutcome = {
|
||||
timedOutRequests: 0,
|
||||
budgetExhausted: false,
|
||||
};
|
||||
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
|
||||
remainingMs,
|
||||
onOutcome: (outcome) => {
|
||||
visionOutcome = outcome;
|
||||
},
|
||||
});
|
||||
const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs());
|
||||
phase(
|
||||
"vision",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
completion.status,
|
||||
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;");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,127 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { remainingVideoDownloadTimeoutMs } from "./mediaCapture.js";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
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", () => {
|
||||
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", () => {
|
||||
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;",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface DiscoveredLottie {
|
||||
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.
|
||||
*
|
||||
@@ -30,11 +38,13 @@ export interface DiscoveredLottie {
|
||||
export async function saveLottieAnimations(
|
||||
discoveredLotties: DiscoveredLottie[],
|
||||
lottieDir: string,
|
||||
budget: RemainingBudget = {},
|
||||
): Promise<number> {
|
||||
let savedCount = 0;
|
||||
const savedHashes = new Set<string>(); // Deduplicate by content
|
||||
|
||||
for (let li = 0; li < discoveredLotties.length && li < 10; li++) {
|
||||
if (liveRemainingMs(budget, 10_000) <= 0) break;
|
||||
const lottieItem = discoveredLotties[li]!;
|
||||
try {
|
||||
let jsonData: string | undefined;
|
||||
@@ -43,9 +53,11 @@ export async function saveLottieAnimations(
|
||||
// Already have the JSON data from network interception
|
||||
jsonData = JSON.stringify(lottieItem.data);
|
||||
} 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
|
||||
const res = await safeFetch(lottieItem.url, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
headers: { "User-Agent": "HyperFrames/1.0" },
|
||||
});
|
||||
if (!res || !res.ok) continue;
|
||||
@@ -117,6 +129,7 @@ export async function renderLottiePreviews(
|
||||
chromeBrowser: Browser,
|
||||
lottieDir: string,
|
||||
outputDir: string,
|
||||
budget: RemainingBudget = {},
|
||||
): Promise<void> {
|
||||
const manifest: Array<{
|
||||
file: string;
|
||||
@@ -133,6 +146,7 @@ export async function renderLottiePreviews(
|
||||
|
||||
for (const file of readdirSync(lottieDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
if (liveRemainingMs(budget, 1) <= 0) break;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
|
||||
const fr = raw.fr || 30;
|
||||
@@ -146,7 +160,9 @@ export async function renderLottiePreviews(
|
||||
|
||||
let previewPage;
|
||||
try {
|
||||
if (liveRemainingMs(budget, 1) <= 0) break;
|
||||
previewPage = await chromeBrowser.newPage();
|
||||
if (liveRemainingMs(budget, 1) <= 0) break;
|
||||
await previewPage.setViewport({ width: 400, height: 400 });
|
||||
const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
|
||||
const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3);
|
||||
@@ -180,11 +196,13 @@ export async function renderLottiePreviews(
|
||||
await previewPage
|
||||
.waitForFunction(() => (window as any).__READY === true, { timeout: 5000 })
|
||||
.catch(() => {});
|
||||
await previewPage.screenshot({
|
||||
path: join(previewDir, previewName),
|
||||
type: "png",
|
||||
omitBackground: true,
|
||||
});
|
||||
if (liveRemainingMs(budget, 1) > 0) {
|
||||
await previewPage.screenshot({
|
||||
path: join(previewDir, previewName),
|
||||
type: "png",
|
||||
omitBackground: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* preview rendering failed — non-critical */
|
||||
} finally {
|
||||
@@ -362,11 +380,12 @@ async function sampleVideoDom(
|
||||
page: Page,
|
||||
budgetMs: number,
|
||||
netSet: Set<string>,
|
||||
budget: RemainingBudget,
|
||||
): Promise<VideoDescriptor[]> {
|
||||
const seen = new Map<string, VideoDescriptor>();
|
||||
const start = Date.now();
|
||||
let stale = 0;
|
||||
while (Date.now() - start < budgetMs && stale < 3) {
|
||||
while (Date.now() - start < budgetMs && stale < 3 && liveRemainingMs(budget, 1) > 0) {
|
||||
let grew = false;
|
||||
const netBefore = netSet.size;
|
||||
for (const d of await scanVideoDom(page)) {
|
||||
@@ -377,7 +396,12 @@ async function sampleVideoDom(
|
||||
}
|
||||
if (netSet.size > netBefore) grew = true;
|
||||
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()];
|
||||
}
|
||||
@@ -407,7 +431,12 @@ export async function captureVideoManifest(
|
||||
page: Page,
|
||||
outputDir: string,
|
||||
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> {
|
||||
const netSet = opts?.networkVideoUrls ?? new Set<string>();
|
||||
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.
|
||||
const initial = await scanVideoDom(page);
|
||||
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
|
||||
// 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();
|
||||
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]!;
|
||||
let preview: string | undefined;
|
||||
|
||||
@@ -496,16 +531,18 @@ export async function captureVideoManifest(
|
||||
}, v.filename)) as { x: number; y: number; width: number; height: number } | null;
|
||||
if (rect && rect.width >= 10) {
|
||||
await new Promise((r) => setTimeout(r, 200)); // let decoder settle
|
||||
await page.screenshot({
|
||||
path: join(previewDir, previewName),
|
||||
clip: {
|
||||
x: Math.max(0, rect.x),
|
||||
y: Math.max(0, rect.y),
|
||||
width: Math.min(rect.width, 1920),
|
||||
height: Math.min(rect.height, 1080),
|
||||
},
|
||||
});
|
||||
preview = `assets/videos/previews/${previewName}`;
|
||||
if (liveRemainingMs(opts ?? {}, 1) > 0) {
|
||||
await page.screenshot({
|
||||
path: join(previewDir, previewName),
|
||||
clip: {
|
||||
x: Math.max(0, rect.x),
|
||||
y: Math.max(0, rect.y),
|
||||
width: Math.min(rect.width, 1920),
|
||||
height: Math.min(rect.height, 1080),
|
||||
},
|
||||
});
|
||||
preview = `assets/videos/previews/${previewName}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* preview failed — non-critical */
|
||||
@@ -516,7 +553,10 @@ export async function captureVideoManifest(
|
||||
// direct file. Cumulative budget caps total download time so a throttled
|
||||
// host or many large clips can't stall capture — over budget, keep the
|
||||
// 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 =
|
||||
downloadTimeoutMs > 0
|
||||
? await downloadVideoBody(v.src, v.filename, videoManifestDir, downloadTimeoutMs)
|
||||
|
||||
@@ -122,6 +122,61 @@ describe("captureScrollScreenshots — capture budget", () => {
|
||||
expect(evaluate).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", () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ export function pngHeight(buf: Uint8Array): number | null {
|
||||
export async function captureFullPagePlate(
|
||||
page: Page,
|
||||
screenshotsDir: string,
|
||||
budget: { remainingMs?: () => number } = {},
|
||||
): Promise<string | null> {
|
||||
// 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.
|
||||
@@ -81,6 +82,7 @@ export async function captureFullPagePlate(
|
||||
`Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`,
|
||||
)) as number;
|
||||
if (docHeight > MAX_PLATE_HEIGHT_PX) return null;
|
||||
if ((budget.remainingMs?.() ?? 1) <= 0) return null;
|
||||
|
||||
const buffer = await page.screenshot({ type: "png", fullPage: true });
|
||||
// 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 filePath = join(screenshotsDir, filename);
|
||||
if ((budget.remainingMs?.() ?? 1) <= 0) break;
|
||||
const buffer = await page.screenshot({ type: "png" });
|
||||
writeFileSync(filePath, buffer);
|
||||
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
|
||||
// needs one continuous plate, which no set of viewport tiles can substitute for.)
|
||||
if ((budget.remainingMs?.() ?? 1) > 0) {
|
||||
const plate = await captureFullPagePlate(page, screenshotsDir);
|
||||
const plate = await captureFullPagePlate(page, screenshotsDir, budget);
|
||||
if (plate) filePaths.push(plate);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface CapturePhaseProgress {
|
||||
status: "started" | "completed" | "degraded";
|
||||
/** Null before the post-navigation budget begins. */
|
||||
remainingMs: number | null;
|
||||
reason?: "budget-exhausted" | "disabled";
|
||||
reason?: "budget-exhausted" | "disabled" | "request-timeout";
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
|
||||
@@ -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 () => {
|
||||
vi.spyOn(console, "log").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 () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
@@ -12,6 +12,16 @@ function emitCapturePhase(event: CapturePhaseProgress): void {
|
||||
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[] = [
|
||||
["Capture a website into ./capture/", "hyperframes capture https://stripe.com"],
|
||||
["Capture to a different directory", "hyperframes capture https://linear.app -o linear-video"],
|
||||
@@ -60,6 +70,11 @@ export default defineCommand({
|
||||
type: "string",
|
||||
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: {
|
||||
type: "boolean",
|
||||
description: "Output JSON (for AI agents / programmatic use)",
|
||||
@@ -112,6 +127,8 @@ export default defineCommand({
|
||||
failCommand();
|
||||
}
|
||||
|
||||
const captureBudgetMs = parseCaptureBudget(args["capture-budget"] as string | undefined);
|
||||
|
||||
const isDefaultOutput = !args.output;
|
||||
let outputName = (args.output as string | undefined) ?? "capture";
|
||||
let outputDir = resolve(outputName);
|
||||
@@ -157,6 +174,7 @@ export default defineCommand({
|
||||
? parseInt(args["max-screenshots"] as string)
|
||||
: undefined,
|
||||
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
|
||||
postNavigationBudgetMs: captureBudgetMs,
|
||||
json: isJson,
|
||||
onPhase: emitCapturePhase,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user