@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.
Files changed (118) hide show
  1. package/README.md +24 -9
  2. package/docs/daemon-protocol.md +15 -10
  3. package/docs/keyframe-retention-contract-v0.md +23 -34
  4. package/docs/session-unify-v0.md +115 -0
  5. package/lib/client/daemon.js +45 -3
  6. package/lib/commands/annotate.js +15 -8
  7. package/lib/commands/calibrate.js +3 -3
  8. package/lib/commands/color.js +3 -3
  9. package/lib/commands/crop.js +8 -8
  10. package/lib/commands/dispatch.js +121 -0
  11. package/lib/commands/env.js +200 -6
  12. package/lib/commands/index.js +7 -1
  13. package/lib/commands/input.js +71 -33
  14. package/lib/commands/kf.js +49 -13
  15. package/lib/commands/probe.js +70 -0
  16. package/lib/commands/reco.js +285 -56
  17. package/lib/commands/repl.js +1305 -502
  18. package/lib/commands/roi-check.js +273 -0
  19. package/lib/commands/roi-compare.js +388 -0
  20. package/lib/commands/runtime.js +295 -91
  21. package/lib/commands/timing.js +28 -10
  22. package/lib/daemon/framed.mjs +1014 -184
  23. package/lib/daemon/reco_child.mjs +150 -9
  24. package/lib/flags.js +2 -0
  25. package/lib/index.js +24 -20
  26. package/lib/interface/hotkey.js +142 -0
  27. package/lib/interface/index.js +1 -1
  28. package/lib/interface/load.js +107 -17
  29. package/lib/interface/override.js +218 -34
  30. package/lib/interface/plan.js +108 -17
  31. package/lib/option-args.js +29 -0
  32. package/lib/pkg.js +36 -0
  33. package/lib/protocol.js +4 -0
  34. package/lib/runtime/actions.js +127 -12
  35. package/lib/runtime/log-watch.js +160 -0
  36. package/lib/runtime/node-source.js +100 -0
  37. package/lib/runtime/ocr-model.js +45 -0
  38. package/lib/runtime/pi-env.js +34 -0
  39. package/lib/runtime/pretask.js +217 -0
  40. package/lib/runtime/prune.js +28 -0
  41. package/lib/runtime/receipts.js +57 -0
  42. package/lib/runtime/reco-args.js +38 -0
  43. package/lib/runtime/reco-verdict.js +82 -0
  44. package/lib/runtime/roi-compare.js +198 -0
  45. package/lib/runtime/session-forward.js +79 -0
  46. package/lib/runtime/session-ipc.js +471 -0
  47. package/lib/runtime/session.js +57 -8
  48. package/lib/runtime/signal-guard.js +35 -0
  49. package/lib/types/commands/dispatch.d.ts +22 -0
  50. package/lib/types/commands/env.d.ts +19 -0
  51. package/lib/types/commands/index.d.ts +1 -1
  52. package/lib/types/commands/probe.d.ts +15 -0
  53. package/lib/types/commands/reco.d.ts +11 -7
  54. package/lib/types/commands/repl.d.ts +8 -0
  55. package/lib/types/commands/roi-check.d.ts +3 -0
  56. package/lib/types/commands/roi-compare.d.ts +3 -0
  57. package/lib/types/commands/runtime.d.ts +20 -2
  58. package/lib/types/flags.d.ts +4 -0
  59. package/lib/types/interface/hotkey.d.ts +36 -0
  60. package/lib/types/interface/index.d.ts +1 -1
  61. package/lib/types/interface/load.d.ts +84 -7
  62. package/lib/types/interface/override.d.ts +58 -13
  63. package/lib/types/interface/plan.d.ts +23 -2
  64. package/lib/types/option-args.d.ts +19 -0
  65. package/lib/types/pkg.d.ts +8 -0
  66. package/lib/types/protocol.d.ts +16 -0
  67. package/lib/types/runtime/actions.d.ts +89 -7
  68. package/lib/types/runtime/log-watch.d.ts +28 -0
  69. package/lib/types/runtime/node-source.d.ts +25 -0
  70. package/lib/types/runtime/ocr-model.d.ts +8 -0
  71. package/lib/types/runtime/pi-env.d.ts +24 -0
  72. package/lib/types/runtime/pretask.d.ts +53 -0
  73. package/lib/types/runtime/prune.d.ts +4 -0
  74. package/lib/types/runtime/receipts.d.ts +7 -0
  75. package/lib/types/runtime/reco-args.d.ts +18 -0
  76. package/lib/types/runtime/reco-verdict.d.ts +50 -0
  77. package/lib/types/runtime/roi-compare.d.ts +117 -0
  78. package/lib/types/runtime/session-forward.d.ts +10 -0
  79. package/lib/types/runtime/session-ipc.d.ts +117 -0
  80. package/lib/types/runtime/session.d.ts +26 -2
  81. package/lib/types/runtime/signal-guard.d.ts +19 -0
  82. package/lib/usage.js +3 -3
  83. package/package.json +17 -4
  84. package/skills/maafw-live/SKILL.md +32 -2
  85. package/skills/maafw-live/references/pitfalls.md +97 -4
  86. package/skills/maafw-live/references/workflows.md +5 -0
  87. package/docs/keyframe-evidence-contract-v0.md +0 -61
  88. package/lib/client/daemon.js.map +0 -1
  89. package/lib/commands/annotate.js.map +0 -1
  90. package/lib/commands/calibrate.js.map +0 -1
  91. package/lib/commands/color.js.map +0 -1
  92. package/lib/commands/crop.js.map +0 -1
  93. package/lib/commands/env.js.map +0 -1
  94. package/lib/commands/index.js.map +0 -1
  95. package/lib/commands/input.js.map +0 -1
  96. package/lib/commands/kf.js.map +0 -1
  97. package/lib/commands/reco.js.map +0 -1
  98. package/lib/commands/repl.js.map +0 -1
  99. package/lib/commands/runtime.js.map +0 -1
  100. package/lib/commands/skill.js.map +0 -1
  101. package/lib/commands/timing.js.map +0 -1
  102. package/lib/commands/version.js.map +0 -1
  103. package/lib/exec.js.map +0 -1
  104. package/lib/flags.js.map +0 -1
  105. package/lib/image.js.map +0 -1
  106. package/lib/index.js.map +0 -1
  107. package/lib/interface/index.js.map +0 -1
  108. package/lib/interface/jsonc.js.map +0 -1
  109. package/lib/interface/load.js.map +0 -1
  110. package/lib/interface/override.js.map +0 -1
  111. package/lib/interface/plan.js.map +0 -1
  112. package/lib/pkg.js.map +0 -1
  113. package/lib/protocol.js.map +0 -1
  114. package/lib/runtime/actions.js.map +0 -1
  115. package/lib/runtime/keyframes.js.map +0 -1
  116. package/lib/runtime/session.js.map +0 -1
  117. package/lib/update-check.js.map +0 -1
  118. package/lib/usage.js.map +0 -1
