Spaces:
Building
Building
| /** | |
| * 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); | |
| } | |