@windsland52/maafw-live 0.0.0-stage → 0.3.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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -2
  3. package/bin/maafw-live.mjs +25 -0
  4. package/docs/daemon-protocol.md +116 -0
  5. package/docs/keyframe-evidence-contract-v0.md +61 -0
  6. package/docs/keyframe-retention-contract-v0.md +236 -0
  7. package/lib/client/daemon.js +234 -0
  8. package/lib/client/daemon.js.map +1 -0
  9. package/lib/commands/annotate.js +172 -0
  10. package/lib/commands/annotate.js.map +1 -0
  11. package/lib/commands/calibrate.js +61 -0
  12. package/lib/commands/calibrate.js.map +1 -0
  13. package/lib/commands/color.js +73 -0
  14. package/lib/commands/color.js.map +1 -0
  15. package/lib/commands/crop.js +228 -0
  16. package/lib/commands/crop.js.map +1 -0
  17. package/lib/commands/env.js +215 -0
  18. package/lib/commands/env.js.map +1 -0
  19. package/lib/commands/index.js +64 -0
  20. package/lib/commands/index.js.map +1 -0
  21. package/lib/commands/input.js +219 -0
  22. package/lib/commands/input.js.map +1 -0
  23. package/lib/commands/kf.js +131 -0
  24. package/lib/commands/kf.js.map +1 -0
  25. package/lib/commands/reco.js +211 -0
  26. package/lib/commands/reco.js.map +1 -0
  27. package/lib/commands/repl.js +698 -0
  28. package/lib/commands/repl.js.map +1 -0
  29. package/lib/commands/runtime.js +514 -0
  30. package/lib/commands/runtime.js.map +1 -0
  31. package/lib/commands/skill.js +169 -0
  32. package/lib/commands/skill.js.map +1 -0
  33. package/lib/commands/timing.js +229 -0
  34. package/lib/commands/timing.js.map +1 -0
  35. package/lib/commands/version.js +66 -0
  36. package/lib/commands/version.js.map +1 -0
  37. package/lib/daemon/framed.mjs +2850 -0
  38. package/lib/daemon/reco_child.mjs +146 -0
  39. package/lib/exec.js +96 -0
  40. package/lib/exec.js.map +1 -0
  41. package/lib/flags.js +42 -0
  42. package/lib/flags.js.map +1 -0
  43. package/lib/image.js +90 -0
  44. package/lib/image.js.map +1 -0
  45. package/lib/index.js +139 -0
  46. package/lib/index.js.map +1 -0
  47. package/lib/interface/index.js +12 -0
  48. package/lib/interface/index.js.map +1 -0
  49. package/lib/interface/jsonc.js +95 -0
  50. package/lib/interface/jsonc.js.map +1 -0
  51. package/lib/interface/load.js +307 -0
  52. package/lib/interface/load.js.map +1 -0
  53. package/lib/interface/override.js +103 -0
  54. package/lib/interface/override.js.map +1 -0
  55. package/lib/interface/plan.js +188 -0
  56. package/lib/interface/plan.js.map +1 -0
  57. package/lib/pkg.js +18 -0
  58. package/lib/pkg.js.map +1 -0
  59. package/lib/protocol.js +90 -0
  60. package/lib/protocol.js.map +1 -0
  61. package/lib/runtime/actions.js +110 -0
  62. package/lib/runtime/actions.js.map +1 -0
  63. package/lib/runtime/keyframes.js +73 -0
  64. package/lib/runtime/keyframes.js.map +1 -0
  65. package/lib/runtime/session.js +86 -0
  66. package/lib/runtime/session.js.map +1 -0
  67. package/lib/types/client/daemon.d.ts +86 -0
  68. package/lib/types/commands/annotate.d.ts +3 -0
  69. package/lib/types/commands/calibrate.d.ts +7 -0
  70. package/lib/types/commands/color.d.ts +9 -0
  71. package/lib/types/commands/crop.d.ts +3 -0
  72. package/lib/types/commands/env.d.ts +2 -0
  73. package/lib/types/commands/index.d.ts +13 -0
  74. package/lib/types/commands/input.d.ts +17 -0
  75. package/lib/types/commands/kf.d.ts +10 -0
  76. package/lib/types/commands/reco.d.ts +23 -0
  77. package/lib/types/commands/repl.d.ts +2 -0
  78. package/lib/types/commands/runtime.d.ts +48 -0
  79. package/lib/types/commands/skill.d.ts +17 -0
  80. package/lib/types/commands/timing.d.ts +64 -0
  81. package/lib/types/commands/version.d.ts +3 -0
  82. package/lib/types/exec.d.ts +31 -0
  83. package/lib/types/flags.d.ts +58 -0
  84. package/lib/types/image.d.ts +27 -0
  85. package/lib/types/index.d.ts +1 -0
  86. package/lib/types/interface/index.d.ts +11 -0
  87. package/lib/types/interface/jsonc.d.ts +9 -0
  88. package/lib/types/interface/load.d.ts +113 -0
  89. package/lib/types/interface/override.d.ts +33 -0
  90. package/lib/types/interface/plan.d.ts +47 -0
  91. package/lib/types/pkg.d.ts +6 -0
  92. package/lib/types/protocol.d.ts +108 -0
  93. package/lib/types/runtime/actions.d.ts +166 -0
  94. package/lib/types/runtime/keyframes.d.ts +43 -0
  95. package/lib/types/runtime/session.d.ts +53 -0
  96. package/lib/types/update-check.d.ts +20 -0
  97. package/lib/types/usage.d.ts +3 -0
  98. package/lib/update-check.js +97 -0
  99. package/lib/update-check.js.map +1 -0
  100. package/lib/usage.js +50 -0
  101. package/lib/usage.js.map +1 -0
  102. package/package.json +81 -4
  103. package/skills/README.md +51 -0
  104. package/skills/maafw-live/SKILL.md +263 -0
  105. package/skills/maafw-live/references/examples.md +238 -0
  106. package/skills/maafw-live/references/pitfalls.md +118 -0
  107. package/skills/maafw-live/references/workflows.md +159 -0
