mocode-ai 1.4.6 → 1.5.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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * 常驻抓屏进程(Windows)。
3
+ *
4
+ * 为什么要有这个:实测一次全屏抓屏 710-737ms,其中绝大部分是 PowerShell 冷启动 +
5
+ * `Add-Type` 加载 System.Windows.Forms / System.Drawing(GDI+ 初始化)。这一步每个动作都要
6
+ * 付一次,而它跟「抓哪块屏」毫无关系 —— 典型的可以摊薄的固定成本。
7
+ *
8
+ * 协议与 `input-injector.ts` 完全同构(stdin 收 NDJSON 动作 / stdout 回 NDJSON 结果),
9
+ * 就是为了复用同一套心智模型:Add-Type 只做一次,之后每次抓屏只是一次管道往返。
10
+ *
11
+ * 生命周期(见 `idle-guard.ts`):惰性启动 → 每次调用续期 → 空闲 3 分钟回收 → /cu off 与
12
+ * REPL 退出强制回收。**不因为 /cu on 就启动** —— /cu 是写进 config 的持久开关,若 on 即起进程,
13
+ * 那么每次开 mocode 都会白起一个 PowerShell。
14
+ *
15
+ * 降级:常驻路径任何一步失败都 kill 掉进程并回落一次性 spawn;连续 2 次失败则本会话内
16
+ * 彻底禁用常驻路径(失败模式通常是环境级的,重试没有意义,反而每次多付一次启动成本)。
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { filterEnv } from '../sandbox/index.js';
20
+ import { IdleGuard, envInt } from './idle-guard.js';
21
+ const OP_TIMEOUT_MS = 15000;
22
+ const DEFAULT_IDLE_MS = 3 * 60 * 1000;
23
+ const BROKEN_AFTER = 2;
24
+ const PS_CAPTURE_SCRIPT = [
25
+ '$ErrorActionPreference = "Stop"',
26
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8',
27
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
28
+ // ── 以下三行只在进程启动时执行一次,这是常驻化的全部收益来源 ──
29
+ 'Add-Type -TypeDefinition @"',
30
+ 'using System;',
31
+ 'using System.Runtime.InteropServices;',
32
+ 'public class MoCap {',
33
+ ' [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);',
34
+ ' [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();',
35
+ ' [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern bool EnumDisplaySettings(string dev, int mode, ref DEVMODE dm);',
36
+ ' [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]',
37
+ ' public struct DEVMODE {',
38
+ ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName;',
39
+ ' public short dmSpecVersion; public short dmDriverVersion; public short dmSize; public short dmDriverExtra;',
40
+ ' public int dmFields; public int dmPositionX; public int dmPositionY; public int dmDisplayOrientation;',
41
+ ' public int dmDisplayFixedOutput; public short dmColor; public short dmDuplex; public short dmYResolution;',
42
+ ' public short dmTTOption; public short dmCollate;',
43
+ ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName;',
44
+ ' public short dmLogPixels; public int dmBitsPerPel; public int dmPelsWidth; public int dmPelsHeight;',
45
+ ' public int dmDisplayFlags; public int dmDisplayFrequency; public int dmICMMethod; public int dmICMIntent;',
46
+ ' public int dmMediaType; public int dmDitherType; public int dmReserved1; public int dmReserved2;',
47
+ ' public int dmPanningWidth; public int dmPanningHeight;',
48
+ ' }',
49
+ '}',
50
+ // here-string 结束符必须在行首,不能有任何前导空格(PowerShell 解析要求)。
51
+ '"@ | Out-Null',
52
+ 'try { if (-not [MoCap]::SetProcessDpiAwarenessContext([IntPtr](-4))) { [void][MoCap]::SetProcessDPIAware() } } catch { }',
53
+ 'Add-Type -AssemblyName System.Windows.Forms',
54
+ 'Add-Type -AssemblyName System.Drawing',
55
+ 'while ($null -ne ($line = [Console]::In.ReadLine())) {',
56
+ ' $resp = @{ id = 0; ok = $false; detail = "" }',
57
+ ' try {',
58
+ ' $req = $line | ConvertFrom-Json',
59
+ ' $resp.id = $req.id',
60
+ ' $bounds = if ($req.target -eq "all") { [System.Windows.Forms.SystemInformation]::VirtualScreen } else { [System.Windows.Forms.Screen]::PrimaryScreen.Bounds }',
61
+ ' $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height)',
62
+ ' $graphics = [System.Drawing.Graphics]::FromImage($bitmap)',
63
+ ' try {',
64
+ ' $graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size)',
65
+ ' $bitmap.Save($req.path, [System.Drawing.Imaging.ImageFormat]::Png)',
66
+ ' } finally {',
67
+ ' $graphics.Dispose()',
68
+ ' $bitmap.Dispose()',
69
+ ' }',
70
+ ' $physW = $bounds.Width',
71
+ ' $physH = $bounds.Height',
72
+ ' try {',
73
+ ' $dm = New-Object MoCap+DEVMODE',
74
+ ' $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)',
75
+ ' if ([MoCap]::EnumDisplaySettings([System.Windows.Forms.Screen]::PrimaryScreen.DeviceName, -1, [ref]$dm)) {',
76
+ ' if ($dm.dmPelsWidth -gt 0) { $physW = $dm.dmPelsWidth }',
77
+ ' if ($dm.dmPelsHeight -gt 0) { $physH = $dm.dmPelsHeight }',
78
+ ' }',
79
+ ' } catch { }',
80
+ ' $resp.ok = $true',
81
+ ' $resp.shotW = $bounds.Width',
82
+ ' $resp.shotH = $bounds.Height',
83
+ ' $resp.physW = $physW',
84
+ ' $resp.physH = $physH',
85
+ ' $resp.originX = $bounds.X',
86
+ ' $resp.originY = $bounds.Y',
87
+ ' } catch {',
88
+ ' $resp.ok = $false',
89
+ ' $resp.detail = $_.Exception.Message',
90
+ ' }',
91
+ ' [Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress))',
92
+ ' [Console]::Out.Flush()',
93
+ '}',
94
+ ].join('\n');
95
+ class ResidentCaptureService {
96
+ child = null;
97
+ nextId = 1;
98
+ pending = new Map();
99
+ stdoutBuf = '';
100
+ lastError = '';
101
+ failures = 0;
102
+ idle;
103
+ constructor(idleMs) {
104
+ this.idle = new IdleGuard(idleMs, () => {
105
+ void this.dispose();
106
+ });
107
+ }
108
+ /** 常驻路径是否可用(进程未崩、未被熔断)。 */
109
+ get available() {
110
+ return this.failures < BROKEN_AFTER;
111
+ }
112
+ ensureProcess() {
113
+ if (this.child && this.child.exitCode === null && !this.child.killed)
114
+ return this.child;
115
+ this.child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', PS_CAPTURE_SCRIPT], { env: filterEnv(process.env), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
116
+ this.stdoutBuf = '';
117
+ this.lastError = '';
118
+ this.child.stdout?.on('data', (chunk) => this.onStdout(chunk.toString('utf8')));
119
+ this.child.stderr?.on('data', (chunk) => {
120
+ this.lastError = (this.lastError + chunk.toString('utf8')).slice(-2000);
121
+ });
122
+ this.child.on('exit', () => this.failAll(new Error(`capture service exited${this.lastError ? `: ${this.lastError.trim()}` : ''}`)));
123
+ this.child.on('error', (err) => this.failAll(err));
124
+ return this.child;
125
+ }
126
+ onStdout(text) {
127
+ this.stdoutBuf += text;
128
+ let idx;
129
+ while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
130
+ const line = this.stdoutBuf.slice(0, idx).trim();
131
+ this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
132
+ if (!line)
133
+ continue;
134
+ let msg;
135
+ try {
136
+ msg = JSON.parse(line);
137
+ }
138
+ catch {
139
+ continue; // 非协议行(Add-Type 警告等)
140
+ }
141
+ const id = typeof msg.id === 'number' ? msg.id : -1;
142
+ const op = this.pending.get(id);
143
+ if (!op)
144
+ continue;
145
+ this.pending.delete(id);
146
+ clearTimeout(op.timer);
147
+ op.resolve(msg);
148
+ }
149
+ }
150
+ failAll(error) {
151
+ for (const [, op] of this.pending) {
152
+ clearTimeout(op.timer);
153
+ op.reject(error);
154
+ }
155
+ this.pending.clear();
156
+ if (this.child) {
157
+ try {
158
+ this.child.kill();
159
+ }
160
+ catch {
161
+ /* 已退出 */
162
+ }
163
+ this.child = null;
164
+ }
165
+ }
166
+ num(msg, key) {
167
+ const v = msg[key];
168
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
169
+ }
170
+ async capture(outputPath, target, signal) {
171
+ if (!this.available)
172
+ throw new Error('resident capture service disabled after repeated failures');
173
+ const child = this.ensureProcess();
174
+ const id = this.nextId++;
175
+ try {
176
+ const resp = await new Promise((resolve, reject) => {
177
+ const timer = setTimeout(() => {
178
+ this.pending.delete(id);
179
+ this.failAll(new Error(`capture timed out after ${OP_TIMEOUT_MS}ms`));
180
+ reject(new Error(`capture timed out after ${OP_TIMEOUT_MS}ms`));
181
+ }, OP_TIMEOUT_MS);
182
+ this.pending.set(id, { resolve, reject, timer });
183
+ const onAbort = () => {
184
+ this.pending.delete(id);
185
+ clearTimeout(timer);
186
+ // abort 通常是用户 Ctrl+C,进程状态未知 —— 直接回收,下次重建。
187
+ this.failAll(new Error('aborted'));
188
+ reject(new Error('Screenshot capture was aborted.'));
189
+ };
190
+ if (signal) {
191
+ if (signal.aborted)
192
+ return onAbort();
193
+ signal.addEventListener('abort', onAbort, { once: true });
194
+ }
195
+ try {
196
+ child.stdin?.write(`${JSON.stringify({ id, op: 'capture', path: outputPath, target })}\n`, 'utf8');
197
+ }
198
+ catch (err) {
199
+ this.pending.delete(id);
200
+ clearTimeout(timer);
201
+ reject(err instanceof Error ? err : new Error(String(err)));
202
+ }
203
+ });
204
+ if (resp.ok !== true) {
205
+ throw new Error(String(resp.detail ?? 'unknown capture error'));
206
+ }
207
+ const shotW = this.num(resp, 'shotW');
208
+ const shotH = this.num(resp, 'shotH');
209
+ const physW = this.num(resp, 'physW');
210
+ const physH = this.num(resp, 'physH');
211
+ const geometry = shotW && shotH && physW && physH
212
+ ? {
213
+ shotW,
214
+ shotH,
215
+ physW,
216
+ physH,
217
+ originX: this.num(resp, 'originX') ?? 0,
218
+ originY: this.num(resp, 'originY') ?? 0,
219
+ }
220
+ : undefined;
221
+ this.failures = 0;
222
+ this.idle.touch();
223
+ return geometry ? { status: 'passed', detail: 'ok', geometry } : { status: 'passed', detail: 'ok' };
224
+ }
225
+ catch (error) {
226
+ // 任何失败都回收进程:不能把一个状态未知的常驻进程留到下一次调用。
227
+ this.failures += 1;
228
+ this.failAll(error instanceof Error ? error : new Error(String(error)));
229
+ throw error;
230
+ }
231
+ }
232
+ async dispose() {
233
+ this.idle.stop();
234
+ this.failAll(new Error('capture service disposed'));
235
+ }
236
+ }
237
+ let singleton = null;
238
+ /** 取常驻抓屏服务(惰性构造,此时还不 spawn;第一次 capture 才真正起进程)。 */
239
+ export function getCaptureService() {
240
+ if (process.platform !== 'win32')
241
+ return null;
242
+ singleton ??= new ResidentCaptureService(envInt('MOCODE_CU_CAPTURE_IDLE_MS', DEFAULT_IDLE_MS));
243
+ return singleton;
244
+ }
245
+ /** 回收常驻抓屏进程(/cu off / REPL 退出 / 空闲超时)。幂等。 */
246
+ export async function disposeCaptureService() {
247
+ if (singleton) {
248
+ await singleton.dispose();
249
+ singleton = null;
250
+ }
251
+ }
@@ -2,17 +2,57 @@
2
2
  * 跨平台桌面截图捕获:从 tools/builtins/screenshot.ts 抽出的可复用底层。
