@windsland52/maafw-live 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -2
- package/bin/maafw-live.mjs +25 -0
- package/docs/daemon-protocol.md +121 -0
- package/docs/keyframe-retention-contract-v0.md +225 -0
- package/docs/session-unify-v0.md +115 -0
- package/lib/client/daemon.js +276 -0
- package/lib/commands/annotate.js +179 -0
- package/lib/commands/calibrate.js +61 -0
- package/lib/commands/color.js +73 -0
- package/lib/commands/crop.js +228 -0
- package/lib/commands/dispatch.js +121 -0
- package/lib/commands/env.js +409 -0
- package/lib/commands/index.js +70 -0
- package/lib/commands/input.js +257 -0
- package/lib/commands/kf.js +167 -0
- package/lib/commands/probe.js +70 -0
- package/lib/commands/reco.js +440 -0
- package/lib/commands/repl.js +1501 -0
- package/lib/commands/roi-check.js +273 -0
- package/lib/commands/roi-compare.js +388 -0
- package/lib/commands/runtime.js +718 -0
- package/lib/commands/skill.js +169 -0
- package/lib/commands/timing.js +247 -0
- package/lib/commands/version.js +66 -0
- package/lib/daemon/framed.mjs +3680 -0
- package/lib/daemon/reco_child.mjs +287 -0
- package/lib/exec.js +96 -0
- package/lib/flags.js +44 -0
- package/lib/image.js +90 -0
- package/lib/index.js +143 -0
- package/lib/interface/hotkey.js +142 -0
- package/lib/interface/index.js +12 -0
- package/lib/interface/jsonc.js +95 -0
- package/lib/interface/load.js +397 -0
- package/lib/interface/override.js +287 -0
- package/lib/interface/plan.js +279 -0
- package/lib/option-args.js +29 -0
- package/lib/pkg.js +54 -0
- package/lib/protocol.js +94 -0
- package/lib/runtime/actions.js +225 -0
- package/lib/runtime/keyframes.js +73 -0
- package/lib/runtime/log-watch.js +160 -0
- package/lib/runtime/node-source.js +100 -0
- package/lib/runtime/ocr-model.js +45 -0
- package/lib/runtime/pi-env.js +34 -0
- package/lib/runtime/pretask.js +217 -0
- package/lib/runtime/prune.js +28 -0
- package/lib/runtime/receipts.js +57 -0
- package/lib/runtime/reco-args.js +38 -0
- package/lib/runtime/reco-verdict.js +82 -0
- package/lib/runtime/roi-compare.js +198 -0
- package/lib/runtime/session-forward.js +79 -0
- package/lib/runtime/session-ipc.js +471 -0
- package/lib/runtime/session.js +135 -0
- package/lib/runtime/signal-guard.js +35 -0
- package/lib/types/client/daemon.d.ts +86 -0
- package/lib/types/commands/annotate.d.ts +3 -0
- package/lib/types/commands/calibrate.d.ts +7 -0
- package/lib/types/commands/color.d.ts +9 -0
- package/lib/types/commands/crop.d.ts +3 -0
- package/lib/types/commands/dispatch.d.ts +22 -0
- package/lib/types/commands/env.d.ts +21 -0
- package/lib/types/commands/index.d.ts +13 -0
- package/lib/types/commands/input.d.ts +17 -0
- package/lib/types/commands/kf.d.ts +10 -0
- package/lib/types/commands/probe.d.ts +15 -0
- package/lib/types/commands/reco.d.ts +27 -0
- package/lib/types/commands/repl.d.ts +10 -0
- package/lib/types/commands/roi-check.d.ts +3 -0
- package/lib/types/commands/roi-compare.d.ts +3 -0
- package/lib/types/commands/runtime.d.ts +66 -0
- package/lib/types/commands/skill.d.ts +17 -0
- package/lib/types/commands/timing.d.ts +64 -0
- package/lib/types/commands/version.d.ts +3 -0
- package/lib/types/exec.d.ts +31 -0
- package/lib/types/flags.d.ts +62 -0
- package/lib/types/image.d.ts +27 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/interface/hotkey.d.ts +36 -0
- package/lib/types/interface/index.d.ts +11 -0
- package/lib/types/interface/jsonc.d.ts +9 -0
- package/lib/types/interface/load.d.ts +190 -0
- package/lib/types/interface/override.d.ts +78 -0
- package/lib/types/interface/plan.d.ts +68 -0
- package/lib/types/option-args.d.ts +19 -0
- package/lib/types/pkg.d.ts +14 -0
- package/lib/types/protocol.d.ts +124 -0
- package/lib/types/runtime/actions.d.ts +248 -0
- package/lib/types/runtime/keyframes.d.ts +43 -0
- package/lib/types/runtime/log-watch.d.ts +28 -0
- package/lib/types/runtime/node-source.d.ts +25 -0
- package/lib/types/runtime/ocr-model.d.ts +8 -0
- package/lib/types/runtime/pi-env.d.ts +24 -0
- package/lib/types/runtime/pretask.d.ts +53 -0
- package/lib/types/runtime/prune.d.ts +4 -0
- package/lib/types/runtime/receipts.d.ts +7 -0
- package/lib/types/runtime/reco-args.d.ts +18 -0
- package/lib/types/runtime/reco-verdict.d.ts +50 -0
- package/lib/types/runtime/roi-compare.d.ts +117 -0
- package/lib/types/runtime/session-forward.d.ts +10 -0
- package/lib/types/runtime/session-ipc.d.ts +117 -0
- package/lib/types/runtime/session.d.ts +77 -0
- package/lib/types/runtime/signal-guard.d.ts +19 -0
- package/lib/types/update-check.d.ts +20 -0
- package/lib/types/usage.d.ts +3 -0
- package/lib/update-check.js +97 -0
- package/lib/usage.js +50 -0
- package/package.json +94 -4
- package/skills/README.md +51 -0
- package/skills/maafw-live/SKILL.md +293 -0
- package/skills/maafw-live/references/examples.md +238 -0
- package/skills/maafw-live/references/pitfalls.md +211 -0
- package/skills/maafw-live/references/workflows.md +164 -0
|
@@ -0,0 +1,3680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrameDaemon —— 设备 daemon,独立子进程运行(maa-node 的 .wait() 可能同步阻塞,
|
|
3
|
+
* 绝不允许进入宿主进程)。
|
|
4
|
+
*
|
|
5
|
+
* 职责(分层消费模型的生产端):
|
|
6
|
+
* - 持有 maa-node 的 Controller / Resource / Tasker(连接能力全部来自 maafw 本体)
|
|
7
|
+
* - 抓帧循环 → 降采样 → 块亮度哈希 → 变化/稳定事件(合并)→ 环形缓冲
|
|
8
|
+
* - 像素默认不出 daemon:仅发元数据;面板实时预览走落盘的预览帧;精查走 ROI 裁剪
|
|
9
|
+
* - Tasker 运行:节点事件流按帧序对齐,内置超时 + post_stop(防 JumpBack 死循环)
|
|
10
|
+
* - 识别单测:在环形缓冲历史帧上跑 post_recognition(支持阈值扫描)
|
|
11
|
+
*
|
|
12
|
+
* 协议(--child 模式:stdin/stdout JSON 行协议,一行一条消息):
|
|
13
|
+
* host → daemon: { id, cmd, ...args }
|
|
14
|
+
* daemon → host: { kind:'reply', id, ok, data|error } (请求应答)
|
|
15
|
+
* { kind:'frame', meta, preview } (每接受帧的元数据与预览帧路径)
|
|
16
|
+
* { kind:'event', ev } (变化/稳定/节点事件)
|
|
17
|
+
* { kind:'stream_error', error }
|
|
18
|
+
* { kind:'stream_stopped', reason } (控制器被销毁→帧流主动停止;
|
|
19
|
+
* 要看帧就重新 stream_start)
|
|
20
|
+
*/
|
|
21
|
+
import { createRequire } from 'node:module'
|
|
22
|
+
import { execFile, spawn, spawnSync } from 'node:child_process'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
25
|
+
import readline from 'node:readline'
|
|
26
|
+
import zlib from 'node:zlib'
|
|
27
|
+
import fs from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url)
|
|
32
|
+
/* 子进程模式(host 经 spawn 启动带 --child):stdin/stdout 走 JSON 行协议;
|
|
33
|
+
* 无 --child 时(headless 单测 import)模块无副作用。原生崩溃只杀子进程,不陪葬 host。 */
|
|
34
|
+
const CHILD = process.argv.includes('--child')
|
|
35
|
+
const send = (m, done) => {
|
|
36
|
+
if (!CHILD) { if (done) done(); return }
|
|
37
|
+
const line = JSON.stringify(m) + '\n'
|
|
38
|
+
if (done) process.stdout.write(line, done)
|
|
39
|
+
else process.stdout.write(line)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let maa = null
|
|
43
|
+
function loadMaa() {
|
|
44
|
+
if (!maa) maa = require('@maaxyz/maa-node')
|
|
45
|
+
return maa
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ────────────────────────── 最小 PNG 解码(8bit RGB/RGBA,非隔行) ────────────────────────── */
|
|
49
|
+
const CRC_TABLE = (() => {
|
|
50
|
+
const t = new Uint32Array(256)
|
|
51
|
+
for (let n = 0; n < 256; n++) {
|
|
52
|
+
let c = n
|
|
53
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1
|
|
54
|
+
t[n] = c
|
|
55
|
+
}
|
|
56
|
+
return t
|
|
57
|
+
})()
|
|
58
|
+
function crc32(buf) {
|
|
59
|
+
let c = 0xFFFFFFFF
|
|
60
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8)
|
|
61
|
+
return (c ^ 0xFFFFFFFF) >>> 0
|
|
62
|
+
}
|
|
63
|
+
function pngChunk(type, data) {
|
|
64
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0)
|
|
65
|
+
const t = Buffer.from(type, 'ascii')
|
|
66
|
+
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0)
|
|
67
|
+
return Buffer.concat([len, t, data, crc])
|
|
68
|
+
}
|
|
69
|
+
function pngDecode(buf) {
|
|
70
|
+
if (buf[0] !== 0x89 || buf[1] !== 0x50) throw new Error('not a png')
|
|
71
|
+
let off = 8, w = 0, h = 0, bitDepth = 0, colorType = 0
|
|
72
|
+
const idat = []
|
|
73
|
+
while (off < buf.length) {
|
|
74
|
+
const len = buf.readUInt32BE(off)
|
|
75
|
+
const type = buf.toString('ascii', off + 4, off + 8)
|
|
76
|
+
const data = buf.subarray(off + 8, off + 8 + len)
|
|
77
|
+
if (type === 'IHDR') {
|
|
78
|
+
w = data.readUInt32BE(0); h = data.readUInt32BE(4)
|
|
79
|
+
bitDepth = data[8]; colorType = data[9]
|
|
80
|
+
if (data[12] !== 0) throw new Error('interlaced png unsupported')
|
|
81
|
+
} else if (type === 'IDAT') idat.push(Buffer.from(data))
|
|
82
|
+
else if (type === 'IEND') break
|
|
83
|
+
off += 12 + len
|
|
84
|
+
}
|
|
85
|
+
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6)) {
|
|
86
|
+
throw new Error('unsupported png: depth=' + bitDepth + ' color=' + colorType)
|
|
87
|
+
}
|
|
88
|
+
const ch = colorType === 6 ? 4 : 3
|
|
89
|
+
const raw = zlib.inflateSync(Buffer.concat(idat))
|
|
90
|
+
const stride = w * ch
|
|
91
|
+
/* 解压后必须正好是 h 行 ×(每行 1 个滤镜字节 + 行字节)。少了就是文件被截断 / IDAT 不完整——
|
|
92
|
+
* 不校验的话 line[x] 取到 undefined,NaN & 255 静默变成 0,产出一张全黑图被当成证据。 */
|
|
93
|
+
const need = h * (stride + 1)
|
|
94
|
+
if (raw.length !== need) {
|
|
95
|
+
throw new Error('png 像素数据长度不符:期望 ' + need + ' 字节(' + h + ' 行 ×(1 + ' + stride + ')),实际 ' + raw.length)
|
|
96
|
+
}
|
|
97
|
+
const out = Buffer.alloc(w * h * ch)
|
|
98
|
+
let pos = 0
|
|
99
|
+
for (let y = 0; y < h; y++) {
|
|
100
|
+
const filter = raw[pos++]
|
|
101
|
+
const line = raw.subarray(pos, pos + stride); pos += stride
|
|
102
|
+
const cur = out.subarray(y * stride, (y + 1) * stride)
|
|
103
|
+
const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null
|
|
104
|
+
for (let x = 0; x < stride; x++) {
|
|
105
|
+
const a = x >= ch ? cur[x - ch] : 0
|
|
106
|
+
const b = prev ? prev[x] : 0
|
|
107
|
+
const c = prev && x >= ch ? prev[x - ch] : 0
|
|
108
|
+
let v = line[x]
|
|
109
|
+
if (filter === 1) v += a
|
|
110
|
+
else if (filter === 2) v += b
|
|
111
|
+
else if (filter === 3) v += (a + b) >> 1
|
|
112
|
+
else if (filter === 4) {
|
|
113
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
|
|
114
|
+
v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c)
|
|
115
|
+
}
|
|
116
|
+
cur[x] = v & 255
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { w, h, ch, data: out }
|
|
120
|
+
}
|
|
121
|
+
/** PNG 声明尺寸(只读 IHDR,不解像素)。
|
|
122
|
+
*
|
|
123
|
+
* 为什么要有这个:`--image` 分支的回执只带 `{source}`,调用方拿到图却不知道它多大,
|
|
124
|
+
* 于是 **roi 空间错了也无从判断**(ctrl 空间 roi 打到 L1 小图上,静默零例)。
|
|
125
|
+
* 与 pngDecode 的第二层同为「读 IHDR 才拿到 w/h」,但**不做解码**——这里只要尺寸,
|
|
126
|
+
* 真解码交给 reco 子进程;文件不可读 / 不是 PNG 时返回 null,让子进程去报真正的读取错误。
|
|
127
|
+
* 名字带 Header 以区别于 image.ts 里那个会完整解码的异步 pngSize。 */
|
|
128
|
+
function pngHeaderSize(buf) {
|
|
129
|
+
try {
|
|
130
|
+
if (buf[0] !== 0x89 || buf[1] !== 0x50) return null
|
|
131
|
+
const len = buf.readUInt32BE(8)
|
|
132
|
+
if (buf.toString('ascii', 12, 16) !== 'IHDR' || len < 8) return null
|
|
133
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }
|
|
134
|
+
} catch (e) { return null }
|
|
135
|
+
}
|
|
136
|
+
/** 归一化为 RGB(丢弃 alpha),返回新 Buffer */
|
|
137
|
+
function toRgb(dec) {
|
|
138
|
+
if (dec.ch === 3) return dec.data
|
|
139
|
+
const out = Buffer.alloc(dec.w * dec.h * 3)
|
|
140
|
+
for (let i = 0, o = 0; i < dec.data.length; i += 4, o += 3) {
|
|
141
|
+
out[o] = dec.data[i]; out[o + 1] = dec.data[i + 1]; out[o + 2] = dec.data[i + 2]
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
function pngEncodeRGB(rgb, w, h) {
|
|
146
|
+
const stride = w * 3
|
|
147
|
+
const raw = Buffer.alloc((stride + 1) * h)
|
|
148
|
+
for (let y = 0; y < h; y++) {
|
|
149
|
+
raw[y * (stride + 1)] = 0
|
|
150
|
+
rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride)
|
|
151
|
+
}
|
|
152
|
+
const ihdr = Buffer.alloc(13)
|
|
153
|
+
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4)
|
|
154
|
+
ihdr[8] = 8; ihdr[9] = 2
|
|
155
|
+
return Buffer.concat([
|
|
156
|
+
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
157
|
+
pngChunk('IHDR', ihdr),
|
|
158
|
+
pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
|
159
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
160
|
+
])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ────────────────────────── 图像降采样 / 感知哈希 ────────────────────────── */
|
|
164
|
+
function downscale(rgb, sw, sh, maxSide) {
|
|
165
|
+
const sc = Math.min(1, maxSide / Math.max(sw, sh))
|
|
166
|
+
const w = Math.max(8, Math.round(sw * sc))
|
|
167
|
+
const h = Math.max(8, Math.round(sh * sc))
|
|
168
|
+
if (w === sw && h === sh) return { w, h, data: rgb }
|
|
169
|
+
const out = Buffer.alloc(w * h * 3)
|
|
170
|
+
for (let y = 0; y < h; y++) {
|
|
171
|
+
const sy0 = Math.floor(y * sh / h)
|
|
172
|
+
const sy1 = Math.max(sy0 + 1, Math.floor((y + 1) * sh / h))
|
|
173
|
+
for (let x = 0; x < w; x++) {
|
|
174
|
+
const sx0 = Math.floor(x * sw / w)
|
|
175
|
+
const sx1 = Math.max(sx0 + 1, Math.floor((x + 1) * sw / w))
|
|
176
|
+
let r = 0, g = 0, b = 0
|
|
177
|
+
const n = (sx1 - sx0) * (sy1 - sy0)
|
|
178
|
+
for (let sy = sy0; sy < sy1; sy++) {
|
|
179
|
+
let i = (sy * sw + sx0) * 3
|
|
180
|
+
for (let sx = sx0; sx < sx1; sx++, i += 3) { r += rgb[i]; g += rgb[i + 1]; b += rgb[i + 2] }
|
|
181
|
+
}
|
|
182
|
+
const o = (y * w + x) * 3
|
|
183
|
+
out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { w, h, data: out }
|
|
187
|
+
}
|
|
188
|
+
/** 8×8 分块亮度哈希(相对整体均值)+ 分块亮度均值(局部变化检测用) */
|
|
189
|
+
function blockAnalyze(rgb, w, h) {
|
|
190
|
+
const cells = new Float64Array(64)
|
|
191
|
+
const cnt = new Float64Array(64)
|
|
192
|
+
let total = 0, n = 0
|
|
193
|
+
for (let y = 0; y < h; y++) {
|
|
194
|
+
const cy = Math.min(7, (y * 8 / h) | 0) * 8
|
|
195
|
+
for (let x = 0; x < w; x++) {
|
|
196
|
+
const i = (y * w + x) * 3
|
|
197
|
+
const l = (rgb[i] * 3 + rgb[i + 1] * 6 + rgb[i + 2]) * 0.1
|
|
198
|
+
const ci = cy + Math.min(7, (x * 8 / w) | 0)
|
|
199
|
+
cells[ci] += l; cnt[ci]++; total += l; n++
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const mean = total / Math.max(1, n)
|
|
203
|
+
const hash = new Uint8Array(64)
|
|
204
|
+
const means = new Float64Array(64)
|
|
205
|
+
for (let k = 0; k < 64; k++) {
|
|
206
|
+
means[k] = cells[k] / Math.max(1, cnt[k])
|
|
207
|
+
hash[k] = means[k] >= mean ? 1 : 0
|
|
208
|
+
}
|
|
209
|
+
return { hash, means }
|
|
210
|
+
}
|
|
211
|
+
/** 兼容导出:只要哈希位 */
|
|
212
|
+
function blockHash(rgb, w, h) {
|
|
213
|
+
return blockAnalyze(rgb, w, h).hash
|
|
214
|
+
}
|
|
215
|
+
function hashDist(a, b) {
|
|
216
|
+
let d = 0
|
|
217
|
+
for (let i = 0; i < 64; i++) if (a[i] !== b[i]) d++
|
|
218
|
+
return d / 64
|
|
219
|
+
}
|
|
220
|
+
const hashStr = (h) => Array.from(h, (v) => (v ? '1' : '0')).join('')
|
|
221
|
+
|
|
222
|
+
/* ────────────────────────── 状态 ────────────────────────── */
|
|
223
|
+
/* 关键帧库磁盘配额(契约 §4.1 保留与清理):满额拒绝新增留存并报告,不后台静默删除——
|
|
224
|
+
* 清理是用户的决定。init 可覆盖;env MAAFW_KF_QUOTA_BYTES 次之。
|
|
225
|
+
* 判据是 `S.kfQuota > 0`,也就是 0 = 不设限(单测直接改 S 走这条);但**从 env 与 init 到不了 0**:
|
|
226
|
+
* 两处都只认 >0 的值(缺省 1GiB),想不设限得由宿主直接控制 S。 */
|
|
227
|
+
const KF_QUOTA_DEFAULT = (() => {
|
|
228
|
+
const v = Number(process.env.MAAFW_KF_QUOTA_BYTES)
|
|
229
|
+
return Number.isFinite(v) && v > 0 ? v : 1024 * 1024 * 1024
|
|
230
|
+
})()
|
|
231
|
+
const S = {
|
|
232
|
+
ctrl: null, tasker: null,
|
|
233
|
+
/* 最近一次 run / --act 的 Resource:只作释放点(换新时 destroy 上一份、disconnect 时销毁),
|
|
234
|
+
* 不当"当前资源"共享——那会让下一次命令带着上一轮的 bundle 跑。 */
|
|
235
|
+
runRes: null,
|
|
236
|
+
session: null, // { kind, target, method }
|
|
237
|
+
daemonId: randomUUID(), // 本 daemon 实例身份(留存记录的 session.daemon)
|
|
238
|
+
connGen: 0, // 连接代次:每次 connect 成功 +1(留存记录定位用)
|
|
239
|
+
runDir: null, // host init 传入;关键帧库 = runDir/frames
|
|
240
|
+
kfQuota: KF_QUOTA_DEFAULT, // 库目录字节配额(>0 生效,0 = 不设限;env/init 都只认 >0)
|
|
241
|
+
fullW: 0, fullH: 0, // 控制器分辨率(默认短边 720p,pipeline roi/模板同空间)
|
|
242
|
+
previewPath: null, // 面板实时预览帧落盘路径(host 传 runDir 初始化)
|
|
243
|
+
stream: false, streamTimer: null,
|
|
244
|
+
streamFps: 8, streamScale: 480, maxFrames: 120,
|
|
245
|
+
/* 变化检测双阈值:全局 blockHash 位差率(分布式变化)+ 分块亮度差(局部变化,任一块超限即变化)。
|
|
246
|
+
* blockThresh 单位为亮度(0-255),默认 8 ≈ 3%;阈值需按真机噪声实测校准(roadmap 条目13)。 */
|
|
247
|
+
changeGlobal: 0.06, blockThresh: 8,
|
|
248
|
+
seq: 0,
|
|
249
|
+
ring: [], // {seq,t,rgb,w,h,fw,fh,hash,diff}——fw/fh 为该帧捕获时控制器尺寸
|
|
250
|
+
events: [], // {type:'change'|'stable', seq, t, diff, dur?}
|
|
251
|
+
framesMeta: [], // {seq,t,hash,diff,w,h}(元数据)
|
|
252
|
+
quietNow: false, stableCount: 0,
|
|
253
|
+
lastPng: null,
|
|
254
|
+
runActive: false,
|
|
255
|
+
/* agent 桥接(PI agent 子进程 ↔ maa.Client):绑定在某个 Resource 实例上,
|
|
256
|
+
* 同资源 + 同声明集合复用,换资源 / disconnect / shutdown 时清理。 */
|
|
257
|
+
agentBind: null,
|
|
258
|
+
/* L0 原图有界缓存(关键帧留存契约 §1-2):滚动区=最近 K 张原图("刚看到就能留");
|
|
259
|
+
* 锚区=change/stable 事件锚点、动作边界、run 起止(各自 FIFO,容量有界可配)。
|
|
260
|
+
* 张数之外还有字节预算:跨两区唯一 seq 去重计字节,超限先淘汰滚动区(锚区是策展证据,
|
|
261
|
+
* 滚动区只是"最近");Buffer 由两区共享,只有两区都淘汰才真正释放。 */
|
|
262
|
+
l0: {
|
|
263
|
+
roll: [], // {seq,t,png,w,h}
|
|
264
|
+
anchor: [], // {seq,t,png,w,h,source}
|
|
265
|
+
rollCap: 16, anchorCap: 32,
|
|
266
|
+
bytesCap: 192 * 1024 * 1024, // 唯一字节预算(默认 192MB ≈ 1080p×48 张上限)
|
|
267
|
+
evicted: 0,
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
const STATUS = { pending: 1000, running: 2000, succeeded: 3000, failed: 4000 }
|
|
271
|
+
|
|
272
|
+
function reply(id, ok, data, done) {
|
|
273
|
+
send({ kind: 'reply', id, ok, ...(ok ? { data } : { error: data }) }, done)
|
|
274
|
+
}
|
|
275
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
276
|
+
|
|
277
|
+
/* ────────────────────────── 设备 / 连接 / 截图 ────────────────────────── */
|
|
278
|
+
async function cmdProbe() {
|
|
279
|
+
const m = loadMaa()
|
|
280
|
+
const out = { version: null, adb: null, win32: null, errors: [] }
|
|
281
|
+
try { out.version = String(m.Global.version) } catch (e) { out.errors.push('version: ' + e.message) }
|
|
282
|
+
try { const a = await m.AdbController.find(); out.adb = a ? a.length : 0 } catch (e) { out.errors.push('adb: ' + e.message) }
|
|
283
|
+
try { const w = await m.Win32Controller.find(); out.win32 = w ? w.length : 0 } catch (e) { out.errors.push('win32: ' + e.message) }
|
|
284
|
+
return out
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 截图能力探测(示范轨迹契约 §3):决定能不能走 A 路(人在我们显示的帧上点)。
|
|
289
|
+
* 方法:两次 post_screencap 间隔抓帧,比 blockHash 与亮度。
|
|
290
|
+
* changed == true → 像素在变(遮挡下能变 = 后台可截)
|
|
291
|
+
* changed == false 且 mean 正常 → 静止(目标停止渲染?改应用设置,不是截图路径问题)
|
|
292
|
+
* mean < 8 → 黑图(截图方法不兼容,换一个)
|
|
293
|
+
* post_screencap 抛错/空 → 报错(如实带出)
|
|
294
|
+
* 探针不知道"此刻是否遮挡"——判据只在遮挡态成立,调用方(CLI)负责提示与如实标注。
|
|
295
|
+
*/
|
|
296
|
+
async function cmdScreencapProbe(args) {
|
|
297
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先 connect)' }
|
|
298
|
+
const interval = Math.min(20000, Math.max(500, Number(args.intervalMs ?? 3000)))
|
|
299
|
+
const grab = async () => {
|
|
300
|
+
try {
|
|
301
|
+
const j = S.ctrl.post_screencap()
|
|
302
|
+
await j.wait()
|
|
303
|
+
const buf = j.get()
|
|
304
|
+
if (!buf || buf.byteLength < 9) return { ok: false, error: '空截图' }
|
|
305
|
+
const dec = pngDecode(Buffer.from(buf))
|
|
306
|
+
const rgb = toRgb(dec)
|
|
307
|
+
const hash = blockHash(rgb, dec.w, dec.h)
|
|
308
|
+
/* 黑帧判定用平均亮度:全黑截图的 mean≈0,正常画面几十起步;阈值取 8 有充分余量 */
|
|
309
|
+
let sum = 0
|
|
310
|
+
for (let i = 0; i < rgb.length; i++) sum += rgb[i]
|
|
311
|
+
const mean = Math.round((sum / rgb.length) * 10) / 10
|
|
312
|
+
return { ok: true, w: dec.w, h: dec.h, hash: hashStr(hash), mean }
|
|
313
|
+
} catch (e) {
|
|
314
|
+
return { ok: false, error: String(e && e.message || e) }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const first = await grab()
|
|
318
|
+
await sleep(interval)
|
|
319
|
+
const second = await grab()
|
|
320
|
+
if (!first.ok || !second.ok) {
|
|
321
|
+
return { ok: true, intervalMs: interval, first, second, changed: null,
|
|
322
|
+
verdict: 'error',
|
|
323
|
+
note: 'post_screencap 报错或返回空——截图方法不兼容或控制器异常,换 win32.screencap 方法重试。' }
|
|
324
|
+
}
|
|
325
|
+
const changed = first.hash !== second.hash
|
|
326
|
+
let verdict = 'changing'
|
|
327
|
+
let note = '像素在变:若此刻窗口被完全遮挡,即"后台可截"(A 路前提成立)。'
|
|
328
|
+
if (!changed && second.mean < 8) {
|
|
329
|
+
verdict = 'black'
|
|
330
|
+
note = '截图近乎全黑:截图方法不兼容这台设备/这个窗口——换 win32.screencap 方法(FramePool / PrintWindow)重试。'
|
|
331
|
+
} else if (!changed) {
|
|
332
|
+
verdict = 'static'
|
|
333
|
+
note = '像素静止:目标停止渲染(改模拟器/应用设置),或两帧之间画面恰好没动——遮挡下再测一次确认。'
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, intervalMs: interval, first, second, changed, verdict, note,
|
|
336
|
+
occluded: args.occluded === true ? true : null }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function cmdDeviceList(kind) {
|
|
340
|
+
const m = loadMaa()
|
|
341
|
+
const list = []
|
|
342
|
+
if (kind !== 'win32') {
|
|
343
|
+
try {
|
|
344
|
+
const a = await m.AdbController.find()
|
|
345
|
+
if (a) for (const d of a) list.push({ kind: 'adb', id: String(d[2] ?? d[0]), name: String(d[0]), adbPath: String(d[1] ?? 'adb') })
|
|
346
|
+
} catch (e) { /* 无 adb 环境 */ }
|
|
347
|
+
}
|
|
348
|
+
if (kind !== 'adb') {
|
|
349
|
+
try {
|
|
350
|
+
const w = await m.Win32Controller.find()
|
|
351
|
+
if (w) for (const d of w) list.push({ kind: 'win32', id: String(d[0]), cls: String(d[1]), name: String(d[2] ?? '') })
|
|
352
|
+
} catch (e) { /* 无窗口 */ }
|
|
353
|
+
}
|
|
354
|
+
return list
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function pickWin32(target, wins) {
|
|
358
|
+
const named = wins.filter((d) => d.kind === 'win32')
|
|
359
|
+
if (!named.length) return null
|
|
360
|
+
if (target) {
|
|
361
|
+
const t = String(target)
|
|
362
|
+
/* hwnd 精确匹配优先:面板与控制器规划器传的就是 hwnd(id)。
|
|
363
|
+
标题匹配放后面——窗口标题可能重复或互为子串。 */
|
|
364
|
+
const exact = named.find((d) => d.id === t)
|
|
365
|
+
if (exact) return exact
|
|
366
|
+
const lower = t.toLowerCase()
|
|
367
|
+
const hit = named.find((d) => d.name.toLowerCase().includes(lower) || d.cls.toLowerCase().includes(lower))
|
|
368
|
+
/* 指定了 target 却没匹配上时**不**退化成"随便挑一个":
|
|
369
|
+
那是静默连错窗口,比连不上更难查(实测踩过)。 */
|
|
370
|
+
return hit ?? null
|
|
371
|
+
}
|
|
372
|
+
return named.find((d) => d.name) || named[0]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 枚举名 → 数值。interface.json 里 screencap/mouse/keyboard 写的是枚举名
|
|
377
|
+
* (如 PrintWindow / SendMessageWithCursorPos),maa-node 要的是数值;
|
|
378
|
+
* 名字大小写不敏感(与本体 string_to_enum 的 iequals 一致)。
|
|
379
|
+
* 认不出的名字不硬塞给原生层,退回默认并把警告带回上层。
|
|
380
|
+
*/
|
|
381
|
+
function resolveEnum(table, value, fallback, label, warns) {
|
|
382
|
+
if (value === undefined || value === null || value === '') return String(fallback)
|
|
383
|
+
const s = String(value)
|
|
384
|
+
if (/^\d+$/.test(s)) return s
|
|
385
|
+
if (table && typeof table === 'object') {
|
|
386
|
+
const key = Object.keys(table).find((k) => k.toLowerCase() === s.toLowerCase())
|
|
387
|
+
if (key !== undefined) return String(table[key])
|
|
388
|
+
}
|
|
389
|
+
warns.push(label + '=' + s + ' 不是当前 maa-node 绑定认识的枚举名,已退回默认值 ' + fallback)
|
|
390
|
+
return String(fallback)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* LinuxController 的 config JSON 组装(maa-support-extension buildControllerRuntime /
|
|
395
|
+
* MXU build_linux_controller_config 同款规则,纯函数、与宿主平台无关,供单测直测):
|
|
396
|
+
* 1) args.target/config 是 JSON 串 → 原样用(字段全集归用户管,逃逸口);
|
|
397
|
+
* 2) 否则按 linuxDecl(interface.json linux{})+ args.linux(用户字段)组 JSON:
|
|
398
|
+
* 方法名→数值枚举、缺省 Wlr/Wlr、Wlr 必填 wlr_socket_path、pipewire_source:Portal
|
|
399
|
+
* 明确拒(portal helper 未接)、UInput/Libei 补各自字段、display_no 透传。
|
|
400
|
+
* 返回 { ok:true, json } 或 { ok:false, error }。
|
|
401
|
+
*/
|
|
402
|
+
function buildLinuxConfig(m, args) {
|
|
403
|
+
const raw = String(args.config ?? args.target ?? '')
|
|
404
|
+
if (raw.trim().startsWith('{')) return { ok: true, json: raw.trim() }
|
|
405
|
+
const decl = (args.linuxDecl && typeof args.linuxDecl === 'object') ? args.linuxDecl : {}
|
|
406
|
+
const user = (args.linux && typeof args.linux === 'object') ? args.linux : {}
|
|
407
|
+
/* 双键名:user/linuxDecl 里驼峰与蛇形都认(interface.json 原生是蛇形,daemon 参数是驼峰) */
|
|
408
|
+
const pickL = (k, k2) => user[k] ?? user[k2] ?? decl[k] ?? decl[k2]
|
|
409
|
+
const resolveLinuxMethod = (tbl, name) => {
|
|
410
|
+
if (name === undefined || name === null || name === '') return undefined
|
|
411
|
+
if (tbl && typeof tbl === 'object' && name in tbl) return Number(tbl[name])
|
|
412
|
+
const n = Number(name)
|
|
413
|
+
return Number.isFinite(n) ? n : undefined
|
|
414
|
+
}
|
|
415
|
+
const screencapName = pickL('screencap', 'screencap') ?? 'Wlr'
|
|
416
|
+
const inputName = pickL('input', 'input') ?? 'Wlr'
|
|
417
|
+
const sc = resolveLinuxMethod(m.LinuxScreencapMethod, screencapName)
|
|
418
|
+
const im = resolveLinuxMethod(m.LinuxInputMethod, inputName)
|
|
419
|
+
if (sc === undefined || im === undefined) {
|
|
420
|
+
return { ok: false, error: 'linux 方法名不合法:screencap=' + screencapName + ' input=' + inputName
|
|
421
|
+
+ '(可选 screencap: Wlr/ExtImage/PipeWire,input: Wlr/UInput/Libei;或直接给 config=<JSON>)' }
|
|
422
|
+
}
|
|
423
|
+
const conf = {
|
|
424
|
+
screencap_method: sc,
|
|
425
|
+
input_method: im,
|
|
426
|
+
use_win32_vk_code: !!(user.useWin32VkCode ?? decl.useWin32VkCode ?? decl.use_win32_vk_code),
|
|
427
|
+
}
|
|
428
|
+
if (screencapName === 'PipeWire') {
|
|
429
|
+
const src = pickL('pipewireSource', 'pipewire_source') ?? 'Gamescope'
|
|
430
|
+
if (src === 'Portal') {
|
|
431
|
+
return { ok: false, error: 'linux pipewire_source=Portal 暂不支持(portal helper 未接入)——换 Gamescope 或自管 fd' }
|
|
432
|
+
}
|
|
433
|
+
conf.pipewire_source = src
|
|
434
|
+
if (user.pwSocketFd !== undefined) conf.pw_socket_fd = user.pwSocketFd
|
|
435
|
+
if (user.pwNodeId !== undefined) conf.pw_node_id = user.pwNodeId
|
|
436
|
+
}
|
|
437
|
+
if (screencapName === 'Wlr' || inputName === 'Wlr') {
|
|
438
|
+
const sock = pickL('wlrSocketPath', 'wlr_socket_path')
|
|
439
|
+
if (!sock) {
|
|
440
|
+
return { ok: false, error: 'linux 方法含 Wlr:需要 wlr_socket_path(嵌套 compositor 的 socket)——在 linux config JSON 或 interface.json linux{} 里给' }
|
|
441
|
+
}
|
|
442
|
+
conf.wlr_socket_path = sock
|
|
443
|
+
}
|
|
444
|
+
if (inputName === 'UInput') {
|
|
445
|
+
if (user.uinputScreenWidth !== undefined) conf.uinput_screen_width = user.uinputScreenWidth
|
|
446
|
+
if (user.uinputScreenHeight !== undefined) conf.uinput_screen_height = user.uinputScreenHeight
|
|
447
|
+
}
|
|
448
|
+
if (inputName === 'Libei') {
|
|
449
|
+
const eis = pickL('eisSocketPath', 'eis_socket_path')
|
|
450
|
+
if (eis) conf.eis_socket_path = eis
|
|
451
|
+
}
|
|
452
|
+
const dno = pickL('displayNo', 'display_no')
|
|
453
|
+
if (dno !== undefined) conf.display_no = dno
|
|
454
|
+
return { ok: true, json: JSON.stringify(conf) }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function cmdConnect(args) {
|
|
458
|
+
const m = loadMaa()
|
|
459
|
+
if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
|
|
460
|
+
/* 旧控制器已销毁:分辨率缓存不能留到下一次连接——连接失败时它会是上一台的尺寸,
|
|
461
|
+
* 让 spaceCheck / stream_status 把旧空间当成当前空间。 */
|
|
462
|
+
S.fullW = 0; S.fullH = 0
|
|
463
|
+
/* 控制器重建 = 旧流失效:显式停掉并在回执里说明(`streamStopped`),别让调用方以为流还在跑。
|
|
464
|
+
* 曾经的行为是"tick 撞到空控制器后静默死掉、S.stream 仍为 true",于是重连后的 stream_start 被
|
|
465
|
+
* "已启动"挡回去,表现为一帧都不来(实测:seq 卡死,而 screencap 正常)。 */
|
|
466
|
+
const streamStopped = S.stream === true
|
|
467
|
+
if (S.stream) await cmdStreamStop()
|
|
468
|
+
S.session = null
|
|
469
|
+
let ctrl = null
|
|
470
|
+
let session = null
|
|
471
|
+
const warns = []
|
|
472
|
+
if (args.kind === 'adb') {
|
|
473
|
+
const adbs = (await m.AdbController.find()) || []
|
|
474
|
+
const addr = args.target || (adbs[0] ? String(adbs[0][2] ?? adbs[0][0]) : null)
|
|
475
|
+
if (!addr) return { ok: false, error: '未发现 adb 设备,且未提供 address' }
|
|
476
|
+
const dev = adbs.find((d) => String(d[2] ?? d[0]) === String(addr))
|
|
477
|
+
?? (args.target ? null : adbs[0])
|
|
478
|
+
/* 指定了 address 却不在已发现列表里:明确报错,不静默连到别的设备上 */
|
|
479
|
+
if (!dev) return { ok: false, error: 'adb 地址 ' + addr + ' 不在已发现设备中(共 ' + adbs.length + ' 台)' }
|
|
480
|
+
const [name, adbPath, address, caps, inputs, config] = dev
|
|
481
|
+
/* caps/inputs 是 find() 给出的方法位掩码(Uint64 字符串),照原样透传——框架会按
|
|
482
|
+
* 固定优先级在掩码内自选。仅当 find() 没给时才退 Default(全方法减禁用项),
|
|
483
|
+
* 不许塞数组:ScreencapOrInputMethods 的绑定类型就是字符串。
|
|
484
|
+
* agent(maa-node 可选第 6 参):MXU 传 MaaAgentBinary 路径供 adb 侧 agent;
|
|
485
|
+
* 声明式 PI 的 agent 桥接走另一条通道(identifier 子进程),这里只透传显式参数。 */
|
|
486
|
+
ctrl = new m.AdbController(adbPath || 'adb', String(address ?? addr),
|
|
487
|
+
caps || String(m.AdbScreencapMethod?.Default ?? '0'),
|
|
488
|
+
inputs || String(m.AdbInputMethod?.Default ?? '0'), config || '{}',
|
|
489
|
+
args.agent ? String(args.agent) : undefined)
|
|
490
|
+
session = { kind: 'adb', target: String(address ?? addr), name: String(name) }
|
|
491
|
+
} else if (args.kind === 'win32' || args.kind === 'gamepad') {
|
|
492
|
+
const wins = await cmdDeviceList('win32')
|
|
493
|
+
let pick = pickWin32(args.target, wins)
|
|
494
|
+
/* GamepadController(hwnd|null, gamepad_type, screencap):规范允许不配图——hwnd=null
|
|
495
|
+
* 即"仅虚拟手柄输入、不截图"。给了 target 没匹配上仍报错(防静默连错窗口);
|
|
496
|
+
* 没给 target 且一个可选窗口都没有时才走 null。 */
|
|
497
|
+
const gamepadNoWindow = args.kind === 'gamepad' && !pick && !args.target
|
|
498
|
+
if (!pick && !gamepadNoWindow) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
error: args.target
|
|
502
|
+
? 'win32 窗口未找到:' + args.target + '(当前 ' + wins.length + ' 个窗口;hwnd 变了或窗口已关闭)'
|
|
503
|
+
: '未发现 win32 窗口',
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/* 截图/输入方法:优先用调用方给的(项目驱动路径 = interface.json 声明 + 官方缺省)。
|
|
507
|
+
缺省值照 maa-support buildControllerRuntime(controller.ts:95-102)的官方解释:
|
|
508
|
+
screencap=FramePool、mouse=SendMessageWithCursorPos、keyboard=SendMessage。
|
|
509
|
+
(此前用的 Foreground/SendMessage 是 daemon 自己的历史缺省,项目没声明时会偏离官方行为。) */
|
|
510
|
+
const cap = resolveEnum(m.Win32ScreencapMethod, args.screencap, m.Win32ScreencapMethod?.FramePool ?? 2, 'screencap', warns)
|
|
511
|
+
if (args.kind === 'gamepad') {
|
|
512
|
+
/* GamepadController 构造器签名(maa-node .d.ts):(hwnd, gamepad_type, screencap_method)。
|
|
513
|
+
gamepad_type 枚举名→值照 m.GamepadType(Xbox360/DualShock4,缺省 Xbox360)。
|
|
514
|
+
输入走虚拟手柄,需宿主装 ViGEm Bus Driver,否则 connect 会在原生层失败。 */
|
|
515
|
+
const padType = resolveEnum(m.GamepadType,
|
|
516
|
+
args.gamepadType === 'DS4' ? 'DualShock4' : args.gamepadType,
|
|
517
|
+
m.GamepadType?.Xbox360 ?? '0', 'gamepadType', warns)
|
|
518
|
+
ctrl = new m.GamepadController(pick ? pick.id : null, padType, cap)
|
|
519
|
+
session = { kind: 'gamepad', target: pick ? pick.id : null,
|
|
520
|
+
name: pick ? (pick.name || pick.cls) : '(无窗口:仅输入)',
|
|
521
|
+
cls: pick ? pick.cls : null, method: { screencap: cap, gamepadType: padType } }
|
|
522
|
+
} else {
|
|
523
|
+
const mouse = resolveEnum(m.Win32InputMethod, args.mouse, m.Win32InputMethod?.SendMessageWithCursorPos ?? 32, 'mouse', warns)
|
|
524
|
+
const key = resolveEnum(m.Win32InputMethod, args.keyboard, m.Win32InputMethod?.SendMessage ?? 2, 'keyboard', warns)
|
|
525
|
+
ctrl = new m.Win32Controller(pick.id, cap, mouse, key)
|
|
526
|
+
session = { kind: 'win32', target: pick.id, name: pick.name || pick.cls, cls: pick.cls, method: { screencap: cap, mouse, keyboard: key } }
|
|
527
|
+
}
|
|
528
|
+
} else if (args.kind === 'playcover' || args.kind === 'macos') {
|
|
529
|
+
/* PlayCover / MacOS 控制器仅 macOS 可用(maa-node 本体在其它平台编译即排除)。
|
|
530
|
+
非 macOS 宿主不给"也许能连"的尝试,直接说明。 */
|
|
531
|
+
if (process.platform !== 'darwin') {
|
|
532
|
+
return { ok: false, error: '控制器类型 ' + args.kind + ' 仅支持在 macOS 上使用;当前宿主是 ' + process.platform }
|
|
533
|
+
}
|
|
534
|
+
if (args.kind === 'playcover') {
|
|
535
|
+
/* PlayCoverController(address, uuid):address 是设备侧服务地址,uuid 是应用的
|
|
536
|
+
Bundle Identifier。两者都有缺省(maa-support-extension 同款):
|
|
537
|
+
localhost:1717 / maa.playcover——不给 target 也按缺省构造。 */
|
|
538
|
+
const parts = String(args.target ?? '').split('/')
|
|
539
|
+
const address = args.address ? String(args.address) : (parts[0] || 'localhost:1717')
|
|
540
|
+
const uuid = args.uuid ? String(args.uuid) : (parts[1] || 'maa.playcover')
|
|
541
|
+
ctrl = new m.PlayCoverController(address, uuid)
|
|
542
|
+
session = { kind: 'playcover', target: address + '/' + uuid }
|
|
543
|
+
} else {
|
|
544
|
+
/* MacOSController(window_id, screencap_method, input_method):窗口用 MacOSController.find() 枚举。
|
|
545
|
+
匹配优先级:target(id 或标题子串)> macosTitleRegex(PI title_regex)> 第一个窗口。 */
|
|
546
|
+
const wins = (await m.MacOSController.find()) || []
|
|
547
|
+
let pick = null
|
|
548
|
+
if (args.target) {
|
|
549
|
+
pick = wins.find((d) => String(d[0]) === String(args.target)) ??
|
|
550
|
+
wins.find((d) => String(d[2] ?? '').includes(String(args.target)))
|
|
551
|
+
} else if (args.macosTitleRegex) {
|
|
552
|
+
let re = null
|
|
553
|
+
try { re = new RegExp(String(args.macosTitleRegex)) } catch (e) { /* 坏正则按下个分支报错 */ }
|
|
554
|
+
if (re) pick = wins.find((d) => re.test(String(d[2] ?? '')))
|
|
555
|
+
else return { ok: false, error: 'macos title_regex 不是合法正则:' + args.macosTitleRegex }
|
|
556
|
+
} else {
|
|
557
|
+
pick = wins[0] ?? null
|
|
558
|
+
}
|
|
559
|
+
if (!pick) {
|
|
560
|
+
return { ok: false, error: args.target ? 'macOS 窗口未找到:' + args.target
|
|
561
|
+
: args.macosTitleRegex ? 'macOS 窗口按 title_regex 未匹配到:' + args.macosTitleRegex
|
|
562
|
+
: '未发现 macOS 窗口' }
|
|
563
|
+
}
|
|
564
|
+
const cap = resolveEnum(m.MacOSScreencapMethod, args.screencap, m.MacOSScreencapMethod?.ScreenCaptureKit ?? '1', 'screencap', warns)
|
|
565
|
+
/* input 走 MacOSInputMethod 表(GlobalEvent/PostToPid),缺省 GlobalEvent(1)——
|
|
566
|
+
* 此前误用了 screencap 表+keyboard 参数,名字解析必错(ScreenCaptureKit 值恰好是 '1' 才没炸)。 */
|
|
567
|
+
const input = resolveEnum(m.MacOSInputMethod, args.input, m.MacOSInputMethod?.GlobalEvent ?? '1', 'input', warns)
|
|
568
|
+
ctrl = new m.MacOSController(pick.id ?? pick[0], cap, input)
|
|
569
|
+
session = { kind: 'macos', target: String(pick[0]), name: String(pick[2] ?? ''), method: { screencap: cap, input } }
|
|
570
|
+
}
|
|
571
|
+
} else if (args.kind === 'linux') {
|
|
572
|
+
if (process.platform !== 'linux') {
|
|
573
|
+
return { ok: false, error: '控制器类型 linux 仅支持在 Linux 上使用;当前宿主是 ' + process.platform }
|
|
574
|
+
}
|
|
575
|
+
const cfg = buildLinuxConfig(m, args)
|
|
576
|
+
if (cfg.ok === false) return { ok: false, error: cfg.error }
|
|
577
|
+
ctrl = new m.LinuxController(cfg.json)
|
|
578
|
+
session = { kind: 'linux', target: '(config)' }
|
|
579
|
+
} else {
|
|
580
|
+
return { ok: false, error: '未知控制器类型:' + args.kind + '(支持 win32/adb/gamepad/playcover/macos/linux)' }
|
|
581
|
+
}
|
|
582
|
+
/* 截图缩放目标:默认不干预 —— maafw 本体按 interface.json 的 short_side(缺省最短边 720p)缩放。
|
|
583
|
+
* 只有用户显式传参才覆盖;注意这会改变 Tasker 识别图像的尺寸(模板/ROI 坐标系随之变化)。
|
|
584
|
+
* display_* 四种模式互斥(MXU DisplayOptions.validate 同款规则):rawSize / expand / longSide
|
|
585
|
+
* / shortSide 只允许给一个,多给是配置错误——静默多设几个 setter 的覆盖顺序不可观测,直接拒。 */
|
|
586
|
+
const displayModes = [!!args.rawSize, !!args.expand, !!args.longSide, !!args.shortSide].filter(Boolean).length
|
|
587
|
+
if (displayModes > 1) {
|
|
588
|
+
try { ctrl.destroy() } catch (e) { /* ignore */ }
|
|
589
|
+
return { ok: false, error: 'display_* 互斥:rawSize / expand / longSide / shortSide 只能给一个(同时声明了 ' + displayModes + ' 个)' }
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
if (args.rawSize) ctrl.screenshot_use_raw_size = true
|
|
593
|
+
else if (args.expand) {
|
|
594
|
+
const [ew, eh] = Array.isArray(args.expand) ? args.expand : []
|
|
595
|
+
if (!Number.isFinite(ew) || !Number.isFinite(eh) || ew <= 0 || eh <= 0) {
|
|
596
|
+
try { ctrl.destroy() } catch (e) { /* ignore */ }
|
|
597
|
+
return { ok: false, error: 'display_expand 需要两个正整数 [w,h]:' + JSON.stringify(args.expand) }
|
|
598
|
+
}
|
|
599
|
+
ctrl.screenshot_target_expand = [Number(ew), Number(eh)]
|
|
600
|
+
} else if (args.longSide) ctrl.screenshot_target_long_side = Number(args.longSide)
|
|
601
|
+
else if (args.shortSide) ctrl.screenshot_target_short_side = Number(args.shortSide)
|
|
602
|
+
} catch (e) { /* 绑定版本不支持 setter 时忽略 */ }
|
|
603
|
+
/* 连接判定:5.14.2 实测 `Job.wait()` 在连接失败时**不抛异常**,只把 status 置为 Failed(4000)。
|
|
604
|
+
* 不查 status 就会把失败当成功:S.ctrl/S.session 照设、connect 回 ok:true,下一条命令再踩
|
|
605
|
+
* 半残控制器(预热截图失败只写进 resolution.error,CLI 摘要里看不出来)。 */
|
|
606
|
+
let connJob = null
|
|
607
|
+
try {
|
|
608
|
+
connJob = ctrl.post_connection()
|
|
609
|
+
await connJob.wait()
|
|
610
|
+
} catch (e) {
|
|
611
|
+
try { ctrl.destroy() } catch (e2) { /* ignore */ }
|
|
612
|
+
return { ok: false, error: '连接失败:' + String(e && e.message || e) }
|
|
613
|
+
}
|
|
614
|
+
const connStatus = Number(connJob && connJob.status)
|
|
615
|
+
if (connStatus !== STATUS.succeeded) {
|
|
616
|
+
try { ctrl.destroy() } catch (e) { /* ignore */ }
|
|
617
|
+
return { ok: false, error: '连接失败:post_connection status=' + String(connStatus) + '(成功应为 ' + STATUS.succeeded + ')' }
|
|
618
|
+
}
|
|
619
|
+
S.ctrl = ctrl
|
|
620
|
+
S.session = session
|
|
621
|
+
S.connGen++
|
|
622
|
+
/* 预热一次截图,取控制器分辨率(默认短边 720 的坐标系,pipeline 的 roi/模板即此空间) */
|
|
623
|
+
let res = null
|
|
624
|
+
try {
|
|
625
|
+
const j = ctrl.post_screencap(); await j.wait()
|
|
626
|
+
const buf = j.get()
|
|
627
|
+
if (buf && buf.byteLength > 8) {
|
|
628
|
+
const dec = pngDecode(Buffer.from(buf))
|
|
629
|
+
S.fullW = dec.w; S.fullH = dec.h
|
|
630
|
+
res = { w: dec.w, h: dec.h, bytes: buf.byteLength }
|
|
631
|
+
}
|
|
632
|
+
} catch (e) { res = { error: String(e.message) } }
|
|
633
|
+
return {
|
|
634
|
+
ok: true,
|
|
635
|
+
session: { ...session, resolution: res, ...(warns.length ? { warns } : {}) },
|
|
636
|
+
/* 重连会重建控制器 → 旧流已停;调用方要接着看帧就得重新 stream_start(回执里说清楚,不靠猜) */
|
|
637
|
+
...(streamStopped ? { streamStopped: true } : {}),
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function cmdDisconnect() {
|
|
642
|
+
if (S.stream) await cmdStreamStop()
|
|
643
|
+
await cleanupAgents()
|
|
644
|
+
if (S.tasker) { try { S.tasker.destroy() } catch (e) { /* ignore */ } S.tasker = null }
|
|
645
|
+
/* tasker 收掉之后再放 Resource:顺序反了会让还在引用的 tasker 拿到已销毁的句柄 */
|
|
646
|
+
if (S.runRes) { try { S.runRes.destroy() } catch (e) { /* ignore */ } S.runRes = null }
|
|
647
|
+
if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
|
|
648
|
+
S.session = null
|
|
649
|
+
return { ok: true }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function cmdScreencap(outFile) {
|
|
653
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
|
|
654
|
+
const j = S.ctrl.post_screencap()
|
|
655
|
+
await j.wait()
|
|
656
|
+
const buf = j.get()
|
|
657
|
+
if (!buf || buf.byteLength < 8) return { ok: false, error: '截图为空' }
|
|
658
|
+
const b = Buffer.from(buf)
|
|
659
|
+
const dec = pngDecode(b)
|
|
660
|
+
/* 截图也是一次观测:进环、进 L0(有捕获身份,可被 kf promote --seq 指定升格) */
|
|
661
|
+
pushFrame(dec, b)
|
|
662
|
+
const d = defaultOutFile(S.runDir, 'screencap');
|
|
663
|
+
const file = outFile || d.file
|
|
664
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
665
|
+
fs.writeFileSync(file, b)
|
|
666
|
+
return { ok: true, path: file, bytes: b.length, w: dec.w, h: dec.h, seq: S.seq }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* ────────────────────────── 帧流(自适应节奏 + 事件合并) ────────────────────────── */
|
|
670
|
+
/** 变化块的联合 bbox:小图坐标 + 控制器坐标(8×8 网格,块边界按小图像素取整,外扩不收缩) */
|
|
671
|
+
function regionOfBlocks(blocks, w, h, fw, fh) {
|
|
672
|
+
let bx0 = 8, by0 = 8, bx1 = -1, by1 = -1
|
|
673
|
+
for (const k of blocks) {
|
|
674
|
+
const bx = k % 8, by = (k / 8) | 0
|
|
675
|
+
if (bx < bx0) bx0 = bx
|
|
676
|
+
if (by < by0) by0 = by
|
|
677
|
+
if (bx > bx1) bx1 = bx
|
|
678
|
+
if (by > by1) by1 = by
|
|
679
|
+
}
|
|
680
|
+
const x0 = Math.round(bx0 * w / 8), y0 = Math.round(by0 * h / 8)
|
|
681
|
+
const x1 = Math.round((bx1 + 1) * w / 8), y1 = Math.round((by1 + 1) * h / 8)
|
|
682
|
+
const sx = fw > 0 ? fw / w : 1, sy = fh > 0 ? fh / h : 1
|
|
683
|
+
return {
|
|
684
|
+
small: [x0, y0, x1 - x0, y1 - y0],
|
|
685
|
+
ctrl: [Math.round(x0 * sx), Math.round(y0 * sy), Math.round((x1 - x0) * sx), Math.round((y1 - y0) * sy)],
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** L0 锚区入队(按 seq 去重:同一捕获可能既是滚动帧又是事件锚点,字节共用同一 Buffer)。
|
|
690
|
+
* relabel:显式来源(动作边界/节点锚点)覆盖同帧已被事件路径标上的 change/stable——
|
|
691
|
+
* 一个捕获一个锚点,但标签以显式意图为准。 */
|
|
692
|
+
function l0AnchorAdd(entry, relabel = false) {
|
|
693
|
+
const existing = S.l0.anchor.find((x) => x.seq === entry.seq)
|
|
694
|
+
if (existing) {
|
|
695
|
+
if (relabel && entry.source) existing.source = entry.source
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
S.l0.anchor.push(entry)
|
|
699
|
+
if (S.l0.anchor.length > S.l0.anchorCap) S.l0.anchor.splice(0, S.l0.anchor.length - S.l0.anchorCap)
|
|
700
|
+
l0EvictToBudget()
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** 字节预算淘汰:唯一 seq 去重计总字节,超限先淘汰滚动区最旧、仍超再淘汰锚区最旧。 */
|
|
704
|
+
function l0EvictToBudget() {
|
|
705
|
+
const cap = S.l0.bytesCap
|
|
706
|
+
if (!(cap > 0)) return
|
|
707
|
+
const bytesOf = (e) => (e.png ? e.png.length : 0)
|
|
708
|
+
let total = 0
|
|
709
|
+
const seen = new Set()
|
|
710
|
+
for (const e of S.l0.roll) if (!seen.has(e.seq)) { seen.add(e.seq); total += bytesOf(e) }
|
|
711
|
+
for (const e of S.l0.anchor) if (!seen.has(e.seq)) { seen.add(e.seq); total += bytesOf(e) }
|
|
712
|
+
while (total > cap) {
|
|
713
|
+
const r0 = S.l0.roll[0]
|
|
714
|
+
if (r0) {
|
|
715
|
+
S.l0.roll.shift()
|
|
716
|
+
if (!S.l0.anchor.some((x) => x.seq === r0.seq)) total -= bytesOf(r0)
|
|
717
|
+
} else if (S.l0.anchor.length) {
|
|
718
|
+
total -= bytesOf(S.l0.anchor.shift())
|
|
719
|
+
} else break
|
|
720
|
+
S.l0.evicted++
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* 一次捕获的统一入口:原图字节先进 L0(先存后解,不解码即落袋),观测面再解码进环。
|
|
726
|
+
* png 是控制器交付的原始 PNG 字节(不解码、不重编码);dec 是它解码后的观测数据。
|
|
727
|
+
*/
|
|
728
|
+
function pushFrame(dec, png) {
|
|
729
|
+
const rgb = toRgb(dec)
|
|
730
|
+
const small = downscale(rgb, dec.w, dec.h, S.streamScale)
|
|
731
|
+
const { hash, means } = blockAnalyze(small.data, small.w, small.h)
|
|
732
|
+
const prev = S.ring.length ? S.ring[S.ring.length - 1] : null
|
|
733
|
+
const diff = prev ? hashDist(prev.hash, hash) : 0
|
|
734
|
+
S.seq++
|
|
735
|
+
const fr = { seq: S.seq, t: Date.now(), rgb: small.data, w: small.w, h: small.h, fw: dec.w, fh: dec.h, hash, means, diff }
|
|
736
|
+
S.ring.push(fr)
|
|
737
|
+
if (S.ring.length > S.maxFrames) S.ring.shift()
|
|
738
|
+
S.fullW = dec.w; S.fullH = dec.h
|
|
739
|
+
const l0 = { seq: fr.seq, t: fr.t, png, w: dec.w, h: dec.h }
|
|
740
|
+
S.l0.roll.push(l0)
|
|
741
|
+
if (S.l0.roll.length > S.l0.rollCap) S.l0.roll.splice(0, S.l0.roll.length - S.l0.rollCap)
|
|
742
|
+
l0EvictToBudget()
|
|
743
|
+
const meta = { seq: fr.seq, t: fr.t, hash: hashStr(hash), diff: Math.round(diff * 1000) / 1000, w: fr.w, h: fr.h }
|
|
744
|
+
S.framesMeta.push(meta)
|
|
745
|
+
if (S.framesMeta.length > 2000) S.framesMeta.splice(0, S.framesMeta.length - 2000)
|
|
746
|
+
/* 预览帧落盘(原子替换):面板 HTTP 直读,避免二进制走 JSON 行协议 */
|
|
747
|
+
const preview = pngEncodeRGB(small.data, small.w, small.h)
|
|
748
|
+
if (S.previewPath) {
|
|
749
|
+
const tmp = S.previewPath + '.tmp'
|
|
750
|
+
try { fs.writeFileSync(tmp, preview); fs.renameSync(tmp, S.previewPath) } catch (e) { /* 落盘失败不致命 */ }
|
|
751
|
+
}
|
|
752
|
+
send({ kind: 'frame', meta, preview: S.previewPath })
|
|
753
|
+
/* 变化 / 稳定事件(400ms 窗口合并);锚区只收"新事件"的首帧,合并窗口内不重复入锚。
|
|
754
|
+
* 双阈值:全局位差率管分布式小变化,分块亮度差管局部小区域变化(小区域位差占比低,
|
|
755
|
+
* 单看全局阈值会漏检且漏检导致降频加剧漏检)。变化事件携带变化区域 bbox(小图与控制器两套坐标)。 */
|
|
756
|
+
let changed = null
|
|
757
|
+
if (prev && means) {
|
|
758
|
+
const blocks = []
|
|
759
|
+
for (let k = 0; k < 64; k++) {
|
|
760
|
+
if (Math.abs(means[k] - prev.means[k]) > S.blockThresh) blocks.push(k)
|
|
761
|
+
}
|
|
762
|
+
if (blocks.length) changed = { blocks, region: regionOfBlocks(blocks, fr.w, fr.h, fr.fw, fr.fh) }
|
|
763
|
+
}
|
|
764
|
+
const isChange = diff > S.changeGlobal || changed !== null
|
|
765
|
+
if (isChange) {
|
|
766
|
+
S.quietNow = false
|
|
767
|
+
const last = S.events.length ? S.events[S.events.length - 1] : null
|
|
768
|
+
if (last && last.type === 'change' && fr.t - last.t < 400) {
|
|
769
|
+
/* 合并同簇:seq/t/diff 前移,**frame 锚点必须跟着前移**——否则事件指向的是簇内第一帧,
|
|
770
|
+
* 而调用方看到的 t/seq 是最后一帧,锚点与内容对不上(同一类"回执与事实不符")。 */
|
|
771
|
+
last.t = fr.t; last.seq = fr.seq; last.diff = Math.max(last.diff, diff)
|
|
772
|
+
l0AnchorAdd({ ...l0, source: 'change' })
|
|
773
|
+
last.frame = eventFrameAnchor(fr.seq)
|
|
774
|
+
} else {
|
|
775
|
+
/* 顺序要紧:**先入锚区、再发事件**。事件里的 `frame.retained` 是按 seq 反查 L0 得出的,
|
|
776
|
+
* 反过来的话刚发出的 change/stable 事件会自报 `retained:'gone'`——明明它自己就是
|
|
777
|
+
* 触发入锚区的那一帧(实测就是这么错了一次)。 */
|
|
778
|
+
l0AnchorAdd({ ...l0, source: 'change' })
|
|
779
|
+
pushEvent({
|
|
780
|
+
type: 'change', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000,
|
|
781
|
+
...(changed ? { blocks: changed.blocks.length, region: changed.region } : {}),
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
} else if (prev) {
|
|
785
|
+
if (!S.quietNow) {
|
|
786
|
+
S.quietNow = true
|
|
787
|
+
l0AnchorAdd({ ...l0, source: 'stable' })
|
|
788
|
+
pushEvent({ type: 'stable', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000 })
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* 主动抓一张原图并入观测流(seq 前进、进环、进 L0,可作动作/run 边界锚点)。
|
|
794
|
+
* 采集失败如实上报,绝不阻断调用方的动作——动作结果与留存失败分别报告(契约 §9)。
|
|
795
|
+
*/
|
|
796
|
+
/**
|
|
797
|
+
* 动作回执帧落盘:一次性 CLI(daemon 随命令死)也能复核"动作前后画面"——
|
|
798
|
+
* 帧本身是 L0 原图字节,落 runDir/action-frames/(与关键帧库分开:这不是留存证据本体,
|
|
799
|
+
* 是回执附件,不占 kf 配额、不进 manifest)。
|
|
800
|
+
* 同一次 input 的 before/after 用同一对文件名;dir 不存在则跳过(静默降级为无路径回执)。
|
|
801
|
+
*/
|
|
802
|
+
function actionFramesRoot() {
|
|
803
|
+
return S.runDir ? path.join(S.runDir, 'action-frames') : null
|
|
804
|
+
}
|
|
805
|
+
function persistActionFrame(seq, source) {
|
|
806
|
+
const root = actionFramesRoot()
|
|
807
|
+
if (!root) return null
|
|
808
|
+
const ent = S.l0.roll.find((e) => e.seq === seq)
|
|
809
|
+
if (!ent || !ent.png) return null
|
|
810
|
+
try {
|
|
811
|
+
fs.mkdirSync(root, { recursive: true })
|
|
812
|
+
const file = path.join(root, source + '-seq' + String(seq).padStart(6, '0') + '.png')
|
|
813
|
+
fs.writeFileSync(file, ent.png)
|
|
814
|
+
return file
|
|
815
|
+
} catch (e) { return null }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function captureNow(source, persist = false) {
|
|
819
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备' }
|
|
820
|
+
try {
|
|
821
|
+
const j = S.ctrl.post_screencap()
|
|
822
|
+
await j.wait()
|
|
823
|
+
const buf = j.get()
|
|
824
|
+
if (!buf || buf.byteLength < 8) return { ok: false, error: '空截图' }
|
|
825
|
+
const b = Buffer.from(buf)
|
|
826
|
+
const dec = pngDecode(b)
|
|
827
|
+
pushFrame(dec, b)
|
|
828
|
+
const ent = S.l0.roll[S.l0.roll.length - 1]
|
|
829
|
+
l0AnchorAdd({ ...ent, source }, true)
|
|
830
|
+
const out = { ok: true, seq: ent.seq, t: ent.t, w: ent.w, h: ent.h }
|
|
831
|
+
/* 动作帧持久化:一次性 CLI 下 seq 无消费者(daemon 死后 frame_get 不存),
|
|
832
|
+
* 路径才是回执里能看的东西;常驻会话里多写一份也无害(同字节、幂等文件名)。 */
|
|
833
|
+
if (persist) {
|
|
834
|
+
const file = persistActionFrame(ent.seq, source)
|
|
835
|
+
if (file) out.file = file
|
|
836
|
+
}
|
|
837
|
+
return out
|
|
838
|
+
} catch (e) {
|
|
839
|
+
return { ok: false, error: String(e && e.message || e) }
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* node-ok/fail 锚点:用控制器缓存图入锚区,带"上一张已留帧的 hash"去重。
|
|
845
|
+
* 关键约束:不要在节点回调里发 post_screencap——add_sink 回调中并发截图会与 Tasker
|
|
846
|
+
* 识别回路竞争控制器,实测触发原生崩溃 0xC0000005(maa-node 5.14.2 win32-x64,
|
|
847
|
+
* 2026-10-05 真机:同任务关锚点正常 / 开锚点必崩)。
|
|
848
|
+
* cached_image 是框架识别回路自己截的图:零新任务、无竞争,且节点回调时刻的缓存
|
|
849
|
+
* 通常就是该节点识别所用的那张(bound 倾向);但下一节点识别可能已刷新缓存,
|
|
850
|
+
* 关联强度按 adjacent 报,不冒称 bound(契约 §4)。
|
|
851
|
+
* 节点结束帧若与上一张已留帧相同(hashDist < NODE_FRAME_DEDUP)就不重复入锚区,
|
|
852
|
+
* 返回 { seq:null, skipped:'same' };不同则入锚区并返回 { seq, hash }(调用方拿 hash 记给下一次)。
|
|
853
|
+
* 阈值取小:目标是"同一个屏重复出现只留一张",不是"相似就丢"——近似画面各自留,别误并。
|
|
854
|
+
*/
|
|
855
|
+
const NODE_FRAME_DEDUP = 0.02
|
|
856
|
+
function anchorFromCacheDedup(source, lastHash) {
|
|
857
|
+
if (!S.ctrl) return { seq: null, skipped: 'no-ctrl' }
|
|
858
|
+
let img = null
|
|
859
|
+
try { img = S.ctrl.cached_image } catch (e) { return { seq: null, skipped: 'no-img' } }
|
|
860
|
+
if (!img || img.byteLength < 9) return { seq: null, skipped: 'no-img' }
|
|
861
|
+
const b = Buffer.from(img)
|
|
862
|
+
let dec = null
|
|
863
|
+
try { dec = pngDecode(b) } catch (e) { return { seq: null, skipped: 'decode' } }
|
|
864
|
+
const rgb = toRgb(dec)
|
|
865
|
+
const h = blockHash(rgb, dec.w, dec.h)
|
|
866
|
+
if (lastHash && hashDist(lastHash, h) < NODE_FRAME_DEDUP) return { seq: null, skipped: 'same' }
|
|
867
|
+
pushFrame(dec, b)
|
|
868
|
+
const ent = S.l0.roll[S.l0.roll.length - 1]
|
|
869
|
+
l0AnchorAdd({ ...ent, source }, true)
|
|
870
|
+
return { seq: ent.seq, hash: h }
|
|
871
|
+
}
|
|
872
|
+
/** 事件入环 + 推送 host(cap 1200)。
|
|
873
|
+
*
|
|
874
|
+
* 事件自带 `frame` 锚点:告诉调用方"这一刻的画面在哪能拿到"。
|
|
875
|
+
* 为什么必须有:事件流的消费者(LLM / 脚本)看到 `{"type":"stable","seq":2,"diff":0}` 时,
|
|
876
|
+
* 唯一想知道的是"那是什么画面"——旧回执只有 seq/t/diff,人得自己去 `frame get 2` 试,
|
|
877
|
+
* 而 L0 是**滚动淘汰**的,试的时候那张图可能已经没了(本仓 G9:事件无画面锚点,
|
|
878
|
+
* 只能拿 seq 去撞运气)。现在按 seq 反查 L0 两区,直接给出可取性:
|
|
879
|
+
*
|
|
880
|
+
* frame: { seq, retained: 'l0' | 'gone', source } // retained='l0' 才能 frame get / kf promote
|
|
881
|
+
*
|
|
882
|
+
* 只报事实,不代取图:取图要走 `frame get`(`--src full` 出控制器分辨率原图),
|
|
883
|
+
* 事件里塞 PNG 会让 1200 条环形缓冲变成内存炸弹。 */
|
|
884
|
+
function eventFrameAnchor(seq) {
|
|
885
|
+
if (typeof seq !== 'number') return undefined
|
|
886
|
+
const inRoll = S.l0.roll.find((x) => x.seq === seq)
|
|
887
|
+
const inAnchor = S.l0.anchor.find((x) => x.seq === seq)
|
|
888
|
+
const hit = inRoll ?? inAnchor
|
|
889
|
+
if (!hit) return { seq, retained: 'gone' }
|
|
890
|
+
return {
|
|
891
|
+
seq,
|
|
892
|
+
retained: 'l0',
|
|
893
|
+
/* 两区都可能有同一 seq(共享 Buffer):优先报锚区来源(锚区才是"值得留的"那张) */
|
|
894
|
+
source: (inAnchor ?? inRoll).source ?? null,
|
|
895
|
+
w: hit.w, h: hit.h,
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function pushEvent(ev) {
|
|
900
|
+
const anchored = { ...ev, frame: eventFrameAnchor(ev.seq) }
|
|
901
|
+
S.events.push(anchored)
|
|
902
|
+
if (S.events.length > 1200) S.events.splice(0, S.events.length - 1200)
|
|
903
|
+
send({ kind: 'event', ev: anchored })
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function streamTick() {
|
|
907
|
+
/* 控制器没了(典型:connect 会先销毁旧 Controller 再重建)时**必须把流的开关也关掉**:
|
|
908
|
+
* 只 return 不排期会让循环静默死掉,而 S.stream 仍是 true → 之后的 stream_start 被"已启动"挡回去,
|
|
909
|
+
* 表现为"重连后一帧都不来"(实测:seq 卡死、screencap 却正常)。 */
|
|
910
|
+
if (!S.ctrl) {
|
|
911
|
+
S.stream = false
|
|
912
|
+
if (S.streamTimer) { clearTimeout(S.streamTimer); S.streamTimer = null }
|
|
913
|
+
send({ kind: 'stream_stopped', reason: '控制器已销毁(connect 会重建控制器,需要时重新 stream_start)' })
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
if (!S.stream) return
|
|
917
|
+
const t0 = Date.now()
|
|
918
|
+
try {
|
|
919
|
+
const j = S.ctrl.post_screencap()
|
|
920
|
+
await j.wait()
|
|
921
|
+
const buf = j.get()
|
|
922
|
+
if (buf && buf.byteLength > 8) pushFrame(pngDecode(Buffer.from(buf)), Buffer.from(buf))
|
|
923
|
+
} catch (e) {
|
|
924
|
+
send({ kind: 'stream_error', error: String(e && e.message || e) })
|
|
925
|
+
}
|
|
926
|
+
if (!S.stream) return
|
|
927
|
+
const elapsed = Date.now() - t0
|
|
928
|
+
const recent = S.ring.slice(-3)
|
|
929
|
+
const quiet = S.quietNow || (recent.length >= 2 && recent.every((r) => r.diff <= S.changeGlobal))
|
|
930
|
+
const wait = quiet ? 1000 : Math.max(60, Math.round(1000 / Math.max(1, S.streamFps)))
|
|
931
|
+
S.streamTimer = setTimeout(streamTick, Math.max(0, wait - elapsed))
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function cmdStreamStart(args) {
|
|
935
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
|
|
936
|
+
if (args.fps) S.streamFps = Math.min(30, Math.max(1, Number(args.fps)))
|
|
937
|
+
if (args.scale) S.streamScale = Math.min(1280, Math.max(160, Number(args.scale)))
|
|
938
|
+
if (args.maxFrames) S.maxFrames = Math.min(600, Math.max(20, Number(args.maxFrames)))
|
|
939
|
+
if (args.l0Roll) S.l0.rollCap = Math.min(64, Math.max(1, Number(args.l0Roll)))
|
|
940
|
+
if (args.l0Anchor) S.l0.anchorCap = Math.min(128, Math.max(1, Number(args.l0Anchor)))
|
|
941
|
+
if (args.l0Bytes !== undefined) S.l0.bytesCap = Math.max(0, Number(args.l0Bytes))
|
|
942
|
+
if (args.blockThresh !== undefined) S.blockThresh = Math.min(64, Math.max(1, Number(args.blockThresh)))
|
|
943
|
+
if (args.changeGlobal !== undefined) S.changeGlobal = Math.min(1, Math.max(0.005, Number(args.changeGlobal)))
|
|
944
|
+
if (!S.stream) {
|
|
945
|
+
S.stream = true
|
|
946
|
+
S.quietNow = false
|
|
947
|
+
S.streamTimer = setTimeout(streamTick, 0)
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
ok: true, fps: S.streamFps, scale: S.streamScale, maxFrames: S.maxFrames,
|
|
951
|
+
l0Roll: S.l0.rollCap, l0Anchor: S.l0.anchorCap,
|
|
952
|
+
blockThresh: S.blockThresh, changeGlobal: S.changeGlobal,
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async function cmdStreamStop() {
|
|
956
|
+
S.stream = false
|
|
957
|
+
if (S.streamTimer) { clearTimeout(S.streamTimer); S.streamTimer = null }
|
|
958
|
+
return { ok: true }
|
|
959
|
+
}
|
|
960
|
+
function cmdStreamStatus() {
|
|
961
|
+
return {
|
|
962
|
+
ok: true, running: S.stream, fps: S.streamFps, scale: S.streamScale,
|
|
963
|
+
maxFrames: S.maxFrames, seq: S.seq, ring: S.ring.length,
|
|
964
|
+
events: S.events.length, full: S.fullW ? [S.fullW, S.fullH] : null,
|
|
965
|
+
session: S.session,
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* 事件环查询(顶层 CLI 用)。
|
|
971
|
+
*
|
|
972
|
+
* 为什么必须做成 daemon 命令:事件环 `S.events` 活在 daemon 进程里,而一次性 CLI 命令
|
|
973
|
+
* **退出即断连**——它拿不到"过去发生过什么",只能自己开着会话边跑边收。于是本仓 G7 的
|
|
974
|
+
* 诉求(`events` 不该只在 repl 里)落不到"客户端本地环"上,只能让 daemon 按需吐出它自己那份。
|
|
975
|
+
*
|
|
976
|
+
* 与 REPL 的 `events` 是**同一份数据**(同一个 `S.events`),只是取用方式不同:
|
|
977
|
+
* REPL 边收推送边留本地副本,CLI 每次现查。两处渲染共用 `frame` 锚点字段。
|
|
978
|
+
*
|
|
979
|
+
* `since` 支持按 seq 增量拉取(长跑时反复查询不必重传全量);`limit` 从尾部截取。
|
|
980
|
+
*/
|
|
981
|
+
function cmdEventsList(args) {
|
|
982
|
+
const rawSince = Number(args.since)
|
|
983
|
+
const since = Number.isFinite(rawSince) ? rawSince : null
|
|
984
|
+
const rawLimit = Number(args.limit)
|
|
985
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : 20
|
|
986
|
+
const all = since === null ? S.events : S.events.filter((e) => typeof e.seq === 'number' && e.seq > since)
|
|
987
|
+
const picked = all.slice(-limit)
|
|
988
|
+
return {
|
|
989
|
+
ok: true,
|
|
990
|
+
/* 报总数与"环里还留着多少",让调用方知道有没有被 1200 上限截掉过 */
|
|
991
|
+
total: S.events.length,
|
|
992
|
+
matched: all.length,
|
|
993
|
+
cap: 1200,
|
|
994
|
+
truncated: all.length > picked.length,
|
|
995
|
+
events: picked,
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/* ────────────────────────── 历史帧取用(ROI 裁剪,像素按需出 worker) ────────────────────────── */
|
|
1000
|
+
/**
|
|
1001
|
+
* roi 参数校验:只接受 4 个**有限数字**。
|
|
1002
|
+
* 非数字会一路传成 NaN 框——`Math.max(0, Math.floor(NaN))` 还是 NaN,于是要么静默错剪一块、
|
|
1003
|
+
* 要么在下游报一个跟病因无关的错("越界"/"参数类型")。与 tpl_crop 的 `roi/point/pad` 校验同一口径。
|
|
1004
|
+
* 返回 `{ roi: null }` 表示"没给 roi"(调用方按整帧处理)。
|
|
1005
|
+
*/
|
|
1006
|
+
function readRoi(raw) {
|
|
1007
|
+
if (raw === undefined || raw === null) return { roi: null }
|
|
1008
|
+
if (!Array.isArray(raw) || raw.length !== 4) {
|
|
1009
|
+
return { error: 'roi 必须是 4 个数字 [x,y,w,h],收到 ' + JSON.stringify(raw) }
|
|
1010
|
+
}
|
|
1011
|
+
const roi = raw.map(Number)
|
|
1012
|
+
if (!roi.every((v) => Number.isFinite(v))) {
|
|
1013
|
+
/* 只拦 NaN(非数字字符串、对象等)。**`null` 会被 Number(null) 收成 0**,那是"合法的退化框",
|
|
1014
|
+
* 由下游夹取兜住——与 tpl_crop 的 roi/point/pad 校验同一口径(拦 NaN,不拦 0)。 */
|
|
1015
|
+
return { error: 'roi 必须是数字(非数字字符串这类会退化成 NaN):' + JSON.stringify(raw) }
|
|
1016
|
+
}
|
|
1017
|
+
return { roi }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** L0(滚动区 + 锚区)里按 seq 找全分辨率原图;命中即拿到控制器分辨率的 1:1 像素。
|
|
1021
|
+
* 两条区共用同一份 Buffer 时按 seq 去重,所以先滚后锚即可。 */
|
|
1022
|
+
function findL0(seq) {
|
|
1023
|
+
return S.l0.roll.find((e) => e.seq === seq) || S.l0.anchor.find((e) => e.seq === seq) || null
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function cmdFrameGet(args) {
|
|
1027
|
+
let fr = null
|
|
1028
|
+
if (args.seq === undefined || args.seq === null) fr = S.ring[S.ring.length - 1]
|
|
1029
|
+
else fr = S.ring.find((r) => r.seq === Number(args.seq))
|
|
1030
|
+
if (!fr) return { ok: false, error: '帧不在缓冲中(seq=' + args.seq + ',范围 ' + (S.ring[0] ? S.ring[0].seq : 0) + '..' + S.seq + ')' }
|
|
1031
|
+
const outFile = args.out || defaultOutFile(S.runDir, 'frame-' + fr.seq).file
|
|
1032
|
+
const roiIn = readRoi(args.roi)
|
|
1033
|
+
if (roiIn.error) return { ok: false, error: roiIn.error }
|
|
1034
|
+
|
|
1035
|
+
/* `src` 决定像素从哪来——**降采样必须是显式选择,不能是静默降级**:
|
|
1036
|
+
* auto(缺省) ROI 先试 L0 全分辨率原图,淘汰了才退环形缓冲并如实标注;
|
|
1037
|
+
* full 只要全分辨率;L0 已淘汰就报错,绝不给一张缩小图冒充;
|
|
1038
|
+
* ring 强制走环形缓冲小图(旧行为,供面板等已知消费方)。
|
|
1039
|
+
* 回执一律带 source / sourceResolution:调用方不必猜手里这张图是什么分辨率。 */
|
|
1040
|
+
const src = String(args.src ?? 'auto').toLowerCase()
|
|
1041
|
+
const wantRing = src === 'ring' || src === 'l1'
|
|
1042
|
+
const wantFull = src === 'full' || src === 'l0'
|
|
1043
|
+
if (!wantRing && !wantFull && src !== 'auto') {
|
|
1044
|
+
return { ok: false, error: 'src 只支持 auto | full | ring,收到 ' + JSON.stringify(args.src) }
|
|
1045
|
+
}
|
|
1046
|
+
const l0hit = wantRing ? null : findL0(fr.seq)
|
|
1047
|
+
let full = null
|
|
1048
|
+
if (l0hit) {
|
|
1049
|
+
try {
|
|
1050
|
+
const dec = pngDecode(l0hit.png)
|
|
1051
|
+
full = { dec, w: dec.w, h: dec.h }
|
|
1052
|
+
} catch (e) { full = null }
|
|
1053
|
+
}
|
|
1054
|
+
const l0Gone = () => 'L0 全分辨率原图已淘汰(seq=' + fr.seq + '):滚动区 ' + S.l0.roll.length
|
|
1055
|
+
+ ' 帧 / 锚区 ' + S.l0.anchor.length + ' 帧,实测 10fps 下滚动窗口约 1.6 秒。'
|
|
1056
|
+
+ '要全分辨率就在截图后立刻取 ROI,或先 kf promote 留存该帧再从库帧裁。'
|
|
1057
|
+
|
|
1058
|
+
if (roiIn.roi) {
|
|
1059
|
+
const [x, y, w, h] = roiIn.roi
|
|
1060
|
+
if (full) {
|
|
1061
|
+
/* 控制器分辨率 1:1 裁剪:roi 本来就是控制器空间坐标,无需换算。 */
|
|
1062
|
+
const rx = Math.max(0, Math.floor(x))
|
|
1063
|
+
const ry = Math.max(0, Math.floor(y))
|
|
1064
|
+
const rw = Math.min(full.w - rx, Math.max(1, Math.round(w)))
|
|
1065
|
+
const rh = Math.min(full.h - ry, Math.max(1, Math.round(h)))
|
|
1066
|
+
if (rx >= full.w || ry >= full.h || rw <= 0 || rh <= 0) {
|
|
1067
|
+
return { ok: false, error: 'ROI 完全越界(' + JSON.stringify(args.roi) + ' vs 捕获时 ' + full.w + 'x' + full.h + ')' }
|
|
1068
|
+
}
|
|
1069
|
+
const src0 = toRgb(full.dec)
|
|
1070
|
+
const crop = Buffer.alloc(rw * rh * 3)
|
|
1071
|
+
for (let yy = 0; yy < rh; yy++) {
|
|
1072
|
+
src0.copy(crop, yy * rw * 3, ((ry + yy) * full.w + rx) * 3, ((ry + yy) * full.w + rx + rw) * 3)
|
|
1073
|
+
}
|
|
1074
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
|
1075
|
+
fs.writeFileSync(outFile, pngEncodeRGB(crop, rw, rh))
|
|
1076
|
+
return {
|
|
1077
|
+
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: rw, h: rh,
|
|
1078
|
+
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
|
|
1079
|
+
/* 帧身份:观测面 blockHash 的 64bit 串——input 的 expectFrame 拿它核对
|
|
1080
|
+
* "现在这帧还是不是我当初看到的那帧"。同 seq 的两帧 hash 必同(同一份像素)。 */
|
|
1081
|
+
frameHash: hashStr(fr.hash),
|
|
1082
|
+
source: 'l0-full', sourceResolution: 1, degraded: false,
|
|
1083
|
+
requested: { w, h },
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (wantFull) return { ok: false, error: l0Gone() }
|
|
1087
|
+
/* 降级:环形缓冲小图。按**该帧捕获时**尺寸换算(重连/改分辨率后旧帧会被错剪)。 */
|
|
1088
|
+
const sx = fr.fw > 0 ? fr.w / fr.fw : 1
|
|
1089
|
+
const sy = fr.fh > 0 ? fr.h / fr.fh : 1
|
|
1090
|
+
const rx = Math.max(0, Math.floor(x * sx))
|
|
1091
|
+
const ry = Math.max(0, Math.floor(y * sy))
|
|
1092
|
+
const rw = Math.min(fr.w - rx, Math.max(1, Math.round(w * sx)))
|
|
1093
|
+
const rh = Math.min(fr.h - ry, Math.max(1, Math.round(h * sy)))
|
|
1094
|
+
if (rx >= fr.w || ry >= fr.h || rw <= 0 || rh <= 0) {
|
|
1095
|
+
return { ok: false, error: 'ROI 完全越界(' + JSON.stringify(args.roi) + ' vs 捕获时 ' + fr.fw + 'x' + fr.fh + ')' }
|
|
1096
|
+
}
|
|
1097
|
+
const crop = Buffer.alloc(rw * rh * 3)
|
|
1098
|
+
for (let yy = 0; yy < rh; yy++) {
|
|
1099
|
+
fr.rgb.copy(crop, yy * rw * 3, ((ry + yy) * fr.w + rx) * 3, ((ry + yy) * fr.w + rx + rw) * 3)
|
|
1100
|
+
}
|
|
1101
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
|
1102
|
+
fs.writeFileSync(outFile, pngEncodeRGB(crop, rw, rh))
|
|
1103
|
+
return {
|
|
1104
|
+
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: rw, h: rh,
|
|
1105
|
+
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: fr.fw, ctrlH: fr.fh,
|
|
1106
|
+
source: 'l1-ring', sourceResolution: sx,
|
|
1107
|
+
requested: { w, h },
|
|
1108
|
+
/* `degraded` 是**相对"调用方要什么"**的判断,不是"分辨率是否小于 1":
|
|
1109
|
+
* 带 roi 且 auto 时,调用方要的是控制器空间的 ROI,退成小图就是真降级(附 warn、判 findings);
|
|
1110
|
+
* 显式 ring 是小图预期,不算降级。整帧路径另有约定(见下)。 */
|
|
1111
|
+
degraded: !wantRing,
|
|
1112
|
+
...(wantRing ? {} : {
|
|
1113
|
+
warn: 'L0 全分辨率原图不可用,本图由环形缓冲小图裁出,有效分辨率 ' + (1 / sx).toFixed(3) + '×'
|
|
1114
|
+
+ '(请求 ' + w + 'x' + h + ',实得 ' + rw + 'x' + rh + ')。' + l0Gone(),
|
|
1115
|
+
}),
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/* 整帧导出:**只有显式 `full` 才导 L0 原图**。
|
|
1120
|
+
* 为什么不给 auto 也导原图:整帧 `auto` 的既有契约就是"环形缓冲那一帧",把它悄悄换成
|
|
1121
|
+
* 1280×720 既改了这个最常见调用的产物尺寸(消费方按小图写的代码会突然拿到大图),
|
|
1122
|
+
* 也与协议文档的迁移承诺冲突——本次行为变更**只限带 `roi` 的缺省路径**。
|
|
1123
|
+
* 要整帧原图就显式 `--src full`(L0 淘汰则报错,不冒充)。 */
|
|
1124
|
+
if (full && wantFull) {
|
|
1125
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
|
1126
|
+
fs.writeFileSync(outFile, pngEncodeRGB(toRgb(full.dec), full.w, full.h))
|
|
1127
|
+
return {
|
|
1128
|
+
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: full.w, h: full.h,
|
|
1129
|
+
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
|
|
1130
|
+
frameHash: hashStr(fr.hash),
|
|
1131
|
+
source: 'l0-full', sourceResolution: 1,
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (wantFull) return { ok: false, error: l0Gone() }
|
|
1135
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
|
1136
|
+
fs.writeFileSync(outFile, pngEncodeRGB(fr.rgb, fr.w, fr.h))
|
|
1137
|
+
const sx0 = fr.fw > 0 ? fr.w / fr.fw : 1
|
|
1138
|
+
return {
|
|
1139
|
+
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: fr.w, h: fr.h,
|
|
1140
|
+
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: fr.fw, ctrlH: fr.fh,
|
|
1141
|
+
frameHash: hashStr(fr.hash),
|
|
1142
|
+
source: 'l1-ring', sourceResolution: sx0,
|
|
1143
|
+
/* 整帧小图是**文档写明的缺省行为**,不是降级:只标注来源与分辨率,不警告、不判 findings——
|
|
1144
|
+
* 否则最普通的 `frame get`(不带 roi)会时不时退 3。要原图就显式 `--src full`。 */
|
|
1145
|
+
degraded: false,
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/* ────────────────────────── 状态谓词:等到条件成立,而不是让调用方睡死 ──────────────────────────
|
|
1150
|
+
* 为什么要有它:没有"什么时候可以读了"的可查询谓词,调用方只能用时钟猜。本案实测代价:
|
|
1151
|
+
* 硬睡 39 次共 155 秒,而且**等了也不保证对**——抓到过渡帧并据此误判"还在大厅"。
|
|
1152
|
+
* 判据复用帧流同一套(最近 quietMs 内有无 change 事件 + 可选 ROI 内逐帧均值差),不另立阈值口径。 */
|
|
1153
|
+
|
|
1154
|
+
/** ROI(控制器空间)内两帧的平均亮度差;坐标按该帧捕获时尺寸换算。 */
|
|
1155
|
+
function roiMeanDiff(a, b, roi) {
|
|
1156
|
+
if (!a || !b || a.w !== b.w || a.h !== b.h) return null
|
|
1157
|
+
const sx = a.fw > 0 ? a.w / a.fw : 1
|
|
1158
|
+
const sy = a.fh > 0 ? a.h / a.fh : 1
|
|
1159
|
+
const x0 = Math.max(0, Math.floor(roi[0] * sx))
|
|
1160
|
+
const y0 = Math.max(0, Math.floor(roi[1] * sy))
|
|
1161
|
+
const x1 = Math.min(a.w, Math.ceil((roi[0] + roi[2]) * sx))
|
|
1162
|
+
const y1 = Math.min(a.h, Math.ceil((roi[1] + roi[3]) * sy))
|
|
1163
|
+
if (x1 <= x0 || y1 <= y0) return null
|
|
1164
|
+
let sum = 0, n = 0
|
|
1165
|
+
for (let y = y0; y < y1; y++) {
|
|
1166
|
+
for (let x = x0; x < x1; x++) {
|
|
1167
|
+
const i = (y * a.w + x) * 3
|
|
1168
|
+
sum += Math.abs(a.rgb[i] - b.rgb[i]) + Math.abs(a.rgb[i + 1] - b.rgb[i + 1]) + Math.abs(a.rgb[i + 2] - b.rgb[i + 2])
|
|
1169
|
+
n += 3
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return n > 0 ? sum / n : null
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function cmdWait(args) {
|
|
1176
|
+
const mode = String(args.mode ?? 'stable') === 'change' ? 'change' : 'stable'
|
|
1177
|
+
const timeout = Math.min(120000, Math.max(200, Number(args.timeout ?? 10000)))
|
|
1178
|
+
const quietMs = Math.min(10000, Math.max(100, Number(args.quiet ?? 600)))
|
|
1179
|
+
const thr = Number(args.threshold ?? 3)
|
|
1180
|
+
const roiIn = readRoi(args.roi)
|
|
1181
|
+
if (roiIn.error) return { ok: false, error: roiIn.error }
|
|
1182
|
+
/* 一帧都没有就别空转到超时再报"画面持续变化"——那是**假原因**,会让人去调阈值。
|
|
1183
|
+
* 与 frame_get 同一口径:先要观测。 */
|
|
1184
|
+
if (!S.ring.length) {
|
|
1185
|
+
return {
|
|
1186
|
+
ok: false,
|
|
1187
|
+
error: '缓冲无任何帧:wait 判的是帧流(变化事件 / ROI 逐帧均值差),没有帧就无从判断稳定与否。先 screencap 或 stream start 产生观测。',
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const t0 = Date.now()
|
|
1191
|
+
let lastPairSeq = null
|
|
1192
|
+
let lastDiff = null
|
|
1193
|
+
while (Date.now() - t0 < timeout) {
|
|
1194
|
+
const n = S.ring.length
|
|
1195
|
+
const cur = n ? S.ring[n - 1] : null
|
|
1196
|
+
const prev = n > 1 ? S.ring[n - 2] : null
|
|
1197
|
+
if (!cur) { await sleep(80); continue }
|
|
1198
|
+
if (roiIn.roi && prev && prev.seq !== lastPairSeq) {
|
|
1199
|
+
lastPairSeq = prev.seq
|
|
1200
|
+
const d = roiMeanDiff(prev, cur, roiIn.roi)
|
|
1201
|
+
if (d !== null) lastDiff = d
|
|
1202
|
+
}
|
|
1203
|
+
const recentChange = S.events.some((e) => e.type === 'change' && Date.now() - e.t < quietMs)
|
|
1204
|
+
const roiMoving = roiIn.roi ? (lastDiff !== null && lastDiff >= thr) : false
|
|
1205
|
+
const moving = recentChange || roiMoving
|
|
1206
|
+
if (mode === 'stable' ? !moving : moving) {
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true, mode, satisfied: true, waitedMs: Date.now() - t0, seq: cur.seq, quietMs,
|
|
1209
|
+
...(roiIn.roi ? { roi: roiIn.roi, threshold: thr, lastRoiMeanDiff: lastDiff } : {}),
|
|
1210
|
+
...(mode === 'change' ? { note: '画面已动(change 事件或 ROI 均值差超阈)——注意这只说明"动了",不说明"到了目标界面";到了哪一屏要用识别或留存帧复核' } : {}),
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
await sleep(100)
|
|
1214
|
+
}
|
|
1215
|
+
const cur = S.ring.length ? S.ring[S.ring.length - 1] : null
|
|
1216
|
+
return {
|
|
1217
|
+
ok: true, mode, satisfied: false, waitedMs: Date.now() - t0, timeout, seq: cur ? cur.seq : null,
|
|
1218
|
+
...(roiIn.roi ? { roi: roiIn.roi, threshold: thr, lastRoiMeanDiff: lastDiff } : {}),
|
|
1219
|
+
warn: mode === 'stable'
|
|
1220
|
+
? '超时仍未稳定(' + timeout + 'ms 内持续变化):目标界面可能本来就在动(动画/加载/字幕),或阈值偏严——先 calibrate 定噪声地板再起流。'
|
|
1221
|
+
: '超时仍未见变化:画面在这段时间内没动过。先确认目标是否已经在该界面,或动作是否真的生效(比对动作回执的 retention.before/after)。',
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** 两帧在若干 ROI 上的差分:回答"这一步究竟改了哪几格 / 哪几块"。
|
|
1226
|
+
* 帧流事件给的是"整帧哪里变了",而离散网格(棋盘 / 背包 / 技能格)要的是"每格变了多少"——
|
|
1227
|
+
* 本案要用它判断"一次拖动选中了哪几格",当时只能自己写脚本逐格比对。 */
|
|
1228
|
+
function cmdFrameDiff(args) {
|
|
1229
|
+
const a = S.ring.find((r) => r.seq === Number(args.a))
|
|
1230
|
+
const b = S.ring.find((r) => r.seq === Number(args.b))
|
|
1231
|
+
if (!a || !b) {
|
|
1232
|
+
return { ok: false, error: '帧不在缓冲中(a=' + args.a + ' b=' + args.b + ',范围 ' + (S.ring[0] ? S.ring[0].seq : 0) + '..' + S.seq + ')' }
|
|
1233
|
+
}
|
|
1234
|
+
if (a.w !== b.w || a.h !== b.h) {
|
|
1235
|
+
return { ok: false, error: '两帧尺寸不同(' + a.w + 'x' + a.h + ' vs ' + b.w + 'x' + b.h + '):不能直接比' }
|
|
1236
|
+
}
|
|
1237
|
+
const rois = Array.isArray(args.rois) ? args.rois : []
|
|
1238
|
+
if (!rois.length) return { ok: false, error: 'rois 要给数组:[[x,y,w,h],...](控制器空间坐标)' }
|
|
1239
|
+
const sx = a.fw > 0 ? a.w / a.fw : 1
|
|
1240
|
+
const sy = a.fh > 0 ? a.h / a.fh : 1
|
|
1241
|
+
const out = []
|
|
1242
|
+
for (const r of rois) {
|
|
1243
|
+
const roi = (Array.isArray(r) ? r : [r.x, r.y, r.w, r.h]).map(Number)
|
|
1244
|
+
if (roi.length !== 4 || !roi.every(Number.isFinite)) { out.push({ roi: r, error: 'roi 必须是 4 个数字' }); continue }
|
|
1245
|
+
const x0 = Math.max(0, Math.floor(roi[0] * sx))
|
|
1246
|
+
const y0 = Math.max(0, Math.floor(roi[1] * sy))
|
|
1247
|
+
const x1 = Math.min(a.w, Math.ceil((roi[0] + roi[2]) * sx))
|
|
1248
|
+
const y1 = Math.min(a.h, Math.ceil((roi[1] + roi[3]) * sy))
|
|
1249
|
+
if (x1 <= x0 || y1 <= y0) { out.push({ roi, error: '完全越界' }); continue }
|
|
1250
|
+
let sum = 0, n = 0, mx = 0
|
|
1251
|
+
for (let y = y0; y < y1; y++) {
|
|
1252
|
+
for (let x = x0; x < x1; x++) {
|
|
1253
|
+
const i = (y * a.w + x) * 3
|
|
1254
|
+
const d = (Math.abs(a.rgb[i] - b.rgb[i]) + Math.abs(a.rgb[i + 1] - b.rgb[i + 1]) + Math.abs(a.rgb[i + 2] - b.rgb[i + 2])) / 3
|
|
1255
|
+
sum += d; n++
|
|
1256
|
+
if (d > mx) mx = d
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
out.push({ roi, meanDiff: n ? Math.round((sum / n) * 100) / 100 : null, maxDiff: Math.round(mx * 100) / 100, pixels: n })
|
|
1260
|
+
}
|
|
1261
|
+
const rank = out.filter((o) => typeof o.meanDiff === 'number').slice().sort((p, q) => q.meanDiff - p.meanDiff)
|
|
1262
|
+
return {
|
|
1263
|
+
ok: true, a: a.seq, b: b.seq, dtMs: Math.abs((b.t || 0) - (a.t || 0)),
|
|
1264
|
+
rois: out, ranked: rank.map((o) => o.roi), sourceResolution: sx,
|
|
1265
|
+
note: 'meanDiff 是 0..255 的平均亮度差,量级随画面内容变;判"变了/没变"的闸门请先用静止画面实测(同 calibrate 的思路)。',
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/* ────────────────────────── 资源 / 运行 / 识别 ────────────────────────── */
|
|
1270
|
+
/**
|
|
1271
|
+
* 资源加载的唯一入口:`dirs` 是整条链(`resource.path[]` + attach,按序叠加、后者覆盖前者,
|
|
1272
|
+
* 对应逐个 `post_bundle`),单包就是长度为 1 的链。
|
|
1273
|
+
*
|
|
1274
|
+
* **每次都新建 Resource**:`post_bundle` 是叠加语义,复用旧实例会把上一次运行的 bundle 残留进来
|
|
1275
|
+
* (上次加载了皮肤包这次没选,残留会让 override 关系错乱);单包若复用会话资源,长活会话里
|
|
1276
|
+
* `run --resource-dir B` 还会带着上一轮 A 的节点/模板跑。会话不该有一份"当前资源"的共享态。
|
|
1277
|
+
*/
|
|
1278
|
+
async function buildResource(dirs, hashCheck) {
|
|
1279
|
+
const m = loadMaa()
|
|
1280
|
+
const res = new m.Resource()
|
|
1281
|
+
/* hashCheck:PI v2.6 的 resource.hash 校验,时机是"只加载完 path[]"(attach_resource_path
|
|
1282
|
+
* 不能算进被校验对象)。两段在 plan 侧已分好:pathDirs 是资源自己的声明路径,
|
|
1283
|
+
* attachDirs 是控制器追加段;dirs 兜底=全链(无校验声明时调用方不传 hashCheck)。
|
|
1284
|
+
* 读出的实际值写回 hashCheck.actual——校验结果(匹配/不匹配)由调用方决定怎么报。 */
|
|
1285
|
+
const pathDirs = hashCheck ? hashCheck.pathDirs : dirs
|
|
1286
|
+
/* plan 侧 paths 做了整体去重(attach 若与 path 相同会消失),这里保持同一份:
|
|
1287
|
+
* attach 去重掉已在 pathDirs 里的项,保证加载序列与 paths 一一对应 */
|
|
1288
|
+
const attachDirs = hashCheck ? hashCheck.attachDirs.filter((d) => !pathDirs.includes(d)) : []
|
|
1289
|
+
try {
|
|
1290
|
+
for (const d of pathDirs) await res.post_bundle(d).wait()
|
|
1291
|
+
if (hashCheck) hashCheck.actual = res.hash || ''
|
|
1292
|
+
for (const d of attachDirs) await res.post_bundle(d).wait()
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
/* 加载中途失败也要把句柄收掉:否则每次失败的 run 都在 native 侧留一份 */
|
|
1295
|
+
try { res.destroy() } catch (e2) { /* ignore */ }
|
|
1296
|
+
throw e
|
|
1297
|
+
}
|
|
1298
|
+
return res
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* 换掉"最近一次 run / --act 的 Resource"并销毁上一份。
|
|
1303
|
+
*
|
|
1304
|
+
* 每次新建保证了资源隔离(单包不再叠上一轮 bundle),但 native 句柄必须有释放点——否则长活
|
|
1305
|
+
* REPL 里每 run 一次就多留一份(OCR 模型尤其重)。
|
|
1306
|
+
*
|
|
1307
|
+
* **调用前提由调用方保证**,不在这里重试:(1) 没有任务在飞(`cmdRun` 开头有 `runActive` 守卫、
|
|
1308
|
+
* `reco --act` 自己拒绝运行中的任务);(2) `tasker.resource` 已指向新对象。满足这两条才能立刻
|
|
1309
|
+
* 销毁上一份——早于 (2) 销毁会让 tasker 拿着死句柄,晚于 (1) 会踩到正在跑的任务。
|
|
1310
|
+
*/
|
|
1311
|
+
function adoptRunResource(res) {
|
|
1312
|
+
const prev = S.runRes
|
|
1313
|
+
S.runRes = res
|
|
1314
|
+
if (!prev || prev === res) return
|
|
1315
|
+
try { prev.destroy() } catch (e) { /* ignore */ }
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function ensureTasker() {
|
|
1319
|
+
if (!S.tasker) {
|
|
1320
|
+
const m = loadMaa()
|
|
1321
|
+
S.tasker = new m.Tasker()
|
|
1322
|
+
}
|
|
1323
|
+
return S.tasker
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/* ────────────────────────── agent 桥接(PI agent 子进程) ──────────────────────────
|
|
1327
|
+
* 模式照 maa-support-extension 的 setupAgent:new Client() 自动生成 identifier,
|
|
1328
|
+
* 把 identifier 追加为子进程最后一个参数(agent 侧 sys.argv[-1] → AgentServer.start_up),
|
|
1329
|
+
* bind_resource 后 connect(与子进程退出/超时竞速)。custom action/recognition 经此
|
|
1330
|
+
* 注册进绑定的 Resource——依赖 agent 的节点(M9A DisableNode 等)由此可用。
|
|
1331
|
+
*
|
|
1332
|
+
* 环境变量:调用方给的 `PI_*` 原样并入子进程 env。本层不解释取值也不补默认——
|
|
1333
|
+
* 协议允许逐项缺省、由 agent 容错;这里编一个默认值只会把"没注入"伪装成"注入了"。 */
|
|
1334
|
+
/* 收掉整棵 agent 进程树。真身在子孙层(uv run → venv-python → uv-python agent/main.py),
|
|
1335
|
+
* child.kill() 只到直接子进程(uv.exe)。顺序是硬约束:必须先杀再 disconnect——
|
|
1336
|
+
* disconnect 会让 uv.exe 自己优雅退出,它的 python 子孙瞬间脱离父链,taskkill /T
|
|
1337
|
+
* 再够不到(实测 disconnect 在先必留两个 python 孤儿)。
|
|
1338
|
+
*
|
|
1339
|
+
* 两刀:① taskkill /T /F 按父链整棵收(够用,但 uv 在 kill 途中重 exec 出的孙级
|
|
1340
|
+
* 会漏);② 用 identifier 扫命令行兜底——agent 树每层 argv 都带同一个
|
|
1341
|
+
* identifier(sys.argv[-1]),一次 -like 点名全树,与父子链无关。
|
|
1342
|
+
* 第②刀的 $PID 排除自身:否则 -like 会命中这个 powershell 自己的 CommandLine
|
|
1343
|
+
* (字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。POSIX 用
|
|
1344
|
+
* detached 进程组(spawn 时 detached:true),负 pid 杀整组。 */
|
|
1345
|
+
function killTree(child, ident) {
|
|
1346
|
+
if (!child || !child.pid) return Promise.resolve()
|
|
1347
|
+
if (process.platform === 'win32') {
|
|
1348
|
+
const root = child.pid
|
|
1349
|
+
/* taskkill /T 收父链 + identifier 兜底:agent 树每层 argv 都带同一个 identifier
|
|
1350
|
+
* (sys.argv[-1]),一次 -like 点名全树,与父子链无关——taskkill 杀 uv.exe 时它
|
|
1351
|
+
* 重 exec 出的孙级会漏,这层兜住。$PID 排除自身:-like 会命中这个 powershell 自己
|
|
1352
|
+
* 的 CommandLine(字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。 */
|
|
1353
|
+
/* 一刀 taskkill 按父链收 + 一刀 ident sweep 点名全树(agent 每层 argv 带同一个
|
|
1354
|
+
* identifier,与父子链无关——taskkill 杀 uv.exe 时它重 exec 出的中层 venv-python
|
|
1355
|
+
* 会漏,这层兜住)。$PID 排除自身:-like 会命中这个 powershell 自己的 CommandLine
|
|
1356
|
+
* (字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。两刀都 await:
|
|
1357
|
+
* fire-and-forget 会让 daemon 在 sweep 还没跑完就 exit,python 照样孤儿。 */
|
|
1358
|
+
const sweep = () => new Promise((resolve) => {
|
|
1359
|
+
const like = '%' + String(ident || '').replace(/'/g, '') + '%'
|
|
1360
|
+
const ps =
|
|
1361
|
+
'$self=' + '$PID;' +
|
|
1362
|
+
`Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $self -and $_.CommandLine -and $_.CommandLine -like '${like}' } |` +
|
|
1363
|
+
'ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} };' +
|
|
1364
|
+
'try { Stop-Process -Id ' + root + ' -Force -ErrorAction SilentlyContinue } catch {}'
|
|
1365
|
+
try { execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 30000 }, () => resolve()) } catch (e) { resolve() }
|
|
1366
|
+
})
|
|
1367
|
+
const kill = () => new Promise((resolve) => {
|
|
1368
|
+
try {
|
|
1369
|
+
execFile('taskkill', ['/pid', String(root), '/T', '/F'], { timeout: 10000 }, () => resolve())
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
try { child.kill() } catch (e2) { /* ignore */ }
|
|
1372
|
+
resolve()
|
|
1373
|
+
}
|
|
1374
|
+
})
|
|
1375
|
+
return kill().then(() => sweep())
|
|
1376
|
+
}
|
|
1377
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch (e) {
|
|
1378
|
+
try { child.kill('SIGKILL') } catch (e2) { /* ignore */ }
|
|
1379
|
+
}
|
|
1380
|
+
return Promise.resolve()
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async function cleanupAgents() {
|
|
1384
|
+
const bind = S.agentBind
|
|
1385
|
+
S.agentBind = null
|
|
1386
|
+
if (!bind) return
|
|
1387
|
+
for (const c of bind.clients) {
|
|
1388
|
+
/* 先树杀、后 disconnect:顺序反过来 disconnect 会抢先让 uv.exe 退掉,子孙脱离
|
|
1389
|
+
* 父链后 taskkill /T 够不到(实测留两个 python 孤儿)。 */
|
|
1390
|
+
try { process.stderr.write('[cleanupAgents] kill ' + (c.child && c.child.pid) + ' ident=' + c.ident + '\n') } catch (e) { /* ignore */ }
|
|
1391
|
+
await killTree(c.child, c.ident)
|
|
1392
|
+
try { c.client.disconnect() } catch (e) { /* ignore */ }
|
|
1393
|
+
try { c.client.destroy() } catch (e) { /* ignore */ }
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function setupAgents(res, decls, cwd, piEnv, sinks, maaMod, spawnFn) {
|
|
1398
|
+
const key = JSON.stringify(decls)
|
|
1399
|
+
if (S.agentBind && S.agentBind.res === res && S.agentBind.key === key) {
|
|
1400
|
+
const alive = S.agentBind.clients.every((c) => {
|
|
1401
|
+
try { return c.client.connected && c.client.alive } catch (e) { return false }
|
|
1402
|
+
})
|
|
1403
|
+
if (alive) return { ok: true, reused: true, clients: S.agentBind, sinks: S.agentBind.sinks }
|
|
1404
|
+
}
|
|
1405
|
+
await cleanupAgents()
|
|
1406
|
+
const m = maaMod ?? loadMaa()
|
|
1407
|
+
const doSpawn = spawnFn ?? spawn
|
|
1408
|
+
const clients = []
|
|
1409
|
+
/* sink 注册是桥接的另一半:只 bind_resource 时 agent 侧 context.tasker.controller /
|
|
1410
|
+
* context.tasker 发出的 reverse request 会带空 controller_id / 找不到 tasker,
|
|
1411
|
+
* 框架日志报 controller not found 后 custom action 挂起(实测:patrol 的 post_click
|
|
1412
|
+
* 静默失败,角色原地等死)。三个 sink 必须在 connect() 之前注册。 */
|
|
1413
|
+
const sinkDefs = [
|
|
1414
|
+
['resource', sinks && sinks.resource, 'register_resource_sink'],
|
|
1415
|
+
['controller', sinks && sinks.controller, 'register_controller_sink'],
|
|
1416
|
+
['tasker', sinks && sinks.tasker, 'register_tasker_sink'],
|
|
1417
|
+
]
|
|
1418
|
+
for (const d of decls) {
|
|
1419
|
+
/* interface 声明固定 identifier 时原样交给 Client(schema:socket 同名,断线可重连)。
|
|
1420
|
+
* 代价:残留旧 socket 时 connect 会等满超时才败——项目声明了就是它要的语义,照做。 */
|
|
1421
|
+
const client = new m.Client(typeof d.identifier === 'string' && d.identifier ? d.identifier : undefined)
|
|
1422
|
+
const ident = client.identifier
|
|
1423
|
+
let child = null
|
|
1424
|
+
try {
|
|
1425
|
+
const env = piEnv && typeof piEnv === 'object' ? Object.fromEntries(
|
|
1426
|
+
Object.entries(piEnv).filter(([, v]) => typeof v === 'string' && v !== ''),
|
|
1427
|
+
) : {}
|
|
1428
|
+
child = doSpawn(d.exec, [...d.args, ident], {
|
|
1429
|
+
cwd,
|
|
1430
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1431
|
+
windowsHide: true,
|
|
1432
|
+
/* POSIX 下脱离父进程组:killTree 靠负 pid 杀整组;Windows 无进程组概念,taskkill /T 兜底 */
|
|
1433
|
+
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
1434
|
+
/* 空对象时干脆不传 env(子进程照常继承),别用空 env 覆盖掉 PATH */
|
|
1435
|
+
...(Object.keys(env).length ? { env: { ...process.env, ...env } } : {}),
|
|
1436
|
+
})
|
|
1437
|
+
} catch (e) {
|
|
1438
|
+
try { client.destroy() } catch (e2) { /* ignore */ }
|
|
1439
|
+
return { ok: false, error: 'agent 子进程启动失败:' + d.exec + '(' + String(e && e.message || e) + ')' }
|
|
1440
|
+
}
|
|
1441
|
+
let out = ''
|
|
1442
|
+
const feed = (b) => { out = (out + String(b)).slice(-4000) }
|
|
1443
|
+
child.stdout?.on('data', feed)
|
|
1444
|
+
child.stderr?.on('data', feed)
|
|
1445
|
+
try { client.timeout = 60000 } catch (e) { /* 绑定不支持时用默认 */ }
|
|
1446
|
+
try { client.bind_resource(res) } catch (e) {
|
|
1447
|
+
try { child.kill() } catch (e2) { /* ignore */ }
|
|
1448
|
+
try { client.destroy() } catch (e2) { /* ignore */ }
|
|
1449
|
+
return { ok: false, error: 'agent 绑定资源失败:' + String(e && e.message || e) }
|
|
1450
|
+
}
|
|
1451
|
+
const sinksRegistered = []
|
|
1452
|
+
const sinkErrs = []
|
|
1453
|
+
for (const [name, obj, method] of sinkDefs) {
|
|
1454
|
+
if (!obj) continue
|
|
1455
|
+
try { client[method](obj); sinksRegistered.push(name) } catch (e) {
|
|
1456
|
+
sinkErrs.push(name + ':' + String(e && e.message || e))
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
client.__sinks = { registered: sinksRegistered, ...(sinkErrs.length ? { errors: sinkErrs } : {}) }
|
|
1460
|
+
/* sink 注册失败不阻断:custom action 不碰 context.controller/tasker 的项目照跑,
|
|
1461
|
+
* 但回执必须点名哪一项没注上——半残不报就是"controller not found"那种排障半天。 */
|
|
1462
|
+
const exited = new Promise((r) => child.on('exit', () => r('exit')))
|
|
1463
|
+
let timer = null
|
|
1464
|
+
const ok = await Promise.race([
|
|
1465
|
+
client.connect().then(() => 'ok', () => 'err'),
|
|
1466
|
+
exited,
|
|
1467
|
+
new Promise((r) => { timer = setTimeout(() => r('timeout'), 60000) }),
|
|
1468
|
+
])
|
|
1469
|
+
/* connect 或子进程退出先赢时把超时就地收掉:不然这个 setTimeout 挂着 60s
|
|
1470
|
+
* 才让进程退(headless 调用方——比如单测——会被它拖住)。 */
|
|
1471
|
+
if (timer) clearTimeout(timer)
|
|
1472
|
+
/* connect() resolve 不代表可用:协议版本错配(maa-node 与 agent 侧 maa 版本不一致)时
|
|
1473
|
+
* 通道建立但握手被拒——必须复核 connected && alive(maa-support 同款检查)。 */
|
|
1474
|
+
let usable = ok === 'ok'
|
|
1475
|
+
if (usable) {
|
|
1476
|
+
try { usable = client.connected === true && client.alive === true } catch (e) { usable = false }
|
|
1477
|
+
}
|
|
1478
|
+
if (!usable) {
|
|
1479
|
+
const why = ok === 'exit' ? '子进程提前退出' : (ok === 'timeout' ? '连接超时(60s)' : '连接失败/握手被拒(版本错配?maa-node 与 agent 侧 maa 库需同版本)')
|
|
1480
|
+
try { client.disconnect() } catch (e) { /* ignore */ }
|
|
1481
|
+
try { client.destroy() } catch (e) { /* ignore */ }
|
|
1482
|
+
try { child.kill() } catch (e) { /* ignore */ }
|
|
1483
|
+
return { ok: false, error: 'agent ' + why + ':' + [d.exec, ...d.args].join(' ') + ';输出尾部:' + out.slice(-300) }
|
|
1484
|
+
}
|
|
1485
|
+
clients.push({ client, child, ident, name: [d.exec, ...d.args].join(' '), out })
|
|
1486
|
+
}
|
|
1487
|
+
/* 汇总所有 client 的 sink 结果:registered 取并集,errors 逐条带上是哪个 agent */
|
|
1488
|
+
const sinkReport = {
|
|
1489
|
+
registered: [...new Set(clients.flatMap((c) => c.client.__sinks?.registered ?? []))],
|
|
1490
|
+
errors: clients.flatMap((c) => (c.client.__sinks?.errors ?? []).map((e) => c.name + ':' + e)),
|
|
1491
|
+
}
|
|
1492
|
+
S.agentBind = { res, key, clients, sinks: sinkReport }
|
|
1493
|
+
let actions = null
|
|
1494
|
+
let recos = null
|
|
1495
|
+
try { actions = clients[0].client.custom_action_list } catch (e) { /* ignore */ }
|
|
1496
|
+
try { recos = clients[0].client.custom_recognition_list } catch (e) { /* ignore */ }
|
|
1497
|
+
return { ok: true, clients: S.agentBind, actions, recos, sinks: sinkReport }
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
1501
|
+
/**
|
|
1502
|
+
* 节点名取哪个字段:框架在 `PipelineNode.*` 的**顶层 `name` 上填的是任务入口名**,
|
|
1503
|
+
* 真节点名在内嵌的 `node_details.name` / `action_details.name` / `reco_details.name` 里。
|
|
1504
|
+
* 顶层名只作创建时的兜底,来源用 `source` 如实标出(`entry` = 未确认)。
|
|
1505
|
+
* 形状来自真机抓取的原始通知(`MAAFW_NOTIFY_DUMP`),单测按同一形状钉住。
|
|
1506
|
+
*/
|
|
1507
|
+
function pickNodeName(m) {
|
|
1508
|
+
const authoritative = (m.node_details && m.node_details.name) ||
|
|
1509
|
+
(m.action_details && m.action_details.name) ||
|
|
1510
|
+
(m.reco_details && m.reco_details.name) || null
|
|
1511
|
+
if (authoritative) return { name: String(authoritative), source: 'node_details' }
|
|
1512
|
+
return { name: String(m.name || ('node#' + m.node_id)), source: 'entry' }
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** 识别半:从带 node_id 的通知里抽识别框——没有它 record.nodes 只有"哪个节点跑过",
|
|
1516
|
+
* 不知道"这一步看的是哪个元素"(示范轨迹缺口 #1)。字段全防御性取(不同版本字段会缺),
|
|
1517
|
+
* 一条都取不到就返回 null、不挂空对象。 */
|
|
1518
|
+
function pickRecoBox(m) {
|
|
1519
|
+
const d = m.reco_details
|
|
1520
|
+
if (!d || typeof d !== 'object') return null
|
|
1521
|
+
const o = {}
|
|
1522
|
+
if (d.name !== undefined) o.name = String(d.name)
|
|
1523
|
+
if (d.algorithm !== undefined) o.algorithm = String(d.algorithm)
|
|
1524
|
+
if (d.hit !== undefined) o.hit = !!d.hit
|
|
1525
|
+
if (Array.isArray(d.box) && d.box.length === 4) o.box = d.box.map(Number)
|
|
1526
|
+
/* detail.best.score 是命中质量分(TemplateMatch 有,OCR 没有)——能拿到就带,拿不到不编 */
|
|
1527
|
+
const best = d.detail && typeof d.detail === 'object' && !Array.isArray(d.detail) ? d.detail.best : null
|
|
1528
|
+
if (best && typeof best === 'object' && best.score !== undefined) o.score = Number(best.score)
|
|
1529
|
+
return Object.keys(o).length ? o : null
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** 动作半:动作的落点框(action_details.box 是实际动作框,success 是动作自身成败)。 */
|
|
1533
|
+
function pickActionBox(m) {
|
|
1534
|
+
const d = m.action_details
|
|
1535
|
+
if (!d || typeof d !== 'object') return null
|
|
1536
|
+
const o = {}
|
|
1537
|
+
if (d.name !== undefined) o.name = String(d.name)
|
|
1538
|
+
if (d.action !== undefined) o.action = String(d.action)
|
|
1539
|
+
if (d.success !== undefined) o.success = !!d.success
|
|
1540
|
+
if (Array.isArray(d.box) && d.box.length === 4) o.box = d.box.map(Number)
|
|
1541
|
+
return Object.keys(o).length ? o : null
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async function cmdRun(args) {
|
|
1545
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
|
|
1546
|
+
if (S.runActive) return { ok: false, error: '已有任务在运行(先 maa_run_stop)' }
|
|
1547
|
+
const resDirs = Array.isArray(args.resourceDirs) && args.resourceDirs.length
|
|
1548
|
+
? args.resourceDirs.map(String)
|
|
1549
|
+
: (args.resourceDir ? [String(args.resourceDir)] : [])
|
|
1550
|
+
if (!resDirs.length) return { ok: false, error: '缺少 resourceDir(或 resourceDirs 数组)' }
|
|
1551
|
+
/* 上一轮的 agent 子进程与 Resource 在这里收掉:走到这行必然没有任务在飞(runActive 守卫),
|
|
1552
|
+
* 这一轮也不存在可复用的资源对象。放在参数校验之后——调用参数错了不该把上一份资源也丢掉。 */
|
|
1553
|
+
/* 上一轮 agent 子进程先断掉:这一轮会新建 Resource,绑定不可能复用。 */
|
|
1554
|
+
await cleanupAgents()
|
|
1555
|
+
const hashCheck = args.resourceHashCheck && typeof args.resourceHashCheck === 'object' ? args.resourceHashCheck : null
|
|
1556
|
+
const res = await buildResource(resDirs, hashCheck)
|
|
1557
|
+
/* v2.6 一致性校验:不匹配只警告不阻断(协议原文"不应阻止继续使用"),结果如实进回执与 warns */
|
|
1558
|
+
const runWarns = []
|
|
1559
|
+
if (hashCheck && hashCheck.expected !== undefined) {
|
|
1560
|
+
hashCheck.match = hashCheck.actual === hashCheck.expected
|
|
1561
|
+
if (!hashCheck.match) {
|
|
1562
|
+
runWarns.push('资源完整性校验失败:' + String(hashCheck.resourceName) +
|
|
1563
|
+
' 期望 hash=' + String(hashCheck.expected) + ' 实际=' + String(hashCheck.actual || '(读不到)') +
|
|
1564
|
+
'——建议重新下载资源包;按 PI v2.6 语义不阻断本次运行')
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/* tasker 要在 setupAgents 之前就绪:agent 侧 context.tasker / context.tasker.controller
|
|
1568
|
+
* 经 sink 反查的就是这个 tasker 与 S.ctrl——先注册、后 connect,缺一个 sink 就是
|
|
1569
|
+
* "controller not found"(见 setupAgents 注释)。 */
|
|
1570
|
+
const tasker = ensureTasker()
|
|
1571
|
+
/* agent 桥接:项目声明了 agent(PI interface 的 child_exec/child_args)就先连上——
|
|
1572
|
+
* custom action 注册进本次运行的 Resource;失败不阻断(非 agent 节点照跑),结果如实报告 */
|
|
1573
|
+
let agentInfo = null
|
|
1574
|
+
if (Array.isArray(args.agents) && args.agents.length) {
|
|
1575
|
+
const ag = await setupAgents(res, args.agents, String(args.agentCwd || process.cwd()), args.agentEnv,
|
|
1576
|
+
{ resource: res, controller: S.ctrl, tasker })
|
|
1577
|
+
agentInfo = ag.ok
|
|
1578
|
+
? { ok: true, reused: !!ag.reused, actions: ag.actions ?? null, recognitions: ag.recos ?? null,
|
|
1579
|
+
...(ag.sinks ? { sinks: ag.sinks } : {}) }
|
|
1580
|
+
: { ok: false, error: ag.error }
|
|
1581
|
+
}
|
|
1582
|
+
tasker.resource = res
|
|
1583
|
+
tasker.controller = S.ctrl
|
|
1584
|
+
/* 顺序是硬约束:先让 tasker 指向新对象,再销毁上一份。反过来(或加载失败前就销毁)会让还握着
|
|
1585
|
+
* 旧句柄的 tasker 拿到已销毁的资源。buildResource 抛错时上一份原样留着。 */
|
|
1586
|
+
adoptRunResource(res)
|
|
1587
|
+
/* timeoutMs=0:不自动 post_stop,停止权交调用方(LLM 看事件流自行判断 + run_stop)。
|
|
1588
|
+
* >0 时沿用超时保护(防 JumpBack 死循环)。300s 硬上限已移除——长任务用 0。 */
|
|
1589
|
+
const rawT = Number(args.timeoutMs)
|
|
1590
|
+
const timeoutMs = rawT === 0 ? 0 : (Number.isFinite(rawT) && rawT > 0 ? Math.max(500, rawT) : 30000)
|
|
1591
|
+
const t0 = Date.now()
|
|
1592
|
+
const retention = { start: await captureNow('run-start') }
|
|
1593
|
+
const nodes = new Map() // node_id → {name, start, end, msg, success, seq, reco?, action?, frameSeq?}
|
|
1594
|
+
const order = []
|
|
1595
|
+
let lastNodeHash = null // 按节点留帧的去重基准:上一张已留节点帧的 blockHash(新屏才留)
|
|
1596
|
+
const nextCandidates = [] // 框架本轮宣布过的候选(NextList 通知),按顺序
|
|
1597
|
+
let nextCandidateCount = 0 // 全部公告次数(含超出上限未记入数组的)
|
|
1598
|
+
const onMsg = (m) => {
|
|
1599
|
+
const t = Date.now()
|
|
1600
|
+
const seq = S.seq
|
|
1601
|
+
if (!m || !m.msg) return
|
|
1602
|
+
/* 原始通知转储(默认关,MAAFW_NOTIFY_DUMP=<file> 打开):节点记录是"两条流按到达顺序归并"的产物,
|
|
1603
|
+
* 归属存疑时(如 record.nodes[].name 重复为入口名)只有原始消息能回答"框架到底发了什么"。 */
|
|
1604
|
+
if (process.env.MAAFW_NOTIFY_DUMP) {
|
|
1605
|
+
try { fs.appendFileSync(process.env.MAAFW_NOTIFY_DUMP, JSON.stringify(m) + '\n') } catch (e) { /* 诊断失败不影响运行 */ }
|
|
1606
|
+
}
|
|
1607
|
+
if (m.node_id !== undefined && m.node_id !== null) {
|
|
1608
|
+
/* 节点名的权威来源:框架在 `PipelineNode.*` 的**顶层 name 上填的是任务入口名**
|
|
1609
|
+
* (实测:`ProbeA --next--> ProbeB` 两条记录顶层都叫 ProbeA),而内嵌
|
|
1610
|
+
* `node_details.name` / `action_details.name` / `reco_details.name` 与同轮的
|
|
1611
|
+
* `Recognition.*` / `Action.*` 通知里是**真节点名**。
|
|
1612
|
+
* 所以:创建时先用顶层名兜底(`PipelineNode.Starting` 只有顶层名),拿到权威名就改写,
|
|
1613
|
+
* 并把来源记进 `nameSource`——消费方据此判断这个名字能不能当"哪个节点跑过"的依据。 */
|
|
1614
|
+
const named = pickNodeName(m)
|
|
1615
|
+
let n = nodes.get(String(m.node_id))
|
|
1616
|
+
if (!n) {
|
|
1617
|
+
n = {
|
|
1618
|
+
id: String(m.node_id),
|
|
1619
|
+
name: named.name,
|
|
1620
|
+
nameSource: named.source,
|
|
1621
|
+
start: t, end: null, status: 'running', msgs: [], seq,
|
|
1622
|
+
}
|
|
1623
|
+
nodes.set(String(m.node_id), n)
|
|
1624
|
+
order.push(n)
|
|
1625
|
+
} else if (named.source === 'node_details') {
|
|
1626
|
+
/* 权威名到了就采纳(哪怕与顶层名相同)——来源要如实升格,否则 nameSource 会一直挂着 entry */
|
|
1627
|
+
n.name = named.name
|
|
1628
|
+
n.nameSource = 'node_details'
|
|
1629
|
+
}
|
|
1630
|
+
/* 识别框/动作框:同一条通知里若带 details 就并入记录——后者覆盖前者,留下的是
|
|
1631
|
+
* 节点结束前最后一份(通常是 Succeeded 那次的判定结果)。 */
|
|
1632
|
+
const rec = pickRecoBox(m)
|
|
1633
|
+
if (rec) n.reco = rec
|
|
1634
|
+
const act = pickActionBox(m)
|
|
1635
|
+
if (act) n.action = act
|
|
1636
|
+
/* node-ok/fail 锚点:读控制器缓存图(不发新截图任务,规避并发崩溃,见 anchorFromCacheDedup);
|
|
1637
|
+
* 按节点留帧带去重——与上一张已留帧相同就跳过(画面没变重复留只是浪费锚区与字节),
|
|
1638
|
+
* 并把留下的 seq 记进节点(record.nodes[].frameSeq),消费方知道"这一节点留的是哪帧"。 */
|
|
1639
|
+
if (/Starting$/.test(m.msg)) { n.status = 'running'; n.start = t }
|
|
1640
|
+
else if (/Succeeded$/.test(m.msg)) {
|
|
1641
|
+
n.status = 'ok'; n.end = t
|
|
1642
|
+
const r = anchorFromCacheDedup('node-ok', lastNodeHash)
|
|
1643
|
+
if (r.seq !== null) { n.frameSeq = r.seq; lastNodeHash = r.hash }
|
|
1644
|
+
}
|
|
1645
|
+
else if (/Failed$/.test(m.msg)) {
|
|
1646
|
+
n.status = 'fail'; n.end = t
|
|
1647
|
+
const r = anchorFromCacheDedup('node-fail', lastNodeHash)
|
|
1648
|
+
if (r.seq !== null) { n.frameSeq = r.seq; lastNodeHash = r.hash }
|
|
1649
|
+
}
|
|
1650
|
+
else if (n.msgs.length < 6) n.msgs.push(m.msg)
|
|
1651
|
+
n.seq = seq
|
|
1652
|
+
/* 节点级消息进事件流:maa_frame_poll / maa_timing_measure 直接消费 */
|
|
1653
|
+
pushEvent({ type: 'run', seq, t, diff: 0, node: n.name, msg: m.msg, id: m.node_id ?? null })
|
|
1654
|
+
} else {
|
|
1655
|
+
const ev = { type: 'run', seq, t, diff: 0, node: m.name ?? null, msg: m.msg, id: m.reco_id ?? m.action_id ?? null }
|
|
1656
|
+
pushEvent(ev)
|
|
1657
|
+
if (order.length) order[order.length - 1].msgs.push(ev.msg + (ev.id ? '#' + ev.id : ''))
|
|
1658
|
+
/* NextList.* 通知带**候选数组** `list: [{name, jump_back, anchor}]`(实测:每次公告一个候选,
|
|
1659
|
+
* Starting 与 Succeeded 各一次)。只取 Starting,免得同一候选记两遍;记在**任务级**按顺序,
|
|
1660
|
+
* 不做"挂给哪个节点"的归属猜测——公告的到达时机在新节点记录创建之后,挂给最近记录会把
|
|
1661
|
+
* "上一个节点的出口"算到下个节点头上。任务级序列 + 权威节点名,足以还原"轮询了哪些出口、最后走了哪个"。
|
|
1662
|
+
* 上限 50 条(JumpBack 长列表会刷很多次),超出只累加计数,避免记录被候选公告撑爆。 */
|
|
1663
|
+
if (!/Succeeded$/.test(m.msg) && Array.isArray(m.list)) {
|
|
1664
|
+
for (const c of m.list) {
|
|
1665
|
+
if (!c || !c.name) continue
|
|
1666
|
+
nextCandidateCount++
|
|
1667
|
+
const name = String(c.name)
|
|
1668
|
+
const last = nextCandidates[nextCandidates.length - 1]
|
|
1669
|
+
/* 同一候选会被反复轮询(例如目标页正在切换动画,识别还没命中)——连续重复折叠成 polls 计数,
|
|
1670
|
+
* 序列读起来才是"轮询过哪些出口",而不是同一行刷屏。 */
|
|
1671
|
+
if (last && last.name === name && !c.jump_back && !last.jumpBack && !c.anchor && !last.anchor) {
|
|
1672
|
+
last.polls++
|
|
1673
|
+
last.seq = seq
|
|
1674
|
+
continue
|
|
1675
|
+
}
|
|
1676
|
+
if (nextCandidates.length < 50) {
|
|
1677
|
+
nextCandidates.push({
|
|
1678
|
+
seq, t, name, polls: 1,
|
|
1679
|
+
...(c.jump_back ? { jumpBack: true } : {}),
|
|
1680
|
+
...(c.anchor ? { anchor: true } : {}),
|
|
1681
|
+
})
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
const s1 = tasker.add_sink((_, m) => onMsg(m))
|
|
1688
|
+
const s2 = tasker.add_context_sink((_, m) => onMsg(m))
|
|
1689
|
+
S.runActive = true
|
|
1690
|
+
let status = null
|
|
1691
|
+
/* stopped 要覆盖两条停止路径:超时保护自停(timeoutMs 分支)与 run_stop 他停。
|
|
1692
|
+
* 后者靠 S.runStopped 旗标——cmdRunStop 先置旗再 post_stop,job.wait 解除后
|
|
1693
|
+
* 这条旗标已经立下;没有它,"run_stop 停的"在回执里会读成"失败"。 */
|
|
1694
|
+
S.runStopped = false
|
|
1695
|
+
let stopped = false
|
|
1696
|
+
try {
|
|
1697
|
+
const job = tasker.post_task(String(args.entry), args.override || {})
|
|
1698
|
+
/* 超时保护:与 wait() 竞速。wait() 内部会泵事件循环(探针实测:节点回调在 wait 期间送达),
|
|
1699
|
+
* 因此 setTimeout 分支可在此期间触发并执行 post_stop —— JumpBack 死循环无法挂死 worker。 */
|
|
1700
|
+
let finished = false
|
|
1701
|
+
if (timeoutMs > 0) {
|
|
1702
|
+
await Promise.race([
|
|
1703
|
+
job.wait().then(() => { finished = true }),
|
|
1704
|
+
sleep(timeoutMs),
|
|
1705
|
+
])
|
|
1706
|
+
if (!finished) {
|
|
1707
|
+
try { tasker.post_stop() } catch (e) { /* ignore */ }
|
|
1708
|
+
stopped = true
|
|
1709
|
+
await Promise.race([job.wait().catch(() => null), sleep(1500)])
|
|
1710
|
+
}
|
|
1711
|
+
} else {
|
|
1712
|
+
await job.wait()
|
|
1713
|
+
}
|
|
1714
|
+
/* run_stop 走并发命令置的旗标:wait 正常解除但其实是"被要求停的" */
|
|
1715
|
+
stopped = stopped || S.runStopped === true
|
|
1716
|
+
status = job.status
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
status = -1
|
|
1719
|
+
order.push({ id: '-', name: 'exception', start: t0, end: Date.now(), status: 'fail', msgs: [String(e && e.message || e)], seq: S.seq })
|
|
1720
|
+
} finally {
|
|
1721
|
+
try { tasker.remove_sink(s1); tasker.remove_context_sink(s2) } catch (e) { /* ignore */ }
|
|
1722
|
+
S.runActive = false
|
|
1723
|
+
}
|
|
1724
|
+
let dump = null
|
|
1725
|
+
try { dump = S.ctrl.get_node_data_parsed('node') } catch (e) { /* ignore */ }
|
|
1726
|
+
retention.end = await captureNow('run-end')
|
|
1727
|
+
const rec = {
|
|
1728
|
+
ok: status === STATUS.succeeded,
|
|
1729
|
+
entry: String(args.entry),
|
|
1730
|
+
status,
|
|
1731
|
+
stopped,
|
|
1732
|
+
/* 本次实际生效的超时预算:回执要能自证"是不是跑满窗口被截断"。
|
|
1733
|
+
* 缺了它,`status:0 + stopped:true` 只能靠人回忆当初给的 --timeout 才分得清
|
|
1734
|
+
* 「跑满窗口」与「自己结束但没成功」——两者都是 stopped:true,而处置完全不同。 */
|
|
1735
|
+
timeoutMs,
|
|
1736
|
+
durationMs: Date.now() - t0,
|
|
1737
|
+
/* baseline 落账:节点坐标/识别框所在控制器空间的短边(契约 demo-trace §meta:
|
|
1738
|
+
* trace 元信息必带 baseline,跨项目迁移时与计划不一致即拒)。取控制器实际交付帧
|
|
1739
|
+
* 的短边——改了 shortSide / 换过设备时如实反映,不冒称"配置值"。 */
|
|
1740
|
+
baseline: Math.min(S.fullW, S.fullH) || null,
|
|
1741
|
+
space: S.fullW ? { w: S.fullW, h: S.fullH } : null,
|
|
1742
|
+
startSeq: nodes.size ? order[0].seq : null,
|
|
1743
|
+
endSeq: S.seq,
|
|
1744
|
+
framesCaptured: S.seq,
|
|
1745
|
+
retention,
|
|
1746
|
+
...(agentInfo ? { agent: agentInfo } : {}),
|
|
1747
|
+
...(hashCheck ? { hashCheck } : {}),
|
|
1748
|
+
...(nextCandidateCount ? { nextCandidates, nextCandidateCount } : {}),
|
|
1749
|
+
nodes: order.map((n) => ({
|
|
1750
|
+
id: n.id, name: n.name, nameSource: n.nameSource, status: n.status,
|
|
1751
|
+
ms: (n.end ?? Date.now()) - n.start, seq: n.seq, msgs: n.msgs.slice(-6),
|
|
1752
|
+
/* 识别框/动作框/节点留存帧:只增不改(协议约定),消费方按"有就读、没有就当该节点没报"处理 */
|
|
1753
|
+
...(n.reco ? { reco: n.reco } : {}),
|
|
1754
|
+
...(n.action ? { action: n.action } : {}),
|
|
1755
|
+
/* 按节点留帧的 seq:画面与上一节点相同会跳过(不重复占锚区),此时没有 frameSeq */
|
|
1756
|
+
...(n.frameSeq !== undefined ? { frameSeq: n.frameSeq } : {}),
|
|
1757
|
+
})),
|
|
1758
|
+
dump: dump ? { taskCount: dump.tasks ? dump.tasks.length : 0, nodes: Object.keys(dump.nodes || {}).length } : null,
|
|
1759
|
+
}
|
|
1760
|
+
return { ok: true, record: rec, ...(runWarns.length ? { warns: runWarns } : {}) }
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
async function cmdRunStop() {
|
|
1764
|
+
/* 先立旗标再 post_stop:cmdRun 的 job.wait 解除后靠它把 record.stopped 补上,
|
|
1765
|
+
* 否则"run_stop 停的"会混进"任务失败"。 */
|
|
1766
|
+
S.runStopped = true
|
|
1767
|
+
if (S.tasker) {
|
|
1768
|
+
try { S.tasker.post_stop() } catch (e) { /* ignore */ }
|
|
1769
|
+
}
|
|
1770
|
+
S.runActive = false
|
|
1771
|
+
return { ok: true }
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/* 输入注入:全部经 maafw 控制器(adb/win32 由 maafw 本体选择注入方法),不直接调 adb。
|
|
1775
|
+
* 动作边界(前/后)各抓一张原图进 L0 锚区(契约 §1"采得到"):
|
|
1776
|
+
* 采集失败不阻断动作——动作结果与留存失败分别报告(契约 §9)。 */
|
|
1777
|
+
/** 帧身份守卫(纯函数,供单测):核对"现在这帧"与调用方期望的身份。
|
|
1778
|
+
* now = S.ring 最新帧({seq, hash});expectSeq 是单调帧号、expectFrame 是 64bit hash 串。
|
|
1779
|
+
* 返回 { ok:true } 放行;{ ok:false, error, detail } 拒发(detail 带 expected/actual 进回执)。 */
|
|
1780
|
+
function checkExpect(now, expectSeqRaw, expectFrameRaw) {
|
|
1781
|
+
const expectSeq = expectSeqRaw !== undefined && expectSeqRaw !== null ? Number(expectSeqRaw) : null
|
|
1782
|
+
const expectFrame = typeof expectFrameRaw === 'string' && expectFrameRaw ? expectFrameRaw : null
|
|
1783
|
+
if (expectSeq === null && expectFrame === null) return { ok: true }
|
|
1784
|
+
if (!now) return { ok: false, error: '帧身份守卫:取不到当前帧(环形缓冲空),不发输入', detail: {} }
|
|
1785
|
+
if (expectSeq !== null && now.seq !== expectSeq) {
|
|
1786
|
+
return { ok: false, error: '帧身份守卫不通过:期望 seq=' + expectSeq + ',当前 seq=' + now.seq
|
|
1787
|
+
+ '——画面已经变了(' + (now.seq - expectSeq) + ' 帧),不发输入。重看再点。',
|
|
1788
|
+
detail: { expected: { seq: expectSeq }, actual: { seq: now.seq } } }
|
|
1789
|
+
}
|
|
1790
|
+
if (expectFrame !== null) {
|
|
1791
|
+
const nowHash = hashStr(now.hash)
|
|
1792
|
+
if (nowHash !== expectFrame) {
|
|
1793
|
+
return { ok: false, error: '帧身份守卫不通过:期望帧 hash 与当前帧不符(期望 ' + expectFrame.slice(0, 20)
|
|
1794
|
+
+ '… 当前 ' + nowHash.slice(0, 20) + '…,seq=' + now.seq + ')——画面已经变了,不发输入。',
|
|
1795
|
+
detail: { expected: { frameHash: expectFrame }, actual: { seq: now.seq, frameHash: nowHash } } }
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return { ok: true }
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async function cmdInput(args) {
|
|
1802
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
|
|
1803
|
+
const t0 = Date.now()
|
|
1804
|
+
const k = String(args.kind || 'click')
|
|
1805
|
+
const retention = {}
|
|
1806
|
+
const done = (job) => ({ ok: true, kind: k, ms: Date.now() - t0, retention })
|
|
1807
|
+
retention.before = await captureNow('action-before', true)
|
|
1808
|
+
/* 帧身份守卫:调用方说"我是在看到 seq=N 那帧之后决定要点的"——守卫核对**现在**这帧
|
|
1809
|
+
* 是不是同一帧。不是就不发输入(MPE validateClick 的拒点同义:画面已经变了,按旧画面
|
|
1810
|
+
* 算的坐标点下去就是误触)。seq 是单调计数器、expectFrame 是像素 hash——前者快(同 seq
|
|
1811
|
+
* 必同像素),后者跨 seq 也能核(同一画面重现)。回执里 code 让 CLI 落到 FINDINGS(3)。 */
|
|
1812
|
+
const chk = checkExpect(S.ring[S.ring.length - 1], args.expectSeq, args.expectFrame)
|
|
1813
|
+
if (!chk.ok) return { ok: false, code: 'EXPECT_FAILED', kind: k, error: chk.error, retention, ...chk.detail }
|
|
1814
|
+
let job = null
|
|
1815
|
+
if (k === 'click') {
|
|
1816
|
+
job = S.ctrl.post_click(Number(args.x), Number(args.y), Number(args.contact ?? 0), Number(args.pressure ?? 1))
|
|
1817
|
+
} else if (k === 'dbclick') {
|
|
1818
|
+
/* 双击:两次 click 间隔一拍(控制器消息模型下无原子双击原语) */
|
|
1819
|
+
await S.ctrl.post_click(Number(args.x), Number(args.y)).wait()
|
|
1820
|
+
await sleep(Math.max(20, Number(args.gap ?? 60)))
|
|
1821
|
+
job = S.ctrl.post_click(Number(args.x), Number(args.y))
|
|
1822
|
+
} else if (k === 'press') {
|
|
1823
|
+
/* 长按:touch_down → 保持 → touch_up(保持期不占用事件循环之外的资源) */
|
|
1824
|
+
const contact = Number(args.contact ?? 0)
|
|
1825
|
+
await S.ctrl.post_touch_down(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1)).wait()
|
|
1826
|
+
await sleep(Math.max(50, Number(args.duration ?? 800)))
|
|
1827
|
+
job = S.ctrl.post_touch_up(contact)
|
|
1828
|
+
} else if (k === 'swipe') {
|
|
1829
|
+
/* `via` 给中途路径点(`[[x,y],...]` 或 `"x,y;x,y"`):走 touch_down → 逐点 touch_move → touch_up,
|
|
1830
|
+
* 也就是**一条笔画画出折线**。为什么需要:`post_swipe` 只有两个端点,只能画直线;
|
|
1831
|
+
* 而连线/谱曲/拖拽排序这类玩法要求"按住 → 经过若干点 → 抬起",且**中途不能抬手**
|
|
1832
|
+
* (一旦抬手就是新笔画,很多玩法会把新笔画当成另一次操作或直接忽略)。
|
|
1833
|
+
* 不给 `via` 时走原生 post_swipe(保持旧行为与旧性能)。 */
|
|
1834
|
+
const viaRaw = args.via
|
|
1835
|
+
let via = []
|
|
1836
|
+
if (Array.isArray(viaRaw)) {
|
|
1837
|
+
via = viaRaw.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : null))
|
|
1838
|
+
} else if (typeof viaRaw === 'string' && viaRaw.trim()) {
|
|
1839
|
+
via = viaRaw.split(';').map((s) => s.split(',').map(Number))
|
|
1840
|
+
}
|
|
1841
|
+
if (via.length) {
|
|
1842
|
+
if (via.some((p) => !p || p.length !== 2 || !p.every(Number.isFinite))) {
|
|
1843
|
+
return { ok: false, error: 'via 路径点格式错:要 [[x,y],...] 或 "x,y;x,y"(收到 ' + JSON.stringify(viaRaw) + ')' }
|
|
1844
|
+
}
|
|
1845
|
+
const contact = Number(args.contact ?? 0)
|
|
1846
|
+
const pressure = Number(args.pressure ?? 1)
|
|
1847
|
+
const pts = [[Number(args.x1), Number(args.y1)], ...via, [Number(args.x2), Number(args.y2)]]
|
|
1848
|
+
if (!pts.flat().every(Number.isFinite)) return { ok: false, error: 'swipe 端点或 via 含非数字' }
|
|
1849
|
+
const duration = Math.max(0, Number(args.duration ?? 300))
|
|
1850
|
+
const step = pts.length > 1 ? Math.max(8, Math.round(duration / (pts.length - 1))) : 8
|
|
1851
|
+
await S.ctrl.post_touch_down(contact, pts[0][0], pts[0][1], pressure).wait()
|
|
1852
|
+
for (const p of pts.slice(1)) {
|
|
1853
|
+
await S.ctrl.post_touch_move(contact, p[0], p[1], pressure).wait()
|
|
1854
|
+
await sleep(step)
|
|
1855
|
+
}
|
|
1856
|
+
job = S.ctrl.post_touch_up(contact)
|
|
1857
|
+
/* 与共享尾同一口径:**先 wait 再抓 after**。抓早了可能截在抬手落地之前,
|
|
1858
|
+
* 而手势类动作恰恰要验证"抬手之后画面变成什么样"。 */
|
|
1859
|
+
await job.wait()
|
|
1860
|
+
retention.after = await captureNow('action-after', true)
|
|
1861
|
+
return { ...done(null), path: pts, viaCount: via.length, stepMs: step }
|
|
1862
|
+
}
|
|
1863
|
+
job = S.ctrl.post_swipe(Number(args.x1), Number(args.y1), Number(args.x2), Number(args.y2),
|
|
1864
|
+
Number(args.duration ?? 300), Number(args.contact ?? 0), Number(args.pressure ?? 1))
|
|
1865
|
+
} else if (k === 'touch_move') {
|
|
1866
|
+
/* 裸的移动原语:与 touch_down / touch_up 配对,供调用方自己拼任意手势序列
|
|
1867
|
+
* (跨多个命令维持同一次按压——联系列动作之间不抬手)。 */
|
|
1868
|
+
const contact = Number(args.contact ?? 0)
|
|
1869
|
+
job = S.ctrl.post_touch_move(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
|
|
1870
|
+
await job.wait()
|
|
1871
|
+
retention.after = await captureNow('action-after', true)
|
|
1872
|
+
return done(null)
|
|
1873
|
+
} else if (k === 'touch_down' || k === 'touch_up') {
|
|
1874
|
+
const contact = Number(args.contact ?? 0)
|
|
1875
|
+
job = k === 'touch_down'
|
|
1876
|
+
? S.ctrl.post_touch_down(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
|
|
1877
|
+
: S.ctrl.post_touch_up(contact)
|
|
1878
|
+
await job.wait()
|
|
1879
|
+
retention.after = await captureNow('action-after', true)
|
|
1880
|
+
return done(null)
|
|
1881
|
+
} else if (k === 'key') {
|
|
1882
|
+
job = S.ctrl.post_click_key(Number(args.code))
|
|
1883
|
+
} else if (k === 'keys') {
|
|
1884
|
+
/* 组合键:全按下再全抬起(按下顺序=给定顺序,抬起反序,模拟真实手型) */
|
|
1885
|
+
const codes = (Array.isArray(args.codes) ? args.codes : String(args.codes ?? '').split(/[+,]/))
|
|
1886
|
+
.map((c) => Number(String(c).trim())).filter((c) => Number.isFinite(c) && c > 0)
|
|
1887
|
+
if (!codes.length) return { ok: false, error: 'keys 需要至少一个键码(数组或逗号/加号分隔)' }
|
|
1888
|
+
const hold = Math.max(20, Number(args.hold ?? 60))
|
|
1889
|
+
for (const c of codes) await S.ctrl.post_key_down(c).wait()
|
|
1890
|
+
await sleep(hold)
|
|
1891
|
+
for (const c of codes.slice().reverse()) await S.ctrl.post_key_up(c).wait()
|
|
1892
|
+
retention.after = await captureNow('action-after', true)
|
|
1893
|
+
return done(null)
|
|
1894
|
+
} else if (k === 'scroll') {
|
|
1895
|
+
/* 滚轮:dx/dy 为格数,建议 120 的倍数(WHEEL_DELTA) */
|
|
1896
|
+
job = S.ctrl.post_scroll(Number(args.dx ?? 0), Number(args.dy ?? 0))
|
|
1897
|
+
} else if (k === 'move') {
|
|
1898
|
+
/* 相对移动(Win32/MacOS;FPS 锁鼠标场景配 mouse_lock_follow) */
|
|
1899
|
+
job = S.ctrl.post_relative_move(Number(args.dx ?? 0), Number(args.dy ?? 0))
|
|
1900
|
+
} else if (k === 'text') {
|
|
1901
|
+
job = S.ctrl.post_input_text(String(args.text ?? ''))
|
|
1902
|
+
} else if (k === 'app') {
|
|
1903
|
+
job = String(args.action) === 'stop'
|
|
1904
|
+
? S.ctrl.post_stop_app(String(args.intent ?? ''))
|
|
1905
|
+
: S.ctrl.post_start_app(String(args.intent ?? ''))
|
|
1906
|
+
} else {
|
|
1907
|
+
return { ok: false, error: 'unknown input kind: ' + k + '(click|dbclick|press|swipe|key|keys|scroll|move|text|app)' }
|
|
1908
|
+
}
|
|
1909
|
+
await job.wait()
|
|
1910
|
+
retention.after = await captureNow('action-after', true)
|
|
1911
|
+
return done(job)
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* 子进程预算:用例数越多放得越宽(实测每例 ≈ 0.24s,留 5s/例的余量)。
|
|
1916
|
+
* 固定 90s 会让"几十个候选"在慢机器上被误杀,而误杀的表现是"识别子进程失败"——
|
|
1917
|
+
* 与真正的识别失败分不开。上限仍由 spawnRecoChild 自己封(120s)。
|
|
1918
|
+
*/
|
|
1919
|
+
function recoChildBudget(cases) {
|
|
1920
|
+
return Math.max(90000, Number(cases) * 5000)
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/* 识别单测:子进程隔离执行(beta 绑定的 post_recognition 在无效模板上会原生崩溃,
|
|
1924
|
+
* 子进程炸掉只损失一次测试,绝不让宿主陪葬)。 */
|
|
1925
|
+
function spawnRecoChild(payload, timeoutMs) {
|
|
1926
|
+
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'reco_child.mjs')
|
|
1927
|
+
const file = path.join(os.tmpdir(), 'maa_reco_' + Date.now() + '_' + Math.floor(Math.random() * 1e6) + '.json')
|
|
1928
|
+
fs.writeFileSync(file, JSON.stringify(payload), 'utf8')
|
|
1929
|
+
const out = file + '.out'
|
|
1930
|
+
return new Promise((resolve) => {
|
|
1931
|
+
execFile(process.execPath, [script, '--cfg', file, '--out', out],
|
|
1932
|
+
{ timeout: Math.min(120000, Math.max(10000, timeoutMs)), windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
|
1933
|
+
(err, stdout) => {
|
|
1934
|
+
try { fs.rmSync(file, { force: true }) } catch (e) { /* ignore */ }
|
|
1935
|
+
try {
|
|
1936
|
+
if (fs.existsSync(out)) {
|
|
1937
|
+
const r = JSON.parse(fs.readFileSync(out, 'utf8'))
|
|
1938
|
+
fs.rmSync(out, { force: true })
|
|
1939
|
+
resolve(r)
|
|
1940
|
+
return
|
|
1941
|
+
}
|
|
1942
|
+
} catch (e) { /* 结果损坏则走失败分支 */ }
|
|
1943
|
+
resolve({ ok: false, error: '识别子进程失败:' + ((err && (err.message || String(err.code))) || '无结果') + ' ' + String(stdout || '').slice(0, 200) })
|
|
1944
|
+
})
|
|
1945
|
+
})
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/* 识别详情瘦身:像素类字段(draws / raw)一律不带出去(与 reco_child 同一规则)。 */
|
|
1949
|
+
function stripDetail(d) {
|
|
1950
|
+
if (!d || typeof d !== 'object') return null
|
|
1951
|
+
const copy = { ...d }
|
|
1952
|
+
delete copy.draws
|
|
1953
|
+
delete copy.raw
|
|
1954
|
+
return copy
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* 节点级单测(--act):识别拿框 → 用框 run_action,动作半在真机 daemon 侧执行。
|
|
1959
|
+
* 与 reco_child 的静态路径互补:那条只测识别(图像假控制器、无输入);这条串起两半,
|
|
1960
|
+
* 专测 target 框语义(框中心/偏移)与组合动作。识别输入 = 抓帧时的当前画面原图
|
|
1961
|
+
* (也是 action-before 边界锚点),动作执行后的画面另抓 action-after——效果可复核。
|
|
1962
|
+
*/
|
|
1963
|
+
async function recoActTest(args, node, dirs) {
|
|
1964
|
+
/* 有任务在飞时不换资源:act 会改写 tasker.resource,等于把正在跑的任务的资源换掉。
|
|
1965
|
+
* 这条路径本来也不该在 run 期间用(要真机动作就等任务结束)。 */
|
|
1966
|
+
if (S.runActive) return { ok: false, error: '有任务正在运行:先 maa_run_stop 再跑 --act(改资源会打断在跑的任务)' }
|
|
1967
|
+
/* 资源链与识别路径同一份:节点里的模板/被引用子节点可能只在 overlay 段里。
|
|
1968
|
+
* 同样每次新建,不与会话里任何资源共享(act 会注册/注销 custom action,复用更不可取)。 */
|
|
1969
|
+
const res = await buildResource(dirs)
|
|
1970
|
+
const tasker = ensureTasker()
|
|
1971
|
+
tasker.resource = res
|
|
1972
|
+
tasker.controller = S.ctrl
|
|
1973
|
+
/* 先让 tasker 指向新对象,再销毁上一份(见 adoptRunResource 的调用前提) */
|
|
1974
|
+
adoptRunResource(res)
|
|
1975
|
+
const before = await captureNow('action-before', true)
|
|
1976
|
+
if (!before.ok) return { ok: false, error: '识别输入抓帧失败:' + before.error }
|
|
1977
|
+
const imgBuf = S.l0.roll[S.l0.roll.length - 1].png
|
|
1978
|
+
const image = imgBuf.buffer.slice(imgBuf.byteOffset, imgBuf.byteOffset + imgBuf.byteLength)
|
|
1979
|
+
let out = null
|
|
1980
|
+
res.register_custom_action('@reco/act', async (self) => {
|
|
1981
|
+
let detail = null
|
|
1982
|
+
try {
|
|
1983
|
+
detail = await self.context.run_recognition('@reco/node', image, { '@reco/node': node })
|
|
1984
|
+
} catch (e) {
|
|
1985
|
+
out = { ok: false, stage: 'recognition', error: String(e && e.message || e) }
|
|
1986
|
+
return true
|
|
1987
|
+
}
|
|
1988
|
+
/* miss 判定不能只看 box 真值:miss 时 run_recognition 返回 hit:false + box 全零(数组恒真),
|
|
1989
|
+
* 漏检会把 run_action 打到 (0,0)。hit 标志为主,box 零尺寸兜底(防个别识别类型缺 hit)。 */
|
|
1990
|
+
const boxDead = !detail || !Array.isArray(detail.box) || Number(detail.box[2]) === 0 || Number(detail.box[3]) === 0
|
|
1991
|
+
if (!detail || detail.hit === false || boxDead) {
|
|
1992
|
+
out = { ok: false, stage: 'recognition', miss: true, ...(detail ? { reco: stripDetail(detail) } : {}) }
|
|
1993
|
+
return true
|
|
1994
|
+
}
|
|
1995
|
+
const rec = stripDetail(detail)
|
|
1996
|
+
try {
|
|
1997
|
+
const act = await self.context.run_action('@reco/node', detail.box, JSON.stringify(rec), { '@reco/node': node })
|
|
1998
|
+
out = { ok: !!act, stage: 'action', reco: rec, action: stripDetail(act) }
|
|
1999
|
+
} catch (e) {
|
|
2000
|
+
out = { ok: false, stage: 'action', error: String(e && e.message || e), reco: rec }
|
|
2001
|
+
}
|
|
2002
|
+
return true
|
|
2003
|
+
})
|
|
2004
|
+
try {
|
|
2005
|
+
await tasker.post_task('@reco/entry', { '@reco/entry': { action: 'Custom', custom_action: '@reco/act' } }).wait()
|
|
2006
|
+
} catch (e) {
|
|
2007
|
+
return { ok: false, error: '动作探针任务失败:' + String(e && e.message || e) }
|
|
2008
|
+
} finally {
|
|
2009
|
+
try { res.unregister_custom_action('@reco/act') } catch (e) { /* ignore */ }
|
|
2010
|
+
}
|
|
2011
|
+
const after = await captureNow('action-after', true)
|
|
2012
|
+
if (!out) return { ok: false, error: '动作探针未被执行(custom action 回调未触发)' }
|
|
2013
|
+
const rec = node && node.recognition
|
|
2014
|
+
const shownType = rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec ?? 'DirectHit')
|
|
2015
|
+
return {
|
|
2016
|
+
ok: out.ok,
|
|
2017
|
+
type: shownType,
|
|
2018
|
+
/* 与识别路径同一口径:这次用的是整条资源链(含 attach 段),layer=resource 表示这里没有
|
|
2019
|
+
* interface 的四级 pipeline_override(run --project 才会叠) */
|
|
2020
|
+
resource: { dirs },
|
|
2021
|
+
layer: 'resource',
|
|
2022
|
+
meta: { seq: before.seq, w: before.w, h: before.h, t: before.t },
|
|
2023
|
+
...(out.error !== undefined ? { error: out.error } : {}),
|
|
2024
|
+
...(out.stage ? { stage: out.stage } : {}),
|
|
2025
|
+
...(out.miss !== undefined ? { miss: out.miss } : {}),
|
|
2026
|
+
...(out.reco ? { reco: out.reco } : {}),
|
|
2027
|
+
...(out.action ? { action: out.action } : {}),
|
|
2028
|
+
retention: { before, after },
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async function cmdRecoTest(args) {
|
|
2033
|
+
/* 资源链:resourceDirs[] 是 interface.json 的 resource.path[](+ 控制器 attach_resource_path)
|
|
2034
|
+
* 按声明顺序叠加的结果;resourceDir 是单包写法。只有单包时测的就不是项目里那个节点——
|
|
2035
|
+
* overlay 段的节点改写、只存在于 overlay 的模板、被 And/Or 按名引用的子节点全会缺。 */
|
|
2036
|
+
const chain = Array.isArray(args.resourceDirs) ? args.resourceDirs.map((d) => String(d)).filter((d) => d) : []
|
|
2037
|
+
const dirs = chain.length ? chain : (args.resourceDir ? [String(args.resourceDir)] : [])
|
|
2038
|
+
if (!dirs.length) return { ok: false, error: '缺少资源:给 resourceDirs[](整链)或 resourceDir(单包)' }
|
|
2039
|
+
const nodeName = typeof args.nodeName === 'string' && args.nodeName !== '' ? args.nodeName : null
|
|
2040
|
+
if (nodeName && args.node && typeof args.node === 'object' && !Array.isArray(args.node)) {
|
|
2041
|
+
return { ok: false, error: 'node 与 nodeName 互斥:一次只测一个节点来源' }
|
|
2042
|
+
}
|
|
2043
|
+
/* --act:节点级单测(识别拿框 → 真机执行动作半),在 daemon 进程内跑(要真控制器) */
|
|
2044
|
+
const actNode = args.act === true && args.node && typeof args.node === 'object' && !Array.isArray(args.node) ? args.node : null
|
|
2045
|
+
if (args.act === true) {
|
|
2046
|
+
if (nodeName) return { ok: false, error: '--act 不接受 nodeName:按资源原名跑的是识别判据,动作半要真机桥接(那是 run)' }
|
|
2047
|
+
if (!actNode) return { ok: false, error: '--act 需要同时给 --node(整节点 JSON,含 action)' }
|
|
2048
|
+
if (!S.ctrl) return { ok: false, error: '--act 要在真机上执行动作:需要已连接设备(REPL 或 --project/--kind)' }
|
|
2049
|
+
return await recoActTest(args, actNode, dirs)
|
|
2050
|
+
}
|
|
2051
|
+
/* 资源链可加载性先验已挪进 reco_child(同一次加载里逐段点名失败段):原先这里
|
|
2052
|
+
* probeResourceChain 先加载一遍、子进程再加载一遍,识别一慢就是双倍预算,
|
|
2053
|
+
* 且永远是客户端先到点杀进程(设计审查 P0-1)。 */
|
|
2054
|
+
/* 图像来源三条:库内留存帧(kfSource:控制器分辨率原图、**可无设备**)/ 显式文件 / 会话缓冲帧。
|
|
2055
|
+
* daemon 自己写的那份临时 PNG **用完即删**——不删就是每跑一次漏一个(CLI 侧的截帧泄漏是同一类,
|
|
2056
|
+
* 现场实测攒过 42 张;这条 `--seq` 路径同理,只是本机还没被触发过)。 */
|
|
2057
|
+
let imageFile = null
|
|
2058
|
+
let tempImage = null
|
|
2059
|
+
let meta = {}
|
|
2060
|
+
if (args.kfSource) {
|
|
2061
|
+
const kf = readKfCropSource(args.kfSource)
|
|
2062
|
+
if (kf.error) return { ok: false, error: kf.error }
|
|
2063
|
+
imageFile = path.join(os.tmpdir(), 'maa_reco_kf_' + Date.now() + '.png')
|
|
2064
|
+
fs.writeFileSync(imageFile, kf.png) // 库帧原样字节,不重编码(裁剪无关,reco 只读这一张)
|
|
2065
|
+
tempImage = imageFile
|
|
2066
|
+
meta = { source: kf.source.id, sha256: kf.source.sha256, w: kf.dec.w, h: kf.dec.h, ctrlW: kf.dec.w, ctrlH: kf.dec.h }
|
|
2067
|
+
} else if (args.image) {
|
|
2068
|
+
imageFile = String(args.image)
|
|
2069
|
+
/* 补 w/h:没有它,调用方无法判断 roi 用的是哪个空间(信息补齐,不改取图行为) */
|
|
2070
|
+
let size = null
|
|
2071
|
+
try { size = pngHeaderSize(fs.readFileSync(imageFile)) } catch (e) { /* 读不到留给子进程报错 */ }
|
|
2072
|
+
meta = { source: args.image, ...(size ? { w: size.w, h: size.h } : {}) }
|
|
2073
|
+
} else {
|
|
2074
|
+
const fr = args.seq !== undefined && args.seq !== null
|
|
2075
|
+
? S.ring.find((r) => r.seq === Number(args.seq))
|
|
2076
|
+
: S.ring[S.ring.length - 1]
|
|
2077
|
+
if (!fr) return { ok: false, error: '缓冲无可用帧(seq=' + args.seq + ')' }
|
|
2078
|
+
imageFile = path.join(os.tmpdir(), 'maa_reco_img_' + Date.now() + '.png')
|
|
2079
|
+
fs.writeFileSync(imageFile, pngEncodeRGB(fr.rgb, fr.w, fr.h))
|
|
2080
|
+
tempImage = imageFile
|
|
2081
|
+
meta = { seq: fr.seq, w: fr.w, h: fr.h, t: fr.t }
|
|
2082
|
+
}
|
|
2083
|
+
/* --node:整节点 JSON 原样透传给子进程(V1 扁平 / V2 嵌套由框架解析,daemon 不转换) */
|
|
2084
|
+
const node = args.node && typeof args.node === 'object' && !Array.isArray(args.node) ? args.node : null
|
|
2085
|
+
if (node && (typeof node.recognition !== 'string') &&
|
|
2086
|
+
!(node.recognition && typeof node.recognition === 'object' && !Array.isArray(node.recognition))) {
|
|
2087
|
+
return { ok: false, error: '--node 缺少可用的 recognition 字段(V1 字符串或 V2 {type,param})' }
|
|
2088
|
+
}
|
|
2089
|
+
const baseParam = args.param && typeof args.param === 'object' ? args.param : {}
|
|
2090
|
+
const sweep = args.sweep && typeof args.sweep === 'object' ? args.sweep : null
|
|
2091
|
+
/* --candidates:候选集合逐例换 roi(roi-compare 走的正是这条)。
|
|
2092
|
+
* 与 sweep 像但**不变量不同**:sweep 是"同键换值",候选是"逐例换 roi"——
|
|
2093
|
+
* 折叠进同一个 cases、走同一次 spawnRecoChild,不新开第二条子进程通道。 */
|
|
2094
|
+
const candidates = Array.isArray(args.candidates) ? args.candidates : null
|
|
2095
|
+
if (candidates && sweep && typeof sweep.min === 'number') {
|
|
2096
|
+
return { ok: false, error: 'candidates 与 sweep 互斥:一次识别单测只跑一种候选来源' }
|
|
2097
|
+
}
|
|
2098
|
+
if (candidates && !candidates.length) {
|
|
2099
|
+
return { ok: false, error: 'candidates 是空数组:至少给一个 [x,y,w,h] 候选' }
|
|
2100
|
+
}
|
|
2101
|
+
/* node / nodeName 与候选来源互斥:整条节点路径(dump + 节点自己的 recognition/param)
|
|
2102
|
+
* 已经是一次完整的"跑这个节点",cases 根本没有位置塞——同给会静默丢掉候选集合,
|
|
2103
|
+
* 调用方还以为 roi-compare 的逐例换 roi 生效了(P1-#5 协议面)。 */
|
|
2104
|
+
if ((node || nodeName) && (candidates || (sweep && typeof sweep.min === 'number'))) {
|
|
2105
|
+
return { ok: false, error: 'node/nodeName 与 candidates/sweep 互斥:整条节点路径有自己完整的 param,没有逐例换值的位置' }
|
|
2106
|
+
}
|
|
2107
|
+
const cases = []
|
|
2108
|
+
if (candidates) {
|
|
2109
|
+
for (const c of candidates) {
|
|
2110
|
+
const roi = Array.isArray(c) ? c.map(Number) : null
|
|
2111
|
+
if (!roi || roi.length !== 4 || !roi.every(Number.isFinite)) {
|
|
2112
|
+
return { ok: false, error: 'candidates 每项都要是 [x,y,w,h] 四个数字,收到:' + JSON.stringify(c) }
|
|
2113
|
+
}
|
|
2114
|
+
cases.push({ ...baseParam, roi })
|
|
2115
|
+
}
|
|
2116
|
+
} else if (sweep && typeof sweep.min === 'number' && typeof sweep.max === 'number') {
|
|
2117
|
+
/* step 带方向:升序 0.5→0.9 / 降序 0.9→0.5 都合法;区间为空明确报错,不静默测个空 */
|
|
2118
|
+
const step = sweep.step !== undefined && Number(sweep.step) !== 0 ? Number(sweep.step) : 1
|
|
2119
|
+
const up = step > 0
|
|
2120
|
+
if ((up && sweep.min > sweep.max) || (!up && sweep.min < sweep.max)) {
|
|
2121
|
+
return { ok: false, error: 'sweep 区间为空:min=' + sweep.min + ' max=' + sweep.max + ' step=' + step + '(step 带方向)' }
|
|
2122
|
+
}
|
|
2123
|
+
for (let v = sweep.min; up ? v <= sweep.max + 1e-9 : v >= sweep.max - 1e-9; v += step) {
|
|
2124
|
+
cases.push({ ...baseParam, [String(sweep.key || 'threshold')]: Math.round(v * 1000) / 1000 })
|
|
2125
|
+
}
|
|
2126
|
+
} else cases.push(baseParam)
|
|
2127
|
+
let r = null
|
|
2128
|
+
try {
|
|
2129
|
+
r = await spawnRecoChild({
|
|
2130
|
+
...(chain.length ? { resourceDirs: dirs } : { resourceDir: dirs[0] }),
|
|
2131
|
+
type: String(args.type || 'TemplateMatch'),
|
|
2132
|
+
image: imageFile,
|
|
2133
|
+
/* 面板场景:模板由调用方裁好落盘传进来(子进程走 override_image),不写进任何资源目录 */
|
|
2134
|
+
...(args.templateImage ? { templateImage: String(args.templateImage) } : {}),
|
|
2135
|
+
...(node ? { node } : (nodeName ? { nodeName } : { cases })),
|
|
2136
|
+
}, recoChildBudget(cases.length))
|
|
2137
|
+
} finally {
|
|
2138
|
+
/* 子进程已经读完这张图(spawnRecoChild 会等到它退出),此时删除是安全的 */
|
|
2139
|
+
if (tempImage) { try { fs.rmSync(tempImage, { force: true }) } catch (e) { /* ignore */ } }
|
|
2140
|
+
}
|
|
2141
|
+
/* 回执带资源链与层级:layer=resource 表示这里只到"资源合并结果",
|
|
2142
|
+
* run --project 还会再叠 interface 的四级 pipeline_override(GUI 选项改过同一字段时两者对不上是预期) */
|
|
2143
|
+
const resource = { dirs }
|
|
2144
|
+
if (r && r.ok) {
|
|
2145
|
+
const rec = node && node.recognition
|
|
2146
|
+
const shownType = node
|
|
2147
|
+
? (rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec))
|
|
2148
|
+
: String(r.type ?? args.type ?? 'TemplateMatch')
|
|
2149
|
+
return {
|
|
2150
|
+
ok: true, type: shownType, meta, results: r.results, resource, layer: 'resource',
|
|
2151
|
+
...(nodeName ? { nodeName } : {}),
|
|
2152
|
+
...(r.nodeDump ? { nodeDump: r.nodeDump } : {}),
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return {
|
|
2156
|
+
ok: false,
|
|
2157
|
+
error: (r && r.error) || 'reco 失败',
|
|
2158
|
+
meta, resource, layer: 'resource',
|
|
2159
|
+
...(r && r.code ? { code: String(r.code) } : {}),
|
|
2160
|
+
/* 近邻节点名:`--node-name` 拼错时给出"是不是想跑这个",CLI/REPL 直接把它变成 hint */
|
|
2161
|
+
...(r && Array.isArray(r.candidates) && r.candidates.length ? { candidates: r.candidates.map(String) } : {}),
|
|
2162
|
+
/* 嵌套 custom 的命中位置:让人知道是节点自己,还是 And/Or 里的子识别 */
|
|
2163
|
+
...(r && Array.isArray(r.customPaths) && r.customPaths.length ? { customPaths: r.customPaths.map(String) } : {}),
|
|
2164
|
+
...(nodeName ? { nodeName } : {}),
|
|
2165
|
+
...(r && r.nodeDump ? { nodeDump: r.nodeDump } : {}),
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/* ────────────────────────── 关键帧库(L0 升格 + 登记,契约 §4) ──────────────────────────
|
|
2170
|
+
* 库根:runDir/frames(host init 传入 runDir,默认 ~/.maafw-live)。
|
|
2171
|
+
* manifest.json = { schema, libraryId, next, frames[] };kf ID = kf:<库UUID>:<零填充序号>。
|
|
2172
|
+
* 写入次序是硬约束:先固定像素(tmp+rename 落 L0 文件),再发布 manifest 记录;
|
|
2173
|
+
* manifest 失败即回收文件,绝不返回半成品引用。库副本 v0 按单写者管理,不自动合并。 */
|
|
2174
|
+
const KF_SCHEMA = 1
|
|
2175
|
+
function kfRoot() { return path.join(S.runDir || path.join(os.homedir(), '.maafw-live'), 'frames') }
|
|
2176
|
+
function kfAbsPath(fileRel) { return path.join(kfRoot(), String(fileRel).split('/').join(path.sep)) }
|
|
2177
|
+
function kfLoad() {
|
|
2178
|
+
const file = path.join(kfRoot(), 'manifest.json')
|
|
2179
|
+
if (!fs.existsSync(file)) return null
|
|
2180
|
+
let m = null
|
|
2181
|
+
try { m = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) { throw new Error('manifest.json 损坏(非合法 JSON)') }
|
|
2182
|
+
if (!m || typeof m !== 'object' || !Array.isArray(m.frames)) throw new Error('manifest.json 结构不符(缺 frames 数组)')
|
|
2183
|
+
/* 未知 schema 明确报错,不默认为当前版(契约 §4) */
|
|
2184
|
+
if (Number(m.schema) !== KF_SCHEMA) throw new Error('manifest schema=' + m.schema + ' 不被当前版本支持(认识:' + KF_SCHEMA + ')')
|
|
2185
|
+
return m
|
|
2186
|
+
}
|
|
2187
|
+
function kfOpen() {
|
|
2188
|
+
return kfLoad() ?? { schema: KF_SCHEMA, libraryId: randomUUID(), next: 1, frames: [] }
|
|
2189
|
+
}
|
|
2190
|
+
function kfSave(m) {
|
|
2191
|
+
const file = path.join(kfRoot(), 'manifest.json')
|
|
2192
|
+
fs.mkdirSync(kfRoot(), { recursive: true })
|
|
2193
|
+
const tmp = file + '.tmp'
|
|
2194
|
+
fs.writeFileSync(tmp, JSON.stringify(m, null, 2))
|
|
2195
|
+
fs.renameSync(tmp, file)
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/* ─── 关键帧库写锁(P1-#8)───────────────────────────────────────────────────
|
|
2199
|
+
* manifest 的读-改-写此前无锁:常驻会话与一次性 `crop --keep-source` 同秒升格时,
|
|
2200
|
+
* 两个进程都读 m.next=N、都写 l0/000N.png,manifest 后写者赢——已发出的 kf ID
|
|
2201
|
+
* 指向另一份像素或 sha 校验失败。锁是文件系统可见的(跨 daemon 进程);
|
|
2202
|
+
* O_EXCL 拿锁(原子,不会两人同时创建成功);持有者崩了留残渣时按 mtime 过期重拿。 */
|
|
2203
|
+
const KF_LOCK_STALE_MS = 60_000
|
|
2204
|
+
function kfLockPath() { return path.join(kfRoot(), 'manifest.lock') }
|
|
2205
|
+
function kfLockAcquire() {
|
|
2206
|
+
const p = kfLockPath()
|
|
2207
|
+
fs.mkdirSync(kfRoot(), { recursive: true })
|
|
2208
|
+
const deadline = Date.now() + 30_000
|
|
2209
|
+
while (Date.now() < deadline) {
|
|
2210
|
+
try {
|
|
2211
|
+
const fd = fs.openSync(p, 'wx') /* 'wx' = O_EXCL:存在即抛,原子拿锁 */
|
|
2212
|
+
fs.closeSync(fd)
|
|
2213
|
+
return true
|
|
2214
|
+
} catch (e) {
|
|
2215
|
+
if (e.code !== 'EEXIST') return false
|
|
2216
|
+
/* 锁在:看是不是崩溃残渣(mtime 太老 = 持有者死了没放) */
|
|
2217
|
+
try {
|
|
2218
|
+
const age = Date.now() - fs.statSync(p).mtimeMs
|
|
2219
|
+
if (age > KF_LOCK_STALE_MS) {
|
|
2220
|
+
try { fs.rmSync(p, { force: true }) } catch (e2) { /* ignore */ }
|
|
2221
|
+
continue /* 删掉残渣立即重试拿锁 */
|
|
2222
|
+
}
|
|
2223
|
+
} catch (e2) { /* stat 失败当新锁等 */ }
|
|
2224
|
+
/* 新锁:等持有者放。帧库写者并发本来就极低(常驻 + 一次性偶发),
|
|
2225
|
+
* 单次升格毫秒级——40ms 空转一拍就够。 */
|
|
2226
|
+
const t0 = Date.now()
|
|
2227
|
+
while (Date.now() - t0 < 40) { /* spin */ }
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return false
|
|
2231
|
+
}
|
|
2232
|
+
function kfLockRelease() {
|
|
2233
|
+
try { fs.rmSync(kfLockPath(), { force: true }) } catch (e) { /* ignore */ }
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/** 库目录现有 L0 字节(按 manifest 记录的文件实测;丢失文件计 0——磁盘占用只算真实存在的) */
|
|
2237
|
+
function kfUsageBytes(m) {
|
|
2238
|
+
let total = 0
|
|
2239
|
+
for (const f of m.frames) {
|
|
2240
|
+
try { total += fs.statSync(kfAbsPath(f.file)).size } catch (e) { /* 丢失不计 */ }
|
|
2241
|
+
}
|
|
2242
|
+
return total
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
async function cmdKfPromoteOne(seq, note) {
|
|
2246
|
+
/* 单个 seq 升格(供 cmdKfPromote 与批量模式共用)。返回 { ok, id?, error?, ... } */
|
|
2247
|
+
if (seq <= 0) return { ok: false, error: '本会话尚无捕获(seq=0),无可升格', seq }
|
|
2248
|
+
const ent = S.l0.anchor.find((x) => x.seq === seq) ?? S.l0.roll.find((x) => x.seq === seq)
|
|
2249
|
+
if (!ent) {
|
|
2250
|
+
const range = (l) => (l.length ? l[0].seq + '..' + l[l.length - 1].seq : '空')
|
|
2251
|
+
return {
|
|
2252
|
+
ok: false, seq,
|
|
2253
|
+
error: 'L0 已淘汰:seq=' + seq + '(滚动区 ' + range(S.l0.roll) + ',锚区 ' + range(S.l0.anchor) + ')。' +
|
|
2254
|
+
'可降级导出参考帧:frame_get ' + seq + ' --out <file>(L1 参考帧,按契约不作 state 证据本体)',
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
/* manifest 读-改-写外面加文件锁(P1-#8):常驻会话与一次性 crop --keep-source
|
|
2258
|
+
* 同秒升格时,两个进程不能各读各的 m.next=N、各写各的 l0/000N.png 后让 manifest
|
|
2259
|
+
* 后写者赢——那会让一个已发出的 kf ID 指向另一份像素。拿锁后重读 manifest,
|
|
2260
|
+
* 整个 RMW 都在临界区。 */
|
|
2261
|
+
if (!kfLockAcquire()) {
|
|
2262
|
+
return { ok: false, seq, error: '关键帧库写锁拿不到(30s):另一进程可能在升格/崩溃残留——稍候重试,或查 ' + kfLockPath() }
|
|
2263
|
+
}
|
|
2264
|
+
let m
|
|
2265
|
+
try { m = kfOpen() } catch (e) { kfLockRelease(); return { ok: false, error: '关键帧库不可用:' + String(e && e.message || e), seq } }
|
|
2266
|
+
const sess = S.session || {}
|
|
2267
|
+
const sessionRef = { daemon: S.daemonId, gen: S.connGen, kind: sess.kind ?? null, target: sess.target ?? null }
|
|
2268
|
+
const sha = createHash('sha256').update(ent.png).digest('hex')
|
|
2269
|
+
try {
|
|
2270
|
+
/* 幂等:同一捕获(daemon + 代次 + seq)重试返回原对象;内容不符报冲突,不任选一份 */
|
|
2271
|
+
const dup = m.frames.find((f) => f.session && f.session.daemon === sessionRef.daemon &&
|
|
2272
|
+
Number(f.session.gen) === sessionRef.gen && Number(f.captureSeq) === seq)
|
|
2273
|
+
if (dup) {
|
|
2274
|
+
if (dup.sha256 === sha) return { ok: true, idempotent: true, id: dup.id, record: dup, path: kfAbsPath(dup.file), sha256: sha, seq }
|
|
2275
|
+
return { ok: false, seq, error: '同一捕获已有内容不同的记录(' + dup.id + '):冲突,拒绝改写旧 ID 的含义' }
|
|
2276
|
+
}
|
|
2277
|
+
/* 磁盘配额(契约 §4.1):幂等重试在上面已返回,走到这里的都是新增字节。
|
|
2278
|
+
* 满额拒绝并报告——已升格对象不后台清理,清理由用户决定。 */
|
|
2279
|
+
if (S.kfQuota > 0) {
|
|
2280
|
+
const used = kfUsageBytes(m)
|
|
2281
|
+
const mb = (v) => (v / 1048576).toFixed(1) + 'MB'
|
|
2282
|
+
if (used + ent.png.length > S.kfQuota) {
|
|
2283
|
+
return {
|
|
2284
|
+
ok: false, seq,
|
|
2285
|
+
error: '关键帧库配额不足:已用 ' + mb(used) + ' + 新增 ' + mb(ent.png.length) +
|
|
2286
|
+
' 超过配额 ' + mb(S.kfQuota) + '(' + m.frames.length + ' 帧留存于 ' + kfRoot() + ')。' +
|
|
2287
|
+
'不会自动删除已升格帧——清理后重试,或调大配额(init kfQuotaBytes / MAAFW_KF_QUOTA_BYTES)',
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
const num = Number(m.next) || m.frames.length + 1
|
|
2292
|
+
const id = 'kf:' + m.libraryId + ':' + String(num).padStart(4, '0')
|
|
2293
|
+
const fileRel = 'l0/' + String(num).padStart(4, '0') + '.png'
|
|
2294
|
+
const abs = kfAbsPath(fileRel)
|
|
2295
|
+
try {
|
|
2296
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
2297
|
+
const tmp = abs + '.tmp'
|
|
2298
|
+
fs.writeFileSync(tmp, ent.png)
|
|
2299
|
+
fs.renameSync(tmp, abs)
|
|
2300
|
+
} catch (e) {
|
|
2301
|
+
return { ok: false, seq, error: 'L0 文件写入失败:' + String(e && e.message || e) }
|
|
2302
|
+
}
|
|
2303
|
+
/* 小图信息只取环内同 seq 帧;已淘汰则明确为空,不借用最新帧数据(契约 §4) */
|
|
2304
|
+
const small = S.ring.find((r) => r.seq === seq)
|
|
2305
|
+
const rec = {
|
|
2306
|
+
id, file: fileRel, sha256: sha,
|
|
2307
|
+
session: sessionRef,
|
|
2308
|
+
capturedAt: new Date(ent.t).toISOString(),
|
|
2309
|
+
captureSeq: seq,
|
|
2310
|
+
ctrlW: ent.w, ctrlH: ent.h,
|
|
2311
|
+
smallW: small ? small.w : null, smallH: small ? small.h : null,
|
|
2312
|
+
scale: small && ent.w ? Math.round((small.w / ent.w) * 1000) / 1000 : null,
|
|
2313
|
+
source: ent.source || 'explicit',
|
|
2314
|
+
note: note != null ? String(note) : null,
|
|
2315
|
+
}
|
|
2316
|
+
m.frames.push(rec)
|
|
2317
|
+
m.next = num + 1
|
|
2318
|
+
try { kfSave(m) } catch (e) {
|
|
2319
|
+
try { fs.rmSync(abs, { force: true }) } catch (e2) { /* ignore */ }
|
|
2320
|
+
return { ok: false, seq, error: 'manifest 写入失败(L0 文件已回收,未发布引用):' + String(e && e.message || e) }
|
|
2321
|
+
}
|
|
2322
|
+
return { ok: true, id, path: abs, sha256: sha, retained: 'promoted', record: rec, seq }
|
|
2323
|
+
} finally {
|
|
2324
|
+
kfLockRelease()
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
async function cmdKfPromote(args) {
|
|
2329
|
+
/* 批量:args.seqs 为 seq 数组(CLI 的 "3909 3910" 或 "a..b" 已解析好)。
|
|
2330
|
+
* 逐条走 cmdKfPromoteOne,各自的成功/淘汰分开报告,不因一条失败中断整批。 */
|
|
2331
|
+
if (Array.isArray(args.seqs) && args.seqs.length > 0) {
|
|
2332
|
+
const results = []
|
|
2333
|
+
for (const s of args.seqs) {
|
|
2334
|
+
results.push(await cmdKfPromoteOne(Number(s), args.note))
|
|
2335
|
+
}
|
|
2336
|
+
const ok = results.every((r) => r.ok)
|
|
2337
|
+
const promoted = results.filter((r) => r.ok)
|
|
2338
|
+
const failed = results.filter((r) => !r.ok)
|
|
2339
|
+
return { ok, results, promoted: promoted.length, failed: failed.length }
|
|
2340
|
+
}
|
|
2341
|
+
/* 固定捕获身份:latest 在接受请求时解析成具体 seq;已淘汰则失败,绝不改取更新帧冒充 */
|
|
2342
|
+
let seq = null
|
|
2343
|
+
if (args.seq !== undefined && args.seq !== null) {
|
|
2344
|
+
seq = Number(args.seq)
|
|
2345
|
+
if (!Number.isFinite(seq)) return { ok: false, error: 'seq 必须是数字' }
|
|
2346
|
+
} else if (args.latest === true) {
|
|
2347
|
+
seq = S.seq
|
|
2348
|
+
} else {
|
|
2349
|
+
return { ok: false, error: '给 seq(数字)、latest:true,或 seqs 数组(批量)指定要升格的捕获' }
|
|
2350
|
+
}
|
|
2351
|
+
return cmdKfPromoteOne(seq, args.note)
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function cmdL0Status() {
|
|
2355
|
+
const brief = (e) => ({ seq: e.seq, t: e.t, source: e.source ?? null, w: e.w, h: e.h, bytes: e.png ? e.png.length : 0 })
|
|
2356
|
+
/* 唯一字节:两区共享 Buffer,简单求和会双计 */
|
|
2357
|
+
const seen = new Set()
|
|
2358
|
+
let uniqueBytes = 0
|
|
2359
|
+
for (const e of S.l0.roll) if (!seen.has(e.seq)) { seen.add(e.seq); uniqueBytes += e.png ? e.png.length : 0 }
|
|
2360
|
+
for (const e of S.l0.anchor) if (!seen.has(e.seq)) { seen.add(e.seq); uniqueBytes += e.png ? e.png.length : 0 }
|
|
2361
|
+
const sum = (list, cap) => ({
|
|
2362
|
+
count: list.length, cap,
|
|
2363
|
+
bytes: list.reduce((a, e) => a + (e.png ? e.png.length : 0), 0),
|
|
2364
|
+
seqRange: list.length ? [list[0].seq, list[list.length - 1].seq] : null,
|
|
2365
|
+
})
|
|
2366
|
+
let m = null
|
|
2367
|
+
try { m = kfLoad() } catch (e) { m = null } /* 状态上报不容因库坏而失败 */
|
|
2368
|
+
const library = m
|
|
2369
|
+
? { frames: m.frames.length, bytes: kfUsageBytes(m), quota: S.kfQuota, dir: kfRoot() }
|
|
2370
|
+
: { frames: 0, bytes: 0, quota: S.kfQuota, dir: kfRoot(), error: 'manifest 不可读' }
|
|
2371
|
+
return {
|
|
2372
|
+
ok: true, seq: S.seq,
|
|
2373
|
+
roll: { ...sum(S.l0.roll, S.l0.rollCap), entries: S.l0.roll.map(brief) },
|
|
2374
|
+
anchor: { ...sum(S.l0.anchor, S.l0.anchorCap), entries: S.l0.anchor.map(brief) },
|
|
2375
|
+
bytes: { unique: uniqueBytes, cap: S.l0.bytesCap, evicted: S.l0.evicted },
|
|
2376
|
+
framesDir: kfRoot(),
|
|
2377
|
+
library,
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/* ────────────────────────── 模板裁剪(snap 收紧 + 自匹配精修) ──────────────────────────
|
|
2382
|
+
* 输入:宽松框(或点+外扩);源 = L0 原图(控制器分辨率——模板尺寸必须与识别空间一致,
|
|
2383
|
+
* 不能从降采样小图裁)。收紧 = 与边框背景差的行列占比;精修 = 逐边 ±2px 贪心重试,
|
|
2384
|
+
* 以"裁出的模板在同帧上的 TemplateMatch 得分"为目标函数(自匹配验证闭环)。 */
|
|
2385
|
+
/* opts.tol / opts.frac 是**测量用覆盖**(默认即生产常数):定标要能 A/B 才有数据,
|
|
2386
|
+
* 不给就用 90 / 0.12,行为与加参数前逐字节一致。 */
|
|
2387
|
+
function tightenBounds(rgb, w, h, opts = {}) {
|
|
2388
|
+
const ring = Math.max(2, Math.round(Math.min(w, h) / 12))
|
|
2389
|
+
let br = 0, bg = 0, bb = 0, bn = 0
|
|
2390
|
+
for (let y = 0; y < h; y++) {
|
|
2391
|
+
const edgeRow = y < ring || y >= h - ring
|
|
2392
|
+
for (let x = 0; x < w; x++) {
|
|
2393
|
+
if (!edgeRow && !(x < ring || x >= w - ring)) continue
|
|
2394
|
+
const i = (y * w + x) * 3
|
|
2395
|
+
br += rgb[i]; bg += rgb[i + 1]; bb += rgb[i + 2]; bn++
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
br /= bn; bg /= bn; bb /= bn
|
|
2399
|
+
const TOL = Number(opts.tol) > 0 ? Number(opts.tol) : 90 // 通道差之和超过此值视为"非背景"
|
|
2400
|
+
const rowHit = new Array(h).fill(0)
|
|
2401
|
+
const colHit = new Array(w).fill(0)
|
|
2402
|
+
for (let y = 0; y < h; y++) {
|
|
2403
|
+
for (let x = 0; x < w; x++) {
|
|
2404
|
+
const i = (y * w + x) * 3
|
|
2405
|
+
if (Math.abs(rgb[i] - br) + Math.abs(rgb[i + 1] - bg) + Math.abs(rgb[i + 2] - bb) > TOL) {
|
|
2406
|
+
rowHit[y]++; colHit[x]++
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
const FRAC = Number(opts.frac) > 0 ? Number(opts.frac) : 0.12
|
|
2411
|
+
let y0 = 0, y1 = h - 1
|
|
2412
|
+
while (y0 < y1 && rowHit[y0] / w < FRAC) y0++
|
|
2413
|
+
while (y1 > y0 && rowHit[y1] / w < FRAC) y1--
|
|
2414
|
+
let x0 = 0, x1 = w - 1
|
|
2415
|
+
while (x0 < x1 && colHit[x0] / h < FRAC) x0++
|
|
2416
|
+
while (x1 > x0 && colHit[x1] / h < FRAC) x1--
|
|
2417
|
+
if (y1 - y0 < 3 || x1 - x0 < 3) return null // 内容撑满或无内容:交回调用方处理
|
|
2418
|
+
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/** 局部对比度图:左右 / 上下邻域差之和(3 通道累加)。文字/图标的描边远高于平铺纹理。 */
|
|
2422
|
+
function edgeMap(rgb, w, h) {
|
|
2423
|
+
const e = new Int32Array(w * h)
|
|
2424
|
+
for (let y = 1; y < h - 1; y++) {
|
|
2425
|
+
for (let x = 1; x < w - 1; x++) {
|
|
2426
|
+
const i = (y * w + x) * 3
|
|
2427
|
+
const l = i - 3, r = i + 3, u = i - w * 3, d = i + w * 3
|
|
2428
|
+
e[y * w + x] =
|
|
2429
|
+
Math.abs(rgb[r] - rgb[l]) + Math.abs(rgb[r + 1] - rgb[l + 1]) + Math.abs(rgb[r + 2] - rgb[l + 2]) +
|
|
2430
|
+
Math.abs(rgb[d] - rgb[u]) + Math.abs(rgb[d + 1] - rgb[u + 1]) + Math.abs(rgb[d + 2] - rgb[u + 2])
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
return e
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* 边缘/局部对比度收紧(roadmap 修法①):`tightenBounds` 量的是"与背景色的距离",而这类游戏 UI 的
|
|
2438
|
+
* **背景自带纹理**——整片纹理都被算成非背景,行/列命中率全满,于是剪不动、留白留下(实测踩过:
|
|
2439
|
+
* `signin_entry` 右半张是背景,自匹配照样报"位置正确 1.0")。
|
|
2440
|
+
*
|
|
2441
|
+
* 这一路量**局部对比度**,判据是自适应阈值:"边框环自身的对比度水平(p90)× z" 与绝对地板 `edgeMin`
|
|
2442
|
+
* 取大者——环就是背景,所以阈值跟着背景纹理走;元素(文字/图标/描边)的对比度通常高出一截。
|
|
2443
|
+
* 行/列的命中比例低于 `edgeFrac` 即判为背景并剪掉。返回 null 表示"没收到东西"(撑满或无内容)。
|
|
2444
|
+
*
|
|
2445
|
+
* 与背景差分**取更紧者**进候选池(不是硬交集):两路都只剪不放,更紧的那个仍是内容的子集;
|
|
2446
|
+
* 而"过紧会不会把元素裁掉"交给下游既有的自匹配判据(位置正确优先于得分)去裁决。
|
|
2447
|
+
*/
|
|
2448
|
+
function tightenBoundsByEdges(rgb, w, h, opts = {}) {
|
|
2449
|
+
const Z = Number(opts.edgeZ) > 0 ? Number(opts.edgeZ) : 2
|
|
2450
|
+
const MIN = Number(opts.edgeMin) > 0 ? Number(opts.edgeMin) : 24
|
|
2451
|
+
const FRAC = Number(opts.edgeFrac) > 0 ? Number(opts.edgeFrac) : 0.02
|
|
2452
|
+
const ring = Math.max(2, Math.round(Math.min(w, h) / 12))
|
|
2453
|
+
const e = edgeMap(rgb, w, h)
|
|
2454
|
+
/* 环上的对比度水平:p90(不是均值——有孤立强边时均值会被拖低,p90 更能代表"这片背景的纹理有多花") */
|
|
2455
|
+
const ringVals = []
|
|
2456
|
+
for (let y = 0; y < h; y++) {
|
|
2457
|
+
const edgeRow = y < ring || y >= h - ring
|
|
2458
|
+
for (let x = 0; x < w; x++) {
|
|
2459
|
+
if (!edgeRow && !(x < ring || x >= w - ring)) continue
|
|
2460
|
+
ringVals.push(e[y * w + x])
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
ringVals.sort((a, b) => a - b)
|
|
2464
|
+
const bgLevel = ringVals.length ? ringVals[Math.min(ringVals.length - 1, Math.floor(ringVals.length * 0.9))] : 0
|
|
2465
|
+
const thresh = Math.max(MIN, bgLevel * Z)
|
|
2466
|
+
const rowHit = new Array(h).fill(0)
|
|
2467
|
+
const colHit = new Array(w).fill(0)
|
|
2468
|
+
for (let y = 1; y < h - 1; y++) {
|
|
2469
|
+
for (let x = 1; x < w - 1; x++) {
|
|
2470
|
+
if (e[y * w + x] > thresh) { rowHit[y]++; colHit[x]++ }
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
let y0 = 0, y1 = h - 1
|
|
2474
|
+
while (y0 < y1 && rowHit[y0] / w < FRAC) y0++
|
|
2475
|
+
while (y1 > y0 && rowHit[y1] / w < FRAC) y1--
|
|
2476
|
+
let x0 = 0, x1 = w - 1
|
|
2477
|
+
while (x0 < x1 && colHit[x0] / h < FRAC) x0++
|
|
2478
|
+
while (x1 > x0 && colHit[x1] / h < FRAC) x1--
|
|
2479
|
+
if (y1 - y0 < 3 || x1 - x0 < 3) return null
|
|
2480
|
+
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, bgLevel, thresh }
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function cropRgb(rgb, sw, sh, box) {
|
|
2484
|
+
const w = Math.min(box.w, sw - box.x), h = Math.min(box.h, sh - box.y)
|
|
2485
|
+
const out = Buffer.alloc(w * h * 3)
|
|
2486
|
+
for (let y = 0; y < h; y++) {
|
|
2487
|
+
rgb.copy(out, y * w * 3, ((box.y + y) * sw + box.x) * 3, ((box.y + y) * sw + box.x + w) * 3)
|
|
2488
|
+
}
|
|
2489
|
+
return { w, h, data: out }
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/**
|
|
2493
|
+
* 库帧裁剪源:读关键帧库内的 L0 文件并校验身份。纯文件操作,不碰 daemon 状态——
|
|
2494
|
+
* 这正是"不可复现状态"的场景:捕获它的那次会话早已结束,设备也不在那个画面上。
|
|
2495
|
+
*
|
|
2496
|
+
* 校验三层,任一不符即拒绝(不许把"另一个字节"当成本次裁剪的依据):
|
|
2497
|
+
* 1. 文件在(调用方已按契约 §4.1 解析过完整 ID,这里是同一份字节的第二次确认);
|
|
2498
|
+
* 2. sha256 与库记录一致——防解析与读取之间文件被替换;
|
|
2499
|
+
* 3. 像素尺寸与库记录 ctrlW/ctrlH 一致——记录与像素不同源即库本身不一致。
|
|
2500
|
+
*/
|
|
2501
|
+
function readKfCropSource(src) {
|
|
2502
|
+
if (!src || typeof src !== 'object') return { error: 'kfSource 必须是对象' }
|
|
2503
|
+
const id = String(src.id ?? '')
|
|
2504
|
+
const file = String(src.path ?? '')
|
|
2505
|
+
if (!id.startsWith('kf:') || !file) return { error: 'kfSource 需要 id(kf:<库UUID>:<序号>)与 path' }
|
|
2506
|
+
let png = null
|
|
2507
|
+
try { png = fs.readFileSync(file) } catch (e) { return { error: '库帧读取失败:' + String(e && e.message || e) } }
|
|
2508
|
+
const sha = createHash('sha256').update(png).digest('hex')
|
|
2509
|
+
if (src.sha256 && String(src.sha256) !== sha) {
|
|
2510
|
+
return { error: '库帧内容与库记录不符(sha256 不匹配):解析后文件被改动,拒绝裁剪' }
|
|
2511
|
+
}
|
|
2512
|
+
let dec = null
|
|
2513
|
+
try { dec = pngDecode(png) } catch (e) { return { error: '库帧不是可解码的 PNG:' + String(e && e.message || e) } }
|
|
2514
|
+
const recW = Number(src.ctrlW), recH = Number(src.ctrlH)
|
|
2515
|
+
if (recW > 0 && recH > 0 && (recW !== dec.w || recH !== dec.h)) {
|
|
2516
|
+
return {
|
|
2517
|
+
error: '库记录尺寸 ' + recW + 'x' + recH + ' 与像素实际 ' + dec.w + 'x' + dec.h +
|
|
2518
|
+
' 不一致:库记录与文件不同源,拒绝裁剪',
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
return {
|
|
2522
|
+
png, dec, sha, path: file,
|
|
2523
|
+
source: {
|
|
2524
|
+
kind: 'kf', id, sha256: sha, ctrlW: dec.w, ctrlH: dec.h,
|
|
2525
|
+
captureSeq: Number.isFinite(Number(src.captureSeq)) ? Number(src.captureSeq) : null,
|
|
2526
|
+
capturedAt: src.capturedAt ? String(src.capturedAt) : null,
|
|
2527
|
+
},
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* L2 派生图的出处记录(契约 §5:L2 记录来源及裁剪 / 缩放变换)。
|
|
2533
|
+
*
|
|
2534
|
+
* 只记来源身份与变换,不记设备地址 / 目标 / daemon 身份 / 本地绝对路径——L2 是提交进仓库的
|
|
2535
|
+
* 交付物,库帧本身(含设备来源)留在本地(契约 §4.1 隐私)。纯函数:时间由调用方传入,可断言。
|
|
2536
|
+
*/
|
|
2537
|
+
function buildL2Provenance(o) {
|
|
2538
|
+
/* 来源字段白名单:即便调用方把整条库记录(含 session/target)递进来,也不落进 L2 出处。
|
|
2539
|
+
* 库记录里的设备来源留在本地 manifest,是这两份文件的边界(契约 §4.1)。 */
|
|
2540
|
+
const s = o.source || {}
|
|
2541
|
+
const derivedFrom = s.kind === 'kf'
|
|
2542
|
+
? {
|
|
2543
|
+
kind: 'kf', id: s.id, sha256: s.sha256, ctrlW: s.ctrlW, ctrlH: s.ctrlH,
|
|
2544
|
+
captureSeq: s.captureSeq ?? null, capturedAt: s.capturedAt ?? null,
|
|
2545
|
+
}
|
|
2546
|
+
: {
|
|
2547
|
+
kind: 'l0-cache', seq: s.seq ?? null, sha256: s.sha256,
|
|
2548
|
+
ctrlW: s.ctrlW, ctrlH: s.ctrlH, capturedAt: s.capturedAt ?? null,
|
|
2549
|
+
}
|
|
2550
|
+
return {
|
|
2551
|
+
schema: 1,
|
|
2552
|
+
level: 'L2',
|
|
2553
|
+
derivedFrom,
|
|
2554
|
+
transform: { op: 'crop', resize: false, scale: 1, loose: o.loose, crop: o.box, snapped: o.snapped },
|
|
2555
|
+
/* annotate --emit-crops 裁的是 SoM 候选,不走自匹配验证——selfMatch 如实为 null,
|
|
2556
|
+
* 不伪造"没测过的匹配度"(契约 §5 的变换与来源照样全)。 */
|
|
2557
|
+
selfMatch: o.selfMatch === null ? null : { score: o.score, positionOk: o.positionOk, box: o.selfMatchBox ?? null, tries: o.tries },
|
|
2558
|
+
cross: o.cross ?? null,
|
|
2559
|
+
output: { w: o.w, h: o.h },
|
|
2560
|
+
createdAt: o.createdAt,
|
|
2561
|
+
tool: o.tool ?? 'maafw-live crop',
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** 缺省落点 out/ 的保留上限(只进不出的产物,不让它无声膨胀) */
|
|
2566
|
+
const OUT_KEEP = 50
|
|
2567
|
+
/** out/ 目录保留修剪:按 mtime 留最新 OUT_KEEP 个 .png。与 client 侧 src/runtime/prune.ts 同逻辑的内联
|
|
2568
|
+
* 副本——framed.mjs 自包含、不能 import lib。失败不抛:清理从来不是任务结论。 */
|
|
2569
|
+
function pruneOutDir(dir) {
|
|
2570
|
+
try {
|
|
2571
|
+
const names = fs.readdirSync(dir)
|
|
2572
|
+
.filter((n) => n.endsWith('.png'))
|
|
2573
|
+
.map((n) => ({ n, t: fs.statSync(path.join(dir, n)).mtimeMs }))
|
|
2574
|
+
.sort((a, b) => b.t - a.t)
|
|
2575
|
+
for (const f of names.slice(OUT_KEEP)) {
|
|
2576
|
+
try { fs.unlinkSync(path.join(dir, f.n)) } catch { /* 单个删不掉就留 */ }
|
|
2577
|
+
}
|
|
2578
|
+
} catch { /* 目录不存在/读不了:没有要清的 */ }
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/** 缺省落点:daemon 的 run 目录下 `out/`(缺省 runDir 是 `~/.maafw-live`,不是临时目录)——
|
|
2582
|
+
* **不写调用方的 cwd**。调用方的 cwd 通常就是项目仓库:一次 `frame_get` / `annotate` 就把产物丢进仓库。
|
|
2583
|
+
* 实测踩过两次(`maa_frame_*.png` 十张进了 maafw-live 仓、`maa_som_*.png` 进了同一个根目录),
|
|
2584
|
+
* 而这几个命令本质是"看/量",产物只是顺便落盘——落哪儿由 `out` 说话,不落调用方家里。 */
|
|
2585
|
+
function defaultOutFile(runDir, prefix, ts = Date.now()) {
|
|
2586
|
+
const dir = path.join(runDir || os.tmpdir(), 'out')
|
|
2587
|
+
pruneOutDir(dir) /* 只在缺省落点修剪——显式 out 是用户指定的位置,不动它 */
|
|
2588
|
+
return { dir, file: path.join(dir, prefix + '-' + ts + '.png') }
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
async function cmdTplCrop(args) {
|
|
2592
|
+
/* 源帧两条路,都必须是控制器分辨率的完整原图(模板尺寸要与识别空间一致,绝不从降采样小图裁):
|
|
2593
|
+
* - L0 热缓存:seq 或最新(同一会话内,有设备);
|
|
2594
|
+
* - 关键帧库留存帧:kfSource(跨会话、可无设备——契约 §2 库内 L0 即原图本体)。 */
|
|
2595
|
+
|
|
2596
|
+
/* 宽松框:显式 roi,或点 + 外扩(点→ROI 派生与裁剪共享同一条 snap 链)。
|
|
2597
|
+
* 校验放在最前:坏参数(既没 roi 也没 point)不该有任何副作用——尤其不该把源帧升格进库。 */
|
|
2598
|
+
let loose = null
|
|
2599
|
+
if (Array.isArray(args.roi) && args.roi.length === 4) {
|
|
2600
|
+
loose = args.roi.map(Number)
|
|
2601
|
+
} else if (Array.isArray(args.point) && args.point.length === 2) {
|
|
2602
|
+
const pad = Math.max(8, Number(args.pad ?? 24))
|
|
2603
|
+
const [px, py] = args.point.map(Number)
|
|
2604
|
+
loose = [px - pad, py - pad, pad * 2, pad * 2]
|
|
2605
|
+
} else {
|
|
2606
|
+
return { ok: false, error: '给 --roi x,y,w,h 或 --point x,y [--pad n]' }
|
|
2607
|
+
}
|
|
2608
|
+
/* 非数字入参要在算框之前拦掉:NaN 会一路传成 NaN 框,最后报一个跟病因无关的失败。
|
|
2609
|
+
* 线上真会遇到的形态是 `[0,0,"x",10]` 这类(JSON 里 NaN 变 null,Number(null) = 0 是合法数字)。 */
|
|
2610
|
+
if (!loose.every((v) => Number.isFinite(v))) {
|
|
2611
|
+
return { ok: false, error: 'roi / point / pad 必须是数字:' + JSON.stringify(loose) }
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
let ent = null
|
|
2615
|
+
let kf = null
|
|
2616
|
+
if (args.kfSource) {
|
|
2617
|
+
kf = readKfCropSource(args.kfSource)
|
|
2618
|
+
if (kf.error) return { ok: false, error: kf.error }
|
|
2619
|
+
} else {
|
|
2620
|
+
ent = args.seq !== undefined && args.seq !== null
|
|
2621
|
+
? (S.l0.anchor.find((x) => x.seq === Number(args.seq)) ?? S.l0.roll.find((x) => x.seq === Number(args.seq)))
|
|
2622
|
+
: (S.l0.anchor[S.l0.anchor.length - 1] ?? S.l0.roll[S.l0.roll.length - 1] ?? null)
|
|
2623
|
+
if (!ent) {
|
|
2624
|
+
return { ok: false, error: 'L0 无可用原图:先 screencap / stream / 输入产生观测(seq=' + args.seq + ')' }
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
const png = kf ? kf.png : ent.png
|
|
2628
|
+
const dec = pngDecode(png)
|
|
2629
|
+
/* 统一成 RGB:cropRgb 按 3 通道步进,RGBA 帧不归一化会静默错剪 */
|
|
2630
|
+
const rgb = toRgb(dec)
|
|
2631
|
+
const source = kf ? kf.source : {
|
|
2632
|
+
kind: 'l0-cache', seq: ent.seq, sha256: createHash('sha256').update(png).digest('hex'),
|
|
2633
|
+
ctrlW: dec.w, ctrlH: dec.h, capturedAt: new Date(ent.t).toISOString(),
|
|
2634
|
+
}
|
|
2635
|
+
/* 「源帧也留下」(--keep-source,只对热缓存路径有意义):
|
|
2636
|
+
* 契约的模型是 **L0 缓存 → 选择性升格 → 从库帧裁 L2**,但少走中间那一步时,模板的出处只剩一个
|
|
2637
|
+
* 随进程消失的 seq(实测踩过:先做的五张模板出处全是 l0-cache,sha256 之外无从复核)。
|
|
2638
|
+
* **必须在裁剪计算之前做**:自匹配 + 跨帧验证要 1–2 秒,滚动区小的时候(实测 l0Roll=2、流 5fps)
|
|
2639
|
+
* 那一帧早就被淘汰了——曾经把升格放在末尾,验收里直接失败。按 ent.seq 升格本次用的那一帧,不取最新帧。 */
|
|
2640
|
+
let keptSource = null
|
|
2641
|
+
if (!kf && args.keepSource === true) {
|
|
2642
|
+
const promoted = await cmdKfPromote({ seq: ent.seq })
|
|
2643
|
+
if (promoted && promoted.ok && promoted.id) {
|
|
2644
|
+
keptSource = { id: promoted.id, path: promoted.path ?? null, seq: ent.seq }
|
|
2645
|
+
source.kind = 'kf'
|
|
2646
|
+
source.id = promoted.id
|
|
2647
|
+
source.captureSeq = ent.seq
|
|
2648
|
+
} else {
|
|
2649
|
+
keptSource = { error: String((promoted && promoted.error) || '升格失败'), seq: ent.seq }
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
const cl = (v, lo, hi) => Math.min(hi, Math.max(lo, Math.round(v)))
|
|
2653
|
+
const box0 = {
|
|
2654
|
+
x: cl(loose[0], 0, dec.w - 8),
|
|
2655
|
+
y: cl(loose[1], 0, dec.h - 8),
|
|
2656
|
+
w: 0, h: 0,
|
|
2657
|
+
}
|
|
2658
|
+
box0.w = cl(loose[2], 8, dec.w - box0.x)
|
|
2659
|
+
box0.h = cl(loose[3], 8, dec.h - box0.y)
|
|
2660
|
+
|
|
2661
|
+
const region = cropRgb(rgb, dec.w, dec.h, box0)
|
|
2662
|
+
/* 两路收紧:背景差分(与背景色的距离)是**生产默认**;边缘(局部对比度)是**测量用**、默认关
|
|
2663
|
+
* (见下方 tightEdge 的说明与定标结论)。两路都进候选池,更紧的排前面(与自匹配 objective 配合:
|
|
2664
|
+
* 都位置正确、都满分时保留先入者 = 更贴合的框;过紧的把元素裁掉了则 objective 会输给宽松档)。 */
|
|
2665
|
+
const tightBg = tightenBounds(region.data, region.w, region.h, { tol: args.snapTol, frac: args.snapFrac })
|
|
2666
|
+
/* 边缘那一路**默认关**:定标结论见 docs/daemon-protocol.md 的 tpl_crop 行——在现有真值集
|
|
2667
|
+
* (scripts/truth/1999-720p.json,12 例)上它单独用 IoU 均值只有 0.50–0.59,而背景差分是 0.804:
|
|
2668
|
+
* 边缘判据找的是**元素内部结构**(文字笔画、图标细节),不是元素的范围,所以"取更紧者"会把
|
|
2669
|
+
* 框收进元素内部(实测 0005 物品卡片:0.866 → 0.449)。要试就显式给 `edge:true`(survey:crop --edge)。 */
|
|
2670
|
+
const tightEdge = args.edge === true
|
|
2671
|
+
? tightenBoundsByEdges(region.data, region.w, region.h, {
|
|
2672
|
+
edgeZ: args.edgeZ, edgeMin: args.edgeMin, edgeFrac: args.edgeFrac,
|
|
2673
|
+
})
|
|
2674
|
+
: null
|
|
2675
|
+
const snapped = !!(tightBg || tightEdge)
|
|
2676
|
+
|
|
2677
|
+
/* 自匹配闭环:裁出的模板在同帧上匹配,逐边 ±2px 贪心取得分最高边界。
|
|
2678
|
+
* 判据两层:位置正确(best 落回裁剪处)优先于得分——大面积纯色的模板在
|
|
2679
|
+
* CCOEFF_NORMED 下得分为噪声(实测同帧自匹配 best 落到别处、0.736 高于真实位置),
|
|
2680
|
+
* "找不到自己"比"分数低"更能说明模板不独特。 */
|
|
2681
|
+
const resourceDir = String(args.resourceDir || '')
|
|
2682
|
+
/* 自匹配的输入帧:库帧直接用库内文件(只读,不复制、不改写 L0);热缓存帧落一份临时副本 */
|
|
2683
|
+
const frameFile = kf ? kf.path : path.join(os.tmpdir(), 'maa_tpl_frame_' + Date.now() + '.png')
|
|
2684
|
+
const candFile = path.join(os.tmpdir(), 'maa_tpl_cand_' + Date.now() + '.png')
|
|
2685
|
+
if (!kf) fs.writeFileSync(frameFile, ent.png)
|
|
2686
|
+
const writeCand = (b) => {
|
|
2687
|
+
const c = cropRgb(rgb, dec.w, dec.h, b)
|
|
2688
|
+
fs.writeFileSync(candFile, pngEncodeRGB(c.data, c.w, c.h))
|
|
2689
|
+
return c
|
|
2690
|
+
}
|
|
2691
|
+
const overlapOk = (a, b) => {
|
|
2692
|
+
const ix = Math.max(0, Math.min(a[0] + a[2], b[0] + b[2]) - Math.max(a[0], b[0]))
|
|
2693
|
+
const iy = Math.max(0, Math.min(a[1] + a[3], b[1] + b[3]) - Math.max(a[1], b[1]))
|
|
2694
|
+
const inter = ix * iy
|
|
2695
|
+
const uni = a[2] * a[3] + b[2] * b[3] - inter
|
|
2696
|
+
return uni > 0 && inter / uni > 0.75
|
|
2697
|
+
}
|
|
2698
|
+
const score = async (b) => {
|
|
2699
|
+
if (b.w < 8 || b.h < 8) return { score: -1, posOk: false, at: null }
|
|
2700
|
+
writeCand(b)
|
|
2701
|
+
const r = await spawnRecoChild({
|
|
2702
|
+
resourceDir, type: 'TemplateMatch', image: frameFile, templateImage: candFile, cases: [{}],
|
|
2703
|
+
}, 60000)
|
|
2704
|
+
if (!r || !r.ok) {
|
|
2705
|
+
/* 资源链加载不出来(reco 把这条明确报成 `RESOURCE_LOAD`)与"识别跑了但没命中"是两回事:
|
|
2706
|
+
* 前者是资源问题、处置是去修资源,后者才是去换框。别让它落进"低纹理/不独特"。 */
|
|
2707
|
+
if (r && (r.code === 'RESOURCE_LOAD' || /资源链加载失败/.test(String(r.error ?? '')))) {
|
|
2708
|
+
selfMatchDead = selfMatchDead ?? ('资源链加载不出来——' + String(r.error ?? '').trim())
|
|
2709
|
+
}
|
|
2710
|
+
return { score: -1, posOk: false, at: null }
|
|
2711
|
+
}
|
|
2712
|
+
/* 子进程回 ok 但**一个 case 结果都没有**:识别压根没跑起来(实测最常见的原因是资源目录里没有可加载的
|
|
2713
|
+
* pipeline,reco 会安静地回 `results: []`)。这与"识别跑了但没命中"是两回事——后者仍会有 1 条结果
|
|
2714
|
+
* (hit:false)。记下来,别让它被下面的判据翻译成"低纹理/不独特"。 */
|
|
2715
|
+
if (!Array.isArray(r.results) || r.results.length === 0) {
|
|
2716
|
+
selfMatchDead = selfMatchDead ?? '识别子进程一个结果都没回'
|
|
2717
|
+
return { score: -1, posOk: false, at: null, noResults: true }
|
|
2718
|
+
}
|
|
2719
|
+
const det = r.results && r.results[0] ? r.results[0].detail : null
|
|
2720
|
+
const best = det && det.detail && det.detail.best
|
|
2721
|
+
if (!best || !Array.isArray(best.box)) return { score: 0, posOk: false, at: null }
|
|
2722
|
+
const at = best.box.map(Number)
|
|
2723
|
+
return { score: Number(best.score), posOk: overlapOk(at, [b.x, b.y, b.w, b.h]), at }
|
|
2724
|
+
}
|
|
2725
|
+
const obj = (s) => (s.posOk ? 1000 : 0) + s.score
|
|
2726
|
+
/** 自匹配有没有跑起来:null = 跑起来了;字符串 = 没跑起来的具体原因(子进程回空结果 / 资源链加载不出来)。
|
|
2727
|
+
* 跑到这里还能不跑起来,说明资源那边加载不出识别节点。 */
|
|
2728
|
+
let selfMatchDead = null
|
|
2729
|
+
const clampBox = (b) => ({
|
|
2730
|
+
x: Math.max(0, b.x), y: Math.max(0, b.y),
|
|
2731
|
+
w: Math.min(b.w, dec.w - Math.max(0, b.x)), h: Math.min(b.h, dec.h - Math.max(0, b.y)),
|
|
2732
|
+
})
|
|
2733
|
+
/* 候选池:snap 可能塌成细条(背景差分在混合内容上会选中稀疏行带),单靠它起步会让
|
|
2734
|
+
* ±2px 精修困死在退化框里——**两路**收紧框、加边距、外扩档、原始宽松框都进池,位置正确者优先。
|
|
2735
|
+
* 更紧的一路排前面:`obj` 相同时保留先入者,于是"同样位置正确、同样满分"时选更贴合的那个。 */
|
|
2736
|
+
const cands = []
|
|
2737
|
+
const pushFamily = (t, src) => {
|
|
2738
|
+
if (!t) return
|
|
2739
|
+
const box = { x: box0.x + t.x, y: box0.y + t.y, w: t.w, h: t.h }
|
|
2740
|
+
cands.push({ ...box, src })
|
|
2741
|
+
cands.push({ ...clampBox({ x: box.x - 4, y: box.y - 4, w: box.w + 8, h: box.h + 8 }), src: src + '+4' })
|
|
2742
|
+
for (const f of [0.25, 0.5]) {
|
|
2743
|
+
cands.push({
|
|
2744
|
+
...clampBox({
|
|
2745
|
+
x: Math.round(box.x - box.w * f / 2), y: Math.round(box.y - box.h * f / 2),
|
|
2746
|
+
w: Math.round(box.w * (1 + f)), h: Math.round(box.h * (1 + f)),
|
|
2747
|
+
}),
|
|
2748
|
+
src: src + '+' + Math.round(f * 100) + '%',
|
|
2749
|
+
})
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
for (const f of [{ t: tightBg, src: 'bg' }, { t: tightEdge, src: 'edge' }]
|
|
2753
|
+
.filter((x) => x.t)
|
|
2754
|
+
.sort((a, b) => a.t.w * a.t.h - b.t.w * b.t.h)) pushFamily(f.t, f.src)
|
|
2755
|
+
/* 池里最后放原始宽松框:**只在没被提前命中跳过时**它才会被评过分,有就如实带上。 */
|
|
2756
|
+
const looseCand = { ...box0, src: 'loose' }
|
|
2757
|
+
cands.push(looseCand)
|
|
2758
|
+
let cand = cands[0]
|
|
2759
|
+
let candSrc = cand.src
|
|
2760
|
+
let best = { score: -1, posOk: false, at: null }
|
|
2761
|
+
let bestObj = -Infinity
|
|
2762
|
+
let looseScored = null
|
|
2763
|
+
const tries = { n: 0 }
|
|
2764
|
+
for (const c of cands) {
|
|
2765
|
+
const s = await score(c)
|
|
2766
|
+
if (c === looseCand) looseScored = s
|
|
2767
|
+
tries.n++
|
|
2768
|
+
if (obj(s) > bestObj) { cand = c; best = s; bestObj = obj(s); candSrc = c.src }
|
|
2769
|
+
if (best.posOk && best.score >= 0.99) break
|
|
2770
|
+
}
|
|
2771
|
+
/* 逐边移动(每边独立尝试内收/外扩 2px,取更优) */
|
|
2772
|
+
const moves = [
|
|
2773
|
+
{ k: 'left', apply: (b) => clampBox({ x: b.x - 2, y: b.y, w: b.w + 2, h: b.h }) },
|
|
2774
|
+
{ k: 'right', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w + 2, h: b.h }) },
|
|
2775
|
+
{ k: 'top', apply: (b) => clampBox({ x: b.x, y: b.y - 2, w: b.w, h: b.h + 2 }) },
|
|
2776
|
+
{ k: 'bottom', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w, h: b.h + 2 }) },
|
|
2777
|
+
{ k: 'left-in', apply: (b) => clampBox({ x: b.x + 2, y: b.y, w: b.w - 2, h: b.h }) },
|
|
2778
|
+
{ k: 'right-in', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w - 2, h: b.h }) },
|
|
2779
|
+
{ k: 'top-in', apply: (b) => clampBox({ x: b.x, y: b.y + 2, w: b.w, h: b.h - 2 }) },
|
|
2780
|
+
{ k: 'bottom-in', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w, h: b.h - 2 }) },
|
|
2781
|
+
]
|
|
2782
|
+
for (let round = 0; round < 2; round++) {
|
|
2783
|
+
let improved = false
|
|
2784
|
+
for (const mv of moves) {
|
|
2785
|
+
const nb = mv.apply(cand)
|
|
2786
|
+
if (nb.w < 8 || nb.h < 8 || (nb.x + nb.w > dec.w) || (nb.y + nb.h > dec.h)) continue
|
|
2787
|
+
if (nb.x === cand.x && nb.y === cand.y && nb.w === cand.w && nb.h === cand.h) continue
|
|
2788
|
+
const s = await score(nb)
|
|
2789
|
+
tries.n++
|
|
2790
|
+
if (obj(s) > bestObj + 1e-3) { cand = nb; best = s; bestObj = obj(s); improved = true; candSrc = 'refine' }
|
|
2791
|
+
}
|
|
2792
|
+
if (!improved) break
|
|
2793
|
+
}
|
|
2794
|
+
/* 贴合度信号(这一条是**免费**的:两个框都是现成的)。
|
|
2795
|
+
* 为什么不是"两个框各自的得分对比":实测同一元素在 40×61(元素本体)/ 80×80(点选窗口)/
|
|
2796
|
+
* 120×120(更大窗口)三种情况下自匹配**都是 1.0** 且都落回自己——像素完全相同,
|
|
2797
|
+
* 自匹配本质上是自洽指标,分辨不出"框不贴合"。几何比例才分辨得出。
|
|
2798
|
+
* `grew` = 最后选中的候选比宽松框还大:背景差分在这块内容上没收到元素边界(背景自带纹理时
|
|
2799
|
+
* 实测常见),或收紧塌成细条后带边距的候选赢了。留白留下的后果是 Click 的随机落点可能落在元素外。 */
|
|
2800
|
+
const looseArea = box0.w * box0.h
|
|
2801
|
+
const boxArea = cand.w * cand.h
|
|
2802
|
+
const areaRatio = looseArea > 0 ? Math.round((boxArea / looseArea) * 1000) / 1000 : null
|
|
2803
|
+
const tighten = {
|
|
2804
|
+
from: [box0.w, box0.h],
|
|
2805
|
+
to: [cand.w, cand.h],
|
|
2806
|
+
areaRatio,
|
|
2807
|
+
grew: boxArea > looseArea,
|
|
2808
|
+
/* 两路收紧各自给出的框(含自适应阈值):定标时看"谁赢、差多少" */
|
|
2809
|
+
bgBox: tightBg ? [tightBg.w, tightBg.h] : null,
|
|
2810
|
+
edgeBox: tightEdge ? [tightEdge.w, tightEdge.h] : null,
|
|
2811
|
+
...(tightEdge ? { edgeThresh: tightEdge.thresh, edgeBgLevel: tightEdge.bgLevel } : {}),
|
|
2812
|
+
/** 最终框来自哪一族:bg / edge / bg+4 / edge+25% / loose / refine */
|
|
2813
|
+
winner: candSrc ?? null,
|
|
2814
|
+
/* 宽松框自己的自匹配结果:有就带上(多数情况下被池内提前命中跳过,为 null 是正常的) */
|
|
2815
|
+
...(looseScored ? { looseMatch: { score: Math.round(looseScored.score * 1000) / 1000, positionOk: looseScored.posOk } } : {}),
|
|
2816
|
+
}
|
|
2817
|
+
const fitWarn = tighten.grew
|
|
2818
|
+
? 'snap 收紧反而放大:' + box0.w + 'x' + box0.h + ' → ' + cand.w + 'x' + cand.h + '(面积 ' + areaRatio + '×);' +
|
|
2819
|
+
'自匹配照样 1.0(它只证明模板与源帧一致,不证明框贴合)。留白仍在——Click 的随机落点可能落在元素外,' +
|
|
2820
|
+
'换更贴合的框或把 target 收窄'
|
|
2821
|
+
: (areaRatio !== null && areaRatio < 0.35
|
|
2822
|
+
? 'snap 把框收到了 ' + areaRatio + '×(' + box0.w + 'x' + box0.h + ' → ' + cand.w + 'x' + cand.h +
|
|
2823
|
+
'):属"塌陷"(定标数据里它位置通常仍正确),确认没把元素本身裁掉'
|
|
2824
|
+
: null)
|
|
2825
|
+
/* 跨帧验证:同帧自匹配 1.0 只证明模板与源帧一致;真正要的是在新帧上仍稳定。
|
|
2826
|
+
* 连接着真机就再抓一帧验证,得分大幅衰减或位置漂移 → 模板跨帧不稳,如实警告。
|
|
2827
|
+
* 库帧路径默认不做:留存帧对应的状态通常已不在画面上,拿当前帧比会得到假警告;
|
|
2828
|
+
* 状态可复现时显式给 cross:true 才做。 */
|
|
2829
|
+
let cross = null
|
|
2830
|
+
const wantCross = kf ? args.cross === true : args.cross !== false
|
|
2831
|
+
if (S.ctrl && wantCross) {
|
|
2832
|
+
const fresh = await captureNow('tpl-verify')
|
|
2833
|
+
if (fresh.ok) {
|
|
2834
|
+
writeCand(cand)
|
|
2835
|
+
const frame2 = path.join(os.tmpdir(), 'maa_tpl_frame2_' + Date.now() + '.png')
|
|
2836
|
+
try {
|
|
2837
|
+
fs.writeFileSync(frame2, S.l0.roll[S.l0.roll.length - 1].png)
|
|
2838
|
+
const r2 = await spawnRecoChild({
|
|
2839
|
+
resourceDir, type: 'TemplateMatch', image: frame2, templateImage: candFile, cases: [{}],
|
|
2840
|
+
}, 60000)
|
|
2841
|
+
const det = r2 && r2.ok && r2.results && r2.results[0] ? r2.results[0].detail : null
|
|
2842
|
+
const b2 = det && det.detail && det.detail.best
|
|
2843
|
+
cross = {
|
|
2844
|
+
seq: fresh.seq,
|
|
2845
|
+
score: b2 ? Math.round(Number(b2.score) * 1000) / 1000 : 0,
|
|
2846
|
+
posOk: b2 && Array.isArray(b2.box) ? overlapOk(b2.box.map(Number), [cand.x, cand.y, cand.w, cand.h]) : false,
|
|
2847
|
+
...(b2 && Array.isArray(b2.box) ? { box: b2.box.map(Number) } : {}),
|
|
2848
|
+
}
|
|
2849
|
+
} finally {
|
|
2850
|
+
try { fs.rmSync(frame2, { force: true }) } catch (e) { /* ignore */ }
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
try { if (!kf) fs.rmSync(frameFile, { force: true }); fs.rmSync(candFile, { force: true }) } catch (e) { /* ignore */ }
|
|
2855
|
+
|
|
2856
|
+
const final = cropRgb(rgb, dec.w, dec.h, cand)
|
|
2857
|
+
const out = args.out || defaultOutFile(S.runDir, 'tpl').file
|
|
2858
|
+
fs.mkdirSync(path.dirname(out), { recursive: true })
|
|
2859
|
+
fs.writeFileSync(out, pngEncodeRGB(final.data, final.w, final.h))
|
|
2860
|
+
const warn = selfMatchDead !== null
|
|
2861
|
+
? '自匹配没跑起来:' + selfMatchDead + '(实测最常见原因是资源目录里没有可加载的 pipeline)。' +
|
|
2862
|
+
'这不是"模板不独特"——先写一个 pipeline 节点,或用 --resource-dir 指向真实资源,再重跑这次的框;' +
|
|
2863
|
+
'在此之前它**未经自匹配验证**,不要拿它去写 pipeline'
|
|
2864
|
+
: !best.posOk
|
|
2865
|
+
? '低纹理/不独特:模板在同帧上都定位不到自己(best 落在 ' + JSON.stringify(best.at) + ',得分 ' +
|
|
2866
|
+
best.score.toFixed(3) + ')——换更纹理化的框,或走点选路径'
|
|
2867
|
+
: (best.score < 0.7 ? '得分偏低(' + best.score.toFixed(3) + ')但位置正确:可用,注意跨帧稳定性' : null)
|
|
2868
|
+
/* 模板空间 = 源帧捕获时的控制器尺寸。与当前控制器不一致时模板不能被当前识别空间直接使用
|
|
2869
|
+
* (契约 §5:坐标 / ROI 必须声明所属图像空间;改过 shortSide 或换过设备就会出现)。 */
|
|
2870
|
+
const spaceCheck = S.ctrl && S.fullW && S.fullH
|
|
2871
|
+
? { current: { w: S.fullW, h: S.fullH }, match: S.fullW === dec.w && S.fullH === dec.h }
|
|
2872
|
+
: { current: null, match: null }
|
|
2873
|
+
const spaceWarn = spaceCheck.match === false
|
|
2874
|
+
? '源帧空间 ' + dec.w + 'x' + dec.h + ' ≠ 当前控制器 ' + S.fullW + 'x' + S.fullH +
|
|
2875
|
+
':模板按源帧空间裁出,与当前识别空间不一致(改过 shortSide / 换过设备?)'
|
|
2876
|
+
: null
|
|
2877
|
+
const crossWarn = cross && best.posOk && (cross.score < best.score - 0.15 || !cross.posOk)
|
|
2878
|
+
? '跨帧不稳:新帧(seq=' + cross.seq + ')上得分 ' + cross.score + (cross.posOk ? '' : '且位置漂移') +
|
|
2879
|
+
',源帧 ' + best.score.toFixed(3) + '——' +
|
|
2880
|
+
(spaceCheck.match === false
|
|
2881
|
+
/* 空间不同源时跨帧匹配本就不可比:别把它归因成"动态区域" */
|
|
2882
|
+
? '源帧空间与当前控制器不一致(见下条),该比对不成立,先在当前空间重采一帧再验'
|
|
2883
|
+
: '模板对动态区域敏感,慎用于识别')
|
|
2884
|
+
: null
|
|
2885
|
+
const box = [cand.x, cand.y, cand.w, cand.h]
|
|
2886
|
+
const looseBox = [box0.x, box0.y, box0.w, box0.h]
|
|
2887
|
+
const selfScore = Math.round(best.score * 1000) / 1000
|
|
2888
|
+
/* L2 出处:字段白名单见 buildL2Provenance;落点由上面的 provOut / 缺省"模板旁"决定 */
|
|
2889
|
+
const provenance = buildL2Provenance({
|
|
2890
|
+
source, loose: looseBox, box, snapped, score: selfScore, positionOk: best.posOk,
|
|
2891
|
+
...(best.at ? { selfMatchBox: best.at } : {}), tries: tries.n, cross,
|
|
2892
|
+
w: final.w, h: final.h, createdAt: new Date().toISOString(),
|
|
2893
|
+
})
|
|
2894
|
+
let provPath = null
|
|
2895
|
+
let provWarn = null
|
|
2896
|
+
if (args.prov !== false) {
|
|
2897
|
+
/* 出处落点:缺省写在模板旁("同生共死",复制/改名时出处跟着走);给了 provOut 就写到那个目录下、
|
|
2898
|
+
* 保持同文件名。**为什么需要它**:模板常常要放进资源包的 image/(框架会整目录打包发给用户),
|
|
2899
|
+
* 而出处是给开发者/计划用的过程资产——留在包外能让资源包只装框架要加载的东西(实测该项目一条:
|
|
2900
|
+
* 发行打包按 resource[].path 整目录拷贝,侧车会随包发出去)。约定不变量:**同一相对路径**,
|
|
2901
|
+
* 目录根由项目自定(例如 state-plan/provenance/ 与 image/ 同构)。 */
|
|
2902
|
+
const provOut = typeof args.provOut === 'string' && args.provOut ? String(args.provOut) : null
|
|
2903
|
+
provPath = provOut ? path.join(provOut, path.basename(out) + '.prov.json') : out + '.prov.json'
|
|
2904
|
+
try {
|
|
2905
|
+
fs.mkdirSync(path.dirname(provPath), { recursive: true })
|
|
2906
|
+
fs.writeFileSync(provPath, JSON.stringify(provenance, null, 2) + '\n')
|
|
2907
|
+
} catch (e) {
|
|
2908
|
+
provPath = null
|
|
2909
|
+
provWarn = 'L2 出处记录写入失败:' + String(e && e.message || e) + '(模板已裁出,但其出处未落盘)'
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
/* 出处是"热缓存帧"时给出可行动提示:这种出处在进程结束后无从复核(seq 指不回任何东西)。
|
|
2913
|
+
* 它本身不是错误(真机上现裁现用是合法动线),但**入库的模板**该有可复核的来源。 */
|
|
2914
|
+
const keepWarn = keptSource && keptSource.error
|
|
2915
|
+
? '--keep-source 没能升格源帧(' + keptSource.error + '):出处仍是热缓存来源,无从复核'
|
|
2916
|
+
: null
|
|
2917
|
+
const transientWarn = provenance.derivedFrom.kind === 'l0-cache'
|
|
2918
|
+
? '出处来源是热缓存帧(seq=' + provenance.derivedFrom.seq + '):本进程结束后无从复核。' +
|
|
2919
|
+
'要入库的模板加 --keep-source(顺手把这一帧升格进库)或先 kf promote 再裁'
|
|
2920
|
+
: null
|
|
2921
|
+
const warns = [fitWarn, warn, crossWarn, spaceWarn, provWarn, keepWarn, transientWarn].filter(Boolean)
|
|
2922
|
+
return {
|
|
2923
|
+
ok: true, path: out, seq: kf ? null : ent.seq, source,
|
|
2924
|
+
provPath, provenance,
|
|
2925
|
+
...(keptSource ? { keptSource } : {}),
|
|
2926
|
+
box, loose: looseBox,
|
|
2927
|
+
tighten,
|
|
2928
|
+
snapped, score: selfScore,
|
|
2929
|
+
positionOk: best.posOk,
|
|
2930
|
+
...(best.at ? { selfMatchBox: best.at } : {}),
|
|
2931
|
+
...(cross ? { cross } : {}),
|
|
2932
|
+
tries: tries.n,
|
|
2933
|
+
w: final.w, h: final.h,
|
|
2934
|
+
ctrlW: dec.w, ctrlH: dec.h,
|
|
2935
|
+
spaceCheck,
|
|
2936
|
+
...(warns.length ? { warn: warns[0], warns } : {}),
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/* ────────────────────────── 探色(ROI 实测,模型只选色不报色值) ──────────────────────────
|
|
2941
|
+
* 在缓冲帧(小图)上实测 ROI 的均值/HSV/主色;坐标用控制器空间(与 frame_get --roi 同语义,
|
|
2942
|
+
* 按该帧捕获时尺寸换算)。选色后用 reco ColorMatch 出框(走已修好的参数透传链)。 */
|
|
2943
|
+
function rgbToHsv(r, g, b) {
|
|
2944
|
+
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn
|
|
2945
|
+
let h = 0
|
|
2946
|
+
if (d !== 0) {
|
|
2947
|
+
if (mx === r) h = ((g - b) / d + 6) % 6
|
|
2948
|
+
else if (mx === g) h = (b - r) / d + 2
|
|
2949
|
+
else h = (r - g) / d + 4
|
|
2950
|
+
h *= 60
|
|
2951
|
+
}
|
|
2952
|
+
return { h: Math.round(h), s: Math.round(mx ? (d / mx) * 100 : 0), v: Math.round((mx / 255) * 100) }
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
function cmdColorProbe(args) {
|
|
2956
|
+
const fr = args.seq !== undefined && args.seq !== null
|
|
2957
|
+
? S.ring.find((r) => r.seq === Number(args.seq))
|
|
2958
|
+
: S.ring[S.ring.length - 1]
|
|
2959
|
+
if (!fr) {
|
|
2960
|
+
return { ok: false, error: '缓冲无可用帧(seq=' + args.seq + '):先 screencap 或 stream start 产生观测' }
|
|
2961
|
+
}
|
|
2962
|
+
let rx = 0, ry = 0, rw = fr.w, rh = fr.h
|
|
2963
|
+
let ctrlRoi = null
|
|
2964
|
+
const roiIn = readRoi(args.roi)
|
|
2965
|
+
if (roiIn.error) return { ok: false, error: roiIn.error }
|
|
2966
|
+
if (roiIn.roi) {
|
|
2967
|
+
const [x, y, w, h] = roiIn.roi
|
|
2968
|
+
const sx = fr.fw > 0 ? fr.w / fr.fw : 1
|
|
2969
|
+
const sy = fr.fh > 0 ? fr.h / fr.fh : 1
|
|
2970
|
+
rx = Math.max(0, Math.floor(x * sx))
|
|
2971
|
+
ry = Math.max(0, Math.floor(y * sy))
|
|
2972
|
+
rw = Math.min(fr.w - rx, Math.max(1, Math.round(w * sx)))
|
|
2973
|
+
rh = Math.min(fr.h - ry, Math.max(1, Math.round(h * sy)))
|
|
2974
|
+
if (rx >= fr.w || ry >= fr.h || rw <= 0 || rh <= 0) {
|
|
2975
|
+
return { ok: false, error: 'ROI 完全越界(' + JSON.stringify(args.roi) + ' vs 捕获时 ' + fr.fw + 'x' + fr.fh + ')' }
|
|
2976
|
+
}
|
|
2977
|
+
ctrlRoi = [x, y, w, h]
|
|
2978
|
+
}
|
|
2979
|
+
let r = 0, g = 0, b = 0, n = 0
|
|
2980
|
+
const buckets = new Map()
|
|
2981
|
+
for (let y = ry; y < ry + rh; y++) {
|
|
2982
|
+
for (let x = rx; x < rx + rw; x++) {
|
|
2983
|
+
const i = (y * fr.w + x) * 3
|
|
2984
|
+
const R = fr.rgb[i], G = fr.rgb[i + 1], B = fr.rgb[i + 2]
|
|
2985
|
+
r += R; g += G; b += B; n++
|
|
2986
|
+
const key = ((R >> 4) << 8) | ((G >> 4) << 4) | (B >> 4)
|
|
2987
|
+
buckets.set(key, (buckets.get(key) || 0) + 1)
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n)
|
|
2991
|
+
let dKey = 0, dCnt = 0
|
|
2992
|
+
for (const [k, c] of buckets) if (c > dCnt) { dCnt = c; dKey = k }
|
|
2993
|
+
const dom = [((dKey >> 8) & 15) * 17, ((dKey >> 4) & 15) * 17, (dKey & 15) * 17]
|
|
2994
|
+
return {
|
|
2995
|
+
ok: true, seq: fr.seq,
|
|
2996
|
+
...(ctrlRoi ? { roi: ctrlRoi } : {}),
|
|
2997
|
+
pixelRoi: [rx, ry, rw, rh], count: n,
|
|
2998
|
+
mean: { r, g, b, gray: Math.round(0.299 * r + 0.587 * g + 0.114 * b) },
|
|
2999
|
+
hsv: rgbToHsv(r, g, b),
|
|
3000
|
+
dominant: { rgb: dom, ratio: Math.round((dCnt / n) * 1000) / 1000 },
|
|
3001
|
+
space: { small: [fr.w, fr.h], ctrl: [fr.fw, fr.fh] },
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/* ────────────────────────── annotate(轻量 SoM:候选区域 + 编号回画) ──────────────────────────
|
|
3006
|
+
* 候选源:OCR 框(MaaFW OCR 管线,detail.all)+ 连通域(积分图背景差 + BFS)+ 边缘密度
|
|
3007
|
+
* (Sobel 块 z 值聚类)+ 变化 diff 区域(change 事件的 ctrl bbox)。回画编号图 + 候选表,
|
|
3008
|
+
* 模型看图选号 → 查 ctrl 坐标 → click/reco。半透明/粒子/渐变场景候选质量降级,走点选路径。 */
|
|
3009
|
+
const FONT3X5 = {
|
|
3010
|
+
0: ['111', '101', '101', '101', '111'], 1: ['010', '110', '010', '010', '111'],
|
|
3011
|
+
2: ['111', '001', '111', '100', '111'], 3: ['111', '001', '111', '001', '111'],
|
|
3012
|
+
4: ['101', '101', '111', '001', '001'], 5: ['111', '100', '111', '001', '111'],
|
|
3013
|
+
6: ['111', '100', '111', '101', '111'], 7: ['111', '001', '001', '001', '001'],
|
|
3014
|
+
8: ['111', '101', '111', '101', '111'], 9: ['111', '101', '111', '001', '111'],
|
|
3015
|
+
}
|
|
3016
|
+
function drawLabel(buf, w, h, x, y, num, color) {
|
|
3017
|
+
const s = String(num)
|
|
3018
|
+
const lw = s.length * 4 + 1, lh = 7
|
|
3019
|
+
const px = Math.max(0, Math.min(w - lw, x)), py = Math.max(0, Math.min(h - lh, y))
|
|
3020
|
+
for (let yy = 0; yy < lh; yy++) for (let xx = 0; xx < lw; xx++) {
|
|
3021
|
+
const i = ((py + yy) * w + px + xx) * 3
|
|
3022
|
+
buf[i] = 255; buf[i + 1] = 255; buf[i + 2] = 255
|
|
3023
|
+
}
|
|
3024
|
+
for (let k = 0; k < s.length; k++) {
|
|
3025
|
+
const glyph = FONT3X5[s[k]]
|
|
3026
|
+
for (let gy = 0; gy < 5; gy++) for (let gx = 0; gx < 3; gx++) {
|
|
3027
|
+
if (glyph[gy][gx] !== '1') continue
|
|
3028
|
+
const i = ((py + 1 + gy) * w + px + 1 + k * 4 + gx) * 3
|
|
3029
|
+
buf[i] = color[0]; buf[i + 1] = color[1]; buf[i + 2] = color[2]
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
function drawRect(buf, w, h, box, color) {
|
|
3034
|
+
const [x, y, bw, bh] = box
|
|
3035
|
+
const x1 = Math.min(w - 1, x + bw - 1), y1 = Math.min(h - 1, y + bh - 1)
|
|
3036
|
+
for (let xx = Math.max(0, x); xx <= x1; xx++) {
|
|
3037
|
+
for (const yy of [Math.max(0, y), y1]) {
|
|
3038
|
+
const i = (yy * w + xx) * 3
|
|
3039
|
+
buf[i] = color[0]; buf[i + 1] = color[1]; buf[i + 2] = color[2]
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
for (let yy = Math.max(0, y); yy <= y1; yy++) {
|
|
3043
|
+
for (const xx of [Math.max(0, x), x1]) {
|
|
3044
|
+
const i = (yy * w + xx) * 3
|
|
3045
|
+
buf[i] = color[0]; buf[i + 1] = color[1]; buf[i + 2] = color[2]
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/** 灰度图积分图 → 均值背景 → 背景差二值 → BFS 连通域(面积过滤) */
|
|
3051
|
+
/** 连通域候选(形态学偏离块 + BFS 成框)。stats 回填被面积窗与"太扁"拒掉的数量。 */
|
|
3052
|
+
function connComponents(rgb, w, h, stats = null) {
|
|
3053
|
+
const gray = new Float64Array(w * h)
|
|
3054
|
+
for (let i = 0, p = 0; i < w * h; i++, p += 3) {
|
|
3055
|
+
gray[i] = (rgb[p] * 3 + rgb[p + 1] * 6 + rgb[p + 2]) * 0.1
|
|
3056
|
+
}
|
|
3057
|
+
const integ = new Float64Array((w + 1) * (h + 1))
|
|
3058
|
+
for (let y = 0; y < h; y++) {
|
|
3059
|
+
let rowSum = 0
|
|
3060
|
+
for (let x = 0; x < w; x++) {
|
|
3061
|
+
rowSum += gray[y * w + x]
|
|
3062
|
+
integ[(y + 1) * (w + 1) + (x + 1)] = integ[y * (w + 1) + (x + 1)] + rowSum
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
const R = 12
|
|
3066
|
+
const dev = new Uint8Array(w * h)
|
|
3067
|
+
for (let y = 0; y < h; y++) {
|
|
3068
|
+
const y0 = Math.max(0, y - R), y1 = Math.min(h, y + R + 1)
|
|
3069
|
+
for (let x = 0; x < w; x++) {
|
|
3070
|
+
const x0 = Math.max(0, x - R), x1 = Math.min(w, x + R + 1)
|
|
3071
|
+
const area = (x1 - x0) * (y1 - y0)
|
|
3072
|
+
const sum = integ[y1 * (w + 1) + x1] - integ[y0 * (w + 1) + x1] - integ[y1 * (w + 1) + x0] + integ[y0 * (w + 1) + x0]
|
|
3073
|
+
if (Math.abs(gray[y * w + x] - sum / area) > 26) dev[y * w + x] = 1
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
const seen = new Uint8Array(w * h)
|
|
3077
|
+
const boxes = []
|
|
3078
|
+
const queue = new Int32Array(w * h)
|
|
3079
|
+
for (let start = 0; start < w * h; start++) {
|
|
3080
|
+
if (!dev[start] || seen[start]) continue
|
|
3081
|
+
let qs = 0, qe = 0
|
|
3082
|
+
queue[qe++] = start
|
|
3083
|
+
seen[start] = 1
|
|
3084
|
+
let minX = w, minY = h, maxX = 0, maxY = 0, area = 0
|
|
3085
|
+
while (qs < qe) {
|
|
3086
|
+
const p = queue[qs++]
|
|
3087
|
+
const px = p % w, py = (p / w) | 0
|
|
3088
|
+
area++
|
|
3089
|
+
if (px < minX) minX = px; if (px > maxX) maxX = px
|
|
3090
|
+
if (py < minY) minY = py; if (py > maxY) maxY = py
|
|
3091
|
+
if (px > 0 && dev[p - 1] && !seen[p - 1]) { seen[p - 1] = 1; queue[qe++] = p - 1 }
|
|
3092
|
+
if (px < w - 1 && dev[p + 1] && !seen[p + 1]) { seen[p + 1] = 1; queue[qe++] = p + 1 }
|
|
3093
|
+
if (py > 0 && dev[p - w] && !seen[p - w]) { seen[p - w] = 1; queue[qe++] = p - w }
|
|
3094
|
+
if (py < h - 1 && dev[p + w] && !seen[p + w]) { seen[p + w] = 1; queue[qe++] = p + w }
|
|
3095
|
+
}
|
|
3096
|
+
if (area >= 120 && area <= 24000 && maxX - minX >= 10 && maxY - minY >= 10) {
|
|
3097
|
+
boxes.push([minX, minY, maxX - minX + 1, maxY - minY + 1])
|
|
3098
|
+
if (stats) stats.passed++
|
|
3099
|
+
} else if (stats) {
|
|
3100
|
+
/* 三类拒绝分开记:面积窗(120–24000)与"太扁"是分辨率/内容相关的两道不同门槛,
|
|
3101
|
+
* 混成一个数字就看不出"换分辨率会不会漂"。 */
|
|
3102
|
+
if (area < 120) stats.tooSmall++
|
|
3103
|
+
else if (area > 24000) stats.tooLarge++
|
|
3104
|
+
else stats.tooNarrow++
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
return boxes
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
/** Sobel 边缘密度的块级聚类(16px 块,z 值超限块 BFS 成框)。
|
|
3111
|
+
* opts.z / opts.min 是**测量用覆盖**(默认即生产常数 1.2 / 2000):edge 源长期只出个位数候选,
|
|
3112
|
+
* 要判断"是阈值太严还是这类内容真没边缘",就得能调着量。stats 回填被两道门槛与簇大小拒掉的数量。 */
|
|
3113
|
+
function edgeDensityBoxes(rgb, w, h, opts = {}, stats = null) {
|
|
3114
|
+
const BS = 16
|
|
3115
|
+
const bw = Math.ceil(w / BS), bh = Math.ceil(h / BS)
|
|
3116
|
+
const mag = new Float64Array(w * h)
|
|
3117
|
+
for (let y = 1; y < h - 1; y++) {
|
|
3118
|
+
for (let x = 1; x < w - 1; x++) {
|
|
3119
|
+
const i = (y * w + x) * 3
|
|
3120
|
+
const gx = (rgb[i + 3] * 2 + rgb[i - w * 3 + 3] + rgb[i + w * 3 + 3]) - (rgb[i - 3] * 2 + rgb[i - w * 3 - 3] + rgb[i + w * 3 - 3])
|
|
3121
|
+
const gy = (rgb[i + w * 3] * 2 + rgb[i + w * 3 + 1] + rgb[i + w * 3 - 1]) - (rgb[i - w * 3] * 2 + rgb[i - w * 3 + 1] + rgb[i - w * 3 - 1])
|
|
3122
|
+
mag[y * w + x] = Math.abs(gx) + Math.abs(gy)
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
const blk = new Float64Array(bw * bh)
|
|
3126
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) blk[((y / BS) | 0) * bw + ((x / BS) | 0)] += mag[y * w + x]
|
|
3127
|
+
let mean = 0
|
|
3128
|
+
for (const v of blk) mean += v
|
|
3129
|
+
mean /= blk.length
|
|
3130
|
+
let sd = 0
|
|
3131
|
+
for (const v of blk) sd += (v - mean) * (v - mean)
|
|
3132
|
+
sd = Math.sqrt(sd / blk.length)
|
|
3133
|
+
const hot = new Uint8Array(bw * bh)
|
|
3134
|
+
const Z = Number(opts.z) > 0 ? Number(opts.z) : 1.2
|
|
3135
|
+
const MIN = Number(opts.min) > 0 ? Number(opts.min) : 2000
|
|
3136
|
+
/* stats 只记账不改行为:被两道门槛拒掉多少块,是"edge 为什么不出力"唯一能看的证据。 */
|
|
3137
|
+
for (let k = 0; k < blk.length; k++) {
|
|
3138
|
+
if (blk[k] <= mean + Z * sd) { if (stats) stats.belowZ++; continue }
|
|
3139
|
+
if (blk[k] <= MIN) { if (stats) stats.belowMin++; continue }
|
|
3140
|
+
hot[k] = 1
|
|
3141
|
+
}
|
|
3142
|
+
const seen = new Uint8Array(bw * bh)
|
|
3143
|
+
const boxes = []
|
|
3144
|
+
const stack = []
|
|
3145
|
+
for (let k = 0; k < bw * bh; k++) {
|
|
3146
|
+
if (!hot[k] || seen[k]) continue
|
|
3147
|
+
stack.length = 0
|
|
3148
|
+
stack.push(k)
|
|
3149
|
+
seen[k] = 1
|
|
3150
|
+
let minX = bw, minY = bh, maxX = 0, maxY = 0, n = 0
|
|
3151
|
+
while (stack.length) {
|
|
3152
|
+
const p = stack.pop()
|
|
3153
|
+
const px = p % bw, py = (p / bw) | 0
|
|
3154
|
+
n++
|
|
3155
|
+
if (px < minX) minX = px; if (px > maxX) maxX = px
|
|
3156
|
+
if (py < minY) minY = py; if (py > maxY) maxY = py
|
|
3157
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
3158
|
+
const nx = px + dx, ny = py + dy
|
|
3159
|
+
if (nx < 0 || ny < 0 || nx >= bw || ny >= bh) continue
|
|
3160
|
+
const q = ny * bw + nx
|
|
3161
|
+
if (hot[q] && !seen[q]) { seen[q] = 1; stack.push(q) }
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
if (n >= 2) {
|
|
3165
|
+
const x = minX * BS, y = minY * BS
|
|
3166
|
+
boxes.push([x, y, Math.min(w - x, (maxX - minX + 1) * BS), Math.min(h - y, (maxY - minY + 1) * BS)])
|
|
3167
|
+
if (stats) stats.passed++
|
|
3168
|
+
} else if (stats) stats.singleBlock++
|
|
3169
|
+
}
|
|
3170
|
+
return boxes
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
const SOM_COLORS = { ocr: [0, 190, 255], conn: [60, 220, 60], edge: [255, 160, 0], diff: [255, 70, 70] }
|
|
3174
|
+
const SOM_PRIORITY = { ocr: 0, diff: 1, conn: 2, edge: 3 }
|
|
3175
|
+
|
|
3176
|
+
/**
|
|
3177
|
+
* 上限截断:按**横向分带轮转**取样,而不是直接截前 N 个。
|
|
3178
|
+
*
|
|
3179
|
+
* 为什么:候选是先按源优先级、再按扫描顺序(自上而下)排的,直接截前 N 个会让候选表系统性偏顶部。
|
|
3180
|
+
* 实测(6 帧 1280×720,上限 30):合并总数中位数 54、最大 135,默认上限只放出 41%;
|
|
3181
|
+
* 大厅帧 63 个底部候选**一个都没进表**,看上去像"底部没有可点区域"——那是策略造成的假象,
|
|
3182
|
+
* 不是画面事实。轮转后各带按供给比例进表,源优先级在带内仍然保持。
|
|
3183
|
+
*/
|
|
3184
|
+
function pickBalancedCandidates(list, limit, h) {
|
|
3185
|
+
const bands = [[], [], []]
|
|
3186
|
+
for (const c of list) {
|
|
3187
|
+
const cy = c.box[1] + c.box[3] / 2
|
|
3188
|
+
bands[cy < h / 3 ? 0 : (cy < (2 * h) / 3 ? 1 : 2)].push(c)
|
|
3189
|
+
}
|
|
3190
|
+
/* 带内再按**来源轮转**。为什么:合并池是按源优先级(ocr > diff > conn > edge)排的,
|
|
3191
|
+
* 而一个带里 OCR 常有几十条——直接把 conn / edge 挤光。偏偏**图标类目标多来自 conn / edge**
|
|
3192
|
+
* (实测:主页面顶部那个图标在合并池里排 #55,默认上限 30 时永远进不了表,覆盖率 0%;
|
|
3193
|
+
* 上限放开到 200 才出现,覆盖 62%)。来源轮转保证每带每一路都有代表,源优先级只决定同轮先后。 */
|
|
3194
|
+
const interleave = (b) => {
|
|
3195
|
+
const groups = {}
|
|
3196
|
+
for (const c of b) (groups[c.source] = groups[c.source] || []).push(c)
|
|
3197
|
+
const srcs = Object.keys(groups).sort((x, y) => (SOM_PRIORITY[x] ?? 9) - (SOM_PRIORITY[y] ?? 9))
|
|
3198
|
+
const res = []
|
|
3199
|
+
for (let i = 0; ; i++) {
|
|
3200
|
+
let any = false
|
|
3201
|
+
for (const s of srcs) {
|
|
3202
|
+
if (groups[s][i]) { res.push(groups[s][i]); any = true }
|
|
3203
|
+
}
|
|
3204
|
+
if (!any) break
|
|
3205
|
+
}
|
|
3206
|
+
return res
|
|
3207
|
+
}
|
|
3208
|
+
const ordered = bands.map(interleave)
|
|
3209
|
+
const out = []
|
|
3210
|
+
for (let i = 0; out.length < limit; i++) {
|
|
3211
|
+
let taken = false
|
|
3212
|
+
for (const b of ordered) {
|
|
3213
|
+
if (!b[i]) continue
|
|
3214
|
+
out.push(b[i])
|
|
3215
|
+
taken = true
|
|
3216
|
+
if (out.length >= limit) break
|
|
3217
|
+
}
|
|
3218
|
+
if (!taken) break
|
|
3219
|
+
}
|
|
3220
|
+
return out
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
async function cmdAnnotate(args) {
|
|
3224
|
+
/* 工作图两条路:会话内 L1 小图(缺省)或关键帧库留存帧(kfSource,控制器分辨率、**可无设备**)。
|
|
3225
|
+
* 小图路径的候选要按该帧捕获时尺寸换算回控制器空间;库帧本身就是控制器空间,换算系数为 1。
|
|
3226
|
+
* diff 源依赖会话内 change 事件——库帧路径没有,如实报不可用,不拿别的区域顶替(不冒称)。 */
|
|
3227
|
+
let work = null
|
|
3228
|
+
let seq = null
|
|
3229
|
+
let source = null
|
|
3230
|
+
let detectionSource = null
|
|
3231
|
+
if (args.kfSource) {
|
|
3232
|
+
const kf = readKfCropSource(args.kfSource)
|
|
3233
|
+
if (kf.error) return { ok: false, error: kf.error }
|
|
3234
|
+
work = { rgb: toRgb(kf.dec), w: kf.dec.w, h: kf.dec.h, fw: 0, fh: 0 }
|
|
3235
|
+
source = kf.source
|
|
3236
|
+
detectionSource = 'kf-frame'
|
|
3237
|
+
} else {
|
|
3238
|
+
const fr = args.seq !== undefined && args.seq !== null
|
|
3239
|
+
? S.ring.find((r) => r.seq === Number(args.seq))
|
|
3240
|
+
: S.ring[S.ring.length - 1]
|
|
3241
|
+
if (!fr) return { ok: false, error: '缓冲无可用帧(seq=' + args.seq + '):先 screencap / stream 产生观测' }
|
|
3242
|
+
/* 检测面分辨率:有同一 seq 的 L0 全分辨率原图就**优先用它**。
|
|
3243
|
+
* 为什么:候选检测的分辨率下限直接决定"小图标能不能被找到"。实测大厅顶部那个 42×55 的图标,
|
|
3244
|
+
* 在 480 宽的 L1 上四个源一个都没探到(合并池仅 30 条、无截断,却仍然没有它),
|
|
3245
|
+
* 在 1280 宽的原图上 conn 一次就命中(527,36,42,55)——漏报的根因是分辨率,不是上限。
|
|
3246
|
+
* L1 只作降级(L0 已淘汰时),且回执里如实标注,别让人以为降级图和原图同源。
|
|
3247
|
+
* `somScale: 'small'` 可强制走小图(要快、且只关心大目标时)。 */
|
|
3248
|
+
let det = null
|
|
3249
|
+
if (String(args.somScale ?? 'full') !== 'small') {
|
|
3250
|
+
const l0 = findL0(fr.seq)
|
|
3251
|
+
if (l0) {
|
|
3252
|
+
try {
|
|
3253
|
+
const dec = pngDecode(l0.png)
|
|
3254
|
+
det = { rgb: toRgb(dec), w: dec.w, h: dec.h }
|
|
3255
|
+
} catch (e) { det = null }
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
work = det
|
|
3259
|
+
? { rgb: det.rgb, w: det.w, h: det.h, fw: det.w, fh: det.h }
|
|
3260
|
+
: { rgb: fr.rgb, w: fr.w, h: fr.h, fw: fr.fw, fh: fr.fh }
|
|
3261
|
+
seq = fr.seq
|
|
3262
|
+
detectionSource = det ? 'l0-full' : 'l1-ring'
|
|
3263
|
+
source = {
|
|
3264
|
+
kind: detectionSource, seq: fr.seq, ctrlW: fr.fw, ctrlH: fr.fh,
|
|
3265
|
+
...(det ? {} : {
|
|
3266
|
+
degraded: true,
|
|
3267
|
+
note: 'L0 全分辨率原图已淘汰,检测在降采样图上进行(' + fr.w + 'x' + fr.h
|
|
3268
|
+
+ ',有效 ' + (fr.w / Math.max(1, fr.fw)).toFixed(3) + '×):小目标可能根本探不到。'
|
|
3269
|
+
+ '要全分辨率就在截图后立刻跑 annotate,或先 kf promote 留存该帧再 --from-kf。',
|
|
3270
|
+
}),
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
const sx = work.fw > 0 ? work.fw / work.w : 1
|
|
3274
|
+
const sy = work.fh > 0 ? work.fh / work.h : 1
|
|
3275
|
+
const toCtrl = (b) => [Math.round(b[0] * sx), Math.round(b[1] * sy), Math.round(b[2] * sx), Math.round(b[3] * sy)]
|
|
3276
|
+
const raw = []
|
|
3277
|
+
|
|
3278
|
+
/* 源 1:OCR 框(子进程跑 MaaFW OCR,detail.all 是全部文本框) */
|
|
3279
|
+
if (args.resourceDir) {
|
|
3280
|
+
const imgFile = path.join(os.tmpdir(), 'maa_som_' + Date.now() + '.png')
|
|
3281
|
+
try {
|
|
3282
|
+
fs.writeFileSync(imgFile, pngEncodeRGB(work.rgb, work.w, work.h))
|
|
3283
|
+
const r = await spawnRecoChild({ resourceDir: String(args.resourceDir), type: 'OCR', image: imgFile, cases: [{}] }, 60000)
|
|
3284
|
+
if (r && r.ok) {
|
|
3285
|
+
const det = r.results && r.results[0] && r.results[0].detail
|
|
3286
|
+
const all = det && det.detail && Array.isArray(det.detail.all) ? det.detail.all : []
|
|
3287
|
+
for (const hit of all) {
|
|
3288
|
+
if (!Array.isArray(hit.box) || Number(hit.score) < 0.5) continue
|
|
3289
|
+
raw.push({ source: 'ocr', box: hit.box.map(Number), extra: { text: String(hit.text ?? ''), score: Number(hit.score) } })
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
} finally {
|
|
3293
|
+
try { fs.rmSync(imgFile, { force: true }) } catch (e) { /* ignore */ }
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
/* 源 2/3:连通域与边缘密度(纯 CPU,小图上毫秒级);stats 记下被门槛拒掉的数量——
|
|
3297
|
+
* "某段没有候选"到底是看不见还是被过滤掉,只有这个计数能回答(分辨率变化时尤其要紧)。 */
|
|
3298
|
+
const connStats = { passed: 0, tooSmall: 0, tooLarge: 0, tooNarrow: 0 }
|
|
3299
|
+
const edgeStats = { passed: 0, belowZ: 0, belowMin: 0, singleBlock: 0 }
|
|
3300
|
+
for (const b of connComponents(work.rgb, work.w, work.h, connStats)) raw.push({ source: 'conn', box: b })
|
|
3301
|
+
for (const b of edgeDensityBoxes(work.rgb, work.w, work.h, { z: args.somEdgeZ, min: args.somEdgeMin }, edgeStats)) {
|
|
3302
|
+
raw.push({ source: 'edge', box: b })
|
|
3303
|
+
}
|
|
3304
|
+
/* 源 4:最近 change 事件的 diff 区域(ctrl → 小图坐标);库帧路径没有会话事件 */
|
|
3305
|
+
const diffs = []
|
|
3306
|
+
if (!args.kfSource) {
|
|
3307
|
+
for (let i = S.events.length - 1; i >= 0 && diffs.length < 3; i--) {
|
|
3308
|
+
const ev = S.events[i]
|
|
3309
|
+
if (ev.type === 'change' && ev.region && ev.region.ctrl) diffs.push(ev.region.ctrl)
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
for (const c of diffs) {
|
|
3313
|
+
raw.push({ source: 'diff', box: [Math.round(c[0] / sx), Math.round(c[1] / sy), Math.round(c[2] / sx), Math.round(c[3] / sy)] })
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
/* 去重合并:IoU>0.6 保留优先级高的源(ocr > diff > conn > edge);上限缺省 30。
|
|
3317
|
+
* somLimit 是**测量用覆盖**:不放开上限就量不到"被截断前有多少候选",也就无法判断 30 该不该动。 */
|
|
3318
|
+
const LIMIT = Number(args.somLimit) > 0 ? Math.floor(Number(args.somLimit)) : 30
|
|
3319
|
+
raw.sort((a, b) => (SOM_PRIORITY[a.source] ?? 9) - (SOM_PRIORITY[b.source] ?? 9))
|
|
3320
|
+
const iou = (a, b) => {
|
|
3321
|
+
const ix = Math.max(0, Math.min(a[0] + a[2], b[0] + b[2]) - Math.max(a[0], b[0]))
|
|
3322
|
+
const iy = Math.max(0, Math.min(a[1] + a[3], b[1] + b[3]) - Math.max(a[1], b[1]))
|
|
3323
|
+
const inter = ix * iy
|
|
3324
|
+
const uni = a[2] * a[3] + b[2] * b[3] - inter
|
|
3325
|
+
return uni > 0 ? inter / uni : 0
|
|
3326
|
+
}
|
|
3327
|
+
/* 巨框剔除:面积占画面比例过大的候选几乎必是背景 / 立绘 / 整屏变化区——对"找可点目标"是纯噪音,
|
|
3328
|
+
* 而且因为面积大反而**遮住真目标**(实测每帧稳定 2 个,含一次 1280×720 全屏)。
|
|
3329
|
+
* OCR 框不参与:文本包围盒天然有界,且是全表里最可信的一路。 */
|
|
3330
|
+
const frameArea = work.w * work.h
|
|
3331
|
+
const maxRatio = Number(args.somMaxAreaRatio) > 0 ? Number(args.somMaxAreaRatio) : 0.12
|
|
3332
|
+
const dropped = { tooSmall: 0, tooBig: 0, duplicated: 0 }
|
|
3333
|
+
const merged = []
|
|
3334
|
+
const IOU = Number(args.somIoU) > 0 ? Math.min(0.99, Number(args.somIoU)) : 0.6
|
|
3335
|
+
for (const c of raw) {
|
|
3336
|
+
if (c.box[2] < 8 || c.box[3] < 8) { dropped.tooSmall++; continue }
|
|
3337
|
+
const ratio = (c.box[2] * c.box[3]) / frameArea
|
|
3338
|
+
if (c.source !== 'ocr' && ratio > maxRatio) { dropped.tooBig++; continue }
|
|
3339
|
+
if (merged.some((x) => iou(x.box, c.box) > IOU)) { dropped.duplicated++; continue }
|
|
3340
|
+
merged.push(c)
|
|
3341
|
+
}
|
|
3342
|
+
/* ROI 收窄(可选):只保留**落在给定区域**的候选,且这一步在**取上限之前**做——
|
|
3343
|
+
* 于是这个区域里的候选只跟自己竞争,不会因为画面别处有 160 条而被挤掉。
|
|
3344
|
+
* 这是"上限截断"的正解:不是把上限调大(那是把上下文成本推给调用方),而是把搜索面收小。 */
|
|
3345
|
+
const roiIn = readRoi(args.roi)
|
|
3346
|
+
if (roiIn.error) return { ok: false, error: roiIn.error }
|
|
3347
|
+
const ctrlW0 = work.fw > 0 ? work.fw : work.w
|
|
3348
|
+
const ctrlH0 = work.fh > 0 ? work.fh : work.h
|
|
3349
|
+
let pool = merged
|
|
3350
|
+
let roiApplied = null
|
|
3351
|
+
if (roiIn.roi) {
|
|
3352
|
+
const [rx, ry, rw, rh] = roiIn.roi
|
|
3353
|
+
const sx0 = work.w / Math.max(1, ctrlW0)
|
|
3354
|
+
const sy0 = work.h / Math.max(1, ctrlH0)
|
|
3355
|
+
const inRoi = (box) => {
|
|
3356
|
+
const cx = (box[0] + box[2] / 2) * (1 / sx0)
|
|
3357
|
+
const cy = (box[1] + box[3] / 2) * (1 / sy0)
|
|
3358
|
+
return cx >= rx && cx <= rx + rw && cy >= ry && cy <= ry + rh
|
|
3359
|
+
}
|
|
3360
|
+
pool = merged.filter((c) => inRoi(c.box))
|
|
3361
|
+
roiApplied = { roi: [rx, ry, rw, rh], inPool: pool.length, outsideRoi: merged.length - pool.length }
|
|
3362
|
+
}
|
|
3363
|
+
const cands = pickBalancedCandidates(pool, LIMIT, work.h) /* 分带可用性:三分带各自"进表多少 / 合并池里有多少"。上限截断是分带轮转的,
|
|
3364
|
+
* 于是"这一带没有候选"要么是供给真没有、要么是被门槛拒了或被上限截了——三列一起读才可归因。 */
|
|
3365
|
+
const bandOf = (cy, h) => (cy < h / 3 ? 0 : (cy < (2 * h) / 3 ? 1 : 2))
|
|
3366
|
+
const bandCount = (arr, bi) => arr.filter((c) => bandOf(c.box[1] + c.box[3] / 2, work.h) === bi).length
|
|
3367
|
+
const bands = [0, 1, 2].map((bi) => ({
|
|
3368
|
+
band: bi, yFrom: Math.round((bi * work.h) / 3), yTo: Math.round(((bi + 1) * work.h) / 3),
|
|
3369
|
+
inPool: bandCount(pool, bi), inTable: bandCount(cands, bi),
|
|
3370
|
+
}))
|
|
3371
|
+
/* 回画:源配色边框 + 编号标签。**优先全分辨率**(回画物的用途是"人照着框选",
|
|
3372
|
+
* 480 宽的图在小目标上根本框不准——本案例实测被用户退回)。
|
|
3373
|
+
* 检测若已用 L0,`work` 就是那张原图:**直接复用**,省掉第二次 PNG 解码,
|
|
3374
|
+
* 也避免"检测时命中、回画时已淘汰"让来源标签说谎——中间隔着最长 60 秒的 OCR await,
|
|
3375
|
+
* 而 L0 滚动窗在 10fps 下只有约 1.6 秒,这个竞态是真会发生的。
|
|
3376
|
+
* 检测若走了降级小图,这里再试一次 L0:命中就能给出全分辨率回画(框按 kx 放大)。 */
|
|
3377
|
+
let draw = { rgb: work.rgb, w: work.w, h: work.h, kx: 1, ky: 1, source: detectionSource ?? 'l1-ring' }
|
|
3378
|
+
if (detectionSource !== 'l0-full' && !args.kfSource && seq !== null) {
|
|
3379
|
+
const l0 = findL0(seq)
|
|
3380
|
+
if (l0) {
|
|
3381
|
+
try {
|
|
3382
|
+
const dec = pngDecode(l0.png)
|
|
3383
|
+
draw = { rgb: toRgb(dec), w: dec.w, h: dec.h, kx: dec.w / work.w, ky: dec.h / work.h, source: 'l0-full' }
|
|
3384
|
+
} catch (e) { /* 解码失败就退回工作图 */ }
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
const buf = Buffer.from(draw.rgb)
|
|
3388
|
+
cands.forEach((c, i) => {
|
|
3389
|
+
const color = SOM_COLORS[c.source] ?? [200, 200, 200]
|
|
3390
|
+
const sb = [
|
|
3391
|
+
Math.round(c.box[0] * draw.kx), Math.round(c.box[1] * draw.ky),
|
|
3392
|
+
Math.max(2, Math.round(c.box[2] * draw.kx)), Math.max(2, Math.round(c.box[3] * draw.ky)),
|
|
3393
|
+
]
|
|
3394
|
+
drawRect(buf, draw.w, draw.h, sb, color)
|
|
3395
|
+
drawLabel(buf, draw.w, draw.h, sb[0], Math.max(0, sb[1] - Math.round(8 * draw.ky)), i + 1, [20, 20, 20])
|
|
3396
|
+
})
|
|
3397
|
+
const out = args.out || defaultOutFile(S.runDir, 'som').file
|
|
3398
|
+
fs.mkdirSync(path.dirname(out), { recursive: true })
|
|
3399
|
+
/* ROI 收窄时把范围画出来(白色双线框):人看图时才知道"表里这些是这一小块里的",
|
|
3400
|
+
* 不会误以为整屏就这些候选。 */
|
|
3401
|
+
if (roiApplied) {
|
|
3402
|
+
const sxr = draw.w / Math.max(1, ctrlW0)
|
|
3403
|
+
const syr = draw.h / Math.max(1, ctrlH0)
|
|
3404
|
+
const rb = [
|
|
3405
|
+
Math.round(roiApplied.roi[0] * sxr), Math.round(roiApplied.roi[1] * syr),
|
|
3406
|
+
Math.max(2, Math.round(roiApplied.roi[2] * sxr)), Math.max(2, Math.round(roiApplied.roi[3] * syr)),
|
|
3407
|
+
]
|
|
3408
|
+
drawRect(buf, draw.w, draw.h, rb, [255, 255, 255])
|
|
3409
|
+
drawRect(buf, draw.w, draw.h, [rb[0] + 1, rb[1] + 1, Math.max(2, rb[2] - 2), Math.max(2, rb[3] - 2)], [255, 255, 255])
|
|
3410
|
+
}
|
|
3411
|
+
fs.writeFileSync(out, pngEncodeRGB(buf, draw.w, draw.h))
|
|
3412
|
+
|
|
3413
|
+
/* --emit-crops <dir>:每个进表候选从 work 图(检测面:L0 全分辨率 or kf 原图 or 降级小图)
|
|
3414
|
+
* 切一张 PNG + 写 .prov.json 出处。box 是 work 坐标系——k 库帧与工作图同空间,x/y 直接裁。
|
|
3415
|
+
* selfMatch 为 null:这是"候选一键出模板"的批量出图,未经自匹配验证;要自匹配(选号)
|
|
3416
|
+
* 走正式 crop。 */
|
|
3417
|
+
const emitted = []
|
|
3418
|
+
const emitDir = typeof args.emitCrops === 'string' && args.emitCrops ? String(args.emitCrops) : null
|
|
3419
|
+
if (emitDir) {
|
|
3420
|
+
try {
|
|
3421
|
+
fs.mkdirSync(emitDir, { recursive: true })
|
|
3422
|
+
cands.forEach((c, i) => {
|
|
3423
|
+
const id = i + 1
|
|
3424
|
+
const b = { x: Math.round(c.box[0]), y: Math.round(c.box[1]), w: Math.round(c.box[2]), h: Math.round(c.box[3]) }
|
|
3425
|
+
const crop = cropRgb(work.rgb, work.w, work.h, b)
|
|
3426
|
+
const f = path.join(emitDir, 'cand-' + String(id).padStart(2, '0') + '.png')
|
|
3427
|
+
fs.writeFileSync(f, pngEncodeRGB(crop.data, crop.w, crop.h))
|
|
3428
|
+
const prov = buildL2Provenance({
|
|
3429
|
+
source, loose: b, box: [b.x, b.y, b.w, b.h], snapped: false, selfMatch: null,
|
|
3430
|
+
cross: null, w: crop.w, h: crop.h, createdAt: new Date().toISOString(),
|
|
3431
|
+
tool: 'maafw-live annotate --emit-crops',
|
|
3432
|
+
})
|
|
3433
|
+
const pf = f + '.prov.json'
|
|
3434
|
+
fs.writeFileSync(pf, JSON.stringify(prov, null, 2) + '\n')
|
|
3435
|
+
emitted.push({ id, path: f, provPath: pf, w: crop.w, h: crop.h, source: c.source })
|
|
3436
|
+
})
|
|
3437
|
+
} catch (e) { /* 逐条写失败不中断 annotate:emit 是可选增值,不是主结果 */ }
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
const ctrlW = work.fw > 0 ? work.fw : work.w
|
|
3441
|
+
const ctrlH = work.fh > 0 ? work.fh : work.h
|
|
3442
|
+
/* 候选框的面积占比(控制器空间,与画面同尺度):调用方可据此再排一次序——
|
|
3443
|
+
* 面积越大越可能是面板/背景而不是小按钮。 */
|
|
3444
|
+
const feat = (c) => {
|
|
3445
|
+
const cb = toCtrl(c.box)
|
|
3446
|
+
const r = (cb[2] * cb[3]) / Math.max(1, ctrlW * ctrlH)
|
|
3447
|
+
return { ctrlBox: cb, areaRatio: Math.round(r * 10000) / 10000 }
|
|
3448
|
+
}
|
|
3449
|
+
return {
|
|
3450
|
+
ok: true, out, seq, source, detectionSource,
|
|
3451
|
+
small: [work.w, work.h], ctrl: [ctrlW, ctrlH],
|
|
3452
|
+
count: cands.length,
|
|
3453
|
+
limit: LIMIT,
|
|
3454
|
+
mergedTotal: merged.length,
|
|
3455
|
+
sources: { ocr: cands.filter((c) => c.source === 'ocr').length, conn: cands.filter((c) => c.source === 'conn').length, edge: cands.filter((c) => c.source === 'edge').length, diff: cands.filter((c) => c.source === 'diff').length },
|
|
3456
|
+
/* 被门槛拒掉的候选:conn 的面积窗/太扁、edge 的两道门槛/单块簇。
|
|
3457
|
+
* 与 sources 一起读,才知道"候选少"是内容如此还是被过滤掉了。 */
|
|
3458
|
+
filtered: { conn: connStats, edge: edgeStats },
|
|
3459
|
+
/* 可用性指标:**"看起来有结果"不等于"有结果"**。上限截断、巨框剔除、去重合并各扣了多少,
|
|
3460
|
+
* 以及每带的供给/进表——没有这三列,调用方会把"30 条候选"读成"画面就这些可点区域"。 */
|
|
3461
|
+
availability: {
|
|
3462
|
+
limit: LIMIT,
|
|
3463
|
+
mergedTotal: merged.length,
|
|
3464
|
+
truncated: Math.max(0, pool.length - cands.length),
|
|
3465
|
+
dropped,
|
|
3466
|
+
maxAreaRatio: maxRatio,
|
|
3467
|
+
...(roiApplied ? { roi: roiApplied } : {}),
|
|
3468
|
+
byBand: bands,
|
|
3469
|
+
outResolution: { w: draw.w, h: draw.h, source: draw.source },
|
|
3470
|
+
detectionResolution: { w: work.w, h: work.h, source: detectionSource ?? (args.kfSource ? 'kf-frame' : '?') },
|
|
3471
|
+
},
|
|
3472
|
+
...(args.kfSource ? { sourcesUnavailable: { diff: '库帧路径无会话事件:diff 源不可用(不拿别的区域顶替)' } } : {}),
|
|
3473
|
+
candidates: cands.map((c, i) => ({ id: i + 1, source: c.source, box: c.box, ctrl: toCtrl(c.box), ...feat(c), ...(c.extra ?? {}) })),
|
|
3474
|
+
...(cands.length === 0 ? { warn: '无候选:画面可能静止且低对比;换帧或走点选路径' } : {}),
|
|
3475
|
+
...(emitted.length ? { emitted: { dir: emitDir, count: emitted.length, files: emitted } } : {}),
|
|
3476
|
+
...(pool.length > LIMIT
|
|
3477
|
+
? { warn: '候选被上限截断:本区域池 ' + pool.length + ' 条,进表 ' + cands.length + ' 条。'
|
|
3478
|
+
+ (roiApplied ? '用更小的 roi 继续收窄' : '给 roi 把搜索面收小(这是正解),或给 somLimit 放开上限') }
|
|
3479
|
+
: {}),
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/* ────────────────────────── 阈值校准(静止画面噪声定标) ──────────────────────────
|
|
3484
|
+
* 采集 N 帧(要求画面静止),统计相邻帧分块亮度差与全局位差率的分布,推荐
|
|
3485
|
+
* blockThresh / changeGlobal。噪声地板之上留裕量,让真变化必超、静止必不超。
|
|
3486
|
+
* 期间若有真实变化会抬高推荐值——max ≫ p99 时警告重跑。 */
|
|
3487
|
+
async function cmdCalibrate(args) {
|
|
3488
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先连接)' }
|
|
3489
|
+
const frames = Math.min(60, Math.max(6, Number(args.frames ?? 24)))
|
|
3490
|
+
const interval = Math.max(120, Number(args.interval ?? 300))
|
|
3491
|
+
const blockDiffs = []
|
|
3492
|
+
const globals = []
|
|
3493
|
+
let last = null
|
|
3494
|
+
for (let i = 0; i < frames; i++) {
|
|
3495
|
+
const j = S.ctrl.post_screencap()
|
|
3496
|
+
await j.wait()
|
|
3497
|
+
const buf = j.get()
|
|
3498
|
+
if (!buf || buf.byteLength < 8) return { ok: false, error: '第 ' + (i + 1) + ' 帧截图失败' }
|
|
3499
|
+
const b = Buffer.from(buf)
|
|
3500
|
+
const dec = pngDecode(b)
|
|
3501
|
+
pushFrame(dec, b) // 校准采集也是观测(进环进 L0,可复核)
|
|
3502
|
+
const small = downscale(toRgb(dec), dec.w, dec.h, S.streamScale)
|
|
3503
|
+
const { hash, means } = blockAnalyze(small.data, small.w, small.h)
|
|
3504
|
+
if (last) {
|
|
3505
|
+
globals.push(hashDist(last.hash, hash))
|
|
3506
|
+
for (let k = 0; k < 64; k++) blockDiffs.push(Math.abs(means[k] - last.means[k]))
|
|
3507
|
+
}
|
|
3508
|
+
last = { hash, means }
|
|
3509
|
+
if (i < frames - 1) await sleep(interval)
|
|
3510
|
+
}
|
|
3511
|
+
blockDiffs.sort((a, b) => a - b)
|
|
3512
|
+
globals.sort((a, b) => a - b)
|
|
3513
|
+
const pct = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))]
|
|
3514
|
+
const stats = (arr) => ({
|
|
3515
|
+
p50: Math.round(pct(arr, 0.5) * 1000) / 1000,
|
|
3516
|
+
p99: Math.round(pct(arr, 0.99) * 1000) / 1000,
|
|
3517
|
+
max: Math.round(arr[arr.length - 1] * 1000) / 1000,
|
|
3518
|
+
})
|
|
3519
|
+
const bs = stats(blockDiffs)
|
|
3520
|
+
const gs = stats(globals)
|
|
3521
|
+
const blockThresh = Math.min(32, Math.max(2, Math.ceil(bs.max + 2)))
|
|
3522
|
+
const changeGlobal = Math.min(0.3, Math.max(0.02, Math.round((gs.max + 0.01) * 1000) / 1000))
|
|
3523
|
+
const moved = bs.max > bs.p99 * 2 + 1
|
|
3524
|
+
return {
|
|
3525
|
+
ok: true, frames,
|
|
3526
|
+
block: bs, global: gs,
|
|
3527
|
+
recommended: { blockThresh, changeGlobal },
|
|
3528
|
+
apply: 'stream start --block-thresh ' + blockThresh + ' --change-global ' + changeGlobal,
|
|
3529
|
+
...(moved ? { warn: 'max 远超 p99(' + bs.max + ' vs ' + bs.p99 + '):校准期间疑似有真实变化,建议静止画面重跑' } : {}),
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
/* ────────────────────────── 消息分发 ────────────────────────── */
|
|
3534
|
+
const handlers = {
|
|
3535
|
+
/* 活性探针:客户端调用超时后先问这个——能应答说明 daemon 事件循环没死,只是这条
|
|
3536
|
+
* 调用超了;只失败该次调用,不杀进程(会话里的连接 / L0 / 事件环都保得住)。
|
|
3537
|
+
* 必须是无状态纯应答:probe 是设备扫描、run_stop 有副作用,都不能借来探活。 */
|
|
3538
|
+
ping: () => ({ ok: true, t: Date.now() }),
|
|
3539
|
+
init: (a) => {
|
|
3540
|
+
if (a.runDir) {
|
|
3541
|
+
S.runDir = String(a.runDir)
|
|
3542
|
+
S.previewPath = path.join(S.runDir, 'preview.png')
|
|
3543
|
+
try { fs.mkdirSync(S.runDir, { recursive: true }) } catch (e) { /* ignore */ }
|
|
3544
|
+
/* action-frames 无 kf 配额:一次性 CLI 每条 click 都落 2 张,纯累积会膨胀。
|
|
3545
|
+
* 不动当前运行——只清 >24h 的(上一个会话的残留)。 */
|
|
3546
|
+
try {
|
|
3547
|
+
const root = path.join(S.runDir, 'action-frames')
|
|
3548
|
+
if (fs.existsSync(root)) {
|
|
3549
|
+
const cutoff = Date.now() - 24 * 3600 * 1000
|
|
3550
|
+
for (const f of fs.readdirSync(root)) {
|
|
3551
|
+
const st = fs.statSync(path.join(root, f))
|
|
3552
|
+
if (st.mtimeMs < cutoff) fs.unlinkSync(path.join(root, f))
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
} catch (e) { /* ignore */ }
|
|
3556
|
+
}
|
|
3557
|
+
if (Number(a.kfQuotaBytes) > 0) S.kfQuota = Math.floor(Number(a.kfQuotaBytes))
|
|
3558
|
+
return { ok: true, previewPath: S.previewPath, daemonId: S.daemonId, framesDir: kfRoot(), kfQuota: S.kfQuota }
|
|
3559
|
+
},
|
|
3560
|
+
probe: cmdProbe,
|
|
3561
|
+
screencap_probe: cmdScreencapProbe,
|
|
3562
|
+
device_list: (a) => cmdDeviceList(a.kind),
|
|
3563
|
+
connect: cmdConnect,
|
|
3564
|
+
disconnect: cmdDisconnect,
|
|
3565
|
+
screencap: (a) => cmdScreencap(a.out),
|
|
3566
|
+
stream_start: cmdStreamStart,
|
|
3567
|
+
stream_stop: cmdStreamStop,
|
|
3568
|
+
stream_status: cmdStreamStatus,
|
|
3569
|
+
events_list: cmdEventsList,
|
|
3570
|
+
frame_get: cmdFrameGet,
|
|
3571
|
+
frame_diff: cmdFrameDiff,
|
|
3572
|
+
wait: cmdWait,
|
|
3573
|
+
tpl_crop: cmdTplCrop,
|
|
3574
|
+
annotate: cmdAnnotate,
|
|
3575
|
+
calibrate: cmdCalibrate,
|
|
3576
|
+
color_probe: cmdColorProbe,
|
|
3577
|
+
l0_status: cmdL0Status,
|
|
3578
|
+
kf_promote: cmdKfPromote,
|
|
3579
|
+
run: cmdRun,
|
|
3580
|
+
run_stop: cmdRunStop,
|
|
3581
|
+
input: cmdInput,
|
|
3582
|
+
reco_test: cmdRecoTest,
|
|
3583
|
+
shutdown: async () => { await cmdStreamStop(); await cleanupAgents(); await cmdDisconnect(); return { ok: true } },
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
/* 子进程模式:stdin JSON 行协议(每行一条消息)。非 --child(headless 单测 import)不挂监听。 */
|
|
3587
|
+
if (CHILD) {
|
|
3588
|
+
/* 兜底回收 agent 子进程:cleanupAgents 正常走 shutdown / run 重入,但 daemon 被 kill、
|
|
3589
|
+
* 崩溃或宿主超时硬杀时没人收——实测会留下成批孤儿 agent 子进程占着设备连接。
|
|
3590
|
+
* exit 里同步走 spawnSync taskkill /T + ident sweep——正常路径走 cleanupAgents
|
|
3591
|
+
* 的完整版,这里是"进程突然死"的防泄漏,不能因为宿主死了就把 agent 树留给系统。 */
|
|
3592
|
+
const killAgentChildrenSync = () => {
|
|
3593
|
+
const bind = S.agentBind
|
|
3594
|
+
if (!bind) return
|
|
3595
|
+
for (const c of bind.clients) {
|
|
3596
|
+
if (process.platform === 'win32') {
|
|
3597
|
+
const root = c.child && c.child.pid
|
|
3598
|
+
if (!root) continue
|
|
3599
|
+
try { spawnSync('taskkill', ['/pid', String(root), '/T', '/F'], { timeout: 8000 }) } catch (e) { /* ignore */ }
|
|
3600
|
+
if (c.ident) {
|
|
3601
|
+
const like = '%' + String(c.ident).replace(/'/g, '') + '%'
|
|
3602
|
+
const ps = '$self=$PID;Get-CimInstance Win32_Process|Where-Object{$_.ProcessId -ne $self -and $_.CommandLine -and $_.CommandLine -like \'' + like + '\'}|ForEach-Object{try{Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue}catch{}}'
|
|
3603
|
+
try { spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 15000 }) } catch (e) { /* ignore */ }
|
|
3604
|
+
}
|
|
3605
|
+
} else {
|
|
3606
|
+
try { process.kill(-c.child.pid, 'SIGKILL') } catch (e) { try { c.child.kill('SIGKILL') } catch (e2) { /* ignore */ } }
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
process.on('exit', killAgentChildrenSync)
|
|
3611
|
+
process.on('uncaughtException', (e) => {
|
|
3612
|
+
killAgentChildrenSync()
|
|
3613
|
+
/* 不吞掉异常:打印后让进程照常死,exit 里再收一次(幂等) */
|
|
3614
|
+
console.error('daemon 未捕获异常:' + String(e && e.stack || e))
|
|
3615
|
+
process.exit(2)
|
|
3616
|
+
})
|
|
3617
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
3618
|
+
/* 宿主已死 = stdin 管道断开:daemon 不自杀就成孤儿(实测 --session-stop 杀的是会话服务
|
|
3619
|
+
* 进程,daemon 还活着、它桥接的 agent 树也活着)。收到 close 就按 shutdown 同等规格
|
|
3620
|
+
* 收摊:停流 → 树杀 agent → 退。兜底 10s:cleanup 卡住也不许吊着。 */
|
|
3621
|
+
let hostGone = false
|
|
3622
|
+
const onHostGone = () => {
|
|
3623
|
+
if (hostGone) return
|
|
3624
|
+
hostGone = true
|
|
3625
|
+
Promise.resolve()
|
|
3626
|
+
.then(() => cmdStreamStop())
|
|
3627
|
+
.then(() => cleanupAgents())
|
|
3628
|
+
.catch(() => { /* ignore */ })
|
|
3629
|
+
.then(() => process.exit(0))
|
|
3630
|
+
setTimeout(() => process.exit(0), 10000).unref()
|
|
3631
|
+
}
|
|
3632
|
+
rl.on('close', onHostGone)
|
|
3633
|
+
/* 宿主已死 ≠ rl close / stdin end:实测两条都不触发(Node readline 在管道另一侧
|
|
3634
|
+
* 被 kill 时不给 end)。管道破开走 stdin 的 error/close;再补一条兜底轮询:
|
|
3635
|
+
* stdin 不再可读(readableEnded 或 destroyed)就按宿主死了走。 */
|
|
3636
|
+
process.stdin.on('end', onHostGone)
|
|
3637
|
+
process.stdin.on('error', onHostGone)
|
|
3638
|
+
process.stdin.on('close', onHostGone)
|
|
3639
|
+
/* 兜底轮询两条路:
|
|
3640
|
+
* ① stdin 状态:宿主正常退出(不是被 kill)时管道还给 end/close;
|
|
3641
|
+
* ② 宿主进程死了:Windows 下 process.kill(pid) = TerminateProcess,管道
|
|
3642
|
+
* 一侧直接没,任何 stdin 事件都不来(实测 end/close/error 全不触发)。
|
|
3643
|
+
* ppid 存活探测是唯一可靠信号——宿主死了 1s 内 daemon 自杀收摊。 */
|
|
3644
|
+
const hostPid = process.ppid
|
|
3645
|
+
const stdinProbe = setInterval(() => {
|
|
3646
|
+
try {
|
|
3647
|
+
if (process.stdin.destroyed || process.stdin.readableEnded) { onHostGone(); return }
|
|
3648
|
+
if (hostPid) {
|
|
3649
|
+
try { process.kill(hostPid, 0) } catch (e) { onHostGone() }
|
|
3650
|
+
}
|
|
3651
|
+
} catch (e) { /* ignore */ }
|
|
3652
|
+
}, 500)
|
|
3653
|
+
stdinProbe.unref()
|
|
3654
|
+
rl.on('line', (line) => {
|
|
3655
|
+
let m = null
|
|
3656
|
+
try { m = JSON.parse(line) } catch (e) {
|
|
3657
|
+
send({ kind: 'reply', id: -1, ok: false, error: 'bad json line' })
|
|
3658
|
+
return
|
|
3659
|
+
}
|
|
3660
|
+
if (!m || typeof m.cmd !== 'string') return
|
|
3661
|
+
const h = handlers[m.cmd]
|
|
3662
|
+
if (!h) { reply(m.id, false, 'unknown cmd: ' + m.cmd); return }
|
|
3663
|
+
Promise.resolve()
|
|
3664
|
+
.then(() => h(m))
|
|
3665
|
+
/* shutdown 的回执要落地再退:process.exit 不等 stdout 冲刷,而调用方在等这条应答 */
|
|
3666
|
+
.then((data) => reply(m.id, true, data, m.cmd === 'shutdown' ? () => process.exit(0) : null))
|
|
3667
|
+
.catch((e) => reply(m.id, false, String(e && e.message || e)))
|
|
3668
|
+
})
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
/* 纯函数导出(供 headless 验证直接断言,子进程运行中不使用)。
|
|
3672
|
+
* S 一并导出:l0EvictToBudget 等维护函数作用于模块态,单测需直改后还原。 */
|
|
3673
|
+
export const __test = {
|
|
3674
|
+
pngDecode, pngEncodeRGB, pngHeaderSize, downscale, blockAnalyze, blockHash, hashDist,
|
|
3675
|
+
regionOfBlocks, tightenBounds, tightenBoundsByEdges, edgeMap, cropRgb, connComponents, edgeDensityBoxes,
|
|
3676
|
+
readKfCropSource, buildL2Provenance, pickBalancedCandidates, pickNodeName, readRoi,
|
|
3677
|
+
l0EvictToBudget, cmdKfPromote, cmdTplCrop, kfUsageBytes, defaultOutFile, eventFrameAnchor, S,
|
|
3678
|
+
pickRecoBox, pickActionBox, anchorFromCacheDedup, checkExpect, blockHash, hashStr,
|
|
3679
|
+
setupAgents, buildLinuxConfig,
|
|
3680
|
+
}
|