@windsland52/maafw-live 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/docs/daemon-protocol.md +15 -10
- package/docs/keyframe-retention-contract-v0.md +23 -34
- package/docs/session-unify-v0.md +115 -0
- package/lib/client/daemon.js +45 -3
- package/lib/commands/annotate.js +15 -8
- package/lib/commands/calibrate.js +3 -3
- package/lib/commands/color.js +3 -3
- package/lib/commands/crop.js +8 -8
- package/lib/commands/dispatch.js +121 -0
- package/lib/commands/env.js +200 -6
- package/lib/commands/index.js +7 -1
- package/lib/commands/input.js +71 -33
- package/lib/commands/kf.js +49 -13
- package/lib/commands/probe.js +70 -0
- package/lib/commands/reco.js +285 -56
- package/lib/commands/repl.js +1305 -502
- package/lib/commands/roi-check.js +273 -0
- package/lib/commands/roi-compare.js +388 -0
- package/lib/commands/runtime.js +295 -91
- package/lib/commands/timing.js +28 -10
- package/lib/daemon/framed.mjs +1014 -184
- package/lib/daemon/reco_child.mjs +150 -9
- package/lib/flags.js +2 -0
- package/lib/index.js +24 -20
- package/lib/interface/hotkey.js +142 -0
- package/lib/interface/index.js +1 -1
- package/lib/interface/load.js +107 -17
- package/lib/interface/override.js +218 -34
- package/lib/interface/plan.js +108 -17
- package/lib/option-args.js +29 -0
- package/lib/pkg.js +36 -0
- package/lib/protocol.js +4 -0
- package/lib/runtime/actions.js +127 -12
- package/lib/runtime/log-watch.js +160 -0
- package/lib/runtime/node-source.js +100 -0
- package/lib/runtime/ocr-model.js +45 -0
- package/lib/runtime/pi-env.js +34 -0
- package/lib/runtime/pretask.js +217 -0
- package/lib/runtime/prune.js +28 -0
- package/lib/runtime/receipts.js +57 -0
- package/lib/runtime/reco-args.js +38 -0
- package/lib/runtime/reco-verdict.js +82 -0
- package/lib/runtime/roi-compare.js +198 -0
- package/lib/runtime/session-forward.js +79 -0
- package/lib/runtime/session-ipc.js +471 -0
- package/lib/runtime/session.js +57 -8
- package/lib/runtime/signal-guard.js +35 -0
- package/lib/types/commands/dispatch.d.ts +22 -0
- package/lib/types/commands/env.d.ts +19 -0
- package/lib/types/commands/index.d.ts +1 -1
- package/lib/types/commands/probe.d.ts +15 -0
- package/lib/types/commands/reco.d.ts +11 -7
- package/lib/types/commands/repl.d.ts +8 -0
- package/lib/types/commands/roi-check.d.ts +3 -0
- package/lib/types/commands/roi-compare.d.ts +3 -0
- package/lib/types/commands/runtime.d.ts +20 -2
- package/lib/types/flags.d.ts +4 -0
- package/lib/types/interface/hotkey.d.ts +36 -0
- package/lib/types/interface/index.d.ts +1 -1
- package/lib/types/interface/load.d.ts +84 -7
- package/lib/types/interface/override.d.ts +58 -13
- package/lib/types/interface/plan.d.ts +23 -2
- package/lib/types/option-args.d.ts +19 -0
- package/lib/types/pkg.d.ts +8 -0
- package/lib/types/protocol.d.ts +16 -0
- package/lib/types/runtime/actions.d.ts +89 -7
- package/lib/types/runtime/log-watch.d.ts +28 -0
- package/lib/types/runtime/node-source.d.ts +25 -0
- package/lib/types/runtime/ocr-model.d.ts +8 -0
- package/lib/types/runtime/pi-env.d.ts +24 -0
- package/lib/types/runtime/pretask.d.ts +53 -0
- package/lib/types/runtime/prune.d.ts +4 -0
- package/lib/types/runtime/receipts.d.ts +7 -0
- package/lib/types/runtime/reco-args.d.ts +18 -0
- package/lib/types/runtime/reco-verdict.d.ts +50 -0
- package/lib/types/runtime/roi-compare.d.ts +117 -0
- package/lib/types/runtime/session-forward.d.ts +10 -0
- package/lib/types/runtime/session-ipc.d.ts +117 -0
- package/lib/types/runtime/session.d.ts +26 -2
- package/lib/types/runtime/signal-guard.d.ts +19 -0
- package/lib/usage.js +3 -3
- package/package.json +17 -4
- package/skills/maafw-live/SKILL.md +32 -2
- package/skills/maafw-live/references/pitfalls.md +97 -4
- package/skills/maafw-live/references/workflows.md +5 -0
- package/docs/keyframe-evidence-contract-v0.md +0 -61
- package/lib/client/daemon.js.map +0 -1
- package/lib/commands/annotate.js.map +0 -1
- package/lib/commands/calibrate.js.map +0 -1
- package/lib/commands/color.js.map +0 -1
- package/lib/commands/crop.js.map +0 -1
- package/lib/commands/env.js.map +0 -1
- package/lib/commands/index.js.map +0 -1
- package/lib/commands/input.js.map +0 -1
- package/lib/commands/kf.js.map +0 -1
- package/lib/commands/reco.js.map +0 -1
- package/lib/commands/repl.js.map +0 -1
- package/lib/commands/runtime.js.map +0 -1
- package/lib/commands/skill.js.map +0 -1
- package/lib/commands/timing.js.map +0 -1
- package/lib/commands/version.js.map +0 -1
- package/lib/exec.js.map +0 -1
- package/lib/flags.js.map +0 -1
- package/lib/image.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/interface/index.js.map +0 -1
- package/lib/interface/jsonc.js.map +0 -1
- package/lib/interface/load.js.map +0 -1
- package/lib/interface/override.js.map +0 -1
- package/lib/interface/plan.js.map +0 -1
- package/lib/pkg.js.map +0 -1
- package/lib/protocol.js.map +0 -1
- package/lib/runtime/actions.js.map +0 -1
- package/lib/runtime/keyframes.js.map +0 -1
- package/lib/runtime/session.js.map +0 -1
- package/lib/update-check.js.map +0 -1
- package/lib/usage.js.map +0 -1
package/lib/daemon/framed.mjs
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* { kind:'frame', meta, preview } (每接受帧的元数据与预览帧路径)
|
|
16
16
|
* { kind:'event', ev } (变化/稳定/节点事件)
|
|
17
17
|
* { kind:'stream_error', error }
|
|
18
|
+
* { kind:'stream_stopped', reason } (控制器被销毁→帧流主动停止;
|
|
19
|
+
* 要看帧就重新 stream_start)
|
|
18
20
|
*/
|
|
19
21
|
import { createRequire } from 'node:module'
|
|
20
|
-
import { execFile, spawn } from 'node:child_process'
|
|
22
|
+
import { execFile, spawn, spawnSync } from 'node:child_process'
|
|
21
23
|
import { fileURLToPath } from 'node:url'
|
|
22
24
|
import { createHash, randomUUID } from 'node:crypto'
|
|
23
25
|
import readline from 'node:readline'
|
|
@@ -116,6 +118,21 @@ function pngDecode(buf) {
|
|
|
116
118
|
}
|
|
117
119
|
return { w, h, ch, data: out }
|
|
118
120
|
}
|
|
121
|
+
/** PNG 声明尺寸(只读 IHDR,不解像素)。
|
|
122
|
+
*
|
|
123
|
+
* 为什么要有这个:`--image` 分支的回执只带 `{source}`,调用方拿到图却不知道它多大,
|
|
124
|
+
* 于是 **roi 空间错了也无从判断**(ctrl 空间 roi 打到 L1 小图上,静默零例)。
|
|
125
|
+
* 与 pngDecode 的第二层同为「读 IHDR 才拿到 w/h」,但**不做解码**——这里只要尺寸,
|
|
126
|
+
* 真解码交给 reco 子进程;文件不可读 / 不是 PNG 时返回 null,让子进程去报真正的读取错误。
|
|
127
|
+
* 名字带 Header 以区别于 image.ts 里那个会完整解码的异步 pngSize。 */
|
|
128
|
+
function pngHeaderSize(buf) {
|
|
129
|
+
try {
|
|
130
|
+
if (buf[0] !== 0x89 || buf[1] !== 0x50) return null
|
|
131
|
+
const len = buf.readUInt32BE(8)
|
|
132
|
+
if (buf.toString('ascii', 12, 16) !== 'IHDR' || len < 8) return null
|
|
133
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }
|
|
134
|
+
} catch (e) { return null }
|
|
135
|
+
}
|
|
119
136
|
/** 归一化为 RGB(丢弃 alpha),返回新 Buffer */
|
|
120
137
|
function toRgb(dec) {
|
|
121
138
|
if (dec.ch === 3) return dec.data
|
|
@@ -212,7 +229,10 @@ const KF_QUOTA_DEFAULT = (() => {
|
|
|
212
229
|
return Number.isFinite(v) && v > 0 ? v : 1024 * 1024 * 1024
|
|
213
230
|
})()
|
|
214
231
|
const S = {
|
|
215
|
-
ctrl: null,
|
|
232
|
+
ctrl: null, tasker: null,
|
|
233
|
+
/* 最近一次 run / --act 的 Resource:只作释放点(换新时 destroy 上一份、disconnect 时销毁),
|
|
234
|
+
* 不当"当前资源"共享——那会让下一次命令带着上一轮的 bundle 跑。 */
|
|
235
|
+
runRes: null,
|
|
216
236
|
session: null, // { kind, target, method }
|
|
217
237
|
daemonId: randomUUID(), // 本 daemon 实例身份(留存记录的 session.daemon)
|
|
218
238
|
connGen: 0, // 连接代次:每次 connect 成功 +1(留存记录定位用)
|
|
@@ -264,6 +284,58 @@ async function cmdProbe() {
|
|
|
264
284
|
return out
|
|
265
285
|
}
|
|
266
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 截图能力探测(示范轨迹契约 §3):决定能不能走 A 路(人在我们显示的帧上点)。
|
|
289
|
+
* 方法:两次 post_screencap 间隔抓帧,比 blockHash 与亮度。
|
|
290
|
+
* changed == true → 像素在变(遮挡下能变 = 后台可截)
|
|
291
|
+
* changed == false 且 mean 正常 → 静止(目标停止渲染?改应用设置,不是截图路径问题)
|
|
292
|
+
* mean < 8 → 黑图(截图方法不兼容,换一个)
|
|
293
|
+
* post_screencap 抛错/空 → 报错(如实带出)
|
|
294
|
+
* 探针不知道"此刻是否遮挡"——判据只在遮挡态成立,调用方(CLI)负责提示与如实标注。
|
|
295
|
+
*/
|
|
296
|
+
async function cmdScreencapProbe(args) {
|
|
297
|
+
if (!S.ctrl) return { ok: false, error: '未连接设备(先 connect)' }
|
|
298
|
+
const interval = Math.min(20000, Math.max(500, Number(args.intervalMs ?? 3000)))
|
|
299
|
+
const grab = async () => {
|
|
300
|
+
try {
|
|
301
|
+
const j = S.ctrl.post_screencap()
|
|
302
|
+
await j.wait()
|
|
303
|
+
const buf = j.get()
|
|
304
|
+
if (!buf || buf.byteLength < 9) return { ok: false, error: '空截图' }
|
|
305
|
+
const dec = pngDecode(Buffer.from(buf))
|
|
306
|
+
const rgb = toRgb(dec)
|
|
307
|
+
const hash = blockHash(rgb, dec.w, dec.h)
|
|
308
|
+
/* 黑帧判定用平均亮度:全黑截图的 mean≈0,正常画面几十起步;阈值取 8 有充分余量 */
|
|
309
|
+
let sum = 0
|
|
310
|
+
for (let i = 0; i < rgb.length; i++) sum += rgb[i]
|
|
311
|
+
const mean = Math.round((sum / rgb.length) * 10) / 10
|
|
312
|
+
return { ok: true, w: dec.w, h: dec.h, hash: hashStr(hash), mean }
|
|
313
|
+
} catch (e) {
|
|
314
|
+
return { ok: false, error: String(e && e.message || e) }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const first = await grab()
|
|
318
|
+
await sleep(interval)
|
|
319
|
+
const second = await grab()
|
|
320
|
+
if (!first.ok || !second.ok) {
|
|
321
|
+
return { ok: true, intervalMs: interval, first, second, changed: null,
|
|
322
|
+
verdict: 'error',
|
|
323
|
+
note: 'post_screencap 报错或返回空——截图方法不兼容或控制器异常,换 win32.screencap 方法重试。' }
|
|
324
|
+
}
|
|
325
|
+
const changed = first.hash !== second.hash
|
|
326
|
+
let verdict = 'changing'
|
|
327
|
+
let note = '像素在变:若此刻窗口被完全遮挡,即"后台可截"(A 路前提成立)。'
|
|
328
|
+
if (!changed && second.mean < 8) {
|
|
329
|
+
verdict = 'black'
|
|
330
|
+
note = '截图近乎全黑:截图方法不兼容这台设备/这个窗口——换 win32.screencap 方法(FramePool / PrintWindow)重试。'
|
|
331
|
+
} else if (!changed) {
|
|
332
|
+
verdict = 'static'
|
|
333
|
+
note = '像素静止:目标停止渲染(改模拟器/应用设置),或两帧之间画面恰好没动——遮挡下再测一次确认。'
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, intervalMs: interval, first, second, changed, verdict, note,
|
|
336
|
+
occluded: args.occluded === true ? true : null }
|
|
337
|
+
}
|
|
338
|
+
|
|
267
339
|
async function cmdDeviceList(kind) {
|
|
268
340
|
const m = loadMaa()
|
|
269
341
|
const list = []
|
|
@@ -318,9 +390,76 @@ function resolveEnum(table, value, fallback, label, warns) {
|
|
|
318
390
|
return String(fallback)
|
|
319
391
|
}
|
|
320
392
|
|
|
393
|
+
/**
|
|
394
|
+
* LinuxController 的 config JSON 组装(maa-support-extension buildControllerRuntime /
|
|
395
|
+
* MXU build_linux_controller_config 同款规则,纯函数、与宿主平台无关,供单测直测):
|
|
396
|
+
* 1) args.target/config 是 JSON 串 → 原样用(字段全集归用户管,逃逸口);
|
|
397
|
+
* 2) 否则按 linuxDecl(interface.json linux{})+ args.linux(用户字段)组 JSON:
|
|
398
|
+
* 方法名→数值枚举、缺省 Wlr/Wlr、Wlr 必填 wlr_socket_path、pipewire_source:Portal
|
|
399
|
+
* 明确拒(portal helper 未接)、UInput/Libei 补各自字段、display_no 透传。
|
|
400
|
+
* 返回 { ok:true, json } 或 { ok:false, error }。
|
|
401
|
+
*/
|
|
402
|
+
function buildLinuxConfig(m, args) {
|
|
403
|
+
const raw = String(args.config ?? args.target ?? '')
|
|
404
|
+
if (raw.trim().startsWith('{')) return { ok: true, json: raw.trim() }
|
|
405
|
+
const decl = (args.linuxDecl && typeof args.linuxDecl === 'object') ? args.linuxDecl : {}
|
|
406
|
+
const user = (args.linux && typeof args.linux === 'object') ? args.linux : {}
|
|
407
|
+
/* 双键名:user/linuxDecl 里驼峰与蛇形都认(interface.json 原生是蛇形,daemon 参数是驼峰) */
|
|
408
|
+
const pickL = (k, k2) => user[k] ?? user[k2] ?? decl[k] ?? decl[k2]
|
|
409
|
+
const resolveLinuxMethod = (tbl, name) => {
|
|
410
|
+
if (name === undefined || name === null || name === '') return undefined
|
|
411
|
+
if (tbl && typeof tbl === 'object' && name in tbl) return Number(tbl[name])
|
|
412
|
+
const n = Number(name)
|
|
413
|
+
return Number.isFinite(n) ? n : undefined
|
|
414
|
+
}
|
|
415
|
+
const screencapName = pickL('screencap', 'screencap') ?? 'Wlr'
|
|
416
|
+
const inputName = pickL('input', 'input') ?? 'Wlr'
|
|
417
|
+
const sc = resolveLinuxMethod(m.LinuxScreencapMethod, screencapName)
|
|
418
|
+
const im = resolveLinuxMethod(m.LinuxInputMethod, inputName)
|
|
419
|
+
if (sc === undefined || im === undefined) {
|
|
420
|
+
return { ok: false, error: 'linux 方法名不合法:screencap=' + screencapName + ' input=' + inputName
|
|
421
|
+
+ '(可选 screencap: Wlr/ExtImage/PipeWire,input: Wlr/UInput/Libei;或直接给 config=<JSON>)' }
|
|
422
|
+
}
|
|
423
|
+
const conf = {
|
|
424
|
+
screencap_method: sc,
|
|
425
|
+
input_method: im,
|
|
426
|
+
use_win32_vk_code: !!(user.useWin32VkCode ?? decl.useWin32VkCode ?? decl.use_win32_vk_code),
|
|
427
|
+
}
|
|
428
|
+
if (screencapName === 'PipeWire') {
|
|
429
|
+
const src = pickL('pipewireSource', 'pipewire_source') ?? 'Gamescope'
|
|
430
|
+
if (src === 'Portal') {
|
|
431
|
+
return { ok: false, error: 'linux pipewire_source=Portal 暂不支持(portal helper 未接入)——换 Gamescope 或自管 fd' }
|
|
432
|
+
}
|
|
433
|
+
conf.pipewire_source = src
|
|
434
|
+
if (user.pwSocketFd !== undefined) conf.pw_socket_fd = user.pwSocketFd
|
|
435
|
+
if (user.pwNodeId !== undefined) conf.pw_node_id = user.pwNodeId
|
|
436
|
+
}
|
|
437
|
+
if (screencapName === 'Wlr' || inputName === 'Wlr') {
|
|
438
|
+
const sock = pickL('wlrSocketPath', 'wlr_socket_path')
|
|
439
|
+
if (!sock) {
|
|
440
|
+
return { ok: false, error: 'linux 方法含 Wlr:需要 wlr_socket_path(嵌套 compositor 的 socket)——在 linux config JSON 或 interface.json linux{} 里给' }
|
|
441
|
+
}
|
|
442
|
+
conf.wlr_socket_path = sock
|
|
443
|
+
}
|
|
444
|
+
if (inputName === 'UInput') {
|
|
445
|
+
if (user.uinputScreenWidth !== undefined) conf.uinput_screen_width = user.uinputScreenWidth
|
|
446
|
+
if (user.uinputScreenHeight !== undefined) conf.uinput_screen_height = user.uinputScreenHeight
|
|
447
|
+
}
|
|
448
|
+
if (inputName === 'Libei') {
|
|
449
|
+
const eis = pickL('eisSocketPath', 'eis_socket_path')
|
|
450
|
+
if (eis) conf.eis_socket_path = eis
|
|
451
|
+
}
|
|
452
|
+
const dno = pickL('displayNo', 'display_no')
|
|
453
|
+
if (dno !== undefined) conf.display_no = dno
|
|
454
|
+
return { ok: true, json: JSON.stringify(conf) }
|
|
455
|
+
}
|
|
456
|
+
|
|
321
457
|
async function cmdConnect(args) {
|
|
322
458
|
const m = loadMaa()
|
|
323
459
|
if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
|
|
460
|
+
/* 旧控制器已销毁:分辨率缓存不能留到下一次连接——连接失败时它会是上一台的尺寸,
|
|
461
|
+
* 让 spaceCheck / stream_status 把旧空间当成当前空间。 */
|
|
462
|
+
S.fullW = 0; S.fullH = 0
|
|
324
463
|
/* 控制器重建 = 旧流失效:显式停掉并在回执里说明(`streamStopped`),别让调用方以为流还在跑。
|
|
325
464
|
* 曾经的行为是"tick 撞到空控制器后静默死掉、S.stream 仍为 true",于是重连后的 stream_start 被
|
|
326
465
|
* "已启动"挡回去,表现为一帧都不来(实测:seq 卡死,而 screencap 正常)。 */
|
|
@@ -339,13 +478,24 @@ async function cmdConnect(args) {
|
|
|
339
478
|
/* 指定了 address 却不在已发现列表里:明确报错,不静默连到别的设备上 */
|
|
340
479
|
if (!dev) return { ok: false, error: 'adb 地址 ' + addr + ' 不在已发现设备中(共 ' + adbs.length + ' 台)' }
|
|
341
480
|
const [name, adbPath, address, caps, inputs, config] = dev
|
|
481
|
+
/* caps/inputs 是 find() 给出的方法位掩码(Uint64 字符串),照原样透传——框架会按
|
|
482
|
+
* 固定优先级在掩码内自选。仅当 find() 没给时才退 Default(全方法减禁用项),
|
|
483
|
+
* 不许塞数组:ScreencapOrInputMethods 的绑定类型就是字符串。
|
|
484
|
+
* agent(maa-node 可选第 6 参):MXU 传 MaaAgentBinary 路径供 adb 侧 agent;
|
|
485
|
+
* 声明式 PI 的 agent 桥接走另一条通道(identifier 子进程),这里只透传显式参数。 */
|
|
342
486
|
ctrl = new m.AdbController(adbPath || 'adb', String(address ?? addr),
|
|
343
|
-
caps
|
|
487
|
+
caps || String(m.AdbScreencapMethod?.Default ?? '0'),
|
|
488
|
+
inputs || String(m.AdbInputMethod?.Default ?? '0'), config || '{}',
|
|
489
|
+
args.agent ? String(args.agent) : undefined)
|
|
344
490
|
session = { kind: 'adb', target: String(address ?? addr), name: String(name) }
|
|
345
491
|
} else if (args.kind === 'win32' || args.kind === 'gamepad') {
|
|
346
492
|
const wins = await cmdDeviceList('win32')
|
|
347
|
-
|
|
348
|
-
|
|
493
|
+
let pick = pickWin32(args.target, wins)
|
|
494
|
+
/* GamepadController(hwnd|null, gamepad_type, screencap):规范允许不配图——hwnd=null
|
|
495
|
+
* 即"仅虚拟手柄输入、不截图"。给了 target 没匹配上仍报错(防静默连错窗口);
|
|
496
|
+
* 没给 target 且一个可选窗口都没有时才走 null。 */
|
|
497
|
+
const gamepadNoWindow = args.kind === 'gamepad' && !pick && !args.target
|
|
498
|
+
if (!pick && !gamepadNoWindow) {
|
|
349
499
|
return {
|
|
350
500
|
ok: false,
|
|
351
501
|
error: args.target
|
|
@@ -362,9 +512,13 @@ async function cmdConnect(args) {
|
|
|
362
512
|
/* GamepadController 构造器签名(maa-node .d.ts):(hwnd, gamepad_type, screencap_method)。
|
|
363
513
|
gamepad_type 枚举名→值照 m.GamepadType(Xbox360/DualShock4,缺省 Xbox360)。
|
|
364
514
|
输入走虚拟手柄,需宿主装 ViGEm Bus Driver,否则 connect 会在原生层失败。 */
|
|
365
|
-
const padType = resolveEnum(m.GamepadType,
|
|
366
|
-
|
|
367
|
-
|
|
515
|
+
const padType = resolveEnum(m.GamepadType,
|
|
516
|
+
args.gamepadType === 'DS4' ? 'DualShock4' : args.gamepadType,
|
|
517
|
+
m.GamepadType?.Xbox360 ?? '0', 'gamepadType', warns)
|
|
518
|
+
ctrl = new m.GamepadController(pick ? pick.id : null, padType, cap)
|
|
519
|
+
session = { kind: 'gamepad', target: pick ? pick.id : null,
|
|
520
|
+
name: pick ? (pick.name || pick.cls) : '(无窗口:仅输入)',
|
|
521
|
+
cls: pick ? pick.cls : null, method: { screencap: cap, gamepadType: padType } }
|
|
368
522
|
} else {
|
|
369
523
|
const mouse = resolveEnum(m.Win32InputMethod, args.mouse, m.Win32InputMethod?.SendMessageWithCursorPos ?? 32, 'mouse', warns)
|
|
370
524
|
const key = resolveEnum(m.Win32InputMethod, args.keyboard, m.Win32InputMethod?.SendMessage ?? 2, 'keyboard', warns)
|
|
@@ -378,55 +532,91 @@ async function cmdConnect(args) {
|
|
|
378
532
|
return { ok: false, error: '控制器类型 ' + args.kind + ' 仅支持在 macOS 上使用;当前宿主是 ' + process.platform }
|
|
379
533
|
}
|
|
380
534
|
if (args.kind === 'playcover') {
|
|
381
|
-
/* PlayCoverController(address, uuid):address
|
|
382
|
-
|
|
535
|
+
/* PlayCoverController(address, uuid):address 是设备侧服务地址,uuid 是应用的
|
|
536
|
+
Bundle Identifier。两者都有缺省(maa-support-extension 同款):
|
|
537
|
+
localhost:1717 / maa.playcover——不给 target 也按缺省构造。 */
|
|
383
538
|
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
|
-
}
|
|
539
|
+
const address = args.address ? String(args.address) : (parts[0] || 'localhost:1717')
|
|
540
|
+
const uuid = args.uuid ? String(args.uuid) : (parts[1] || 'maa.playcover')
|
|
389
541
|
ctrl = new m.PlayCoverController(address, uuid)
|
|
390
542
|
session = { kind: 'playcover', target: address + '/' + uuid }
|
|
391
543
|
} else {
|
|
392
|
-
/* MacOSController(window_id, screencap_method, input_method):窗口用 MacOSController.find() 枚举。
|
|
544
|
+
/* MacOSController(window_id, screencap_method, input_method):窗口用 MacOSController.find() 枚举。
|
|
545
|
+
匹配优先级:target(id 或标题子串)> macosTitleRegex(PI title_regex)> 第一个窗口。 */
|
|
393
546
|
const wins = (await m.MacOSController.find()) || []
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
547
|
+
let pick = null
|
|
548
|
+
if (args.target) {
|
|
549
|
+
pick = wins.find((d) => String(d[0]) === String(args.target)) ??
|
|
550
|
+
wins.find((d) => String(d[2] ?? '').includes(String(args.target)))
|
|
551
|
+
} else if (args.macosTitleRegex) {
|
|
552
|
+
let re = null
|
|
553
|
+
try { re = new RegExp(String(args.macosTitleRegex)) } catch (e) { /* 坏正则按下个分支报错 */ }
|
|
554
|
+
if (re) pick = wins.find((d) => re.test(String(d[2] ?? '')))
|
|
555
|
+
else return { ok: false, error: 'macos title_regex 不是合法正则:' + args.macosTitleRegex }
|
|
556
|
+
} else {
|
|
557
|
+
pick = wins[0] ?? null
|
|
558
|
+
}
|
|
398
559
|
if (!pick) {
|
|
399
|
-
return { ok: false, error: args.target ? 'macOS 窗口未找到:' + args.target
|
|
560
|
+
return { ok: false, error: args.target ? 'macOS 窗口未找到:' + args.target
|
|
561
|
+
: args.macosTitleRegex ? 'macOS 窗口按 title_regex 未匹配到:' + args.macosTitleRegex
|
|
562
|
+
: '未发现 macOS 窗口' }
|
|
400
563
|
}
|
|
401
|
-
const cap = resolveEnum(m.MacOSScreencapMethod, args.screencap, m.MacOSScreencapMethod?.ScreenCaptureKit ??
|
|
402
|
-
|
|
564
|
+
const cap = resolveEnum(m.MacOSScreencapMethod, args.screencap, m.MacOSScreencapMethod?.ScreenCaptureKit ?? '1', 'screencap', warns)
|
|
565
|
+
/* input 走 MacOSInputMethod 表(GlobalEvent/PostToPid),缺省 GlobalEvent(1)——
|
|
566
|
+
* 此前误用了 screencap 表+keyboard 参数,名字解析必错(ScreenCaptureKit 值恰好是 '1' 才没炸)。 */
|
|
567
|
+
const input = resolveEnum(m.MacOSInputMethod, args.input, m.MacOSInputMethod?.GlobalEvent ?? '1', 'input', warns)
|
|
403
568
|
ctrl = new m.MacOSController(pick.id ?? pick[0], cap, input)
|
|
404
569
|
session = { kind: 'macos', target: String(pick[0]), name: String(pick[2] ?? ''), method: { screencap: cap, input } }
|
|
405
570
|
}
|
|
406
571
|
} else if (args.kind === 'linux') {
|
|
407
|
-
/* LinuxController(config JSON):wlroots / PipeWire / uinput / Libei 会话按配置串交给本体。
|
|
408
|
-
config 必须显式给(含 screencap_method / input_method 等必填字段),工具不猜会话形态。 */
|
|
409
572
|
if (process.platform !== 'linux') {
|
|
410
573
|
return { ok: false, error: '控制器类型 linux 仅支持在 Linux 上使用;当前宿主是 ' + process.platform }
|
|
411
574
|
}
|
|
412
|
-
const cfg =
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
ctrl = new m.LinuxController(cfg)
|
|
575
|
+
const cfg = buildLinuxConfig(m, args)
|
|
576
|
+
if (cfg.ok === false) return { ok: false, error: cfg.error }
|
|
577
|
+
ctrl = new m.LinuxController(cfg.json)
|
|
417
578
|
session = { kind: 'linux', target: '(config)' }
|
|
418
579
|
} else {
|
|
419
580
|
return { ok: false, error: '未知控制器类型:' + args.kind + '(支持 win32/adb/gamepad/playcover/macos/linux)' }
|
|
420
581
|
}
|
|
421
582
|
/* 截图缩放目标:默认不干预 —— maafw 本体按 interface.json 的 short_side(缺省最短边 720p)缩放。
|
|
422
|
-
* 只有用户显式传参才覆盖;注意这会改变 Tasker 识别图像的尺寸(模板/ROI 坐标系随之变化)。
|
|
583
|
+
* 只有用户显式传参才覆盖;注意这会改变 Tasker 识别图像的尺寸(模板/ROI 坐标系随之变化)。
|
|
584
|
+
* display_* 四种模式互斥(MXU DisplayOptions.validate 同款规则):rawSize / expand / longSide
|
|
585
|
+
* / shortSide 只允许给一个,多给是配置错误——静默多设几个 setter 的覆盖顺序不可观测,直接拒。 */
|
|
586
|
+
const displayModes = [!!args.rawSize, !!args.expand, !!args.longSide, !!args.shortSide].filter(Boolean).length
|
|
587
|
+
if (displayModes > 1) {
|
|
588
|
+
try { ctrl.destroy() } catch (e) { /* ignore */ }
|
|
589
|
+
return { ok: false, error: 'display_* 互斥:rawSize / expand / longSide / shortSide 只能给一个(同时声明了 ' + displayModes + ' 个)' }
|
|
590
|
+
}
|
|
423
591
|
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
592
|
if (args.rawSize) ctrl.screenshot_use_raw_size = true
|
|
593
|
+
else if (args.expand) {
|
|
594
|
+
const [ew, eh] = Array.isArray(args.expand) ? args.expand : []
|
|
595
|
+
if (!Number.isFinite(ew) || !Number.isFinite(eh) || ew <= 0 || eh <= 0) {
|
|
596
|
+
try { ctrl.destroy() } catch (e) { /* ignore */ }
|
|
597
|
+
return { ok: false, error: 'display_expand 需要两个正整数 [w,h]:' + JSON.stringify(args.expand) }
|
|
598
|
+
}
|
|
599
|
+
ctrl.screenshot_target_expand = [Number(ew), Number(eh)]
|
|
600
|
+
} else if (args.longSide) ctrl.screenshot_target_long_side = Number(args.longSide)
|
|
601
|
+
else if (args.shortSide) ctrl.screenshot_target_short_side = Number(args.shortSide)
|
|
427
602
|
} catch (e) { /* 绑定版本不支持 setter 时忽略 */ }
|
|
603
|
+
/* 连接判定:5.14.2 实测 `Job.wait()` 在连接失败时**不抛异常**,只把 status 置为 Failed(4000)。
|
|
604
|
+
* 不查 status 就会把失败当成功:S.ctrl/S.session 照设、connect 回 ok:true,下一条命令再踩
|
|
605
|
+
* 半残控制器(预热截图失败只写进 resolution.error,CLI 摘要里看不出来)。 */
|
|
606
|
+
let connJob = null
|
|
607
|
+
try {
|
|
608
|
+
connJob = ctrl.post_connection()
|
|
609
|
+
await connJob.wait()
|
|
610
|
+
} catch (e) {
|
|
611
|
+
try { ctrl.destroy() } catch (e2) { /* ignore */ }
|
|
612
|
+
return { ok: false, error: '连接失败:' + String(e && e.message || e) }
|
|
613
|
+
}
|
|
614
|
+
const connStatus = Number(connJob && connJob.status)
|
|
615
|
+
if (connStatus !== STATUS.succeeded) {
|
|
616
|
+
try { ctrl.destroy() } catch (e) { /* ignore */ }
|
|
617
|
+
return { ok: false, error: '连接失败:post_connection status=' + String(connStatus) + '(成功应为 ' + STATUS.succeeded + ')' }
|
|
618
|
+
}
|
|
428
619
|
S.ctrl = ctrl
|
|
429
|
-
await ctrl.post_connection().wait()
|
|
430
620
|
S.session = session
|
|
431
621
|
S.connGen++
|
|
432
622
|
/* 预热一次截图,取控制器分辨率(默认短边 720 的坐标系,pipeline 的 roi/模板即此空间) */
|
|
@@ -452,6 +642,8 @@ async function cmdDisconnect() {
|
|
|
452
642
|
if (S.stream) await cmdStreamStop()
|
|
453
643
|
await cleanupAgents()
|
|
454
644
|
if (S.tasker) { try { S.tasker.destroy() } catch (e) { /* ignore */ } S.tasker = null }
|
|
645
|
+
/* tasker 收掉之后再放 Resource:顺序反了会让还在引用的 tasker 拿到已销毁的句柄 */
|
|
646
|
+
if (S.runRes) { try { S.runRes.destroy() } catch (e) { /* ignore */ } S.runRes = null }
|
|
455
647
|
if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
|
|
456
648
|
S.session = null
|
|
457
649
|
return { ok: true }
|
|
@@ -574,19 +766,26 @@ function pushFrame(dec, png) {
|
|
|
574
766
|
S.quietNow = false
|
|
575
767
|
const last = S.events.length ? S.events[S.events.length - 1] : null
|
|
576
768
|
if (last && last.type === 'change' && fr.t - last.t < 400) {
|
|
769
|
+
/* 合并同簇:seq/t/diff 前移,**frame 锚点必须跟着前移**——否则事件指向的是簇内第一帧,
|
|
770
|
+
* 而调用方看到的 t/seq 是最后一帧,锚点与内容对不上(同一类"回执与事实不符")。 */
|
|
577
771
|
last.t = fr.t; last.seq = fr.seq; last.diff = Math.max(last.diff, diff)
|
|
772
|
+
l0AnchorAdd({ ...l0, source: 'change' })
|
|
773
|
+
last.frame = eventFrameAnchor(fr.seq)
|
|
578
774
|
} else {
|
|
775
|
+
/* 顺序要紧:**先入锚区、再发事件**。事件里的 `frame.retained` 是按 seq 反查 L0 得出的,
|
|
776
|
+
* 反过来的话刚发出的 change/stable 事件会自报 `retained:'gone'`——明明它自己就是
|
|
777
|
+
* 触发入锚区的那一帧(实测就是这么错了一次)。 */
|
|
778
|
+
l0AnchorAdd({ ...l0, source: 'change' })
|
|
579
779
|
pushEvent({
|
|
580
780
|
type: 'change', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000,
|
|
581
781
|
...(changed ? { blocks: changed.blocks.length, region: changed.region } : {}),
|
|
582
782
|
})
|
|
583
|
-
l0AnchorAdd({ ...l0, source: 'change' })
|
|
584
783
|
}
|
|
585
784
|
} else if (prev) {
|
|
586
785
|
if (!S.quietNow) {
|
|
587
786
|
S.quietNow = true
|
|
588
|
-
pushEvent({ type: 'stable', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000 })
|
|
589
787
|
l0AnchorAdd({ ...l0, source: 'stable' })
|
|
788
|
+
pushEvent({ type: 'stable', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000 })
|
|
590
789
|
}
|
|
591
790
|
}
|
|
592
791
|
}
|
|
@@ -594,7 +793,29 @@ function pushFrame(dec, png) {
|
|
|
594
793
|
* 主动抓一张原图并入观测流(seq 前进、进环、进 L0,可作动作/run 边界锚点)。
|
|
595
794
|
* 采集失败如实上报,绝不阻断调用方的动作——动作结果与留存失败分别报告(契约 §9)。
|
|
596
795
|
*/
|
|
597
|
-
|
|
796
|
+
/**
|
|
797
|
+
* 动作回执帧落盘:一次性 CLI(daemon 随命令死)也能复核"动作前后画面"——
|
|
798
|
+
* 帧本身是 L0 原图字节,落 runDir/action-frames/(与关键帧库分开:这不是留存证据本体,
|
|
799
|
+
* 是回执附件,不占 kf 配额、不进 manifest)。
|
|
800
|
+
* 同一次 input 的 before/after 用同一对文件名;dir 不存在则跳过(静默降级为无路径回执)。
|
|
801
|
+
*/
|
|
802
|
+
function actionFramesRoot() {
|
|
803
|
+
return S.runDir ? path.join(S.runDir, 'action-frames') : null
|
|
804
|
+
}
|
|
805
|
+
function persistActionFrame(seq, source) {
|
|
806
|
+
const root = actionFramesRoot()
|
|
807
|
+
if (!root) return null
|
|
808
|
+
const ent = S.l0.roll.find((e) => e.seq === seq)
|
|
809
|
+
if (!ent || !ent.png) return null
|
|
810
|
+
try {
|
|
811
|
+
fs.mkdirSync(root, { recursive: true })
|
|
812
|
+
const file = path.join(root, source + '-seq' + String(seq).padStart(6, '0') + '.png')
|
|
813
|
+
fs.writeFileSync(file, ent.png)
|
|
814
|
+
return file
|
|
815
|
+
} catch (e) { return null }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function captureNow(source, persist = false) {
|
|
598
819
|
if (!S.ctrl) return { ok: false, error: '未连接设备' }
|
|
599
820
|
try {
|
|
600
821
|
const j = S.ctrl.post_screencap()
|
|
@@ -606,37 +827,80 @@ async function captureNow(source) {
|
|
|
606
827
|
pushFrame(dec, b)
|
|
607
828
|
const ent = S.l0.roll[S.l0.roll.length - 1]
|
|
608
829
|
l0AnchorAdd({ ...ent, source }, true)
|
|
609
|
-
|
|
830
|
+
const out = { ok: true, seq: ent.seq, t: ent.t, w: ent.w, h: ent.h }
|
|
831
|
+
/* 动作帧持久化:一次性 CLI 下 seq 无消费者(daemon 死后 frame_get 不存),
|
|
832
|
+
* 路径才是回执里能看的东西;常驻会话里多写一份也无害(同字节、幂等文件名)。 */
|
|
833
|
+
if (persist) {
|
|
834
|
+
const file = persistActionFrame(ent.seq, source)
|
|
835
|
+
if (file) out.file = file
|
|
836
|
+
}
|
|
837
|
+
return out
|
|
610
838
|
} catch (e) {
|
|
611
839
|
return { ok: false, error: String(e && e.message || e) }
|
|
612
840
|
}
|
|
613
841
|
}
|
|
614
842
|
|
|
615
843
|
/**
|
|
616
|
-
*
|
|
844
|
+
* node-ok/fail 锚点:用控制器缓存图入锚区,带"上一张已留帧的 hash"去重。
|
|
617
845
|
* 关键约束:不要在节点回调里发 post_screencap——add_sink 回调中并发截图会与 Tasker
|
|
618
846
|
* 识别回路竞争控制器,实测触发原生崩溃 0xC0000005(maa-node 5.14.2 win32-x64,
|
|
619
847
|
* 2026-10-05 真机:同任务关锚点正常 / 开锚点必崩)。
|
|
620
848
|
* cached_image 是框架识别回路自己截的图:零新任务、无竞争,且节点回调时刻的缓存
|
|
621
849
|
* 通常就是该节点识别所用的那张(bound 倾向);但下一节点识别可能已刷新缓存,
|
|
622
|
-
* 关联强度按 adjacent 报,不冒称 bound(契约 §4)。
|
|
623
|
-
|
|
624
|
-
|
|
850
|
+
* 关联强度按 adjacent 报,不冒称 bound(契约 §4)。
|
|
851
|
+
* 节点结束帧若与上一张已留帧相同(hashDist < NODE_FRAME_DEDUP)就不重复入锚区,
|
|
852
|
+
* 返回 { seq:null, skipped:'same' };不同则入锚区并返回 { seq, hash }(调用方拿 hash 记给下一次)。
|
|
853
|
+
* 阈值取小:目标是"同一个屏重复出现只留一张",不是"相似就丢"——近似画面各自留,别误并。
|
|
854
|
+
*/
|
|
855
|
+
const NODE_FRAME_DEDUP = 0.02
|
|
856
|
+
function anchorFromCacheDedup(source, lastHash) {
|
|
857
|
+
if (!S.ctrl) return { seq: null, skipped: 'no-ctrl' }
|
|
625
858
|
let img = null
|
|
626
|
-
try { img = S.ctrl.cached_image } catch (e) { return }
|
|
627
|
-
if (!img || img.byteLength < 9) return
|
|
859
|
+
try { img = S.ctrl.cached_image } catch (e) { return { seq: null, skipped: 'no-img' } }
|
|
860
|
+
if (!img || img.byteLength < 9) return { seq: null, skipped: 'no-img' }
|
|
628
861
|
const b = Buffer.from(img)
|
|
629
862
|
let dec = null
|
|
630
|
-
try { dec = pngDecode(b) } catch (e) { return }
|
|
863
|
+
try { dec = pngDecode(b) } catch (e) { return { seq: null, skipped: 'decode' } }
|
|
864
|
+
const rgb = toRgb(dec)
|
|
865
|
+
const h = blockHash(rgb, dec.w, dec.h)
|
|
866
|
+
if (lastHash && hashDist(lastHash, h) < NODE_FRAME_DEDUP) return { seq: null, skipped: 'same' }
|
|
631
867
|
pushFrame(dec, b)
|
|
632
868
|
const ent = S.l0.roll[S.l0.roll.length - 1]
|
|
633
869
|
l0AnchorAdd({ ...ent, source }, true)
|
|
870
|
+
return { seq: ent.seq, hash: h }
|
|
634
871
|
}
|
|
635
|
-
/** 事件入环 + 推送 host(cap 1200
|
|
872
|
+
/** 事件入环 + 推送 host(cap 1200)。
|
|
873
|
+
*
|
|
874
|
+
* 事件自带 `frame` 锚点:告诉调用方"这一刻的画面在哪能拿到"。
|
|
875
|
+
* 为什么必须有:事件流的消费者(LLM / 脚本)看到 `{"type":"stable","seq":2,"diff":0}` 时,
|
|
876
|
+
* 唯一想知道的是"那是什么画面"——旧回执只有 seq/t/diff,人得自己去 `frame get 2` 试,
|
|
877
|
+
* 而 L0 是**滚动淘汰**的,试的时候那张图可能已经没了(本仓 G9:事件无画面锚点,
|
|
878
|
+
* 只能拿 seq 去撞运气)。现在按 seq 反查 L0 两区,直接给出可取性:
|
|
879
|
+
*
|
|
880
|
+
* frame: { seq, retained: 'l0' | 'gone', source } // retained='l0' 才能 frame get / kf promote
|
|
881
|
+
*
|
|
882
|
+
* 只报事实,不代取图:取图要走 `frame get`(`--src full` 出控制器分辨率原图),
|
|
883
|
+
* 事件里塞 PNG 会让 1200 条环形缓冲变成内存炸弹。 */
|
|
884
|
+
function eventFrameAnchor(seq) {
|
|
885
|
+
if (typeof seq !== 'number') return undefined
|
|
886
|
+
const inRoll = S.l0.roll.find((x) => x.seq === seq)
|
|
887
|
+
const inAnchor = S.l0.anchor.find((x) => x.seq === seq)
|
|
888
|
+
const hit = inRoll ?? inAnchor
|
|
889
|
+
if (!hit) return { seq, retained: 'gone' }
|
|
890
|
+
return {
|
|
891
|
+
seq,
|
|
892
|
+
retained: 'l0',
|
|
893
|
+
/* 两区都可能有同一 seq(共享 Buffer):优先报锚区来源(锚区才是"值得留的"那张) */
|
|
894
|
+
source: (inAnchor ?? inRoll).source ?? null,
|
|
895
|
+
w: hit.w, h: hit.h,
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
636
899
|
function pushEvent(ev) {
|
|
637
|
-
|
|
900
|
+
const anchored = { ...ev, frame: eventFrameAnchor(ev.seq) }
|
|
901
|
+
S.events.push(anchored)
|
|
638
902
|
if (S.events.length > 1200) S.events.splice(0, S.events.length - 1200)
|
|
639
|
-
send({ kind: 'event', ev })
|
|
903
|
+
send({ kind: 'event', ev: anchored })
|
|
640
904
|
}
|
|
641
905
|
|
|
642
906
|
async function streamTick() {
|
|
@@ -702,6 +966,36 @@ function cmdStreamStatus() {
|
|
|
702
966
|
}
|
|
703
967
|
}
|
|
704
968
|
|
|
969
|
+
/**
|
|
970
|
+
* 事件环查询(顶层 CLI 用)。
|
|
971
|
+
*
|
|
972
|
+
* 为什么必须做成 daemon 命令:事件环 `S.events` 活在 daemon 进程里,而一次性 CLI 命令
|
|
973
|
+
* **退出即断连**——它拿不到"过去发生过什么",只能自己开着会话边跑边收。于是本仓 G7 的
|
|
974
|
+
* 诉求(`events` 不该只在 repl 里)落不到"客户端本地环"上,只能让 daemon 按需吐出它自己那份。
|
|
975
|
+
*
|
|
976
|
+
* 与 REPL 的 `events` 是**同一份数据**(同一个 `S.events`),只是取用方式不同:
|
|
977
|
+
* REPL 边收推送边留本地副本,CLI 每次现查。两处渲染共用 `frame` 锚点字段。
|
|
978
|
+
*
|
|
979
|
+
* `since` 支持按 seq 增量拉取(长跑时反复查询不必重传全量);`limit` 从尾部截取。
|
|
980
|
+
*/
|
|
981
|
+
function cmdEventsList(args) {
|
|
982
|
+
const rawSince = Number(args.since)
|
|
983
|
+
const since = Number.isFinite(rawSince) ? rawSince : null
|
|
984
|
+
const rawLimit = Number(args.limit)
|
|
985
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : 20
|
|
986
|
+
const all = since === null ? S.events : S.events.filter((e) => typeof e.seq === 'number' && e.seq > since)
|
|
987
|
+
const picked = all.slice(-limit)
|
|
988
|
+
return {
|
|
989
|
+
ok: true,
|
|
990
|
+
/* 报总数与"环里还留着多少",让调用方知道有没有被 1200 上限截掉过 */
|
|
991
|
+
total: S.events.length,
|
|
992
|
+
matched: all.length,
|
|
993
|
+
cap: 1200,
|
|
994
|
+
truncated: all.length > picked.length,
|
|
995
|
+
events: picked,
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
705
999
|
/* ────────────────────────── 历史帧取用(ROI 裁剪,像素按需出 worker) ────────────────────────── */
|
|
706
1000
|
/**
|
|
707
1001
|
* roi 参数校验:只接受 4 个**有限数字**。
|
|
@@ -782,6 +1076,9 @@ function cmdFrameGet(args) {
|
|
|
782
1076
|
return {
|
|
783
1077
|
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: rw, h: rh,
|
|
784
1078
|
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
|
|
1079
|
+
/* 帧身份:观测面 blockHash 的 64bit 串——input 的 expectFrame 拿它核对
|
|
1080
|
+
* "现在这帧还是不是我当初看到的那帧"。同 seq 的两帧 hash 必同(同一份像素)。 */
|
|
1081
|
+
frameHash: hashStr(fr.hash),
|
|
785
1082
|
source: 'l0-full', sourceResolution: 1, degraded: false,
|
|
786
1083
|
requested: { w, h },
|
|
787
1084
|
}
|
|
@@ -830,6 +1127,7 @@ function cmdFrameGet(args) {
|
|
|
830
1127
|
return {
|
|
831
1128
|
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: full.w, h: full.h,
|
|
832
1129
|
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
|
|
1130
|
+
frameHash: hashStr(fr.hash),
|
|
833
1131
|
source: 'l0-full', sourceResolution: 1,
|
|
834
1132
|
}
|
|
835
1133
|
}
|
|
@@ -840,6 +1138,7 @@ function cmdFrameGet(args) {
|
|
|
840
1138
|
return {
|
|
841
1139
|
ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: fr.w, h: fr.h,
|
|
842
1140
|
seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: fr.fw, ctrlH: fr.fh,
|
|
1141
|
+
frameHash: hashStr(fr.hash),
|
|
843
1142
|
source: 'l1-ring', sourceResolution: sx0,
|
|
844
1143
|
/* 整帧小图是**文档写明的缺省行为**,不是降级:只标注来源与分辨率,不警告、不判 findings——
|
|
845
1144
|
* 否则最普通的 `frame get`(不带 roi)会时不时退 3。要原图就显式 `--src full`。 */
|
|
@@ -968,26 +1267,54 @@ function cmdFrameDiff(args) {
|
|
|
968
1267
|
}
|
|
969
1268
|
|
|
970
1269
|
/* ────────────────────────── 资源 / 运行 / 识别 ────────────────────────── */
|
|
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
1270
|
/**
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
1271
|
+
* 资源加载的唯一入口:`dirs` 是整条链(`resource.path[]` + attach,按序叠加、后者覆盖前者,
|
|
1272
|
+
* 对应逐个 `post_bundle`),单包就是长度为 1 的链。
|
|
1273
|
+
*
|
|
1274
|
+
* **每次都新建 Resource**:`post_bundle` 是叠加语义,复用旧实例会把上一次运行的 bundle 残留进来
|
|
1275
|
+
* (上次加载了皮肤包这次没选,残留会让 override 关系错乱);单包若复用会话资源,长活会话里
|
|
1276
|
+
* `run --resource-dir B` 还会带着上一轮 A 的节点/模板跑。会话不该有一份"当前资源"的共享态。
|
|
984
1277
|
*/
|
|
985
|
-
async function buildResource(dirs) {
|
|
1278
|
+
async function buildResource(dirs, hashCheck) {
|
|
986
1279
|
const m = loadMaa()
|
|
987
1280
|
const res = new m.Resource()
|
|
988
|
-
|
|
1281
|
+
/* hashCheck:PI v2.6 的 resource.hash 校验,时机是"只加载完 path[]"(attach_resource_path
|
|
1282
|
+
* 不能算进被校验对象)。两段在 plan 侧已分好:pathDirs 是资源自己的声明路径,
|
|
1283
|
+
* attachDirs 是控制器追加段;dirs 兜底=全链(无校验声明时调用方不传 hashCheck)。
|
|
1284
|
+
* 读出的实际值写回 hashCheck.actual——校验结果(匹配/不匹配)由调用方决定怎么报。 */
|
|
1285
|
+
const pathDirs = hashCheck ? hashCheck.pathDirs : dirs
|
|
1286
|
+
/* plan 侧 paths 做了整体去重(attach 若与 path 相同会消失),这里保持同一份:
|
|
1287
|
+
* attach 去重掉已在 pathDirs 里的项,保证加载序列与 paths 一一对应 */
|
|
1288
|
+
const attachDirs = hashCheck ? hashCheck.attachDirs.filter((d) => !pathDirs.includes(d)) : []
|
|
1289
|
+
try {
|
|
1290
|
+
for (const d of pathDirs) await res.post_bundle(d).wait()
|
|
1291
|
+
if (hashCheck) hashCheck.actual = res.hash || ''
|
|
1292
|
+
for (const d of attachDirs) await res.post_bundle(d).wait()
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
/* 加载中途失败也要把句柄收掉:否则每次失败的 run 都在 native 侧留一份 */
|
|
1295
|
+
try { res.destroy() } catch (e2) { /* ignore */ }
|
|
1296
|
+
throw e
|
|
1297
|
+
}
|
|
989
1298
|
return res
|
|
990
1299
|
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* 换掉"最近一次 run / --act 的 Resource"并销毁上一份。
|
|
1303
|
+
*
|
|
1304
|
+
* 每次新建保证了资源隔离(单包不再叠上一轮 bundle),但 native 句柄必须有释放点——否则长活
|
|
1305
|
+
* REPL 里每 run 一次就多留一份(OCR 模型尤其重)。
|
|
1306
|
+
*
|
|
1307
|
+
* **调用前提由调用方保证**,不在这里重试:(1) 没有任务在飞(`cmdRun` 开头有 `runActive` 守卫、
|
|
1308
|
+
* `reco --act` 自己拒绝运行中的任务);(2) `tasker.resource` 已指向新对象。满足这两条才能立刻
|
|
1309
|
+
* 销毁上一份——早于 (2) 销毁会让 tasker 拿着死句柄,晚于 (1) 会踩到正在跑的任务。
|
|
1310
|
+
*/
|
|
1311
|
+
function adoptRunResource(res) {
|
|
1312
|
+
const prev = S.runRes
|
|
1313
|
+
S.runRes = res
|
|
1314
|
+
if (!prev || prev === res) return
|
|
1315
|
+
try { prev.destroy() } catch (e) { /* ignore */ }
|
|
1316
|
+
}
|
|
1317
|
+
|
|
991
1318
|
function ensureTasker() {
|
|
992
1319
|
if (!S.tasker) {
|
|
993
1320
|
const m = loadMaa()
|
|
@@ -1000,35 +1327,113 @@ function ensureTasker() {
|
|
|
1000
1327
|
* 模式照 maa-support-extension 的 setupAgent:new Client() 自动生成 identifier,
|
|
1001
1328
|
* 把 identifier 追加为子进程最后一个参数(agent 侧 sys.argv[-1] → AgentServer.start_up),
|
|
1002
1329
|
* bind_resource 后 connect(与子进程退出/超时竞速)。custom action/recognition 经此
|
|
1003
|
-
* 注册进绑定的 Resource——依赖 agent 的节点(M9A DisableNode 等)由此可用。
|
|
1330
|
+
* 注册进绑定的 Resource——依赖 agent 的节点(M9A DisableNode 等)由此可用。
|
|
1331
|
+
*
|
|
1332
|
+
* 环境变量:调用方给的 `PI_*` 原样并入子进程 env。本层不解释取值也不补默认——
|
|
1333
|
+
* 协议允许逐项缺省、由 agent 容错;这里编一个默认值只会把"没注入"伪装成"注入了"。 */
|
|
1334
|
+
/* 收掉整棵 agent 进程树。真身在子孙层(uv run → venv-python → uv-python agent/main.py),
|
|
1335
|
+
* child.kill() 只到直接子进程(uv.exe)。顺序是硬约束:必须先杀再 disconnect——
|
|
1336
|
+
* disconnect 会让 uv.exe 自己优雅退出,它的 python 子孙瞬间脱离父链,taskkill /T
|
|
1337
|
+
* 再够不到(实测 disconnect 在先必留两个 python 孤儿)。
|
|
1338
|
+
*
|
|
1339
|
+
* 两刀:① taskkill /T /F 按父链整棵收(够用,但 uv 在 kill 途中重 exec 出的孙级
|
|
1340
|
+
* 会漏);② 用 identifier 扫命令行兜底——agent 树每层 argv 都带同一个
|
|
1341
|
+
* identifier(sys.argv[-1]),一次 -like 点名全树,与父子链无关。
|
|
1342
|
+
* 第②刀的 $PID 排除自身:否则 -like 会命中这个 powershell 自己的 CommandLine
|
|
1343
|
+
* (字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。POSIX 用
|
|
1344
|
+
* detached 进程组(spawn 时 detached:true),负 pid 杀整组。 */
|
|
1345
|
+
function killTree(child, ident) {
|
|
1346
|
+
if (!child || !child.pid) return Promise.resolve()
|
|
1347
|
+
if (process.platform === 'win32') {
|
|
1348
|
+
const root = child.pid
|
|
1349
|
+
/* taskkill /T 收父链 + identifier 兜底:agent 树每层 argv 都带同一个 identifier
|
|
1350
|
+
* (sys.argv[-1]),一次 -like 点名全树,与父子链无关——taskkill 杀 uv.exe 时它
|
|
1351
|
+
* 重 exec 出的孙级会漏,这层兜住。$PID 排除自身:-like 会命中这个 powershell 自己
|
|
1352
|
+
* 的 CommandLine(字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。 */
|
|
1353
|
+
/* 一刀 taskkill 按父链收 + 一刀 ident sweep 点名全树(agent 每层 argv 带同一个
|
|
1354
|
+
* identifier,与父子链无关——taskkill 杀 uv.exe 时它重 exec 出的中层 venv-python
|
|
1355
|
+
* 会漏,这层兜住)。$PID 排除自身:-like 会命中这个 powershell 自己的 CommandLine
|
|
1356
|
+
* (字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。两刀都 await:
|
|
1357
|
+
* fire-and-forget 会让 daemon 在 sweep 还没跑完就 exit,python 照样孤儿。 */
|
|
1358
|
+
const sweep = () => new Promise((resolve) => {
|
|
1359
|
+
const like = '%' + String(ident || '').replace(/'/g, '') + '%'
|
|
1360
|
+
const ps =
|
|
1361
|
+
'$self=' + '$PID;' +
|
|
1362
|
+
`Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $self -and $_.CommandLine -and $_.CommandLine -like '${like}' } |` +
|
|
1363
|
+
'ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} };' +
|
|
1364
|
+
'try { Stop-Process -Id ' + root + ' -Force -ErrorAction SilentlyContinue } catch {}'
|
|
1365
|
+
try { execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 30000 }, () => resolve()) } catch (e) { resolve() }
|
|
1366
|
+
})
|
|
1367
|
+
const kill = () => new Promise((resolve) => {
|
|
1368
|
+
try {
|
|
1369
|
+
execFile('taskkill', ['/pid', String(root), '/T', '/F'], { timeout: 10000 }, () => resolve())
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
try { child.kill() } catch (e2) { /* ignore */ }
|
|
1372
|
+
resolve()
|
|
1373
|
+
}
|
|
1374
|
+
})
|
|
1375
|
+
return kill().then(() => sweep())
|
|
1376
|
+
}
|
|
1377
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch (e) {
|
|
1378
|
+
try { child.kill('SIGKILL') } catch (e2) { /* ignore */ }
|
|
1379
|
+
}
|
|
1380
|
+
return Promise.resolve()
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1004
1383
|
async function cleanupAgents() {
|
|
1005
1384
|
const bind = S.agentBind
|
|
1006
1385
|
S.agentBind = null
|
|
1007
1386
|
if (!bind) return
|
|
1008
1387
|
for (const c of bind.clients) {
|
|
1388
|
+
/* 先树杀、后 disconnect:顺序反过来 disconnect 会抢先让 uv.exe 退掉,子孙脱离
|
|
1389
|
+
* 父链后 taskkill /T 够不到(实测留两个 python 孤儿)。 */
|
|
1390
|
+
try { process.stderr.write('[cleanupAgents] kill ' + (c.child && c.child.pid) + ' ident=' + c.ident + '\n') } catch (e) { /* ignore */ }
|
|
1391
|
+
await killTree(c.child, c.ident)
|
|
1009
1392
|
try { c.client.disconnect() } catch (e) { /* ignore */ }
|
|
1010
1393
|
try { c.client.destroy() } catch (e) { /* ignore */ }
|
|
1011
|
-
try { c.child.kill() } catch (e) { /* ignore */ }
|
|
1012
1394
|
}
|
|
1013
1395
|
}
|
|
1014
1396
|
|
|
1015
|
-
async function setupAgents(res, decls, cwd) {
|
|
1397
|
+
async function setupAgents(res, decls, cwd, piEnv, sinks, maaMod, spawnFn) {
|
|
1016
1398
|
const key = JSON.stringify(decls)
|
|
1017
1399
|
if (S.agentBind && S.agentBind.res === res && S.agentBind.key === key) {
|
|
1018
1400
|
const alive = S.agentBind.clients.every((c) => {
|
|
1019
1401
|
try { return c.client.connected && c.client.alive } catch (e) { return false }
|
|
1020
1402
|
})
|
|
1021
|
-
if (alive) return { ok: true, reused: true, clients: S.agentBind }
|
|
1403
|
+
if (alive) return { ok: true, reused: true, clients: S.agentBind, sinks: S.agentBind.sinks }
|
|
1022
1404
|
}
|
|
1023
1405
|
await cleanupAgents()
|
|
1024
|
-
const m = loadMaa()
|
|
1406
|
+
const m = maaMod ?? loadMaa()
|
|
1407
|
+
const doSpawn = spawnFn ?? spawn
|
|
1025
1408
|
const clients = []
|
|
1409
|
+
/* sink 注册是桥接的另一半:只 bind_resource 时 agent 侧 context.tasker.controller /
|
|
1410
|
+
* context.tasker 发出的 reverse request 会带空 controller_id / 找不到 tasker,
|
|
1411
|
+
* 框架日志报 controller not found 后 custom action 挂起(实测:patrol 的 post_click
|
|
1412
|
+
* 静默失败,角色原地等死)。三个 sink 必须在 connect() 之前注册。 */
|
|
1413
|
+
const sinkDefs = [
|
|
1414
|
+
['resource', sinks && sinks.resource, 'register_resource_sink'],
|
|
1415
|
+
['controller', sinks && sinks.controller, 'register_controller_sink'],
|
|
1416
|
+
['tasker', sinks && sinks.tasker, 'register_tasker_sink'],
|
|
1417
|
+
]
|
|
1026
1418
|
for (const d of decls) {
|
|
1027
|
-
|
|
1419
|
+
/* interface 声明固定 identifier 时原样交给 Client(schema:socket 同名,断线可重连)。
|
|
1420
|
+
* 代价:残留旧 socket 时 connect 会等满超时才败——项目声明了就是它要的语义,照做。 */
|
|
1421
|
+
const client = new m.Client(typeof d.identifier === 'string' && d.identifier ? d.identifier : undefined)
|
|
1028
1422
|
const ident = client.identifier
|
|
1029
1423
|
let child = null
|
|
1030
1424
|
try {
|
|
1031
|
-
|
|
1425
|
+
const env = piEnv && typeof piEnv === 'object' ? Object.fromEntries(
|
|
1426
|
+
Object.entries(piEnv).filter(([, v]) => typeof v === 'string' && v !== ''),
|
|
1427
|
+
) : {}
|
|
1428
|
+
child = doSpawn(d.exec, [...d.args, ident], {
|
|
1429
|
+
cwd,
|
|
1430
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1431
|
+
windowsHide: true,
|
|
1432
|
+
/* POSIX 下脱离父进程组:killTree 靠负 pid 杀整组;Windows 无进程组概念,taskkill /T 兜底 */
|
|
1433
|
+
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
1434
|
+
/* 空对象时干脆不传 env(子进程照常继承),别用空 env 覆盖掉 PATH */
|
|
1435
|
+
...(Object.keys(env).length ? { env: { ...process.env, ...env } } : {}),
|
|
1436
|
+
})
|
|
1032
1437
|
} catch (e) {
|
|
1033
1438
|
try { client.destroy() } catch (e2) { /* ignore */ }
|
|
1034
1439
|
return { ok: false, error: 'agent 子进程启动失败:' + d.exec + '(' + String(e && e.message || e) + ')' }
|
|
@@ -1043,12 +1448,27 @@ async function setupAgents(res, decls, cwd) {
|
|
|
1043
1448
|
try { client.destroy() } catch (e2) { /* ignore */ }
|
|
1044
1449
|
return { ok: false, error: 'agent 绑定资源失败:' + String(e && e.message || e) }
|
|
1045
1450
|
}
|
|
1451
|
+
const sinksRegistered = []
|
|
1452
|
+
const sinkErrs = []
|
|
1453
|
+
for (const [name, obj, method] of sinkDefs) {
|
|
1454
|
+
if (!obj) continue
|
|
1455
|
+
try { client[method](obj); sinksRegistered.push(name) } catch (e) {
|
|
1456
|
+
sinkErrs.push(name + ':' + String(e && e.message || e))
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
client.__sinks = { registered: sinksRegistered, ...(sinkErrs.length ? { errors: sinkErrs } : {}) }
|
|
1460
|
+
/* sink 注册失败不阻断:custom action 不碰 context.controller/tasker 的项目照跑,
|
|
1461
|
+
* 但回执必须点名哪一项没注上——半残不报就是"controller not found"那种排障半天。 */
|
|
1046
1462
|
const exited = new Promise((r) => child.on('exit', () => r('exit')))
|
|
1463
|
+
let timer = null
|
|
1047
1464
|
const ok = await Promise.race([
|
|
1048
1465
|
client.connect().then(() => 'ok', () => 'err'),
|
|
1049
1466
|
exited,
|
|
1050
|
-
|
|
1467
|
+
new Promise((r) => { timer = setTimeout(() => r('timeout'), 60000) }),
|
|
1051
1468
|
])
|
|
1469
|
+
/* connect 或子进程退出先赢时把超时就地收掉:不然这个 setTimeout 挂着 60s
|
|
1470
|
+
* 才让进程退(headless 调用方——比如单测——会被它拖住)。 */
|
|
1471
|
+
if (timer) clearTimeout(timer)
|
|
1052
1472
|
/* connect() resolve 不代表可用:协议版本错配(maa-node 与 agent 侧 maa 版本不一致)时
|
|
1053
1473
|
* 通道建立但握手被拒——必须复核 connected && alive(maa-support 同款检查)。 */
|
|
1054
1474
|
let usable = ok === 'ok'
|
|
@@ -1062,14 +1482,19 @@ async function setupAgents(res, decls, cwd) {
|
|
|
1062
1482
|
try { child.kill() } catch (e) { /* ignore */ }
|
|
1063
1483
|
return { ok: false, error: 'agent ' + why + ':' + [d.exec, ...d.args].join(' ') + ';输出尾部:' + out.slice(-300) }
|
|
1064
1484
|
}
|
|
1065
|
-
clients.push({ client, child, name: [d.exec, ...d.args].join(' '), out })
|
|
1485
|
+
clients.push({ client, child, ident, name: [d.exec, ...d.args].join(' '), out })
|
|
1486
|
+
}
|
|
1487
|
+
/* 汇总所有 client 的 sink 结果:registered 取并集,errors 逐条带上是哪个 agent */
|
|
1488
|
+
const sinkReport = {
|
|
1489
|
+
registered: [...new Set(clients.flatMap((c) => c.client.__sinks?.registered ?? []))],
|
|
1490
|
+
errors: clients.flatMap((c) => (c.client.__sinks?.errors ?? []).map((e) => c.name + ':' + e)),
|
|
1066
1491
|
}
|
|
1067
|
-
S.agentBind = { res, key, clients }
|
|
1492
|
+
S.agentBind = { res, key, clients, sinks: sinkReport }
|
|
1068
1493
|
let actions = null
|
|
1069
1494
|
let recos = null
|
|
1070
1495
|
try { actions = clients[0].client.custom_action_list } catch (e) { /* ignore */ }
|
|
1071
1496
|
try { recos = clients[0].client.custom_recognition_list } catch (e) { /* ignore */ }
|
|
1072
|
-
return { ok: true, clients: S.agentBind, actions, recos }
|
|
1497
|
+
return { ok: true, clients: S.agentBind, actions, recos, sinks: sinkReport }
|
|
1073
1498
|
}
|
|
1074
1499
|
|
|
1075
1500
|
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
@@ -1087,37 +1512,87 @@ function pickNodeName(m) {
|
|
|
1087
1512
|
return { name: String(m.name || ('node#' + m.node_id)), source: 'entry' }
|
|
1088
1513
|
}
|
|
1089
1514
|
|
|
1515
|
+
/** 识别半:从带 node_id 的通知里抽识别框——没有它 record.nodes 只有"哪个节点跑过",
|
|
1516
|
+
* 不知道"这一步看的是哪个元素"(示范轨迹缺口 #1)。字段全防御性取(不同版本字段会缺),
|
|
1517
|
+
* 一条都取不到就返回 null、不挂空对象。 */
|
|
1518
|
+
function pickRecoBox(m) {
|
|
1519
|
+
const d = m.reco_details
|
|
1520
|
+
if (!d || typeof d !== 'object') return null
|
|
1521
|
+
const o = {}
|
|
1522
|
+
if (d.name !== undefined) o.name = String(d.name)
|
|
1523
|
+
if (d.algorithm !== undefined) o.algorithm = String(d.algorithm)
|
|
1524
|
+
if (d.hit !== undefined) o.hit = !!d.hit
|
|
1525
|
+
if (Array.isArray(d.box) && d.box.length === 4) o.box = d.box.map(Number)
|
|
1526
|
+
/* detail.best.score 是命中质量分(TemplateMatch 有,OCR 没有)——能拿到就带,拿不到不编 */
|
|
1527
|
+
const best = d.detail && typeof d.detail === 'object' && !Array.isArray(d.detail) ? d.detail.best : null
|
|
1528
|
+
if (best && typeof best === 'object' && best.score !== undefined) o.score = Number(best.score)
|
|
1529
|
+
return Object.keys(o).length ? o : null
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** 动作半:动作的落点框(action_details.box 是实际动作框,success 是动作自身成败)。 */
|
|
1533
|
+
function pickActionBox(m) {
|
|
1534
|
+
const d = m.action_details
|
|
1535
|
+
if (!d || typeof d !== 'object') return null
|
|
1536
|
+
const o = {}
|
|
1537
|
+
if (d.name !== undefined) o.name = String(d.name)
|
|
1538
|
+
if (d.action !== undefined) o.action = String(d.action)
|
|
1539
|
+
if (d.success !== undefined) o.success = !!d.success
|
|
1540
|
+
if (Array.isArray(d.box) && d.box.length === 4) o.box = d.box.map(Number)
|
|
1541
|
+
return Object.keys(o).length ? o : null
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1090
1544
|
async function cmdRun(args) {
|
|
1091
1545
|
if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
|
|
1092
1546
|
if (S.runActive) return { ok: false, error: '已有任务在运行(先 maa_run_stop)' }
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1547
|
+
const resDirs = Array.isArray(args.resourceDirs) && args.resourceDirs.length
|
|
1548
|
+
? args.resourceDirs.map(String)
|
|
1549
|
+
: (args.resourceDir ? [String(args.resourceDir)] : [])
|
|
1550
|
+
if (!resDirs.length) return { ok: false, error: '缺少 resourceDir(或 resourceDirs 数组)' }
|
|
1551
|
+
/* 上一轮的 agent 子进程与 Resource 在这里收掉:走到这行必然没有任务在飞(runActive 守卫),
|
|
1552
|
+
* 这一轮也不存在可复用的资源对象。放在参数校验之后——调用参数错了不该把上一份资源也丢掉。 */
|
|
1553
|
+
/* 上一轮 agent 子进程先断掉:这一轮会新建 Resource,绑定不可能复用。 */
|
|
1554
|
+
await cleanupAgents()
|
|
1555
|
+
const hashCheck = args.resourceHashCheck && typeof args.resourceHashCheck === 'object' ? args.resourceHashCheck : null
|
|
1556
|
+
const res = await buildResource(resDirs, hashCheck)
|
|
1557
|
+
/* v2.6 一致性校验:不匹配只警告不阻断(协议原文"不应阻止继续使用"),结果如实进回执与 warns */
|
|
1558
|
+
const runWarns = []
|
|
1559
|
+
if (hashCheck && hashCheck.expected !== undefined) {
|
|
1560
|
+
hashCheck.match = hashCheck.actual === hashCheck.expected
|
|
1561
|
+
if (!hashCheck.match) {
|
|
1562
|
+
runWarns.push('资源完整性校验失败:' + String(hashCheck.resourceName) +
|
|
1563
|
+
' 期望 hash=' + String(hashCheck.expected) + ' 实际=' + String(hashCheck.actual || '(读不到)') +
|
|
1564
|
+
'——建议重新下载资源包;按 PI v2.6 语义不阻断本次运行')
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/* tasker 要在 setupAgents 之前就绪:agent 侧 context.tasker / context.tasker.controller
|
|
1568
|
+
* 经 sink 反查的就是这个 tasker 与 S.ctrl——先注册、后 connect,缺一个 sink 就是
|
|
1569
|
+
* "controller not found"(见 setupAgents 注释)。 */
|
|
1570
|
+
const tasker = ensureTasker()
|
|
1101
1571
|
/* agent 桥接:项目声明了 agent(PI interface 的 child_exec/child_args)就先连上——
|
|
1102
1572
|
* custom action 注册进本次运行的 Resource;失败不阻断(非 agent 节点照跑),结果如实报告 */
|
|
1103
1573
|
let agentInfo = null
|
|
1104
1574
|
if (Array.isArray(args.agents) && args.agents.length) {
|
|
1105
|
-
const ag = await setupAgents(res, args.agents, String(args.agentCwd || process.cwd())
|
|
1575
|
+
const ag = await setupAgents(res, args.agents, String(args.agentCwd || process.cwd()), args.agentEnv,
|
|
1576
|
+
{ resource: res, controller: S.ctrl, tasker })
|
|
1106
1577
|
agentInfo = ag.ok
|
|
1107
|
-
? { ok: true, reused: !!ag.reused, actions: ag.actions ?? null, recognitions: ag.recos ?? null
|
|
1578
|
+
? { ok: true, reused: !!ag.reused, actions: ag.actions ?? null, recognitions: ag.recos ?? null,
|
|
1579
|
+
...(ag.sinks ? { sinks: ag.sinks } : {}) }
|
|
1108
1580
|
: { ok: false, error: ag.error }
|
|
1109
1581
|
}
|
|
1110
|
-
const tasker = ensureTasker()
|
|
1111
1582
|
tasker.resource = res
|
|
1112
1583
|
tasker.controller = S.ctrl
|
|
1584
|
+
/* 顺序是硬约束:先让 tasker 指向新对象,再销毁上一份。反过来(或加载失败前就销毁)会让还握着
|
|
1585
|
+
* 旧句柄的 tasker 拿到已销毁的资源。buildResource 抛错时上一份原样留着。 */
|
|
1586
|
+
adoptRunResource(res)
|
|
1113
1587
|
/* timeoutMs=0:不自动 post_stop,停止权交调用方(LLM 看事件流自行判断 + run_stop)。
|
|
1114
1588
|
* >0 时沿用超时保护(防 JumpBack 死循环)。300s 硬上限已移除——长任务用 0。 */
|
|
1115
1589
|
const rawT = Number(args.timeoutMs)
|
|
1116
1590
|
const timeoutMs = rawT === 0 ? 0 : (Number.isFinite(rawT) && rawT > 0 ? Math.max(500, rawT) : 30000)
|
|
1117
1591
|
const t0 = Date.now()
|
|
1118
1592
|
const retention = { start: await captureNow('run-start') }
|
|
1119
|
-
const nodes = new Map() // node_id → {name, start, end, msg, success, seq}
|
|
1593
|
+
const nodes = new Map() // node_id → {name, start, end, msg, success, seq, reco?, action?, frameSeq?}
|
|
1120
1594
|
const order = []
|
|
1595
|
+
let lastNodeHash = null // 按节点留帧的去重基准:上一张已留节点帧的 blockHash(新屏才留)
|
|
1121
1596
|
const nextCandidates = [] // 框架本轮宣布过的候选(NextList 通知),按顺序
|
|
1122
1597
|
let nextCandidateCount = 0 // 全部公告次数(含超出上限未记入数组的)
|
|
1123
1598
|
const onMsg = (m) => {
|
|
@@ -1152,10 +1627,26 @@ async function cmdRun(args) {
|
|
|
1152
1627
|
n.name = named.name
|
|
1153
1628
|
n.nameSource = 'node_details'
|
|
1154
1629
|
}
|
|
1155
|
-
/*
|
|
1630
|
+
/* 识别框/动作框:同一条通知里若带 details 就并入记录——后者覆盖前者,留下的是
|
|
1631
|
+
* 节点结束前最后一份(通常是 Succeeded 那次的判定结果)。 */
|
|
1632
|
+
const rec = pickRecoBox(m)
|
|
1633
|
+
if (rec) n.reco = rec
|
|
1634
|
+
const act = pickActionBox(m)
|
|
1635
|
+
if (act) n.action = act
|
|
1636
|
+
/* node-ok/fail 锚点:读控制器缓存图(不发新截图任务,规避并发崩溃,见 anchorFromCacheDedup);
|
|
1637
|
+
* 按节点留帧带去重——与上一张已留帧相同就跳过(画面没变重复留只是浪费锚区与字节),
|
|
1638
|
+
* 并把留下的 seq 记进节点(record.nodes[].frameSeq),消费方知道"这一节点留的是哪帧"。 */
|
|
1156
1639
|
if (/Starting$/.test(m.msg)) { n.status = 'running'; n.start = t }
|
|
1157
|
-
else if (/Succeeded$/.test(m.msg)) {
|
|
1158
|
-
|
|
1640
|
+
else if (/Succeeded$/.test(m.msg)) {
|
|
1641
|
+
n.status = 'ok'; n.end = t
|
|
1642
|
+
const r = anchorFromCacheDedup('node-ok', lastNodeHash)
|
|
1643
|
+
if (r.seq !== null) { n.frameSeq = r.seq; lastNodeHash = r.hash }
|
|
1644
|
+
}
|
|
1645
|
+
else if (/Failed$/.test(m.msg)) {
|
|
1646
|
+
n.status = 'fail'; n.end = t
|
|
1647
|
+
const r = anchorFromCacheDedup('node-fail', lastNodeHash)
|
|
1648
|
+
if (r.seq !== null) { n.frameSeq = r.seq; lastNodeHash = r.hash }
|
|
1649
|
+
}
|
|
1159
1650
|
else if (n.msgs.length < 6) n.msgs.push(m.msg)
|
|
1160
1651
|
n.seq = seq
|
|
1161
1652
|
/* 节点级消息进事件流:maa_frame_poll / maa_timing_measure 直接消费 */
|
|
@@ -1197,6 +1688,10 @@ async function cmdRun(args) {
|
|
|
1197
1688
|
const s2 = tasker.add_context_sink((_, m) => onMsg(m))
|
|
1198
1689
|
S.runActive = true
|
|
1199
1690
|
let status = null
|
|
1691
|
+
/* stopped 要覆盖两条停止路径:超时保护自停(timeoutMs 分支)与 run_stop 他停。
|
|
1692
|
+
* 后者靠 S.runStopped 旗标——cmdRunStop 先置旗再 post_stop,job.wait 解除后
|
|
1693
|
+
* 这条旗标已经立下;没有它,"run_stop 停的"在回执里会读成"失败"。 */
|
|
1694
|
+
S.runStopped = false
|
|
1200
1695
|
let stopped = false
|
|
1201
1696
|
try {
|
|
1202
1697
|
const job = tasker.post_task(String(args.entry), args.override || {})
|
|
@@ -1216,6 +1711,8 @@ async function cmdRun(args) {
|
|
|
1216
1711
|
} else {
|
|
1217
1712
|
await job.wait()
|
|
1218
1713
|
}
|
|
1714
|
+
/* run_stop 走并发命令置的旗标:wait 正常解除但其实是"被要求停的" */
|
|
1715
|
+
stopped = stopped || S.runStopped === true
|
|
1219
1716
|
status = job.status
|
|
1220
1717
|
} catch (e) {
|
|
1221
1718
|
status = -1
|
|
@@ -1232,23 +1729,41 @@ async function cmdRun(args) {
|
|
|
1232
1729
|
entry: String(args.entry),
|
|
1233
1730
|
status,
|
|
1234
1731
|
stopped,
|
|
1732
|
+
/* 本次实际生效的超时预算:回执要能自证"是不是跑满窗口被截断"。
|
|
1733
|
+
* 缺了它,`status:0 + stopped:true` 只能靠人回忆当初给的 --timeout 才分得清
|
|
1734
|
+
* 「跑满窗口」与「自己结束但没成功」——两者都是 stopped:true,而处置完全不同。 */
|
|
1735
|
+
timeoutMs,
|
|
1235
1736
|
durationMs: Date.now() - t0,
|
|
1737
|
+
/* baseline 落账:节点坐标/识别框所在控制器空间的短边(契约 demo-trace §meta:
|
|
1738
|
+
* trace 元信息必带 baseline,跨项目迁移时与计划不一致即拒)。取控制器实际交付帧
|
|
1739
|
+
* 的短边——改了 shortSide / 换过设备时如实反映,不冒称"配置值"。 */
|
|
1740
|
+
baseline: Math.min(S.fullW, S.fullH) || null,
|
|
1741
|
+
space: S.fullW ? { w: S.fullW, h: S.fullH } : null,
|
|
1236
1742
|
startSeq: nodes.size ? order[0].seq : null,
|
|
1237
1743
|
endSeq: S.seq,
|
|
1238
1744
|
framesCaptured: S.seq,
|
|
1239
1745
|
retention,
|
|
1240
1746
|
...(agentInfo ? { agent: agentInfo } : {}),
|
|
1747
|
+
...(hashCheck ? { hashCheck } : {}),
|
|
1241
1748
|
...(nextCandidateCount ? { nextCandidates, nextCandidateCount } : {}),
|
|
1242
1749
|
nodes: order.map((n) => ({
|
|
1243
1750
|
id: n.id, name: n.name, nameSource: n.nameSource, status: n.status,
|
|
1244
1751
|
ms: (n.end ?? Date.now()) - n.start, seq: n.seq, msgs: n.msgs.slice(-6),
|
|
1752
|
+
/* 识别框/动作框/节点留存帧:只增不改(协议约定),消费方按"有就读、没有就当该节点没报"处理 */
|
|
1753
|
+
...(n.reco ? { reco: n.reco } : {}),
|
|
1754
|
+
...(n.action ? { action: n.action } : {}),
|
|
1755
|
+
/* 按节点留帧的 seq:画面与上一节点相同会跳过(不重复占锚区),此时没有 frameSeq */
|
|
1756
|
+
...(n.frameSeq !== undefined ? { frameSeq: n.frameSeq } : {}),
|
|
1245
1757
|
})),
|
|
1246
1758
|
dump: dump ? { taskCount: dump.tasks ? dump.tasks.length : 0, nodes: Object.keys(dump.nodes || {}).length } : null,
|
|
1247
1759
|
}
|
|
1248
|
-
return { ok: true, record: rec }
|
|
1760
|
+
return { ok: true, record: rec, ...(runWarns.length ? { warns: runWarns } : {}) }
|
|
1249
1761
|
}
|
|
1250
1762
|
|
|
1251
1763
|
async function cmdRunStop() {
|
|
1764
|
+
/* 先立旗标再 post_stop:cmdRun 的 job.wait 解除后靠它把 record.stopped 补上,
|
|
1765
|
+
* 否则"run_stop 停的"会混进"任务失败"。 */
|
|
1766
|
+
S.runStopped = true
|
|
1252
1767
|
if (S.tasker) {
|
|
1253
1768
|
try { S.tasker.post_stop() } catch (e) { /* ignore */ }
|
|
1254
1769
|
}
|
|
@@ -1259,13 +1774,43 @@ async function cmdRunStop() {
|
|
|
1259
1774
|
/* 输入注入:全部经 maafw 控制器(adb/win32 由 maafw 本体选择注入方法),不直接调 adb。
|
|
1260
1775
|
* 动作边界(前/后)各抓一张原图进 L0 锚区(契约 §1"采得到"):
|
|
1261
1776
|
* 采集失败不阻断动作——动作结果与留存失败分别报告(契约 §9)。 */
|
|
1777
|
+
/** 帧身份守卫(纯函数,供单测):核对"现在这帧"与调用方期望的身份。
|
|
1778
|
+
* now = S.ring 最新帧({seq, hash});expectSeq 是单调帧号、expectFrame 是 64bit hash 串。
|
|
1779
|
+
* 返回 { ok:true } 放行;{ ok:false, error, detail } 拒发(detail 带 expected/actual 进回执)。 */
|
|
1780
|
+
function checkExpect(now, expectSeqRaw, expectFrameRaw) {
|
|
1781
|
+
const expectSeq = expectSeqRaw !== undefined && expectSeqRaw !== null ? Number(expectSeqRaw) : null
|
|
1782
|
+
const expectFrame = typeof expectFrameRaw === 'string' && expectFrameRaw ? expectFrameRaw : null
|
|
1783
|
+
if (expectSeq === null && expectFrame === null) return { ok: true }
|
|
1784
|
+
if (!now) return { ok: false, error: '帧身份守卫:取不到当前帧(环形缓冲空),不发输入', detail: {} }
|
|
1785
|
+
if (expectSeq !== null && now.seq !== expectSeq) {
|
|
1786
|
+
return { ok: false, error: '帧身份守卫不通过:期望 seq=' + expectSeq + ',当前 seq=' + now.seq
|
|
1787
|
+
+ '——画面已经变了(' + (now.seq - expectSeq) + ' 帧),不发输入。重看再点。',
|
|
1788
|
+
detail: { expected: { seq: expectSeq }, actual: { seq: now.seq } } }
|
|
1789
|
+
}
|
|
1790
|
+
if (expectFrame !== null) {
|
|
1791
|
+
const nowHash = hashStr(now.hash)
|
|
1792
|
+
if (nowHash !== expectFrame) {
|
|
1793
|
+
return { ok: false, error: '帧身份守卫不通过:期望帧 hash 与当前帧不符(期望 ' + expectFrame.slice(0, 20)
|
|
1794
|
+
+ '… 当前 ' + nowHash.slice(0, 20) + '…,seq=' + now.seq + ')——画面已经变了,不发输入。',
|
|
1795
|
+
detail: { expected: { frameHash: expectFrame }, actual: { seq: now.seq, frameHash: nowHash } } }
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return { ok: true }
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1262
1801
|
async function cmdInput(args) {
|
|
1263
1802
|
if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
|
|
1264
1803
|
const t0 = Date.now()
|
|
1265
1804
|
const k = String(args.kind || 'click')
|
|
1266
1805
|
const retention = {}
|
|
1267
1806
|
const done = (job) => ({ ok: true, kind: k, ms: Date.now() - t0, retention })
|
|
1268
|
-
retention.before = await captureNow('action-before')
|
|
1807
|
+
retention.before = await captureNow('action-before', true)
|
|
1808
|
+
/* 帧身份守卫:调用方说"我是在看到 seq=N 那帧之后决定要点的"——守卫核对**现在**这帧
|
|
1809
|
+
* 是不是同一帧。不是就不发输入(MPE validateClick 的拒点同义:画面已经变了,按旧画面
|
|
1810
|
+
* 算的坐标点下去就是误触)。seq 是单调计数器、expectFrame 是像素 hash——前者快(同 seq
|
|
1811
|
+
* 必同像素),后者跨 seq 也能核(同一画面重现)。回执里 code 让 CLI 落到 FINDINGS(3)。 */
|
|
1812
|
+
const chk = checkExpect(S.ring[S.ring.length - 1], args.expectSeq, args.expectFrame)
|
|
1813
|
+
if (!chk.ok) return { ok: false, code: 'EXPECT_FAILED', kind: k, error: chk.error, retention, ...chk.detail }
|
|
1269
1814
|
let job = null
|
|
1270
1815
|
if (k === 'click') {
|
|
1271
1816
|
job = S.ctrl.post_click(Number(args.x), Number(args.y), Number(args.contact ?? 0), Number(args.pressure ?? 1))
|
|
@@ -1312,7 +1857,7 @@ async function cmdInput(args) {
|
|
|
1312
1857
|
/* 与共享尾同一口径:**先 wait 再抓 after**。抓早了可能截在抬手落地之前,
|
|
1313
1858
|
* 而手势类动作恰恰要验证"抬手之后画面变成什么样"。 */
|
|
1314
1859
|
await job.wait()
|
|
1315
|
-
retention.after = await captureNow('action-after')
|
|
1860
|
+
retention.after = await captureNow('action-after', true)
|
|
1316
1861
|
return { ...done(null), path: pts, viaCount: via.length, stepMs: step }
|
|
1317
1862
|
}
|
|
1318
1863
|
job = S.ctrl.post_swipe(Number(args.x1), Number(args.y1), Number(args.x2), Number(args.y2),
|
|
@@ -1323,7 +1868,7 @@ async function cmdInput(args) {
|
|
|
1323
1868
|
const contact = Number(args.contact ?? 0)
|
|
1324
1869
|
job = S.ctrl.post_touch_move(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
|
|
1325
1870
|
await job.wait()
|
|
1326
|
-
retention.after = await captureNow('action-after')
|
|
1871
|
+
retention.after = await captureNow('action-after', true)
|
|
1327
1872
|
return done(null)
|
|
1328
1873
|
} else if (k === 'touch_down' || k === 'touch_up') {
|
|
1329
1874
|
const contact = Number(args.contact ?? 0)
|
|
@@ -1331,7 +1876,7 @@ async function cmdInput(args) {
|
|
|
1331
1876
|
? S.ctrl.post_touch_down(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
|
|
1332
1877
|
: S.ctrl.post_touch_up(contact)
|
|
1333
1878
|
await job.wait()
|
|
1334
|
-
retention.after = await captureNow('action-after')
|
|
1879
|
+
retention.after = await captureNow('action-after', true)
|
|
1335
1880
|
return done(null)
|
|
1336
1881
|
} else if (k === 'key') {
|
|
1337
1882
|
job = S.ctrl.post_click_key(Number(args.code))
|
|
@@ -1344,7 +1889,7 @@ async function cmdInput(args) {
|
|
|
1344
1889
|
for (const c of codes) await S.ctrl.post_key_down(c).wait()
|
|
1345
1890
|
await sleep(hold)
|
|
1346
1891
|
for (const c of codes.slice().reverse()) await S.ctrl.post_key_up(c).wait()
|
|
1347
|
-
retention.after = await captureNow('action-after')
|
|
1892
|
+
retention.after = await captureNow('action-after', true)
|
|
1348
1893
|
return done(null)
|
|
1349
1894
|
} else if (k === 'scroll') {
|
|
1350
1895
|
/* 滚轮:dx/dy 为格数,建议 120 的倍数(WHEEL_DELTA) */
|
|
@@ -1362,10 +1907,19 @@ async function cmdInput(args) {
|
|
|
1362
1907
|
return { ok: false, error: 'unknown input kind: ' + k + '(click|dbclick|press|swipe|key|keys|scroll|move|text|app)' }
|
|
1363
1908
|
}
|
|
1364
1909
|
await job.wait()
|
|
1365
|
-
retention.after = await captureNow('action-after')
|
|
1910
|
+
retention.after = await captureNow('action-after', true)
|
|
1366
1911
|
return done(job)
|
|
1367
1912
|
}
|
|
1368
1913
|
|
|
1914
|
+
/**
|
|
1915
|
+
* 子进程预算:用例数越多放得越宽(实测每例 ≈ 0.24s,留 5s/例的余量)。
|
|
1916
|
+
* 固定 90s 会让"几十个候选"在慢机器上被误杀,而误杀的表现是"识别子进程失败"——
|
|
1917
|
+
* 与真正的识别失败分不开。上限仍由 spawnRecoChild 自己封(120s)。
|
|
1918
|
+
*/
|
|
1919
|
+
function recoChildBudget(cases) {
|
|
1920
|
+
return Math.max(90000, Number(cases) * 5000)
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1369
1923
|
/* 识别单测:子进程隔离执行(beta 绑定的 post_recognition 在无效模板上会原生崩溃,
|
|
1370
1924
|
* 子进程炸掉只损失一次测试,绝不让宿主陪葬)。 */
|
|
1371
1925
|
function spawnRecoChild(payload, timeoutMs) {
|
|
@@ -1406,12 +1960,19 @@ function stripDetail(d) {
|
|
|
1406
1960
|
* 专测 target 框语义(框中心/偏移)与组合动作。识别输入 = 抓帧时的当前画面原图
|
|
1407
1961
|
* (也是 action-before 边界锚点),动作执行后的画面另抓 action-after——效果可复核。
|
|
1408
1962
|
*/
|
|
1409
|
-
async function recoActTest(args, node) {
|
|
1410
|
-
|
|
1963
|
+
async function recoActTest(args, node, dirs) {
|
|
1964
|
+
/* 有任务在飞时不换资源:act 会改写 tasker.resource,等于把正在跑的任务的资源换掉。
|
|
1965
|
+
* 这条路径本来也不该在 run 期间用(要真机动作就等任务结束)。 */
|
|
1966
|
+
if (S.runActive) return { ok: false, error: '有任务正在运行:先 maa_run_stop 再跑 --act(改资源会打断在跑的任务)' }
|
|
1967
|
+
/* 资源链与识别路径同一份:节点里的模板/被引用子节点可能只在 overlay 段里。
|
|
1968
|
+
* 同样每次新建,不与会话里任何资源共享(act 会注册/注销 custom action,复用更不可取)。 */
|
|
1969
|
+
const res = await buildResource(dirs)
|
|
1411
1970
|
const tasker = ensureTasker()
|
|
1412
1971
|
tasker.resource = res
|
|
1413
1972
|
tasker.controller = S.ctrl
|
|
1414
|
-
|
|
1973
|
+
/* 先让 tasker 指向新对象,再销毁上一份(见 adoptRunResource 的调用前提) */
|
|
1974
|
+
adoptRunResource(res)
|
|
1975
|
+
const before = await captureNow('action-before', true)
|
|
1415
1976
|
if (!before.ok) return { ok: false, error: '识别输入抓帧失败:' + before.error }
|
|
1416
1977
|
const imgBuf = S.l0.roll[S.l0.roll.length - 1].png
|
|
1417
1978
|
const image = imgBuf.buffer.slice(imgBuf.byteOffset, imgBuf.byteOffset + imgBuf.byteLength)
|
|
@@ -1447,13 +2008,17 @@ async function recoActTest(args, node) {
|
|
|
1447
2008
|
} finally {
|
|
1448
2009
|
try { res.unregister_custom_action('@reco/act') } catch (e) { /* ignore */ }
|
|
1449
2010
|
}
|
|
1450
|
-
const after = await captureNow('action-after')
|
|
2011
|
+
const after = await captureNow('action-after', true)
|
|
1451
2012
|
if (!out) return { ok: false, error: '动作探针未被执行(custom action 回调未触发)' }
|
|
1452
2013
|
const rec = node && node.recognition
|
|
1453
2014
|
const shownType = rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec ?? 'DirectHit')
|
|
1454
2015
|
return {
|
|
1455
2016
|
ok: out.ok,
|
|
1456
2017
|
type: shownType,
|
|
2018
|
+
/* 与识别路径同一口径:这次用的是整条资源链(含 attach 段),layer=resource 表示这里没有
|
|
2019
|
+
* interface 的四级 pipeline_override(run --project 才会叠) */
|
|
2020
|
+
resource: { dirs },
|
|
2021
|
+
layer: 'resource',
|
|
1457
2022
|
meta: { seq: before.seq, w: before.w, h: before.h, t: before.t },
|
|
1458
2023
|
...(out.error !== undefined ? { error: out.error } : {}),
|
|
1459
2024
|
...(out.stage ? { stage: out.stage } : {}),
|
|
@@ -1465,14 +2030,27 @@ async function recoActTest(args, node) {
|
|
|
1465
2030
|
}
|
|
1466
2031
|
|
|
1467
2032
|
async function cmdRecoTest(args) {
|
|
2033
|
+
/* 资源链:resourceDirs[] 是 interface.json 的 resource.path[](+ 控制器 attach_resource_path)
|
|
2034
|
+
* 按声明顺序叠加的结果;resourceDir 是单包写法。只有单包时测的就不是项目里那个节点——
|
|
2035
|
+
* overlay 段的节点改写、只存在于 overlay 的模板、被 And/Or 按名引用的子节点全会缺。 */
|
|
2036
|
+
const chain = Array.isArray(args.resourceDirs) ? args.resourceDirs.map((d) => String(d)).filter((d) => d) : []
|
|
2037
|
+
const dirs = chain.length ? chain : (args.resourceDir ? [String(args.resourceDir)] : [])
|
|
2038
|
+
if (!dirs.length) return { ok: false, error: '缺少资源:给 resourceDirs[](整链)或 resourceDir(单包)' }
|
|
2039
|
+
const nodeName = typeof args.nodeName === 'string' && args.nodeName !== '' ? args.nodeName : null
|
|
2040
|
+
if (nodeName && args.node && typeof args.node === 'object' && !Array.isArray(args.node)) {
|
|
2041
|
+
return { ok: false, error: 'node 与 nodeName 互斥:一次只测一个节点来源' }
|
|
2042
|
+
}
|
|
1468
2043
|
/* --act:节点级单测(识别拿框 → 真机执行动作半),在 daemon 进程内跑(要真控制器) */
|
|
1469
2044
|
const actNode = args.act === true && args.node && typeof args.node === 'object' && !Array.isArray(args.node) ? args.node : null
|
|
1470
2045
|
if (args.act === true) {
|
|
2046
|
+
if (nodeName) return { ok: false, error: '--act 不接受 nodeName:按资源原名跑的是识别判据,动作半要真机桥接(那是 run)' }
|
|
1471
2047
|
if (!actNode) return { ok: false, error: '--act 需要同时给 --node(整节点 JSON,含 action)' }
|
|
1472
2048
|
if (!S.ctrl) return { ok: false, error: '--act 要在真机上执行动作:需要已连接设备(REPL 或 --project/--kind)' }
|
|
1473
|
-
return await recoActTest(args, actNode)
|
|
2049
|
+
return await recoActTest(args, actNode, dirs)
|
|
1474
2050
|
}
|
|
1475
|
-
|
|
2051
|
+
/* 资源链可加载性先验已挪进 reco_child(同一次加载里逐段点名失败段):原先这里
|
|
2052
|
+
* probeResourceChain 先加载一遍、子进程再加载一遍,识别一慢就是双倍预算,
|
|
2053
|
+
* 且永远是客户端先到点杀进程(设计审查 P0-1)。 */
|
|
1476
2054
|
/* 图像来源三条:库内留存帧(kfSource:控制器分辨率原图、**可无设备**)/ 显式文件 / 会话缓冲帧。
|
|
1477
2055
|
* daemon 自己写的那份临时 PNG **用完即删**——不删就是每跑一次漏一个(CLI 侧的截帧泄漏是同一类,
|
|
1478
2056
|
* 现场实测攒过 42 张;这条 `--seq` 路径同理,只是本机还没被触发过)。 */
|
|
@@ -1488,7 +2066,10 @@ async function cmdRecoTest(args) {
|
|
|
1488
2066
|
meta = { source: kf.source.id, sha256: kf.source.sha256, w: kf.dec.w, h: kf.dec.h, ctrlW: kf.dec.w, ctrlH: kf.dec.h }
|
|
1489
2067
|
} else if (args.image) {
|
|
1490
2068
|
imageFile = String(args.image)
|
|
1491
|
-
|
|
2069
|
+
/* 补 w/h:没有它,调用方无法判断 roi 用的是哪个空间(信息补齐,不改取图行为) */
|
|
2070
|
+
let size = null
|
|
2071
|
+
try { size = pngHeaderSize(fs.readFileSync(imageFile)) } catch (e) { /* 读不到留给子进程报错 */ }
|
|
2072
|
+
meta = { source: args.image, ...(size ? { w: size.w, h: size.h } : {}) }
|
|
1492
2073
|
} else {
|
|
1493
2074
|
const fr = args.seq !== undefined && args.seq !== null
|
|
1494
2075
|
? S.ring.find((r) => r.seq === Number(args.seq))
|
|
@@ -1507,8 +2088,32 @@ async function cmdRecoTest(args) {
|
|
|
1507
2088
|
}
|
|
1508
2089
|
const baseParam = args.param && typeof args.param === 'object' ? args.param : {}
|
|
1509
2090
|
const sweep = args.sweep && typeof args.sweep === 'object' ? args.sweep : null
|
|
2091
|
+
/* --candidates:候选集合逐例换 roi(roi-compare 走的正是这条)。
|
|
2092
|
+
* 与 sweep 像但**不变量不同**:sweep 是"同键换值",候选是"逐例换 roi"——
|
|
2093
|
+
* 折叠进同一个 cases、走同一次 spawnRecoChild,不新开第二条子进程通道。 */
|
|
2094
|
+
const candidates = Array.isArray(args.candidates) ? args.candidates : null
|
|
2095
|
+
if (candidates && sweep && typeof sweep.min === 'number') {
|
|
2096
|
+
return { ok: false, error: 'candidates 与 sweep 互斥:一次识别单测只跑一种候选来源' }
|
|
2097
|
+
}
|
|
2098
|
+
if (candidates && !candidates.length) {
|
|
2099
|
+
return { ok: false, error: 'candidates 是空数组:至少给一个 [x,y,w,h] 候选' }
|
|
2100
|
+
}
|
|
2101
|
+
/* node / nodeName 与候选来源互斥:整条节点路径(dump + 节点自己的 recognition/param)
|
|
2102
|
+
* 已经是一次完整的"跑这个节点",cases 根本没有位置塞——同给会静默丢掉候选集合,
|
|
2103
|
+
* 调用方还以为 roi-compare 的逐例换 roi 生效了(P1-#5 协议面)。 */
|
|
2104
|
+
if ((node || nodeName) && (candidates || (sweep && typeof sweep.min === 'number'))) {
|
|
2105
|
+
return { ok: false, error: 'node/nodeName 与 candidates/sweep 互斥:整条节点路径有自己完整的 param,没有逐例换值的位置' }
|
|
2106
|
+
}
|
|
1510
2107
|
const cases = []
|
|
1511
|
-
if (
|
|
2108
|
+
if (candidates) {
|
|
2109
|
+
for (const c of candidates) {
|
|
2110
|
+
const roi = Array.isArray(c) ? c.map(Number) : null
|
|
2111
|
+
if (!roi || roi.length !== 4 || !roi.every(Number.isFinite)) {
|
|
2112
|
+
return { ok: false, error: 'candidates 每项都要是 [x,y,w,h] 四个数字,收到:' + JSON.stringify(c) }
|
|
2113
|
+
}
|
|
2114
|
+
cases.push({ ...baseParam, roi })
|
|
2115
|
+
}
|
|
2116
|
+
} else if (sweep && typeof sweep.min === 'number' && typeof sweep.max === 'number') {
|
|
1512
2117
|
/* step 带方向:升序 0.5→0.9 / 降序 0.9→0.5 都合法;区间为空明确报错,不静默测个空 */
|
|
1513
2118
|
const step = sweep.step !== undefined && Number(sweep.step) !== 0 ? Number(sweep.step) : 1
|
|
1514
2119
|
const up = step > 0
|
|
@@ -1522,25 +2127,43 @@ async function cmdRecoTest(args) {
|
|
|
1522
2127
|
let r = null
|
|
1523
2128
|
try {
|
|
1524
2129
|
r = await spawnRecoChild({
|
|
1525
|
-
resourceDir:
|
|
2130
|
+
...(chain.length ? { resourceDirs: dirs } : { resourceDir: dirs[0] }),
|
|
1526
2131
|
type: String(args.type || 'TemplateMatch'),
|
|
1527
2132
|
image: imageFile,
|
|
1528
2133
|
/* 面板场景:模板由调用方裁好落盘传进来(子进程走 override_image),不写进任何资源目录 */
|
|
1529
2134
|
...(args.templateImage ? { templateImage: String(args.templateImage) } : {}),
|
|
1530
|
-
...(node ? { node } : { cases }),
|
|
1531
|
-
},
|
|
2135
|
+
...(node ? { node } : (nodeName ? { nodeName } : { cases })),
|
|
2136
|
+
}, recoChildBudget(cases.length))
|
|
1532
2137
|
} finally {
|
|
1533
2138
|
/* 子进程已经读完这张图(spawnRecoChild 会等到它退出),此时删除是安全的 */
|
|
1534
2139
|
if (tempImage) { try { fs.rmSync(tempImage, { force: true }) } catch (e) { /* ignore */ } }
|
|
1535
2140
|
}
|
|
2141
|
+
/* 回执带资源链与层级:layer=resource 表示这里只到"资源合并结果",
|
|
2142
|
+
* run --project 还会再叠 interface 的四级 pipeline_override(GUI 选项改过同一字段时两者对不上是预期) */
|
|
2143
|
+
const resource = { dirs }
|
|
1536
2144
|
if (r && r.ok) {
|
|
1537
2145
|
const rec = node && node.recognition
|
|
1538
2146
|
const shownType = node
|
|
1539
2147
|
? (rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec))
|
|
1540
|
-
: args.type
|
|
1541
|
-
return {
|
|
2148
|
+
: String(r.type ?? args.type ?? 'TemplateMatch')
|
|
2149
|
+
return {
|
|
2150
|
+
ok: true, type: shownType, meta, results: r.results, resource, layer: 'resource',
|
|
2151
|
+
...(nodeName ? { nodeName } : {}),
|
|
2152
|
+
...(r.nodeDump ? { nodeDump: r.nodeDump } : {}),
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return {
|
|
2156
|
+
ok: false,
|
|
2157
|
+
error: (r && r.error) || 'reco 失败',
|
|
2158
|
+
meta, resource, layer: 'resource',
|
|
2159
|
+
...(r && r.code ? { code: String(r.code) } : {}),
|
|
2160
|
+
/* 近邻节点名:`--node-name` 拼错时给出"是不是想跑这个",CLI/REPL 直接把它变成 hint */
|
|
2161
|
+
...(r && Array.isArray(r.candidates) && r.candidates.length ? { candidates: r.candidates.map(String) } : {}),
|
|
2162
|
+
/* 嵌套 custom 的命中位置:让人知道是节点自己,还是 And/Or 里的子识别 */
|
|
2163
|
+
...(r && Array.isArray(r.customPaths) && r.customPaths.length ? { customPaths: r.customPaths.map(String) } : {}),
|
|
2164
|
+
...(nodeName ? { nodeName } : {}),
|
|
2165
|
+
...(r && r.nodeDump ? { nodeDump: r.nodeDump } : {}),
|
|
1542
2166
|
}
|
|
1543
|
-
return { ok: false, error: (r && r.error) || 'reco 失败', meta }
|
|
1544
2167
|
}
|
|
1545
2168
|
|
|
1546
2169
|
/* ────────────────────────── 关键帧库(L0 升格 + 登记,契约 §4) ──────────────────────────
|
|
@@ -1572,6 +2195,44 @@ function kfSave(m) {
|
|
|
1572
2195
|
fs.renameSync(tmp, file)
|
|
1573
2196
|
}
|
|
1574
2197
|
|
|
2198
|
+
/* ─── 关键帧库写锁(P1-#8)───────────────────────────────────────────────────
|
|
2199
|
+
* manifest 的读-改-写此前无锁:常驻会话与一次性 `crop --keep-source` 同秒升格时,
|
|
2200
|
+
* 两个进程都读 m.next=N、都写 l0/000N.png,manifest 后写者赢——已发出的 kf ID
|
|
2201
|
+
* 指向另一份像素或 sha 校验失败。锁是文件系统可见的(跨 daemon 进程);
|
|
2202
|
+
* O_EXCL 拿锁(原子,不会两人同时创建成功);持有者崩了留残渣时按 mtime 过期重拿。 */
|
|
2203
|
+
const KF_LOCK_STALE_MS = 60_000
|
|
2204
|
+
function kfLockPath() { return path.join(kfRoot(), 'manifest.lock') }
|
|
2205
|
+
function kfLockAcquire() {
|
|
2206
|
+
const p = kfLockPath()
|
|
2207
|
+
fs.mkdirSync(kfRoot(), { recursive: true })
|
|
2208
|
+
const deadline = Date.now() + 30_000
|
|
2209
|
+
while (Date.now() < deadline) {
|
|
2210
|
+
try {
|
|
2211
|
+
const fd = fs.openSync(p, 'wx') /* 'wx' = O_EXCL:存在即抛,原子拿锁 */
|
|
2212
|
+
fs.closeSync(fd)
|
|
2213
|
+
return true
|
|
2214
|
+
} catch (e) {
|
|
2215
|
+
if (e.code !== 'EEXIST') return false
|
|
2216
|
+
/* 锁在:看是不是崩溃残渣(mtime 太老 = 持有者死了没放) */
|
|
2217
|
+
try {
|
|
2218
|
+
const age = Date.now() - fs.statSync(p).mtimeMs
|
|
2219
|
+
if (age > KF_LOCK_STALE_MS) {
|
|
2220
|
+
try { fs.rmSync(p, { force: true }) } catch (e2) { /* ignore */ }
|
|
2221
|
+
continue /* 删掉残渣立即重试拿锁 */
|
|
2222
|
+
}
|
|
2223
|
+
} catch (e2) { /* stat 失败当新锁等 */ }
|
|
2224
|
+
/* 新锁:等持有者放。帧库写者并发本来就极低(常驻 + 一次性偶发),
|
|
2225
|
+
* 单次升格毫秒级——40ms 空转一拍就够。 */
|
|
2226
|
+
const t0 = Date.now()
|
|
2227
|
+
while (Date.now() - t0 < 40) { /* spin */ }
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return false
|
|
2231
|
+
}
|
|
2232
|
+
function kfLockRelease() {
|
|
2233
|
+
try { fs.rmSync(kfLockPath(), { force: true }) } catch (e) { /* ignore */ }
|
|
2234
|
+
}
|
|
2235
|
+
|
|
1575
2236
|
/** 库目录现有 L0 字节(按 manifest 记录的文件实测;丢失文件计 0——磁盘占用只算真实存在的) */
|
|
1576
2237
|
function kfUsageBytes(m) {
|
|
1577
2238
|
let total = 0
|
|
@@ -1581,85 +2242,113 @@ function kfUsageBytes(m) {
|
|
|
1581
2242
|
return total
|
|
1582
2243
|
}
|
|
1583
2244
|
|
|
1584
|
-
async function
|
|
1585
|
-
/*
|
|
1586
|
-
|
|
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),无可升格' }
|
|
2245
|
+
async function cmdKfPromoteOne(seq, note) {
|
|
2246
|
+
/* 单个 seq 升格(供 cmdKfPromote 与批量模式共用)。返回 { ok, id?, error?, ... } */
|
|
2247
|
+
if (seq <= 0) return { ok: false, error: '本会话尚无捕获(seq=0),无可升格', seq }
|
|
1596
2248
|
const ent = S.l0.anchor.find((x) => x.seq === seq) ?? S.l0.roll.find((x) => x.seq === seq)
|
|
1597
2249
|
if (!ent) {
|
|
1598
2250
|
const range = (l) => (l.length ? l[0].seq + '..' + l[l.length - 1].seq : '空')
|
|
1599
2251
|
return {
|
|
1600
|
-
ok: false,
|
|
2252
|
+
ok: false, seq,
|
|
1601
2253
|
error: 'L0 已淘汰:seq=' + seq + '(滚动区 ' + range(S.l0.roll) + ',锚区 ' + range(S.l0.anchor) + ')。' +
|
|
1602
|
-
'
|
|
2254
|
+
'可降级导出参考帧:frame_get ' + seq + ' --out <file>(L1 参考帧,按契约不作 state 证据本体)',
|
|
1603
2255
|
}
|
|
1604
2256
|
}
|
|
2257
|
+
/* manifest 读-改-写外面加文件锁(P1-#8):常驻会话与一次性 crop --keep-source
|
|
2258
|
+
* 同秒升格时,两个进程不能各读各的 m.next=N、各写各的 l0/000N.png 后让 manifest
|
|
2259
|
+
* 后写者赢——那会让一个已发出的 kf ID 指向另一份像素。拿锁后重读 manifest,
|
|
2260
|
+
* 整个 RMW 都在临界区。 */
|
|
2261
|
+
if (!kfLockAcquire()) {
|
|
2262
|
+
return { ok: false, seq, error: '关键帧库写锁拿不到(30s):另一进程可能在升格/崩溃残留——稍候重试,或查 ' + kfLockPath() }
|
|
2263
|
+
}
|
|
1605
2264
|
let m
|
|
1606
|
-
try { m = kfOpen() } catch (e) { return { ok: false, error: '关键帧库不可用:' + String(e && e.message || e) } }
|
|
2265
|
+
try { m = kfOpen() } catch (e) { kfLockRelease(); return { ok: false, error: '关键帧库不可用:' + String(e && e.message || e), seq } }
|
|
1607
2266
|
const sess = S.session || {}
|
|
1608
2267
|
const sessionRef = { daemon: S.daemonId, gen: S.connGen, kind: sess.kind ?? null, target: sess.target ?? null }
|
|
1609
2268
|
const sha = createHash('sha256').update(ent.png).digest('hex')
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
if (dup
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
'
|
|
1627
|
-
|
|
2269
|
+
try {
|
|
2270
|
+
/* 幂等:同一捕获(daemon + 代次 + seq)重试返回原对象;内容不符报冲突,不任选一份 */
|
|
2271
|
+
const dup = m.frames.find((f) => f.session && f.session.daemon === sessionRef.daemon &&
|
|
2272
|
+
Number(f.session.gen) === sessionRef.gen && Number(f.captureSeq) === seq)
|
|
2273
|
+
if (dup) {
|
|
2274
|
+
if (dup.sha256 === sha) return { ok: true, idempotent: true, id: dup.id, record: dup, path: kfAbsPath(dup.file), sha256: sha, seq }
|
|
2275
|
+
return { ok: false, seq, error: '同一捕获已有内容不同的记录(' + dup.id + '):冲突,拒绝改写旧 ID 的含义' }
|
|
2276
|
+
}
|
|
2277
|
+
/* 磁盘配额(契约 §4.1):幂等重试在上面已返回,走到这里的都是新增字节。
|
|
2278
|
+
* 满额拒绝并报告——已升格对象不后台清理,清理由用户决定。 */
|
|
2279
|
+
if (S.kfQuota > 0) {
|
|
2280
|
+
const used = kfUsageBytes(m)
|
|
2281
|
+
const mb = (v) => (v / 1048576).toFixed(1) + 'MB'
|
|
2282
|
+
if (used + ent.png.length > S.kfQuota) {
|
|
2283
|
+
return {
|
|
2284
|
+
ok: false, seq,
|
|
2285
|
+
error: '关键帧库配额不足:已用 ' + mb(used) + ' + 新增 ' + mb(ent.png.length) +
|
|
2286
|
+
' 超过配额 ' + mb(S.kfQuota) + '(' + m.frames.length + ' 帧留存于 ' + kfRoot() + ')。' +
|
|
2287
|
+
'不会自动删除已升格帧——清理后重试,或调大配额(init kfQuotaBytes / MAAFW_KF_QUOTA_BYTES)',
|
|
2288
|
+
}
|
|
1628
2289
|
}
|
|
1629
2290
|
}
|
|
2291
|
+
const num = Number(m.next) || m.frames.length + 1
|
|
2292
|
+
const id = 'kf:' + m.libraryId + ':' + String(num).padStart(4, '0')
|
|
2293
|
+
const fileRel = 'l0/' + String(num).padStart(4, '0') + '.png'
|
|
2294
|
+
const abs = kfAbsPath(fileRel)
|
|
2295
|
+
try {
|
|
2296
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
2297
|
+
const tmp = abs + '.tmp'
|
|
2298
|
+
fs.writeFileSync(tmp, ent.png)
|
|
2299
|
+
fs.renameSync(tmp, abs)
|
|
2300
|
+
} catch (e) {
|
|
2301
|
+
return { ok: false, seq, error: 'L0 文件写入失败:' + String(e && e.message || e) }
|
|
2302
|
+
}
|
|
2303
|
+
/* 小图信息只取环内同 seq 帧;已淘汰则明确为空,不借用最新帧数据(契约 §4) */
|
|
2304
|
+
const small = S.ring.find((r) => r.seq === seq)
|
|
2305
|
+
const rec = {
|
|
2306
|
+
id, file: fileRel, sha256: sha,
|
|
2307
|
+
session: sessionRef,
|
|
2308
|
+
capturedAt: new Date(ent.t).toISOString(),
|
|
2309
|
+
captureSeq: seq,
|
|
2310
|
+
ctrlW: ent.w, ctrlH: ent.h,
|
|
2311
|
+
smallW: small ? small.w : null, smallH: small ? small.h : null,
|
|
2312
|
+
scale: small && ent.w ? Math.round((small.w / ent.w) * 1000) / 1000 : null,
|
|
2313
|
+
source: ent.source || 'explicit',
|
|
2314
|
+
note: note != null ? String(note) : null,
|
|
2315
|
+
}
|
|
2316
|
+
m.frames.push(rec)
|
|
2317
|
+
m.next = num + 1
|
|
2318
|
+
try { kfSave(m) } catch (e) {
|
|
2319
|
+
try { fs.rmSync(abs, { force: true }) } catch (e2) { /* ignore */ }
|
|
2320
|
+
return { ok: false, seq, error: 'manifest 写入失败(L0 文件已回收,未发布引用):' + String(e && e.message || e) }
|
|
2321
|
+
}
|
|
2322
|
+
return { ok: true, id, path: abs, sha256: sha, retained: 'promoted', record: rec, seq }
|
|
2323
|
+
} finally {
|
|
2324
|
+
kfLockRelease()
|
|
1630
2325
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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,
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
async function cmdKfPromote(args) {
|
|
2329
|
+
/* 批量:args.seqs 为 seq 数组(CLI 的 "3909 3910" 或 "a..b" 已解析好)。
|
|
2330
|
+
* 逐条走 cmdKfPromoteOne,各自的成功/淘汰分开报告,不因一条失败中断整批。 */
|
|
2331
|
+
if (Array.isArray(args.seqs) && args.seqs.length > 0) {
|
|
2332
|
+
const results = []
|
|
2333
|
+
for (const s of args.seqs) {
|
|
2334
|
+
results.push(await cmdKfPromoteOne(Number(s), args.note))
|
|
2335
|
+
}
|
|
2336
|
+
const ok = results.every((r) => r.ok)
|
|
2337
|
+
const promoted = results.filter((r) => r.ok)
|
|
2338
|
+
const failed = results.filter((r) => !r.ok)
|
|
2339
|
+
return { ok, results, promoted: promoted.length, failed: failed.length }
|
|
1655
2340
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
return { ok: false, error: '
|
|
2341
|
+
/* 固定捕获身份:latest 在接受请求时解析成具体 seq;已淘汰则失败,绝不改取更新帧冒充 */
|
|
2342
|
+
let seq = null
|
|
2343
|
+
if (args.seq !== undefined && args.seq !== null) {
|
|
2344
|
+
seq = Number(args.seq)
|
|
2345
|
+
if (!Number.isFinite(seq)) return { ok: false, error: 'seq 必须是数字' }
|
|
2346
|
+
} else if (args.latest === true) {
|
|
2347
|
+
seq = S.seq
|
|
2348
|
+
} else {
|
|
2349
|
+
return { ok: false, error: '给 seq(数字)、latest:true,或 seqs 数组(批量)指定要升格的捕获' }
|
|
1661
2350
|
}
|
|
1662
|
-
return
|
|
2351
|
+
return cmdKfPromoteOne(seq, args.note)
|
|
1663
2352
|
}
|
|
1664
2353
|
|
|
1665
2354
|
function cmdL0Status() {
|
|
@@ -1863,20 +2552,39 @@ function buildL2Provenance(o) {
|
|
|
1863
2552
|
level: 'L2',
|
|
1864
2553
|
derivedFrom,
|
|
1865
2554
|
transform: { op: 'crop', resize: false, scale: 1, loose: o.loose, crop: o.box, snapped: o.snapped },
|
|
1866
|
-
|
|
2555
|
+
/* annotate --emit-crops 裁的是 SoM 候选,不走自匹配验证——selfMatch 如实为 null,
|
|
2556
|
+
* 不伪造"没测过的匹配度"(契约 §5 的变换与来源照样全)。 */
|
|
2557
|
+
selfMatch: o.selfMatch === null ? null : { score: o.score, positionOk: o.positionOk, box: o.selfMatchBox ?? null, tries: o.tries },
|
|
1867
2558
|
cross: o.cross ?? null,
|
|
1868
2559
|
output: { w: o.w, h: o.h },
|
|
1869
2560
|
createdAt: o.createdAt,
|
|
1870
|
-
tool: 'maafw-live crop',
|
|
2561
|
+
tool: o.tool ?? 'maafw-live crop',
|
|
1871
2562
|
}
|
|
1872
2563
|
}
|
|
1873
2564
|
|
|
1874
|
-
/**
|
|
1875
|
-
|
|
2565
|
+
/** 缺省落点 out/ 的保留上限(只进不出的产物,不让它无声膨胀) */
|
|
2566
|
+
const OUT_KEEP = 50
|
|
2567
|
+
/** out/ 目录保留修剪:按 mtime 留最新 OUT_KEEP 个 .png。与 client 侧 src/runtime/prune.ts 同逻辑的内联
|
|
2568
|
+
* 副本——framed.mjs 自包含、不能 import lib。失败不抛:清理从来不是任务结论。 */
|
|
2569
|
+
function pruneOutDir(dir) {
|
|
2570
|
+
try {
|
|
2571
|
+
const names = fs.readdirSync(dir)
|
|
2572
|
+
.filter((n) => n.endsWith('.png'))
|
|
2573
|
+
.map((n) => ({ n, t: fs.statSync(path.join(dir, n)).mtimeMs }))
|
|
2574
|
+
.sort((a, b) => b.t - a.t)
|
|
2575
|
+
for (const f of names.slice(OUT_KEEP)) {
|
|
2576
|
+
try { fs.unlinkSync(path.join(dir, f.n)) } catch { /* 单个删不掉就留 */ }
|
|
2577
|
+
}
|
|
2578
|
+
} catch { /* 目录不存在/读不了:没有要清的 */ }
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/** 缺省落点:daemon 的 run 目录下 `out/`(缺省 runDir 是 `~/.maafw-live`,不是临时目录)——
|
|
2582
|
+
* **不写调用方的 cwd**。调用方的 cwd 通常就是项目仓库:一次 `frame_get` / `annotate` 就把产物丢进仓库。
|
|
1876
2583
|
* 实测踩过两次(`maa_frame_*.png` 十张进了 maafw-live 仓、`maa_som_*.png` 进了同一个根目录),
|
|
1877
2584
|
* 而这几个命令本质是"看/量",产物只是顺便落盘——落哪儿由 `out` 说话,不落调用方家里。 */
|
|
1878
2585
|
function defaultOutFile(runDir, prefix, ts = Date.now()) {
|
|
1879
2586
|
const dir = path.join(runDir || os.tmpdir(), 'out')
|
|
2587
|
+
pruneOutDir(dir) /* 只在缺省落点修剪——显式 out 是用户指定的位置,不动它 */
|
|
1880
2588
|
return { dir, file: path.join(dir, prefix + '-' + ts + '.png') }
|
|
1881
2589
|
}
|
|
1882
2590
|
|
|
@@ -1993,12 +2701,19 @@ async function cmdTplCrop(args) {
|
|
|
1993
2701
|
const r = await spawnRecoChild({
|
|
1994
2702
|
resourceDir, type: 'TemplateMatch', image: frameFile, templateImage: candFile, cases: [{}],
|
|
1995
2703
|
}, 60000)
|
|
1996
|
-
if (!r || !r.ok)
|
|
2704
|
+
if (!r || !r.ok) {
|
|
2705
|
+
/* 资源链加载不出来(reco 把这条明确报成 `RESOURCE_LOAD`)与"识别跑了但没命中"是两回事:
|
|
2706
|
+
* 前者是资源问题、处置是去修资源,后者才是去换框。别让它落进"低纹理/不独特"。 */
|
|
2707
|
+
if (r && (r.code === 'RESOURCE_LOAD' || /资源链加载失败/.test(String(r.error ?? '')))) {
|
|
2708
|
+
selfMatchDead = selfMatchDead ?? ('资源链加载不出来——' + String(r.error ?? '').trim())
|
|
2709
|
+
}
|
|
2710
|
+
return { score: -1, posOk: false, at: null }
|
|
2711
|
+
}
|
|
1997
2712
|
/* 子进程回 ok 但**一个 case 结果都没有**:识别压根没跑起来(实测最常见的原因是资源目录里没有可加载的
|
|
1998
2713
|
* pipeline,reco 会安静地回 `results: []`)。这与"识别跑了但没命中"是两回事——后者仍会有 1 条结果
|
|
1999
2714
|
* (hit:false)。记下来,别让它被下面的判据翻译成"低纹理/不独特"。 */
|
|
2000
2715
|
if (!Array.isArray(r.results) || r.results.length === 0) {
|
|
2001
|
-
selfMatchDead =
|
|
2716
|
+
selfMatchDead = selfMatchDead ?? '识别子进程一个结果都没回'
|
|
2002
2717
|
return { score: -1, posOk: false, at: null, noResults: true }
|
|
2003
2718
|
}
|
|
2004
2719
|
const det = r.results && r.results[0] ? r.results[0].detail : null
|
|
@@ -2008,8 +2723,9 @@ async function cmdTplCrop(args) {
|
|
|
2008
2723
|
return { score: Number(best.score), posOk: overlapOk(at, [b.x, b.y, b.w, b.h]), at }
|
|
2009
2724
|
}
|
|
2010
2725
|
const obj = (s) => (s.posOk ? 1000 : 0) + s.score
|
|
2011
|
-
/**
|
|
2012
|
-
|
|
2726
|
+
/** 自匹配有没有跑起来:null = 跑起来了;字符串 = 没跑起来的具体原因(子进程回空结果 / 资源链加载不出来)。
|
|
2727
|
+
* 跑到这里还能不跑起来,说明资源那边加载不出识别节点。 */
|
|
2728
|
+
let selfMatchDead = null
|
|
2013
2729
|
const clampBox = (b) => ({
|
|
2014
2730
|
x: Math.max(0, b.x), y: Math.max(0, b.y),
|
|
2015
2731
|
w: Math.min(b.w, dec.w - Math.max(0, b.x)), h: Math.min(b.h, dec.h - Math.max(0, b.y)),
|
|
@@ -2141,8 +2857,8 @@ async function cmdTplCrop(args) {
|
|
|
2141
2857
|
const out = args.out || defaultOutFile(S.runDir, 'tpl').file
|
|
2142
2858
|
fs.mkdirSync(path.dirname(out), { recursive: true })
|
|
2143
2859
|
fs.writeFileSync(out, pngEncodeRGB(final.data, final.w, final.h))
|
|
2144
|
-
const warn = selfMatchDead
|
|
2145
|
-
? '
|
|
2860
|
+
const warn = selfMatchDead !== null
|
|
2861
|
+
? '自匹配没跑起来:' + selfMatchDead + '(实测最常见原因是资源目录里没有可加载的 pipeline)。' +
|
|
2146
2862
|
'这不是"模板不独特"——先写一个 pipeline 节点,或用 --resource-dir 指向真实资源,再重跑这次的框;' +
|
|
2147
2863
|
'在此之前它**未经自匹配验证**,不要拿它去写 pipeline'
|
|
2148
2864
|
: !best.posOk
|
|
@@ -2693,6 +3409,34 @@ async function cmdAnnotate(args) {
|
|
|
2693
3409
|
drawRect(buf, draw.w, draw.h, [rb[0] + 1, rb[1] + 1, Math.max(2, rb[2] - 2), Math.max(2, rb[3] - 2)], [255, 255, 255])
|
|
2694
3410
|
}
|
|
2695
3411
|
fs.writeFileSync(out, pngEncodeRGB(buf, draw.w, draw.h))
|
|
3412
|
+
|
|
3413
|
+
/* --emit-crops <dir>:每个进表候选从 work 图(检测面:L0 全分辨率 or kf 原图 or 降级小图)
|
|
3414
|
+
* 切一张 PNG + 写 .prov.json 出处。box 是 work 坐标系——k 库帧与工作图同空间,x/y 直接裁。
|
|
3415
|
+
* selfMatch 为 null:这是"候选一键出模板"的批量出图,未经自匹配验证;要自匹配(选号)
|
|
3416
|
+
* 走正式 crop。 */
|
|
3417
|
+
const emitted = []
|
|
3418
|
+
const emitDir = typeof args.emitCrops === 'string' && args.emitCrops ? String(args.emitCrops) : null
|
|
3419
|
+
if (emitDir) {
|
|
3420
|
+
try {
|
|
3421
|
+
fs.mkdirSync(emitDir, { recursive: true })
|
|
3422
|
+
cands.forEach((c, i) => {
|
|
3423
|
+
const id = i + 1
|
|
3424
|
+
const b = { x: Math.round(c.box[0]), y: Math.round(c.box[1]), w: Math.round(c.box[2]), h: Math.round(c.box[3]) }
|
|
3425
|
+
const crop = cropRgb(work.rgb, work.w, work.h, b)
|
|
3426
|
+
const f = path.join(emitDir, 'cand-' + String(id).padStart(2, '0') + '.png')
|
|
3427
|
+
fs.writeFileSync(f, pngEncodeRGB(crop.data, crop.w, crop.h))
|
|
3428
|
+
const prov = buildL2Provenance({
|
|
3429
|
+
source, loose: b, box: [b.x, b.y, b.w, b.h], snapped: false, selfMatch: null,
|
|
3430
|
+
cross: null, w: crop.w, h: crop.h, createdAt: new Date().toISOString(),
|
|
3431
|
+
tool: 'maafw-live annotate --emit-crops',
|
|
3432
|
+
})
|
|
3433
|
+
const pf = f + '.prov.json'
|
|
3434
|
+
fs.writeFileSync(pf, JSON.stringify(prov, null, 2) + '\n')
|
|
3435
|
+
emitted.push({ id, path: f, provPath: pf, w: crop.w, h: crop.h, source: c.source })
|
|
3436
|
+
})
|
|
3437
|
+
} catch (e) { /* 逐条写失败不中断 annotate:emit 是可选增值,不是主结果 */ }
|
|
3438
|
+
}
|
|
3439
|
+
|
|
2696
3440
|
const ctrlW = work.fw > 0 ? work.fw : work.w
|
|
2697
3441
|
const ctrlH = work.fh > 0 ? work.fh : work.h
|
|
2698
3442
|
/* 候选框的面积占比(控制器空间,与画面同尺度):调用方可据此再排一次序——
|
|
@@ -2728,6 +3472,7 @@ async function cmdAnnotate(args) {
|
|
|
2728
3472
|
...(args.kfSource ? { sourcesUnavailable: { diff: '库帧路径无会话事件:diff 源不可用(不拿别的区域顶替)' } } : {}),
|
|
2729
3473
|
candidates: cands.map((c, i) => ({ id: i + 1, source: c.source, box: c.box, ctrl: toCtrl(c.box), ...feat(c), ...(c.extra ?? {}) })),
|
|
2730
3474
|
...(cands.length === 0 ? { warn: '无候选:画面可能静止且低对比;换帧或走点选路径' } : {}),
|
|
3475
|
+
...(emitted.length ? { emitted: { dir: emitDir, count: emitted.length, files: emitted } } : {}),
|
|
2731
3476
|
...(pool.length > LIMIT
|
|
2732
3477
|
? { warn: '候选被上限截断:本区域池 ' + pool.length + ' 条,进表 ' + cands.length + ' 条。'
|
|
2733
3478
|
+ (roiApplied ? '用更小的 roi 继续收窄' : '给 roi 把搜索面收小(这是正解),或给 somLimit 放开上限') }
|
|
@@ -2787,16 +3532,33 @@ async function cmdCalibrate(args) {
|
|
|
2787
3532
|
|
|
2788
3533
|
/* ────────────────────────── 消息分发 ────────────────────────── */
|
|
2789
3534
|
const handlers = {
|
|
3535
|
+
/* 活性探针:客户端调用超时后先问这个——能应答说明 daemon 事件循环没死,只是这条
|
|
3536
|
+
* 调用超了;只失败该次调用,不杀进程(会话里的连接 / L0 / 事件环都保得住)。
|
|
3537
|
+
* 必须是无状态纯应答:probe 是设备扫描、run_stop 有副作用,都不能借来探活。 */
|
|
3538
|
+
ping: () => ({ ok: true, t: Date.now() }),
|
|
2790
3539
|
init: (a) => {
|
|
2791
3540
|
if (a.runDir) {
|
|
2792
3541
|
S.runDir = String(a.runDir)
|
|
2793
3542
|
S.previewPath = path.join(S.runDir, 'preview.png')
|
|
2794
3543
|
try { fs.mkdirSync(S.runDir, { recursive: true }) } catch (e) { /* ignore */ }
|
|
3544
|
+
/* action-frames 无 kf 配额:一次性 CLI 每条 click 都落 2 张,纯累积会膨胀。
|
|
3545
|
+
* 不动当前运行——只清 >24h 的(上一个会话的残留)。 */
|
|
3546
|
+
try {
|
|
3547
|
+
const root = path.join(S.runDir, 'action-frames')
|
|
3548
|
+
if (fs.existsSync(root)) {
|
|
3549
|
+
const cutoff = Date.now() - 24 * 3600 * 1000
|
|
3550
|
+
for (const f of fs.readdirSync(root)) {
|
|
3551
|
+
const st = fs.statSync(path.join(root, f))
|
|
3552
|
+
if (st.mtimeMs < cutoff) fs.unlinkSync(path.join(root, f))
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
} catch (e) { /* ignore */ }
|
|
2795
3556
|
}
|
|
2796
3557
|
if (Number(a.kfQuotaBytes) > 0) S.kfQuota = Math.floor(Number(a.kfQuotaBytes))
|
|
2797
3558
|
return { ok: true, previewPath: S.previewPath, daemonId: S.daemonId, framesDir: kfRoot(), kfQuota: S.kfQuota }
|
|
2798
3559
|
},
|
|
2799
3560
|
probe: cmdProbe,
|
|
3561
|
+
screencap_probe: cmdScreencapProbe,
|
|
2800
3562
|
device_list: (a) => cmdDeviceList(a.kind),
|
|
2801
3563
|
connect: cmdConnect,
|
|
2802
3564
|
disconnect: cmdDisconnect,
|
|
@@ -2804,6 +3566,7 @@ const handlers = {
|
|
|
2804
3566
|
stream_start: cmdStreamStart,
|
|
2805
3567
|
stream_stop: cmdStreamStop,
|
|
2806
3568
|
stream_status: cmdStreamStatus,
|
|
3569
|
+
events_list: cmdEventsList,
|
|
2807
3570
|
frame_get: cmdFrameGet,
|
|
2808
3571
|
frame_diff: cmdFrameDiff,
|
|
2809
3572
|
wait: cmdWait,
|
|
@@ -2822,7 +3585,72 @@ const handlers = {
|
|
|
2822
3585
|
|
|
2823
3586
|
/* 子进程模式:stdin JSON 行协议(每行一条消息)。非 --child(headless 单测 import)不挂监听。 */
|
|
2824
3587
|
if (CHILD) {
|
|
3588
|
+
/* 兜底回收 agent 子进程:cleanupAgents 正常走 shutdown / run 重入,但 daemon 被 kill、
|
|
3589
|
+
* 崩溃或宿主超时硬杀时没人收——实测会留下成批孤儿 agent 子进程占着设备连接。
|
|
3590
|
+
* exit 里同步走 spawnSync taskkill /T + ident sweep——正常路径走 cleanupAgents
|
|
3591
|
+
* 的完整版,这里是"进程突然死"的防泄漏,不能因为宿主死了就把 agent 树留给系统。 */
|
|
3592
|
+
const killAgentChildrenSync = () => {
|
|
3593
|
+
const bind = S.agentBind
|
|
3594
|
+
if (!bind) return
|
|
3595
|
+
for (const c of bind.clients) {
|
|
3596
|
+
if (process.platform === 'win32') {
|
|
3597
|
+
const root = c.child && c.child.pid
|
|
3598
|
+
if (!root) continue
|
|
3599
|
+
try { spawnSync('taskkill', ['/pid', String(root), '/T', '/F'], { timeout: 8000 }) } catch (e) { /* ignore */ }
|
|
3600
|
+
if (c.ident) {
|
|
3601
|
+
const like = '%' + String(c.ident).replace(/'/g, '') + '%'
|
|
3602
|
+
const ps = '$self=$PID;Get-CimInstance Win32_Process|Where-Object{$_.ProcessId -ne $self -and $_.CommandLine -and $_.CommandLine -like \'' + like + '\'}|ForEach-Object{try{Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue}catch{}}'
|
|
3603
|
+
try { spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 15000 }) } catch (e) { /* ignore */ }
|
|
3604
|
+
}
|
|
3605
|
+
} else {
|
|
3606
|
+
try { process.kill(-c.child.pid, 'SIGKILL') } catch (e) { try { c.child.kill('SIGKILL') } catch (e2) { /* ignore */ } }
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
process.on('exit', killAgentChildrenSync)
|
|
3611
|
+
process.on('uncaughtException', (e) => {
|
|
3612
|
+
killAgentChildrenSync()
|
|
3613
|
+
/* 不吞掉异常:打印后让进程照常死,exit 里再收一次(幂等) */
|
|
3614
|
+
console.error('daemon 未捕获异常:' + String(e && e.stack || e))
|
|
3615
|
+
process.exit(2)
|
|
3616
|
+
})
|
|
2825
3617
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
3618
|
+
/* 宿主已死 = stdin 管道断开:daemon 不自杀就成孤儿(实测 --session-stop 杀的是会话服务
|
|
3619
|
+
* 进程,daemon 还活着、它桥接的 agent 树也活着)。收到 close 就按 shutdown 同等规格
|
|
3620
|
+
* 收摊:停流 → 树杀 agent → 退。兜底 10s:cleanup 卡住也不许吊着。 */
|
|
3621
|
+
let hostGone = false
|
|
3622
|
+
const onHostGone = () => {
|
|
3623
|
+
if (hostGone) return
|
|
3624
|
+
hostGone = true
|
|
3625
|
+
Promise.resolve()
|
|
3626
|
+
.then(() => cmdStreamStop())
|
|
3627
|
+
.then(() => cleanupAgents())
|
|
3628
|
+
.catch(() => { /* ignore */ })
|
|
3629
|
+
.then(() => process.exit(0))
|
|
3630
|
+
setTimeout(() => process.exit(0), 10000).unref()
|
|
3631
|
+
}
|
|
3632
|
+
rl.on('close', onHostGone)
|
|
3633
|
+
/* 宿主已死 ≠ rl close / stdin end:实测两条都不触发(Node readline 在管道另一侧
|
|
3634
|
+
* 被 kill 时不给 end)。管道破开走 stdin 的 error/close;再补一条兜底轮询:
|
|
3635
|
+
* stdin 不再可读(readableEnded 或 destroyed)就按宿主死了走。 */
|
|
3636
|
+
process.stdin.on('end', onHostGone)
|
|
3637
|
+
process.stdin.on('error', onHostGone)
|
|
3638
|
+
process.stdin.on('close', onHostGone)
|
|
3639
|
+
/* 兜底轮询两条路:
|
|
3640
|
+
* ① stdin 状态:宿主正常退出(不是被 kill)时管道还给 end/close;
|
|
3641
|
+
* ② 宿主进程死了:Windows 下 process.kill(pid) = TerminateProcess,管道
|
|
3642
|
+
* 一侧直接没,任何 stdin 事件都不来(实测 end/close/error 全不触发)。
|
|
3643
|
+
* ppid 存活探测是唯一可靠信号——宿主死了 1s 内 daemon 自杀收摊。 */
|
|
3644
|
+
const hostPid = process.ppid
|
|
3645
|
+
const stdinProbe = setInterval(() => {
|
|
3646
|
+
try {
|
|
3647
|
+
if (process.stdin.destroyed || process.stdin.readableEnded) { onHostGone(); return }
|
|
3648
|
+
if (hostPid) {
|
|
3649
|
+
try { process.kill(hostPid, 0) } catch (e) { onHostGone() }
|
|
3650
|
+
}
|
|
3651
|
+
} catch (e) { /* ignore */ }
|
|
3652
|
+
}, 500)
|
|
3653
|
+
stdinProbe.unref()
|
|
2826
3654
|
rl.on('line', (line) => {
|
|
2827
3655
|
let m = null
|
|
2828
3656
|
try { m = JSON.parse(line) } catch (e) {
|
|
@@ -2843,8 +3671,10 @@ if (CHILD) {
|
|
|
2843
3671
|
/* 纯函数导出(供 headless 验证直接断言,子进程运行中不使用)。
|
|
2844
3672
|
* S 一并导出:l0EvictToBudget 等维护函数作用于模块态,单测需直改后还原。 */
|
|
2845
3673
|
export const __test = {
|
|
2846
|
-
pngDecode, pngEncodeRGB, downscale, blockAnalyze, blockHash, hashDist,
|
|
3674
|
+
pngDecode, pngEncodeRGB, pngHeaderSize, downscale, blockAnalyze, blockHash, hashDist,
|
|
2847
3675
|
regionOfBlocks, tightenBounds, tightenBoundsByEdges, edgeMap, cropRgb, connComponents, edgeDensityBoxes,
|
|
2848
3676
|
readKfCropSource, buildL2Provenance, pickBalancedCandidates, pickNodeName, readRoi,
|
|
2849
|
-
l0EvictToBudget, cmdKfPromote, cmdTplCrop, kfUsageBytes, defaultOutFile, S,
|
|
3677
|
+
l0EvictToBudget, cmdKfPromote, cmdTplCrop, kfUsageBytes, defaultOutFile, eventFrameAnchor, S,
|
|
3678
|
+
pickRecoBox, pickActionBox, anchorFromCacheDedup, checkExpect, blockHash, hashStr,
|
|
3679
|
+
setupAgents, buildLinuxConfig,
|
|
2850
3680
|
}
|