Files
MineGeo/renderer/render.mjs

226 lines
8.6 KiB
JavaScript

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<void>}} [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));
}
}