#!/usr/bin/env node /** * Partner Project Media 3D microtool. * * Usage: node render-3d-preview.mjs * 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 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 "); 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 = ` `; 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); }