voronstl/web/batch_glb_png/batch_render.js

176 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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); });