217 lines
8.3 KiB
JavaScript
217 lines
8.3 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 } 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,onProgress?:(event:{percent:number,message:string})=>Promise<void>}} [options] */
|
|
export async function renderSnapshot(
|
|
snapshot,
|
|
viewpoints,
|
|
output,
|
|
{ size = 1024, signal, onProgress } = {},
|
|
) {
|
|
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") || 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),
|
|
);
|
|
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");
|
|
// 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" });
|
|
const map = await page.evaluate(() => window.renderMap(1024));
|
|
const mapBytes = Buffer.from(map.split(",")[1], "base64");
|
|
if (map.startsWith("data:image/webp;"))
|
|
await writeFile(`${output}/map.webp`, mapBytes);
|
|
else
|
|
await sharp(mapBytes).webp({ quality: 90 }).toFile(`${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));
|
|
}
|
|
}
|