voronstl/web/batch_glb_png/batch_render.js

176 lines
6.1 KiB
JavaScript
Raw Normal View History

2026-02-28 16:33:16 -08:00
#!/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)}`;
// Dont 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); });