3
3
  * screenshot 工具与 computer 工具的闭环重截屏共用同一份平台逻辑,零重复。
4
4
  * 只做「抓屏 → 写 PNG 文件」,不做缩放/坐标/回灌(那是 screen-pipeline 的职责)。
5
+ *
6
+ * DPI 正确性(computer use 的点击精度命门):
7
+ * PowerShell 默认以 DPI UNAWARE 启动,此时 `Screen.PrimaryScreen.Bounds` 与 `CopyFromScreen`
8
+ * 返回的都是**逻辑像素**(150% 缩放下 2560×1440 的屏只有 1707×960),而 `SetCursorPos` 始终
9
+ * 按**物理像素**解释坐标 —— 两者差一个缩放比,表现为「所有点击都落在从左上角算起的 2/3 处,
10
+ * 越往右下偏得越多」。因此这里做两件事:
11
+ * 1. 抓屏前显式声明 PER_MONITOR_AWARE_V2(失败回落 SYSTEM_AWARE),让 Bounds 与抓到的图都是物理像素;
12
+ * 2. 额外用 EnumDisplaySettings 读取**不受 DPI 虚拟化影响**的物理分辨率并回传(geometry),
13
+ * 调用方据此校准坐标。即使 1 在老系统上失败,2 仍能兜底。
5
14
  */
