- 门①构建绿:all-plugins barrel+host-dev 双构建日志留档 - 门②SIZES增量实测:九件单件全≤配额,插件gz净增13263B(自律线60K),core基准gz 3222 - 门③浏览器证据:六锚点FNV-1a链verify.ok=true/burst-trail-drift三预设哈希×3重跑一致+几何命中+直方图非空/vignette-dither开关对照哈希互异/CDP dispatchTouchEvent真点击→probe第7条firstInput旁证 - 模板哲学红线被门③实证:particles不内置颜色致首版全黑,host作引擎供色后过——验证门逮住设计而非绕过 - α不接littlejsengine裁定留痕(host.js顶注/index.html/REPORT §4.1),受控面桩即引擎接缝点 - 遗留:probe前四锚tMono=0(冻结lane件,注入时钟拆分留β) - games/wanglanmei-ref/LOOP-LOG.md:参考件内部预判轮1计划(五要素挂点/插件用法图/mmx资产单13次/红线五条) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
448 lines
21 KiB
JavaScript
448 lines
21 KiB
JavaScript
/**
|
||
* test/harness/browser-evidence.cjs — 浏览器视觉证据 harness(core-protocol-v0)
|
||
* owner:T1b-α lane-core 奠基(纯计算口径)+ 集成段 T1b-α 接 CDP(浏览器侧四函数)
|
||
* 消费方:集成段(mini-desktop Chrome)+ lane-vfx/lane-sys 视觉/渲染证据
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【交付边界 —— 两段口径】
|
||
* · **纯计算口径**(FNV-1a 哈希 / 非空 / 色彩分布 / 几何断言):Gate0 奠基即**完整可跑、口径已冻结**,
|
||
* 不依赖浏览器,本机(6c6g)可独立自检。
|
||
* · **浏览器侧四函数**(connectCdp / driveFrames / captureImageData / saveScreenshot):集成段(本次)
|
||
* 已**接真 CDP 实现**(连 mini-desktop 本地 chrome,见下「实现策略」),**仅集成段跑**——
|
||
* 本机(6c6g)禁启 headless Chrome(OOM/exit144,CLAUDE.md 红线)。
|
||
* vfx/audio 的渲染/发声证据按 spec **必须**在集成段浏览器出,**禁 node-canvas 兜底**。
|
||
*
|
||
* 【固定取证环境(集成段实现时必须照此设,§2 第6条)】
|
||
* viewport = 390×844(移动竖屏基准);DPR = 2;
|
||
* seed = 注入受控随机源种子(复现);时间源 mock = 注入虚拟时钟驱动帧(复现,不依赖真实墙钟)。
|
||
* → 同一 build + 同 seed + 同 mock 时钟 ⇒ 渲染逐像素确定 ⇒ ImageData 哈希可作回归断言。
|
||
*
|
||
* 【证据四件(与参考件/集成段口径对齐,§4/§6)】
|
||
* ① ImageData FNV-1a 哈希(逐像素,回归对照);② 非空断言(画面有不透明像素,非全透);
|
||
* ③ 色彩分布(直方图,防「画了但全黑」);④ 几何断言(指定区域命中预期像素,验「画在了该画的位置」);
|
||
* 截图落 evidence/<round|integration>/<name>.png(路径约定见 §6)。
|
||
*
|
||
* 【哈希口径】FNV-1a 32-bit —— 与 P10 RuntimeProbe 的哈希链同口径(§3),全发行版证据用同一哈希族。
|
||
*
|
||
* 用法(集成段,伪流程):
|
||
* const H = require('./browser-evidence.cjs');
|
||
* const cdp = await H.connectCdp({ url, viewport:{w:390,h:844}, dpr:2, seed, mockClock:true });
|
||
* await H.driveFrames(cdp, 60); // 用 mock 时钟推进 60 帧
|
||
* const img = await H.captureImageData(cdp, 'canvas'); // 抓 canvas 像素
|
||
* const ev = H.buildEvidence(img, { geometry:[{x,y,w,h,expect:'non-empty'}] });
|
||
* await H.saveScreenshot(cdp, H.screenshotPath('integration','first-paint'));
|
||
* // ev = { hash, nonEmpty, histogramTop, geometry:[...] } → 落 evidence + sha256-manifest
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('node:path');
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 一、固定取证环境常量(集成段实现 connectCdp 时必须照此设)
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 固定取证环境(§2 第6条)。集成段建会话时照此设置,保证证据可复现。 */
|
||
const FIXED_ENV = Object.freeze({
|
||
viewport: Object.freeze({ width: 390, height: 844 }), // 移动竖屏基准
|
||
deviceScaleFactor: 2, // DPR 2
|
||
// seed / mockClock 由调用方按用例传入;此处声明「必须可注入」这一契约。
|
||
requiresSeed: true,
|
||
requiresMockClock: true,
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 二、纯计算口径(不依赖浏览器,Gate0 即完整实现并冻结口径,集成段直接复用)
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* FNV-1a 32-bit 哈希(与 P10 probe 哈希链同口径)。对 ImageData 的 RGBA 字节序列逐字节哈希。
|
||
* @param {Uint8Array|Buffer|number[]} bytes 字节序列(如 ImageData.data)
|
||
* @returns {string} 8 位十六进制哈希串(如 'a3f1c0de')
|
||
*/
|
||
function fnv1a32(bytes) {
|
||
let h = 0x811c9dc5; // FNV offset basis
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
h ^= bytes[i] & 0xff;
|
||
// FNV prime 16777619,用 Math.imul 保 32 位乘法
|
||
h = Math.imul(h, 0x01000193) >>> 0;
|
||
}
|
||
return (h >>> 0).toString(16).padStart(8, '0');
|
||
}
|
||
|
||
/**
|
||
* 非空断言:画面是否「有内容」(防「成功抓图但全透明,啥也没画」)。
|
||
* 判据:存在任一不透明像素(alpha>0)即视为非空。
|
||
* 注:本断言只判「画面非全透」;「画面非纯一色」(如背景同色没画东西)由 colorHistogram
|
||
* 的主色集中度 + assertGeometry 的区域命中覆盖,不混入本断言,避免「画了单色物体反被判空」。
|
||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img ImageData 形
|
||
* @returns {boolean}
|
||
*/
|
||
function assertNonEmpty(img) {
|
||
const d = img.data;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
if (d[i + 3] > 0) return true; // 存在不透明像素 → 画面有内容
|
||
}
|
||
return false; // 全透明 → 视为空
|
||
}
|
||
|
||
/**
|
||
* 色彩分布直方图:把 RGB 量化到粗粒度桶,统计 topN 主色(防「画了但全黑/全白」)。
|
||
* @param {{ data:(Uint8Array|number[]) }} img
|
||
* @param {number} [topN=5] 返回前 N 主色
|
||
* @returns {Array<{ color:string, count:number }>} 形如 [{color:'r0g128b255', count:1234}, ...]
|
||
*/
|
||
function colorHistogram(img, topN = 5) {
|
||
const d = img.data;
|
||
/** @type {Map<string, number>} */
|
||
const buckets = new Map();
|
||
// 量化到 32 级/通道(>>3),降桶数;只统计不透明像素。
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
if (d[i + 3] === 0) continue;
|
||
const key = `r${d[i] >> 3}g${d[i + 1] >> 3}b${d[i + 2] >> 3}`;
|
||
buckets.set(key, (buckets.get(key) || 0) + 1);
|
||
}
|
||
return Array.from(buckets.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, topN)
|
||
.map(([color, count]) => ({ color, count }));
|
||
}
|
||
|
||
/**
|
||
* 几何断言:检查指定矩形区域是否命中预期('non-empty' = 区域内有不透明像素)。
|
||
* 用于验「画在了该画的位置」(如:飞机应出现在屏幕下方中央矩形内)。
|
||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
|
||
* @param {Array<{ x:number, y:number, w:number, h:number, expect:'non-empty'|'empty' }>} regions
|
||
* @returns {Array<{ region:object, pass:boolean, opaquePixels:number }>}
|
||
*/
|
||
function assertGeometry(img, regions) {
|
||
const d = img.data;
|
||
const W = img.width;
|
||
return (regions || []).map((reg) => {
|
||
let opaque = 0;
|
||
for (let yy = reg.y; yy < reg.y + reg.h; yy++) {
|
||
for (let xx = reg.x; xx < reg.x + reg.w; xx++) {
|
||
const idx = (yy * W + xx) * 4;
|
||
if (d[idx + 3] > 0) opaque++;
|
||
}
|
||
}
|
||
const hasContent = opaque > 0;
|
||
const pass = reg.expect === 'empty' ? !hasContent : hasContent;
|
||
return { region: reg, pass, opaquePixels: opaque };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 把一张 ImageData 汇成「证据对象」(四件套:哈希/非空/色彩分布/几何)。
|
||
* 这是集成段把渲染结果落证的统一入口;纯计算,Gate0 即可用。
|
||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
|
||
* @param {{ geometry?: Array<object> }} [opts]
|
||
* @returns {{ hash:string, nonEmpty:boolean, histogramTop:Array, geometry:Array, width:number, height:number }}
|
||
*/
|
||
function buildEvidence(img, opts = {}) {
|
||
return {
|
||
hash: fnv1a32(img.data),
|
||
nonEmpty: assertNonEmpty(img),
|
||
histogramTop: colorHistogram(img, 5),
|
||
geometry: assertGeometry(img, opts.geometry || []),
|
||
width: img.width,
|
||
height: img.height,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 截图路径约定(§6):evidence/<bucket>/<name>.png。
|
||
* @param {'integration'|`round-${number}`|string} bucket 证据桶(集成段=integration;参考件=round-N)
|
||
* @param {string} name 截图名(不含扩展名)
|
||
* @returns {string} 相对仓根的截图路径
|
||
*/
|
||
function screenshotPath(bucket, name) {
|
||
return path.posix.join('evidence', bucket, `${name}.png`);
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 三、浏览器侧能力(集成段实现 —— 经 CDP 连真 Chrome;仅 mini-desktop 跑,6c6g 禁)
|
||
*
|
||
* 【实现策略(与 .agents/skills/ui-walkthrough-cdp.md 配方对齐,Chrome 146 实证)】
|
||
* · 复用 admin_walk.py 同款 CDP 接法:Chrome 以 --remote-debugging-port=9222 + --no-sandbox
|
||
* + --no-zygote + --remote-allow-origins=* 启动(由集成段 driver 脚本负责拉起,本文件只连);
|
||
* · Chrome 111+ 建 target 用 PUT /json/new?<url>;
|
||
* · WebSocket 经 `ws` 包连 webSocketDebuggerUrl(origin 已由 --remote-allow-origins=* 放行);
|
||
* · Runtime.evaluate(returnByValue+awaitPromise)驱动 host-dev 的确定性入口 window.__hostdev;
|
||
* · ImageData 经页面内 canvas.getContext('2d').getImageData 读「物理像素」回传(取证哈希逐字节);
|
||
* · 截图 Page.captureScreenshot 落 evidence/<bucket>/<name>.png。
|
||
*
|
||
* 【依赖纪律】
|
||
* `ws` 仅在下列 CDP 函数内 **lazy require**——本机(6c6g)只用上节「纯计算口径」时无需装 ws、
|
||
* require 本文件不报错;集成段 driver 先 `npm i ws` 再调 CDP 函数。
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/** CDP HTTP 端点(集成段 driver 在 mini-desktop 本地起 chrome)。可被 connectCdp 的 opts.cdpHttp 覆盖。 */
|
||
const DEFAULT_CDP_HTTP = 'http://localhost:9222';
|
||
|
||
/** lazy 取 ws 构造器;未装给出明确指引(守「纯计算路径零依赖」纪律)。 */
|
||
function getWebSocketCtor() {
|
||
try {
|
||
return require('ws');
|
||
} catch {
|
||
const e = new Error(
|
||
'[browser-evidence] 需要 `ws` 包驱动 CDP —— 仅集成段(mini-desktop)跑浏览器证据时需要。' +
|
||
' 请在集成段执行:npm i ws,再重跑。本机纯计算口径(fnv1a32/buildEvidence 等)无需 ws。'
|
||
);
|
||
e.code = 'WS_NOT_INSTALLED';
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/** 极简 HTTP GET/PUT(取 CDP /json 列表与建 target,零依赖 node:http)。 */
|
||
function httpJson(method, urlStr) {
|
||
return new Promise((resolve, reject) => {
|
||
const http = require('node:http');
|
||
const u = new URL(urlStr);
|
||
const req = http.request(
|
||
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 },
|
||
(res) => {
|
||
let buf = '';
|
||
res.on('data', (c) => (buf += c));
|
||
res.on('end', () => {
|
||
try {
|
||
resolve(buf ? JSON.parse(buf) : {});
|
||
} catch (e) {
|
||
reject(new Error('CDP HTTP 响应非 JSON:' + buf.slice(0, 200)));
|
||
}
|
||
});
|
||
}
|
||
);
|
||
req.on('error', reject);
|
||
req.on('timeout', () => req.destroy(new Error('CDP HTTP 超时:' + urlStr)));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 一个最小 CDP 会话(mirror admin_walk.py CDP 类):建 target → 连 WS → 收发带 id 的方法调用。
|
||
* 集成段内部用,不对外导出形状(connectCdp 返回它)。
|
||
*/
|
||
class CdpSession {
|
||
/**
|
||
* @param {object} ws 已连接的 ws 实例
|
||
* @param {string} targetId 目标 id
|
||
*/
|
||
constructor(ws, targetId) {
|
||
this.ws = ws;
|
||
this.targetId = targetId;
|
||
this._id = 0;
|
||
/** @type {Map<number,{resolve:Function,reject:Function}>} */
|
||
this._pending = new Map();
|
||
this.ws.on('message', (raw) => {
|
||
let msg;
|
||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||
if (msg.id != null && this._pending.has(msg.id)) {
|
||
const { resolve, reject } = this._pending.get(msg.id);
|
||
this._pending.delete(msg.id);
|
||
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
|
||
else resolve(msg.result || {});
|
||
}
|
||
// 事件(无 id)本会话不订阅,忽略。
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 发一个 CDP 方法,等其 id 应答。
|
||
* @param {string} method 如 'Page.navigate'
|
||
* @param {object} [params]
|
||
* @param {number} [timeoutMs]
|
||
* @returns {Promise<object>}
|
||
*/
|
||
send(method, params, timeoutMs) {
|
||
const id = ++this._id;
|
||
const payload = JSON.stringify({ id, method, params: params || {} });
|
||
return new Promise((resolve, reject) => {
|
||
const to = setTimeout(() => {
|
||
this._pending.delete(id);
|
||
reject(new Error('CDP 方法超时:' + method));
|
||
}, timeoutMs || 40000);
|
||
this._pending.set(id, {
|
||
resolve: (r) => { clearTimeout(to); resolve(r); },
|
||
reject: (e) => { clearTimeout(to); reject(e); },
|
||
});
|
||
this.ws.send(payload);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 在页面上下文求值表达式(returnByValue + awaitPromise)。
|
||
* @param {string} expression
|
||
* @param {number} [timeoutMs]
|
||
* @returns {Promise<*>} 求值结果 value(异常时 reject)。
|
||
*/
|
||
async evaluate(expression, timeoutMs) {
|
||
const r = await this.send(
|
||
'Runtime.evaluate',
|
||
{ expression, returnByValue: true, awaitPromise: true },
|
||
timeoutMs
|
||
);
|
||
if (r && r.exceptionDetails) {
|
||
throw new Error('页面求值异常:' + JSON.stringify(r.exceptionDetails).slice(0, 400));
|
||
}
|
||
return r && r.result ? r.result.value : undefined;
|
||
}
|
||
|
||
/** 关闭会话(关 WS)。 */
|
||
close() {
|
||
try { this.ws.close(); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 连接 CDP 并按固定取证环境建会话。
|
||
* 前置:集成段 driver 已在 mini-desktop 本地起好 chrome(:9222,含 --no-sandbox/--no-zygote/--remote-allow-origins=*)。
|
||
* 本函数:建 target → 连 WS → 启用 Page/Runtime → 设 deviceMetrics(viewport/DPR) → 导航 url → 等 host 引导完成。
|
||
*
|
||
* @param {{ url:string, seed?:number, viewport?:{width:number,height:number}, dpr?:number, cdpHttp?:string, settleMs?:number }} opts
|
||
* url 应已带 ?seed=&mode=(seed 经 URL 注入发行版受控随机源,与 mockClock 由 host.js 落实)。
|
||
* @returns {Promise<CdpSession>} CDP 会话句柄
|
||
*/
|
||
async function connectCdp(opts) {
|
||
const o = opts || {};
|
||
if (!o.url) throw new Error('[browser-evidence] connectCdp 需要 opts.url');
|
||
const cdpHttp = o.cdpHttp || DEFAULT_CDP_HTTP;
|
||
const vp = o.viewport || FIXED_ENV.viewport;
|
||
const dpr = o.dpr || FIXED_ENV.deviceScaleFactor;
|
||
|
||
// 1) 建 target(Chrome 111+ 必须 PUT /json/new?<url>,GET 不行——见 CDP 七坑#4)。
|
||
const created = await httpJson('PUT', `${cdpHttp}/json/new?${encodeURI('about:blank')}`);
|
||
const wsUrl = created.webSocketDebuggerUrl;
|
||
const targetId = created.id;
|
||
if (!wsUrl) throw new Error('[browser-evidence] 建 target 失败(无 webSocketDebuggerUrl):' + JSON.stringify(created).slice(0, 200));
|
||
|
||
// 2) 连 WS(origin 由 chrome 启动参数 --remote-allow-origins=* 放行)。
|
||
const WebSocket = getWebSocketCtor();
|
||
const ws = await new Promise((resolve, reject) => {
|
||
const sock = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
|
||
sock.on('open', () => resolve(sock));
|
||
sock.on('error', reject);
|
||
});
|
||
const cdp = new CdpSession(ws, targetId);
|
||
|
||
// 3) 启用域。
|
||
await cdp.send('Page.enable');
|
||
await cdp.send('Runtime.enable');
|
||
// 4) 设固定取证环境:viewport 逻辑尺寸 + DPR(host.js 内部也会按 DPR 设 canvas 物理像素,双保险一致)。
|
||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||
width: vp.width,
|
||
height: vp.height,
|
||
deviceScaleFactor: dpr,
|
||
mobile: true,
|
||
});
|
||
// 开触摸模拟:使 Input.dispatchTouchEvent 的 touchStart 能可靠合成兼容 pointerdown 事件
|
||
//(host 经 pointerdown 桥接输入 → runtime-probe 记 firstInput 旁证)。
|
||
try {
|
||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||
} catch { /* 个别 Chrome 版本无此方法时忽略,touch 仍可由 deviceMetrics mobile:true 启用 */ }
|
||
|
||
// 5) 导航 + 等加载完成 + 等 host 引导(window.__hostBooted)。
|
||
await cdp.send('Page.navigate', { url: o.url });
|
||
const settle = typeof o.settleMs === 'number' ? o.settleMs : 800;
|
||
await waitForBoot(cdp, settle);
|
||
return cdp;
|
||
}
|
||
|
||
/** 轮询页面直到 window.__hostBooted===true(或拿到 __hostBootError 即抛),最多约 12s。 */
|
||
async function waitForBoot(cdp, settleMs) {
|
||
const deadline = Date.now() + 12000;
|
||
// 先给一个固定 settle,让脚本起步。
|
||
await delay(settleMs);
|
||
while (Date.now() < deadline) {
|
||
const st = await cdp.evaluate(
|
||
'({ booted: !!window.__hostBooted, err: window.__hostBootError || null, hasHostdev: !!window.__hostdev })'
|
||
);
|
||
if (st && st.err) throw new Error('[browser-evidence] host 引导失败:' + st.err);
|
||
if (st && st.booted && st.hasHostdev) return;
|
||
await delay(300);
|
||
}
|
||
throw new Error('[browser-evidence] 等 host 引导超时(window.__hostBooted 未就绪)');
|
||
}
|
||
|
||
/** 纯 Promise 延时(不走被拦的 sleep 命令;harness 内部异步等待用)。 */
|
||
function delay(ms) {
|
||
return new Promise((r) => setTimeout(r, ms));
|
||
}
|
||
|
||
/**
|
||
* 用 host 的步进时钟确定性推进 N 帧(不走真实 RAF)。
|
||
* 经 window.__hostdev.stepFrames(n) 落实(host.js 内每帧固定 dt + 固定时钟步进 + render)。
|
||
* @param {CdpSession} cdp 会话
|
||
* @param {number} frames 帧数
|
||
* @returns {Promise<{frame:number}>} 推进后的帧号
|
||
*/
|
||
async function driveFrames(cdp, frames) {
|
||
const n = frames | 0;
|
||
const r = await cdp.evaluate(`(function(){ window.__hostdev.stepFrames(${n}); return { frame: window.__hostdev.frameCount() }; })()`);
|
||
return r;
|
||
}
|
||
|
||
/**
|
||
* 抓取指定 canvas 的 ImageData(物理像素,逐字节供 FNV-1a 哈希)。
|
||
* 经页面内 getImageData 读出 RGBA 字节,base64 回传后解码为 Uint8Array(避免巨数组 JSON 化)。
|
||
* @param {CdpSession} cdp 会话
|
||
* @param {string} selector canvas 选择器(如 '#game')
|
||
* @returns {Promise<{ data:Uint8Array, width:number, height:number }>}
|
||
*/
|
||
async function captureImageData(cdp, selector) {
|
||
// 页面内:取 canvas 物理像素 ImageData → 转 base64(btoa 逐字节)回传。
|
||
const expr = `(function(){
|
||
var c = document.querySelector(${JSON.stringify(selector)});
|
||
if (!c) return { err: 'canvas not found: ' + ${JSON.stringify(selector)} };
|
||
var g = c.getContext('2d', { willReadFrequently: true });
|
||
var img = g.getImageData(0, 0, c.width, c.height);
|
||
var d = img.data, n = d.length, s = '';
|
||
// 分块 btoa(避免 String.fromCharCode 超长参数栈溢出)。
|
||
var CH = 0x8000;
|
||
for (var i = 0; i < n; i += CH) {
|
||
s += String.fromCharCode.apply(null, d.subarray(i, Math.min(i + CH, n)));
|
||
}
|
||
return { b64: btoa(s), width: img.width, height: img.height };
|
||
})()`;
|
||
const r = await cdp.evaluate(expr, 60000);
|
||
if (!r || r.err) throw new Error('[browser-evidence] captureImageData 失败:' + (r && r.err ? r.err : 'empty'));
|
||
const data = new Uint8Array(Buffer.from(r.b64, 'base64'));
|
||
return { data, width: r.width, height: r.height };
|
||
}
|
||
|
||
/**
|
||
* 保存截图到约定路径(Page.captureScreenshot → 落盘)。
|
||
* @param {CdpSession} cdp 会话
|
||
* @param {string} outPath screenshotPath() 给出的路径(相对仓根;driver 应保证 cwd=game-runtime)
|
||
* @returns {Promise<string>} 实际落盘绝对路径
|
||
*/
|
||
async function saveScreenshot(cdp, outPath) {
|
||
const fs = require('node:fs');
|
||
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
|
||
if (!r || !r.data) throw new Error('[browser-evidence] 截图失败(无 data)');
|
||
const abs = path.resolve(outPath);
|
||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||
fs.writeFileSync(abs, Buffer.from(r.data, 'base64'));
|
||
return abs;
|
||
}
|
||
|
||
module.exports = {
|
||
// 常量
|
||
FIXED_ENV,
|
||
DEFAULT_CDP_HTTP,
|
||
// 纯计算口径(Gate0 完整实现,集成段复用)
|
||
fnv1a32,
|
||
assertNonEmpty,
|
||
colorHistogram,
|
||
assertGeometry,
|
||
buildEvidence,
|
||
screenshotPath,
|
||
// 浏览器侧(集成段实现,经 CDP 连真 Chrome)
|
||
connectCdp,
|
||
driveFrames,
|
||
captureImageData,
|
||
saveScreenshot,
|
||
CdpSession,
|
||
};
|