// Stable, reproducible showcase captures. // // A thumbnail must show the composed page, not a font fallback, an unloaded image, // a transition frame, or the next frame of a canvas loop. Keep the local HTTP // server and settling contract shared by every capture entrypoint. import fs from "node:fs"; import http from "node:http"; import path from "node:path"; export const MIME = new Map([ [".avif", "image/avif"], [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], [".ico", "image/x-icon"], [".jpeg", "image/jpeg"], [".jpg", "image/jpeg"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".png", "image/png"], [".svg", "image/svg+xml"], [".webmanifest", "application/manifest+json"], [".webp", "image/webp"], [".woff", "font/woff"], [".woff2", "font/woff2"], ]); export function resolveChrome() { const candidates = [ process.env.CHROME_PATH, "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", "/usr/bin/google-chrome", "/usr/bin/chromium", ]; const chrome = candidates.find((candidate) => candidate && fs.existsSync(candidate)); if (!chrome) throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium."); return chrome; } function isInside(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative)); } export async function startPublicServer(publicRoot) { const server = http.createServer((request, response) => { try { const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); const candidate = path.resolve(publicRoot, `.${pathname}`); if (!isInside(publicRoot, candidate)) { response.writeHead(403).end("forbidden"); return; } const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? path.join(candidate, "index.html") : candidate; if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404).end("not found"); return; } response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream"); response.setHeader("Cache-Control", "no-store"); fs.createReadStream(file).pipe(response); } catch { response.writeHead(400).end("bad request"); } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); return { origin: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolve) => server.close(resolve)), }; } export function routeForFile(publicRoot, file) { const relative = path.relative(publicRoot, file).split(path.sep).join("/"); if (relative === "index.html") return "/"; return `/${relative.replace(/(?:^|\/)index\.html$/, "/")}`.replace(/\/+/g, "/"); } export async function prepareStillPage(page) { await page.setCacheEnabled(false); await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); } export function collectPageFailures(page, origin) { const failures = []; page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`)); page.on("response", (response) => { if (response.url().startsWith(origin) && response.status() >= 400) { failures.push(`${response.status()} ${response.url()}`); } }); return failures; } async function waitForFonts(page) { await Promise.race([ page.evaluate(() => document.fonts?.ready || Promise.resolve()), new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)), ]); } async function waitForImages(page) { await page.evaluate(async () => { const images = [...document.images]; for (const image of images) image.loading = "eager"; await Promise.all(images.map(async (image) => { if (!image.complete) { await new Promise((resolve) => { const timer = window.setTimeout(resolve, 10000); image.addEventListener("load", () => { window.clearTimeout(timer); resolve(); }, { once: true }); image.addEventListener("error", () => { window.clearTimeout(timer); resolve(); }, { once: true }); }); } await image.decode().catch(() => undefined); })); }); const broken = await page.evaluate(() => [...document.images] .filter((image) => image.complete && image.naturalWidth === 0) .map((image) => image.currentSrc || image.src)); if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`); } async function stopTimeBasedMedia(page) { await page.evaluate(async () => { const media = [...document.querySelectorAll("video, audio")]; for (const item of media) { item.pause(); if (item instanceof HTMLVideoElement && Number.isFinite(item.duration) && item.duration > 0) { await new Promise((resolve) => { const timer = window.setTimeout(resolve, 500); item.addEventListener("seeked", () => { window.clearTimeout(timer); resolve(); }, { once: true }); item.currentTime = 0; }); } } }); } async function freezeAnimations(page) { await page.evaluate(() => { for (const animation of document.getAnimations({ subtree: true })) { const timing = animation.effect?.getComputedTiming(); try { if (timing?.iterations === Infinity) animation.pause(); else animation.finish(); } catch { animation.pause(); } } // Existing rAF loops schedule their next frame after this callback completes. // Replacing it here preserves the visually settled canvas while preventing a // different frame from being captured on each run. window.requestAnimationFrame = () => 0; document.documentElement.dataset.thumbnailCapture = "settled"; window.scrollTo({ top: 0, left: 0, behavior: "instant" }); }); await page.addStyleTag({ content: ` html[data-thumbnail-capture="settled"] *, html[data-thumbnail-capture="settled"] *::before, html[data-thumbnail-capture="settled"] *::after { animation-play-state: paused !important; transition-property: none !important; caret-color: transparent !important; } html[data-thumbnail-capture="settled"] { scroll-behavior: auto !important; } html[data-thumbnail-capture="settled"] ::-webkit-scrollbar { display: none !important; } `, }); } export async function waitForStableVisual(page, { settleMs = 900 } = {}) { await waitForFonts(page); await waitForImages(page); // Let the page apply its data and initial layout before freezing its final frame. await new Promise((resolve) => setTimeout(resolve, settleMs)); await stopTimeBasedMedia(page); await freezeAnimations(page); await new Promise((resolve) => setTimeout(resolve, 80)); }