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, prepareAssets } from "../scripts/assets.mjs"; import { assertTerrainImage } from "./image-validation.mjs"; import { renderSurfaceMap } from "./surface-map.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,onProgress?:(event:{percent:number,message:string})=>Promise}} [options] */ export async function renderSnapshot( snapshot, viewpoints, output, { size = 1024, signal, onProgress } = {}, ) { const start = Date.now(); if (signal?.aborted) throw new Error("Rendering cancelled"); await onProgress?.({ percent: 22, message: "Checking Minecraft models and textures", }); await prepareAssets(); 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") || path.endsWith(".mjs") ? "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; let lastActivity = Date.now(); let failure; const report = async (event) => { lastActivity = Date.now(); if (signal?.aborted) throw new Error("Rendering cancelled"); await onProgress?.(event); }; const cancel = () => { failure = new Error("Rendering cancelled or worker lease lost"); void browser?.close().catch(() => {}); }; const idleSeconds = Number(process.env.MINEGEO_RENDER_IDLE_SECONDS || 600); if (!Number.isFinite(idleSeconds) || idleSeconds < 60) { await new Promise((r) => server.close(r)); throw new Error("MINEGEO_RENDER_IDLE_SECONDS must be at least 60"); } const backend = process.env.MINEGEO_RENDER_BACKEND || "auto"; if (!["auto", "software"].includes(backend)) { await new Promise((r) => server.close(r)); throw new Error("MINEGEO_RENDER_BACKEND must be auto or software"); } const watchdog = setInterval(() => { if (Date.now() - lastActivity > idleSeconds * 1000) { failure = new Error( `Renderer stopped reporting progress for ${idleSeconds} seconds. Try a smaller export area or lower MINEGEO_FACE_SIZE.`, ); void browser?.close().catch(() => {}); } }, 10000); signal?.addEventListener("abort", cancel, { once: true }); try { if (signal?.aborted) throw new Error("Rendering cancelled"); await report({ percent: 24, message: "Starting terrain renderer" }); let page; let selectedBackend = backend; async function openRenderer(mode) { browser = await chromium.launch({ headless: true, args: mode === "software" ? ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] : ["--enable-gpu", "--enable-unsafe-swiftshader"], }); if (signal?.aborted || failure) throw failure || new Error("Rendering cancelled"); const rendererPage = await browser.newPage(); await rendererPage.exposeFunction("minegeoProgress", report); rendererPage.on("pageerror", (err) => console.error("Renderer:", err.message), ); rendererPage.on("console", (message) => { if (["warning", "error"].includes(message.type())) console.error(`Renderer ${message.type()}: ${message.text()}`); }); await rendererPage.goto(`http://127.0.0.1:${server.address().port}`); await rendererPage.waitForFunction(() => window.rendererReady); return rendererPage; } try { page = await openRenderer(backend); } catch (error) { if (backend === "software" || signal?.aborted || failure || !browser) throw error; await browser.close(); console.warn( "GPU renderer initialization failed; falling back to software rendering.", ); selectedBackend = "software"; page = await openRenderer("software"); } const graphics = await page.evaluate(() => window.rendererInfo); console.log(`MineGeo graphics (${selectedBackend}): ${graphics.renderer}`); await report({ percent: 24, message: `Graphics backend: ${graphics.renderer}`, }); 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()); }); stats.graphics = graphics; stats.backend = selectedBackend; await report({ percent: 60, message: `Terrain ready: ${stats.blocks} blocks; rendering ${viewpoints.length} panoramas`, }); let completedFaces = 0; 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 encoded = Buffer.from(data.split(",")[1], "base64"); if (face === "ny") await assertTerrainImage(encoded, `Panorama ${p.id}, downward face`); // Chromium encodes WebP once; retain a PNG fallback for canvas runtimes // without WebP support rather than silently writing the wrong MIME type. if (data.startsWith("data:image/webp;")) await writeFile(`${output}/${p.id}/${face}.webp`, encoded); else await sharp(encoded) .webp({ quality: 85 }) .toFile(`${output}/${p.id}/${face}.webp`); if (face === "nz") await sharp(encoded) .resize(512, 512) .webp({ quality: 76 }) .toFile(`${output}/${p.id}/preview.webp`); await report({ percent: 60 + (25 * ++completedFaces) / (viewpoints.length * 6), message: `Panorama faces ${completedFaces}/${viewpoints.length * 6}`, }); } } await report({ percent: 85, message: "Rendering the surface map" }); stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`); await report({ percent: 90, message: "Rendered all panoramas and the surface map", }); stats.renderMs = Date.now() - start; await writeFile( `${output}/render-report.json`, JSON.stringify(stats, null, 2), ); return stats; } catch (error) { throw failure || error; } finally { clearInterval(watchdog); signal?.removeEventListener("abort", cancel); await browser?.close().catch(() => {}); await new Promise((r) => server.close(r)); } }