195 lines
7 KiB
JavaScript
195 lines
7 KiB
JavaScript
// 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));
|
|
}
|