6
15
  import { spawn } from 'node:child_process';
7
16
  import { filterEnv } from '../sandbox/index.js';
8
- function runCaptureProcess(program, args, env, signal) {
17
+ function parseGeometry(stdout) {
18
+ const line = stdout
19
+ .split('\n')
20
+ .map((s) => s.trim())
21
+ .filter(Boolean)
22
+ .pop();
23
+ if (!line || !line.startsWith('{'))
24
+ return undefined;
25
+ try {
26
+ const o = JSON.parse(line);
27
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
28
+ const shotW = num(o.shotW);
29
+ const shotH = num(o.shotH);
30
+ const physW = num(o.physW);
31
+ const physH = num(o.physH);
32
+ if (shotW === undefined || shotH === undefined || physW === undefined || physH === undefined)
33
+ return undefined;
34
+ return {
35
+ shotW,
36
+ shotH,
37
+ physW,
38
+ physH,
39
+ originX: num(o.originX) ?? 0,
40
+ originY: num(o.originY) ?? 0,
41
+ };
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ function runCaptureProcess(program, args, env, signal, collectStdout = false) {
9
48
  return new Promise((resolve) => {
10
49
  const child = spawn(program, args, {
11
50
  env,
12
51
  windowsHide: true,
13
- stdio: ['ignore', 'ignore', 'pipe'],
52
+ stdio: ['ignore', collectStdout ? 'pipe' : 'ignore', 'pipe'],
14
53
  });
15
54
  let stderr = '';
55
+ let stdout = '';
16
56
  let finished = false;
17
57
  const finish = (result) => {
18
58
  if (finished)
@@ -25,14 +65,21 @@ function runCaptureProcess(program, args, env, signal) {
25
65
  child.kill();
26
66
  finish({ status: 'aborted', detail: 'Screenshot capture was aborted.' });
27
67
  };
28
- child.stderr.on('data', (chunk) => {
68
+ child.stderr?.on('data', (chunk) => {
29
69
  stderr = (stderr + chunk.toString('utf8')).slice(-4000);
30
70
  });
71
+ if (collectStdout) {
72
+ child.stdout?.on('data', (chunk) => {
73
+ stdout = (stdout + chunk.toString('utf8')).slice(-4000);
74
+ });
75
+ }
31
76
  child.on('error', (error) => finish({ status: 'spawn_error', detail: error.message }));
32
- child.on('close', (code) => finish({
33
- status: code === 0 ? 'passed' : 'failed',
34
- detail: stderr.trim() || `Capture process exited with code ${code ?? 'null'}.`,
35
- }));
77
+ child.on('close', (code) => {
78
+ const status = code === 0 ? 'passed' : 'failed';
79
+ const detail = stderr.trim() || `Capture process exited with code ${code ?? 'null'}.`;
80
+ const geometry = collectStdout ? parseGeometry(stdout) : undefined;
81
+ finish(geometry ? { status, detail, geometry } : { status, detail });
82
+ });
36
83
  if (signal) {
37
84
  if (signal.aborted)
38
85
  onAbort();
@@ -42,6 +89,34 @@ function runCaptureProcess(program, args, env, signal) {
42
89
  });
43
90
  }
44
91
  const WINDOWS_CAPTURE_SCRIPT = [
92
+ '$ErrorActionPreference = "Stop"',
93
+ // ── DPI 感知:必须在读取任何屏幕信息之前声明 ──
94
+ 'try {',
95
+ ' Add-Type -TypeDefinition @"',
96
+ ' using System;',
97
+ ' using System.Runtime.InteropServices;',
98
+ ' public class MoCap {',
99
+ ' [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);',
100
+ ' [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();',
101
+ ' [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern bool EnumDisplaySettings(string dev, int mode, ref DEVMODE dm);',
102
+ ' [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]',
103
+ ' public struct DEVMODE {',
104
+ ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName;',
105
+ ' public short dmSpecVersion; public short dmDriverVersion; public short dmSize; public short dmDriverExtra;',
106
+ ' public int dmFields; public int dmPositionX; public int dmPositionY; public int dmDisplayOrientation;',
107
+ ' public int dmDisplayFixedOutput; public short dmColor; public short dmDuplex; public short dmYResolution;',
108
+ ' public short dmTTOption; public short dmCollate;',
109
+ ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName;',
110
+ ' public short dmLogPixels; public int dmBitsPerPel; public int dmPelsWidth; public int dmPelsHeight;',
111
+ ' public int dmDisplayFlags; public int dmDisplayFrequency; public int dmICMMethod; public int dmICMIntent;',
112
+ ' public int dmMediaType; public int dmDitherType; public int dmReserved1; public int dmReserved2;',
113
+ ' public int dmPanningWidth; public int dmPanningHeight;',
114
+ ' }',
115
+ ' }',
116
+ // 注意:here-string 的结束符 "@ 必须在行首,不能有任何前导空格,否则 PowerShell 解析失败。
117
+ '"@ | Out-Null',
118
+ ' if (-not [MoCap]::SetProcessDpiAwarenessContext([IntPtr](-4))) { [void][MoCap]::SetProcessDPIAware() }',
119
+ '} catch { }',
45
120
  'Add-Type -AssemblyName System.Windows.Forms',
46
121
  'Add-Type -AssemblyName System.Drawing',
47
122
  "$bounds = if ($env:MOCODE_SCREENSHOT_TARGET -eq 'all') { [System.Windows.Forms.SystemInformation]::VirtualScreen } else { [System.Windows.Forms.Screen]::PrimaryScreen.Bounds }",
@@ -54,6 +129,20 @@ const WINDOWS_CAPTURE_SCRIPT = [
54
129
  ' $graphics.Dispose()',
55
130
  ' $bitmap.Dispose()',
56
131
  '}',
132
+ // ── 物理分辨率兜底:EnumDisplaySettings 不受 DPI 虚拟化影响 ──
133
+ '$physW = $bounds.Width',
134
+ '$physH = $bounds.Height',
135
+ 'try {',
136
+ ' $dm = New-Object MoCap+DEVMODE',
137
+ ' $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)',
138
+ ' if ([MoCap]::EnumDisplaySettings([System.Windows.Forms.Screen]::PrimaryScreen.DeviceName, -1, [ref]$dm)) {',
139
+ ' if ($dm.dmPelsWidth -gt 0) { $physW = $dm.dmPelsWidth }',
140
+ ' if ($dm.dmPelsHeight -gt 0) { $physH = $dm.dmPelsHeight }',
141
+ ' }',
142
+ '} catch { }',
143
+ 'try {',
144
+ ' [Console]::Out.WriteLine((@{ shotW = $bounds.Width; shotH = $bounds.Height; physW = $physW; physH = $physH; originX = $bounds.X; originY = $bounds.Y } | ConvertTo-Json -Compress))',
145
+ '} catch { }',
57
146
  ].join('\n');
58
147
  /** 抓屏并写入 outputPath。target=primary 主屏,all 整个虚拟桌面。 */
59
148
  export async function captureDesktop(outputPath, target, signal) {
@@ -63,7 +152,18 @@ export async function captureDesktop(outputPath, target, signal) {
63
152
  MOCODE_SCREENSHOT_TARGET: target,
64
153
  };
65
154
  if (process.platform === 'win32') {
66
- return runCaptureProcess('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_CAPTURE_SCRIPT], env, signal);
155
+ // 优先走常驻进程(Add-Type / GDI+ 初始化只付一次,实测能省掉 710ms 里的大头)。
156
+ // 常驻路径任何异常都回落到一次性 spawn —— 这是原有行为,保证不会有功能倒退。
157
+ try {
158
+ const { getCaptureService } = await import('./screen-capture-service.js');
159
+ const svc = getCaptureService();
160
+ if (svc?.available)
161
+ return await svc.capture(outputPath, target, signal);
162
+ }
163
+ catch {
164
+ // 常驻路径失败 → 回落到一次性 spawn(下面继续)。
165
+ }
166
+ return runCaptureProcess('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_CAPTURE_SCRIPT], env, signal, true);
67
167
  }
68
168
  if (process.platform === 'darwin') {
69
169
  const args = ['-x', ...(target === 'primary' ? ['-m'] : []), outputPath];
@@ -10,6 +10,22 @@
10
10
  */
11
11
  import { inflateSync, deflateSync } from 'node:zlib';
12
12
  const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13
+ /**
14
+ * 只读 IHDR 拿宽高,不做 inflate / 反滤镜。
15
+ * 抓屏图 4K 时全量 decodePng 要吃满 14MB 像素缓冲 + 逐字节反滤镜循环,而宽高在文件头 24 字节里就有。
16
+ * 非 Windows 平台(macOS screencapture / Linux)拿不到 CaptureGeometry,靠这条路径避免整图解开销。
17
+ */
18
+ export function readPngSize(buf) {
19
+ if (buf.length < 24 || !buf.subarray(0, 8).equals(PNG_SIG))
20
+ return null;
21
+ if (buf.toString('ascii', 12, 16) !== 'IHDR')
22
+ return null;
23
+ const width = buf.readUInt32BE(16);
24
+ const height = buf.readUInt32BE(20);
25
+ if (!width || !height)
26
+ return null;
27
+ return { width, height };
28
+ }
13
29
  function paeth(a, b, c) {
14
30
  const p = a + b - c;
15
31
  const pa = Math.abs(p - a);
@@ -83,7 +99,8 @@ export function decodePng(buf) {
83
99
  case 4:
84
100
  v = cur + paeth(a, b, c);
85
101
  break;
86
- default: throw new Error(`bad PNG filter ${f}`);
102
+ default:
103
+ throw new Error(`bad PNG filter ${f}`);
87
104
  }
88
105
  out[rowOut + x] = v & 0xff;
89
106
  }
@@ -119,8 +136,14 @@ function chunk(type, data) {
119
136
  crc.writeUInt32BE(crc32(body), 0);
120
137
  return Buffer.concat([len, body, crc]);
121
138
  }
122
- /** 编码 RGBA → PNG(colorType 6,filter 0)。 */
123
- export function encodePng(img) {
139
+ /**
140
+ * 编码 RGBA → PNG(colorType 6,filter 0)。
141
+ *
142
+ * `level` 默认 1(而非 zlib 默认的 6):截图是高频噪声图,压缩率对等级极不敏感
143
+ * (实测体积差 <10%),但 level 6 在 4K 图上要多花数倍 CPU。图片 token 按**像素**计费、
144
+ * 与字节数无关,所以「压得小」在 LLM 侧没有任何收益 —— 这里只买速度。
145
+ */
146
+ export function encodePng(img, opts) {
124
147
  const { width, height, data } = img;
125
148
  const stride = width * 4;
126
149
  const raw = Buffer.alloc((stride + 1) * height);
@@ -139,7 +162,7 @@ export function encodePng(img) {
139
162
  return Buffer.concat([
140
163
  PNG_SIG,
141
164
  chunk('IHDR', ihdr),
142
- chunk('IDAT', deflateSync(raw)),
165
+ chunk('IDAT', deflateSync(raw, { level: opts?.level ?? 1 })),
143
166
  chunk('IEND', Buffer.alloc(0)),
144
167
  ]);
145
168
  }
@@ -194,6 +217,52 @@ export function crop(img, x, y, w, h) {
194
217
  }
195
218
  return { width: cw, height: ch, data: out };
196
219
  }
220
+ // ── 帧间差分 ───────────────────────────────────────────────────────────
221
+ /**
222
+ * 把任意尺寸图降采样成 grid×grid 的灰度块均值(box average,非最近邻 —— 4K → 64 时
223
+ * 最近邻会严重走样,块均值才对「有没有变」这个问题敏感)。
224
+ */
225
+ function grayGrid(img, grid) {
226
+ const out = new Float64Array(grid * grid);
227
+ const { width, height, data } = img;
228
+ for (let gy = 0; gy < grid; gy++) {
229
+ const y0 = Math.floor((gy * height) / grid);
230
+ const y1 = Math.max(y0 + 1, Math.floor(((gy + 1) * height) / grid));
231
+ for (let gx = 0; gx < grid; gx++) {
232
+ const x0 = Math.floor((gx * width) / grid);
233
+ const x1 = Math.max(x0 + 1, Math.floor(((gx + 1) * width) / grid));
234
+ let sum = 0;
235
+ let n = 0;
236
+ for (let y = y0; y < y1; y++) {
237
+ const row = y * width;
238
+ for (let x = x0; x < x1; x++) {
239
+ const i = (row + x) * 4;
240
+ // Rec.601 亮度;alpha 视为不透明(截图不透明)。
241
+ sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
242
+ n++;
243
+ }
244
+ }
245
+ out[gy * grid + gx] = n ? sum / n : 0;
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+ /**
251
+ * 两帧的差异比例(0..1):各网格块灰度差的平均值 / 255。
252
+ * 典型值:完全静止 ≈ 0;光标移动或抗锯齿抖动 < 0.005;局部区域重绘 0.01-0.1;整屏切换 > 0.2。
253
+ * 输入必须同尺寸(调用方负责保证),否则抛错 —— 尺寸不同意味着显示器配置变了,不该静默比。
254
+ */
255
+ export function diffRatio(a, b, grid = 64) {
256
+ if (a.width !== b.width || a.height !== b.height) {
257
+ throw new Error(`diffRatio size mismatch: ${a.width}×${a.height} vs ${b.width}×${b.height}`);
258
+ }
259
+ const ga = grayGrid(a, grid);
260
+ const gb = grayGrid(b, grid);
261
+ let sum = 0;
262
+ for (let i = 0; i < ga.length; i++)
263
+ sum += Math.abs(ga[i] - gb[i]);
264
+ return sum / ga.length / 255;
265
+ }
197
266
  // ── norm1000 坐标换算 ──────────────────────────────────────────────────
198
267
  /** 归一化坐标(0-1000)→ 物理像素。scale = 喂模型的图相对物理的缩放比。 */
199
268
  export function normToPhysical(nx, ny, physW, physH) {
@@ -11,6 +11,19 @@ export async function shutdownRuntime() {
11
11
  return;
12
12
  asyncDone = true;
13
13
  stopAllDevServersSync();
14
+ try {
15
+ // 动态 import:未用过 computer 工具时不该把这两个常驻进程拉进来。
16
+ // 常驻 PowerShell 进程不随主进程自动退出,必须显式回收(否则每次会话留一组孤儿)。
17
+ const [{ disposeInputInjector }, { disposeCaptureService }] = await Promise.all([
18
+ import('./input-injector.js'),
19
+ import('./screen-capture-service.js'),
20
+ ]);
21
+ await disposeInputInjector();
22
+ await disposeCaptureService();
23
+ }
24
+ catch {
25
+ // 从未启动过:忽略。
26
+ }
14
27
  try {
15
28
  // 动态 import:未用过 browser 工具时不该把 playwright 拉进进程。
16
29
  const { closeAllBrowsers } = await import('./browser-manager.js');