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
+62 -22
View File
@@ -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)