@@ -0,0 +1,217 @@
1
+ /**
2
+ * pretask:连接 Controller **之前**跑一次的子进程(PI v2.7.0+,过滤字段 v2.8.1)。
3
+ *
4
+ * 契约(MaaFW 5.14.2 `3.3-ProjectInterfaceV2协议`,pinned commit 8061d5bc):
5
+ * - 命令行 = `exec` + `args[]` + 可选末位 JSON(只有 `option` 非空时才追加);
6
+ * - 那个 JSON 是**单行紧凑**字符串,作为**单个 argv 元素**传递,值为 OptionValue
7
+ * (select/switch → `case.name`;checkbox → `case.name` 数组;input → `{字段: 值}`);
8
+ * - 按声明序串行、等它结束,CWD = interface.json 所在目录;
9
+ * - **非零退出即中止本次启动**——任务根本不开始。
10
+ *
11
+ * 协议没规定、由本工具定的三处(写在这里,免得下一个人重新猜):
12
+ * 1. 声明了 `controller` / `resource` 过滤且当前不适用时**跳过并说明**。协议只说 Client
13
+ * 可以隐藏/置灰,不保证跳过执行;但"启动模拟器"这类命令跑错控制器代价更大。
14
+ * 2. 末位 JSON **不用 preset**:pretask 发生在任务选择之前,而 preset 是按任务的快照。
15
+ * 这里只吃 CLI `--option` 与声明里的初始值(`default_case` / `inputs[].default`)。
16
+ * 3. 超时协议没规定,本工具定 120s(`MAAFW_PRETASK_TIMEOUT_MS` 覆盖;<=0 关超时):
17
+ * "启动模拟器"挂成死等时不能靠调用方 Ctrl-C 收摊(设计审查 P0-2)。超时杀的是
18
+ * **进程树**(Windows taskkill /T)——pretask 拉起模拟器的链式脚本只杀头部会留孙代。
19
+ */
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ import { resolveOptionValue } from '../interface/override.js';
22
+ /** 子进程输出只留尾部:够归因,不会把大日志塞进上下文。 */
23
+ const TAIL_LIMIT = 4000;
24
+ /**
25
+ * 拼一条 pretask 的完整 argv(含末位 JSON)。**纯函数**:执行与参数构造分开,
26
+ * 参数形状可以离线单测——这部分错了就是"脚本收到了奇怪的参数但没人报错"。
27
+ */
28
+ export function buildPretaskArgv(p, loaded, ctx) {
29
+ const warns = [];
30
+ const json = {};
31
+ for (const key of p.option) {
32
+ const o = loaded.options[key];
33
+ if (!o) {
34
+ warns.push('pretask ' + (p.name ?? p.exec) + ' 引用了未定义的 option ' + key + ',未写入末位 JSON');
35
+ continue;
36
+ }
37
+ /* 适用性过滤:不满足当前 controller / resource 的 option 不进这个对象 */
38
+ if (o.controller?.length && ctx.controllerName && !o.controller.includes(ctx.controllerName))
39
+ continue;
40
+ if (o.resource?.length && ctx.resourceName && !o.resource.includes(ctx.resourceName))
41
+ continue;
42
+ const v = resolveOptionValue(o, ctx.valueCtx);
43
+ if (!v)
44
+ continue;
45
+ if (v.kind === 'fields') {
46
+ json[key] = v.fields;
47
+ continue;
48
+ }
49
+ /* case 型取值要校验:写一个不存在的 case 名进 JSON,脚本会按错的配置跑而不报错 */
50
+ const known = o.type === 'checkbox' ? v.cases : [v.cases[0]];
51
+ const bad = known.filter((c) => !o.cases.some((x) => x.name === c));
52
+ if (bad.length) {
53
+ warns.push('pretask ' + (p.name ?? p.exec) + ' 的 option ' + key + ' 取值 ' + bad.join(' / ') + ' 不是已声明的 case,未写入末位 JSON');
54
+ continue;
55
+ }
56
+ json[key] = o.type === 'checkbox' ? v.cases : v.cases[0];
57
+ }
58
+ const argv = [p.exec, ...p.args, ...(Object.keys(json).length ? [JSON.stringify(json)] : [])];
59
+ return { argv, warns };
60
+ }
61
+ /** pretask 超时缺省 120s:够"启动模拟器到可连"的常态,又兜住真正挂死的脚本。 */
62
+ export const PRETASK_TIMEOUT_MS = 120000;
63
+ function pretaskTimeoutMs() {
64
+ const raw = process.env.MAAFW_PRETASK_TIMEOUT_MS;
65
+ if (raw === undefined)
66
+ return PRETASK_TIMEOUT_MS;
67
+ const n = Number(raw);
68
+ return Number.isFinite(n) ? n : PRETASK_TIMEOUT_MS;
69
+ }
70
+ /** 进程树杀:pretask 常是"拉起模拟器"的壳脚本,只杀头部会留孙代占设备。 */
71
+ function killTree(child) {
72
+ const pid = child.pid;
73
+ if (pid === undefined) {
74
+ try {
75
+ child.kill();
76
+ }
77
+ catch { /* ignore */ }
78
+ return;
79
+ }
80
+ if (process.platform === 'win32') {
81
+ try {
82
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { timeout: 5000 });
83
+ }
84
+ catch { /* ignore */ }
85
+ return;
86
+ }
87
+ try {
88
+ process.kill(-pid, 'SIGKILL');
89
+ }
90
+ catch {
91
+ try {
92
+ child.kill('SIGKILL');
93
+ }
94
+ catch { /* ignore */ }
95
+ }
96
+ }
97
+ /**
98
+ * 执行一条 pretask;timeoutMs<=0 不限时。
99
+ * Windows 的 .cmd/.bat 必须过 cmd.exe(CreateProcess 只认 exe;不带 shell 的 spawn 同步抛
100
+ * EINVAL,原实现却提示"检查 PATH"——把用户往完全错的方向带)。args 逐元素传给 cmd.exe:
101
+ * Node 会自己做 cmd 元字符转义,不经 shell:true(那个走全串拼接,空格参数必坏)。
102
+ */
103
+ function spawnOnce(exe, args, cwd, timeoutMs) {
104
+ return new Promise((resolve) => {
105
+ let tail = '';
106
+ let settled = false;
107
+ const done = (r) => {
108
+ if (!settled) {
109
+ settled = true;
110
+ resolve(r);
111
+ }
112
+ };
113
+ const isBatch = process.platform === 'win32' && /\.(cmd|bat)$/i.test(exe);
114
+ const realExe = isBatch ? 'cmd.exe' : exe;
115
+ const realArgs = isBatch ? ['/d', '/s', '/c', exe, ...args] : args;
116
+ let child;
117
+ try {
118
+ child = spawn(realExe, realArgs, {
119
+ cwd: cwd ?? undefined, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
120
+ /* POSIX 单独成组,超时才能 -pid 收整棵树;Windows 不靠 detached(它另有语义),taskkill /T 收。 */
121
+ ...(isBatch || process.platform === 'win32' ? {} : { detached: true }),
122
+ });
123
+ }
124
+ catch (e) {
125
+ done({ code: null, signal: null, tail: '', error: String(e.message || e) });
126
+ return;
127
+ }
128
+ const feed = (b) => { tail = (tail + String(b)).slice(-TAIL_LIMIT); };
129
+ child.stdout?.on('data', feed);
130
+ child.stderr?.on('data', feed);
131
+ child.on('error', (e) => done({ code: null, signal: null, tail, error: e.message }));
132
+ child.on('exit', (code, signal) => done({ code, signal, tail }));
133
+ if (timeoutMs > 0) {
134
+ const timer = setTimeout(() => {
135
+ killTree(child);
136
+ done({ code: null, signal: null, tail, timedOut: true });
137
+ }, timeoutMs);
138
+ timer.unref?.();
139
+ }
140
+ });
141
+ }
142
+ /**
143
+ * 按声明序执行全部适用的 pretask。任一条失败即返回 ok:false(调用方据此中止连接)。
144
+ * 不抛异常:失败是一种结果,由调用方决定怎么呈现。
145
+ */
146
+ export async function runPretasks(input) {
147
+ const { loaded } = input;
148
+ const ran = [];
149
+ const skipped = [];
150
+ const notes = [];
151
+ const warns = [];
152
+ const valueCtx = {
153
+ ...(input.optionCases ? { optionCases: input.optionCases } : {}),
154
+ ...(input.optionFields ? { optionFields: input.optionFields } : {}),
155
+ };
156
+ for (const p of loaded.pretask ?? []) {
157
+ const label = p.name ?? p.exec;
158
+ if (p.controller?.length && input.controllerName && !p.controller.includes(input.controllerName)) {
159
+ skipped.push(label + '(声明只适用于控制器 ' + p.controller.join(' / ') + ')');
160
+ continue;
161
+ }
162
+ if (p.resource?.length && input.resourceName && !p.resource.includes(input.resourceName)) {
163
+ skipped.push(label + '(声明只适用于资源 ' + p.resource.join(' / ') + ')');
164
+ continue;
165
+ }
166
+ const built = buildPretaskArgv(p, loaded, {
167
+ controllerName: input.controllerName,
168
+ resourceName: input.resourceName,
169
+ valueCtx,
170
+ });
171
+ warns.push(...built.warns);
172
+ const [exe, ...args] = built.argv;
173
+ const started = Date.now();
174
+ const r = await spawnOnce(exe, args, loaded.dir, input.timeoutMs ?? pretaskTimeoutMs());
175
+ const outcome = {
176
+ name: label, exec: exe, argv: built.argv, code: r.code, signal: r.signal, ms: Date.now() - started, tail: r.tail,
177
+ };
178
+ ran.push(outcome);
179
+ if (r.error) {
180
+ return {
181
+ ok: false, ran, skipped, notes, warns,
182
+ error: {
183
+ code: 'PRETASK_SPAWN',
184
+ message: 'pretask ' + label + ' 启动失败:' + r.error,
185
+ hint: '检查 exec 是否在 PATH 里(协议的 exec 不走 shell,不能写管道/重定向)',
186
+ },
187
+ };
188
+ }
189
+ if (r.timedOut) {
190
+ return {
191
+ ok: false, ran, skipped, notes, warns,
192
+ error: {
193
+ code: 'PRETASK_TIMEOUT',
194
+ message: 'pretask ' + label + ' 超时(' + (input.timeoutMs ?? pretaskTimeoutMs()) + 'ms)已树杀,本次启动中止',
195
+ hint: (r.tail ? '子进程输出尾部:\n' + r.tail + '\n' : '') +
196
+ '启动模拟器类脚本确实需要更久时,用 MAAFW_PRETASK_TIMEOUT_MS 放宽(毫秒),不是去掉超时',
197
+ },
198
+ };
199
+ }
200
+ if (r.code !== 0) {
201
+ return {
202
+ ok: false, ran, skipped, notes, warns,
203
+ error: {
204
+ code: 'PRETASK_FAILED',
205
+ message: 'pretask ' + label + ' 以 ' + (r.signal ? '信号 ' + r.signal : '退出码 ' + r.code) +
206
+ ' 结束,本次启动中止(PI 契约:非零退出即中止)',
207
+ ...(r.tail ? { hint: '子进程输出尾部:\n' + r.tail } : {}),
208
+ },
209
+ };
210
+ }
211
+ notes.push('pretask 已执行:' + label + '(' + outcome.ms + 'ms)');
212
+ }
213
+ if (skipped.length)
214
+ notes.push('跳过 ' + skipped.length + ' 条 pretask:' + skipped.join(';'));
215
+ return { ok: true, ran, skipped, notes, warns };
216
+ }
217
+ //# sourceMappingURL=pretask.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 落盘目录的保留上限(runs/、png/ 共用):按 mtime 留最新 keep 个,删其余。
3
+ * 背景:~/.maafw-live 下这些目录以前没有上限——回执与截图只进不出,
4
+ * 用久了会无声膨胀(实测 runs/ 44 个、frames 15MB)。修剪失败不抛:
5
+ * 落盘/清理从来不是任务结论,最坏是目录多留几个。
6
+ *
7
+ * 只删 suffix 匹配的文件,不碰目录与其他扩展名;目录本身不存在时直接返回。
8
+ * daemon 侧(framed.mjs)的 out/ 有同逻辑的内联副本——它自包含、不能 import lib。
9
+ */
10
+ import { readdirSync, statSync, unlinkSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ export function pruneDir(dir, opts) {
13
+ try {
14
+ const suffix = opts.suffix ?? '';
15
+ const names = readdirSync(dir)
16
+ .filter((n) => n.endsWith(suffix))
17
+ .map((n) => ({ n, t: statSync(join(dir, n)).mtimeMs }))
18
+ .sort((a, b) => b.t - a.t); /* 新的在前 */
19
+ for (const f of names.slice(Math.max(0, opts.keep))) {
20
+ try {
21
+ unlinkSync(join(dir, f.n));
22
+ }
23
+ catch { /* 单个删不掉就留 */ }
24
+ }
25
+ }
26
+ catch { /* 目录不存在/读不了:按没有要清的看 */ }
27
+ }
28
+ //# sourceMappingURL=prune.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * run 回执落盘(repl 与一次性 CLI 共用):W1 runs 存储的种子。
3
+ *
4
+ * 目录默认 `~/.maafw-live/runs/`,可用 `MAAFW_LIVE_RUNS_DIR` 覆盖——
5
+ * 单测必须指向临时目录,不许往真实 home 写桩数据(2026-10-26 实测污染过一轮)。
6
+ * 落盘失败不属于任务结论:返回体没有 path 时,调用方自行退化(如打印完整 JSON)。
7
+ *
8
+ * 文件名带完整毫秒时间戳,且**独占创建**(flag 'wx'):回执是"当时只能观测一次"的证据,
9
+ * 被后来的同名写入静默覆盖就等于丢证据——所以撞名不覆盖,改为加序号重试。
10
+ */
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { pruneDir } from './prune.js';
15
+ /** 保留上限:回执是"一次性观测"的证据,留够回看的就够,不让它无声膨胀。 */
16
+ const RUNS_KEEP = 50;
17
+ export function runsDir() {
18
+ const override = process.env.MAAFW_LIVE_RUNS_DIR;
19
+ return override && override !== '' ? override : join(homedir(), '.maafw-live', 'runs');
20
+ }
21
+ /** 回执文件名:`run-<ISO 时间戳去分隔符>-<entry>.json`(时间戳保留到毫秒,含 Z 时区标记)。
22
+ * `now` 可注入:单测据此制造"同一毫秒撞名",不必靠 sleep。 */
23
+ export function receiptFileName(entry, now = new Date()) {
24
+ const stamp = now.toISOString().replace(/[-:.]/g, '');
25
+ const safeEntry = entry.replace(/[\\/:*?"<>|\s]+/g, '_');
26
+ return 'run-' + stamp + '-' + safeEntry + '.json';
27
+ }
28
+ /** 同一毫秒内最多重试到这个序号(撞名说明并发落盘,实际不会逼近这个量级) */
29
+ const MAX_SAME_NAME = 100;
30
+ export function writeRunReceipt(record, entry, now = new Date()) {
31
+ try {
32
+ const dir = runsDir();
33
+ mkdirSync(dir, { recursive: true });
34
+ const base = receiptFileName(entry, now);
35
+ const body = JSON.stringify(record, null, 2) + '\n';
36
+ for (let n = 0; n < MAX_SAME_NAME; n++) {
37
+ const name = n === 0 ? base : base.replace(/\.json$/, '-' + n + '.json');
38
+ const path = join(dir, name);
39
+ try {
40
+ writeFileSync(path, body, { flag: 'wx' });
41
+ pruneDir(dir, { keep: RUNS_KEEP, suffix: '.json' });
42
+ return { path };
43
+ }
44
+ catch (e) {
45
+ /* 撞名是"已有同名回执",换序号重试;别的 IO 错误按落盘失败处理 */
46
+ if (e.code !== 'EEXIST')
47
+ throw e;
48
+ }
49
+ }
50
+ return {};
51
+ }
52
+ catch {
53
+ /* 落盘失败:调用方按"没有 path"退化,不吞任务结论 */
54
+ return {};
55
+ }
56
+ }
57
+ //# sourceMappingURL=receipts.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * reco 参数面的共享判据:**roi 空间**校验。
3
+ *
4
+ * 为什么单独立一个文件:repl 与一次性 CLI 都要能给「roi 越出所用图像尺寸」下一个明确结论,
5
+ * 判据必须只有一份 —— 两处各写一遍,迟早分叉成「CLI 报错、repl 静默」,而静默的正是最贵的那种
6
+ * (G16:ctrl 坐标的 roi 打在 L1 小图上 → `results: []`,看起来像"模板没读到")。
7
+ */
8
+ /**
9
+ * roi(控制器/图像空间)是否越出图像尺寸。越界就是**参数用错空间**,不是"没识别到":
10
+ * 边界相等不算越界(`x+w === w` 是贴右边)。
11
+ *
12
+ * **左上边界同样要判**:负坐标是最典型的"空间算错"(L1↔ctrl 换算时先减了偏移),
13
+ * 而它只比右下是看不出来的(`[-50,200,80,300]` 的右下 = 30/500,落在画面里)。
14
+ * 漏判的代价正是本判据要防的那件事:回落到"静默未中 + 指向模板/资源"。
15
+ *
16
+ * 返回 null 表示在界内或无法判定(缺尺寸)。
17
+ */
18
+ export function roiOutOfBounds(roi, w, h) {
19
+ if (!roi || roi.length !== 4)
20
+ return null;
21
+ const iw = Number(w);
22
+ const ih = Number(h);
23
+ if (!Number.isFinite(iw) || !Number.isFinite(ih) || iw <= 0 || ih <= 0)
24
+ return null;
25
+ const [x, y, rw, rh] = roi;
26
+ const right = x + rw;
27
+ const bottom = y + rh;
28
+ if (x >= 0 && y >= 0 && right <= iw && bottom <= ih)
29
+ return null;
30
+ const where = (x < 0 || y < 0)
31
+ ? '(左上 ' + x + ',' + y + ' 为负' + (right > iw || bottom > ih ? ',且右 ' + right + ' / 下 ' + bottom + ' 超出' : '') + ')'
32
+ : '(右 ' + right + ' / 下 ' + bottom + ')';
33
+ return 'roi 越出图像:roi=' + roi.join(',') + where
34
+ + ' 超出图像尺寸 ' + iw + 'x' + ih
35
+ + ' —— 这是**参数用错空间**(roi 按图像像素给;负值多半是换算时先减了偏移),不是识别未命中;'
36
+ + '控制器的 roi 要按所用图的尺寸换算(L1 小图 vs 控制器原图差一个 scale)';
37
+ }
38
+ //# sourceMappingURL=reco-args.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 识别未命中的原因与计数:**只有这一份判据**,一次性 CLI 与 REPL 都读它。
3
+ *
4
+ * 为什么单独立文件:miss 的病因直接决定下一步查什么(roi 里没有字 / expected 过滤掉了 /
5
+ * 分数不够 / 模板根本读不到),两个壳各推一遍必然分叉,而分叉的代价是把"expected 不匹配"
6
+ * 当成"模板读不到"去翻资源目录。
7
+ *
8
+ * 名字按**算法**分开给,不共用一套 OCR 口径:OCR 没有阈值这回事,模板匹配也没有 `expected`
9
+ * 这回事——共用口径就会编出不存在的病因。表以外的算法不猜,如实不给原因。
10
+ *
11
+ * OCR 的空结果还有一个同形病因:**模型没加载时框架不报错,只回空结果**。这条不在本文件的
12
+ * 职责内(要查文件系统),由调用方用 `runtime/ocr-model` 判好、以 `options.ocrModel` 传进来。
13
+ *
14
+ * 派生的前提是**先看 hit**:`noConfidence` 旧式判据(`threshold 建议为 null`)在 OCR 命中时
15
+ * 也成立(OCR 无数值分),不看 `ok` 就会把命中标成"无置信度"。
16
+ */
17
+ function innerDetail(detail) {
18
+ const outer = detail;
19
+ const inner = outer && typeof outer === 'object' ? outer.detail : undefined;
20
+ return inner && typeof inner === 'object' ? inner : null;
21
+ }
22
+ function countOf(v) {
23
+ return Array.isArray(v) ? v.length : null;
24
+ }
25
+ function reasonFor(hit, type, rawCount, scores, ocrModel, failure) {
26
+ if (hit)
27
+ return null;
28
+ /* 调用本身报错(detail 为 null)优先于一切算法口径:这不是"未命中",别按未命中解释 */
29
+ if (failure !== null)
30
+ return '识别调用报错:' + failure;
31
+ if (type === 'OCR') {
32
+ if (rawCount === 0) {
33
+ /* 模型没加载时框架不报错,只回空结果——与"roi 里没有字"同形,先排前者 */
34
+ if (ocrModel === 'missing')
35
+ return 'OCR 模型不在资源链里(model/ocr 需 det.onnx + rec.onnx + keys.txt)';
36
+ return 'roi 内无文字';
37
+ }
38
+ if (rawCount !== null && rawCount > 0)
39
+ return 'expected 无匹配';
40
+ return null;
41
+ }
42
+ if (type === 'TemplateMatch') {
43
+ if (scores === null)
44
+ return '识别未返回任何置信度(模板/资源读不到)';
45
+ if (scores.lowMatch)
46
+ return '模板与画面不匹配(最高置信度 ' + scores.bestScore + ')';
47
+ return '低于阈值(最高置信度 ' + scores.bestScore + ')';
48
+ }
49
+ return null;
50
+ }
51
+ export function describeMisses(results, type, scores, options = {}) {
52
+ const perResult = results.map((r) => {
53
+ const inner = innerDetail(r.detail);
54
+ const rawCount = countOf(inner?.all);
55
+ const filteredCount = countOf(inner?.filtered);
56
+ /* detail 缺失 = 子进程捕获到异常(正常 miss 时框架仍回 detail 对象)。异常文本直接给人看,
57
+ * 但截断:它是框架的原生消息,可能很长。 */
58
+ const hasDetail = r.detail !== null && typeof r.detail === 'object';
59
+ const errText = typeof r.error === 'string' && r.error.trim() !== '' ? r.error.trim() : null;
60
+ const failure = hasDetail ? null : (errText ?? '未返回 detail(调用失败)');
61
+ return {
62
+ missReason: reasonFor(r.ok === true, type, rawCount, scores, options.ocrModel, failure && failure.length > 160 ? failure.slice(0, 160) + ' …' : failure),
63
+ rawCount,
64
+ filteredCount,
65
+ failed: failure !== null && r.ok !== true,
66
+ };
67
+ });
68
+ const reasons = [];
69
+ for (const p of perResult) {
70
+ if (p.missReason && !reasons.includes(p.missReason))
71
+ reasons.push(p.missReason);
72
+ }
73
+ const single = results.length === 1 ? perResult[0] : undefined;
74
+ return {
75
+ missReason: reasons.length === 1 ? reasons[0] : null,
76
+ missReasons: reasons,
77
+ rawCount: single ? single.rawCount : null,
78
+ filteredCount: single ? single.filteredCount : null,
79
+ perResult,
80
+ };
81
+ }
82
+ //# sourceMappingURL=reco-verdict.js.map
@@ -0,0 +1,198 @@
1
+ /**
2
+ * 候选集合比较的判据内核:**给定外来目标 + 一组位置假设 → 排序表 + bestGap + 并列带 + 三态结论**。
3
+ *
4
+ * 为什么把它从 `roi-check` 里拆出来:旧形态把候选写死成"沿 x 偏移 ±pitch/2"——y 从不偏移,
5
+ * 还要求你先知道周期。而真实棋盘的行距 ≠ 列距、列距本身也逐行不同(单一标量周期不成立),
6
+ * 所以"半周期关系"只是近似。判据的正确形状是**候选集合比较**:候选从哪来(显式全套 / 相对平移 /
7
+ * 发现模式)是调用方的事,这里只负责"生成 → 逐个取分 → 排序 → 下结论"。
8
+ *
9
+ * 三条不许动的性质:
10
+ * 1. **没有证据 ≠ 0 分**:`score: null` 的候选不进 `ranked`(没有分数就没有名次),也不许被当成
11
+ * 垫底来"把最优衬托成唯一";
12
+ * 2. **`bestGap` 无上限地照给**:永远报第 1 与第 2 名的真实分差,不做阈值截断——
13
+ * 只报"带内"的差值等于把"差多少"这件事藏起来;
14
+ * 3. **顺序可追溯**:候选带 `origin`(输入列表下标),排序稳定(同分按 origin),
15
+ * 于是输出能被还原回输入,而不是"看起来像排序过"。
16
+ *
17
+ * 纯函数、不碰 IO、不碰协议:退出码与文案归命令层。
18
+ */
19
+ import { roiOutOfBounds } from './reco-args.js';
20
+ /** 允许说"分不出来"的并列带宽度:最优与次优分差小于它就不下结论。 */
21
+ export const DEFAULT_GAP = 0.15;
22
+ const round3 = (n) => Math.round(n * 1000) / 1000;
23
+ function finiteRoi(value) {
24
+ if (!Array.isArray(value) || value.length !== 4)
25
+ return null;
26
+ const roi = value.map(Number);
27
+ return roi.every((n) => Number.isFinite(n)) ? roi : null;
28
+ }
29
+ /** 唯一的越界口径:一律走 `roiOutOfBounds`(它连左上负坐标一起判)。 */
30
+ function collect(raw, size) {
31
+ const candidates = [];
32
+ const skipped = [];
33
+ for (const e of raw) {
34
+ if (!e.roi) {
35
+ skipped.push({ roi: e.echo, origin: e.origin, reason: '不是 4 个有限数字,无法当 roi' });
36
+ continue;
37
+ }
38
+ const oob = roiOutOfBounds(e.roi, size?.w, size?.h);
39
+ if (oob) {
40
+ skipped.push({ roi: e.roi, origin: e.origin, reason: oob });
41
+ continue;
42
+ }
43
+ candidates.push({ roi: e.roi, origin: e.origin });
44
+ }
45
+ return { candidates, skipped };
46
+ }
47
+ /** 显式全套:每个元素就是 `[x,y,w,h]`。位置假设已经知道时用这条(**没有周期假设**)。 */
48
+ export function fromRois(rois, size) {
49
+ return collect(rois.map((r, origin) => ({
50
+ roi: finiteRoi(r),
51
+ echo: Array.isArray(r) ? r.map(Number) : [],
52
+ origin,
53
+ })), size);
54
+ }
55
+ /** 相对一个基准位置的平移:x / y 分量都允许,列表允许非均匀(逐行不同的列距照样能表达)。 */
56
+ export function fromOffsets(offsets, base, size) {
57
+ return collect(offsets.map((o, origin) => {
58
+ const roi = [base[0] + o.dx, base[1] + o.dy, base[2], base[3]];
59
+ return { roi: finiteRoi(roi), echo: roi, origin };
60
+ }), size);
61
+ }
62
+ /** 一个轴的取值列表(单点恒为 1 个值)。 */
63
+ export function axisValues(axis) {
64
+ if (!axis)
65
+ return [0];
66
+ const step = Number.isFinite(axis.step) && axis.step > 0 ? axis.step : 1;
67
+ const out = [];
68
+ for (let v = axis.from; v <= axis.to + 1e-9; v += step)
69
+ out.push(round3(v));
70
+ return out;
71
+ }
72
+ export function scanInfo(spec) {
73
+ return {
74
+ dx: spec.dx ?? null,
75
+ dy: spec.dy ?? null,
76
+ count: axisValues(spec.dx).length * axisValues(spec.dy).length,
77
+ };
78
+ }
79
+ /** 发现模式:笛卡尔积 dx × dy 全部落在**同一个基准 roi** 上(不是判据,见命令层文案)。 */
80
+ export function fromScan(spec, base, size) {
81
+ const xs = axisValues(spec.dx);
82
+ const ys = axisValues(spec.dy);
83
+ const raw = [];
84
+ let origin = 0;
85
+ for (const dx of xs) {
86
+ for (const dy of ys) {
87
+ const roi = [base[0] + dx, base[1] + dy, base[2], base[3]];
88
+ raw.push({ roi: finiteRoi(roi), echo: roi, origin: origin++ });
89
+ }
90
+ }
91
+ return collect(raw, size);
92
+ }
93
+ /**
94
+ * `--scan` 语法解析:`dx=-160..160/8,dy=0`(逗号分段,轴是 dx / dy,值是 `<n>` 或 `<from>..<to>/<step>`)。
95
+ *
96
+ * 范围**必须带步长**:候选数是 (to−from)/step 线性增长、而耗时按候选数线性增长,
97
+ * 缺省步长等于让一次手滑变成几百个候选。
98
+ */
99
+ export function parseScan(raw) {
100
+ const spec = {};
101
+ for (const part of String(raw).split(',')) {
102
+ const seg = part.trim();
103
+ if (!seg)
104
+ continue;
105
+ const eq = seg.indexOf('=');
106
+ if (eq < 0)
107
+ return { error: '--scan 每段要写成 <轴>=<值>:' + seg + '(形如 dx=-160..160/8,dy=0)' };
108
+ const axis = seg.slice(0, eq).trim();
109
+ if (axis !== 'dx' && axis !== 'dy')
110
+ return { error: '--scan 只认识 dx / dy 两个轴,不认识:' + seg };
111
+ const body = seg.slice(eq + 1).trim();
112
+ const m = /^(-?\d+(?:\.\d+)?)(?:\.\.(-?\d+(?:\.\d+)?)(?:\/(\d+(?:\.\d+)?))?)?$/.exec(body);
113
+ if (!m)
114
+ return { error: '--scan 的值要写成 <n> 或 <from>..<to>/<step>:' + seg };
115
+ const from = Number(m[1]);
116
+ if (m[2] === undefined) {
117
+ spec[axis] = { from, to: from, step: 1 };
118
+ continue;
119
+ }
120
+ if (m[3] === undefined) {
121
+ return { error: '--scan 的范围要给步长(' + seg + ' 应写成 ' + body + '/<step>):耗时随候选数线性增长,缺步长会让候选数失控' };
122
+ }
123
+ const to = Number(m[2]);
124
+ const step = Number(m[3]);
125
+ if (!(step > 0))
126
+ return { error: '--scan 的步长要正数:' + seg };
127
+ if (to < from)
128
+ return { error: '--scan 的区间为空(from > to):' + seg };
129
+ spec[axis] = { from, to, step };
130
+ }
131
+ if (spec.dx === undefined && spec.dy === undefined) {
132
+ return { error: '--scan 至少要给一个轴:dx=… 或 dy=…' };
133
+ }
134
+ return { value: spec };
135
+ }
136
+ export function sampleOf(result) {
137
+ const inner = result?.detail?.detail;
138
+ const best = inner?.best?.score;
139
+ const boxes = (inner?.all ?? [])
140
+ .map((x) => Number(x.score))
141
+ .filter((n) => Number.isFinite(n))
142
+ .concat(Number.isFinite(Number(best)) ? [Number(best)] : []);
143
+ const box = inner?.best?.box;
144
+ return {
145
+ score: boxes.length ? Math.max(...boxes) : null,
146
+ ok: result?.ok === true,
147
+ box: Array.isArray(box) ? box : [],
148
+ };
149
+ }
150
+ /**
151
+ * 三态判据。优先序(**别重排**):**先把没有证据的候选剔除,再谈歧义**——
152
+ * ① 可评估候选 0 个 → 未命中;② 最优未过 threshold → 未命中;
153
+ * ③ 与最优并列(|Δ| < gap)的 ≥ 2 → AMBIGUOUS;④ 否则唯一最优。
154
+ *
155
+ * "剔除"以**输出形状**兑现:`score: null` 的候选**不在 `ranked` 里**——没有分数就没有名次,
156
+ * 也进不了并列带("没有证据"既不是 0 分,也不许被当成垫底来把最优衬托成唯一)。
157
+ * 剩下有证据的那些谁更优,才是这里回答的问题;"还有候选没被评估过"由调用方写进退出码(3)
158
+ * 与逐例回执,不改变这里的结论——一次 41 点的扫描里,落在背景上的点本来就拿不到相关系数
159
+ * (实测 `all: []`),把它们算成"分不出来"会淹没真正的信号。
160
+ */
161
+ export function aggregate(items, options = {}) {
162
+ const gap = options.gap ?? DEFAULT_GAP;
163
+ const threshold = options.threshold;
164
+ const byScore = items
165
+ .filter((x) => x.score !== null)
166
+ .sort((a, b) => (b.score - a.score) || (a.origin - b.origin));
167
+ const best = byScore.length ? byScore[0].score : null;
168
+ const second = byScore.length > 1 ? byScore[1].score : null;
169
+ const bestGap = best !== null && second !== null ? round3(best - second) : null;
170
+ const band = best === null ? [] : byScore.filter((x) => best - x.score < gap);
171
+ const withinGap = band.map((_, i) => i + 1);
172
+ const ranked = byScore.map((x, i) => ({
173
+ ...x,
174
+ rank: i + 1,
175
+ inBand: i < band.length,
176
+ }));
177
+ let verdict = 'MISS';
178
+ let reason = 'no-candidates';
179
+ if (items.length === 0) {
180
+ reason = 'no-candidates';
181
+ }
182
+ else if (!byScore.length) {
183
+ reason = 'no-evidence';
184
+ }
185
+ else if (threshold !== undefined && best < threshold) {
186
+ reason = 'below-threshold';
187
+ }
188
+ else if (band.length >= 2) {
189
+ verdict = 'AMBIGUOUS';
190
+ reason = 'ambiguous';
191
+ }
192
+ else {
193
+ verdict = 'HIT';
194
+ reason = 'unique';
195
+ }
196
+ return { ranked, bestGap, gap, withinGap, verdict, reason };
197
+ }
198
+ //# sourceMappingURL=roi-compare.js.map