mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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 { 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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
if (liveRemainingMs(budget, 1) > 0) {
|
||||||
await previewPage.screenshot({
|
await previewPage.screenshot({
|
||||||
path: join(previewDir, previewName),
|
path: join(previewDir, previewName),
|
||||||
type: "png",
|
type: "png",
|
||||||
omitBackground: true,
|
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,6 +531,7 @@ 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
|
||||||
|
if (liveRemainingMs(opts ?? {}, 1) > 0) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: join(previewDir, previewName),
|
path: join(previewDir, previewName),
|
||||||
clip: {
|
clip: {
|
||||||
@@ -507,6 +543,7 @@ export async function captureVideoManifest(
|
|||||||
});
|
});
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user