176 lines
6.1 KiB
JavaScript
176 lines
6.1 KiB
JavaScript
|
|
#!/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); });
|