| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- #!/usr/bin/env node
- /**
- * Headless content screenshot for Cocos Creator 3.x web builds.
- *
- * Why this exists: in the editor's edit-mode the render pipeline does NOT
- * rasterize scene geometry into a custom RenderTexture, and the in-editor
- * gameView preview runs in a separate engine context the MCP scene process
- * can't reach. So the only way to get a REAL engine render of just the Canvas
- * content (no editor chrome) is to run the actual runtime. This script loads a
- * web build in headless Chrome, switches to the target scene, waits for it to
- * render, and screenshots the game canvas — producing a content-only PNG at the
- * design resolution with a transparent (or chosen) background.
- *
- * Pipeline: static-serve the web build -> headless Chrome -> wait engine boot
- * -> director.loadScene(name) -> wait assets+layout+frames -> canvas screenshot.
- *
- * Usage:
- * node headless-screenshot.js --build <web-build-dir> --scene <name|db://...>
- * [--out <png>] [--width N] [--height N]
- * [--bg r,g,b,a] (0-255; default transparent)
- * [--chrome <path>] [--timeout ms] [--keep-fps]
- *
- * Exit 0 on success; prints a JSON result line (success,path,width,height,opaquePixels...).
- */
- const http = require('http');
- const fs = require('fs');
- const path = require('path');
- const zlib = require('zlib');
- // ---- self-contained PNG encoder (RGBA8 -> PNG, no external deps) ----
- // Kept inline so this script is a single portable file (works in the cocosMcp
- // repo and in any copy, e.g. dropped into the RT project). Mirrors the logic of
- // cocosMcp/source/tools/cocos/utils/png-encoder.ts.
- const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
- let _crc;
- function crcTable() {
- if (_crc) return _crc;
- _crc = new Array(256);
- for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); _crc[n] = c >>> 0; }
- return _crc;
- }
- function crc32(buf) { const t = crcTable(); let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
- function pngChunk(type, data) {
- const tb = Buffer.from(type, 'ascii'), lb = Buffer.alloc(4), cb = Buffer.alloc(4);
- lb.writeUInt32BE(data.length, 0);
- cb.writeUInt32BE(crc32(Buffer.concat([tb, data])), 0);
- return Buffer.concat([lb, tb, data, cb]);
- }
- function encodePng(opts) {
- const { width, height, flipY } = opts;
- const src = Buffer.isBuffer(opts.rgba) ? opts.rgba : Buffer.from(opts.rgba);
- const rowBytes = width * 4;
- const raw = Buffer.alloc((rowBytes + 1) * height);
- for (let y = 0; y < height; y++) {
- const sy = flipY ? (height - 1 - y) : y;
- raw[y * (rowBytes + 1)] = 0;
- src.copy(raw, y * (rowBytes + 1) + 1, sy * rowBytes, sy * rowBytes + rowBytes);
- }
- const ihdr = Buffer.alloc(13);
- ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
- ihdr.writeUInt8(8, 8); ihdr.writeUInt8(6, 9); ihdr.writeUInt8(0, 10); ihdr.writeUInt8(0, 11); ihdr.writeUInt8(0, 12);
- return Buffer.concat([PNG_SIG, pngChunk('IHDR', ihdr), pngChunk('IDAT', zlib.deflateSync(raw)), pngChunk('IEND', Buffer.alloc(0))]);
- }
- // ---- arg parsing ----
- function parseArgs(argv) {
- const a = {};
- for (let i = 2; i < argv.length; i++) {
- const k = argv[i];
- if (k.startsWith('--')) {
- const key = k.slice(2);
- const next = argv[i + 1];
- if (next === undefined || next.startsWith('--')) { a[key] = true; }
- else { a[key] = next; i++; }
- }
- }
- return a;
- }
- const args = parseArgs(process.argv);
- function fail(msg) {
- console.log(JSON.stringify({ success: false, error: msg }));
- process.exit(1);
- }
- const BUILD_DIR = args.build && path.resolve(args.build);
- if (!BUILD_DIR || !fs.existsSync(path.join(BUILD_DIR, 'index.html'))) {
- fail(`--build must point to a web build dir containing index.html (got: ${args.build})`);
- }
- // Scene: accept a db:// path or a bare name; we use the basename without extension.
- let sceneArg = args.scene;
- if (!sceneArg || sceneArg === true) fail('--scene <name|db://assets/.../X.scene> is required');
- const sceneName = String(sceneArg).replace(/^db:\/\//, '').replace(/\.scene$/, '').split('/').pop();
- const OUT = args.out ? path.resolve(args.out) : path.join(require('os').tmpdir(), `cocos3x_headless_${sceneName}.png`);
- const WANT_W = args.width ? parseInt(args.width, 10) : 0;
- const WANT_H = args.height ? parseInt(args.height, 10) : 0;
- let BG = { r: 0, g: 0, b: 0, a: 0 };
- if (args.bg && args.bg !== true) {
- const p = String(args.bg).split(',').map(s => parseInt(s.trim(), 10));
- BG = { r: p[0] || 0, g: p[1] || 0, b: p[2] || 0, a: p[3] === undefined ? 255 : p[3] };
- }
- const TIMEOUT = args.timeout ? parseInt(args.timeout, 10) : 30000;
- const KEEP_FPS = !!args['keep-fps'];
- // Optional: deactivate node(s) by name before capture (comma-separated). Useful to
- // drop a scene's full-screen background to verify a truly transparent capture.
- const HIDE_NODES = (args.hide && args.hide !== true) ? String(args.hide).split(',').map(s => s.trim()).filter(Boolean) : [];
- // Locate system Chrome (avoid bundling Chromium).
- function findChrome() {
- if (args.chrome && args.chrome !== true) return args.chrome;
- const candidates = [
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
- ];
- for (const c of candidates) if (fs.existsSync(c)) return c;
- return null;
- }
- // ---- minimal static file server for the build dir ----
- const MIME = {
- '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json',
- '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
- '.wasm': 'application/wasm', '.bin': 'application/octet-stream', '.mp3': 'audio/mpeg',
- '.ttf': 'font/ttf', '.plist': 'application/xml', '.svg': 'image/svg+xml',
- };
- function startServer(root) {
- return new Promise((resolve) => {
- const server = http.createServer((req, res) => {
- let urlPath = decodeURIComponent(req.url.split('?')[0]);
- if (urlPath === '/') urlPath = '/index.html';
- const filePath = path.join(root, urlPath);
- if (!filePath.startsWith(root) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
- res.writeHead(404); res.end('not found'); return;
- }
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Content-Type', MIME[path.extname(filePath)] || 'application/octet-stream');
- fs.createReadStream(filePath).pipe(res);
- });
- server.listen(0, '127.0.0.1', () => resolve(server));
- });
- }
- // ---- in-page driver: boot wait + loadScene + readiness + transparent bg ----
- // Runs inside the headless page. Returns {ok, w, h, err}.
- function pageDriver(sceneName, wantW, wantH, bg, keepFps, hideNodes) {
- return new Promise((resolve) => {
- const T0 = Date.now();
- const HARD = 25000;
- function err(m) { resolve({ ok: false, err: m, t: Date.now() - T0 }); }
- // 1) Wait for the engine to FULLY boot AND the start scene to actually be
- // running before we switch scenes. Proceeding as soon as `director` exists
- // races the in-flight start-scene load, which then clobbers our loadScene
- // (capturing the start scene instead of the target). Require getScene() to
- // return a real scene, then a short settle.
- let bootSeenAt = 0;
- (function waitEngine() {
- const cc = window.cc;
- const scene = cc && cc.director && cc.director.getScene ? cc.director.getScene() : null;
- const booted = cc && cc.director && cc.game && cc.game.canvas && scene && scene.name;
- if (booted) {
- if (!bootSeenAt) bootSeenAt = Date.now();
- // Let the start scene fully settle so its async load can't clobber ours.
- if (Date.now() - bootSeenAt >= 600) { afterEngine(cc); return; }
- }
- if (Date.now() - T0 > HARD) { err('engine did not boot (no running scene)'); return; }
- setTimeout(waitEngine, 50);
- })();
- function afterEngine(cc) {
- try {
- const dr = cc.view && cc.view.getDesignResolutionSize ? cc.view.getDesignResolutionSize() : null;
- const W = wantW || (dr && dr.width) || 960;
- const H = wantH || (dr && dr.height) || 640;
- window.__shot = { W: Math.round(W), H: Math.round(H) };
- // Switch to the target scene (the build starts at the project start scene).
- let switched = false;
- // loadScene returns false synchronously for an unknown scene and never
- // calls back — guard with that and a timeout so a bad scene name yields
- // a clean error instead of hanging until the outer timeout.
- const ret = cc.director.loadScene(sceneName, function (e) {
- switched = true;
- if (e) { err('loadScene("' + sceneName + '") failed: ' + (e.message || e)); return; }
- const cur = cc.director.getScene();
- window.__loadedScene = cur ? cur.name : null;
- afterScene(cc, Math.round(W), Math.round(H));
- });
- if (ret === false) { err('Scene not found in build: "' + sceneName + '". Check the scene name/path, or rebuild (rebuild:true) if it was added/renamed.'); return; }
- setTimeout(() => { if (!switched) err('loadScene("' + sceneName + '") timed out (scene may not exist in the build).'); }, 8000);
- } catch (e) { err('afterEngine: ' + (e && e.message || e)); }
- }
- function afterScene(cc, W, H) {
- try {
- // Hide the FPS/profiler stats overlay so it isn't captured as content.
- // Must be DETERMINISTIC for pixel-diff baselines: the engine can
- // (re)create the PROFILER_NODE on scene load and re-show stats a frame
- // later, so a one-shot hideStats() races. We hide it three ways and
- // re-apply every frame (see hideProfiler in the per-frame hook below):
- // 1) cc.profiler.hideStats() 2) deactivate the PROFILER_NODE
- // 3) zero the profiler setting so it can't re-show.
- const hideProfiler = () => {
- if (keepFps) return;
- try { if (cc.profiler && cc.profiler.hideStats) cc.profiler.hideStats(); } catch (e) {}
- try {
- const sc = cc.director.getScene();
- if (sc) {
- for (const n of sc.children) {
- if (n && n.name === 'PROFILER_NODE') { n.active = false; }
- }
- }
- } catch (e) {}
- try { if (cc.settings && cc.settings.overrideSettings) cc.settings.overrideSettings('profiling', 'showFPS', false); } catch (e) {}
- };
- hideProfiler();
- window.__hideProfiler = hideProfiler;
- // Optionally deactivate named nodes (e.g. a full-screen Background) so
- // a truly transparent capture can be produced for verification.
- if (hideNodes && hideNodes.length) {
- try {
- const sc0 = cc.director.getScene();
- const want = new Set(hideNodes);
- (function walk(n) {
- if (!n) return;
- if (want.has(n.name)) n.active = false;
- (n.children || []).forEach(walk);
- })(sc0);
- } catch (e) {}
- }
- // The web build's style.css gives <body> an opaque grey background.
- // With a transparent canvas, that grey would show through the canvas
- // compositing (and leak into the element screenshot). Null out the
- // page background so only the canvas content is captured.
- if (bg.a === 0) {
- try {
- document.documentElement.style.background = 'transparent';
- document.body.style.background = 'transparent';
- const c = cc.game.canvas;
- if (c) c.style.background = 'transparent';
- } catch (e) {}
- }
- // Make the on-screen canvas exactly the design resolution and force
- // the Canvas camera(s) to clear to the requested (transparent) color.
- const scene = cc.director.getScene();
- const Color = cc.Color;
- const cams = [];
- (function walk(n) {
- if (!n) return;
- if (n.getComponent) {
- const cam = n.getComponent(cc.Camera);
- if (cam) cams.push(cam);
- }
- (n.children || []).forEach(walk);
- })(scene);
- const diag = { forcedAlpha: !!window.__forcedAlpha, camCount: cams.length, camInfo: [] };
- const FLAG = cc.gfx.ClearFlagBit;
- // Re-apply the requested clear color to every camera (component +
- // low-level render camera) BEFORE EACH DRAW. The engine re-syncs the
- // low-level camera from the component each frame, so a one-shot set is
- // overwritten — re-applying every frame keeps it stuck until capture.
- const applyClear = () => {
- // Keep the FPS overlay hidden every frame (it can re-show otherwise).
- hideProfiler();
- for (const cam of cams) {
- try {
- cam.clearColor = new Color(bg.r, bg.g, bg.b, bg.a);
- cam.clearFlags = FLAG.ALL;
- const low = cam.camera;
- if (low) {
- if (low.clearColor && typeof low.clearColor.set === 'function') {
- low.clearColor.set(bg.r / 255, bg.g / 255, bg.b / 255, bg.a / 255);
- } else {
- low.clearColor = { x: bg.r / 255, y: bg.g / 255, z: bg.b / 255, w: bg.a / 255 };
- }
- if ('clearFlag' in low) low.clearFlag = FLAG.ALL;
- }
- } catch (e) {}
- }
- };
- applyClear();
- try { cc.director.on(cc.Director.EVENT_BEFORE_DRAW, applyClear); } catch (e) {}
- window.__applyClear = applyClear;
- for (const cam of cams) {
- const low = cam.camera;
- diag.camInfo.push({ node: cam.node && cam.node.name, afterClear: [cam.clearColor.r, cam.clearColor.g, cam.clearColor.b, cam.clearColor.a], clearFlags: cam.clearFlags, lowClear: low && low.clearColor ? [Math.round(low.clearColor.x * 255), Math.round(low.clearColor.y * 255), Math.round(low.clearColor.z * 255), Math.round(low.clearColor.w * 255)] : null });
- }
- window.__diag = diag;
- // NOTE: do NOT force canvas.width/height here — the engine's design
- // resolution policy already sizes the backbuffer, and resizing
- // mid-switch can capture a stale/previous scene. The page viewport is
- // set to the target size before load so the canvas comes up correct.
- diag.canvasWH = [cc.game.canvas.width, cc.game.canvas.height];
- // Wait for several real frames, then read the WebGL pixels DIRECTLY
- // off the backbuffer (inside the AFTER_DRAW handler, before the browser
- // composites/clears). This bypasses puppeteer's element-screenshot
- // compositing — which leaks the page background and drops alpha — and
- // gives the exact canvas content with a correct alpha channel.
- const canvas = cc.game.canvas;
- const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
- let frames = 0;
- let done = false;
- const finish = (rgbaBase64) => {
- if (done) return; done = true;
- cc.director.off(cc.Director.EVENT_AFTER_DRAW, onDraw);
- // Report the ACTUAL backbuffer dimensions (the PNG must match what
- // gl.readPixels returned, not the requested size which the engine's
- // resolution policy may have adjusted).
- resolve({ ok: true, w: canvas.width, h: canvas.height, requestedW: W, requestedH: H, frames: frames, diag: window.__diag, rgbaBase64: rgbaBase64, glReadback: !!rgbaBase64 });
- };
- const grab = () => {
- try {
- try { const s = cc.director.getScene(); window.__diag.sceneAtGrab = s ? s.name : null; window.__diag.topKids = s ? s.children.map(function(c){return c.name;}) : null; } catch (e) {}
- if (gl && !window.__diag.glAlpha) {
- const a = gl.getContextAttributes ? gl.getContextAttributes() : null;
- window.__diag.glAlpha = a ? a.alpha : null;
- window.__diag.glPreserve = a ? a.preserveDrawingBuffer : null;
- }
- const cw = canvas.width, ch = canvas.height;
- const buf = new Uint8Array(cw * ch * 4);
- gl.readPixels(0, 0, cw, ch, gl.RGBA, gl.UNSIGNED_BYTE, buf);
- // base64-encode in-page (binary-safe).
- let bin = '';
- const CHUNK = 0x8000;
- for (let i = 0; i < buf.length; i += CHUNK) {
- bin += String.fromCharCode.apply(null, buf.subarray(i, i + CHUNK));
- }
- return btoa(bin);
- } catch (e) { return null; }
- };
- // Give the freshly-loaded scene time to (a) finish loading async
- // sprite/font textures and (b) settle layout, BEFORE we start counting
- // the frames we capture. Reading too early can grab the previous scene
- // (preserveDrawingBuffer keeps the last frame) or a half-loaded one.
- const SETTLE_MS = 1200;
- const t0 = Date.now();
- let counting = false;
- const onDraw = () => {
- if (!counting) {
- if (Date.now() - t0 < SETTLE_MS) return;
- counting = true; frames = 0;
- }
- frames++;
- if (frames >= 4) {
- const data = gl ? grab() : null;
- finish(data);
- }
- };
- cc.director.on(cc.Director.EVENT_AFTER_DRAW, onDraw);
- // Safety timeout: if AFTER_DRAW stalls, grab whatever is there.
- setTimeout(() => { if (!done) finish(gl ? grab() : null); }, SETTLE_MS + 4000);
- } catch (e) { err('afterScene: ' + (e && e.message || e)); }
- }
- });
- }
- // ---- main ----
- (async () => {
- const chromePath = findChrome();
- if (!chromePath) fail('No Chrome/Chromium/Edge found. Pass --chrome <path>.');
- // Resolve puppeteer-core. Modern puppeteer-core ships as ESM, which can't be
- // require()'d from CommonJS (and we run under ELECTRON_RUN_AS_NODE), so load it
- // via dynamic import(). Try a passed --puppeteer path, then common locations,
- // so this single file works both in-repo and when copied into the extension.
- let puppeteer;
- const { pathToFileURL } = require('url');
- const ppCandidates = [
- 'puppeteer-core',
- path.join(__dirname, 'node_modules', 'puppeteer-core'),
- path.join(__dirname, '..', 'node_modules', 'puppeteer-core'),
- path.join(__dirname, '..', '..', 'node_modules', 'puppeteer-core'),
- // 通用兜底:从调用进程的工作目录解析(不再硬编码任何作者机器上的绝对路径)。
- path.join(process.cwd(), 'node_modules', 'puppeteer-core'),
- ];
- if (args['puppeteer'] && args['puppeteer'] !== true) ppCandidates.unshift(args['puppeteer']);
- let ppErr = '';
- for (const c of ppCandidates) {
- try {
- // For bare specifiers, import by name; for filesystem paths, import the
- // package's resolved entry. Resolve via require.resolve first so import()
- // gets the actual ESM entry file URL.
- let mod;
- if (c.startsWith('/') || c.startsWith('.')) {
- let entry;
- try { entry = require.resolve(c); } catch (e) { entry = path.join(c, 'lib', 'esm', 'puppeteer', 'puppeteer-core.js'); }
- mod = await import(pathToFileURL(entry).href);
- } else {
- mod = await import(c);
- }
- puppeteer = mod.default || mod;
- if (puppeteer && (puppeteer.launch || (puppeteer.default && puppeteer.default.launch))) {
- puppeteer = puppeteer.launch ? puppeteer : puppeteer.default;
- break;
- }
- puppeteer = null;
- } catch (e) { ppErr = String(e && e.message || e); }
- }
- if (!puppeteer || typeof puppeteer.launch !== 'function') {
- fail('puppeteer-core not loadable (ESM import failed). Last error: ' + ppErr + '. Pass --puppeteer <dir>.');
- }
- const server = await startServer(BUILD_DIR);
- const port = server.address().port;
- const url = `http://127.0.0.1:${port}/index.html`;
- let browser;
- try {
- browser = await puppeteer.launch({
- executablePath: chromePath,
- headless: 'new',
- args: [
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--use-gl=angle',
- '--use-angle=swiftshader', // software WebGL — reliable in headless
- '--enable-webgl',
- '--ignore-gpu-blocklist',
- '--hide-scrollbars',
- '--mute-audio',
- '--force-device-scale-factor=1',
- ],
- });
- const page = await browser.newPage();
- await page.setViewport({ width: Math.max(WANT_W || 960, 320), height: Math.max(WANT_H || 640, 240), deviceScaleFactor: 1 });
- const pageErrors = [];
- page.on('pageerror', e => pageErrors.push(String(e && e.message || e)));
- page.on('console', m => { if (m.type() === 'error') pageErrors.push('console.error: ' + m.text()); });
- // Patch HTMLCanvasElement.getContext BEFORE any page script runs:
- // - alpha:true so a transparent backbuffer is possible (the engine otherwise
- // creates the context opaque from cc.macro.ENABLE_TRANSPARENT_CANVAS=false,
- // which we can't reliably flip before context creation);
- // - preserveDrawingBuffer:true so gl.readPixels returns the rendered frame
- // instead of a cleared buffer.
- // We then read the canvas pixels directly (in the page driver) and encode the
- // PNG in Node — bypassing puppeteer's element screenshot, which composites the
- // canvas against the page's grey body background and drops the alpha channel.
- await page.evaluateOnNewDocument((wantAlpha) => {
- try {
- const orig = HTMLCanvasElement.prototype.getContext;
- HTMLCanvasElement.prototype.getContext = function (type, attrs) {
- if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
- attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true });
- if (wantAlpha) { attrs.alpha = true; attrs.premultipliedAlpha = true; window.__forcedAlpha = true; }
- else { attrs.alpha = false; }
- }
- return orig.call(this, type, attrs);
- };
- } catch (e) { /* ignore */ }
- }, BG.a === 0);
- await page.goto(url, { waitUntil: 'networkidle2', timeout: TIMEOUT });
- const driveResult = await page.evaluate(pageDriver, sceneName, WANT_W, WANT_H, BG, KEEP_FPS, HIDE_NODES);
- if (!driveResult || !driveResult.ok) {
- fail(`in-page driver failed: ${driveResult && driveResult.err}. pageErrors=${JSON.stringify(pageErrors.slice(0, 5))}`);
- }
- const W = driveResult.w, H = driveResult.h;
- let pngBuf;
- let opaquePixels;
- if (driveResult.rgbaBase64) {
- // Direct WebGL readback path (preferred): exact content + correct alpha.
- const rgba = Buffer.from(driveResult.rgbaBase64, 'base64');
- opaquePixels = 0;
- for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 0) opaquePixels++;
- // gl.readPixels is bottom-up; flip to top-down for PNG.
- pngBuf = encodePng({ rgba, width: W, height: H, flipY: true });
- } else {
- // Fallback: puppeteer element screenshot (may include page bg / no alpha).
- const canvasEl = await page.$('#GameCanvas') || await page.$('canvas');
- if (!canvasEl) fail('No game canvas element found and no GL readback.');
- pngBuf = await canvasEl.screenshot({ omitBackground: BG.a === 0, type: 'png' });
- }
- fs.writeFileSync(OUT, pngBuf);
- console.log(JSON.stringify({
- success: true,
- path: OUT,
- width: W,
- height: H,
- bytes: pngBuf.length,
- scene: sceneName,
- background: BG,
- frames: driveResult.frames,
- opaquePixels: opaquePixels,
- captureMethod: driveResult.rgbaBase64 ? 'gl-readback' : 'puppeteer-screenshot',
- diag: driveResult.diag,
- pageErrors: pageErrors.slice(0, 3),
- }));
- await browser.close();
- server.close();
- process.exit(0);
- } catch (e) {
- try { if (browser) await browser.close(); } catch (_) {}
- try { server.close(); } catch (_) {}
- fail('headless run error: ' + (e && e.message || e));
- }
- })();
|