🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

截图工具箱 JHboy203/dsh-screenshot

DeepSeek Harness
JHboy2033 天前有提交已提供中文文档扫描:含敏感能力

DSH 截图插件:QQ 同款框选、标注、离线 OCR 提取文字、贴图、录屏、长截图。仅支持 Windows(PowerShell/.NET)。开发版(非最终版),支持 DSH 0.2.1–0.2.x。

31.3/100
综合分
2
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/11/3 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add JHboy203/dsh-screenshot
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
dsh 原生插件 · vision
装得上吗
本站尚未做安装检查
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 3 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 65 处,展示前 43 条)
  • 读取本地文件
    index.js:42
    if (existsSync(candidate)) return candidate
  • 读取本地文件
    index.js:72
    const exists = candidate.file === null ? null : existsSync(candidate.file)
  • 写入剪贴板
    client.js:1429
    await navigator.clipboard.writeText(body.value)
  • 写入剪贴板
    client.js:1456
    await navigator.clipboard.write([new window.ClipboardItem({ 'image/png': blob })])
  • 发起网络请求
    client.js:1735
    void fetch(url, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'text/plain' }, body: body })
  • 写入剪贴板
    client.js:1844
    await navigator.clipboard.write([new window.ClipboardItem({ 'image/png': blob })])
  • 发起网络请求
    client.js:2260
    const blob = await (await fetch(got.dataUrl)).blob()
  • 发起网络请求
    client.js:3200
    const res = await fetch(AI_BRIDGE.url + '?t=' + Date.now(), { cache: 'no-store' })
  • 发起网络请求
    client.js:3249
    void fetch(AI_BRIDGE.post, {
  • 发起网络请求
    client.js:3476
    const r = await fetch('http://127.0.0.1:8934/.tmp-vision/_ai/' + rel + '?t=' + Date.now(), { cache: 'no-store' })
  • 发起网络请求
    client.js:3570
    const blob = await (await fetch(item.dataUrl)).blob()
  • 写入剪贴板
    client.js:3725
    await navigator.clipboard.writeText(body.value)
  • 执行外部命令来自配置/变量
    lib/shot.mjs:123
    child = spawn(bin, args, {
  • 读取本地文件
    lib/shot.mjs:186
    if (c === 'powershell.exe' || existsSync(c)) return c
  • 读取本地文件
    lib/shot.mjs:199
    if (c === 'pwsh.exe' || existsSync(c)) return c
  • 读取本地文件
    lib/shot.mjs:214
    if (c && existsSync(c) && !/mise/i.test(c)) return c
  • 读取本地文件
    lib/shot.mjs:227
    if (existsSync(c)) return c
  • 写入本地文件
    lib/shot.mjs:370
    await mkdir(SHOTS_DIR, { recursive: true })
  • 读取本地文件
    lib/shot.mjs:393
    const names = (await readdir(SHOTS_DIR)).filter((n) => n.endsWith('.png') || n.endsWith('.jpg'))
  • 读取本地文件
    lib/shot.mjs:397
    const s = await stat(path.join(SHOTS_DIR, n))
  • 删除本地文件
    lib/shot.mjs:401
    for (const [p] of withTime.slice(0, withTime.length - MAX_KEEP_SHOTS)) await rm(p, { force: true })
  • 写入本地文件
    lib/shot.mjs:1027
    await mkdir(CACHE_DIR, { recursive: true })
  • 删除本地文件
    lib/shot.mjs:1044
    await rm(out, { force: true })
  • 执行外部命令来自配置/变量
    lib/shot.mjs:1299
    const child = spawn(ffmpeg, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] })
  • 写入本地文件
    lib/shot.mjs:1347
    await writeFile(argsFile, JSON.stringify(args), 'utf8')
  • 删除本地文件
    lib/shot.mjs:1350
    await rm(f, { force: true })
  • 执行外部命令来自配置/变量
    lib/shot.mjs:1356
    child = spawn(
  • 删除本地文件
    lib/shot.mjs:1368
    await rm(pidFile, { force: true })
  • 写入本地文件
    lib/shot.mjs:1385
    await writeFile(quitFile, 'q', 'utf8')
  • 删除本地文件
    lib/shot.mjs:1390
    await rm(f, { force: true })
  • 执行外部命令来自配置/变量
    lib/shot.mjs:1422
    const child = spawn(
  • 写入本地文件
    lib/shot.mjs:1530
    await mkdir(REC_DIR, { recursive: true })
  • 写入本地文件
    lib/shot.mjs:1752
    await mkdir(CACHE_DIR, { recursive: true })
  • 执行外部命令来自配置/变量
    lib/devtools.mjs:44
    execFile(
  • 执行外部命令来自配置/变量
    lib/devtools.mjs:96
    child = spawn(process.execPath, [script, ...(args || [])], {
  • 发起网络请求
    lib/devtools.mjs:309
    const res = await fetch(url, { signal: ctrl.signal, cache: 'no-store' })
  • 执行外部命令来自配置/变量
    lib/devtools.mjs:447
    const child = spawn(process.execPath, [path.join(WORKSPACE, '.tools', 'devserver.mjs'), String(BRIDGE_STATIC_PORT), WORKSPACE], {
  • 执行外部命令来自配置/变量
    lib/hotkey.mjs:156
    child = spawn(
  • 执行外部命令固定命令
    lib/land.mjs:34
    const stdout = execFileSync('git', args, {
  • 执行外部命令固定命令
    lib/overlay.mjs:147
    child = spawn('powershell.exe', args, { cwd: path.dirname(PS_DIR), windowsHide: true, stdio: ['ignore', 'ignore', errFd] })
  • 执行外部命令来自配置/变量
    lib/preflight.mjs:100
    child = spawn(process.execPath, [scriptPath, ...extraArgs], {
  • 执行外部命令来自配置/变量
    lib/probes.mjs:339
    child = spawn(process.execPath, [script, ...(def.args ?? []), ...extra], {
  • 执行外部命令来自配置/变量
    lib/shooter.mjs:185
    child = spawn(
扫描于 2026/11/2(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/dsh-typert-protocol
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

README

由 DeepSeek 最新模型翻译生成
dsh-screenshot · DSH 截图插件 状态:开发版(0.2.x),非最终版本。 这是「边用边改」放出来的版本:功能与界面会不定期更新。 抓屏是 Windows 专有实现,换宿主大版本前请先跑 shot_health。 0.2.0 起:贴图窗能缩放、有右键动作菜单、能把图送进输入框、Ctrl+拖动可拖进任何应用;长截图的拼接器补了「窄重叠只有逐像素精确才算证据」与「精确候选先比深浅、并列才比重叠宽度」两条规则。 同一个版本里,纹理回放探针学会了一件事:先证明这张页面判得了,再判拼接器(见「已知限制」里那条)。 Status: development build (0.2.x) — not a final release. Released as-is from daily use; features and UI change from time to time. Screen capture is Windows-only; run shot_health after any host upgrade. 截图演示:框选区域 + 尺寸角标 + 工具条 上图为演示图:工具条与选区样式取自覆盖层的真实渲染(dev/ovl-skin-shot.mjs),背景用的是本插件配套面板的截图,不含任何本机桌面、托盘或会话信息。
展开完整文档(README 剩余 1 节)
dsh-screenshot · DSH 截图插件

状态:开发版(0.2.x),非最终版本。 这是「边用边改」放出来的版本:功能与界面会不定期更新。
抓屏是 Windows 专有实现,换宿主大版本前请先跑 shot_health。

0.2.0 起:贴图窗能缩放、有右键动作菜单、能把图送进输入框、Ctrl+拖动可拖进任何应用;长截图的拼接器补了「窄重叠只有逐像素精确才算证据」与「精确候选先比深浅、并列才比重叠宽度」两条规则。
同一个版本里,纹理回放探针学会了一件事:先证明这张页面判得了,再判拼接器(见「已知限制」里那条)。

Status: development build (0.2.x) — not a final release. Released as-is from daily use; features and UI change from time to time. Screen capture is Windows-only; run shot_health after any host upgrade.

截图演示:框选区域 + 尺寸角标 + 工具条

上图为演示图:工具条与选区样式取自覆盖层的真实渲染(dev/ovl-skin-shot.mjs),背景用的是本插件配套面板的截图,不含任何本机桌面、托盘或会话信息。

QQ 同款截图,按钮挂在对话输入框「语音」键旁边(conversation.input.left 槽,order -90)。

能力

| 菜单项 | 说明 |
| ✂ 截图 | 一律在原生覆盖层里框选(独立进程的全屏真窗口,能盖住别的应用)→ 标注 → 贴进输入框 + 复制到剪贴板 |
| ⏺ 录屏 | ffmpeg gdigrab 录制(H.264 + faststart)。默认先框一块再录(见下),屏幕上有一圈红框标出边界,右上角计时胶囊点「停止」收尾。菜单里可开录音(R41,默认关) |
| Aa 提取文字 | 框选后离线 OCR(Windows 自带引擎,不出本机),出可复制/可贴进输入框的文本面板 |
| 译 屏幕翻译 | OCR 之后把文字(带翻译指令前缀)贴进输入框,交给 DSH 自己的模型翻 |
| ⇕ 长截图 | 框选区域 → 宿主逐格滚轮、滚一格抓一张 → 重叠区对齐拼接(不重叠就报错,不猜) → 长图回到标注界面 |
| ☐ 截图时隐藏 DSH 界面 | 原生覆盖层在冻结桌面之前把 DSH 窗口藏起来(QQ 同款),框完/取消后还原并回到前台;默认关,在「更多设置」里勾 |

R33 起删掉了「范围 / 选一个窗口来截 / 自己框范围 / 抓窗方式」四项,以及在 DSH 窗口里渲染桌面截图的那套(canvas + DOM 覆盖层)。它只能盖住 DSH 自己的窗口、在 125% 缩放下只能框到屏幕左上角一块 —— 留着它等于"看起来能用,框不到你要的东西"。现在唯一入口是原生覆盖层;不可用时明说("重启 DSH 一次"),不再偷偷换成降级界面。老的 range/method/pickHwnd/pickTitle/nativeOverlay 设置由 prefsVer 迁移一次性清掉。

标注工具:矩形 / 椭圆 / 箭头 / 画笔 / 文字 / 马赛克,八色 + 粗细滑杆,撤销重做(游标模型), Enter 完成、Esc 取消、Ctrl+Z/Y、Ctrl+C、Ctrl+S、Ctrl+A 全选、双击全屏。
另有贴图(框选后按 P,或点工具条上的 📌):截下来的图变成一块置顶的小窗钉在屏幕上 ——滚轮或右下角手柄缩放(宽高比不变,左上角显示 宽 x 高 百分比)、左键拖动移动、双击 / Esc 关闭, 右键是动作菜单:送进输入框 / 复制图像 / 保存为文件 / 放大 / 缩小 / 原始大小 / 关闭。
Ctrl + 左键拖动可以把这张图直接拖进 DSH 的对话框(拖的是文件,任何接受文件拖放的应用都行;拖动期间助手不接新的截图请求,这是 OLE 拖放本身的模态循环)。
「送进输入框」把这张图当附件直接贴进 DSH 的对话框,并把 DSH 提到前台(下一步就是在那儿打字;顺带避开"贴图盖满整个 DSH → 窗口被判成被遮挡 → 心跳被节流")。贴图窗比产生它的那次截图活得久,所以走的是「助手落盘 → 宿主心跳取件 → 界面 attach」这条接力;老宿主不取件时贴图会明说「界面没来取」。
长截图 / 录屏抽帧里那两颗贴图按钮走的是同一个原生贴图窗(R67c 删掉了页面里那套 DOM 贴图),太大的图(比如 30000px 的长图)会先等比缩到屏幕的 80% 再造窗口。

工具条(R66 换的皮肤):一张圆角卡片,九个矢量图标 + 中文小标题(矩/箭/笔/马赛克/序号/文字/椭圆/色/撤销),激活项是实心底色药丸,鼠标悬停有浅色底;快捷键提示行搬进卡片里(以前是工具条下面一行浮空白字);分组用细分隔线分成「工具 | 颜色 | 撤销」。选区贴到屏幕底部时卡片自动翻到选区上方,不会盖住要截的内容。
尺寸角标和放大镜用的是同一套卡片语言(圆角 + 投影 + 描边 + 读数在卡内)。
工具条是画出来的(GDI+,无位图资产),所以任意 DPI 下都清晰、插件仍是一个纯文本 .ps1。

热键:Ctrl+Alt+A 截图,Ctrl+Alt+O 提取文字。

为什么抓屏一定在宿主半边

渲染进程拿不到屏幕:官方主会话没有 setDisplayMediaRequestHandler,权限处理器只放行麦克风(/lib/main.js 逐个文件取证过),getDisplayMedia() 拿不到任何源。
所以宿主半边用 PowerShell/.NET:

- System.Drawing.Graphics.CopyFromScreen → 整屏(自动覆盖多显示器虚拟屏)
- PrintWindow(PW_RENDERFULLCONTENT) → 单窗口,被别的窗口挡住也拍得到
- Windows.Media.Ocr → 离线 OCR
- ffmpeg -f gdigrab → 录屏
- 合成滚轮 mouse_event(WHEEL) + Python/PIL 重叠对齐 → 长截图

三个必须记住的坑

1. DPI:不先 SetProcessDPIAware(),进程拿到的是虚拟化坐标 —— 本机 125% 缩放下同一窗口会是 1295×823 而不是 1619×1029(截图发糊)。
2. 低完整性进程做不了窗口操作:DSH 的工具沙箱是 Low 完整性,ShowWindow / SetForegroundWindow / PrintWindow 会被 UIPI 静默拒绝(不报错、窗口纹丝不动)。
所以「隐藏窗口」「窗口自绘」只有在插件宿主进程里起的 pwsh 才有效,在工具 shell 里验证必然失败 —— 这不是 bug。
3. 含中文的 .ps1 必须带 UTF-8 BOM:Windows PowerShell 5.1 对无 BOM 的 .ps1 按 GBK 解码,中文注释会被撕碎,甚至吞掉后面的代码行(症状是莫名其妙的 LockBits/参数类型错误)。

宿主工具(12 个)

| 工具 | 干什么 |
| screenshot / screenshot_windows | AI 自己抓图(不弹覆盖层、不抢前台、不动窗口),返回文件路径 |
| shot_health | 换会话第一条命令:按「文件时间 vs 进程/实例时间」回答要不要重启 |
| shot_reap | 录制残留体检(只读)+ 可选清理(R52):内存里在不在录、rec/current.json 记的是谁、pid 还活着吗、rec/ 堆了几个 mp4;apply:true 才按三步判据清掉(活着 → 仍是 ffmpeg.exe → taskkill) |
| shot_owner | 「这一点归谁」(R57):对一串坐标(或一个矩形:中心+四角内缩 2px)做 WindowFromPoint,报 hwnd / 标题 / 类名 / 进程。与 shot_pixels 配对:颜色说这里是我的、它说这里是别人 → 中间有屏幕抓得到但命中测试看不到的浮层(click-through / layered) |
| shot_gate | 一条命令跑完闸门:默认「静态契约 + 覆盖层端到端」;full:true = 交付前那一套(再加一拍成本 + 探针聚合,约 2.5–5 分钟);host:true 再加宿主全量(≈10 分钟,不在 full 里) |
| shot_bridge | 驱动正在运行的客户端(state / eval / click / menu / dome / longshot) |
| shot_displays | 显示器 / DPI / 负坐标体检 + 越界裁剪实测 |
| shot_pixels | 像素事实(R34):rect 现场抓或 path 读图 → 黑图判据(meanLuma/blackPct)、某颜色的像素数与外接框、与另一张图的差异占比(可限定 region 只看内部) |
| shot_probe | 一条命令跑任意 dev 探针:probe:"<名字>" 单跑;probe:"all" 全跑聚合(重活 hosttest 默认不跑、给了 includeHeavy 才跑且排最后);probe:"list" 只列清单(不 spawn)。判据:超时 / 零检查(退出码 0 但一行 PASS/FAIL 都没打印)/ 非零退出都如实算失败;退出码 2 = env(环境挡住,见下) |
| shot_preflight | 改完宿主半边、重启前用磁盘上的代码验一遍(R40):默认「探针库自检 + full 闸门」;可点名 probe-selftest / gate-full / all-probes / hosttest。判据按结论行翻,没有结论行 = 失败;每项独立子进程(脚本会 process.exit,不能 import) |
| shot_land | 收尾一步走(R40):自检 → git add -A → 提交(中文走 stdin)→ push → 回读 sha 确认。默认 dryRun 只看计划、自检红不提交、空标题拒绝。绕开「命令行 git 被沙箱拒 / 推送辅助漏推 / 中文编码」三个坑 |

这两个是换会话时的交接面:验证这个插件最费劲的不是写代码,是「怎么证明屏幕上真的发生了什么」。前者把像素判据变成一次调用,后者把「必须在宿主里跑、各有各的参数」的门槛固化成一张表。

探针清单自动发现 + 全跑聚合(R35)、清单说清参数 + 并行实测(R36)

- 自动发现:dev/-probe.mjs 会被自动收进清单(不用改 lib/probes.mjs)。
但它默认不进 all —— 实测 dev/auto-wheel-probe.mjs / dev/postwheel-probe.mjs 都需要命令行参数,无参跑只会得到无意义结果。发现 ≠ 知道怎么跑,所以:probe:"list" 里有、点名能跑、 includeAuto:true 才进 all。已被显式表覆盖的脚本不会再登记(否则同一脚本会被跑两遍)。
- all 的默认集合 = 显式表里非重活的 5 个(recframe / wheel-target / hide-self / wheel-app-qt / wheel-app-java),一轮 约 3–5 分钟(机器冷/忙时更久:夹具的 PowerShell 和覆盖层助手冷启动都可能 >20 s)。
- env 是第三态:夹具被别的窗口盖住、窗口上不了屏 → 探针退 2,shot_probe 报 reason:"env",聚合里单独标 ENV 并给出处置话术。它既不算通过,也不算产品回归 ——混成一个红会把人往错的方向查(本轮实测:QQ / DSH 盖住夹具时 wheel-target 4 项全红,全是假红)。
- env 在 all 里自动重试一次(R45):两轮 shot_probe({probe:'all'}) 分别挂在不同探针上(一次 wheel-target、一次 recframe),单跑都 6/6 —— 探针自己用退出码 2 说「环境挡住」,那不是回归。所以全跑聚合里遇到 env 会等 2.5 s 重试一次,结果行标注 (env 重试后通过) / (env 重试后仍 env),probes[].attempts 如实记 2。
真红(exit 1 / 有 FAIL 行)永不重试,单跑(点名)也不重试 —— 重试真红会把回归藏起来,单跑是操作者要看这一次的真实结果。
- recframe 取景不再依赖桌面(R48 → R53):R35 写死右下角,被 QQ 盖住;R48 改成「网格搜索被窗口盖得最少的位置 + 350 ms 静态度」;R49 再加「压在活窗口上且只改一部分就记 env」—— 三代都是缓解。2026-10-24 实测:DSH 1619×1029@150,0 + 设置 1920×1020 + 资源管理器 1268×759 之后,唯一的静态候选只剩 (8,968) 的 120×90;同一份代码两次跑出 12/12 与 11/12,full 闸门长期 3/4(红的是桌面,不是框)。现在把框压在自己画的棋盘窗(dev/recframe-backdrop.ps1:400×300、30 px 格、颜色与格子大小都已知)上,内部判据变成「那个屏幕坐标上还是不是预期颜色」—— 与时间、桌面状态、别人的动画都无关;env 三态那套随之删掉(背景不再会动,对不上就是真红)。探针 12 → 16 项。
⚠️ 同一条探针里还有一枚同类的假红:canary 的「画的是物理像素」断言原先把整张裁剪里的柠檬绿像素都算进外接框,而裁剪右半边压在 DSH 窗口上(DSH 从 x=150 起,canary 240 宽),DSH 界面偶发的绿点把 bbox 从 240×120 撑成 240×126 → 读成「窗口被缩放 0.8」。现在只认成片的绿(连续 ≥60 px 的行),孤立的绿点不算证据;真的被缩放的窗口行宽是 192 px,照样抓得住。
- 参数写进头部(R36):探针认命令行参数,就在头部加一行 // args: -Hwnd <句柄>(必填) → probe:"list" 把「参数」与「依赖」(pyqt / JDK)分开列出,all 的「未跑」原因也带具体参数。没有这一行,清单只能告诉你它存在,不告诉你它怎么跑。
- all 保持串行 —— 这是量过的结论,不是没做优化(R36):dev/probe-concurrency.mjs 在同一批次里串行/并行各跑一遍:并行快 36%(101s → 65s),但 wheel-app-qt 从 5 PASS 掉到 2 PASS。原因是两个夹具在抢同一套真实光标与前台,彼此成了对方的「环境」。要真正提速,得先让探针不再共享这些资源(每夹具自己的注入通道 / 独立虚拟桌面),那是另一个量级。

自检

交付前就一条命令(R37):

shot_gate({full:true}) # 静态契约 + 覆盖层端到端 + 一拍成本 + 探针聚合(≈2.5–5 分钟)
shot_gate({full:true, host:true}) # 再加宿主全量(≈10 分钟)

改完宿主半边、还没重启 DSH 时(工具表是启动期读的),用同一个入口的宿主版:shot_preflight({})(R40)—— 默认「探针库自检 + full 闸门」,也可以点名 {what:'probe-selftest'}(≈1.5 秒)/ {what:'hosttest'}(≈5–10 分钟)。

收尾一步走:shot_land({message:'Rxx: …'}) 先看计划(默认 dryRun),确认后 {dryRun:false} 才真的提交推送;自检红了它不会提交。

node .tools/dsh-shot-smoke.mjs # 611 项:文件/清单/两端契约/覆盖层/接线/PowerShell 静态体检/宿主可加载
node .tools/dsh-shot-smoke.mjs --live # 再加真抓屏 + 真 OCR(沙箱里会 SKIP,见坑 2)

宿主半边 + 真窗口的回归(必须在宿主进程里跑,沙箱里注入不了消息):

host_selfcheck({plugin:'dsh-screenshot'}) # 全量,77 项 / FAIL 必须 0
host_selfcheck({plugin:'dsh-screenshot', script:'dev/all-probes-hostrun.mjs'}) # 真 runner 跑一遍聚合(改完 lib/probes.mjs、重启前用)
host_selfcheck({plugin:'dsh-screenshot', script:'dev/wheel-target-probe.mjs'}) # 6 项:投递滚轮投给谁
host_selfcheck({plugin:'dsh-screenshot', script:'dev/hide-self-probe.mjs'}) # 11 项:真藏一次 + 还原 + 几何不变

⚠️ 「猜出来的等待」是本项目探针最大的假红来源(R35 一次收口三条):覆盖层助手冷启动要 ~10 s(实测 readyMs:10205)、录屏框的 canary 要等窗口自己写 Info、滚轮夹具要抢到前台才不被 DSH 盖住。三处都改成等测得的就绪信号(overlayStatus().ready / waitForFile(info) / AttachThreadInput 后再 SetForegroundWindow),固定 sleep 一律不可靠。

hide-self-probe 会真的把 DSH 窗口藏起来约 1.5 秒(覆盖层正盖着屏幕,所以看不见),用来验「藏」与「还原」两半;它同时证明还原不会改动窗口的摆放(用 SW_RESTORE 会悄悄把最大化窗口变回普通大小)。

host_selfcheck({plugin:'dsh-screenshot', script:'dev/recframe-probe.mjs'}) # 22 项:录屏框在屏幕上的像素级证据(R53 起压在已知棋盘窗上;R55 起自己找干净子区 + 直接问「边框那一点归谁」;R65 起分得清「被别的窗口盖住」(env)和「我根本没画」(回归),并有一条故意遮挡的臂在验这个区分)
host_selfcheck({plugin:'dsh-screenshot', script:'dev/record-audio-probe.mjs'}) # 45 项:录屏录音(设备枚举 / 真带 AAC 音轨 / 坏设备照录 / 自动选设备档位 / 坐标参数类型守卫 / auto 与挑选结果一致 / 并发 start 串行 + service 回执 already / 孤儿录制清理 / recspawn 退路与它的孤儿保护),约 55 秒
host_selfcheck({plugin:'dsh-screenshot', script:'dev/record-integrity-probe.mjs'}) # 9 项:录进文件的那块画面(真录 2.5s → 抽帧 → 视频里 0 个红框像素、亮度差 ≤8%、两色都在),约 13 秒
host_selfcheck({plugin:'dsh-screenshot', script:'dev/longshot-probe.mjs'}) # 81 项:长截图成品(编号亮度色带夹具 → auto / manual / 滚动条可见 / 3840px 长页面 / 区域里有动画 五条真实臂 → 色带顺序 / 每条恰好一次 / 每条高度 / 内容结束位置;外加「两帧不重叠必须报错」的反向臂),166 秒
host_selfcheck({plugin:'dsh-screenshot', script:'dev/longshot-lab.mjs',
args:['measure','10','8']}) # 实验台:只量夹具几何(行高 / 带高 / 总高 / 一格位移 / 一帧一格要几帧),7 秒
host_selfcheck({plugin:'dsh-screenshot', script:'dev/longshot-lab.mjs',
args:['run','10','8','0','20']}) # 实验台:3840px 长页面跑一次 auto,回帧数 / 停因 / 成品高 / 色带 run / 逐拍轨迹
host_selfcheck({plugin:'dsh-screenshot', script:'dev/longshot-lab.mjs',
args:['run','40','1','90','14']}) # 实验台:区域里有东西每 90ms 重画时跑一次 auto(R61 那个场景)
host_selfcheck({plugin:'dsh-screenshot', script:'dev/_diag-longshot.mjs'}) # 排障用(不是探针、dev/_ 不入库):跑一次会话并留下所有帧,逐对打印「真实位移 vs 拼接器报的偏移」
host_selfcheck({plugin:'dsh-screenshot', script:'dev/probe-selftest.mjs'}) # 43 项:像素库 / 探针库 / 清单元数据自检(1.5 秒,确定性)
host_selfcheck({plugin:'dsh-screenshot', script:'dev/ovl-selftest.mjs'}) # 58 项:覆盖层端到端(真实框选 → 标注 → 渲染 → 剪贴板 / 落盘 → regionOnly → 贴图),并把整个窗体渲染成 form.select/.annotate/.flip.png(R66)。R67 起贴图那段报数字:缩放 150% 且宽高比不变、七项中文菜单穿过 ASCII 闸门、右键菜单能开两次(第一版在 Closed 里 Dispose → 第二次 Show 抛「已释放对象」,真机上是堵死消息泵的模态框)、pinout.json+PNG 落进信箱、takePinHandoff 取一次即删、越界路径被拒、Ctrl+拖动的 FileDrop 准备、pinFromFile 贴已有文件、service.pin 端到端,并把贴图窗渲成 pin.png,约 30 秒
host_selfcheck({plugin:'dsh-screenshot', script:'dev/pin-e2e.mjs'}) # R67:贴图「送进输入框」的宿主取件验收 —— 往真实信箱写一条交接,等宿主心跳取走(取走即删,判据是标记消失,不需要界面)。20 秒。老宿主(没重启)会 FAIL 并让你重启 DSH,这不是回归;新宿主会真的往输入框贴一张 1x1 探针图(跑完按提示移除)
host_selfcheck({plugin:'dsh-screenshot', script:'dev/ovl-skin-shot.mjs',
args:['--dir','<上面那个 DSH_OVERLAY_DIR>']}) # 渲染 → 裁图 → 放大(R66):从 paint 代码的常量算出卡片 / 角标 / 放大镜的矩形,一次出 zoom/bar|flip|badge|loupe.png(3 倍,NEAREST),1 秒
host_selfcheck({plugin:'dsh-screenshot', script:'dev/stitch-tex-probe.mjs'}) # 36 项:拼接器在真实页面纹理上(真截屏 → 造长页面 → 切已知帧 → 与真值逐像素比):滚动 / 区域里有动画 / 同位置重复帧 / 跳太远 / 窄重叠(25%) / 窄重叠且动画压在重叠带里,约 35 秒(R63 起;R65 加后两条;R68 加「这张页面判得了吗」)。锁屏、屏幕上没有互不相同的纹理、或页面本身二义(另一条接缝与真值同样精确,含差一像素的情形)时退 2(env),不是回归 —— 判据是 python dev/texreplay.py check --dir <臂目录>,它会把「真值多少分 / 另一条多少分 / 差在哪几行」打出来
host_selfcheck({plugin:'dsh-screenshot', script:'dev/ocr-probe.mjs'}) # 29 项:OCR 端到端(node 渲染已知文字 → lib/shot.mjs#ocr()):白底三行 / 深色主题 / 11px 降级 / 空白不报错 / 只认矩形内,约 17 秒(R63)
host_selfcheck({plugin:'dsh-screenshot', script:'dev/wheel-app-probe.mjs',
args:['-App','qt']}) # Qt 认不认投递滚轮
host_selfcheck({plugin:'dsh-screenshot', script:'dev/wheel-app-probe.mjs',
args:['-App','java']}) # Java(Swing) 认不认
host_selfcheck({plugin:'dsh-screenshot', script:'dev/wheel-app-probe.mjs',
args:['-Title','记事本']}) # 任何已开着的应用

客户端热重载留下的孤儿 DOM(R38)

客户端半边被热重载时,旧模块已经不在了 —— 它 appendChild 到 body 的节点(覆盖层根、调色板、录屏胶囊、面板、菜单、贴图)没人回收,攒几次就会「点不动 / 盖住」。
以前唯一的解法是刷新页面。

现在:每次加载生成一个代次,所有长期节点都经 mountBody 打上 data-dshot-gen, 加载即清扫别的代次(只认这个属性,官方 DOM 一个都不碰)。
__dshotDebug 里多了 gen / swept / sweep(),可以现场验:

shot_bridge({action:'send', ops:[{op:'eval', js:"(()=>{const o=document.createElement('div');o.id='dshot-stale-test';o.setAttribute('data-dshot-gen','g-old');document.body.appendChild(o);const n=window.__dshotDebug.sweep();return {swept:n,gone:document.getElementById('dshot-stale-test')===null,gen:window.__dshotDebug.gen}})()"}]})

判据:swept >= 1 且 gone: true。smoke 里还有一条假 DOM 真跑的行为测试(上一代被清 / 本代保留 / 无标记不碰),所以这条逻辑不靠「字符串在不在」来保证。

版本号只有一个真源(R39):const CLIENT_BUILD = 'Rxx',桥注册与 __dshotDebug 都引用它, lib/health.mjs 也优先读它。以前这个号在文件里有两处、readClientBuild 只读第一处 ——只改一处时磁盘与运行中一起自洽地错(实测:代码已是 R38,回执还报 R33)。

区域录屏(R32)

默认就让人框一块,而不是整屏:整屏录到不该录的东西代价更高,而「框一块」以前藏在右键菜单第十几行里、几乎没人知道它存在。菜单里「录制范围:每次框一块 / 整个桌面」可以切回去,选择会被记住。

四件容易被忽略、但都会让这个功能「看起来坏了」的事:

1. 奇数宽高会让 ffmpeg 当场死掉(width not divisible by 2,留下 0 字节文件)。
人手拖框极容易拖出 801×601,所以宿主把宽高向下取偶,并且回执里报实际录的尺寸——客户端显示的是真在录的 800×600,不是用户拖的那个数。
2. 框到的区域小于 2×2 时明确报错,绝不「那就整屏录」(那会把整个桌面录进去)。
3. 屏幕上会画一圈红框(ps/recframe.ps1)说明正在录哪一块 —— 框选覆盖层一关,屏幕上再没有别的东西能说明边界在哪。红框画在捕获矩形之外,所以不会被录进视频;内部用窗口 region 挖掉(用 TransparencyKey 的那个版本实测屏幕上一个像素都不显示)。
框起不来不影响录制。
⚠️ 「后创建」不等于「在最上面」(R54/R55 实测):框是 WS_EX_NOACTIVATE(绝不抢焦点),所以它永远不会成为前台窗口 —— 而一个既是 TopMost、又是前台的窗口会压住它,不论谁先创建。2026-10-24 实测:窗口存在、几何正确、也确实画了,屏幕上却一个红像素都没有。
修法是 SetWindowPos(HWND_TOPMOST, …) 抬到 topmost 组的最上层,并且每 250 ms 重抬一次 (一次不够:同一份代码能 16/16 也能 0/0/0/0,差别只有之后有没有人把自己抬上去)。
⚠️ 重抬绝不能带 SWP_SHOWWINDOW:对一个已经可见的窗口反复「显示」,Windows 每次都会重跑 DWM transition —— 实测把被框住的那块画面整体压暗 17%(216,222,233 → 179,184,193),而那块正是要录进视频的区域。探针现在会同时判「边框在」与「内部 100% 还是原来的颜色」,这两个一起绿才算过。
4. 录完必须收掉框:record('stop') 会先落停止信号再杀进程,残留一个红框比不画更糟。

交付物是文件,所以要判文件(R56):上面 3 的证据全在屏幕上 —— 边框在矩形外、内部没变,只能推出「应该没进视频」。dev/record-integrity-probe.mjs 把它落到文件:压一块已知棋盘 → 真录 2.5 s → 用结果面板那条 recAction('frame') 抽一帧 → 判那一帧的像素:

- 视频里红框像素 = 0(几何上「在矩形外」到底有没有 1 px 渗进来,只有这样才知道);
- 亮度与被录内容相差 ≤8%(R55 那个 17% 压暗在屏幕上看不出来,在文件里就是整段);
- 两色都还在(H.264 有损,所以判「两色各占 30–70%」,不判逐像素相等)。

录屏录音(R41)

菜单 → 更多设置 → 「录屏录音」。默认关:录屏带麦是有隐私含义的开关,默认开等于在用户不知情时录周围的声音。点开时先问宿主一次设备表 —— 没有麦就拒绝打开并说明,有麦就说出用的是哪一块(药丸上显示 🎙 有声 / 静音,结果面板显示 有声 / ⚠ 无声(没录上))。

R41 之前这条功能是两半各写一半的死代码,而且两边都不报错:

| 半边 | 原来的写法 | 后果 |
| client.js | record('start', {audio: false, …}) 写死 | 永远不请求声音 |
| lib/shot.mjs | -i audio=virtual-audio-capturer 写死 | 本机没装那块虚拟声卡,带声必失败 |

现在的链路:listCaptureDevices()(ffmpeg -list_devices -f dshow)→ resolveAudioDevice()
(按名字/ASCII 别名精确匹配;空 want = 自动档位:先麦克风(旁白),其次系统音 立体声混音 / Stereo Mix / loopback,都没有才拿第一块 audio 设备 —— 分档是为了与系统列出的先后无关,旧写法「第一块命中大正则的」换台机器结论就变)→ -i audio=<真实设备名>
+ 显式 -map 0:v:0 -map 1:a:0 + -c:a aac。

三条不变量(都由 dev/record-audio-probe.mjs 现场证明,33 项断言):

1. 有麦就必须真有音轨:record('stop') 的 hasAudio 来自 rec.ps1 info 的 ffprobe,不是「用户选了有声」;
2. 没麦/名字错了不许失败:静音继续录,回执带 audio:false + audioWarning 原话,客户端必须念出来(静默降级最坏的结果是用户录完一整套才发现全程无声);
3. 设备打不开(被独占/驱动抽风)退一次静音重试 —— 只有 dshow|audio|device 这类错才降级,区域越界之类的视频侧错误不许拿静音去掩盖。

设备枚举的判据不是退出码:-list_devices 没有正常输入,找不到东西时它也 exit 1,
唯一能信的是「解析出了设备」。另外 (none) 不是「没有设备」——本机 ffmpeg 用它标视频设备。

region 只收字符串,传错了必须报错(R43)

record('start', {region}) 的 region 只能是 "x,y,w,h"。以前 lib/service.mjs 写的是
typeof parsed.region === 'string' ? parsed.region : '' —— 传一个 {x,y,w,h} 对象进来会被 静默替成空,于是「想录 64×64 的一小块」变成「录整屏 1920×1080」,而回执是 ok:true / region:""。
实测就是踩在这上面(一段端到端录音意外录了整屏)。

现在两道门都拒:lib/service.mjs 的 record('start') 与 lib/shot.mjs 的 recordStart 都返回 ok:false + region 只接受 "x,y,w,h" 字符串(收到 object:{…}),且不会开始录制(探针里有一条断言专门证明「没开录」)。理由与 recordStart 里「框了却算不出有效区域必须报错」同一条:把框选静默换成整屏,会录进用户根本没打算公开的桌面。

同一个坑在截图与 OCR 里也有:cap() / ocr() 的 rect 以前也是 typeof rect === 'string' ? rect : ''。
它们现在共用同一条判据(lib/service.mjs 的 coordTypeError)—— 拒掉,并在错误里说清是哪个参数、收到了什么类型。三处一起管,是因为「静默替空」在这三条路上意味着同一件事:把整屏当成你要的那块。

报出来的设备名由宿主算,界面不许猜(R44)

拨开「录屏录音」时那句 录屏会录音,使用:… 以前取自 devices[0] —— 那是列表顺序,不是宿主的选择(R42 的自动档是「麦克风 > 系统音 > 第一块」)。两块设备时界面可能报 A、实际录 B。
现在 record('devices') 回执除 devices[] 还带 auto / autoReason / autoWarning (宿主用 resolveAudioDevice(list, '') 算出来的),客户端优先念 auto; 旧宿主没有这个字段时才退回 devices[0](降级也要能说话,不能因为字段缺就静默)。

并发 start 必须串行,不然会漏一个 ffmpeg(R47)

recordStart 的「已在录」判据在开头,而真正赋值 recording 要等 ensureDirs() → 设备枚举(另起一个 ffmpeg)→ sleep(600) 探活。两次 start 落进这个窗口时,两边都能通过判据,各起一个 ffmpeg;recording 被后赋值者覆盖,先起的那个再也没人持有句柄 —— stop 停不到它,它会一直 gdigrab 下去。修复前实测:两个 mp4(一个 2823 B 被 stop,一个 48 B 还在长),磁盘与 CPU 就这么漏;两次回执还各报一个 path,调用方分不清哪段是「那段」。

现在的处理不是把两次合并成一次(那会把第二个调用方的 fps/region/audio 静默吞掉),而是让第二个调用等第一次收尾,再走既有 already:true 分支 —— 回执带着实际在录的 region/width/height/audio/mic,调用方看得见「你要的」和「在录的」是不是一回事。互斥是一个模块级 starting 承诺(let starting = null + 排队 while + run.finally),只包住 startOnce:一次 start、一个 ffmpeg、一个文件。探针里两臂现场证明(同一 path / already:true / 只多一个 mp4)。

R48 补了一处回执白名单:service.mjs 以前不转发 already,活宿主上两次并发 record('start') 的回执都是 already: undefined —— 调用方只看到「两个 ok + 同一个 path」,分不清是「两次都成功」又是「第二次被并进来」。现在 recFields 有默认值 already:false,start 分支带上 already: res.already === true;探针再加一条分支(service 级第二次 start → already:true + 同一 path)。

录屏结果面板:为什么「抽一帧」会失败(R33)

用户现场:录了 7.3 秒,面板显示 0 KB、「当前帧」空白、报 moov atom not found;换成一段正常的录屏,面板又报 frame produced no file。两条根因:

1. ffmpeg -ss <t> 落在末尾之后 = 退出码 0 且不产出文件(不是报错!)。面板默认就在 时长 - 0.05 处取第一张预览帧,而时长用的是墙钟,常常比文件真正的结尾多几十毫秒。
现在:时长优先用文件自己的(ffprobe);Invoke-Frame 在「没产出文件」时依次退到 -sseof -0.4(文件末尾前 0.4 秒)和 t = 0 —— 末帧附近拿不到就退一步,而不是宣布失败。
2. 0 字节的录屏以前会被当成成功(文件存在、recordStop 返回 ok),于是界面开一个「必然抽帧失败」的面板,看起来像截屏/录屏整个坏了。现在 recordStop 发现 bytes === 0 或时长 0 就直接报错,并把 ffmpeg 的 stderr 尾巴带出来 —— 那句话才是下次能定位的线索。

没在录时按「停止」不弹面板(R50)

record('stop') 是幂等的:没有在录时回 ok:true + path:''。客户端以前只看 ok,于是
连点两下停止(或上一段刚结束)会:拿空 path 去调 clip → 弹一句「录屏已结束,但没能放进剪贴板: path required」;再打开一个 path 为空的结果面板,里面每条抽帧/剪辑都注定失败。
现在 stop 先判 path === '':只说一句「当前没有在录屏(可能已经结束了)」,不碰剪贴板、不开面板。
(真机验过:面板数 0、toast 原文如上。)

宿主崩溃留下的孤儿录制,下次起录前清掉(R51)

recording 只在内存里。宿主被结束/重启时 recordStop 不会跑,而 ffmpeg 不会跟着退出 ——它会一直 gdigrab 写那个 mp4,没有句柄、没有界面入口,唯一痕迹是 .cache/rec 里一颗一直长胖的文件。

现在起录成功时把 {pid, file, started, via} 写到 rec/current.json,正常 stop 时删掉; 下一次起录前(以及「没有在录时点了停止」)先跑 reapOrphanRecorder():

1. tasklist 查这个 pid 还活着吗(拿不到就退 process.kill(pid,0));
2. 那个 pid 现在还是 ffmpeg.exe 吗 —— pid 会被系统复用,杀别人的进程比留着孤儿更糟;
3. 才 taskkill /PID … /T /F,并删掉状态文件。

回执里 reapedPrev:true 表示「上一段没收尾、已经被终止」。

两条起进程的路都被保护(R54):via:'node' 直接起 ffmpeg,pid 就是 child.pid;沙箱里由 ps/recspawn.ps1 代起时 child.pid 是 PowerShell —— 所以脚本自己把真 ffmpeg 的 pid 写进一个 pid 文件(-PidFile,{"pid":N}),宿主读出来照常落 current.json,孤儿判据对两条路完全一致。
⚠️ 这条退路只在 node 的 spawn 撞上 EPERM 时才会走,而宿主进程从不被沙箱限制 —— 换句话说它 在生产里执行不到。为了不留下「看起来活着、其实从没跑过」的分支,加了测试缝 DSH_SHOT_FORCE_RECSPAWN=1(只有探针会设),record-audio 探针因此多出 4 条判据:真的走了 recspawn、状态里记的是真 ffmpeg、那个 pid 还活着且确实叫 ffmpeg.exe、停止后 mp4 可播且状态清掉。

投递滚轮的适用范围(R32 实测)

auto 长截图不移动鼠标,而是 PostMessage(WM_MOUSEWHEEL) 给点位下面的叶子控件。已实测:

| 应用类型 | 消息投给 | 结果 |
| WinForms | 叶子 TextBox | ✅ 首个可见行 0 → 3(R29) |
| Chromium(DSH 自己) | 叶子 | ✅ scrollTop 180 → 349.6(R29) |
| Qt 5.15 | 顶层(Qt 一窗一 HWND) | ✅ 滚动条 0 → 9 |
| Java Swing(JDK 17) | 顶层(Swing 一窗一 HWND) | ✅ 滚动条 0 → 189 |

⚠️ 这两行的判据是被测程序自己记的滚动条值,不是「画面变了没有」——夹具的每一行内容长得一样,滚动后像素几乎不变(实测 pixel diff 0.0%),只看画面会得出「没滚动」的错误结论。

改了 ps/.ps1 要重启 DSH:抓屏助手在执行的那一刻就把脚本读完了,之后不会重读。
(host_selfcheck 跑在另一个 node 进程里,它起的助手跟着自己消失,替代不了应用那个 —— dev/refresh-shooter.mjs 能给出「哪个助手是谁的」的证据。覆盖层助手例外:它盯着自己的 .ps1,改完会自己退出重生。)

浏览器里真点真画地验标注界面:

node .tools/devserver.mjs 8934 "$DSH_WORKSPACE"
打开 http://127.0.0.1:8934/DSH-Plugin/dsh-screenshot/dev/harness.html

dev/harness.html 会从 client.js 里把覆盖层那段抠出来 eval —— 验的就是真正装进 DSH 的代码。
(为什么内联而不是 import:官方客户端模块只服务声明过的 chunk,任意路径取不到。)

接线

node .tools/dsh-plugin-install.cjs install dsh-screenshot "$DSH_WORKSPACE\DSH-Plugin\dsh-screenshot"
↑ 这是作者工作区的辅助脚本,不在本仓内;没有它就按文末「安装」一节的三步手做沙箱下 symlink 会 EPERM,改用 cmd /c mklink /J + node .tools/_add-bundle.cjs

宿主半边改完必须重启 DSH Desktop(Electron host 不消费 patchReload: live)。

支持的 DSH 版本

装之前先对一下版本。版本不对时不会「凑合能用」:轻则输入框旁没有按钮,重则整个插件树加载失败。

| 项 | 值 |
| 声明区间 | >=0.1.2-rc.1 <0.3.0 —— 写在 package.json 的 engines.dsh(同时声明 peerDependencies 的 @deepseek-ai/dsh-typert-protocol) |
| 实测通过 | 0.2.1-rc.2 / 0.2.1-rc.3(Windows 11 · desktop profile · Node 24.21) |
| 为什么不能更低 | 依赖 Typert remote 通道把宿主抓到的图交给浏览器半边,以及 manifestVersion: 1 的 dsh.client.inject 槽位声明 —— 这两样在更低的宿主上不可用 |
| 0.3.x 及以上 | 未验证。Typert 协议与客户端槽位若再变,按钮可能挂不上;宿主升级后先跑 shot_health |
| Node | 未单独声明(跟随宿主);本机实测 24.21 |

适用 profile

本插件面向 DSH 的 desktop profile。本机已在 desktop profile 下加载(.dsh/profiles/desktop)。插件包本身不含 profile 配置,profile 名可自行替换。

安装

平台:Windows 专有。 抓屏、覆盖层、OCR、录屏全部走 PowerShell + .NET + ffmpeg -f gdigrab, macOS / Linux 上没有实现 —— 在那些平台上装上去,你只会得到一个点了没反应的按钮。

三步,不需要任何本机脚本。<profile> 默认 desktop。

0) 版本先对上(见上一节)
dsh --version

1) 克隆到工作区(不要直接克隆进 profile/node_modules)
git clone https://github.com/JHboy203/dsh-screenshot.git "<你的工作区>/DSH-Plugin/dsh-screenshot"

2) 链进 profile(普通 cmd 即可,不需要管理员) mklink /J "%USERPROFILE%\.dsh\profiles\<profile>\node_modules\dsh-screenshot" "<你的工作区>\DSH-Plugin\dsh-screenshot"

3) 改 <profile>/package.json 两处,然后完全退出并重启 DSH:

{
"dependencies": {
"dsh-screenshot": "link:<你的工作区>/DSH-Plugin/dsh-screenshot" },
"dsh": {
"profile": {
"bundles": [ / …原有条目…, "dsh-screenshot" */ ]
}
}
}

重启后对话输入框「语音」键旁边多一个截图按钮即成功;按 Ctrl+Alt+A 也能直接截。

⚠️ 不要用 pnpm 系命令(dsh plugin add / pnpm add)装它。 dsh plugin 只是把参数转发给 pnpm;
pnpm 会重算整棵树、把 @deepseek-ai/dsh-tools 再 hoist 一份进 profile,那份会遮蔽宿主的,
于是每次工具调用报 Cannot read properties of undefined (reading 'prepare')。

本仓 cordis.patch.yml 是 bundle patch:包进了 dsh.profile.bundles 后宿主会自己读它、自动 insert,不需要手工往 profile 的 patch 里抄。

额外外部依赖:ffmpeg(录屏用)。没有它时录屏会明确报错,其它功能不受影响。
抓屏与 OCR 用的是 Windows 自带能力,不需要再装东西。

外部依赖与权限

- 运行位置:DSH 宿主进程(Node 侧)或浏览器侧,需要本机文件读写权限(部分工具会读写工作区内的文件)。
- 外部命令:powershell.exe + .NET(抓屏在宿主半边做)
- 环境变量(可选,用于覆盖默认值;值不写进仓库):DSH_SHOT_DIR、DSH_SHOT_CACHE、DSH_OVERLAY_DIR、DSH_SHOT_HELPER、DSH_SHOT_HELPER_MS、DSH_SHOT_MAX_TOTAL_HEIGHT、DSH_SHOT_OVERLAY
- 监听端口:无

已知限制

- 仅在本机 Windows + DSH desktop profile 实测,其他平台/版本未验证。
- 长截图的 auto 模式优先投递滚轮(不动光标),判定靠「下一张画面变没变」。投递要投给 点位下面那个控件:投给顶层窗口时 Chromium 认、WinForms 等旧式应用不认 (实测:顶层窗体只触发自己的 MouseWheel,内容不滚;投给 WindowFromPoint 的叶子才滚)。
不认时自动降级成移光标。Qt / Java / Electron 的自绘滚动容器尚未逐个实测。
- 长截图一次滚一格滚轮,不是滚「一屏的 80%」(R58 改的)。滚轮的 delta 单位是 1/120 格,应用再按自己的行高翻(WinForms 默认 3 行/格):以前按像素发(520px 区域 → -416),被读成 3.5 格 ≈ 10 行,一次滚掉 811px,比一屏还高 —— 两帧之间没有重叠,拼接器只能猜。
现在按格发;一格的位移由目标应用决定。
- 但一格能滚多远是应用决定的:40pt 大字一帧 ≈ 237px,而 10pt 小字一帧只有 60px。于是「一帧固定一格」能覆盖的总高 = 帧数上限 × 一格 —— 后者在密集小字页面上只够 ~2900px,一张 3840px 的页面会被静默截掉一截(R60 实测:40 帧停在 2880px,stopReason 还是空的,用户看到的就是一张「正常」的长图)。现在每帧滚几格按实测位移自适应(R60):滚过一帧就知道一格值多少像素,把步长调到区域高度的 ~45%(上限 3 格,重叠仍剩一大半);同一张 3840px 页面 18 帧到底、每条色带 160px 一条不差。撞到帧数上限时 stopReason='max-frames',客户端会明说「后面可能没截完」,不再说「已经到底了」。
- 拼接器不接受「没有重叠的两帧」:残差超过阈值就报错并保留会话(「滚得太多了,分两次滚」),不会再静默拼出一张又高又像真的错图(R58 用探针量到过 +479px 的静默重复内容)。只差几个像素的重复帧(光标闪烁、时钟跳秒)会被识别成「同一张画面」直接跳过,不再塞进一段重复内容。
- 拼接偏移在缩小图上粗搜之后,再做一次全分辨率精修:粗搜的量化误差是 ~4px/像素,以前每条接缝会因此偏 2px(重复/丢掉 2px 内容),现在缝落在像素上(探针断言每条色带 79px)。
- 长截图的区域外缘如果有会自己动的东西(滚动条拇指、缩放手柄、贴边浮层),那些列在帧间就不一致。拼接器现在两遍(粗搜 + 精修)都先裁掉外缘(宽度的 6%,至少 6px、至多 40px)再找重叠(R59),所以滚动条可见也能拼。实测:R58 的真实帧在未裁边时精修残差 0.20–0.22(左缘在闪的光标),裁边后 0.00,偏移不变;合成的「右侧 14px 滚动条 + 拇指每帧移动」四帧,偏移 237/237/237 与真值一致(不裁边时同一组会挑错偏移)。
- 区域内部有东西自己动(视频、转圈、每帧重画的图表)时,整块区域在帧间永远不一致。拼接器先按普通残差找接缝;判成「没有重叠」时才走第二条路 —— 它看的不是「残差降下来了没有」,而是分歧长在哪儿:动画是一块紧凑区域,错的偏移是满屏字都对不齐(R63)。实测:一块 220×130 每帧重画时,真值处的普通残差是 29.29(远高于 8.00,任何残差都不可能接受它),而「分歧外接框」只占重叠的 0.48、框内 49% 像素超阈值;错偏移是 1.00 / 27%。这条判据在真实屏幕纹理上也是对的(dev/stitch-tex-probe.mjs:四条臂的偏移逐帧精确,动画块之外的成品与真值逐像素相同)。⚠️ R61 用的是「去掉最差 30% 的行」的评分,它在色带夹具上成立,但在真实纹理上会偏向最窄的重叠(真值 237 px,裁剪评分选 386 / 397 / 509,还静默多贴过 +160 px)—— 这是 R63 换掉它的原因。
- 窄到几行的重叠不算证据(R63):27 行真实文字能巧合地给出 2.37 的平均差,于是错了 256 px 还「看起来像真的」。重叠不足区域高度 35% 时,只有逐像素精确(残差 ≤ 1.0)才作数 —— 色带夹具真的会一格滚掉 75%,它每次都精确到 0.00,所以这条不会误伤真截图。
- 窄重叠「精确」也可能落在错的偏移上(R65,真实纹理上抓到的真 bug):一页应用(一次滚一屏减一行,25% 重叠)某一对帧有两个逐像素精确的候选 —— 真值 390(130 行证据)与 477(43 行,因为页面上 43 行内容自己重复了 87 px)。粗搜在 2.6:1 的缩小签名上偏好 477(43 行缩成 16 行签名全等,而 390 落不到签名行上),旧规则又把它当「精确」接受 → 成品 4117 而不是 4030(静默 +87 px、243 523 个像素错)。现在窄重叠会重判一次:在这些候选里取最深的最小值,一样深时取重叠最多的(py/stitch.py#find_exact_shift)。实测同一条夹具 4117 → 4030 且逐像素相同。另一半也是实测的:同一条臂上另一种错法(386,「只比真值差 4 px」)也被这条规则挡住 —— 它落在容差里但不是最深的最小值。
- 窄重叠 + 重叠带里有东西在动 = 诚实的拒绝(R65):重叠只剩 25%、而那块每帧重画的区域正好压在重叠带里时,这一带没有任何可用证据(真值处残差 37.98 > 8.00),结构判据又故意不看 35% 以下的重叠(R63:11 行文字满足过它,并挑了个错的 509 px)。所以它会报 no-overlap、退出 3、不出文件,而不是拼一张看起来对的图。自动滚的步长是区域高 45%,正常不会走到这里;真遇到就「分两次滚」。
- 同位置重复帧仍然先判后拼(「内容没前进、只有那一小块在变」)。顺序反了会把重复帧按猜出来的偏移贴进去 —— R63 实测:色带夹具 + 动画那条臂因此多出 158 px 重复内容。
- 已知边界:在动的那块会在成品上留一道「重影」(每帧各贴了一次),这是拼接的必然结果;这种情况自动滚也认不出「到底」(整帧永远在变),会话会走到帧数上限并如实报 max-frames(「后面可能没截完」),但成品本身是完整的。
- 上面这几条现在有真实纹理上的独立探针(R63,R65 扩到六条臂):dev/texreplay.py 拿一张真截屏造长页面、切出已知帧,六条臂分别断言「偏移逐帧精确 / 成品与真值逐像素相同(动画块那几列除外)/ 同位置帧跳过 / 跳太远必须报错 / 窄重叠也精确 / 窄重叠+动画必须拒绝」。它第一次跑就抓到两个真 bug(上面两条 ⚠️),R65 加窄重叠臂时又抓到第三个(上面那条 +87 px)。
- 改拼接器时先看 texreplay.py landscape(R64):它把每个评分的前几名和真值处的四个数摆在一起(python dev/texreplay.py landscape --dir <build 出来的目录> --pair <第几对>)。R63 那四个 bug 没有一个能从产品 JSON 里看出来 —— 每个评分都只报「一个数字」。实测(真实纹理、真值 237):普通滚动时真值是唯一 0.00、结构判据在 486 个位移里只认它一个;动画页里真值处普通残差 29.33(任何残差都不能接受),而按行裁剪与普通评分都会选到 511(最窄重叠),结构判据仍只认真值。
- 页面本身有歧义时,拼接器判不出来 —— 已实测并写进探针(R68,2026-10-31)。长截图唯一的证据是「重叠带里的像素一样」,所以当页面里有大片纯色带(深色界面的空白区)或行会周期性重复 (会话列表那种等高行)时,一条错误的接缝会和真值同样精确、甚至「更准」。同一份代码实测:真值 237 处残差 21.16(动画块在重画),而 shift 509 在 11 行纯深色上拿到 0.0000,拼接器照收 → 成品 3334 px(应为 2653);窄重叠臂的同一帧里 389..396 全是 0.0000, R65 那条「并列取更宽」于是选了 389 —— 4029 px,差一个像素。这两类都不可能从像素里判出来,所以:① 纹理回放探针会先判「这张页面判得了吗」,判不了就如实退 2(env),不再报成产品回归; ② 真实长截图遇到纯色/重复内容时,成品高度可能多算或少算几十到几百像素 —— 这是当前已知上限,不是随机故障。要稳,就框每一行都有区分度的区域。
- 夹具(dev/longshot-backdrop.ps1)默认藏滚动条;-Scrollbar 1 保留它,探针的第四条臂就是用这个跑一遍 auto,证明上面那条不是纸面结论。
- 剪贴板是全机独占资源:别的程序正占着时截图复制会失败(实测报「所请求的剪贴板操作失败」)。
助手会重试 4 次;仍失败时客户端会明说「没复制成功」并留下诊断,不会假装复制过。
- 「截图时隐藏 DSH 界面」靠标题找窗口(DeepSeek Harness 子串匹配);窗口标题被改得面目全非时只会不藏(截图照常),不会报错。
- 贴图窗(R67)的几条边界:① 缩放固定宽高比(不做自由拉伸 —— 拉扁的截图没有用处),范围 10%–400%;② 滚轮要先点一下贴图(WinForms 的滚轮只发给有焦点的窗口),右键菜单里的放大/缩小/原始大小不需要焦点;③ 「送进输入框」靠宿主心跳取件(≤1 秒),宿主还是旧代码时不会有人来取,贴图会在 4 秒后明说「界面没来取(重启 DSH 后可用)」而不是一直报「已送出」; ④ 贴图窗是独立进程里的原生置顶窗,DSH 退出/助手重启时它会一起消失(图仍在剪贴板/输入框里); ⑤ Ctrl+左键拖动发的是 OLE 文件拖放:拖动期间助手不接新的截图请求(DoDragDrop 自己跑模态循环),松手即恢复;⑥ 比屏幕还大的图(例如 30000px 的长图)造窗前会先等比缩到屏幕的 80%。
- 私有备份仓:按 .gitignore 排除了 node_modules、运行时缓存、调试 dump 与备份文件;要复现运行请自行 npm install(如有依赖)。
- 文档中出现的 $DSH_WORKSPACE、$TK_ROOT、%USERPROFILE% 等是占位符,使用前需替换成本机实际路径。

许可证

MIT,见 LICENSE。第三方代码、字体、图标或数据的许可证需单独核对(见仓内说明)。
预览分享卡片长什么样 ↓JHboy203/dsh-screenshot
分享 · DeepSeek Harness Hub
JHboy203/dsh-screenshot

DSH 截图插件:QQ 同款框选、标注、离线 OCR 提取文字、贴图、录屏、长截图。仅支持 Windows(PowerShell/.NET)。开发版(非最终版),支持 DSH 0.2.1–0.2.x。

这是你的仓库?认领条目

回到顶部