@@ -0,0 +1,2850 @@
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
+ */
19
+ import { createRequire } from 'node:module'
20
+ import { execFile, spawn } from 'node:child_process'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { createHash, randomUUID } from 'node:crypto'
23
+ import readline from 'node:readline'
24
+ import zlib from 'node:zlib'
25
+ import fs from 'node:fs'
26
+ import os from 'node:os'
27
+ import path from 'node:path'
28
+
29
+ const require = createRequire(import.meta.url)
30
+ /* 子进程模式(host 经 spawn 启动带 --child):stdin/stdout 走 JSON 行协议;
31
+ * 无 --child 时(headless 单测 import)模块无副作用。原生崩溃只杀子进程,不陪葬 host。 */
32
+ const CHILD = process.argv.includes('--child')
33
+ const send = (m, done) => {
34
+ if (!CHILD) { if (done) done(); return }
35
+ const line = JSON.stringify(m) + '\n'
36
+ if (done) process.stdout.write(line, done)
37
+ else process.stdout.write(line)
38
+ }
39
+
40
+ let maa = null
41
+ function loadMaa() {
42
+ if (!maa) maa = require('@maaxyz/maa-node')
43
+ return maa
44
+ }
45
+
46
+ /* ────────────────────────── 最小 PNG 解码(8bit RGB/RGBA,非隔行) ────────────────────────── */
47
+ const CRC_TABLE = (() => {
48
+ const t = new Uint32Array(256)
49
+ for (let n = 0; n < 256; n++) {
50
+ let c = n
51
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1
52
+ t[n] = c
53
+ }
54
+ return t
55
+ })()
56
+ function crc32(buf) {
57
+ let c = 0xFFFFFFFF
58
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8)
59
+ return (c ^ 0xFFFFFFFF) >>> 0
60
+ }
61
+ function pngChunk(type, data) {
62
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0)
63
+ const t = Buffer.from(type, 'ascii')
64
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0)
65
+ return Buffer.concat([len, t, data, crc])
66
+ }
67
+ function pngDecode(buf) {
68
+ if (buf[0] !== 0x89 || buf[1] !== 0x50) throw new Error('not a png')
69
+ let off = 8, w = 0, h = 0, bitDepth = 0, colorType = 0
70
+ const idat = []
71
+ while (off < buf.length) {
72
+ const len = buf.readUInt32BE(off)
73
+ const type = buf.toString('ascii', off + 4, off + 8)
74
+ const data = buf.subarray(off + 8, off + 8 + len)
75
+ if (type === 'IHDR') {
76
+ w = data.readUInt32BE(0); h = data.readUInt32BE(4)
77
+ bitDepth = data[8]; colorType = data[9]
78
+ if (data[12] !== 0) throw new Error('interlaced png unsupported')
79
+ } else if (type === 'IDAT') idat.push(Buffer.from(data))
80
+ else if (type === 'IEND') break
81
+ off += 12 + len
82
+ }
83
+ if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6)) {
84
+ throw new Error('unsupported png: depth=' + bitDepth + ' color=' + colorType)
85
+ }
86
+ const ch = colorType === 6 ? 4 : 3
87
+ const raw = zlib.inflateSync(Buffer.concat(idat))
88
+ const stride = w * ch
89
+ /* 解压后必须正好是 h 行 ×(每行 1 个滤镜字节 + 行字节)。少了就是文件被截断 / IDAT 不完整——
90
+ * 不校验的话 line[x] 取到 undefined,NaN & 255 静默变成 0,产出一张全黑图被当成证据。 */
91
+ const need = h * (stride + 1)
92
+ if (raw.length !== need) {
93
+ throw new Error('png 像素数据长度不符:期望 ' + need + ' 字节(' + h + ' 行 ×(1 + ' + stride + ')),实际 ' + raw.length)
94
+ }
95
+ const out = Buffer.alloc(w * h * ch)
96
+ let pos = 0
97
+ for (let y = 0; y < h; y++) {
98
+ const filter = raw[pos++]
99
+ const line = raw.subarray(pos, pos + stride); pos += stride
100
+ const cur = out.subarray(y * stride, (y + 1) * stride)
101
+ const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null
102
+ for (let x = 0; x < stride; x++) {
103
+ const a = x >= ch ? cur[x - ch] : 0
104
+ const b = prev ? prev[x] : 0
105
+ const c = prev && x >= ch ? prev[x - ch] : 0
106
+ let v = line[x]
107
+ if (filter === 1) v += a
108
+ else if (filter === 2) v += b
109
+ else if (filter === 3) v += (a + b) >> 1
110
+ else if (filter === 4) {
111
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
112
+ v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c)
113
+ }
114
+ cur[x] = v & 255
115
+ }
116
+ }
117
+ return { w, h, ch, data: out }
118
+ }
119
+ /** 归一化为 RGB(丢弃 alpha),返回新 Buffer */
120
+ function toRgb(dec) {
121
+ if (dec.ch === 3) return dec.data
122
+ const out = Buffer.alloc(dec.w * dec.h * 3)
123
+ for (let i = 0, o = 0; i < dec.data.length; i += 4, o += 3) {
124
+ out[o] = dec.data[i]; out[o + 1] = dec.data[i + 1]; out[o + 2] = dec.data[i + 2]
125
+ }
126
+ return out
127
+ }
128
+ function pngEncodeRGB(rgb, w, h) {
129
+ const stride = w * 3
130
+ const raw = Buffer.alloc((stride + 1) * h)
131
+ for (let y = 0; y < h; y++) {
132
+ raw[y * (stride + 1)] = 0
133
+ rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride)
134
+ }
135
+ const ihdr = Buffer.alloc(13)
136
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4)
137
+ ihdr[8] = 8; ihdr[9] = 2
138
+ return Buffer.concat([
139
+ Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
140
+ pngChunk('IHDR', ihdr),
141
+ pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
142
+ pngChunk('IEND', Buffer.alloc(0)),
143
+ ])
144
+ }
145
+
146
+ /* ────────────────────────── 图像降采样 / 感知哈希 ────────────────────────── */
147
+ function downscale(rgb, sw, sh, maxSide) {
148
+ const sc = Math.min(1, maxSide / Math.max(sw, sh))
149
+ const w = Math.max(8, Math.round(sw * sc))
150
+ const h = Math.max(8, Math.round(sh * sc))
151
+ if (w === sw && h === sh) return { w, h, data: rgb }
152
+ const out = Buffer.alloc(w * h * 3)
153
+ for (let y = 0; y < h; y++) {
154
+ const sy0 = Math.floor(y * sh / h)
155
+ const sy1 = Math.max(sy0 + 1, Math.floor((y + 1) * sh / h))
156
+ for (let x = 0; x < w; x++) {
157
+ const sx0 = Math.floor(x * sw / w)
158
+ const sx1 = Math.max(sx0 + 1, Math.floor((x + 1) * sw / w))
159
+ let r = 0, g = 0, b = 0
160
+ const n = (sx1 - sx0) * (sy1 - sy0)
161
+ for (let sy = sy0; sy < sy1; sy++) {
162
+ let i = (sy * sw + sx0) * 3
163
+ for (let sx = sx0; sx < sx1; sx++, i += 3) { r += rgb[i]; g += rgb[i + 1]; b += rgb[i + 2] }
164
+ }
165
+ const o = (y * w + x) * 3
166
+ out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n
167
+ }
168
+ }
169
+ return { w, h, data: out }
170
+ }
171
+ /** 8×8 分块亮度哈希(相对整体均值)+ 分块亮度均值(局部变化检测用) */
172
+ function blockAnalyze(rgb, w, h) {
173
+ const cells = new Float64Array(64)
174
+ const cnt = new Float64Array(64)
175
+ let total = 0, n = 0
176
+ for (let y = 0; y < h; y++) {
177
+ const cy = Math.min(7, (y * 8 / h) | 0) * 8
178
+ for (let x = 0; x < w; x++) {
179
+ const i = (y * w + x) * 3
180
+ const l = (rgb[i] * 3 + rgb[i + 1] * 6 + rgb[i + 2]) * 0.1
181
+ const ci = cy + Math.min(7, (x * 8 / w) | 0)
182
+ cells[ci] += l; cnt[ci]++; total += l; n++
183
+ }
184
+ }
185
+ const mean = total / Math.max(1, n)
186
+ const hash = new Uint8Array(64)
187
+ const means = new Float64Array(64)
188
+ for (let k = 0; k < 64; k++) {
189
+ means[k] = cells[k] / Math.max(1, cnt[k])
190
+ hash[k] = means[k] >= mean ? 1 : 0
191
+ }
192
+ return { hash, means }
193
+ }
194
+ /** 兼容导出:只要哈希位 */
195
+ function blockHash(rgb, w, h) {
196
+ return blockAnalyze(rgb, w, h).hash
197
+ }
198
+ function hashDist(a, b) {
199
+ let d = 0
200
+ for (let i = 0; i < 64; i++) if (a[i] !== b[i]) d++
201
+ return d / 64
202
+ }
203
+ const hashStr = (h) => Array.from(h, (v) => (v ? '1' : '0')).join('')
204
+
205
+ /* ────────────────────────── 状态 ────────────────────────── */
206
+ /* 关键帧库磁盘配额(契约 §4.1 保留与清理):满额拒绝新增留存并报告,不后台静默删除——
207
+ * 清理是用户的决定。init 可覆盖;env MAAFW_KF_QUOTA_BYTES 次之。
208
+ * 判据是 `S.kfQuota > 0`,也就是 0 = 不设限(单测直接改 S 走这条);但**从 env 与 init 到不了 0**:
209
+ * 两处都只认 >0 的值(缺省 1GiB),想不设限得由宿主直接控制 S。 */
210
+ const KF_QUOTA_DEFAULT = (() => {
211
+ const v = Number(process.env.MAAFW_KF_QUOTA_BYTES)
212
+ return Number.isFinite(v) && v > 0 ? v : 1024 * 1024 * 1024
213
+ })()
214
+ const S = {
215
+ ctrl: null, res: null, tasker: null,
216
+ session: null, // { kind, target, method }
217
+ daemonId: randomUUID(), // 本 daemon 实例身份(留存记录的 session.daemon)
218
+ connGen: 0, // 连接代次:每次 connect 成功 +1(留存记录定位用)
219
+ runDir: null, // host init 传入;关键帧库 = runDir/frames
220
+ kfQuota: KF_QUOTA_DEFAULT, // 库目录字节配额(>0 生效,0 = 不设限;env/init 都只认 >0)
221
+ fullW: 0, fullH: 0, // 控制器分辨率(默认短边 720p,pipeline roi/模板同空间)
222
+ previewPath: null, // 面板实时预览帧落盘路径(host 传 runDir 初始化)
223
+ stream: false, streamTimer: null,
224
+ streamFps: 8, streamScale: 480, maxFrames: 120,
225
+ /* 变化检测双阈值:全局 blockHash 位差率(分布式变化)+ 分块亮度差(局部变化,任一块超限即变化)。
226
+ * blockThresh 单位为亮度(0-255),默认 8 ≈ 3%;阈值需按真机噪声实测校准(roadmap 条目13)。 */
227
+ changeGlobal: 0.06, blockThresh: 8,
228
+ seq: 0,
229
+ ring: [], // {seq,t,rgb,w,h,fw,fh,hash,diff}——fw/fh 为该帧捕获时控制器尺寸
230
+ events: [], // {type:'change'|'stable', seq, t, diff, dur?}
231
+ framesMeta: [], // {seq,t,hash,diff,w,h}(元数据)
232
+ quietNow: false, stableCount: 0,
233
+ lastPng: null,
234
+ runActive: false,
235
+ /* agent 桥接(PI agent 子进程 ↔ maa.Client):绑定在某个 Resource 实例上,
236
+ * 同资源 + 同声明集合复用,换资源 / disconnect / shutdown 时清理。 */
237
+ agentBind: null,
238
+ /* L0 原图有界缓存(关键帧留存契约 §1-2):滚动区=最近 K 张原图("刚看到就能留");
239
+ * 锚区=change/stable 事件锚点、动作边界、run 起止(各自 FIFO,容量有界可配)。
240
+ * 张数之外还有字节预算:跨两区唯一 seq 去重计字节,超限先淘汰滚动区(锚区是策展证据,
241
+ * 滚动区只是"最近");Buffer 由两区共享,只有两区都淘汰才真正释放。 */
242
+ l0: {
243
+ roll: [], // {seq,t,png,w,h}
244
+ anchor: [], // {seq,t,png,w,h,source}
245
+ rollCap: 16, anchorCap: 32,
246
+ bytesCap: 192 * 1024 * 1024, // 唯一字节预算(默认 192MB ≈ 1080p×48 张上限)
247
+ evicted: 0,
248
+ },
249
+ }
250
+ const STATUS = { pending: 1000, running: 2000, succeeded: 3000, failed: 4000 }
251
+
252
+ function reply(id, ok, data, done) {
253
+ send({ kind: 'reply', id, ok, ...(ok ? { data } : { error: data }) }, done)
254
+ }
255
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
256
+
257
+ /* ────────────────────────── 设备 / 连接 / 截图 ────────────────────────── */
258
+ async function cmdProbe() {
259
+ const m = loadMaa()
260
+ const out = { version: null, adb: null, win32: null, errors: [] }
261
+ try { out.version = String(m.Global.version) } catch (e) { out.errors.push('version: ' + e.message) }
262
+ try { const a = await m.AdbController.find(); out.adb = a ? a.length : 0 } catch (e) { out.errors.push('adb: ' + e.message) }
263
+ try { const w = await m.Win32Controller.find(); out.win32 = w ? w.length : 0 } catch (e) { out.errors.push('win32: ' + e.message) }
264
+ return out
265
+ }
266
+
267
+ async function cmdDeviceList(kind) {
268
+ const m = loadMaa()
269
+ const list = []
270
+ if (kind !== 'win32') {
271
+ try {
272
+ const a = await m.AdbController.find()
273
+ 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') })
274
+ } catch (e) { /* 无 adb 环境 */ }
275
+ }
276
+ if (kind !== 'adb') {
277
+ try {
278
+ const w = await m.Win32Controller.find()
279
+ if (w) for (const d of w) list.push({ kind: 'win32', id: String(d[0]), cls: String(d[1]), name: String(d[2] ?? '') })
280
+ } catch (e) { /* 无窗口 */ }
281
+ }
282
+ return list
283
+ }
284
+
285
+ function pickWin32(target, wins) {
286
+ const named = wins.filter((d) => d.kind === 'win32')
287
+ if (!named.length) return null
288
+ if (target) {
289
+ const t = String(target)
290
+ /* hwnd 精确匹配优先:面板与控制器规划器传的就是 hwnd(id)。
291
+ 标题匹配放后面——窗口标题可能重复或互为子串。 */
292
+ const exact = named.find((d) => d.id === t)
293
+ if (exact) return exact
294
+ const lower = t.toLowerCase()
295
+ const hit = named.find((d) => d.name.toLowerCase().includes(lower) || d.cls.toLowerCase().includes(lower))
296
+ /* 指定了 target 却没匹配上时**不**退化成"随便挑一个":
297
+ 那是静默连错窗口,比连不上更难查(实测踩过)。 */
298
+ return hit ?? null
299
+ }
300
+ return named.find((d) => d.name) || named[0]
301
+ }
302
+
303
+ /**
304
+ * 枚举名 → 数值。interface.json 里 screencap/mouse/keyboard 写的是枚举名
305
+ * (如 PrintWindow / SendMessageWithCursorPos),maa-node 要的是数值;
306
+ * 名字大小写不敏感(与本体 string_to_enum 的 iequals 一致)。
307
+ * 认不出的名字不硬塞给原生层,退回默认并把警告带回上层。
308
+ */
309
+ function resolveEnum(table, value, fallback, label, warns) {
310
+ if (value === undefined || value === null || value === '') return String(fallback)
311
+ const s = String(value)
312
+ if (/^\d+$/.test(s)) return s
313
+ if (table && typeof table === 'object') {
314
+ const key = Object.keys(table).find((k) => k.toLowerCase() === s.toLowerCase())
315
+ if (key !== undefined) return String(table[key])
316
+ }
317
+ warns.push(label + '=' + s + ' 不是当前 maa-node 绑定认识的枚举名,已退回默认值 ' + fallback)
318
+ return String(fallback)
319
+ }
320
+
321
+ async function cmdConnect(args) {
322
+ const m = loadMaa()
323
+ if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
324
+ /* 控制器重建 = 旧流失效:显式停掉并在回执里说明(`streamStopped`),别让调用方以为流还在跑。
325
+ * 曾经的行为是"tick 撞到空控制器后静默死掉、S.stream 仍为 true",于是重连后的 stream_start 被
326
+ * "已启动"挡回去,表现为一帧都不来(实测:seq 卡死,而 screencap 正常)。 */
327
+ const streamStopped = S.stream === true
328
+ if (S.stream) await cmdStreamStop()
329
+ S.session = null
330
+ let ctrl = null
331
+ let session = null
332
+ const warns = []
333
+ if (args.kind === 'adb') {
334
+ const adbs = (await m.AdbController.find()) || []
335
+ const addr = args.target || (adbs[0] ? String(adbs[0][2] ?? adbs[0][0]) : null)
336
+ if (!addr) return { ok: false, error: '未发现 adb 设备,且未提供 address' }
337
+ const dev = adbs.find((d) => String(d[2] ?? d[0]) === String(addr))
338
+ ?? (args.target ? null : adbs[0])
339
+ /* 指定了 address 却不在已发现列表里:明确报错,不静默连到别的设备上 */
340
+ if (!dev) return { ok: false, error: 'adb 地址 ' + addr + ' 不在已发现设备中(共 ' + adbs.length + ' 台)' }
341
+ const [name, adbPath, address, caps, inputs, config] = dev
342
+ ctrl = new m.AdbController(adbPath || 'adb', String(address ?? addr),
343
+ caps && caps.length ? caps : [1, 2], inputs && inputs.length ? inputs : [1, 2], config || '{}')
344
+ session = { kind: 'adb', target: String(address ?? addr), name: String(name) }
345
+ } else if (args.kind === 'win32' || args.kind === 'gamepad') {
346
+ const wins = await cmdDeviceList('win32')
347
+ const pick = pickWin32(args.target, wins)
348
+ if (!pick) {
349
+ return {
350
+ ok: false,
351
+ error: args.target
352
+ ? 'win32 窗口未找到:' + args.target + '(当前 ' + wins.length + ' 个窗口;hwnd 变了或窗口已关闭)'
353
+ : '未发现 win32 窗口',
354
+ }
355
+ }
356
+ /* 截图/输入方法:优先用调用方给的(项目驱动路径 = interface.json 声明 + 官方缺省)。
357
+ 缺省值照 maa-support buildControllerRuntime(controller.ts:95-102)的官方解释:
358
+ screencap=FramePool、mouse=SendMessageWithCursorPos、keyboard=SendMessage。
359
+ (此前用的 Foreground/SendMessage 是 daemon 自己的历史缺省,项目没声明时会偏离官方行为。) */
360
+ const cap = resolveEnum(m.Win32ScreencapMethod, args.screencap, m.Win32ScreencapMethod?.FramePool ?? 2, 'screencap', warns)
361
+ if (args.kind === 'gamepad') {
362
+ /* GamepadController 构造器签名(maa-node .d.ts):(hwnd, gamepad_type, screencap_method)。
363
+ gamepad_type 枚举名→值照 m.GamepadType(Xbox360/DualShock4,缺省 Xbox360)。
364
+ 输入走虚拟手柄,需宿主装 ViGEm Bus Driver,否则 connect 会在原生层失败。 */
365
+ const padType = resolveEnum(m.GamepadType, args.gamepadType, m.GamepadType?.Xbox360 ?? 0, 'gamepadType', warns)
366
+ ctrl = new m.GamepadController(pick.id, padType, cap)
367
+ session = { kind: 'gamepad', target: pick.id, name: pick.name || pick.cls, cls: pick.cls, method: { screencap: cap, gamepadType: padType } }
368
+ } else {
369
+ const mouse = resolveEnum(m.Win32InputMethod, args.mouse, m.Win32InputMethod?.SendMessageWithCursorPos ?? 32, 'mouse', warns)
370
+ const key = resolveEnum(m.Win32InputMethod, args.keyboard, m.Win32InputMethod?.SendMessage ?? 2, 'keyboard', warns)
371
+ ctrl = new m.Win32Controller(pick.id, cap, mouse, key)
372
+ session = { kind: 'win32', target: pick.id, name: pick.name || pick.cls, cls: pick.cls, method: { screencap: cap, mouse, keyboard: key } }
373
+ }
374
+ } else if (args.kind === 'playcover' || args.kind === 'macos') {
375
+ /* PlayCover / MacOS 控制器仅 macOS 可用(maa-node 本体在其它平台编译即排除)。
376
+ 非 macOS 宿主不给"也许能连"的尝试,直接说明。 */
377
+ if (process.platform !== 'darwin') {
378
+ return { ok: false, error: '控制器类型 ' + args.kind + ' 仅支持在 macOS 上使用;当前宿主是 ' + process.platform }
379
+ }
380
+ if (args.kind === 'playcover') {
381
+ /* PlayCoverController(address, uuid):address 是 PlayCover 设备侧服务地址(如 127.0.0.1:port),
382
+ uuid 是 PlayCover 显示的设备 UUID;两个都必须显式给,猜不了。 */
383
+ const parts = String(args.target ?? '').split('/')
384
+ const address = parts[0] || ''
385
+ const uuid = args.uuid ? String(args.uuid) : (parts[1] || '')
386
+ if (!address || !uuid) {
387
+ return { ok: false, error: 'playcover 需要 target=<address>/<uuid>(PlayCover 设置里可见,两者都必填)' }
388
+ }
389
+ ctrl = new m.PlayCoverController(address, uuid)
390
+ session = { kind: 'playcover', target: address + '/' + uuid }
391
+ } else {
392
+ /* MacOSController(window_id, screencap_method, input_method):窗口用 MacOSController.find() 枚举。 */
393
+ const wins = (await m.MacOSController.find()) || []
394
+ const pick = args.target
395
+ ? (wins.find((d) => String(d[0]) === String(args.target)) ??
396
+ wins.find((d) => String(d[2] ?? '').includes(String(args.target))))
397
+ : (wins[0] ?? null)
398
+ if (!pick) {
399
+ return { ok: false, error: args.target ? 'macOS 窗口未找到:' + args.target : '未发现 macOS 窗口' }
400
+ }
401
+ const cap = resolveEnum(m.MacOSScreencapMethod, args.screencap, m.MacOSScreencapMethod?.ScreenCaptureKit ?? 2, 'screencap', warns)
402
+ const input = resolveEnum(m.MacOSScreencapMethod, args.keyboard, m.MacOSScreencapMethod?.ScreenCaptureKit ?? 2, 'keyboard', warns)
403
+ ctrl = new m.MacOSController(pick.id ?? pick[0], cap, input)
404
+ session = { kind: 'macos', target: String(pick[0]), name: String(pick[2] ?? ''), method: { screencap: cap, input } }
405
+ }
406
+ } else if (args.kind === 'linux') {
407
+ /* LinuxController(config JSON):wlroots / PipeWire / uinput / Libei 会话按配置串交给本体。
408
+ config 必须显式给(含 screencap_method / input_method 等必填字段),工具不猜会话形态。 */
409
+ if (process.platform !== 'linux') {
410
+ return { ok: false, error: '控制器类型 linux 仅支持在 Linux 上使用;当前宿主是 ' + process.platform }
411
+ }
412
+ const cfg = String(args.config ?? args.target ?? '')
413
+ if (!cfg.trim().startsWith('{')) {
414
+ return { ok: false, error: 'linux 控制器需要 config=<JSON>(screencap_method/input_method 必填;wlroots 要 wlr_socket_path,PipeWire 要 pw_socket_fd+pw_node_id 等)' }
415
+ }
416
+ ctrl = new m.LinuxController(cfg)
417
+ session = { kind: 'linux', target: '(config)' }
418
+ } else {
419
+ return { ok: false, error: '未知控制器类型:' + args.kind + '(支持 win32/adb/gamepad/playcover/macos/linux)' }
420
+ }
421
+ /* 截图缩放目标:默认不干预 —— maafw 本体按 interface.json 的 short_side(缺省最短边 720p)缩放。
422
+ * 只有用户显式传参才覆盖;注意这会改变 Tasker 识别图像的尺寸(模板/ROI 坐标系随之变化)。 */
423
+ try {
424
+ if (args.shortSide) ctrl.screenshot_target_short_side = Number(args.shortSide)
425
+ if (args.longSide) ctrl.screenshot_target_long_side = Number(args.longSide)
426
+ if (args.rawSize) ctrl.screenshot_use_raw_size = true
427
+ } catch (e) { /* 绑定版本不支持 setter 时忽略 */ }
428
+ S.ctrl = ctrl
429
+ await ctrl.post_connection().wait()
430
+ S.session = session
431
+ S.connGen++
432
+ /* 预热一次截图,取控制器分辨率(默认短边 720 的坐标系,pipeline 的 roi/模板即此空间) */
433
+ let res = null
434
+ try {
435
+ const j = ctrl.post_screencap(); await j.wait()
436
+ const buf = j.get()
437
+ if (buf && buf.byteLength > 8) {
438
+ const dec = pngDecode(Buffer.from(buf))
439
+ S.fullW = dec.w; S.fullH = dec.h
440
+ res = { w: dec.w, h: dec.h, bytes: buf.byteLength }
441
+ }
442
+ } catch (e) { res = { error: String(e.message) } }
443
+ return {
444
+ ok: true,
445
+ session: { ...session, resolution: res, ...(warns.length ? { warns } : {}) },
446
+ /* 重连会重建控制器 → 旧流已停;调用方要接着看帧就得重新 stream_start(回执里说清楚,不靠猜) */
447
+ ...(streamStopped ? { streamStopped: true } : {}),
448
+ }
449
+ }
450
+
451
+ async function cmdDisconnect() {
452
+ if (S.stream) await cmdStreamStop()
453
+ await cleanupAgents()
454
+ if (S.tasker) { try { S.tasker.destroy() } catch (e) { /* ignore */ } S.tasker = null }
455
+ if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
456
+ S.session = null
457
+ return { ok: true }
458
+ }
459
+
460
+ async function cmdScreencap(outFile) {
461
+ if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
462
+ const j = S.ctrl.post_screencap()
463
+ await j.wait()
464
+ const buf = j.get()
465
+ if (!buf || buf.byteLength < 8) return { ok: false, error: '截图为空' }
466
+ const b = Buffer.from(buf)
467
+ const dec = pngDecode(b)
468
+ /* 截图也是一次观测:进环、进 L0(有捕获身份,可被 kf promote --seq 指定升格) */
469
+ pushFrame(dec, b)
470
+ const d = defaultOutFile(S.runDir, 'screencap');
471
+ const file = outFile || d.file
472
+ fs.mkdirSync(path.dirname(file), { recursive: true })
473
+ fs.writeFileSync(file, b)
474
+ return { ok: true, path: file, bytes: b.length, w: dec.w, h: dec.h, seq: S.seq }
475
+ }
476
+
477
+ /* ────────────────────────── 帧流(自适应节奏 + 事件合并) ────────────────────────── */
478
+ /** 变化块的联合 bbox:小图坐标 + 控制器坐标(8×8 网格,块边界按小图像素取整,外扩不收缩) */
479
+ function regionOfBlocks(blocks, w, h, fw, fh) {
480
+ let bx0 = 8, by0 = 8, bx1 = -1, by1 = -1
481
+ for (const k of blocks) {
482
+ const bx = k % 8, by = (k / 8) | 0
483
+ if (bx < bx0) bx0 = bx
484
+ if (by < by0) by0 = by
485
+ if (bx > bx1) bx1 = bx
486
+ if (by > by1) by1 = by
487
+ }
488
+ const x0 = Math.round(bx0 * w / 8), y0 = Math.round(by0 * h / 8)
489
+ const x1 = Math.round((bx1 + 1) * w / 8), y1 = Math.round((by1 + 1) * h / 8)
490
+ const sx = fw > 0 ? fw / w : 1, sy = fh > 0 ? fh / h : 1
491
+ return {
492
+ small: [x0, y0, x1 - x0, y1 - y0],
493
+ ctrl: [Math.round(x0 * sx), Math.round(y0 * sy), Math.round((x1 - x0) * sx), Math.round((y1 - y0) * sy)],
494
+ }
495
+ }
496
+
497
+ /** L0 锚区入队(按 seq 去重:同一捕获可能既是滚动帧又是事件锚点,字节共用同一 Buffer)。
498
+ * relabel:显式来源(动作边界/节点锚点)覆盖同帧已被事件路径标上的 change/stable——
499
+ * 一个捕获一个锚点,但标签以显式意图为准。 */
500
+ function l0AnchorAdd(entry, relabel = false) {
501
+ const existing = S.l0.anchor.find((x) => x.seq === entry.seq)
502
+ if (existing) {
503
+ if (relabel && entry.source) existing.source = entry.source
504
+ return
505
+ }
506
+ S.l0.anchor.push(entry)
507
+ if (S.l0.anchor.length > S.l0.anchorCap) S.l0.anchor.splice(0, S.l0.anchor.length - S.l0.anchorCap)
508
+ l0EvictToBudget()
509
+ }
510
+
511
+ /** 字节预算淘汰:唯一 seq 去重计总字节,超限先淘汰滚动区最旧、仍超再淘汰锚区最旧。 */
512
+ function l0EvictToBudget() {
513
+ const cap = S.l0.bytesCap
514
+ if (!(cap > 0)) return
515
+ const bytesOf = (e) => (e.png ? e.png.length : 0)
516
+ let total = 0
517
+ const seen = new Set()
518
+ for (const e of S.l0.roll) if (!seen.has(e.seq)) { seen.add(e.seq); total += bytesOf(e) }
519
+ for (const e of S.l0.anchor) if (!seen.has(e.seq)) { seen.add(e.seq); total += bytesOf(e) }
520
+ while (total > cap) {
521
+ const r0 = S.l0.roll[0]
522
+ if (r0) {
523
+ S.l0.roll.shift()
524
+ if (!S.l0.anchor.some((x) => x.seq === r0.seq)) total -= bytesOf(r0)
525
+ } else if (S.l0.anchor.length) {
526
+ total -= bytesOf(S.l0.anchor.shift())
527
+ } else break
528
+ S.l0.evicted++
529
+ }
530
+ }
531
+
532
+ /**
533
+ * 一次捕获的统一入口:原图字节先进 L0(先存后解,不解码即落袋),观测面再解码进环。
534
+ * png 是控制器交付的原始 PNG 字节(不解码、不重编码);dec 是它解码后的观测数据。
535
+ */
536
+ function pushFrame(dec, png) {
537
+ const rgb = toRgb(dec)
538
+ const small = downscale(rgb, dec.w, dec.h, S.streamScale)
539
+ const { hash, means } = blockAnalyze(small.data, small.w, small.h)
540
+ const prev = S.ring.length ? S.ring[S.ring.length - 1] : null
541
+ const diff = prev ? hashDist(prev.hash, hash) : 0
542
+ S.seq++
543
+ 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 }
544
+ S.ring.push(fr)
545
+ if (S.ring.length > S.maxFrames) S.ring.shift()
546
+ S.fullW = dec.w; S.fullH = dec.h
547
+ const l0 = { seq: fr.seq, t: fr.t, png, w: dec.w, h: dec.h }
548
+ S.l0.roll.push(l0)
549
+ if (S.l0.roll.length > S.l0.rollCap) S.l0.roll.splice(0, S.l0.roll.length - S.l0.rollCap)
550
+ l0EvictToBudget()
551
+ const meta = { seq: fr.seq, t: fr.t, hash: hashStr(hash), diff: Math.round(diff * 1000) / 1000, w: fr.w, h: fr.h }
552
+ S.framesMeta.push(meta)
553
+ if (S.framesMeta.length > 2000) S.framesMeta.splice(0, S.framesMeta.length - 2000)
554
+ /* 预览帧落盘(原子替换):面板 HTTP 直读,避免二进制走 JSON 行协议 */
555
+ const preview = pngEncodeRGB(small.data, small.w, small.h)
556
+ if (S.previewPath) {
557
+ const tmp = S.previewPath + '.tmp'
558
+ try { fs.writeFileSync(tmp, preview); fs.renameSync(tmp, S.previewPath) } catch (e) { /* 落盘失败不致命 */ }
559
+ }
560
+ send({ kind: 'frame', meta, preview: S.previewPath })
561
+ /* 变化 / 稳定事件(400ms 窗口合并);锚区只收"新事件"的首帧,合并窗口内不重复入锚。
562
+ * 双阈值:全局位差率管分布式小变化,分块亮度差管局部小区域变化(小区域位差占比低,
563
+ * 单看全局阈值会漏检且漏检导致降频加剧漏检)。变化事件携带变化区域 bbox(小图与控制器两套坐标)。 */
564
+ let changed = null
565
+ if (prev && means) {
566
+ const blocks = []
567
+ for (let k = 0; k < 64; k++) {
568
+ if (Math.abs(means[k] - prev.means[k]) > S.blockThresh) blocks.push(k)
569
+ }
570
+ if (blocks.length) changed = { blocks, region: regionOfBlocks(blocks, fr.w, fr.h, fr.fw, fr.fh) }
571
+ }
572
+ const isChange = diff > S.changeGlobal || changed !== null
573
+ if (isChange) {
574
+ S.quietNow = false
575
+ const last = S.events.length ? S.events[S.events.length - 1] : null
576
+ if (last && last.type === 'change' && fr.t - last.t < 400) {
577
+ last.t = fr.t; last.seq = fr.seq; last.diff = Math.max(last.diff, diff)
578
+ } else {
579
+ pushEvent({
580
+ type: 'change', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000,
581
+ ...(changed ? { blocks: changed.blocks.length, region: changed.region } : {}),
582
+ })
583
+ l0AnchorAdd({ ...l0, source: 'change' })
584
+ }
585
+ } else if (prev) {
586
+ if (!S.quietNow) {
587
+ S.quietNow = true
588
+ pushEvent({ type: 'stable', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000 })
589
+ l0AnchorAdd({ ...l0, source: 'stable' })
590
+ }
591
+ }
592
+ }
593
+ /**
594
+ * 主动抓一张原图并入观测流(seq 前进、进环、进 L0,可作动作/run 边界锚点)。
595
+ * 采集失败如实上报,绝不阻断调用方的动作——动作结果与留存失败分别报告(契约 §9)。
596
+ */
597
+ async function captureNow(source) {
598
+ if (!S.ctrl) return { ok: false, error: '未连接设备' }
599
+ try {
600
+ const j = S.ctrl.post_screencap()
601
+ await j.wait()
602
+ const buf = j.get()
603
+ if (!buf || buf.byteLength < 8) return { ok: false, error: '空截图' }
604
+ const b = Buffer.from(buf)
605
+ const dec = pngDecode(b)
606
+ pushFrame(dec, b)
607
+ const ent = S.l0.roll[S.l0.roll.length - 1]
608
+ l0AnchorAdd({ ...ent, source }, true)
609
+ return { ok: true, seq: ent.seq, t: ent.t, w: ent.w, h: ent.h }
610
+ } catch (e) {
611
+ return { ok: false, error: String(e && e.message || e) }
612
+ }
613
+ }
614
+
615
+ /**
616
+ * 用控制器缓存图入锚区(node-ok/fail 锚点的实现方式)。
617
+ * 关键约束:不要在节点回调里发 post_screencap——add_sink 回调中并发截图会与 Tasker
618
+ * 识别回路竞争控制器,实测触发原生崩溃 0xC0000005(maa-node 5.14.2 win32-x64,
619
+ * 2026-10-05 真机:同任务关锚点正常 / 开锚点必崩)。
620
+ * cached_image 是框架识别回路自己截的图:零新任务、无竞争,且节点回调时刻的缓存
621
+ * 通常就是该节点识别所用的那张(bound 倾向);但下一节点识别可能已刷新缓存,
622
+ * 关联强度按 adjacent 报,不冒称 bound(契约 §4)。 */
623
+ function anchorFromCache(source) {
624
+ if (!S.ctrl) return
625
+ let img = null
626
+ try { img = S.ctrl.cached_image } catch (e) { return }
627
+ if (!img || img.byteLength < 9) return
628
+ const b = Buffer.from(img)
629
+ let dec = null
630
+ try { dec = pngDecode(b) } catch (e) { return }
631
+ pushFrame(dec, b)
632
+ const ent = S.l0.roll[S.l0.roll.length - 1]
633
+ l0AnchorAdd({ ...ent, source }, true)
634
+ }
635
+ /** 事件入环 + 推送 host(cap 1200) */
636
+ function pushEvent(ev) {
637
+ S.events.push(ev)
638
+ if (S.events.length > 1200) S.events.splice(0, S.events.length - 1200)
639
+ send({ kind: 'event', ev })
640
+ }
641
+
642
+ async function streamTick() {
643
+ /* 控制器没了(典型:connect 会先销毁旧 Controller 再重建)时**必须把流的开关也关掉**:
644
+ * 只 return 不排期会让循环静默死掉,而 S.stream 仍是 true → 之后的 stream_start 被"已启动"挡回去,
645
+ * 表现为"重连后一帧都不来"(实测:seq 卡死、screencap 却正常)。 */
646
+ if (!S.ctrl) {
647
+ S.stream = false
648
+ if (S.streamTimer) { clearTimeout(S.streamTimer); S.streamTimer = null }
649
+ send({ kind: 'stream_stopped', reason: '控制器已销毁(connect 会重建控制器,需要时重新 stream_start)' })
650
+ return
651
+ }
652
+ if (!S.stream) return
653
+ const t0 = Date.now()
654
+ try {
655
+ const j = S.ctrl.post_screencap()
656
+ await j.wait()
657
+ const buf = j.get()
658
+ if (buf && buf.byteLength > 8) pushFrame(pngDecode(Buffer.from(buf)), Buffer.from(buf))
659
+ } catch (e) {
660
+ send({ kind: 'stream_error', error: String(e && e.message || e) })
661
+ }
662
+ if (!S.stream) return
663
+ const elapsed = Date.now() - t0
664
+ const recent = S.ring.slice(-3)
665
+ const quiet = S.quietNow || (recent.length >= 2 && recent.every((r) => r.diff <= S.changeGlobal))
666
+ const wait = quiet ? 1000 : Math.max(60, Math.round(1000 / Math.max(1, S.streamFps)))
667
+ S.streamTimer = setTimeout(streamTick, Math.max(0, wait - elapsed))
668
+ }
669
+
670
+ async function cmdStreamStart(args) {
671
+ if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
672
+ if (args.fps) S.streamFps = Math.min(30, Math.max(1, Number(args.fps)))
673
+ if (args.scale) S.streamScale = Math.min(1280, Math.max(160, Number(args.scale)))
674
+ if (args.maxFrames) S.maxFrames = Math.min(600, Math.max(20, Number(args.maxFrames)))
675
+ if (args.l0Roll) S.l0.rollCap = Math.min(64, Math.max(1, Number(args.l0Roll)))
676
+ if (args.l0Anchor) S.l0.anchorCap = Math.min(128, Math.max(1, Number(args.l0Anchor)))
677
+ if (args.l0Bytes !== undefined) S.l0.bytesCap = Math.max(0, Number(args.l0Bytes))
678
+ if (args.blockThresh !== undefined) S.blockThresh = Math.min(64, Math.max(1, Number(args.blockThresh)))
679
+ if (args.changeGlobal !== undefined) S.changeGlobal = Math.min(1, Math.max(0.005, Number(args.changeGlobal)))
680
+ if (!S.stream) {
681
+ S.stream = true
682
+ S.quietNow = false
683
+ S.streamTimer = setTimeout(streamTick, 0)
684
+ }
685
+ return {
686
+ ok: true, fps: S.streamFps, scale: S.streamScale, maxFrames: S.maxFrames,
687
+ l0Roll: S.l0.rollCap, l0Anchor: S.l0.anchorCap,
688
+ blockThresh: S.blockThresh, changeGlobal: S.changeGlobal,
689
+ }
690
+ }
691
+ async function cmdStreamStop() {
692
+ S.stream = false
693
+ if (S.streamTimer) { clearTimeout(S.streamTimer); S.streamTimer = null }
694
+ return { ok: true }
695
+ }
696
+ function cmdStreamStatus() {
697
+ return {
698
+ ok: true, running: S.stream, fps: S.streamFps, scale: S.streamScale,
699
+ maxFrames: S.maxFrames, seq: S.seq, ring: S.ring.length,
700
+ events: S.events.length, full: S.fullW ? [S.fullW, S.fullH] : null,
701
+ session: S.session,
702
+ }
703
+ }
704
+
705
+ /* ────────────────────────── 历史帧取用(ROI 裁剪,像素按需出 worker) ────────────────────────── */
706
+ /**
707
+ * roi 参数校验:只接受 4 个**有限数字**。
708
+ * 非数字会一路传成 NaN 框——`Math.max(0, Math.floor(NaN))` 还是 NaN,于是要么静默错剪一块、
709
+ * 要么在下游报一个跟病因无关的错("越界"/"参数类型")。与 tpl_crop 的 `roi/point/pad` 校验同一口径。
710
+ * 返回 `{ roi: null }` 表示"没给 roi"(调用方按整帧处理)。
711
+ */
712
+ function readRoi(raw) {
713
+ if (raw === undefined || raw === null) return { roi: null }
714
+ if (!Array.isArray(raw) || raw.length !== 4) {
715
+ return { error: 'roi 必须是 4 个数字 [x,y,w,h],收到 ' + JSON.stringify(raw) }
716
+ }
717
+ const roi = raw.map(Number)
718
+ if (!roi.every((v) => Number.isFinite(v))) {
719
+ /* 只拦 NaN(非数字字符串、对象等)。**`null` 会被 Number(null) 收成 0**,那是"合法的退化框",
720
+ * 由下游夹取兜住——与 tpl_crop 的 roi/point/pad 校验同一口径(拦 NaN,不拦 0)。 */
721
+ return { error: 'roi 必须是数字(非数字字符串这类会退化成 NaN):' + JSON.stringify(raw) }
722
+ }
723
+ return { roi }
724
+ }
725
+
726
+ /** L0(滚动区 + 锚区)里按 seq 找全分辨率原图;命中即拿到控制器分辨率的 1:1 像素。
727
+ * 两条区共用同一份 Buffer 时按 seq 去重,所以先滚后锚即可。 */
728
+ function findL0(seq) {
729
+ return S.l0.roll.find((e) => e.seq === seq) || S.l0.anchor.find((e) => e.seq === seq) || null
730
+ }
731
+
732
+ function cmdFrameGet(args) {
733
+ let fr = null
734
+ if (args.seq === undefined || args.seq === null) fr = S.ring[S.ring.length - 1]
735
+ else fr = S.ring.find((r) => r.seq === Number(args.seq))
736
+ if (!fr) return { ok: false, error: '帧不在缓冲中(seq=' + args.seq + ',范围 ' + (S.ring[0] ? S.ring[0].seq : 0) + '..' + S.seq + ')' }
737
+ const outFile = args.out || defaultOutFile(S.runDir, 'frame-' + fr.seq).file
738
+ const roiIn = readRoi(args.roi)
739
+ if (roiIn.error) return { ok: false, error: roiIn.error }
740
+
741
+ /* `src` 决定像素从哪来——**降采样必须是显式选择,不能是静默降级**:
742
+ * auto(缺省) ROI 先试 L0 全分辨率原图,淘汰了才退环形缓冲并如实标注;
743
+ * full 只要全分辨率;L0 已淘汰就报错,绝不给一张缩小图冒充;
744
+ * ring 强制走环形缓冲小图(旧行为,供面板等已知消费方)。
745
+ * 回执一律带 source / sourceResolution:调用方不必猜手里这张图是什么分辨率。 */
746
+ const src = String(args.src ?? 'auto').toLowerCase()
747
+ const wantRing = src === 'ring' || src === 'l1'
748
+ const wantFull = src === 'full' || src === 'l0'
749
+ if (!wantRing && !wantFull && src !== 'auto') {
750
+ return { ok: false, error: 'src 只支持 auto | full | ring,收到 ' + JSON.stringify(args.src) }
751
+ }
752
+ const l0hit = wantRing ? null : findL0(fr.seq)
753
+ let full = null
754
+ if (l0hit) {
755
+ try {
756
+ const dec = pngDecode(l0hit.png)
757
+ full = { dec, w: dec.w, h: dec.h }
758
+ } catch (e) { full = null }
759
+ }
760
+ const l0Gone = () => 'L0 全分辨率原图已淘汰(seq=' + fr.seq + '):滚动区 ' + S.l0.roll.length
761
+ + ' 帧 / 锚区 ' + S.l0.anchor.length + ' 帧,实测 10fps 下滚动窗口约 1.6 秒。'
762
+ + '要全分辨率就在截图后立刻取 ROI,或先 kf promote 留存该帧再从库帧裁。'
763
+
764
+ if (roiIn.roi) {
765
+ const [x, y, w, h] = roiIn.roi
766
+ if (full) {
767
+ /* 控制器分辨率 1:1 裁剪:roi 本来就是控制器空间坐标,无需换算。 */
768
+ const rx = Math.max(0, Math.floor(x))
769
+ const ry = Math.max(0, Math.floor(y))
770
+ const rw = Math.min(full.w - rx, Math.max(1, Math.round(w)))
771
+ const rh = Math.min(full.h - ry, Math.max(1, Math.round(h)))
772
+ if (rx >= full.w || ry >= full.h || rw <= 0 || rh <= 0) {
773
+ return { ok: false, error: 'ROI 完全越界(' + JSON.stringify(args.roi) + ' vs 捕获时 ' + full.w + 'x' + full.h + ')' }
774
+ }
775
+ const src0 = toRgb(full.dec)
776
+ const crop = Buffer.alloc(rw * rh * 3)
777
+ for (let yy = 0; yy < rh; yy++) {
778
+ src0.copy(crop, yy * rw * 3, ((ry + yy) * full.w + rx) * 3, ((ry + yy) * full.w + rx + rw) * 3)
779
+ }
780
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
781
+ fs.writeFileSync(outFile, pngEncodeRGB(crop, rw, rh))
782
+ return {
783
+ ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: rw, h: rh,
784
+ seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
785
+ source: 'l0-full', sourceResolution: 1, degraded: false,
786
+ requested: { w, h },
787
+ }
788
+ }
789
+ if (wantFull) return { ok: false, error: l0Gone() }
790
+ /* 降级:环形缓冲小图。按**该帧捕获时**尺寸换算(重连/改分辨率后旧帧会被错剪)。 */
791
+ const sx = fr.fw > 0 ? fr.w / fr.fw : 1
792
+ const sy = fr.fh > 0 ? fr.h / fr.fh : 1
793
+ const rx = Math.max(0, Math.floor(x * sx))
794
+ const ry = Math.max(0, Math.floor(y * sy))
795
+ const rw = Math.min(fr.w - rx, Math.max(1, Math.round(w * sx)))
796
+ const rh = Math.min(fr.h - ry, Math.max(1, Math.round(h * sy)))
797
+ if (rx >= fr.w || ry >= fr.h || rw <= 0 || rh <= 0) {
798
+ return { ok: false, error: 'ROI 完全越界(' + JSON.stringify(args.roi) + ' vs 捕获时 ' + fr.fw + 'x' + fr.fh + ')' }
799
+ }
800
+ const crop = Buffer.alloc(rw * rh * 3)
801
+ for (let yy = 0; yy < rh; yy++) {
802
+ fr.rgb.copy(crop, yy * rw * 3, ((ry + yy) * fr.w + rx) * 3, ((ry + yy) * fr.w + rx + rw) * 3)
803
+ }
804
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
805
+ fs.writeFileSync(outFile, pngEncodeRGB(crop, rw, rh))
806
+ return {
807
+ ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: rw, h: rh,
808
+ seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: fr.fw, ctrlH: fr.fh,
809
+ source: 'l1-ring', sourceResolution: sx,
810
+ requested: { w, h },
811
+ /* `degraded` 是**相对"调用方要什么"**的判断,不是"分辨率是否小于 1":
812
+ * 带 roi 且 auto 时,调用方要的是控制器空间的 ROI,退成小图就是真降级(附 warn、判 findings);
813
+ * 显式 ring 是小图预期,不算降级。整帧路径另有约定(见下)。 */
814
+ degraded: !wantRing,
815
+ ...(wantRing ? {} : {
816
+ warn: 'L0 全分辨率原图不可用,本图由环形缓冲小图裁出,有效分辨率 ' + (1 / sx).toFixed(3) + '×'
817
+ + '(请求 ' + w + 'x' + h + ',实得 ' + rw + 'x' + rh + ')。' + l0Gone(),
818
+ }),
819
+ }
820
+ }
821
+
822
+ /* 整帧导出:**只有显式 `full` 才导 L0 原图**。
823
+ * 为什么不给 auto 也导原图:整帧 `auto` 的既有契约就是"环形缓冲那一帧",把它悄悄换成
824
+ * 1280×720 既改了这个最常见调用的产物尺寸(消费方按小图写的代码会突然拿到大图),
825
+ * 也与协议文档的迁移承诺冲突——本次行为变更**只限带 `roi` 的缺省路径**。
826
+ * 要整帧原图就显式 `--src full`(L0 淘汰则报错,不冒充)。 */
827
+ if (full && wantFull) {
828
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
829
+ fs.writeFileSync(outFile, pngEncodeRGB(toRgb(full.dec), full.w, full.h))
830
+ return {
831
+ ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: full.w, h: full.h,
832
+ seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
833
+ source: 'l0-full', sourceResolution: 1,
834
+ }
835
+ }
836
+ if (wantFull) return { ok: false, error: l0Gone() }
837
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
838
+ fs.writeFileSync(outFile, pngEncodeRGB(fr.rgb, fr.w, fr.h))
839
+ const sx0 = fr.fw > 0 ? fr.w / fr.fw : 1
840
+ return {
841
+ ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: fr.w, h: fr.h,
842
+ seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: fr.fw, ctrlH: fr.fh,
843
+ source: 'l1-ring', sourceResolution: sx0,
844
+ /* 整帧小图是**文档写明的缺省行为**,不是降级:只标注来源与分辨率,不警告、不判 findings——
845
+ * 否则最普通的 `frame get`(不带 roi)会时不时退 3。要原图就显式 `--src full`。 */
846
+ degraded: false,
847
+ }
848
+ }
849
+
850
+ /* ────────────────────────── 状态谓词:等到条件成立,而不是让调用方睡死 ──────────────────────────
851
+ * 为什么要有它:没有"什么时候可以读了"的可查询谓词,调用方只能用时钟猜。本案实测代价:
852
+ * 硬睡 39 次共 155 秒,而且**等了也不保证对**——抓到过渡帧并据此误判"还在大厅"。
853
+ * 判据复用帧流同一套(最近 quietMs 内有无 change 事件 + 可选 ROI 内逐帧均值差),不另立阈值口径。 */
854
+
855
+ /** ROI(控制器空间)内两帧的平均亮度差;坐标按该帧捕获时尺寸换算。 */
856
+ function roiMeanDiff(a, b, roi) {
857
+ if (!a || !b || a.w !== b.w || a.h !== b.h) return null
858
+ const sx = a.fw > 0 ? a.w / a.fw : 1
859
+ const sy = a.fh > 0 ? a.h / a.fh : 1
860
+ const x0 = Math.max(0, Math.floor(roi[0] * sx))
861
+ const y0 = Math.max(0, Math.floor(roi[1] * sy))
862
+ const x1 = Math.min(a.w, Math.ceil((roi[0] + roi[2]) * sx))
863
+ const y1 = Math.min(a.h, Math.ceil((roi[1] + roi[3]) * sy))
864
+ if (x1 <= x0 || y1 <= y0) return null
865
+ let sum = 0, n = 0
866
+ for (let y = y0; y < y1; y++) {
867
+ for (let x = x0; x < x1; x++) {
868
+ const i = (y * a.w + x) * 3
869
+ 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])
870
+ n += 3
871
+ }
872
+ }
873
+ return n > 0 ? sum / n : null
874
+ }
875
+
876
+ async function cmdWait(args) {
877
+ const mode = String(args.mode ?? 'stable') === 'change' ? 'change' : 'stable'
878
+ const timeout = Math.min(120000, Math.max(200, Number(args.timeout ?? 10000)))
879
+ const quietMs = Math.min(10000, Math.max(100, Number(args.quiet ?? 600)))
880
+ const thr = Number(args.threshold ?? 3)
881
+ const roiIn = readRoi(args.roi)
882
+ if (roiIn.error) return { ok: false, error: roiIn.error }
883
+ /* 一帧都没有就别空转到超时再报"画面持续变化"——那是**假原因**,会让人去调阈值。
884
+ * 与 frame_get 同一口径:先要观测。 */
885
+ if (!S.ring.length) {
886
+ return {
887
+ ok: false,
888
+ error: '缓冲无任何帧:wait 判的是帧流(变化事件 / ROI 逐帧均值差),没有帧就无从判断稳定与否。先 screencap 或 stream start 产生观测。',
889
+ }
890
+ }
891
+ const t0 = Date.now()
892
+ let lastPairSeq = null
893
+ let lastDiff = null
894
+ while (Date.now() - t0 < timeout) {
895
+ const n = S.ring.length
896
+ const cur = n ? S.ring[n - 1] : null
897
+ const prev = n > 1 ? S.ring[n - 2] : null
898
+ if (!cur) { await sleep(80); continue }
899
+ if (roiIn.roi && prev && prev.seq !== lastPairSeq) {
900
+ lastPairSeq = prev.seq
901
+ const d = roiMeanDiff(prev, cur, roiIn.roi)
902
+ if (d !== null) lastDiff = d
903
+ }
904
+ const recentChange = S.events.some((e) => e.type === 'change' && Date.now() - e.t < quietMs)
905
+ const roiMoving = roiIn.roi ? (lastDiff !== null && lastDiff >= thr) : false
906
+ const moving = recentChange || roiMoving
907
+ if (mode === 'stable' ? !moving : moving) {
908
+ return {
909
+ ok: true, mode, satisfied: true, waitedMs: Date.now() - t0, seq: cur.seq, quietMs,
910
+ ...(roiIn.roi ? { roi: roiIn.roi, threshold: thr, lastRoiMeanDiff: lastDiff } : {}),
911
+ ...(mode === 'change' ? { note: '画面已动(change 事件或 ROI 均值差超阈)——注意这只说明"动了",不说明"到了目标界面";到了哪一屏要用识别或留存帧复核' } : {}),
912
+ }
913
+ }
914
+ await sleep(100)
915
+ }
916
+ const cur = S.ring.length ? S.ring[S.ring.length - 1] : null
917
+ return {
918
+ ok: true, mode, satisfied: false, waitedMs: Date.now() - t0, timeout, seq: cur ? cur.seq : null,
919
+ ...(roiIn.roi ? { roi: roiIn.roi, threshold: thr, lastRoiMeanDiff: lastDiff } : {}),
920
+ warn: mode === 'stable'
921
+ ? '超时仍未稳定(' + timeout + 'ms 内持续变化):目标界面可能本来就在动(动画/加载/字幕),或阈值偏严——先 calibrate 定噪声地板再起流。'
922
+ : '超时仍未见变化:画面在这段时间内没动过。先确认目标是否已经在该界面,或动作是否真的生效(比对动作回执的 retention.before/after)。',
923
+ }
924
+ }
925
+
926
+ /** 两帧在若干 ROI 上的差分:回答"这一步究竟改了哪几格 / 哪几块"。
927
+ * 帧流事件给的是"整帧哪里变了",而离散网格(棋盘 / 背包 / 技能格)要的是"每格变了多少"——
928
+ * 本案要用它判断"一次拖动选中了哪几格",当时只能自己写脚本逐格比对。 */
929
+ function cmdFrameDiff(args) {
930
+ const a = S.ring.find((r) => r.seq === Number(args.a))
931
+ const b = S.ring.find((r) => r.seq === Number(args.b))
932
+ if (!a || !b) {
933
+ return { ok: false, error: '帧不在缓冲中(a=' + args.a + ' b=' + args.b + ',范围 ' + (S.ring[0] ? S.ring[0].seq : 0) + '..' + S.seq + ')' }
934
+ }
935
+ if (a.w !== b.w || a.h !== b.h) {
936
+ return { ok: false, error: '两帧尺寸不同(' + a.w + 'x' + a.h + ' vs ' + b.w + 'x' + b.h + '):不能直接比' }
937
+ }
938
+ const rois = Array.isArray(args.rois) ? args.rois : []
939
+ if (!rois.length) return { ok: false, error: 'rois 要给数组:[[x,y,w,h],...](控制器空间坐标)' }
940
+ const sx = a.fw > 0 ? a.w / a.fw : 1
941
+ const sy = a.fh > 0 ? a.h / a.fh : 1
942
+ const out = []
943
+ for (const r of rois) {
944
+ const roi = (Array.isArray(r) ? r : [r.x, r.y, r.w, r.h]).map(Number)
945
+ if (roi.length !== 4 || !roi.every(Number.isFinite)) { out.push({ roi: r, error: 'roi 必须是 4 个数字' }); continue }
946
+ const x0 = Math.max(0, Math.floor(roi[0] * sx))
947
+ const y0 = Math.max(0, Math.floor(roi[1] * sy))
948
+ const x1 = Math.min(a.w, Math.ceil((roi[0] + roi[2]) * sx))
949
+ const y1 = Math.min(a.h, Math.ceil((roi[1] + roi[3]) * sy))
950
+ if (x1 <= x0 || y1 <= y0) { out.push({ roi, error: '完全越界' }); continue }
951
+ let sum = 0, n = 0, mx = 0
952
+ for (let y = y0; y < y1; y++) {
953
+ for (let x = x0; x < x1; x++) {
954
+ const i = (y * a.w + x) * 3
955
+ 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
956
+ sum += d; n++
957
+ if (d > mx) mx = d
958
+ }
959
+ }
960
+ out.push({ roi, meanDiff: n ? Math.round((sum / n) * 100) / 100 : null, maxDiff: Math.round(mx * 100) / 100, pixels: n })
961
+ }
962
+ const rank = out.filter((o) => typeof o.meanDiff === 'number').slice().sort((p, q) => q.meanDiff - p.meanDiff)
963
+ return {
964
+ ok: true, a: a.seq, b: b.seq, dtMs: Math.abs((b.t || 0) - (a.t || 0)),
965
+ rois: out, ranked: rank.map((o) => o.roi), sourceResolution: sx,
966
+ note: 'meanDiff 是 0..255 的平均亮度差,量级随画面内容变;判"变了/没变"的闸门请先用静止画面实测(同 calibrate 的思路)。',
967
+ }
968
+ }
969
+
970
+ /* ────────────────────────── 资源 / 运行 / 识别 ────────────────────────── */
971
+ async function ensureResource(dir) {
972
+ if (!S.res) {
973
+ const m = loadMaa()
974
+ S.res = new m.Resource()
975
+ }
976
+ if (dir) await S.res.post_bundle(dir).wait()
977
+ return S.res
978
+ }
979
+ /**
980
+ * 项目驱动的多路径资源:interface.json 的 resource.path[] + attach_resource_path
981
+ * 按顺序叠加、后者覆盖前者(MaaPiCli Configurator 的语义),对应逐个 post_bundle。
982
+ * 每次运行都新建 Resource——复用旧实例会把上一次运行的 bundle 残留进来
983
+ * (比如上次加载了皮肤包这次没选,残留会让 override 关系错乱)。
984
+ */
985
+ async function buildResource(dirs) {
986
+ const m = loadMaa()
987
+ const res = new m.Resource()
988
+ for (const d of dirs) await res.post_bundle(d).wait()
989
+ return res
990
+ }
991
+ function ensureTasker() {
992
+ if (!S.tasker) {
993
+ const m = loadMaa()
994
+ S.tasker = new m.Tasker()
995
+ }
996
+ return S.tasker
997
+ }
998
+
999
+ /* ────────────────────────── agent 桥接(PI agent 子进程) ──────────────────────────
1000
+ * 模式照 maa-support-extension 的 setupAgent:new Client() 自动生成 identifier,
1001
+ * 把 identifier 追加为子进程最后一个参数(agent 侧 sys.argv[-1] → AgentServer.start_up),
1002
+ * bind_resource 后 connect(与子进程退出/超时竞速)。custom action/recognition 经此
1003
+ * 注册进绑定的 Resource——依赖 agent 的节点(M9A DisableNode 等)由此可用。 */
1004
+ async function cleanupAgents() {
1005
+ const bind = S.agentBind
1006
+ S.agentBind = null
1007
+ if (!bind) return
1008
+ for (const c of bind.clients) {
1009
+ try { c.client.disconnect() } catch (e) { /* ignore */ }
1010
+ try { c.client.destroy() } catch (e) { /* ignore */ }
1011
+ try { c.child.kill() } catch (e) { /* ignore */ }
1012
+ }
1013
+ }
1014
+
1015
+ async function setupAgents(res, decls, cwd) {
1016
+ const key = JSON.stringify(decls)
1017
+ if (S.agentBind && S.agentBind.res === res && S.agentBind.key === key) {
1018
+ const alive = S.agentBind.clients.every((c) => {
1019
+ try { return c.client.connected && c.client.alive } catch (e) { return false }
1020
+ })
1021
+ if (alive) return { ok: true, reused: true, clients: S.agentBind }
1022
+ }
1023
+ await cleanupAgents()
1024
+ const m = loadMaa()
1025
+ const clients = []
1026
+ for (const d of decls) {
1027
+ const client = new m.Client()
1028
+ const ident = client.identifier
1029
+ let child = null
1030
+ try {
1031
+ child = spawn(d.exec, [...d.args, ident], { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
1032
+ } catch (e) {
1033
+ try { client.destroy() } catch (e2) { /* ignore */ }
1034
+ return { ok: false, error: 'agent 子进程启动失败:' + d.exec + '(' + String(e && e.message || e) + ')' }
1035
+ }
1036
+ let out = ''
1037
+ const feed = (b) => { out = (out + String(b)).slice(-4000) }
1038
+ child.stdout?.on('data', feed)
1039
+ child.stderr?.on('data', feed)
1040
+ try { client.timeout = 60000 } catch (e) { /* 绑定不支持时用默认 */ }
1041
+ try { client.bind_resource(res) } catch (e) {
1042
+ try { child.kill() } catch (e2) { /* ignore */ }
1043
+ try { client.destroy() } catch (e2) { /* ignore */ }
1044
+ return { ok: false, error: 'agent 绑定资源失败:' + String(e && e.message || e) }
1045
+ }
1046
+ const exited = new Promise((r) => child.on('exit', () => r('exit')))
1047
+ const ok = await Promise.race([
1048
+ client.connect().then(() => 'ok', () => 'err'),
1049
+ exited,
1050
+ sleep(60000).then(() => 'timeout'),
1051
+ ])
1052
+ /* connect() resolve 不代表可用:协议版本错配(maa-node 与 agent 侧 maa 版本不一致)时
1053
+ * 通道建立但握手被拒——必须复核 connected && alive(maa-support 同款检查)。 */
1054
+ let usable = ok === 'ok'
1055
+ if (usable) {
1056
+ try { usable = client.connected === true && client.alive === true } catch (e) { usable = false }
1057
+ }
1058
+ if (!usable) {
1059
+ const why = ok === 'exit' ? '子进程提前退出' : (ok === 'timeout' ? '连接超时(60s)' : '连接失败/握手被拒(版本错配?maa-node 与 agent 侧 maa 库需同版本)')
1060
+ try { client.disconnect() } catch (e) { /* ignore */ }
1061
+ try { client.destroy() } catch (e) { /* ignore */ }
1062
+ try { child.kill() } catch (e) { /* ignore */ }
1063
+ return { ok: false, error: 'agent ' + why + ':' + [d.exec, ...d.args].join(' ') + ';输出尾部:' + out.slice(-300) }
1064
+ }
1065
+ clients.push({ client, child, name: [d.exec, ...d.args].join(' '), out })
1066
+ }
1067
+ S.agentBind = { res, key, clients }
1068
+ let actions = null
1069
+ let recos = null
1070
+ try { actions = clients[0].client.custom_action_list } catch (e) { /* ignore */ }
1071
+ try { recos = clients[0].client.custom_recognition_list } catch (e) { /* ignore */ }
1072
+ return { ok: true, clients: S.agentBind, actions, recos }
1073
+ }
1074
+
1075
+ const sab = new Int32Array(new SharedArrayBuffer(4))
1076
+ /**
1077
+ * 节点名取哪个字段:框架在 `PipelineNode.*` 的**顶层 `name` 上填的是任务入口名**,
1078
+ * 真节点名在内嵌的 `node_details.name` / `action_details.name` / `reco_details.name` 里。
1079
+ * 顶层名只作创建时的兜底,来源用 `source` 如实标出(`entry` = 未确认)。
1080
+ * 形状来自真机抓取的原始通知(`MAAFW_NOTIFY_DUMP`),单测按同一形状钉住。
1081
+ */
1082
+ function pickNodeName(m) {
1083
+ const authoritative = (m.node_details && m.node_details.name) ||
1084
+ (m.action_details && m.action_details.name) ||
1085
+ (m.reco_details && m.reco_details.name) || null
1086
+ if (authoritative) return { name: String(authoritative), source: 'node_details' }
1087
+ return { name: String(m.name || ('node#' + m.node_id)), source: 'entry' }
1088
+ }
1089
+
1090
+ async function cmdRun(args) {
1091
+ if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
1092
+ if (S.runActive) return { ok: false, error: '已有任务在运行(先 maa_run_stop)' }
1093
+ let res
1094
+ if (Array.isArray(args.resourceDirs) && args.resourceDirs.length) {
1095
+ res = await buildResource(args.resourceDirs.map(String))
1096
+ S.res = res
1097
+ } else {
1098
+ if (!args.resourceDir) return { ok: false, error: '缺少 resourceDir(或 resourceDirs 数组)' }
1099
+ res = await ensureResource(args.resourceDir)
1100
+ }
1101
+ /* agent 桥接:项目声明了 agent(PI interface 的 child_exec/child_args)就先连上——
1102
+ * custom action 注册进本次运行的 Resource;失败不阻断(非 agent 节点照跑),结果如实报告 */
1103
+ let agentInfo = null
1104
+ if (Array.isArray(args.agents) && args.agents.length) {
1105
+ const ag = await setupAgents(res, args.agents, String(args.agentCwd || process.cwd()))
1106
+ agentInfo = ag.ok
1107
+ ? { ok: true, reused: !!ag.reused, actions: ag.actions ?? null, recognitions: ag.recos ?? null }
1108
+ : { ok: false, error: ag.error }
1109
+ }
1110
+ const tasker = ensureTasker()
1111
+ tasker.resource = res
1112
+ tasker.controller = S.ctrl
1113
+ /* timeoutMs=0:不自动 post_stop,停止权交调用方(LLM 看事件流自行判断 + run_stop)。
1114
+ * >0 时沿用超时保护(防 JumpBack 死循环)。300s 硬上限已移除——长任务用 0。 */
1115
+ const rawT = Number(args.timeoutMs)
1116
+ const timeoutMs = rawT === 0 ? 0 : (Number.isFinite(rawT) && rawT > 0 ? Math.max(500, rawT) : 30000)
1117
+ const t0 = Date.now()
1118
+ const retention = { start: await captureNow('run-start') }
1119
+ const nodes = new Map() // node_id → {name, start, end, msg, success, seq}
1120
+ const order = []
1121
+ const nextCandidates = [] // 框架本轮宣布过的候选(NextList 通知),按顺序
1122
+ let nextCandidateCount = 0 // 全部公告次数(含超出上限未记入数组的)
1123
+ const onMsg = (m) => {
1124
+ const t = Date.now()
1125
+ const seq = S.seq
1126
+ if (!m || !m.msg) return
1127
+ /* 原始通知转储(默认关,MAAFW_NOTIFY_DUMP=<file> 打开):节点记录是"两条流按到达顺序归并"的产物,
1128
+ * 归属存疑时(如 record.nodes[].name 重复为入口名)只有原始消息能回答"框架到底发了什么"。 */
1129
+ if (process.env.MAAFW_NOTIFY_DUMP) {
1130
+ try { fs.appendFileSync(process.env.MAAFW_NOTIFY_DUMP, JSON.stringify(m) + '\n') } catch (e) { /* 诊断失败不影响运行 */ }
1131
+ }
1132
+ if (m.node_id !== undefined && m.node_id !== null) {
1133
+ /* 节点名的权威来源:框架在 `PipelineNode.*` 的**顶层 name 上填的是任务入口名**
1134
+ * (实测:`ProbeA --next--> ProbeB` 两条记录顶层都叫 ProbeA),而内嵌
1135
+ * `node_details.name` / `action_details.name` / `reco_details.name` 与同轮的
1136
+ * `Recognition.*` / `Action.*` 通知里是**真节点名**。
1137
+ * 所以:创建时先用顶层名兜底(`PipelineNode.Starting` 只有顶层名),拿到权威名就改写,
1138
+ * 并把来源记进 `nameSource`——消费方据此判断这个名字能不能当"哪个节点跑过"的依据。 */
1139
+ const named = pickNodeName(m)
1140
+ let n = nodes.get(String(m.node_id))
1141
+ if (!n) {
1142
+ n = {
1143
+ id: String(m.node_id),
1144
+ name: named.name,
1145
+ nameSource: named.source,
1146
+ start: t, end: null, status: 'running', msgs: [], seq,
1147
+ }
1148
+ nodes.set(String(m.node_id), n)
1149
+ order.push(n)
1150
+ } else if (named.source === 'node_details') {
1151
+ /* 权威名到了就采纳(哪怕与顶层名相同)——来源要如实升格,否则 nameSource 会一直挂着 entry */
1152
+ n.name = named.name
1153
+ n.nameSource = 'node_details'
1154
+ }
1155
+ /* node-ok/fail 锚点:读控制器缓存图(不发新截图任务,规避并发崩溃,见 anchorFromCache) */
1156
+ if (/Starting$/.test(m.msg)) { n.status = 'running'; n.start = t }
1157
+ else if (/Succeeded$/.test(m.msg)) { n.status = 'ok'; n.end = t; anchorFromCache('node-ok') }
1158
+ else if (/Failed$/.test(m.msg)) { n.status = 'fail'; n.end = t; anchorFromCache('node-fail') }
1159
+ else if (n.msgs.length < 6) n.msgs.push(m.msg)
1160
+ n.seq = seq
1161
+ /* 节点级消息进事件流:maa_frame_poll / maa_timing_measure 直接消费 */
1162
+ pushEvent({ type: 'run', seq, t, diff: 0, node: n.name, msg: m.msg, id: m.node_id ?? null })
1163
+ } else {
1164
+ const ev = { type: 'run', seq, t, diff: 0, node: m.name ?? null, msg: m.msg, id: m.reco_id ?? m.action_id ?? null }
1165
+ pushEvent(ev)
1166
+ if (order.length) order[order.length - 1].msgs.push(ev.msg + (ev.id ? '#' + ev.id : ''))
1167
+ /* NextList.* 通知带**候选数组** `list: [{name, jump_back, anchor}]`(实测:每次公告一个候选,
1168
+ * Starting 与 Succeeded 各一次)。只取 Starting,免得同一候选记两遍;记在**任务级**按顺序,
1169
+ * 不做"挂给哪个节点"的归属猜测——公告的到达时机在新节点记录创建之后,挂给最近记录会把
1170
+ * "上一个节点的出口"算到下个节点头上。任务级序列 + 权威节点名,足以还原"轮询了哪些出口、最后走了哪个"。
1171
+ * 上限 50 条(JumpBack 长列表会刷很多次),超出只累加计数,避免记录被候选公告撑爆。 */
1172
+ if (!/Succeeded$/.test(m.msg) && Array.isArray(m.list)) {
1173
+ for (const c of m.list) {
1174
+ if (!c || !c.name) continue
1175
+ nextCandidateCount++
1176
+ const name = String(c.name)
1177
+ const last = nextCandidates[nextCandidates.length - 1]
1178
+ /* 同一候选会被反复轮询(例如目标页正在切换动画,识别还没命中)——连续重复折叠成 polls 计数,
1179
+ * 序列读起来才是"轮询过哪些出口",而不是同一行刷屏。 */
1180
+ if (last && last.name === name && !c.jump_back && !last.jumpBack && !c.anchor && !last.anchor) {
1181
+ last.polls++
1182
+ last.seq = seq
1183
+ continue
1184
+ }
1185
+ if (nextCandidates.length < 50) {
1186
+ nextCandidates.push({
1187
+ seq, t, name, polls: 1,
1188
+ ...(c.jump_back ? { jumpBack: true } : {}),
1189
+ ...(c.anchor ? { anchor: true } : {}),
1190
+ })
1191
+ }
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+ const s1 = tasker.add_sink((_, m) => onMsg(m))
1197
+ const s2 = tasker.add_context_sink((_, m) => onMsg(m))
1198
+ S.runActive = true
1199
+ let status = null
1200
+ let stopped = false
1201
+ try {
1202
+ const job = tasker.post_task(String(args.entry), args.override || {})
1203
+ /* 超时保护:与 wait() 竞速。wait() 内部会泵事件循环(探针实测:节点回调在 wait 期间送达),
1204
+ * 因此 setTimeout 分支可在此期间触发并执行 post_stop —— JumpBack 死循环无法挂死 worker。 */
1205
+ let finished = false
1206
+ if (timeoutMs > 0) {
1207
+ await Promise.race([
1208
+ job.wait().then(() => { finished = true }),
1209
+ sleep(timeoutMs),
1210
+ ])
1211
+ if (!finished) {
1212
+ try { tasker.post_stop() } catch (e) { /* ignore */ }
1213
+ stopped = true
1214
+ await Promise.race([job.wait().catch(() => null), sleep(1500)])
1215
+ }
1216
+ } else {
1217
+ await job.wait()
1218
+ }
1219
+ status = job.status
1220
+ } catch (e) {
1221
+ status = -1
1222
+ order.push({ id: '-', name: 'exception', start: t0, end: Date.now(), status: 'fail', msgs: [String(e && e.message || e)], seq: S.seq })
1223
+ } finally {
1224
+ try { tasker.remove_sink(s1); tasker.remove_context_sink(s2) } catch (e) { /* ignore */ }
1225
+ S.runActive = false
1226
+ }
1227
+ let dump = null
1228
+ try { dump = S.ctrl.get_node_data_parsed('node') } catch (e) { /* ignore */ }
1229
+ retention.end = await captureNow('run-end')
1230
+ const rec = {
1231
+ ok: status === STATUS.succeeded,
1232
+ entry: String(args.entry),
1233
+ status,
1234
+ stopped,
1235
+ durationMs: Date.now() - t0,
1236
+ startSeq: nodes.size ? order[0].seq : null,
1237
+ endSeq: S.seq,
1238
+ framesCaptured: S.seq,
1239
+ retention,
1240
+ ...(agentInfo ? { agent: agentInfo } : {}),
1241
+ ...(nextCandidateCount ? { nextCandidates, nextCandidateCount } : {}),
1242
+ nodes: order.map((n) => ({
1243
+ id: n.id, name: n.name, nameSource: n.nameSource, status: n.status,
1244
+ ms: (n.end ?? Date.now()) - n.start, seq: n.seq, msgs: n.msgs.slice(-6),
1245
+ })),
1246
+ dump: dump ? { taskCount: dump.tasks ? dump.tasks.length : 0, nodes: Object.keys(dump.nodes || {}).length } : null,
1247
+ }
1248
+ return { ok: true, record: rec }
1249
+ }
1250
+
1251
+ async function cmdRunStop() {
1252
+ if (S.tasker) {
1253
+ try { S.tasker.post_stop() } catch (e) { /* ignore */ }
1254
+ }
1255
+ S.runActive = false
1256
+ return { ok: true }
1257
+ }
1258
+
1259
+ /* 输入注入:全部经 maafw 控制器(adb/win32 由 maafw 本体选择注入方法),不直接调 adb。
1260
+ * 动作边界(前/后)各抓一张原图进 L0 锚区(契约 §1"采得到"):
1261
+ * 采集失败不阻断动作——动作结果与留存失败分别报告(契约 §9)。 */
1262
+ async function cmdInput(args) {
1263
+ if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
1264
+ const t0 = Date.now()
1265
+ const k = String(args.kind || 'click')
1266
+ const retention = {}
1267
+ const done = (job) => ({ ok: true, kind: k, ms: Date.now() - t0, retention })
1268
+ retention.before = await captureNow('action-before')
1269
+ let job = null
1270
+ if (k === 'click') {
1271
+ job = S.ctrl.post_click(Number(args.x), Number(args.y), Number(args.contact ?? 0), Number(args.pressure ?? 1))
1272
+ } else if (k === 'dbclick') {
1273
+ /* 双击:两次 click 间隔一拍(控制器消息模型下无原子双击原语) */
1274
+ await S.ctrl.post_click(Number(args.x), Number(args.y)).wait()
1275
+ await sleep(Math.max(20, Number(args.gap ?? 60)))
1276
+ job = S.ctrl.post_click(Number(args.x), Number(args.y))
1277
+ } else if (k === 'press') {
1278
+ /* 长按:touch_down → 保持 → touch_up(保持期不占用事件循环之外的资源) */
1279
+ const contact = Number(args.contact ?? 0)
1280
+ await S.ctrl.post_touch_down(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1)).wait()
1281
+ await sleep(Math.max(50, Number(args.duration ?? 800)))
1282
+ job = S.ctrl.post_touch_up(contact)
1283
+ } else if (k === 'swipe') {
1284
+ /* `via` 给中途路径点(`[[x,y],...]` 或 `"x,y;x,y"`):走 touch_down → 逐点 touch_move → touch_up,
1285
+ * 也就是**一条笔画画出折线**。为什么需要:`post_swipe` 只有两个端点,只能画直线;
1286
+ * 而连线/谱曲/拖拽排序这类玩法要求"按住 → 经过若干点 → 抬起",且**中途不能抬手**
1287
+ * (一旦抬手就是新笔画,很多玩法会把新笔画当成另一次操作或直接忽略)。
1288
+ * 不给 `via` 时走原生 post_swipe(保持旧行为与旧性能)。 */
1289
+ const viaRaw = args.via
1290
+ let via = []
1291
+ if (Array.isArray(viaRaw)) {
1292
+ via = viaRaw.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : null))
1293
+ } else if (typeof viaRaw === 'string' && viaRaw.trim()) {
1294
+ via = viaRaw.split(';').map((s) => s.split(',').map(Number))
1295
+ }
1296
+ if (via.length) {
1297
+ if (via.some((p) => !p || p.length !== 2 || !p.every(Number.isFinite))) {
1298
+ return { ok: false, error: 'via 路径点格式错:要 [[x,y],...] 或 "x,y;x,y"(收到 ' + JSON.stringify(viaRaw) + ')' }
1299
+ }
1300
+ const contact = Number(args.contact ?? 0)
1301
+ const pressure = Number(args.pressure ?? 1)
1302
+ const pts = [[Number(args.x1), Number(args.y1)], ...via, [Number(args.x2), Number(args.y2)]]
1303
+ if (!pts.flat().every(Number.isFinite)) return { ok: false, error: 'swipe 端点或 via 含非数字' }
1304
+ const duration = Math.max(0, Number(args.duration ?? 300))
1305
+ const step = pts.length > 1 ? Math.max(8, Math.round(duration / (pts.length - 1))) : 8
1306
+ await S.ctrl.post_touch_down(contact, pts[0][0], pts[0][1], pressure).wait()
1307
+ for (const p of pts.slice(1)) {
1308
+ await S.ctrl.post_touch_move(contact, p[0], p[1], pressure).wait()
1309
+ await sleep(step)
1310
+ }
1311
+ job = S.ctrl.post_touch_up(contact)
1312
+ /* 与共享尾同一口径:**先 wait 再抓 after**。抓早了可能截在抬手落地之前,
1313
+ * 而手势类动作恰恰要验证"抬手之后画面变成什么样"。 */
1314
+ await job.wait()
1315
+ retention.after = await captureNow('action-after')
1316
+ return { ...done(null), path: pts, viaCount: via.length, stepMs: step }
1317
+ }
1318
+ job = S.ctrl.post_swipe(Number(args.x1), Number(args.y1), Number(args.x2), Number(args.y2),
1319
+ Number(args.duration ?? 300), Number(args.contact ?? 0), Number(args.pressure ?? 1))
1320
+ } else if (k === 'touch_move') {
1321
+ /* 裸的移动原语:与 touch_down / touch_up 配对,供调用方自己拼任意手势序列
1322
+ * (跨多个命令维持同一次按压——联系列动作之间不抬手)。 */
1323
+ const contact = Number(args.contact ?? 0)
1324
+ job = S.ctrl.post_touch_move(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
1325
+ await job.wait()
1326
+ retention.after = await captureNow('action-after')
1327
+ return done(null)
1328
+ } else if (k === 'touch_down' || k === 'touch_up') {
1329
+ const contact = Number(args.contact ?? 0)
1330
+ job = k === 'touch_down'
1331
+ ? S.ctrl.post_touch_down(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
1332
+ : S.ctrl.post_touch_up(contact)
1333
+ await job.wait()
1334
+ retention.after = await captureNow('action-after')
1335
+ return done(null)
1336
+ } else if (k === 'key') {
1337
+ job = S.ctrl.post_click_key(Number(args.code))
1338
+ } else if (k === 'keys') {
1339
+ /* 组合键:全按下再全抬起(按下顺序=给定顺序,抬起反序,模拟真实手型) */
1340
+ const codes = (Array.isArray(args.codes) ? args.codes : String(args.codes ?? '').split(/[+,]/))
1341
+ .map((c) => Number(String(c).trim())).filter((c) => Number.isFinite(c) && c > 0)
1342
+ if (!codes.length) return { ok: false, error: 'keys 需要至少一个键码(数组或逗号/加号分隔)' }
1343
+ const hold = Math.max(20, Number(args.hold ?? 60))
1344
+ for (const c of codes) await S.ctrl.post_key_down(c).wait()
1345
+ await sleep(hold)
1346
+ for (const c of codes.slice().reverse()) await S.ctrl.post_key_up(c).wait()
1347
+ retention.after = await captureNow('action-after')
1348
+ return done(null)
1349
+ } else if (k === 'scroll') {
1350
+ /* 滚轮:dx/dy 为格数,建议 120 的倍数(WHEEL_DELTA) */
1351
+ job = S.ctrl.post_scroll(Number(args.dx ?? 0), Number(args.dy ?? 0))
1352
+ } else if (k === 'move') {
1353
+ /* 相对移动(Win32/MacOS;FPS 锁鼠标场景配 mouse_lock_follow) */
1354
+ job = S.ctrl.post_relative_move(Number(args.dx ?? 0), Number(args.dy ?? 0))
1355
+ } else if (k === 'text') {
1356
+ job = S.ctrl.post_input_text(String(args.text ?? ''))
1357
+ } else if (k === 'app') {
1358
+ job = String(args.action) === 'stop'
1359
+ ? S.ctrl.post_stop_app(String(args.intent ?? ''))
1360
+ : S.ctrl.post_start_app(String(args.intent ?? ''))
1361
+ } else {
1362
+ return { ok: false, error: 'unknown input kind: ' + k + '(click|dbclick|press|swipe|key|keys|scroll|move|text|app)' }
1363
+ }
1364
+ await job.wait()
1365
+ retention.after = await captureNow('action-after')
1366
+ return done(job)
1367
+ }
1368
+
1369
+ /* 识别单测:子进程隔离执行(beta 绑定的 post_recognition 在无效模板上会原生崩溃,
1370
+ * 子进程炸掉只损失一次测试,绝不让宿主陪葬)。 */
1371
+ function spawnRecoChild(payload, timeoutMs) {
1372
+ const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'reco_child.mjs')
1373
+ const file = path.join(os.tmpdir(), 'maa_reco_' + Date.now() + '_' + Math.floor(Math.random() * 1e6) + '.json')
1374
+ fs.writeFileSync(file, JSON.stringify(payload), 'utf8')
1375
+ const out = file + '.out'
1376
+ return new Promise((resolve) => {
1377
+ execFile(process.execPath, [script, '--cfg', file, '--out', out],
1378
+ { timeout: Math.min(120000, Math.max(10000, timeoutMs)), windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
1379
+ (err, stdout) => {
1380
+ try { fs.rmSync(file, { force: true }) } catch (e) { /* ignore */ }
1381
+ try {
1382
+ if (fs.existsSync(out)) {
1383
+ const r = JSON.parse(fs.readFileSync(out, 'utf8'))
1384
+ fs.rmSync(out, { force: true })
1385
+ resolve(r)
1386
+ return
1387
+ }
1388
+ } catch (e) { /* 结果损坏则走失败分支 */ }
1389
+ resolve({ ok: false, error: '识别子进程失败:' + ((err && (err.message || String(err.code))) || '无结果') + ' ' + String(stdout || '').slice(0, 200) })
1390
+ })
1391
+ })
1392
+ }
1393
+
1394
+ /* 识别详情瘦身:像素类字段(draws / raw)一律不带出去(与 reco_child 同一规则)。 */
1395
+ function stripDetail(d) {
1396
+ if (!d || typeof d !== 'object') return null
1397
+ const copy = { ...d }
1398
+ delete copy.draws
1399
+ delete copy.raw
1400
+ return copy
1401
+ }
1402
+
1403
+ /**
1404
+ * 节点级单测(--act):识别拿框 → 用框 run_action,动作半在真机 daemon 侧执行。
1405
+ * 与 reco_child 的静态路径互补:那条只测识别(图像假控制器、无输入);这条串起两半,
1406
+ * 专测 target 框语义(框中心/偏移)与组合动作。识别输入 = 抓帧时的当前画面原图
1407
+ * (也是 action-before 边界锚点),动作执行后的画面另抓 action-after——效果可复核。
1408
+ */
1409
+ async function recoActTest(args, node) {
1410
+ const res = await ensureResource(String(args.resourceDir))
1411
+ const tasker = ensureTasker()
1412
+ tasker.resource = res
1413
+ tasker.controller = S.ctrl
1414
+ const before = await captureNow('action-before')
1415
+ if (!before.ok) return { ok: false, error: '识别输入抓帧失败:' + before.error }
1416
+ const imgBuf = S.l0.roll[S.l0.roll.length - 1].png
1417
+ const image = imgBuf.buffer.slice(imgBuf.byteOffset, imgBuf.byteOffset + imgBuf.byteLength)
1418
+ let out = null
1419
+ res.register_custom_action('@reco/act', async (self) => {
1420
+ let detail = null
1421
+ try {
1422
+ detail = await self.context.run_recognition('@reco/node', image, { '@reco/node': node })
1423
+ } catch (e) {
1424
+ out = { ok: false, stage: 'recognition', error: String(e && e.message || e) }
1425
+ return true
1426
+ }
1427
+ /* miss 判定不能只看 box 真值:miss 时 run_recognition 返回 hit:false + box 全零(数组恒真),
1428
+ * 漏检会把 run_action 打到 (0,0)。hit 标志为主,box 零尺寸兜底(防个别识别类型缺 hit)。 */
1429
+ const boxDead = !detail || !Array.isArray(detail.box) || Number(detail.box[2]) === 0 || Number(detail.box[3]) === 0
1430
+ if (!detail || detail.hit === false || boxDead) {
1431
+ out = { ok: false, stage: 'recognition', miss: true, ...(detail ? { reco: stripDetail(detail) } : {}) }
1432
+ return true
1433
+ }
1434
+ const rec = stripDetail(detail)
1435
+ try {
1436
+ const act = await self.context.run_action('@reco/node', detail.box, JSON.stringify(rec), { '@reco/node': node })
1437
+ out = { ok: !!act, stage: 'action', reco: rec, action: stripDetail(act) }
1438
+ } catch (e) {
1439
+ out = { ok: false, stage: 'action', error: String(e && e.message || e), reco: rec }
1440
+ }
1441
+ return true
1442
+ })
1443
+ try {
1444
+ await tasker.post_task('@reco/entry', { '@reco/entry': { action: 'Custom', custom_action: '@reco/act' } }).wait()
1445
+ } catch (e) {
1446
+ return { ok: false, error: '动作探针任务失败:' + String(e && e.message || e) }
1447
+ } finally {
1448
+ try { res.unregister_custom_action('@reco/act') } catch (e) { /* ignore */ }
1449
+ }
1450
+ const after = await captureNow('action-after')
1451
+ if (!out) return { ok: false, error: '动作探针未被执行(custom action 回调未触发)' }
1452
+ const rec = node && node.recognition
1453
+ const shownType = rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec ?? 'DirectHit')
1454
+ return {
1455
+ ok: out.ok,
1456
+ type: shownType,
1457
+ meta: { seq: before.seq, w: before.w, h: before.h, t: before.t },
1458
+ ...(out.error !== undefined ? { error: out.error } : {}),
1459
+ ...(out.stage ? { stage: out.stage } : {}),
1460
+ ...(out.miss !== undefined ? { miss: out.miss } : {}),
1461
+ ...(out.reco ? { reco: out.reco } : {}),
1462
+ ...(out.action ? { action: out.action } : {}),
1463
+ retention: { before, after },
1464
+ }
1465
+ }
1466
+
1467
+ async function cmdRecoTest(args) {
1468
+ /* --act:节点级单测(识别拿框 → 真机执行动作半),在 daemon 进程内跑(要真控制器) */
1469
+ const actNode = args.act === true && args.node && typeof args.node === 'object' && !Array.isArray(args.node) ? args.node : null
1470
+ if (args.act === true) {
1471
+ if (!actNode) return { ok: false, error: '--act 需要同时给 --node(整节点 JSON,含 action)' }
1472
+ if (!S.ctrl) return { ok: false, error: '--act 要在真机上执行动作:需要已连接设备(REPL 或 --project/--kind)' }
1473
+ return await recoActTest(args, actNode)
1474
+ }
1475
+ await ensureResource(args.resourceDir) // 校验资源可加载(子进程会重新加载)
1476
+ /* 图像来源三条:库内留存帧(kfSource:控制器分辨率原图、**可无设备**)/ 显式文件 / 会话缓冲帧。
1477
+ * daemon 自己写的那份临时 PNG **用完即删**——不删就是每跑一次漏一个(CLI 侧的截帧泄漏是同一类,
1478
+ * 现场实测攒过 42 张;这条 `--seq` 路径同理,只是本机还没被触发过)。 */
1479
+ let imageFile = null
1480
+ let tempImage = null
1481
+ let meta = {}
1482
+ if (args.kfSource) {
1483
+ const kf = readKfCropSource(args.kfSource)
1484
+ if (kf.error) return { ok: false, error: kf.error }
1485
+ imageFile = path.join(os.tmpdir(), 'maa_reco_kf_' + Date.now() + '.png')
1486
+ fs.writeFileSync(imageFile, kf.png) // 库帧原样字节,不重编码(裁剪无关,reco 只读这一张)
1487
+ tempImage = imageFile
1488
+ 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 }
1489
+ } else if (args.image) {
1490
+ imageFile = String(args.image)
1491
+ meta = { source: args.image }
1492
+ } else {
1493
+ const fr = args.seq !== undefined && args.seq !== null
1494
+ ? S.ring.find((r) => r.seq === Number(args.seq))
1495
+ : S.ring[S.ring.length - 1]
1496
+ if (!fr) return { ok: false, error: '缓冲无可用帧(seq=' + args.seq + ')' }
1497
+ imageFile = path.join(os.tmpdir(), 'maa_reco_img_' + Date.now() + '.png')
1498
+ fs.writeFileSync(imageFile, pngEncodeRGB(fr.rgb, fr.w, fr.h))
1499
+ tempImage = imageFile
1500
+ meta = { seq: fr.seq, w: fr.w, h: fr.h, t: fr.t }
1501
+ }
1502
+ /* --node:整节点 JSON 原样透传给子进程(V1 扁平 / V2 嵌套由框架解析,daemon 不转换) */
1503
+ const node = args.node && typeof args.node === 'object' && !Array.isArray(args.node) ? args.node : null
1504
+ if (node && (typeof node.recognition !== 'string') &&
1505
+ !(node.recognition && typeof node.recognition === 'object' && !Array.isArray(node.recognition))) {
1506
+ return { ok: false, error: '--node 缺少可用的 recognition 字段(V1 字符串或 V2 {type,param})' }
1507
+ }
1508
+ const baseParam = args.param && typeof args.param === 'object' ? args.param : {}
1509
+ const sweep = args.sweep && typeof args.sweep === 'object' ? args.sweep : null
1510
+ const cases = []
1511
+ if (sweep && typeof sweep.min === 'number' && typeof sweep.max === 'number') {
1512
+ /* step 带方向:升序 0.5→0.9 / 降序 0.9→0.5 都合法;区间为空明确报错,不静默测个空 */
1513
+ const step = sweep.step !== undefined && Number(sweep.step) !== 0 ? Number(sweep.step) : 1
1514
+ const up = step > 0
1515
+ if ((up && sweep.min > sweep.max) || (!up && sweep.min < sweep.max)) {
1516
+ return { ok: false, error: 'sweep 区间为空:min=' + sweep.min + ' max=' + sweep.max + ' step=' + step + '(step 带方向)' }
1517
+ }
1518
+ for (let v = sweep.min; up ? v <= sweep.max + 1e-9 : v >= sweep.max - 1e-9; v += step) {
1519
+ cases.push({ ...baseParam, [String(sweep.key || 'threshold')]: Math.round(v * 1000) / 1000 })
1520
+ }
1521
+ } else cases.push(baseParam)
1522
+ let r = null
1523
+ try {
1524
+ r = await spawnRecoChild({
1525
+ resourceDir: String(args.resourceDir),
1526
+ type: String(args.type || 'TemplateMatch'),
1527
+ image: imageFile,
1528
+ /* 面板场景:模板由调用方裁好落盘传进来(子进程走 override_image),不写进任何资源目录 */
1529
+ ...(args.templateImage ? { templateImage: String(args.templateImage) } : {}),
1530
+ ...(node ? { node } : { cases }),
1531
+ }, 90000)
1532
+ } finally {
1533
+ /* 子进程已经读完这张图(spawnRecoChild 会等到它退出),此时删除是安全的 */
1534
+ if (tempImage) { try { fs.rmSync(tempImage, { force: true }) } catch (e) { /* ignore */ } }
1535
+ }
1536
+ if (r && r.ok) {
1537
+ const rec = node && node.recognition
1538
+ const shownType = node
1539
+ ? (rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec))
1540
+ : args.type
1541
+ return { ok: true, type: shownType, meta, results: r.results }
1542
+ }
1543
+ return { ok: false, error: (r && r.error) || 'reco 失败', meta }
1544
+ }
1545
+
1546
+ /* ────────────────────────── 关键帧库(L0 升格 + 登记,契约 §4) ──────────────────────────
1547
+ * 库根:runDir/frames(host init 传入 runDir,默认 ~/.maafw-live)。
1548
+ * manifest.json = { schema, libraryId, next, frames[] };kf ID = kf:<库UUID>:<零填充序号>。
1549
+ * 写入次序是硬约束:先固定像素(tmp+rename 落 L0 文件),再发布 manifest 记录;
1550
+ * manifest 失败即回收文件,绝不返回半成品引用。库副本 v0 按单写者管理,不自动合并。 */
1551
+ const KF_SCHEMA = 1
1552
+ function kfRoot() { return path.join(S.runDir || path.join(os.homedir(), '.maafw-live'), 'frames') }
1553
+ function kfAbsPath(fileRel) { return path.join(kfRoot(), String(fileRel).split('/').join(path.sep)) }
1554
+ function kfLoad() {
1555
+ const file = path.join(kfRoot(), 'manifest.json')
1556
+ if (!fs.existsSync(file)) return null
1557
+ let m = null
1558
+ try { m = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) { throw new Error('manifest.json 损坏(非合法 JSON)') }
1559
+ if (!m || typeof m !== 'object' || !Array.isArray(m.frames)) throw new Error('manifest.json 结构不符(缺 frames 数组)')
1560
+ /* 未知 schema 明确报错,不默认为当前版(契约 §4) */
1561
+ if (Number(m.schema) !== KF_SCHEMA) throw new Error('manifest schema=' + m.schema + ' 不被当前版本支持(认识:' + KF_SCHEMA + ')')
1562
+ return m
1563
+ }
1564
+ function kfOpen() {
1565
+ return kfLoad() ?? { schema: KF_SCHEMA, libraryId: randomUUID(), next: 1, frames: [] }
1566
+ }
1567
+ function kfSave(m) {
1568
+ const file = path.join(kfRoot(), 'manifest.json')
1569
+ fs.mkdirSync(kfRoot(), { recursive: true })
1570
+ const tmp = file + '.tmp'
1571
+ fs.writeFileSync(tmp, JSON.stringify(m, null, 2))
1572
+ fs.renameSync(tmp, file)
1573
+ }
1574
+
1575
+ /** 库目录现有 L0 字节(按 manifest 记录的文件实测;丢失文件计 0——磁盘占用只算真实存在的) */
1576
+ function kfUsageBytes(m) {
1577
+ let total = 0
1578
+ for (const f of m.frames) {
1579
+ try { total += fs.statSync(kfAbsPath(f.file)).size } catch (e) { /* 丢失不计 */ }
1580
+ }
1581
+ return total
1582
+ }
1583
+
1584
+ async function cmdKfPromote(args) {
1585
+ /* 固定捕获身份:latest 在接受请求时解析成具体 seq;已淘汰则失败,绝不改取更新帧冒充 */
1586
+ let seq = null
1587
+ if (args.seq !== undefined && args.seq !== null) {
1588
+ seq = Number(args.seq)
1589
+ if (!Number.isFinite(seq)) return { ok: false, error: 'seq 必须是数字' }
1590
+ } else if (args.latest === true) {
1591
+ seq = S.seq
1592
+ } else {
1593
+ return { ok: false, error: '给 seq(数字)或 latest:true 指定要升格的捕获' }
1594
+ }
1595
+ if (seq <= 0) return { ok: false, error: '本会话尚无捕获(seq=0),无可升格' }
1596
+ const ent = S.l0.anchor.find((x) => x.seq === seq) ?? S.l0.roll.find((x) => x.seq === seq)
1597
+ if (!ent) {
1598
+ const range = (l) => (l.length ? l[0].seq + '..' + l[l.length - 1].seq : '空')
1599
+ return {
1600
+ ok: false,
1601
+ error: 'L0 已淘汰:seq=' + seq + '(滚动区 ' + range(S.l0.roll) + ',锚区 ' + range(S.l0.anchor) + ')。' +
1602
+ 'L1 参考帧可用 frame_get 导出,但按契约不作 state 证据本体',
1603
+ }
1604
+ }
1605
+ let m
1606
+ try { m = kfOpen() } catch (e) { return { ok: false, error: '关键帧库不可用:' + String(e && e.message || e) } }
1607
+ const sess = S.session || {}
1608
+ const sessionRef = { daemon: S.daemonId, gen: S.connGen, kind: sess.kind ?? null, target: sess.target ?? null }
1609
+ const sha = createHash('sha256').update(ent.png).digest('hex')
1610
+ /* 幂等:同一捕获(daemon + 代次 + seq)重试返回原对象;内容不符报冲突,不任选一份 */
1611
+ const dup = m.frames.find((f) => f.session && f.session.daemon === sessionRef.daemon &&
1612
+ Number(f.session.gen) === sessionRef.gen && Number(f.captureSeq) === seq)
1613
+ if (dup) {
1614
+ if (dup.sha256 === sha) return { ok: true, idempotent: true, id: dup.id, record: dup, path: kfAbsPath(dup.file), sha256: sha }
1615
+ return { ok: false, error: '同一捕获已有内容不同的记录(' + dup.id + '):冲突,拒绝改写旧 ID 的含义' }
1616
+ }
1617
+ /* 磁盘配额(契约 §4.1):幂等重试在上面已返回,走到这里的都是新增字节。
1618
+ * 满额拒绝并报告——已升格对象不后台清理,清理由用户决定。 */
1619
+ if (S.kfQuota > 0) {
1620
+ const used = kfUsageBytes(m)
1621
+ const mb = (v) => (v / 1048576).toFixed(1) + 'MB'
1622
+ if (used + ent.png.length > S.kfQuota) {
1623
+ return {
1624
+ ok: false,
1625
+ error: '关键帧库配额不足:已用 ' + mb(used) + ' + 新增 ' + mb(ent.png.length) +
1626
+ ' 超过配额 ' + mb(S.kfQuota) + '(' + m.frames.length + ' 帧留存于 ' + kfRoot() + ')。' +
1627
+ '不会自动删除已升格帧——清理后重试,或调大配额(init kfQuotaBytes / MAAFW_KF_QUOTA_BYTES)',
1628
+ }
1629
+ }
1630
+ }
1631
+ const num = Number(m.next) || m.frames.length + 1
1632
+ const id = 'kf:' + m.libraryId + ':' + String(num).padStart(4, '0')
1633
+ const fileRel = 'l0/' + String(num).padStart(4, '0') + '.png'
1634
+ const abs = kfAbsPath(fileRel)
1635
+ try {
1636
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
1637
+ const tmp = abs + '.tmp'
1638
+ fs.writeFileSync(tmp, ent.png)
1639
+ fs.renameSync(tmp, abs)
1640
+ } catch (e) {
1641
+ return { ok: false, error: 'L0 文件写入失败:' + String(e && e.message || e) }
1642
+ }
1643
+ /* 小图信息只取环内同 seq 帧;已淘汰则明确为空,不借用最新帧数据(契约 §4) */
1644
+ const small = S.ring.find((r) => r.seq === seq)
1645
+ const rec = {
1646
+ id, file: fileRel, sha256: sha,
1647
+ session: sessionRef,
1648
+ capturedAt: new Date(ent.t).toISOString(),
1649
+ captureSeq: seq,
1650
+ ctrlW: ent.w, ctrlH: ent.h,
1651
+ smallW: small ? small.w : null, smallH: small ? small.h : null,
1652
+ scale: small && ent.w ? Math.round((small.w / ent.w) * 1000) / 1000 : null,
1653
+ source: ent.source || 'explicit',
1654
+ note: args.note != null ? String(args.note) : null,
1655
+ }
1656
+ m.frames.push(rec)
1657
+ m.next = num + 1
1658
+ try { kfSave(m) } catch (e) {
1659
+ try { fs.rmSync(abs, { force: true }) } catch (e2) { /* ignore */ }
1660
+ return { ok: false, error: 'manifest 写入失败(L0 文件已回收,未发布引用):' + String(e && e.message || e) }
1661
+ }
1662
+ return { ok: true, id, path: abs, sha256: sha, retained: 'promoted', record: rec }
1663
+ }
1664
+
1665
+ function cmdL0Status() {
1666
+ 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 })
1667
+ /* 唯一字节:两区共享 Buffer,简单求和会双计 */
1668
+ const seen = new Set()
1669
+ let uniqueBytes = 0
1670
+ for (const e of S.l0.roll) if (!seen.has(e.seq)) { seen.add(e.seq); uniqueBytes += e.png ? e.png.length : 0 }
1671
+ for (const e of S.l0.anchor) if (!seen.has(e.seq)) { seen.add(e.seq); uniqueBytes += e.png ? e.png.length : 0 }
1672
+ const sum = (list, cap) => ({
1673
+ count: list.length, cap,
1674
+ bytes: list.reduce((a, e) => a + (e.png ? e.png.length : 0), 0),
1675
+ seqRange: list.length ? [list[0].seq, list[list.length - 1].seq] : null,
1676
+ })
1677
+ let m = null
1678
+ try { m = kfLoad() } catch (e) { m = null } /* 状态上报不容因库坏而失败 */
1679
+ const library = m
1680
+ ? { frames: m.frames.length, bytes: kfUsageBytes(m), quota: S.kfQuota, dir: kfRoot() }
1681
+ : { frames: 0, bytes: 0, quota: S.kfQuota, dir: kfRoot(), error: 'manifest 不可读' }
1682
+ return {
1683
+ ok: true, seq: S.seq,
1684
+ roll: { ...sum(S.l0.roll, S.l0.rollCap), entries: S.l0.roll.map(brief) },
1685
+ anchor: { ...sum(S.l0.anchor, S.l0.anchorCap), entries: S.l0.anchor.map(brief) },
1686
+ bytes: { unique: uniqueBytes, cap: S.l0.bytesCap, evicted: S.l0.evicted },
1687
+ framesDir: kfRoot(),
1688
+ library,
1689
+ }
1690
+ }
1691
+
1692
+ /* ────────────────────────── 模板裁剪(snap 收紧 + 自匹配精修) ──────────────────────────
1693
+ * 输入:宽松框(或点+外扩);源 = L0 原图(控制器分辨率——模板尺寸必须与识别空间一致,
1694
+ * 不能从降采样小图裁)。收紧 = 与边框背景差的行列占比;精修 = 逐边 ±2px 贪心重试,
1695
+ * 以"裁出的模板在同帧上的 TemplateMatch 得分"为目标函数(自匹配验证闭环)。 */
1696
+ /* opts.tol / opts.frac 是**测量用覆盖**(默认即生产常数):定标要能 A/B 才有数据,
1697
+ * 不给就用 90 / 0.12,行为与加参数前逐字节一致。 */
1698
+ function tightenBounds(rgb, w, h, opts = {}) {
1699
+ const ring = Math.max(2, Math.round(Math.min(w, h) / 12))
1700
+ let br = 0, bg = 0, bb = 0, bn = 0
1701
+ for (let y = 0; y < h; y++) {
1702
+ const edgeRow = y < ring || y >= h - ring
1703
+ for (let x = 0; x < w; x++) {
1704
+ if (!edgeRow && !(x < ring || x >= w - ring)) continue
1705
+ const i = (y * w + x) * 3
1706
+ br += rgb[i]; bg += rgb[i + 1]; bb += rgb[i + 2]; bn++
1707
+ }
1708
+ }
1709
+ br /= bn; bg /= bn; bb /= bn
1710
+ const TOL = Number(opts.tol) > 0 ? Number(opts.tol) : 90 // 通道差之和超过此值视为"非背景"
1711
+ const rowHit = new Array(h).fill(0)
1712
+ const colHit = new Array(w).fill(0)
1713
+ for (let y = 0; y < h; y++) {
1714
+ for (let x = 0; x < w; x++) {
1715
+ const i = (y * w + x) * 3
1716
+ if (Math.abs(rgb[i] - br) + Math.abs(rgb[i + 1] - bg) + Math.abs(rgb[i + 2] - bb) > TOL) {
1717
+ rowHit[y]++; colHit[x]++
1718
+ }
1719
+ }
1720
+ }
1721
+ const FRAC = Number(opts.frac) > 0 ? Number(opts.frac) : 0.12
1722
+ let y0 = 0, y1 = h - 1
1723
+ while (y0 < y1 && rowHit[y0] / w < FRAC) y0++
1724
+ while (y1 > y0 && rowHit[y1] / w < FRAC) y1--
1725
+ let x0 = 0, x1 = w - 1
1726
+ while (x0 < x1 && colHit[x0] / h < FRAC) x0++
1727
+ while (x1 > x0 && colHit[x1] / h < FRAC) x1--
1728
+ if (y1 - y0 < 3 || x1 - x0 < 3) return null // 内容撑满或无内容:交回调用方处理
1729
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }
1730
+ }
1731
+
1732
+ /** 局部对比度图:左右 / 上下邻域差之和(3 通道累加)。文字/图标的描边远高于平铺纹理。 */
1733
+ function edgeMap(rgb, w, h) {
1734
+ const e = new Int32Array(w * h)
1735
+ for (let y = 1; y < h - 1; y++) {
1736
+ for (let x = 1; x < w - 1; x++) {
1737
+ const i = (y * w + x) * 3
1738
+ const l = i - 3, r = i + 3, u = i - w * 3, d = i + w * 3
1739
+ e[y * w + x] =
1740
+ Math.abs(rgb[r] - rgb[l]) + Math.abs(rgb[r + 1] - rgb[l + 1]) + Math.abs(rgb[r + 2] - rgb[l + 2]) +
1741
+ Math.abs(rgb[d] - rgb[u]) + Math.abs(rgb[d + 1] - rgb[u + 1]) + Math.abs(rgb[d + 2] - rgb[u + 2])
1742
+ }
1743
+ }
1744
+ return e
1745
+ }
1746
+
1747
+ /**
1748
+ * 边缘/局部对比度收紧(roadmap 修法①):`tightenBounds` 量的是"与背景色的距离",而这类游戏 UI 的
1749
+ * **背景自带纹理**——整片纹理都被算成非背景,行/列命中率全满,于是剪不动、留白留下(实测踩过:
1750
+ * `signin_entry` 右半张是背景,自匹配照样报"位置正确 1.0")。
1751
+ *
1752
+ * 这一路量**局部对比度**,判据是自适应阈值:"边框环自身的对比度水平(p90)× z" 与绝对地板 `edgeMin`
1753
+ * 取大者——环就是背景,所以阈值跟着背景纹理走;元素(文字/图标/描边)的对比度通常高出一截。
1754
+ * 行/列的命中比例低于 `edgeFrac` 即判为背景并剪掉。返回 null 表示"没收到东西"(撑满或无内容)。
1755
+ *
1756
+ * 与背景差分**取更紧者**进候选池(不是硬交集):两路都只剪不放,更紧的那个仍是内容的子集;
1757
+ * 而"过紧会不会把元素裁掉"交给下游既有的自匹配判据(位置正确优先于得分)去裁决。
1758
+ */
1759
+ function tightenBoundsByEdges(rgb, w, h, opts = {}) {
1760
+ const Z = Number(opts.edgeZ) > 0 ? Number(opts.edgeZ) : 2
1761
+ const MIN = Number(opts.edgeMin) > 0 ? Number(opts.edgeMin) : 24
1762
+ const FRAC = Number(opts.edgeFrac) > 0 ? Number(opts.edgeFrac) : 0.02
1763
+ const ring = Math.max(2, Math.round(Math.min(w, h) / 12))
1764
+ const e = edgeMap(rgb, w, h)
1765
+ /* 环上的对比度水平:p90(不是均值——有孤立强边时均值会被拖低,p90 更能代表"这片背景的纹理有多花") */
1766
+ const ringVals = []
1767
+ for (let y = 0; y < h; y++) {
1768
+ const edgeRow = y < ring || y >= h - ring
1769
+ for (let x = 0; x < w; x++) {
1770
+ if (!edgeRow && !(x < ring || x >= w - ring)) continue
1771
+ ringVals.push(e[y * w + x])
1772
+ }
1773
+ }
1774
+ ringVals.sort((a, b) => a - b)
1775
+ const bgLevel = ringVals.length ? ringVals[Math.min(ringVals.length - 1, Math.floor(ringVals.length * 0.9))] : 0
1776
+ const thresh = Math.max(MIN, bgLevel * Z)
1777
+ const rowHit = new Array(h).fill(0)
1778
+ const colHit = new Array(w).fill(0)
1779
+ for (let y = 1; y < h - 1; y++) {
1780
+ for (let x = 1; x < w - 1; x++) {
1781
+ if (e[y * w + x] > thresh) { rowHit[y]++; colHit[x]++ }
1782
+ }
1783
+ }
1784
+ let y0 = 0, y1 = h - 1
1785
+ while (y0 < y1 && rowHit[y0] / w < FRAC) y0++
1786
+ while (y1 > y0 && rowHit[y1] / w < FRAC) y1--
1787
+ let x0 = 0, x1 = w - 1
1788
+ while (x0 < x1 && colHit[x0] / h < FRAC) x0++
1789
+ while (x1 > x0 && colHit[x1] / h < FRAC) x1--
1790
+ if (y1 - y0 < 3 || x1 - x0 < 3) return null
1791
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, bgLevel, thresh }
1792
+ }
1793
+
1794
+ function cropRgb(rgb, sw, sh, box) {
1795
+ const w = Math.min(box.w, sw - box.x), h = Math.min(box.h, sh - box.y)
1796
+ const out = Buffer.alloc(w * h * 3)
1797
+ for (let y = 0; y < h; y++) {
1798
+ rgb.copy(out, y * w * 3, ((box.y + y) * sw + box.x) * 3, ((box.y + y) * sw + box.x + w) * 3)
1799
+ }
1800
+ return { w, h, data: out }
1801
+ }
1802
+
1803
+ /**
1804
+ * 库帧裁剪源:读关键帧库内的 L0 文件并校验身份。纯文件操作,不碰 daemon 状态——
1805
+ * 这正是"不可复现状态"的场景:捕获它的那次会话早已结束,设备也不在那个画面上。
1806
+ *
1807
+ * 校验三层,任一不符即拒绝(不许把"另一个字节"当成本次裁剪的依据):
1808
+ * 1. 文件在(调用方已按契约 §4.1 解析过完整 ID,这里是同一份字节的第二次确认);
1809
+ * 2. sha256 与库记录一致——防解析与读取之间文件被替换;
1810
+ * 3. 像素尺寸与库记录 ctrlW/ctrlH 一致——记录与像素不同源即库本身不一致。
1811
+ */
1812
+ function readKfCropSource(src) {
1813
+ if (!src || typeof src !== 'object') return { error: 'kfSource 必须是对象' }
1814
+ const id = String(src.id ?? '')
1815
+ const file = String(src.path ?? '')
1816
+ if (!id.startsWith('kf:') || !file) return { error: 'kfSource 需要 id(kf:<库UUID>:<序号>)与 path' }
1817
+ let png = null
1818
+ try { png = fs.readFileSync(file) } catch (e) { return { error: '库帧读取失败:' + String(e && e.message || e) } }
1819
+ const sha = createHash('sha256').update(png).digest('hex')
1820
+ if (src.sha256 && String(src.sha256) !== sha) {
1821
+ return { error: '库帧内容与库记录不符(sha256 不匹配):解析后文件被改动,拒绝裁剪' }
1822
+ }
1823
+ let dec = null
1824
+ try { dec = pngDecode(png) } catch (e) { return { error: '库帧不是可解码的 PNG:' + String(e && e.message || e) } }
1825
+ const recW = Number(src.ctrlW), recH = Number(src.ctrlH)
1826
+ if (recW > 0 && recH > 0 && (recW !== dec.w || recH !== dec.h)) {
1827
+ return {
1828
+ error: '库记录尺寸 ' + recW + 'x' + recH + ' 与像素实际 ' + dec.w + 'x' + dec.h +
1829
+ ' 不一致:库记录与文件不同源,拒绝裁剪',
1830
+ }
1831
+ }
1832
+ return {
1833
+ png, dec, sha, path: file,
1834
+ source: {
1835
+ kind: 'kf', id, sha256: sha, ctrlW: dec.w, ctrlH: dec.h,
1836
+ captureSeq: Number.isFinite(Number(src.captureSeq)) ? Number(src.captureSeq) : null,
1837
+ capturedAt: src.capturedAt ? String(src.capturedAt) : null,
1838
+ },
1839
+ }
1840
+ }
1841
+
1842
+ /**
1843
+ * L2 派生图的出处记录(契约 §5:L2 记录来源及裁剪 / 缩放变换)。
1844
+ *
1845
+ * 只记来源身份与变换,不记设备地址 / 目标 / daemon 身份 / 本地绝对路径——L2 是提交进仓库的
1846
+ * 交付物,库帧本身(含设备来源)留在本地(契约 §4.1 隐私)。纯函数:时间由调用方传入,可断言。
1847
+ */
1848
+ function buildL2Provenance(o) {
1849
+ /* 来源字段白名单:即便调用方把整条库记录(含 session/target)递进来,也不落进 L2 出处。
1850
+ * 库记录里的设备来源留在本地 manifest,是这两份文件的边界(契约 §4.1)。 */
1851
+ const s = o.source || {}
1852
+ const derivedFrom = s.kind === 'kf'
1853
+ ? {
1854
+ kind: 'kf', id: s.id, sha256: s.sha256, ctrlW: s.ctrlW, ctrlH: s.ctrlH,
1855
+ captureSeq: s.captureSeq ?? null, capturedAt: s.capturedAt ?? null,
1856
+ }
1857
+ : {
1858
+ kind: 'l0-cache', seq: s.seq ?? null, sha256: s.sha256,
1859
+ ctrlW: s.ctrlW, ctrlH: s.ctrlH, capturedAt: s.capturedAt ?? null,
1860
+ }
1861
+ return {
1862
+ schema: 1,
1863
+ level: 'L2',
1864
+ derivedFrom,
1865
+ transform: { op: 'crop', resize: false, scale: 1, loose: o.loose, crop: o.box, snapped: o.snapped },
1866
+ selfMatch: { score: o.score, positionOk: o.positionOk, box: o.selfMatchBox ?? null, tries: o.tries },
1867
+ cross: o.cross ?? null,
1868
+ output: { w: o.w, h: o.h },
1869
+ createdAt: o.createdAt,
1870
+ tool: 'maafw-live crop',
1871
+ }
1872
+ }
1873
+
1874
+ /** 缺省落点:daemon 的 run 目录下 `out/`(一次性命令给的是临时目录)——**不写调用方的 cwd**。
1875
+ * 调用方的 cwd 通常就是项目仓库:一次 `frame_get` / `annotate` 就把产物丢进仓库。
1876
+ * 实测踩过两次(`maa_frame_*.png` 十张进了 maafw-live 仓、`maa_som_*.png` 进了同一个根目录),
1877
+ * 而这几个命令本质是"看/量",产物只是顺便落盘——落哪儿由 `out` 说话,不落调用方家里。 */
1878
+ function defaultOutFile(runDir, prefix, ts = Date.now()) {
1879
+ const dir = path.join(runDir || os.tmpdir(), 'out')
1880
+ return { dir, file: path.join(dir, prefix + '-' + ts + '.png') }
1881
+ }
1882
+
1883
+ async function cmdTplCrop(args) {
1884
+ /* 源帧两条路,都必须是控制器分辨率的完整原图(模板尺寸要与识别空间一致,绝不从降采样小图裁):
1885
+ * - L0 热缓存:seq 或最新(同一会话内,有设备);
1886
+ * - 关键帧库留存帧:kfSource(跨会话、可无设备——契约 §2 库内 L0 即原图本体)。 */
1887
+
1888
+ /* 宽松框:显式 roi,或点 + 外扩(点→ROI 派生与裁剪共享同一条 snap 链)。
1889
+ * 校验放在最前:坏参数(既没 roi 也没 point)不该有任何副作用——尤其不该把源帧升格进库。 */
1890
+ let loose = null
1891
+ if (Array.isArray(args.roi) && args.roi.length === 4) {
1892
+ loose = args.roi.map(Number)
1893
+ } else if (Array.isArray(args.point) && args.point.length === 2) {
1894
+ const pad = Math.max(8, Number(args.pad ?? 24))
1895
+ const [px, py] = args.point.map(Number)
1896
+ loose = [px - pad, py - pad, pad * 2, pad * 2]
1897
+ } else {
1898
+ return { ok: false, error: '给 --roi x,y,w,h 或 --point x,y [--pad n]' }
1899
+ }
1900
+ /* 非数字入参要在算框之前拦掉:NaN 会一路传成 NaN 框,最后报一个跟病因无关的失败。
1901
+ * 线上真会遇到的形态是 `[0,0,"x",10]` 这类(JSON 里 NaN 变 null,Number(null) = 0 是合法数字)。 */
1902
+ if (!loose.every((v) => Number.isFinite(v))) {
1903
+ return { ok: false, error: 'roi / point / pad 必须是数字:' + JSON.stringify(loose) }
1904
+ }
1905
+
1906
+ let ent = null
1907
+ let kf = null
1908
+ if (args.kfSource) {
1909
+ kf = readKfCropSource(args.kfSource)
1910
+ if (kf.error) return { ok: false, error: kf.error }
1911
+ } else {
1912
+ ent = args.seq !== undefined && args.seq !== null
1913
+ ? (S.l0.anchor.find((x) => x.seq === Number(args.seq)) ?? S.l0.roll.find((x) => x.seq === Number(args.seq)))
1914
+ : (S.l0.anchor[S.l0.anchor.length - 1] ?? S.l0.roll[S.l0.roll.length - 1] ?? null)
1915
+ if (!ent) {
1916
+ return { ok: false, error: 'L0 无可用原图:先 screencap / stream / 输入产生观测(seq=' + args.seq + ')' }
1917
+ }
1918
+ }
1919
+ const png = kf ? kf.png : ent.png
1920
+ const dec = pngDecode(png)
1921
+ /* 统一成 RGB:cropRgb 按 3 通道步进,RGBA 帧不归一化会静默错剪 */
1922
+ const rgb = toRgb(dec)
1923
+ const source = kf ? kf.source : {
1924
+ kind: 'l0-cache', seq: ent.seq, sha256: createHash('sha256').update(png).digest('hex'),
1925
+ ctrlW: dec.w, ctrlH: dec.h, capturedAt: new Date(ent.t).toISOString(),
1926
+ }
1927
+ /* 「源帧也留下」(--keep-source,只对热缓存路径有意义):
1928
+ * 契约的模型是 **L0 缓存 → 选择性升格 → 从库帧裁 L2**,但少走中间那一步时,模板的出处只剩一个
1929
+ * 随进程消失的 seq(实测踩过:先做的五张模板出处全是 l0-cache,sha256 之外无从复核)。
1930
+ * **必须在裁剪计算之前做**:自匹配 + 跨帧验证要 1–2 秒,滚动区小的时候(实测 l0Roll=2、流 5fps)
1931
+ * 那一帧早就被淘汰了——曾经把升格放在末尾,验收里直接失败。按 ent.seq 升格本次用的那一帧,不取最新帧。 */
1932
+ let keptSource = null
1933
+ if (!kf && args.keepSource === true) {
1934
+ const promoted = await cmdKfPromote({ seq: ent.seq })
1935
+ if (promoted && promoted.ok && promoted.id) {
1936
+ keptSource = { id: promoted.id, path: promoted.path ?? null, seq: ent.seq }
1937
+ source.kind = 'kf'
1938
+ source.id = promoted.id
1939
+ source.captureSeq = ent.seq
1940
+ } else {
1941
+ keptSource = { error: String((promoted && promoted.error) || '升格失败'), seq: ent.seq }
1942
+ }
1943
+ }
1944
+ const cl = (v, lo, hi) => Math.min(hi, Math.max(lo, Math.round(v)))
1945
+ const box0 = {
1946
+ x: cl(loose[0], 0, dec.w - 8),
1947
+ y: cl(loose[1], 0, dec.h - 8),
1948
+ w: 0, h: 0,
1949
+ }
1950
+ box0.w = cl(loose[2], 8, dec.w - box0.x)
1951
+ box0.h = cl(loose[3], 8, dec.h - box0.y)
1952
+
1953
+ const region = cropRgb(rgb, dec.w, dec.h, box0)
1954
+ /* 两路收紧:背景差分(与背景色的距离)是**生产默认**;边缘(局部对比度)是**测量用**、默认关
1955
+ * (见下方 tightEdge 的说明与定标结论)。两路都进候选池,更紧的排前面(与自匹配 objective 配合:
1956
+ * 都位置正确、都满分时保留先入者 = 更贴合的框;过紧的把元素裁掉了则 objective 会输给宽松档)。 */
1957
+ const tightBg = tightenBounds(region.data, region.w, region.h, { tol: args.snapTol, frac: args.snapFrac })
1958
+ /* 边缘那一路**默认关**:定标结论见 docs/daemon-protocol.md 的 tpl_crop 行——在现有真值集
1959
+ * (scripts/truth/1999-720p.json,12 例)上它单独用 IoU 均值只有 0.50–0.59,而背景差分是 0.804:
1960
+ * 边缘判据找的是**元素内部结构**(文字笔画、图标细节),不是元素的范围,所以"取更紧者"会把
1961
+ * 框收进元素内部(实测 0005 物品卡片:0.866 → 0.449)。要试就显式给 `edge:true`(survey:crop --edge)。 */
1962
+ const tightEdge = args.edge === true
1963
+ ? tightenBoundsByEdges(region.data, region.w, region.h, {
1964
+ edgeZ: args.edgeZ, edgeMin: args.edgeMin, edgeFrac: args.edgeFrac,
1965
+ })
1966
+ : null
1967
+ const snapped = !!(tightBg || tightEdge)
1968
+
1969
+ /* 自匹配闭环:裁出的模板在同帧上匹配,逐边 ±2px 贪心取得分最高边界。
1970
+ * 判据两层:位置正确(best 落回裁剪处)优先于得分——大面积纯色的模板在
1971
+ * CCOEFF_NORMED 下得分为噪声(实测同帧自匹配 best 落到别处、0.736 高于真实位置),
1972
+ * "找不到自己"比"分数低"更能说明模板不独特。 */
1973
+ const resourceDir = String(args.resourceDir || '')
1974
+ /* 自匹配的输入帧:库帧直接用库内文件(只读,不复制、不改写 L0);热缓存帧落一份临时副本 */
1975
+ const frameFile = kf ? kf.path : path.join(os.tmpdir(), 'maa_tpl_frame_' + Date.now() + '.png')
1976
+ const candFile = path.join(os.tmpdir(), 'maa_tpl_cand_' + Date.now() + '.png')
1977
+ if (!kf) fs.writeFileSync(frameFile, ent.png)
1978
+ const writeCand = (b) => {
1979
+ const c = cropRgb(rgb, dec.w, dec.h, b)
1980
+ fs.writeFileSync(candFile, pngEncodeRGB(c.data, c.w, c.h))
1981
+ return c
1982
+ }
1983
+ const overlapOk = (a, b) => {
1984
+ const ix = Math.max(0, Math.min(a[0] + a[2], b[0] + b[2]) - Math.max(a[0], b[0]))
1985
+ const iy = Math.max(0, Math.min(a[1] + a[3], b[1] + b[3]) - Math.max(a[1], b[1]))
1986
+ const inter = ix * iy
1987
+ const uni = a[2] * a[3] + b[2] * b[3] - inter
1988
+ return uni > 0 && inter / uni > 0.75
1989
+ }
1990
+ const score = async (b) => {
1991
+ if (b.w < 8 || b.h < 8) return { score: -1, posOk: false, at: null }
1992
+ writeCand(b)
1993
+ const r = await spawnRecoChild({
1994
+ resourceDir, type: 'TemplateMatch', image: frameFile, templateImage: candFile, cases: [{}],
1995
+ }, 60000)
1996
+ if (!r || !r.ok) return { score: -1, posOk: false, at: null }
1997
+ /* 子进程回 ok 但**一个 case 结果都没有**:识别压根没跑起来(实测最常见的原因是资源目录里没有可加载的
1998
+ * pipeline,reco 会安静地回 `results: []`)。这与"识别跑了但没命中"是两回事——后者仍会有 1 条结果
1999
+ * (hit:false)。记下来,别让它被下面的判据翻译成"低纹理/不独特"。 */
2000
+ if (!Array.isArray(r.results) || r.results.length === 0) {
2001
+ selfMatchDead = true
2002
+ return { score: -1, posOk: false, at: null, noResults: true }
2003
+ }
2004
+ const det = r.results && r.results[0] ? r.results[0].detail : null
2005
+ const best = det && det.detail && det.detail.best
2006
+ if (!best || !Array.isArray(best.box)) return { score: 0, posOk: false, at: null }
2007
+ const at = best.box.map(Number)
2008
+ return { score: Number(best.score), posOk: overlapOk(at, [b.x, b.y, b.w, b.h]), at }
2009
+ }
2010
+ const obj = (s) => (s.posOk ? 1000 : 0) + s.score
2011
+ /** 自匹配有没有跑起来(子进程回空结果 = 没跑起来)。跑到这里说明资源加载不出识别节点。 */
2012
+ let selfMatchDead = false
2013
+ const clampBox = (b) => ({
2014
+ x: Math.max(0, b.x), y: Math.max(0, b.y),
2015
+ w: Math.min(b.w, dec.w - Math.max(0, b.x)), h: Math.min(b.h, dec.h - Math.max(0, b.y)),
2016
+ })
2017
+ /* 候选池:snap 可能塌成细条(背景差分在混合内容上会选中稀疏行带),单靠它起步会让
2018
+ * ±2px 精修困死在退化框里——**两路**收紧框、加边距、外扩档、原始宽松框都进池,位置正确者优先。
2019
+ * 更紧的一路排前面:`obj` 相同时保留先入者,于是"同样位置正确、同样满分"时选更贴合的那个。 */
2020
+ const cands = []
2021
+ const pushFamily = (t, src) => {
2022
+ if (!t) return
2023
+ const box = { x: box0.x + t.x, y: box0.y + t.y, w: t.w, h: t.h }
2024
+ cands.push({ ...box, src })
2025
+ cands.push({ ...clampBox({ x: box.x - 4, y: box.y - 4, w: box.w + 8, h: box.h + 8 }), src: src + '+4' })
2026
+ for (const f of [0.25, 0.5]) {
2027
+ cands.push({
2028
+ ...clampBox({
2029
+ x: Math.round(box.x - box.w * f / 2), y: Math.round(box.y - box.h * f / 2),
2030
+ w: Math.round(box.w * (1 + f)), h: Math.round(box.h * (1 + f)),
2031
+ }),
2032
+ src: src + '+' + Math.round(f * 100) + '%',
2033
+ })
2034
+ }
2035
+ }
2036
+ for (const f of [{ t: tightBg, src: 'bg' }, { t: tightEdge, src: 'edge' }]
2037
+ .filter((x) => x.t)
2038
+ .sort((a, b) => a.t.w * a.t.h - b.t.w * b.t.h)) pushFamily(f.t, f.src)
2039
+ /* 池里最后放原始宽松框:**只在没被提前命中跳过时**它才会被评过分,有就如实带上。 */
2040
+ const looseCand = { ...box0, src: 'loose' }
2041
+ cands.push(looseCand)
2042
+ let cand = cands[0]
2043
+ let candSrc = cand.src
2044
+ let best = { score: -1, posOk: false, at: null }
2045
+ let bestObj = -Infinity
2046
+ let looseScored = null
2047
+ const tries = { n: 0 }
2048
+ for (const c of cands) {
2049
+ const s = await score(c)
2050
+ if (c === looseCand) looseScored = s
2051
+ tries.n++
2052
+ if (obj(s) > bestObj) { cand = c; best = s; bestObj = obj(s); candSrc = c.src }
2053
+ if (best.posOk && best.score >= 0.99) break
2054
+ }
2055
+ /* 逐边移动(每边独立尝试内收/外扩 2px,取更优) */
2056
+ const moves = [
2057
+ { k: 'left', apply: (b) => clampBox({ x: b.x - 2, y: b.y, w: b.w + 2, h: b.h }) },
2058
+ { k: 'right', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w + 2, h: b.h }) },
2059
+ { k: 'top', apply: (b) => clampBox({ x: b.x, y: b.y - 2, w: b.w, h: b.h + 2 }) },
2060
+ { k: 'bottom', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w, h: b.h + 2 }) },
2061
+ { k: 'left-in', apply: (b) => clampBox({ x: b.x + 2, y: b.y, w: b.w - 2, h: b.h }) },
2062
+ { k: 'right-in', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w - 2, h: b.h }) },
2063
+ { k: 'top-in', apply: (b) => clampBox({ x: b.x, y: b.y + 2, w: b.w, h: b.h - 2 }) },
2064
+ { k: 'bottom-in', apply: (b) => clampBox({ x: b.x, y: b.y, w: b.w, h: b.h - 2 }) },
2065
+ ]
2066
+ for (let round = 0; round < 2; round++) {
2067
+ let improved = false
2068
+ for (const mv of moves) {
2069
+ const nb = mv.apply(cand)
2070
+ if (nb.w < 8 || nb.h < 8 || (nb.x + nb.w > dec.w) || (nb.y + nb.h > dec.h)) continue
2071
+ if (nb.x === cand.x && nb.y === cand.y && nb.w === cand.w && nb.h === cand.h) continue
2072
+ const s = await score(nb)
2073
+ tries.n++
2074
+ if (obj(s) > bestObj + 1e-3) { cand = nb; best = s; bestObj = obj(s); improved = true; candSrc = 'refine' }
2075
+ }
2076
+ if (!improved) break
2077
+ }
2078
+ /* 贴合度信号(这一条是**免费**的:两个框都是现成的)。
2079
+ * 为什么不是"两个框各自的得分对比":实测同一元素在 40×61(元素本体)/ 80×80(点选窗口)/
2080
+ * 120×120(更大窗口)三种情况下自匹配**都是 1.0** 且都落回自己——像素完全相同,
2081
+ * 自匹配本质上是自洽指标,分辨不出"框不贴合"。几何比例才分辨得出。
2082
+ * `grew` = 最后选中的候选比宽松框还大:背景差分在这块内容上没收到元素边界(背景自带纹理时
2083
+ * 实测常见),或收紧塌成细条后带边距的候选赢了。留白留下的后果是 Click 的随机落点可能落在元素外。 */
2084
+ const looseArea = box0.w * box0.h
2085
+ const boxArea = cand.w * cand.h
2086
+ const areaRatio = looseArea > 0 ? Math.round((boxArea / looseArea) * 1000) / 1000 : null
2087
+ const tighten = {
2088
+ from: [box0.w, box0.h],
2089
+ to: [cand.w, cand.h],
2090
+ areaRatio,
2091
+ grew: boxArea > looseArea,
2092
+ /* 两路收紧各自给出的框(含自适应阈值):定标时看"谁赢、差多少" */
2093
+ bgBox: tightBg ? [tightBg.w, tightBg.h] : null,
2094
+ edgeBox: tightEdge ? [tightEdge.w, tightEdge.h] : null,
2095
+ ...(tightEdge ? { edgeThresh: tightEdge.thresh, edgeBgLevel: tightEdge.bgLevel } : {}),
2096
+ /** 最终框来自哪一族:bg / edge / bg+4 / edge+25% / loose / refine */
2097
+ winner: candSrc ?? null,
2098
+ /* 宽松框自己的自匹配结果:有就带上(多数情况下被池内提前命中跳过,为 null 是正常的) */
2099
+ ...(looseScored ? { looseMatch: { score: Math.round(looseScored.score * 1000) / 1000, positionOk: looseScored.posOk } } : {}),
2100
+ }
2101
+ const fitWarn = tighten.grew
2102
+ ? 'snap 收紧反而放大:' + box0.w + 'x' + box0.h + ' → ' + cand.w + 'x' + cand.h + '(面积 ' + areaRatio + '×);' +
2103
+ '自匹配照样 1.0(它只证明模板与源帧一致,不证明框贴合)。留白仍在——Click 的随机落点可能落在元素外,' +
2104
+ '换更贴合的框或把 target 收窄'
2105
+ : (areaRatio !== null && areaRatio < 0.35
2106
+ ? 'snap 把框收到了 ' + areaRatio + '×(' + box0.w + 'x' + box0.h + ' → ' + cand.w + 'x' + cand.h +
2107
+ '):属"塌陷"(定标数据里它位置通常仍正确),确认没把元素本身裁掉'
2108
+ : null)
2109
+ /* 跨帧验证:同帧自匹配 1.0 只证明模板与源帧一致;真正要的是在新帧上仍稳定。
2110
+ * 连接着真机就再抓一帧验证,得分大幅衰减或位置漂移 → 模板跨帧不稳,如实警告。
2111
+ * 库帧路径默认不做:留存帧对应的状态通常已不在画面上,拿当前帧比会得到假警告;
2112
+ * 状态可复现时显式给 cross:true 才做。 */
2113
+ let cross = null
2114
+ const wantCross = kf ? args.cross === true : args.cross !== false
2115
+ if (S.ctrl && wantCross) {
2116
+ const fresh = await captureNow('tpl-verify')
2117
+ if (fresh.ok) {
2118
+ writeCand(cand)
2119
+ const frame2 = path.join(os.tmpdir(), 'maa_tpl_frame2_' + Date.now() + '.png')
2120
+ try {
2121
+ fs.writeFileSync(frame2, S.l0.roll[S.l0.roll.length - 1].png)
2122
+ const r2 = await spawnRecoChild({
2123
+ resourceDir, type: 'TemplateMatch', image: frame2, templateImage: candFile, cases: [{}],
2124
+ }, 60000)
2125
+ const det = r2 && r2.ok && r2.results && r2.results[0] ? r2.results[0].detail : null
2126
+ const b2 = det && det.detail && det.detail.best
2127
+ cross = {
2128
+ seq: fresh.seq,
2129
+ score: b2 ? Math.round(Number(b2.score) * 1000) / 1000 : 0,
2130
+ posOk: b2 && Array.isArray(b2.box) ? overlapOk(b2.box.map(Number), [cand.x, cand.y, cand.w, cand.h]) : false,
2131
+ ...(b2 && Array.isArray(b2.box) ? { box: b2.box.map(Number) } : {}),
2132
+ }
2133
+ } finally {
2134
+ try { fs.rmSync(frame2, { force: true }) } catch (e) { /* ignore */ }
2135
+ }
2136
+ }
2137
+ }
2138
+ try { if (!kf) fs.rmSync(frameFile, { force: true }); fs.rmSync(candFile, { force: true }) } catch (e) { /* ignore */ }
2139
+
2140
+ const final = cropRgb(rgb, dec.w, dec.h, cand)
2141
+ const out = args.out || defaultOutFile(S.runDir, 'tpl').file
2142
+ fs.mkdirSync(path.dirname(out), { recursive: true })
2143
+ fs.writeFileSync(out, pngEncodeRGB(final.data, final.w, final.h))
2144
+ const warn = selfMatchDead
2145
+ ? '自匹配没跑起来:识别子进程一个结果都没回(实测最常见原因是资源目录里没有可加载的 pipeline)。' +
2146
+ '这不是"模板不独特"——先写一个 pipeline 节点,或用 --resource-dir 指向真实资源,再重跑这次的框;' +
2147
+ '在此之前它**未经自匹配验证**,不要拿它去写 pipeline'
2148
+ : !best.posOk
2149
+ ? '低纹理/不独特:模板在同帧上都定位不到自己(best 落在 ' + JSON.stringify(best.at) + ',得分 ' +
2150
+ best.score.toFixed(3) + ')——换更纹理化的框,或走点选路径'
2151
+ : (best.score < 0.7 ? '得分偏低(' + best.score.toFixed(3) + ')但位置正确:可用,注意跨帧稳定性' : null)
2152
+ /* 模板空间 = 源帧捕获时的控制器尺寸。与当前控制器不一致时模板不能被当前识别空间直接使用
2153
+ * (契约 §5:坐标 / ROI 必须声明所属图像空间;改过 shortSide 或换过设备就会出现)。 */
2154
+ const spaceCheck = S.ctrl && S.fullW && S.fullH
2155
+ ? { current: { w: S.fullW, h: S.fullH }, match: S.fullW === dec.w && S.fullH === dec.h }
2156
+ : { current: null, match: null }
2157
+ const spaceWarn = spaceCheck.match === false
2158
+ ? '源帧空间 ' + dec.w + 'x' + dec.h + ' ≠ 当前控制器 ' + S.fullW + 'x' + S.fullH +
2159
+ ':模板按源帧空间裁出,与当前识别空间不一致(改过 shortSide / 换过设备?)'
2160
+ : null
2161
+ const crossWarn = cross && best.posOk && (cross.score < best.score - 0.15 || !cross.posOk)
2162
+ ? '跨帧不稳:新帧(seq=' + cross.seq + ')上得分 ' + cross.score + (cross.posOk ? '' : '且位置漂移') +
2163
+ ',源帧 ' + best.score.toFixed(3) + '——' +
2164
+ (spaceCheck.match === false
2165
+ /* 空间不同源时跨帧匹配本就不可比:别把它归因成"动态区域" */
2166
+ ? '源帧空间与当前控制器不一致(见下条),该比对不成立,先在当前空间重采一帧再验'
2167
+ : '模板对动态区域敏感,慎用于识别')
2168
+ : null
2169
+ const box = [cand.x, cand.y, cand.w, cand.h]
2170
+ const looseBox = [box0.x, box0.y, box0.w, box0.h]
2171
+ const selfScore = Math.round(best.score * 1000) / 1000
2172
+ /* L2 出处:字段白名单见 buildL2Provenance;落点由上面的 provOut / 缺省"模板旁"决定 */
2173
+ const provenance = buildL2Provenance({
2174
+ source, loose: looseBox, box, snapped, score: selfScore, positionOk: best.posOk,
2175
+ ...(best.at ? { selfMatchBox: best.at } : {}), tries: tries.n, cross,
2176
+ w: final.w, h: final.h, createdAt: new Date().toISOString(),
2177
+ })
2178
+ let provPath = null
2179
+ let provWarn = null
2180
+ if (args.prov !== false) {
2181
+ /* 出处落点:缺省写在模板旁("同生共死",复制/改名时出处跟着走);给了 provOut 就写到那个目录下、
2182
+ * 保持同文件名。**为什么需要它**:模板常常要放进资源包的 image/(框架会整目录打包发给用户),
2183
+ * 而出处是给开发者/计划用的过程资产——留在包外能让资源包只装框架要加载的东西(实测该项目一条:
2184
+ * 发行打包按 resource[].path 整目录拷贝,侧车会随包发出去)。约定不变量:**同一相对路径**,
2185
+ * 目录根由项目自定(例如 state-plan/provenance/ 与 image/ 同构)。 */
2186
+ const provOut = typeof args.provOut === 'string' && args.provOut ? String(args.provOut) : null
2187
+ provPath = provOut ? path.join(provOut, path.basename(out) + '.prov.json') : out + '.prov.json'
2188
+ try {
2189
+ fs.mkdirSync(path.dirname(provPath), { recursive: true })
2190
+ fs.writeFileSync(provPath, JSON.stringify(provenance, null, 2) + '\n')
2191
+ } catch (e) {
2192
+ provPath = null
2193
+ provWarn = 'L2 出处记录写入失败:' + String(e && e.message || e) + '(模板已裁出,但其出处未落盘)'
2194
+ }
2195
+ }
2196
+ /* 出处是"热缓存帧"时给出可行动提示:这种出处在进程结束后无从复核(seq 指不回任何东西)。
2197
+ * 它本身不是错误(真机上现裁现用是合法动线),但**入库的模板**该有可复核的来源。 */
2198
+ const keepWarn = keptSource && keptSource.error
2199
+ ? '--keep-source 没能升格源帧(' + keptSource.error + '):出处仍是热缓存来源,无从复核'
2200
+ : null
2201
+ const transientWarn = provenance.derivedFrom.kind === 'l0-cache'
2202
+ ? '出处来源是热缓存帧(seq=' + provenance.derivedFrom.seq + '):本进程结束后无从复核。' +
2203
+ '要入库的模板加 --keep-source(顺手把这一帧升格进库)或先 kf promote 再裁'
2204
+ : null
2205
+ const warns = [fitWarn, warn, crossWarn, spaceWarn, provWarn, keepWarn, transientWarn].filter(Boolean)
2206
+ return {
2207
+ ok: true, path: out, seq: kf ? null : ent.seq, source,
2208
+ provPath, provenance,
2209
+ ...(keptSource ? { keptSource } : {}),
2210
+ box, loose: looseBox,
2211
+ tighten,
2212
+ snapped, score: selfScore,
2213
+ positionOk: best.posOk,
2214
+ ...(best.at ? { selfMatchBox: best.at } : {}),
2215
+ ...(cross ? { cross } : {}),
2216
+ tries: tries.n,
2217
+ w: final.w, h: final.h,
2218
+ ctrlW: dec.w, ctrlH: dec.h,
2219
+ spaceCheck,
2220
+ ...(warns.length ? { warn: warns[0], warns } : {}),
2221
+ }
2222
+ }
2223
+
2224
+ /* ────────────────────────── 探色(ROI 实测,模型只选色不报色值) ──────────────────────────
2225
+ * 在缓冲帧(小图)上实测 ROI 的均值/HSV/主色;坐标用控制器空间(与 frame_get --roi 同语义,
2226
+ * 按该帧捕获时尺寸换算)。选色后用 reco ColorMatch 出框(走已修好的参数透传链)。 */
2227
+ function rgbToHsv(r, g, b) {
2228
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn
2229
+ let h = 0
2230
+ if (d !== 0) {
2231
+ if (mx === r) h = ((g - b) / d + 6) % 6
2232
+ else if (mx === g) h = (b - r) / d + 2
2233
+ else h = (r - g) / d + 4
2234
+ h *= 60
2235
+ }
2236
+ return { h: Math.round(h), s: Math.round(mx ? (d / mx) * 100 : 0), v: Math.round((mx / 255) * 100) }
2237
+ }
2238
+
2239
+ function cmdColorProbe(args) {
2240
+ const fr = args.seq !== undefined && args.seq !== null
2241
+ ? S.ring.find((r) => r.seq === Number(args.seq))
2242
+ : S.ring[S.ring.length - 1]
2243
+ if (!fr) {
2244
+ return { ok: false, error: '缓冲无可用帧(seq=' + args.seq + '):先 screencap 或 stream start 产生观测' }
2245
+ }
2246
+ let rx = 0, ry = 0, rw = fr.w, rh = fr.h
2247
+ let ctrlRoi = null
2248
+ const roiIn = readRoi(args.roi)
2249
+ if (roiIn.error) return { ok: false, error: roiIn.error }
2250
+ if (roiIn.roi) {
2251
+ const [x, y, w, h] = roiIn.roi
2252
+ const sx = fr.fw > 0 ? fr.w / fr.fw : 1
2253
+ const sy = fr.fh > 0 ? fr.h / fr.fh : 1
2254
+ rx = Math.max(0, Math.floor(x * sx))
2255
+ ry = Math.max(0, Math.floor(y * sy))
2256
+ rw = Math.min(fr.w - rx, Math.max(1, Math.round(w * sx)))
2257
+ rh = Math.min(fr.h - ry, Math.max(1, Math.round(h * sy)))
2258
+ if (rx >= fr.w || ry >= fr.h || rw <= 0 || rh <= 0) {
2259
+ return { ok: false, error: 'ROI 完全越界(' + JSON.stringify(args.roi) + ' vs 捕获时 ' + fr.fw + 'x' + fr.fh + ')' }
2260
+ }
2261
+ ctrlRoi = [x, y, w, h]
2262
+ }
2263
+ let r = 0, g = 0, b = 0, n = 0
2264
+ const buckets = new Map()
2265
+ for (let y = ry; y < ry + rh; y++) {
2266
+ for (let x = rx; x < rx + rw; x++) {
2267
+ const i = (y * fr.w + x) * 3
2268
+ const R = fr.rgb[i], G = fr.rgb[i + 1], B = fr.rgb[i + 2]
2269
+ r += R; g += G; b += B; n++
2270
+ const key = ((R >> 4) << 8) | ((G >> 4) << 4) | (B >> 4)
2271
+ buckets.set(key, (buckets.get(key) || 0) + 1)
2272
+ }
2273
+ }
2274
+ r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n)
2275
+ let dKey = 0, dCnt = 0
2276
+ for (const [k, c] of buckets) if (c > dCnt) { dCnt = c; dKey = k }
2277
+ const dom = [((dKey >> 8) & 15) * 17, ((dKey >> 4) & 15) * 17, (dKey & 15) * 17]
2278
+ return {
2279
+ ok: true, seq: fr.seq,
2280
+ ...(ctrlRoi ? { roi: ctrlRoi } : {}),
2281
+ pixelRoi: [rx, ry, rw, rh], count: n,
2282
+ mean: { r, g, b, gray: Math.round(0.299 * r + 0.587 * g + 0.114 * b) },
2283
+ hsv: rgbToHsv(r, g, b),
2284
+ dominant: { rgb: dom, ratio: Math.round((dCnt / n) * 1000) / 1000 },
2285
+ space: { small: [fr.w, fr.h], ctrl: [fr.fw, fr.fh] },
2286
+ }
2287
+ }
2288
+
2289
+ /* ────────────────────────── annotate(轻量 SoM:候选区域 + 编号回画) ──────────────────────────
2290
+ * 候选源:OCR 框(MaaFW OCR 管线,detail.all)+ 连通域(积分图背景差 + BFS)+ 边缘密度
2291
+ * (Sobel 块 z 值聚类)+ 变化 diff 区域(change 事件的 ctrl bbox)。回画编号图 + 候选表,
2292
+ * 模型看图选号 → 查 ctrl 坐标 → click/reco。半透明/粒子/渐变场景候选质量降级,走点选路径。 */
2293
+ const FONT3X5 = {
2294
+ 0: ['111', '101', '101', '101', '111'], 1: ['010', '110', '010', '010', '111'],
2295
+ 2: ['111', '001', '111', '100', '111'], 3: ['111', '001', '111', '001', '111'],
2296
+ 4: ['101', '101', '111', '001', '001'], 5: ['111', '100', '111', '001', '111'],
2297
+ 6: ['111', '100', '111', '101', '111'], 7: ['111', '001', '001', '001', '001'],
2298
+ 8: ['111', '101', '111', '101', '111'], 9: ['111', '101', '111', '001', '111'],
2299
+ }
2300
+ function drawLabel(buf, w, h, x, y, num, color) {
2301
+ const s = String(num)
2302
+ const lw = s.length * 4 + 1, lh = 7
2303
+ const px = Math.max(0, Math.min(w - lw, x)), py = Math.max(0, Math.min(h - lh, y))
2304
+ for (let yy = 0; yy < lh; yy++) for (let xx = 0; xx < lw; xx++) {
2305
+ const i = ((py + yy) * w + px + xx) * 3
2306
+ buf[i] = 255; buf[i + 1] = 255; buf[i + 2] = 255
2307
+ }
2308
+ for (let k = 0; k < s.length; k++) {
2309
+ const glyph = FONT3X5[s[k]]
2310
+ for (let gy = 0; gy < 5; gy++) for (let gx = 0; gx < 3; gx++) {
2311
+ if (glyph[gy][gx] !== '1') continue
2312
+ const i = ((py + 1 + gy) * w + px + 1 + k * 4 + gx) * 3
2313
+ buf[i] = color[0]; buf[i + 1] = color[1]; buf[i + 2] = color[2]
2314
+ }
2315
+ }
2316
+ }
2317
+ function drawRect(buf, w, h, box, color) {
2318
+ const [x, y, bw, bh] = box
2319
+ const x1 = Math.min(w - 1, x + bw - 1), y1 = Math.min(h - 1, y + bh - 1)
2320
+ for (let xx = Math.max(0, x); xx <= x1; xx++) {
2321
+ for (const yy of [Math.max(0, y), y1]) {
2322
+ const i = (yy * w + xx) * 3
2323
+ buf[i] = color[0]; buf[i + 1] = color[1]; buf[i + 2] = color[2]
2324
+ }
2325
+ }
2326
+ for (let yy = Math.max(0, y); yy <= y1; yy++) {
2327
+ for (const xx of [Math.max(0, x), x1]) {
2328
+ const i = (yy * w + xx) * 3
2329
+ buf[i] = color[0]; buf[i + 1] = color[1]; buf[i + 2] = color[2]
2330
+ }
2331
+ }
2332
+ }
2333
+
2334
+ /** 灰度图积分图 → 均值背景 → 背景差二值 → BFS 连通域(面积过滤) */
2335
+ /** 连通域候选(形态学偏离块 + BFS 成框)。stats 回填被面积窗与"太扁"拒掉的数量。 */
2336
+ function connComponents(rgb, w, h, stats = null) {
2337
+ const gray = new Float64Array(w * h)
2338
+ for (let i = 0, p = 0; i < w * h; i++, p += 3) {
2339
+ gray[i] = (rgb[p] * 3 + rgb[p + 1] * 6 + rgb[p + 2]) * 0.1
2340
+ }
2341
+ const integ = new Float64Array((w + 1) * (h + 1))
2342
+ for (let y = 0; y < h; y++) {
2343
+ let rowSum = 0
2344
+ for (let x = 0; x < w; x++) {
2345
+ rowSum += gray[y * w + x]
2346
+ integ[(y + 1) * (w + 1) + (x + 1)] = integ[y * (w + 1) + (x + 1)] + rowSum
2347
+ }
2348
+ }
2349
+ const R = 12
2350
+ const dev = new Uint8Array(w * h)
2351
+ for (let y = 0; y < h; y++) {
2352
+ const y0 = Math.max(0, y - R), y1 = Math.min(h, y + R + 1)
2353
+ for (let x = 0; x < w; x++) {
2354
+ const x0 = Math.max(0, x - R), x1 = Math.min(w, x + R + 1)
2355
+ const area = (x1 - x0) * (y1 - y0)
2356
+ const sum = integ[y1 * (w + 1) + x1] - integ[y0 * (w + 1) + x1] - integ[y1 * (w + 1) + x0] + integ[y0 * (w + 1) + x0]
2357
+ if (Math.abs(gray[y * w + x] - sum / area) > 26) dev[y * w + x] = 1
2358
+ }
2359
+ }
2360
+ const seen = new Uint8Array(w * h)
2361
+ const boxes = []
2362
+ const queue = new Int32Array(w * h)
2363
+ for (let start = 0; start < w * h; start++) {
2364
+ if (!dev[start] || seen[start]) continue
2365
+ let qs = 0, qe = 0
2366
+ queue[qe++] = start
2367
+ seen[start] = 1
2368
+ let minX = w, minY = h, maxX = 0, maxY = 0, area = 0
2369
+ while (qs < qe) {
2370
+ const p = queue[qs++]
2371
+ const px = p % w, py = (p / w) | 0
2372
+ area++
2373
+ if (px < minX) minX = px; if (px > maxX) maxX = px
2374
+ if (py < minY) minY = py; if (py > maxY) maxY = py
2375
+ if (px > 0 && dev[p - 1] && !seen[p - 1]) { seen[p - 1] = 1; queue[qe++] = p - 1 }
2376
+ if (px < w - 1 && dev[p + 1] && !seen[p + 1]) { seen[p + 1] = 1; queue[qe++] = p + 1 }
2377
+ if (py > 0 && dev[p - w] && !seen[p - w]) { seen[p - w] = 1; queue[qe++] = p - w }
2378
+ if (py < h - 1 && dev[p + w] && !seen[p + w]) { seen[p + w] = 1; queue[qe++] = p + w }
2379
+ }
2380
+ if (area >= 120 && area <= 24000 && maxX - minX >= 10 && maxY - minY >= 10) {
2381
+ boxes.push([minX, minY, maxX - minX + 1, maxY - minY + 1])
2382
+ if (stats) stats.passed++
2383
+ } else if (stats) {
2384
+ /* 三类拒绝分开记:面积窗(120–24000)与"太扁"是分辨率/内容相关的两道不同门槛,
2385
+ * 混成一个数字就看不出"换分辨率会不会漂"。 */
2386
+ if (area < 120) stats.tooSmall++
2387
+ else if (area > 24000) stats.tooLarge++
2388
+ else stats.tooNarrow++
2389
+ }
2390
+ }
2391
+ return boxes
2392
+ }
2393
+
2394
+ /** Sobel 边缘密度的块级聚类(16px 块,z 值超限块 BFS 成框)。
2395
+ * opts.z / opts.min 是**测量用覆盖**(默认即生产常数 1.2 / 2000):edge 源长期只出个位数候选,
2396
+ * 要判断"是阈值太严还是这类内容真没边缘",就得能调着量。stats 回填被两道门槛与簇大小拒掉的数量。 */
2397
+ function edgeDensityBoxes(rgb, w, h, opts = {}, stats = null) {
2398
+ const BS = 16
2399
+ const bw = Math.ceil(w / BS), bh = Math.ceil(h / BS)
2400
+ const mag = new Float64Array(w * h)
2401
+ for (let y = 1; y < h - 1; y++) {
2402
+ for (let x = 1; x < w - 1; x++) {
2403
+ const i = (y * w + x) * 3
2404
+ 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])
2405
+ 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])
2406
+ mag[y * w + x] = Math.abs(gx) + Math.abs(gy)
2407
+ }
2408
+ }
2409
+ const blk = new Float64Array(bw * bh)
2410
+ 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]
2411
+ let mean = 0
2412
+ for (const v of blk) mean += v
2413
+ mean /= blk.length
2414
+ let sd = 0
2415
+ for (const v of blk) sd += (v - mean) * (v - mean)
2416
+ sd = Math.sqrt(sd / blk.length)
2417
+ const hot = new Uint8Array(bw * bh)
2418
+ const Z = Number(opts.z) > 0 ? Number(opts.z) : 1.2
2419
+ const MIN = Number(opts.min) > 0 ? Number(opts.min) : 2000
2420
+ /* stats 只记账不改行为:被两道门槛拒掉多少块,是"edge 为什么不出力"唯一能看的证据。 */
2421
+ for (let k = 0; k < blk.length; k++) {
2422
+ if (blk[k] <= mean + Z * sd) { if (stats) stats.belowZ++; continue }
2423
+ if (blk[k] <= MIN) { if (stats) stats.belowMin++; continue }
2424
+ hot[k] = 1
2425
+ }
2426
+ const seen = new Uint8Array(bw * bh)
2427
+ const boxes = []
2428
+ const stack = []
2429
+ for (let k = 0; k < bw * bh; k++) {
2430
+ if (!hot[k] || seen[k]) continue
2431
+ stack.length = 0
2432
+ stack.push(k)
2433
+ seen[k] = 1
2434
+ let minX = bw, minY = bh, maxX = 0, maxY = 0, n = 0
2435
+ while (stack.length) {
2436
+ const p = stack.pop()
2437
+ const px = p % bw, py = (p / bw) | 0
2438
+ n++
2439
+ if (px < minX) minX = px; if (px > maxX) maxX = px
2440
+ if (py < minY) minY = py; if (py > maxY) maxY = py
2441
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
2442
+ const nx = px + dx, ny = py + dy
2443
+ if (nx < 0 || ny < 0 || nx >= bw || ny >= bh) continue
2444
+ const q = ny * bw + nx
2445
+ if (hot[q] && !seen[q]) { seen[q] = 1; stack.push(q) }
2446
+ }
2447
+ }
2448
+ if (n >= 2) {
2449
+ const x = minX * BS, y = minY * BS
2450
+ boxes.push([x, y, Math.min(w - x, (maxX - minX + 1) * BS), Math.min(h - y, (maxY - minY + 1) * BS)])
2451
+ if (stats) stats.passed++
2452
+ } else if (stats) stats.singleBlock++
2453
+ }
2454
+ return boxes
2455
+ }
2456
+
2457
+ const SOM_COLORS = { ocr: [0, 190, 255], conn: [60, 220, 60], edge: [255, 160, 0], diff: [255, 70, 70] }
2458
+ const SOM_PRIORITY = { ocr: 0, diff: 1, conn: 2, edge: 3 }
2459
+
2460
+ /**
2461
+ * 上限截断:按**横向分带轮转**取样,而不是直接截前 N 个。
2462
+ *
2463
+ * 为什么:候选是先按源优先级、再按扫描顺序(自上而下)排的,直接截前 N 个会让候选表系统性偏顶部。
2464
+ * 实测(6 帧 1280×720,上限 30):合并总数中位数 54、最大 135,默认上限只放出 41%;
2465
+ * 大厅帧 63 个底部候选**一个都没进表**,看上去像"底部没有可点区域"——那是策略造成的假象,
2466
+ * 不是画面事实。轮转后各带按供给比例进表,源优先级在带内仍然保持。
2467
+ */
2468
+ function pickBalancedCandidates(list, limit, h) {
2469
+ const bands = [[], [], []]
2470
+ for (const c of list) {
2471
+ const cy = c.box[1] + c.box[3] / 2
2472
+ bands[cy < h / 3 ? 0 : (cy < (2 * h) / 3 ? 1 : 2)].push(c)
2473
+ }
2474
+ /* 带内再按**来源轮转**。为什么:合并池是按源优先级(ocr > diff > conn > edge)排的,
2475
+ * 而一个带里 OCR 常有几十条——直接把 conn / edge 挤光。偏偏**图标类目标多来自 conn / edge**
2476
+ * (实测:主页面顶部那个图标在合并池里排 #55,默认上限 30 时永远进不了表,覆盖率 0%;
2477
+ * 上限放开到 200 才出现,覆盖 62%)。来源轮转保证每带每一路都有代表,源优先级只决定同轮先后。 */
2478
+ const interleave = (b) => {
2479
+ const groups = {}
2480
+ for (const c of b) (groups[c.source] = groups[c.source] || []).push(c)
2481
+ const srcs = Object.keys(groups).sort((x, y) => (SOM_PRIORITY[x] ?? 9) - (SOM_PRIORITY[y] ?? 9))
2482
+ const res = []
2483
+ for (let i = 0; ; i++) {
2484
+ let any = false
2485
+ for (const s of srcs) {
2486
+ if (groups[s][i]) { res.push(groups[s][i]); any = true }
2487
+ }
2488
+ if (!any) break
2489
+ }
2490
+ return res
2491
+ }
2492
+ const ordered = bands.map(interleave)
2493
+ const out = []
2494
+ for (let i = 0; out.length < limit; i++) {
2495
+ let taken = false
2496
+ for (const b of ordered) {
2497
+ if (!b[i]) continue
2498
+ out.push(b[i])
2499
+ taken = true
2500
+ if (out.length >= limit) break
2501
+ }
2502
+ if (!taken) break
2503
+ }
2504
+ return out
2505
+ }
2506
+
2507
+ async function cmdAnnotate(args) {
2508
+ /* 工作图两条路:会话内 L1 小图(缺省)或关键帧库留存帧(kfSource,控制器分辨率、**可无设备**)。
2509
+ * 小图路径的候选要按该帧捕获时尺寸换算回控制器空间;库帧本身就是控制器空间,换算系数为 1。
2510
+ * diff 源依赖会话内 change 事件——库帧路径没有,如实报不可用,不拿别的区域顶替(不冒称)。 */
2511
+ let work = null
2512
+ let seq = null
2513
+ let source = null
2514
+ let detectionSource = null
2515
+ if (args.kfSource) {
2516
+ const kf = readKfCropSource(args.kfSource)
2517
+ if (kf.error) return { ok: false, error: kf.error }
2518
+ work = { rgb: toRgb(kf.dec), w: kf.dec.w, h: kf.dec.h, fw: 0, fh: 0 }
2519
+ source = kf.source
2520
+ detectionSource = 'kf-frame'
2521
+ } else {
2522
+ const fr = args.seq !== undefined && args.seq !== null
2523
+ ? S.ring.find((r) => r.seq === Number(args.seq))
2524
+ : S.ring[S.ring.length - 1]
2525
+ if (!fr) return { ok: false, error: '缓冲无可用帧(seq=' + args.seq + '):先 screencap / stream 产生观测' }
2526
+ /* 检测面分辨率:有同一 seq 的 L0 全分辨率原图就**优先用它**。
2527
+ * 为什么:候选检测的分辨率下限直接决定"小图标能不能被找到"。实测大厅顶部那个 42×55 的图标,
2528
+ * 在 480 宽的 L1 上四个源一个都没探到(合并池仅 30 条、无截断,却仍然没有它),
2529
+ * 在 1280 宽的原图上 conn 一次就命中(527,36,42,55)——漏报的根因是分辨率,不是上限。
2530
+ * L1 只作降级(L0 已淘汰时),且回执里如实标注,别让人以为降级图和原图同源。
2531
+ * `somScale: 'small'` 可强制走小图(要快、且只关心大目标时)。 */
2532
+ let det = null
2533
+ if (String(args.somScale ?? 'full') !== 'small') {
2534
+ const l0 = findL0(fr.seq)
2535
+ if (l0) {
2536
+ try {
2537
+ const dec = pngDecode(l0.png)
2538
+ det = { rgb: toRgb(dec), w: dec.w, h: dec.h }
2539
+ } catch (e) { det = null }
2540
+ }
2541
+ }
2542
+ work = det
2543
+ ? { rgb: det.rgb, w: det.w, h: det.h, fw: det.w, fh: det.h }
2544
+ : { rgb: fr.rgb, w: fr.w, h: fr.h, fw: fr.fw, fh: fr.fh }
2545
+ seq = fr.seq
2546
+ detectionSource = det ? 'l0-full' : 'l1-ring'
2547
+ source = {
2548
+ kind: detectionSource, seq: fr.seq, ctrlW: fr.fw, ctrlH: fr.fh,
2549
+ ...(det ? {} : {
2550
+ degraded: true,
2551
+ note: 'L0 全分辨率原图已淘汰,检测在降采样图上进行(' + fr.w + 'x' + fr.h
2552
+ + ',有效 ' + (fr.w / Math.max(1, fr.fw)).toFixed(3) + '×):小目标可能根本探不到。'
2553
+ + '要全分辨率就在截图后立刻跑 annotate,或先 kf promote 留存该帧再 --from-kf。',
2554
+ }),
2555
+ }
2556
+ }
2557
+ const sx = work.fw > 0 ? work.fw / work.w : 1
2558
+ const sy = work.fh > 0 ? work.fh / work.h : 1
2559
+ const toCtrl = (b) => [Math.round(b[0] * sx), Math.round(b[1] * sy), Math.round(b[2] * sx), Math.round(b[3] * sy)]
2560
+ const raw = []
2561
+
2562
+ /* 源 1:OCR 框(子进程跑 MaaFW OCR,detail.all 是全部文本框) */
2563
+ if (args.resourceDir) {
2564
+ const imgFile = path.join(os.tmpdir(), 'maa_som_' + Date.now() + '.png')
2565
+ try {
2566
+ fs.writeFileSync(imgFile, pngEncodeRGB(work.rgb, work.w, work.h))
2567
+ const r = await spawnRecoChild({ resourceDir: String(args.resourceDir), type: 'OCR', image: imgFile, cases: [{}] }, 60000)
2568
+ if (r && r.ok) {
2569
+ const det = r.results && r.results[0] && r.results[0].detail
2570
+ const all = det && det.detail && Array.isArray(det.detail.all) ? det.detail.all : []
2571
+ for (const hit of all) {
2572
+ if (!Array.isArray(hit.box) || Number(hit.score) < 0.5) continue
2573
+ raw.push({ source: 'ocr', box: hit.box.map(Number), extra: { text: String(hit.text ?? ''), score: Number(hit.score) } })
2574
+ }
2575
+ }
2576
+ } finally {
2577
+ try { fs.rmSync(imgFile, { force: true }) } catch (e) { /* ignore */ }
2578
+ }
2579
+ }
2580
+ /* 源 2/3:连通域与边缘密度(纯 CPU,小图上毫秒级);stats 记下被门槛拒掉的数量——
2581
+ * "某段没有候选"到底是看不见还是被过滤掉,只有这个计数能回答(分辨率变化时尤其要紧)。 */
2582
+ const connStats = { passed: 0, tooSmall: 0, tooLarge: 0, tooNarrow: 0 }
2583
+ const edgeStats = { passed: 0, belowZ: 0, belowMin: 0, singleBlock: 0 }
2584
+ for (const b of connComponents(work.rgb, work.w, work.h, connStats)) raw.push({ source: 'conn', box: b })
2585
+ for (const b of edgeDensityBoxes(work.rgb, work.w, work.h, { z: args.somEdgeZ, min: args.somEdgeMin }, edgeStats)) {
2586
+ raw.push({ source: 'edge', box: b })
2587
+ }
2588
+ /* 源 4:最近 change 事件的 diff 区域(ctrl → 小图坐标);库帧路径没有会话事件 */
2589
+ const diffs = []
2590
+ if (!args.kfSource) {
2591
+ for (let i = S.events.length - 1; i >= 0 && diffs.length < 3; i--) {
2592
+ const ev = S.events[i]
2593
+ if (ev.type === 'change' && ev.region && ev.region.ctrl) diffs.push(ev.region.ctrl)
2594
+ }
2595
+ }
2596
+ for (const c of diffs) {
2597
+ 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)] })
2598
+ }
2599
+
2600
+ /* 去重合并:IoU>0.6 保留优先级高的源(ocr > diff > conn > edge);上限缺省 30。
2601
+ * somLimit 是**测量用覆盖**:不放开上限就量不到"被截断前有多少候选",也就无法判断 30 该不该动。 */
2602
+ const LIMIT = Number(args.somLimit) > 0 ? Math.floor(Number(args.somLimit)) : 30
2603
+ raw.sort((a, b) => (SOM_PRIORITY[a.source] ?? 9) - (SOM_PRIORITY[b.source] ?? 9))
2604
+ const iou = (a, b) => {
2605
+ const ix = Math.max(0, Math.min(a[0] + a[2], b[0] + b[2]) - Math.max(a[0], b[0]))
2606
+ const iy = Math.max(0, Math.min(a[1] + a[3], b[1] + b[3]) - Math.max(a[1], b[1]))
2607
+ const inter = ix * iy
2608
+ const uni = a[2] * a[3] + b[2] * b[3] - inter
2609
+ return uni > 0 ? inter / uni : 0
2610
+ }
2611
+ /* 巨框剔除:面积占画面比例过大的候选几乎必是背景 / 立绘 / 整屏变化区——对"找可点目标"是纯噪音,
2612
+ * 而且因为面积大反而**遮住真目标**(实测每帧稳定 2 个,含一次 1280×720 全屏)。
2613
+ * OCR 框不参与:文本包围盒天然有界,且是全表里最可信的一路。 */
2614
+ const frameArea = work.w * work.h
2615
+ const maxRatio = Number(args.somMaxAreaRatio) > 0 ? Number(args.somMaxAreaRatio) : 0.12
2616
+ const dropped = { tooSmall: 0, tooBig: 0, duplicated: 0 }
2617
+ const merged = []
2618
+ const IOU = Number(args.somIoU) > 0 ? Math.min(0.99, Number(args.somIoU)) : 0.6
2619
+ for (const c of raw) {
2620
+ if (c.box[2] < 8 || c.box[3] < 8) { dropped.tooSmall++; continue }
2621
+ const ratio = (c.box[2] * c.box[3]) / frameArea
2622
+ if (c.source !== 'ocr' && ratio > maxRatio) { dropped.tooBig++; continue }
2623
+ if (merged.some((x) => iou(x.box, c.box) > IOU)) { dropped.duplicated++; continue }
2624
+ merged.push(c)
2625
+ }
2626
+ /* ROI 收窄(可选):只保留**落在给定区域**的候选,且这一步在**取上限之前**做——
2627
+ * 于是这个区域里的候选只跟自己竞争,不会因为画面别处有 160 条而被挤掉。
2628
+ * 这是"上限截断"的正解:不是把上限调大(那是把上下文成本推给调用方),而是把搜索面收小。 */
2629
+ const roiIn = readRoi(args.roi)
2630
+ if (roiIn.error) return { ok: false, error: roiIn.error }
2631
+ const ctrlW0 = work.fw > 0 ? work.fw : work.w
2632
+ const ctrlH0 = work.fh > 0 ? work.fh : work.h
2633
+ let pool = merged
2634
+ let roiApplied = null
2635
+ if (roiIn.roi) {
2636
+ const [rx, ry, rw, rh] = roiIn.roi
2637
+ const sx0 = work.w / Math.max(1, ctrlW0)
2638
+ const sy0 = work.h / Math.max(1, ctrlH0)
2639
+ const inRoi = (box) => {
2640
+ const cx = (box[0] + box[2] / 2) * (1 / sx0)
2641
+ const cy = (box[1] + box[3] / 2) * (1 / sy0)
2642
+ return cx >= rx && cx <= rx + rw && cy >= ry && cy <= ry + rh
2643
+ }
2644
+ pool = merged.filter((c) => inRoi(c.box))
2645
+ roiApplied = { roi: [rx, ry, rw, rh], inPool: pool.length, outsideRoi: merged.length - pool.length }
2646
+ }
2647
+ const cands = pickBalancedCandidates(pool, LIMIT, work.h) /* 分带可用性:三分带各自"进表多少 / 合并池里有多少"。上限截断是分带轮转的,
2648
+ * 于是"这一带没有候选"要么是供给真没有、要么是被门槛拒了或被上限截了——三列一起读才可归因。 */
2649
+ const bandOf = (cy, h) => (cy < h / 3 ? 0 : (cy < (2 * h) / 3 ? 1 : 2))
2650
+ const bandCount = (arr, bi) => arr.filter((c) => bandOf(c.box[1] + c.box[3] / 2, work.h) === bi).length
2651
+ const bands = [0, 1, 2].map((bi) => ({
2652
+ band: bi, yFrom: Math.round((bi * work.h) / 3), yTo: Math.round(((bi + 1) * work.h) / 3),
2653
+ inPool: bandCount(pool, bi), inTable: bandCount(cands, bi),
2654
+ }))
2655
+ /* 回画:源配色边框 + 编号标签。**优先全分辨率**(回画物的用途是"人照着框选",
2656
+ * 480 宽的图在小目标上根本框不准——本案例实测被用户退回)。
2657
+ * 检测若已用 L0,`work` 就是那张原图:**直接复用**,省掉第二次 PNG 解码,
2658
+ * 也避免"检测时命中、回画时已淘汰"让来源标签说谎——中间隔着最长 60 秒的 OCR await,
2659
+ * 而 L0 滚动窗在 10fps 下只有约 1.6 秒,这个竞态是真会发生的。
2660
+ * 检测若走了降级小图,这里再试一次 L0:命中就能给出全分辨率回画(框按 kx 放大)。 */
2661
+ let draw = { rgb: work.rgb, w: work.w, h: work.h, kx: 1, ky: 1, source: detectionSource ?? 'l1-ring' }
2662
+ if (detectionSource !== 'l0-full' && !args.kfSource && seq !== null) {
2663
+ const l0 = findL0(seq)
2664
+ if (l0) {
2665
+ try {
2666
+ const dec = pngDecode(l0.png)
2667
+ draw = { rgb: toRgb(dec), w: dec.w, h: dec.h, kx: dec.w / work.w, ky: dec.h / work.h, source: 'l0-full' }
2668
+ } catch (e) { /* 解码失败就退回工作图 */ }
2669
+ }
2670
+ }
2671
+ const buf = Buffer.from(draw.rgb)
2672
+ cands.forEach((c, i) => {
2673
+ const color = SOM_COLORS[c.source] ?? [200, 200, 200]
2674
+ const sb = [
2675
+ Math.round(c.box[0] * draw.kx), Math.round(c.box[1] * draw.ky),
2676
+ Math.max(2, Math.round(c.box[2] * draw.kx)), Math.max(2, Math.round(c.box[3] * draw.ky)),
2677
+ ]
2678
+ drawRect(buf, draw.w, draw.h, sb, color)
2679
+ drawLabel(buf, draw.w, draw.h, sb[0], Math.max(0, sb[1] - Math.round(8 * draw.ky)), i + 1, [20, 20, 20])
2680
+ })
2681
+ const out = args.out || defaultOutFile(S.runDir, 'som').file
2682
+ fs.mkdirSync(path.dirname(out), { recursive: true })
2683
+ /* ROI 收窄时把范围画出来(白色双线框):人看图时才知道"表里这些是这一小块里的",
2684
+ * 不会误以为整屏就这些候选。 */
2685
+ if (roiApplied) {
2686
+ const sxr = draw.w / Math.max(1, ctrlW0)
2687
+ const syr = draw.h / Math.max(1, ctrlH0)
2688
+ const rb = [
2689
+ Math.round(roiApplied.roi[0] * sxr), Math.round(roiApplied.roi[1] * syr),
2690
+ Math.max(2, Math.round(roiApplied.roi[2] * sxr)), Math.max(2, Math.round(roiApplied.roi[3] * syr)),
2691
+ ]
2692
+ drawRect(buf, draw.w, draw.h, rb, [255, 255, 255])
2693
+ 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])
2694
+ }
2695
+ fs.writeFileSync(out, pngEncodeRGB(buf, draw.w, draw.h))
2696
+ const ctrlW = work.fw > 0 ? work.fw : work.w
2697
+ const ctrlH = work.fh > 0 ? work.fh : work.h
2698
+ /* 候选框的面积占比(控制器空间,与画面同尺度):调用方可据此再排一次序——
2699
+ * 面积越大越可能是面板/背景而不是小按钮。 */
2700
+ const feat = (c) => {
2701
+ const cb = toCtrl(c.box)
2702
+ const r = (cb[2] * cb[3]) / Math.max(1, ctrlW * ctrlH)
2703
+ return { ctrlBox: cb, areaRatio: Math.round(r * 10000) / 10000 }
2704
+ }
2705
+ return {
2706
+ ok: true, out, seq, source, detectionSource,
2707
+ small: [work.w, work.h], ctrl: [ctrlW, ctrlH],
2708
+ count: cands.length,
2709
+ limit: LIMIT,
2710
+ mergedTotal: merged.length,
2711
+ 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 },
2712
+ /* 被门槛拒掉的候选:conn 的面积窗/太扁、edge 的两道门槛/单块簇。
2713
+ * 与 sources 一起读,才知道"候选少"是内容如此还是被过滤掉了。 */
2714
+ filtered: { conn: connStats, edge: edgeStats },
2715
+ /* 可用性指标:**"看起来有结果"不等于"有结果"**。上限截断、巨框剔除、去重合并各扣了多少,
2716
+ * 以及每带的供给/进表——没有这三列,调用方会把"30 条候选"读成"画面就这些可点区域"。 */
2717
+ availability: {
2718
+ limit: LIMIT,
2719
+ mergedTotal: merged.length,
2720
+ truncated: Math.max(0, pool.length - cands.length),
2721
+ dropped,
2722
+ maxAreaRatio: maxRatio,
2723
+ ...(roiApplied ? { roi: roiApplied } : {}),
2724
+ byBand: bands,
2725
+ outResolution: { w: draw.w, h: draw.h, source: draw.source },
2726
+ detectionResolution: { w: work.w, h: work.h, source: detectionSource ?? (args.kfSource ? 'kf-frame' : '?') },
2727
+ },
2728
+ ...(args.kfSource ? { sourcesUnavailable: { diff: '库帧路径无会话事件:diff 源不可用(不拿别的区域顶替)' } } : {}),
2729
+ candidates: cands.map((c, i) => ({ id: i + 1, source: c.source, box: c.box, ctrl: toCtrl(c.box), ...feat(c), ...(c.extra ?? {}) })),
2730
+ ...(cands.length === 0 ? { warn: '无候选:画面可能静止且低对比;换帧或走点选路径' } : {}),
2731
+ ...(pool.length > LIMIT
2732
+ ? { warn: '候选被上限截断:本区域池 ' + pool.length + ' 条,进表 ' + cands.length + ' 条。'
2733
+ + (roiApplied ? '用更小的 roi 继续收窄' : '给 roi 把搜索面收小(这是正解),或给 somLimit 放开上限') }
2734
+ : {}),
2735
+ }
2736
+ }
2737
+
2738
+ /* ────────────────────────── 阈值校准(静止画面噪声定标) ──────────────────────────
2739
+ * 采集 N 帧(要求画面静止),统计相邻帧分块亮度差与全局位差率的分布,推荐
2740
+ * blockThresh / changeGlobal。噪声地板之上留裕量,让真变化必超、静止必不超。
2741
+ * 期间若有真实变化会抬高推荐值——max ≫ p99 时警告重跑。 */
2742
+ async function cmdCalibrate(args) {
2743
+ if (!S.ctrl) return { ok: false, error: '未连接设备(先连接)' }
2744
+ const frames = Math.min(60, Math.max(6, Number(args.frames ?? 24)))
2745
+ const interval = Math.max(120, Number(args.interval ?? 300))
2746
+ const blockDiffs = []
2747
+ const globals = []
2748
+ let last = null
2749
+ for (let i = 0; i < frames; i++) {
2750
+ const j = S.ctrl.post_screencap()
2751
+ await j.wait()
2752
+ const buf = j.get()
2753
+ if (!buf || buf.byteLength < 8) return { ok: false, error: '第 ' + (i + 1) + ' 帧截图失败' }
2754
+ const b = Buffer.from(buf)
2755
+ const dec = pngDecode(b)
2756
+ pushFrame(dec, b) // 校准采集也是观测(进环进 L0,可复核)
2757
+ const small = downscale(toRgb(dec), dec.w, dec.h, S.streamScale)
2758
+ const { hash, means } = blockAnalyze(small.data, small.w, small.h)
2759
+ if (last) {
2760
+ globals.push(hashDist(last.hash, hash))
2761
+ for (let k = 0; k < 64; k++) blockDiffs.push(Math.abs(means[k] - last.means[k]))
2762
+ }
2763
+ last = { hash, means }
2764
+ if (i < frames - 1) await sleep(interval)
2765
+ }
2766
+ blockDiffs.sort((a, b) => a - b)
2767
+ globals.sort((a, b) => a - b)
2768
+ const pct = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))]
2769
+ const stats = (arr) => ({
2770
+ p50: Math.round(pct(arr, 0.5) * 1000) / 1000,
2771
+ p99: Math.round(pct(arr, 0.99) * 1000) / 1000,
2772
+ max: Math.round(arr[arr.length - 1] * 1000) / 1000,
2773
+ })
2774
+ const bs = stats(blockDiffs)
2775
+ const gs = stats(globals)
2776
+ const blockThresh = Math.min(32, Math.max(2, Math.ceil(bs.max + 2)))
2777
+ const changeGlobal = Math.min(0.3, Math.max(0.02, Math.round((gs.max + 0.01) * 1000) / 1000))
2778
+ const moved = bs.max > bs.p99 * 2 + 1
2779
+ return {
2780
+ ok: true, frames,
2781
+ block: bs, global: gs,
2782
+ recommended: { blockThresh, changeGlobal },
2783
+ apply: 'stream start --block-thresh ' + blockThresh + ' --change-global ' + changeGlobal,
2784
+ ...(moved ? { warn: 'max 远超 p99(' + bs.max + ' vs ' + bs.p99 + '):校准期间疑似有真实变化,建议静止画面重跑' } : {}),
2785
+ }
2786
+ }
2787
+
2788
+ /* ────────────────────────── 消息分发 ────────────────────────── */
2789
+ const handlers = {
2790
+ init: (a) => {
2791
+ if (a.runDir) {
2792
+ S.runDir = String(a.runDir)
2793
+ S.previewPath = path.join(S.runDir, 'preview.png')
2794
+ try { fs.mkdirSync(S.runDir, { recursive: true }) } catch (e) { /* ignore */ }
2795
+ }
2796
+ if (Number(a.kfQuotaBytes) > 0) S.kfQuota = Math.floor(Number(a.kfQuotaBytes))
2797
+ return { ok: true, previewPath: S.previewPath, daemonId: S.daemonId, framesDir: kfRoot(), kfQuota: S.kfQuota }
2798
+ },
2799
+ probe: cmdProbe,
2800
+ device_list: (a) => cmdDeviceList(a.kind),
2801
+ connect: cmdConnect,
2802
+ disconnect: cmdDisconnect,
2803
+ screencap: (a) => cmdScreencap(a.out),
2804
+ stream_start: cmdStreamStart,
2805
+ stream_stop: cmdStreamStop,
2806
+ stream_status: cmdStreamStatus,
2807
+ frame_get: cmdFrameGet,
2808
+ frame_diff: cmdFrameDiff,
2809
+ wait: cmdWait,
2810
+ tpl_crop: cmdTplCrop,
2811
+ annotate: cmdAnnotate,
2812
+ calibrate: cmdCalibrate,
2813
+ color_probe: cmdColorProbe,
2814
+ l0_status: cmdL0Status,
2815
+ kf_promote: cmdKfPromote,
2816
+ run: cmdRun,
2817
+ run_stop: cmdRunStop,
2818
+ input: cmdInput,
2819
+ reco_test: cmdRecoTest,
2820
+ shutdown: async () => { await cmdStreamStop(); await cleanupAgents(); await cmdDisconnect(); return { ok: true } },
2821
+ }
2822
+
2823
+ /* 子进程模式:stdin JSON 行协议(每行一条消息)。非 --child(headless 单测 import)不挂监听。 */
2824
+ if (CHILD) {
2825
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
2826
+ rl.on('line', (line) => {
2827
+ let m = null
2828
+ try { m = JSON.parse(line) } catch (e) {
2829
+ send({ kind: 'reply', id: -1, ok: false, error: 'bad json line' })
2830
+ return
2831
+ }
2832
+ if (!m || typeof m.cmd !== 'string') return
2833
+ const h = handlers[m.cmd]
2834
+ if (!h) { reply(m.id, false, 'unknown cmd: ' + m.cmd); return }
2835
+ Promise.resolve()
2836
+ .then(() => h(m))
2837
+ /* shutdown 的回执要落地再退:process.exit 不等 stdout 冲刷,而调用方在等这条应答 */
2838
+ .then((data) => reply(m.id, true, data, m.cmd === 'shutdown' ? () => process.exit(0) : null))
2839
+ .catch((e) => reply(m.id, false, String(e && e.message || e)))
2840
+ })
2841
+ }
2842
+
2843
+ /* 纯函数导出(供 headless 验证直接断言,子进程运行中不使用)。
2844
+ * S 一并导出:l0EvictToBudget 等维护函数作用于模块态,单测需直改后还原。 */
2845
+ export const __test = {
2846
+ pngDecode, pngEncodeRGB, downscale, blockAnalyze, blockHash, hashDist,
2847
+ regionOfBlocks, tightenBounds, tightenBoundsByEdges, edgeMap, cropRgb, connComponents, edgeDensityBoxes,
2848
+ readKfCropSource, buildL2Provenance, pickBalancedCandidates, pickNodeName, readRoi,
2849
+ l0EvictToBudget, cmdKfPromote, cmdTplCrop, kfUsageBytes, defaultOutFile, S,
2850
+ }