/** * 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; } /** * 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, ): Promise { let savedCount = 0; const savedHashes = new Set(); // Deduplicate by content for (let li = 0; li < discoveredLotties.length && li < 10; li++) { 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) { // SSRF guard — safeFetch re-checks the denylist on every redirect hop const res = await safeFetch(lottieItem.url, { signal: AbortSignal.timeout(10000), 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 + preview paths. */ // fallow-ignore-next-line complexity export async function renderLottiePreviews( chromeBrowser: Browser, lottieDir: string, outputDir: string, ): 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; 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"); // 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 { previewPage = await chromeBrowser.newPage(); 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(() => {}); await previewPage.screenshot({ path: join(previewDir, previewName), type: "png", omitBackground: true, }); } catch { /* preview rendering failed — non-critical */ } finally { await previewPage?.close().catch(() => {}); } manifest.push({ file: `assets/lottie/${file}`, preview: `assets/lottie/previews/${previewName}`, 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"]); /** * Download a