/** * Media capture helpers for the website capture pipeline. * * Handles Lottie animation preview rendering and video element manifest capture. * * All page.evaluate() calls use string expressions to avoid * tsx/esbuild __name injection (see esbuild issue #1031). */ import type { Browser, Page } from "puppeteer-core"; import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, extname } from "node:path"; import { isPrivateUrl, safeFetch } from "./assetDownloader.js"; /** Discovered Lottie item from network interception or DOM scan. */ export interface DiscoveredLottie { url: string; data?: unknown; dimensions?: { w: number; h: 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. * * Handles both plain JSON and dotLottie (.lottie ZIP) formats. * Deduplicates by content hash. Returns the count of saved files. */ // fallow-ignore-next-line complexity export async function saveLottieAnimations( discoveredLotties: DiscoveredLottie[], lottieDir: string, budget: RemainingBudget = {}, ): Promise { let savedCount = 0; const savedHashes = new Set(); // 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; if (lottieItem.data) { // 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(requestTimeoutMs), headers: { "User-Agent": "HyperFrames/1.0" }, }); if (!res || !res.ok) continue; const buf = Buffer.from(await res.arrayBuffer()); if (lottieItem.url.endsWith(".lottie")) { // dotLottie is a ZIP — extract the animation JSON try { const AdmZip = (await import("adm-zip")).default; const zip = new AdmZip(buf); const entries = zip.getEntries(); // Look for animation JSON in both v1 (animations/) and v2 (a/) paths const animEntry = entries.find( (e) => (e.entryName.startsWith("a/") || e.entryName.startsWith("animations/")) && e.entryName.endsWith(".json"), ); if (animEntry) { jsonData = animEntry.getData().toString("utf-8"); } } catch { // adm-zip not available or extraction failed — save raw .lottie const hash = buf.toString("base64").slice(0, 100); if (savedHashes.has(hash)) continue; savedHashes.add(hash); writeFileSync(join(lottieDir, `animation-${savedCount}.lottie`), buf); savedCount++; continue; } } else { // Plain JSON file jsonData = buf.toString("utf-8"); } } if (jsonData) { // Deduplicate by content hash (first 100 chars of stringified JSON) const hash = jsonData.slice(0, 200); if (savedHashes.has(hash)) continue; savedHashes.add(hash); // Validate it's actually Lottie try { const parsed = JSON.parse(jsonData); if (!parsed.layers || !parsed.w) continue; } catch { continue; } writeFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8"); savedCount++; } } catch { /* skip */ } } return savedCount; } /** * Render preview thumbnails for saved Lottie animation JSON files. * * Opens each Lottie JSON in a headless Chrome page via lottie-web, * seeks to ~30% through the animation, and takes a transparent screenshot. * Writes a lottie-manifest.json with metadata and successfully rendered preview paths. */ // fallow-ignore-next-line complexity export async function renderLottiePreviews( chromeBrowser: Browser, lottieDir: string, outputDir: string, budget: RemainingBudget = {}, ): Promise { const manifest: Array<{ file: string; preview?: string; name: string; width: number; height: number; duration: number; frameRate: number; layers: number; }> = []; const previewDir = join(lottieDir, "previews"); mkdirSync(previewDir, { recursive: true }); 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; const dur = ((raw.op || 0) - (raw.ip || 0)) / fr; const previewName = file.replace(".json", "-preview.png"); let preview: string | undefined; // Render a mid-frame thumbnail using Puppeteer + lottie-web // Skip huge Lottie files for preview (CDP has a ~256MB message limit) const fileSize = statSync(join(lottieDir, file)).size; if (fileSize > 2_000_000) continue; 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); // Load the shell page first (no untrusted data in the HTML) await previewPage.setContent( `
`, { waitUntil: "load", timeout: 10000 }, ); // Pass animation data safely via parameterized evaluate (no string interpolation) await previewPage.evaluate( (data: unknown, frame: number) => { const a = (window as any).lottie.loadAnimation({ container: document.getElementById("c"), renderer: "svg", loop: false, autoplay: false, animationData: data, }); a.addEventListener("DOMLoaded", () => { a.goToAndStop(frame, true); (window as any).__READY = true; }); }, animData, midFrame, ); await previewPage .waitForFunction(() => (window as any).__READY === true, { timeout: 5000 }) .catch(() => {}); if (liveRemainingMs(budget, 1) > 0) { await previewPage.screenshot({ path: join(previewDir, previewName), type: "png", omitBackground: true, }); preview = `assets/lottie/previews/${previewName}`; } } catch { /* preview rendering failed — non-critical */ } finally { await previewPage?.close().catch(() => {}); } manifest.push({ file: `assets/lottie/${file}`, ...(preview ? { preview } : {}), name: raw.nm || file, width: raw.w || 0, height: raw.h || 0, duration: Math.round(dur * 10) / 10, frameRate: fr, layers: (raw.layers || []).length, }); } catch { /* skip */ } } if (manifest.length > 0) { writeFileSync( join(outputDir, "extracted", "lottie-manifest.json"), JSON.stringify(manifest, null, 2), "utf-8", ); } } const MAX_VIDEO_BYTES = 75 * 1024 * 1024; // 75 MB — hero/demo clips, not full films const DOWNLOADABLE_VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".m4v"]); const VIDEO_DOWNLOAD_TIMEOUT_MS = 120_000; export function remainingVideoDownloadTimeoutMs( budgetStartedAt: number, budgetMs: number, now = Date.now(), ): number { return Math.max(0, Math.min(VIDEO_DOWNLOAD_TIMEOUT_MS, budgetMs - (now - budgetStartedAt))); } /** * Download a