mdn-backend / scripts /render-3d-preview.mjs
internationalscholarsprogram's picture
Backend: Essentials, Investment Club foundation + NAV engine, vehicle CRUD,
d7511e4
Raw
History Blame Contribute Delete
3.73 kB
#!/usr/bin/env node
/**
* Partner Project Media 3D microtool.
*
* Usage: node render-3d-preview.mjs <input.glb|gltf> <output-thumbnail.png>
* Prints a single JSON line to stdout: { polyCount, vertexCount, boundingBox }
*
* Requires (npm install in this directory, or wherever package.json for
* this script lives on the queue-worker host):
* @gltf-transform/core -- poly/vertex count + bounding box, no GPU needed
* puppeteer -- headless screenshot of a local <model-viewer> page
*
* Invoked by app/Jobs/ProcessPartnerProjectModelMedia.php via
* Symfony\Component\Process\Process. See docs/partner-project-media.md for
* why this is a Node microtool rather than a PHP library (none of the
* available PHP glTF libraries render or compute bounding boxes reliably).
*/
import { NodeIO } from "@gltf-transform/core";
import puppeteer from "puppeteer";
import { writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const [, , inputPath, outputThumbnailPath] = process.argv;
if (!inputPath || !outputThumbnailPath) {
console.error("Usage: render-3d-preview.mjs <input> <output-thumbnail.png>");
process.exit(1);
}
async function computeStats(path) {
const io = new NodeIO();
const document = await io.read(path);
let vertexCount = 0;
let polyCount = 0;
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const position = primitive.getAttribute("POSITION");
if (!position) continue;
vertexCount += position.getCount();
const indices = primitive.getIndices();
polyCount += indices ? Math.floor(indices.getCount() / 3) : Math.floor(position.getCount() / 3);
for (let i = 0; i < position.getCount(); i++) {
const v = position.getElement(i, []);
for (let axis = 0; axis < 3; axis++) {
if (v[axis] < min[axis]) min[axis] = v[axis];
if (v[axis] > max[axis]) max[axis] = v[axis];
}
}
}
}
return { polyCount, vertexCount, boundingBox: { min, max } };
}
async function renderThumbnail(path, outputPath) {
const fileUrl = pathToFileURL(path).href;
const html = `<!doctype html>
<html><head><style>html,body{margin:0;background:#0B0B0B}model-viewer{width:512px;height:512px}</style></head>
<body>
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
<model-viewer src="${fileUrl}" camera-controls="false" auto-rotate="false" exposure="1" shadow-intensity="0" disable-zoom></model-viewer>
</body></html>`;
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] });
try {
const page = await browser.newPage();
await page.setViewport({ width: 512, height: 512 });
await page.setContent(html, { waitUntil: "networkidle0" });
await page.waitForFunction(
() => document.querySelector("model-viewer")?.modelIsVisible === true,
{ timeout: 20000 },
).catch(() => {});
const element = await page.$("model-viewer");
await element.screenshot({ path: outputPath });
} finally {
await browser.close();
}
}
try {
const stats = await computeStats(inputPath);
try {
await renderThumbnail(inputPath, outputThumbnailPath);
} catch (renderError) {
// Thumbnail is best-effort; stats are the required output. Leaving no
// file at outputThumbnailPath signals the job to skip the thumbnail key.
console.error("Thumbnail render failed (non-fatal):", renderError.message);
}
process.stdout.write(JSON.stringify(stats));
} catch (error) {
console.error(error.message);
process.exit(1);
}