headless-screenshot.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. #!/usr/bin/env node
  2. /**
  3. * Headless content screenshot for Cocos Creator 3.x web builds.
  4. *
  5. * Why this exists: in the editor's edit-mode the render pipeline does NOT
  6. * rasterize scene geometry into a custom RenderTexture, and the in-editor
  7. * gameView preview runs in a separate engine context the MCP scene process
  8. * can't reach. So the only way to get a REAL engine render of just the Canvas
  9. * content (no editor chrome) is to run the actual runtime. This script loads a
  10. * web build in headless Chrome, switches to the target scene, waits for it to
  11. * render, and screenshots the game canvas — producing a content-only PNG at the
  12. * design resolution with a transparent (or chosen) background.
  13. *
  14. * Pipeline: static-serve the web build -> headless Chrome -> wait engine boot
  15. * -> director.loadScene(name) -> wait assets+layout+frames -> canvas screenshot.
  16. *
  17. * Usage:
  18. * node headless-screenshot.js --build <web-build-dir> --scene <name|db://...>
  19. * [--out <png>] [--width N] [--height N]
  20. * [--bg r,g,b,a] (0-255; default transparent)
  21. * [--chrome <path>] [--timeout ms] [--keep-fps]
  22. *
  23. * Exit 0 on success; prints a JSON result line (success,path,width,height,opaquePixels...).
  24. */
  25. const http = require('http');
  26. const fs = require('fs');
  27. const path = require('path');
  28. const zlib = require('zlib');
  29. // ---- self-contained PNG encoder (RGBA8 -> PNG, no external deps) ----
  30. // Kept inline so this script is a single portable file (works in the cocosMcp
  31. // repo and in any copy, e.g. dropped into the RT project). Mirrors the logic of
  32. // cocosMcp/source/tools/cocos/utils/png-encoder.ts.
  33. const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  34. let _crc;
  35. function crcTable() {
  36. if (_crc) return _crc;
  37. _crc = new Array(256);
  38. 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; }
  39. return _crc;
  40. }
  41. 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; }
  42. function pngChunk(type, data) {
  43. const tb = Buffer.from(type, 'ascii'), lb = Buffer.alloc(4), cb = Buffer.alloc(4);
  44. lb.writeUInt32BE(data.length, 0);
  45. cb.writeUInt32BE(crc32(Buffer.concat([tb, data])), 0);
  46. return Buffer.concat([lb, tb, data, cb]);
  47. }
  48. function encodePng(opts) {
  49. const { width, height, flipY } = opts;
  50. const src = Buffer.isBuffer(opts.rgba) ? opts.rgba : Buffer.from(opts.rgba);
  51. const rowBytes = width * 4;
  52. const raw = Buffer.alloc((rowBytes + 1) * height);
  53. for (let y = 0; y < height; y++) {
  54. const sy = flipY ? (height - 1 - y) : y;
  55. raw[y * (rowBytes + 1)] = 0;
  56. src.copy(raw, y * (rowBytes + 1) + 1, sy * rowBytes, sy * rowBytes + rowBytes);
  57. }
  58. const ihdr = Buffer.alloc(13);
  59. ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
  60. ihdr.writeUInt8(8, 8); ihdr.writeUInt8(6, 9); ihdr.writeUInt8(0, 10); ihdr.writeUInt8(0, 11); ihdr.writeUInt8(0, 12);
  61. return Buffer.concat([PNG_SIG, pngChunk('IHDR', ihdr), pngChunk('IDAT', zlib.deflateSync(raw)), pngChunk('IEND', Buffer.alloc(0))]);
  62. }
  63. // ---- arg parsing ----
  64. function parseArgs(argv) {
  65. const a = {};
  66. for (let i = 2; i < argv.length; i++) {
  67. const k = argv[i];
  68. if (k.startsWith('--')) {
  69. const key = k.slice(2);
  70. const next = argv[i + 1];
  71. if (next === undefined || next.startsWith('--')) { a[key] = true; }
  72. else { a[key] = next; i++; }
  73. }
  74. }
  75. return a;
  76. }
  77. const args = parseArgs(process.argv);
  78. function fail(msg) {
  79. console.log(JSON.stringify({ success: false, error: msg }));
  80. process.exit(1);
  81. }
  82. const BUILD_DIR = args.build && path.resolve(args.build);
  83. if (!BUILD_DIR || !fs.existsSync(path.join(BUILD_DIR, 'index.html'))) {
  84. fail(`--build must point to a web build dir containing index.html (got: ${args.build})`);
  85. }
  86. // Scene: accept a db:// path or a bare name; we use the basename without extension.
  87. let sceneArg = args.scene;
  88. if (!sceneArg || sceneArg === true) fail('--scene <name|db://assets/.../X.scene> is required');
  89. const sceneName = String(sceneArg).replace(/^db:\/\//, '').replace(/\.scene$/, '').split('/').pop();
  90. const OUT = args.out ? path.resolve(args.out) : path.join(require('os').tmpdir(), `cocos3x_headless_${sceneName}.png`);
  91. const WANT_W = args.width ? parseInt(args.width, 10) : 0;
  92. const WANT_H = args.height ? parseInt(args.height, 10) : 0;
  93. let BG = { r: 0, g: 0, b: 0, a: 0 };
  94. if (args.bg && args.bg !== true) {
  95. const p = String(args.bg).split(',').map(s => parseInt(s.trim(), 10));
  96. BG = { r: p[0] || 0, g: p[1] || 0, b: p[2] || 0, a: p[3] === undefined ? 255 : p[3] };
  97. }
  98. const TIMEOUT = args.timeout ? parseInt(args.timeout, 10) : 30000;
  99. const KEEP_FPS = !!args['keep-fps'];
  100. // Optional: deactivate node(s) by name before capture (comma-separated). Useful to
  101. // drop a scene's full-screen background to verify a truly transparent capture.
  102. const HIDE_NODES = (args.hide && args.hide !== true) ? String(args.hide).split(',').map(s => s.trim()).filter(Boolean) : [];
  103. // Locate system Chrome (avoid bundling Chromium).
  104. function findChrome() {
  105. if (args.chrome && args.chrome !== true) return args.chrome;
  106. const candidates = [
  107. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  108. '/Applications/Chromium.app/Contents/MacOS/Chromium',
  109. '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  110. ];
  111. for (const c of candidates) if (fs.existsSync(c)) return c;
  112. return null;
  113. }
  114. // ---- minimal static file server for the build dir ----
  115. const MIME = {
  116. '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json',
  117. '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  118. '.wasm': 'application/wasm', '.bin': 'application/octet-stream', '.mp3': 'audio/mpeg',
  119. '.ttf': 'font/ttf', '.plist': 'application/xml', '.svg': 'image/svg+xml',
  120. };
  121. function startServer(root) {
  122. return new Promise((resolve) => {
  123. const server = http.createServer((req, res) => {
  124. let urlPath = decodeURIComponent(req.url.split('?')[0]);
  125. if (urlPath === '/') urlPath = '/index.html';
  126. const filePath = path.join(root, urlPath);
  127. if (!filePath.startsWith(root) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
  128. res.writeHead(404); res.end('not found'); return;
  129. }
  130. res.setHeader('Access-Control-Allow-Origin', '*');
  131. res.setHeader('Content-Type', MIME[path.extname(filePath)] || 'application/octet-stream');
  132. fs.createReadStream(filePath).pipe(res);
  133. });
  134. server.listen(0, '127.0.0.1', () => resolve(server));
  135. });
  136. }
  137. // ---- in-page driver: boot wait + loadScene + readiness + transparent bg ----
  138. // Runs inside the headless page. Returns {ok, w, h, err}.
  139. function pageDriver(sceneName, wantW, wantH, bg, keepFps, hideNodes) {
  140. return new Promise((resolve) => {
  141. const T0 = Date.now();
  142. const HARD = 25000;
  143. function err(m) { resolve({ ok: false, err: m, t: Date.now() - T0 }); }
  144. // 1) Wait for the engine to FULLY boot AND the start scene to actually be
  145. // running before we switch scenes. Proceeding as soon as `director` exists
  146. // races the in-flight start-scene load, which then clobbers our loadScene
  147. // (capturing the start scene instead of the target). Require getScene() to
  148. // return a real scene, then a short settle.
  149. let bootSeenAt = 0;
  150. (function waitEngine() {
  151. const cc = window.cc;
  152. const scene = cc && cc.director && cc.director.getScene ? cc.director.getScene() : null;
  153. const booted = cc && cc.director && cc.game && cc.game.canvas && scene && scene.name;
  154. if (booted) {
  155. if (!bootSeenAt) bootSeenAt = Date.now();
  156. // Let the start scene fully settle so its async load can't clobber ours.
  157. if (Date.now() - bootSeenAt >= 600) { afterEngine(cc); return; }
  158. }
  159. if (Date.now() - T0 > HARD) { err('engine did not boot (no running scene)'); return; }
  160. setTimeout(waitEngine, 50);
  161. })();
  162. function afterEngine(cc) {
  163. try {
  164. const dr = cc.view && cc.view.getDesignResolutionSize ? cc.view.getDesignResolutionSize() : null;
  165. const W = wantW || (dr && dr.width) || 960;
  166. const H = wantH || (dr && dr.height) || 640;
  167. window.__shot = { W: Math.round(W), H: Math.round(H) };
  168. // Switch to the target scene (the build starts at the project start scene).
  169. let switched = false;
  170. // loadScene returns false synchronously for an unknown scene and never
  171. // calls back — guard with that and a timeout so a bad scene name yields
  172. // a clean error instead of hanging until the outer timeout.
  173. const ret = cc.director.loadScene(sceneName, function (e) {
  174. switched = true;
  175. if (e) { err('loadScene("' + sceneName + '") failed: ' + (e.message || e)); return; }
  176. const cur = cc.director.getScene();
  177. window.__loadedScene = cur ? cur.name : null;
  178. afterScene(cc, Math.round(W), Math.round(H));
  179. });
  180. 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; }
  181. setTimeout(() => { if (!switched) err('loadScene("' + sceneName + '") timed out (scene may not exist in the build).'); }, 8000);
  182. } catch (e) { err('afterEngine: ' + (e && e.message || e)); }
  183. }
  184. function afterScene(cc, W, H) {
  185. try {
  186. // Hide the FPS/profiler stats overlay so it isn't captured as content.
  187. // Must be DETERMINISTIC for pixel-diff baselines: the engine can
  188. // (re)create the PROFILER_NODE on scene load and re-show stats a frame
  189. // later, so a one-shot hideStats() races. We hide it three ways and
  190. // re-apply every frame (see hideProfiler in the per-frame hook below):
  191. // 1) cc.profiler.hideStats() 2) deactivate the PROFILER_NODE
  192. // 3) zero the profiler setting so it can't re-show.
  193. const hideProfiler = () => {
  194. if (keepFps) return;
  195. try { if (cc.profiler && cc.profiler.hideStats) cc.profiler.hideStats(); } catch (e) {}
  196. try {
  197. const sc = cc.director.getScene();
  198. if (sc) {
  199. for (const n of sc.children) {
  200. if (n && n.name === 'PROFILER_NODE') { n.active = false; }
  201. }
  202. }
  203. } catch (e) {}
  204. try { if (cc.settings && cc.settings.overrideSettings) cc.settings.overrideSettings('profiling', 'showFPS', false); } catch (e) {}
  205. };
  206. hideProfiler();
  207. window.__hideProfiler = hideProfiler;
  208. // Optionally deactivate named nodes (e.g. a full-screen Background) so
  209. // a truly transparent capture can be produced for verification.
  210. if (hideNodes && hideNodes.length) {
  211. try {
  212. const sc0 = cc.director.getScene();
  213. const want = new Set(hideNodes);
  214. (function walk(n) {
  215. if (!n) return;
  216. if (want.has(n.name)) n.active = false;
  217. (n.children || []).forEach(walk);
  218. })(sc0);
  219. } catch (e) {}
  220. }
  221. // The web build's style.css gives <body> an opaque grey background.
  222. // With a transparent canvas, that grey would show through the canvas
  223. // compositing (and leak into the element screenshot). Null out the
  224. // page background so only the canvas content is captured.
  225. if (bg.a === 0) {
  226. try {
  227. document.documentElement.style.background = 'transparent';
  228. document.body.style.background = 'transparent';
  229. const c = cc.game.canvas;
  230. if (c) c.style.background = 'transparent';
  231. } catch (e) {}
  232. }
  233. // Make the on-screen canvas exactly the design resolution and force
  234. // the Canvas camera(s) to clear to the requested (transparent) color.
  235. const scene = cc.director.getScene();
  236. const Color = cc.Color;
  237. const cams = [];
  238. (function walk(n) {
  239. if (!n) return;
  240. if (n.getComponent) {
  241. const cam = n.getComponent(cc.Camera);
  242. if (cam) cams.push(cam);
  243. }
  244. (n.children || []).forEach(walk);
  245. })(scene);
  246. const diag = { forcedAlpha: !!window.__forcedAlpha, camCount: cams.length, camInfo: [] };
  247. const FLAG = cc.gfx.ClearFlagBit;
  248. // Re-apply the requested clear color to every camera (component +
  249. // low-level render camera) BEFORE EACH DRAW. The engine re-syncs the
  250. // low-level camera from the component each frame, so a one-shot set is
  251. // overwritten — re-applying every frame keeps it stuck until capture.
  252. const applyClear = () => {
  253. // Keep the FPS overlay hidden every frame (it can re-show otherwise).
  254. hideProfiler();
  255. for (const cam of cams) {
  256. try {
  257. cam.clearColor = new Color(bg.r, bg.g, bg.b, bg.a);
  258. cam.clearFlags = FLAG.ALL;
  259. const low = cam.camera;
  260. if (low) {
  261. if (low.clearColor && typeof low.clearColor.set === 'function') {
  262. low.clearColor.set(bg.r / 255, bg.g / 255, bg.b / 255, bg.a / 255);
  263. } else {
  264. low.clearColor = { x: bg.r / 255, y: bg.g / 255, z: bg.b / 255, w: bg.a / 255 };
  265. }
  266. if ('clearFlag' in low) low.clearFlag = FLAG.ALL;
  267. }
  268. } catch (e) {}
  269. }
  270. };
  271. applyClear();
  272. try { cc.director.on(cc.Director.EVENT_BEFORE_DRAW, applyClear); } catch (e) {}
  273. window.__applyClear = applyClear;
  274. for (const cam of cams) {
  275. const low = cam.camera;
  276. 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 });
  277. }
  278. window.__diag = diag;
  279. // NOTE: do NOT force canvas.width/height here — the engine's design
  280. // resolution policy already sizes the backbuffer, and resizing
  281. // mid-switch can capture a stale/previous scene. The page viewport is
  282. // set to the target size before load so the canvas comes up correct.
  283. diag.canvasWH = [cc.game.canvas.width, cc.game.canvas.height];
  284. // Wait for several real frames, then read the WebGL pixels DIRECTLY
  285. // off the backbuffer (inside the AFTER_DRAW handler, before the browser
  286. // composites/clears). This bypasses puppeteer's element-screenshot
  287. // compositing — which leaks the page background and drops alpha — and
  288. // gives the exact canvas content with a correct alpha channel.
  289. const canvas = cc.game.canvas;
  290. const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
  291. let frames = 0;
  292. let done = false;
  293. const finish = (rgbaBase64) => {
  294. if (done) return; done = true;
  295. cc.director.off(cc.Director.EVENT_AFTER_DRAW, onDraw);
  296. // Report the ACTUAL backbuffer dimensions (the PNG must match what
  297. // gl.readPixels returned, not the requested size which the engine's
  298. // resolution policy may have adjusted).
  299. resolve({ ok: true, w: canvas.width, h: canvas.height, requestedW: W, requestedH: H, frames: frames, diag: window.__diag, rgbaBase64: rgbaBase64, glReadback: !!rgbaBase64 });
  300. };
  301. const grab = () => {
  302. try {
  303. 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) {}
  304. if (gl && !window.__diag.glAlpha) {
  305. const a = gl.getContextAttributes ? gl.getContextAttributes() : null;
  306. window.__diag.glAlpha = a ? a.alpha : null;
  307. window.__diag.glPreserve = a ? a.preserveDrawingBuffer : null;
  308. }
  309. const cw = canvas.width, ch = canvas.height;
  310. const buf = new Uint8Array(cw * ch * 4);
  311. gl.readPixels(0, 0, cw, ch, gl.RGBA, gl.UNSIGNED_BYTE, buf);
  312. // base64-encode in-page (binary-safe).
  313. let bin = '';
  314. const CHUNK = 0x8000;
  315. for (let i = 0; i < buf.length; i += CHUNK) {
  316. bin += String.fromCharCode.apply(null, buf.subarray(i, i + CHUNK));
  317. }
  318. return btoa(bin);
  319. } catch (e) { return null; }
  320. };
  321. // Give the freshly-loaded scene time to (a) finish loading async
  322. // sprite/font textures and (b) settle layout, BEFORE we start counting
  323. // the frames we capture. Reading too early can grab the previous scene
  324. // (preserveDrawingBuffer keeps the last frame) or a half-loaded one.
  325. const SETTLE_MS = 1200;
  326. const t0 = Date.now();
  327. let counting = false;
  328. const onDraw = () => {
  329. if (!counting) {
  330. if (Date.now() - t0 < SETTLE_MS) return;
  331. counting = true; frames = 0;
  332. }
  333. frames++;
  334. if (frames >= 4) {
  335. const data = gl ? grab() : null;
  336. finish(data);
  337. }
  338. };
  339. cc.director.on(cc.Director.EVENT_AFTER_DRAW, onDraw);
  340. // Safety timeout: if AFTER_DRAW stalls, grab whatever is there.
  341. setTimeout(() => { if (!done) finish(gl ? grab() : null); }, SETTLE_MS + 4000);
  342. } catch (e) { err('afterScene: ' + (e && e.message || e)); }
  343. }
  344. });
  345. }
  346. // ---- main ----
  347. (async () => {
  348. const chromePath = findChrome();
  349. if (!chromePath) fail('No Chrome/Chromium/Edge found. Pass --chrome <path>.');
  350. // Resolve puppeteer-core. Modern puppeteer-core ships as ESM, which can't be
  351. // require()'d from CommonJS (and we run under ELECTRON_RUN_AS_NODE), so load it
  352. // via dynamic import(). Try a passed --puppeteer path, then common locations,
  353. // so this single file works both in-repo and when copied into the extension.
  354. let puppeteer;
  355. const { pathToFileURL } = require('url');
  356. const ppCandidates = [
  357. 'puppeteer-core',
  358. path.join(__dirname, 'node_modules', 'puppeteer-core'),
  359. path.join(__dirname, '..', 'node_modules', 'puppeteer-core'),
  360. path.join(__dirname, '..', '..', 'node_modules', 'puppeteer-core'),
  361. // 通用兜底:从调用进程的工作目录解析(不再硬编码任何作者机器上的绝对路径)。
  362. path.join(process.cwd(), 'node_modules', 'puppeteer-core'),
  363. ];
  364. if (args['puppeteer'] && args['puppeteer'] !== true) ppCandidates.unshift(args['puppeteer']);
  365. let ppErr = '';
  366. for (const c of ppCandidates) {
  367. try {
  368. // For bare specifiers, import by name; for filesystem paths, import the
  369. // package's resolved entry. Resolve via require.resolve first so import()
  370. // gets the actual ESM entry file URL.
  371. let mod;
  372. if (c.startsWith('/') || c.startsWith('.')) {
  373. let entry;
  374. try { entry = require.resolve(c); } catch (e) { entry = path.join(c, 'lib', 'esm', 'puppeteer', 'puppeteer-core.js'); }
  375. mod = await import(pathToFileURL(entry).href);
  376. } else {
  377. mod = await import(c);
  378. }
  379. puppeteer = mod.default || mod;
  380. if (puppeteer && (puppeteer.launch || (puppeteer.default && puppeteer.default.launch))) {
  381. puppeteer = puppeteer.launch ? puppeteer : puppeteer.default;
  382. break;
  383. }
  384. puppeteer = null;
  385. } catch (e) { ppErr = String(e && e.message || e); }
  386. }
  387. if (!puppeteer || typeof puppeteer.launch !== 'function') {
  388. fail('puppeteer-core not loadable (ESM import failed). Last error: ' + ppErr + '. Pass --puppeteer <dir>.');
  389. }
  390. const server = await startServer(BUILD_DIR);
  391. const port = server.address().port;
  392. const url = `http://127.0.0.1:${port}/index.html`;
  393. let browser;
  394. try {
  395. browser = await puppeteer.launch({
  396. executablePath: chromePath,
  397. headless: 'new',
  398. args: [
  399. '--no-sandbox',
  400. '--disable-setuid-sandbox',
  401. '--use-gl=angle',
  402. '--use-angle=swiftshader', // software WebGL — reliable in headless
  403. '--enable-webgl',
  404. '--ignore-gpu-blocklist',
  405. '--hide-scrollbars',
  406. '--mute-audio',
  407. '--force-device-scale-factor=1',
  408. ],
  409. });
  410. const page = await browser.newPage();
  411. await page.setViewport({ width: Math.max(WANT_W || 960, 320), height: Math.max(WANT_H || 640, 240), deviceScaleFactor: 1 });
  412. const pageErrors = [];
  413. page.on('pageerror', e => pageErrors.push(String(e && e.message || e)));
  414. page.on('console', m => { if (m.type() === 'error') pageErrors.push('console.error: ' + m.text()); });
  415. // Patch HTMLCanvasElement.getContext BEFORE any page script runs:
  416. // - alpha:true so a transparent backbuffer is possible (the engine otherwise
  417. // creates the context opaque from cc.macro.ENABLE_TRANSPARENT_CANVAS=false,
  418. // which we can't reliably flip before context creation);
  419. // - preserveDrawingBuffer:true so gl.readPixels returns the rendered frame
  420. // instead of a cleared buffer.
  421. // We then read the canvas pixels directly (in the page driver) and encode the
  422. // PNG in Node — bypassing puppeteer's element screenshot, which composites the
  423. // canvas against the page's grey body background and drops the alpha channel.
  424. await page.evaluateOnNewDocument((wantAlpha) => {
  425. try {
  426. const orig = HTMLCanvasElement.prototype.getContext;
  427. HTMLCanvasElement.prototype.getContext = function (type, attrs) {
  428. if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
  429. attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true });
  430. if (wantAlpha) { attrs.alpha = true; attrs.premultipliedAlpha = true; window.__forcedAlpha = true; }
  431. else { attrs.alpha = false; }
  432. }
  433. return orig.call(this, type, attrs);
  434. };
  435. } catch (e) { /* ignore */ }
  436. }, BG.a === 0);
  437. await page.goto(url, { waitUntil: 'networkidle2', timeout: TIMEOUT });
  438. const driveResult = await page.evaluate(pageDriver, sceneName, WANT_W, WANT_H, BG, KEEP_FPS, HIDE_NODES);
  439. if (!driveResult || !driveResult.ok) {
  440. fail(`in-page driver failed: ${driveResult && driveResult.err}. pageErrors=${JSON.stringify(pageErrors.slice(0, 5))}`);
  441. }
  442. const W = driveResult.w, H = driveResult.h;
  443. let pngBuf;
  444. let opaquePixels;
  445. if (driveResult.rgbaBase64) {
  446. // Direct WebGL readback path (preferred): exact content + correct alpha.
  447. const rgba = Buffer.from(driveResult.rgbaBase64, 'base64');
  448. opaquePixels = 0;
  449. for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 0) opaquePixels++;
  450. // gl.readPixels is bottom-up; flip to top-down for PNG.
  451. pngBuf = encodePng({ rgba, width: W, height: H, flipY: true });
  452. } else {
  453. // Fallback: puppeteer element screenshot (may include page bg / no alpha).
  454. const canvasEl = await page.$('#GameCanvas') || await page.$('canvas');
  455. if (!canvasEl) fail('No game canvas element found and no GL readback.');
  456. pngBuf = await canvasEl.screenshot({ omitBackground: BG.a === 0, type: 'png' });
  457. }
  458. fs.writeFileSync(OUT, pngBuf);
  459. console.log(JSON.stringify({
  460. success: true,
  461. path: OUT,
  462. width: W,
  463. height: H,
  464. bytes: pngBuf.length,
  465. scene: sceneName,
  466. background: BG,
  467. frames: driveResult.frames,
  468. opaquePixels: opaquePixels,
  469. captureMethod: driveResult.rgbaBase64 ? 'gl-readback' : 'puppeteer-screenshot',
  470. diag: driveResult.diag,
  471. pageErrors: pageErrors.slice(0, 3),
  472. }));
  473. await browser.close();
  474. server.close();
  475. process.exit(0);
  476. } catch (e) {
  477. try { if (browser) await browser.close(); } catch (_) {}
  478. try { server.close(); } catch (_) {}
  479. fail('headless run error: ' + (e && e.message || e));
  480. }
  481. })();