#!/usr/bin/env node /** * batch_render.js * 20260228 ChatGPT * $Header$ * * Example: * node batch_render.js job.json */ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import http from 'node:http'; import { pathToFileURL, fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; function die (msg) { console.error(msg); process.exit(1); } function ensureDir(p) { fs.mkdirSync(p, { recursive: true }); } function listFiles(dir, suffix) { const out = []; for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, ent.name); if (ent.isDirectory()) out.push(...listFiles(full, suffix)); else if (!suffix || ent.name.toLowerCase().endsWith(suffix)) out.push(full); } return out; } function sanitizeBasename(p) { const b = path.basename(p); return b.replace(/[^\w.\-]+/g, '_'); } function mimeTypeFor(p) { const ext = path.extname(p).toLowerCase(); if (ext === '.html') return 'text/html; charset=utf-8'; if (ext === '.js') return 'text/javascript; charset=utf-8'; if (ext === '.json') return 'application/json; charset=utf-8'; if (ext === '.glb') return 'model/gltf-binary'; if (ext === '.gltf') return 'model/gltf+json; charset=utf-8'; if (ext === '.bin') return 'application/octet-stream'; if (ext === '.png') return 'image/png'; if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; if (ext === '.css') return 'text/css; charset=utf-8'; return 'application/octet-stream'; } // Serve static files from rootDir function startStaticServer(rootDir) { const server = http.createServer((req, res) => { try { const urlPath = decodeURIComponent((req.url || '/').split('?')[0]); const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, ''); const filePath = path.join(rootDir, safePath); // Default route const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isDirectory() ? path.join(filePath, 'index.html') : filePath; if (!fs.existsSync(finalPath) || !fs.statSync(finalPath).isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('404\n'); return; } const data = fs.readFileSync(finalPath); res.writeHead(200, { 'Content-Type': mimeTypeFor(finalPath) }); res.end(data); } catch (e) { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(`500\n${String(e)}\n`); } }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const addr = server.address(); resolve({ server, port: addr.port }); }); }); } async function main() { const jobPath = process.argv[2]; if (!jobPath) die("Usage: node batch_render.js job.json"); const jobAbs = path.resolve(jobPath); const jobDir = path.dirname(jobAbs); const job = JSON.parse(fs.readFileSync(jobAbs, 'utf8')); const profileAbs = path.resolve(jobDir, job.profile ?? die("job.profile missing")); const inputDirAbs = path.resolve(jobDir, job.input_dir ?? die("job.input_dir missing")); const outputDirAbs = path.resolve(jobDir, job.output_dir ?? die("job.output_dir missing")); const pattern = (job.pattern ?? ".glb").toLowerCase(); ensureDir(outputDirAbs); const glbs = listFiles(inputDirAbs, pattern); if (glbs.length === 0) die(`No files ending with '${pattern}' found under ${inputDirAbs}`); const renderHtmlAbs = path.resolve(jobDir, 'render.html'); if (!fs.existsSync(renderHtmlAbs)) die(`Missing render.html at ${renderHtmlAbs}`); // Start local server rooted at jobDir so render.html, libs/, glbs/, profile json are reachable const { server, port } = await startStaticServer(jobDir); const base = `http://127.0.0.1:${port}`; // Load profile for viewport sizing const profile = JSON.parse(fs.readFileSync(profileAbs, 'utf8')); const vw = profile.output?.width ?? 1024; const vh = profile.output?.height ?? 768; const dpr = profile.output?.pixelRatio ?? 1; const browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader' ] }); try { const page = await browser.newPage(); // Log page-side failures clearly page.on('console', (msg) => console.log('PAGE console:', msg.type(), msg.text())); page.on('pageerror', (err) => console.log('PAGE error:', err)); page.on('requestfailed', (req) => console.log('REQ failed:', req.url(), req.failure()?.errorText)); await page.setViewport({ width: vw, height: vh, deviceScaleFactor: dpr }); // Make URLs relative to server root const renderUrl = `${base}/render.html`; const profileUrl = `${base}/${encodeURIComponent(path.relative(jobDir, profileAbs)).replace(/%2F/g,'/')}`; for (const glbAbs of glbs) { const rel = path.relative(jobDir, glbAbs); const glbUrl = `${base}/${encodeURIComponent(rel).replace(/%2F/g,'/')}`; const outName = sanitizeBasename(glbAbs).replace(/\.glb$/i, '.png'); const outAbs = path.join(outputDirAbs, outName); const url = `${renderUrl}?glb=${encodeURIComponent(glbUrl)}&profile=${encodeURIComponent(profileUrl)}`; // Don’t use networkidle0 here; module imports + GLB load can keep network “busy” await page.goto(url, { waitUntil: 'load' }); await page.waitForFunction(() => { return window.__RENDER_STATUS__ && window.__RENDER_STATUS__.ready === true; }, { timeout: 120000 }); // 2 minutes, GLBs can be chunky const status = await page.evaluate(() => window.__RENDER_STATUS__); if (!status || !status.pngDataUrl) { throw new Error(`No pngDataUrl produced for ${glbAbs} (status=${JSON.stringify(status)})`); } const b64 = status.pngDataUrl.replace(/^data:image\/png;base64,/, ''); fs.writeFileSync(outAbs, Buffer.from(b64, 'base64')); console.log(`WROTE\t${outAbs}`); } await page.close(); } finally { await browser.close(); server.close(); } } main().catch((e) => { console.error("ERROR:", e); process.exit(2); });