import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { resolve, sep } from "node:path"; import sharp from "sharp"; import { assetsRoot } from "../scripts/assets.mjs"; /** @param {import('../shared/protocol').TerrainSnapshot} snapshot * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {string} output * @param {{size?:number,signal?:AbortSignal}} [options] */ export async function renderSnapshot( snapshot, viewpoints, output, { size = 1024, signal } = {}, ) { const start = Date.now(); await mkdir(output, { recursive: true }); const server = createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); if (url.pathname === "/terrain.json") { res.setHeader("Content-Type", "application/json"); // Avoid Playwright's recursive protocol serialization of millions of // block records. Keep serialization buffers bounded and honor backpressure. const send = async (chunk) => { if (res.destroyed) throw new Error("Terrain connection closed"); await new Promise((resolve, reject) => { res.write(chunk, (error) => error ? reject(error) : resolve()); }); }; const { blocks, ...metadata } = snapshot; await send(JSON.stringify(metadata).slice(0, -1) + ',"blocks":['); for (let i = 0; i < blocks.length; i += 2048) { if (signal?.aborted) throw new Error("Rendering cancelled"); await send((i ? "," : "") + JSON.stringify(blocks.slice(i, i + 2048)).slice(1, -1)); } res.end("]}"); return; } let path = url.pathname === "/three.js" ? "node_modules/three/build/three.module.js" : url.pathname === "/three.core.js" ? "node_modules/three/build/three.core.js" : url.pathname.startsWith("/assets/") ? `${assetsRoot}/${url.pathname.slice(8)}` : url.pathname === "/" ? "renderer/index.html" : url.pathname.slice(1); path = resolve(path); if (!path.startsWith(process.cwd() + sep)) throw new Error("Invalid path"); res.setHeader( "Content-Type", path.endsWith(".js") ? "text/javascript" : path.endsWith(".json") ? "application/json" : path.endsWith(".png") ? "image/png" : "text/html", ); res.end(await readFile(path)); } catch { if (res.headersSent) { res.destroy(); return; } res.writeHead(404); res.end(); } }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); let browser; try { browser = await chromium.launch({ headless: true, args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage(); page.on("pageerror", (err) => console.error("Renderer:", err.message)); await page.goto(`http://127.0.0.1:${server.address().port}`); await page.waitForFunction(() => window.rendererReady); const stats = await page.evaluate(async () => { const response = await fetch("/terrain.json"); if (!response.ok) throw new Error("Unable to load terrain snapshot"); return window.buildTerrain(await response.json()); }); for (const p of viewpoints) { if (signal?.aborted) throw new Error("Rendering cancelled"); await mkdir(`${output}/${p.id}`, { recursive: true }); for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) { const data = await page.evaluate( ({ p, face, size }) => window.renderFace(p, face, size), { p, face, size }, ); const png = Buffer.from(data.split(",")[1], "base64"); await sharp(png) .webp({ quality: 85 }) .toFile(`${output}/${p.id}/${face}.webp`); if (face === "nz") await sharp(png) .resize(512, 512) .webp({ quality: 76 }) .toFile(`${output}/${p.id}/preview.webp`); } } const map = await page.evaluate(() => window.renderMap(1024)); await sharp(Buffer.from(map.split(",")[1], "base64")) .webp({ quality: 90 }) .toFile(`${output}/map.webp`); stats.renderMs = Date.now() - start; await writeFile( `${output}/render-report.json`, JSON.stringify(stats, null, 2), ); return stats; } finally { await browser?.close(); await new Promise((r) => server.close(r)); } }