dsh-mask-editor-plugin
Verifieddsh-mask-editor-plugin · v0.1.1 · MIT · Web UI
DeepSeek Harness 画布插件:canvas_editor —— 调用后在网页端弹出画笔画布(参考 ComfyUI 掩码编辑器),用户亲手涂。画布上有两个互相独立的笔迹层:「掩码」层产出与原图同尺寸的黑白掩码 PNG,「笔迹」层产出把笔迹烧进原图的合成 PNG;画了就是要、没画就是不要,所以两层都画就两个文件都落盘、只画一层就只落那一个、一层都没画就点保存=放弃这次编辑。工具栏可把一层复制追加到另一层(省得画两遍),画完还能补一句给模型的话。结果自动落盘(路径、尺寸、笔数与覆盖率随工具结果返回
Install
dsh plugin add dsh-mask-editor-plugin Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-mask-editor-plugin —— 画布编辑器(工具 canvas_editor)
包名还是
dsh-mask-editor-plugin(它是插件在 profile 里的身份,改它要动 profile 的依赖声明), 但工具名叫canvas_editor:它现在既出掩码也出「被编辑的图像」,mask_editor这个名字只承诺 了其中一半。
让用户亲手画,再把结果交给下游。一个工具 canvas_editor:调用后网页端在这条工具调用
的卡片里弹出画笔画布(参考 ComfyUI 掩码编辑器的交互),用户在图上涂抹/标注,点「完成并保存」,
结果自动落盘,路径、尺寸、笔数、涂抹覆盖率随工具结果返回给模型,可直接交给别的图像工具用。
原图是必填参数:两个笔迹层都以它为底。
装法
工作区里 pnpm install && pnpm --filter dsh-mask-editor-plugin run build 之后,用
Plugins → Add plugin 以绝对路径(C:\DSH\dsh-mask-editor-plugin)安装本 bundle。
依赖只有两处,都有兜底:
| 服务 | 必要性 | 缺席时 |
|---|---|---|
tools |
硬依赖(inject) |
插件不激活 |
connection |
机会性(ctx.inject) |
插件照常激活,调用 canvas_editor 时报错说明「需要一个打开的网页端界面」 |
attachments |
可选 | 结果里的预览图块缺席,落盘的文件不受影响 |
核心:两个笔迹层,两种输出
画布上有两个互相独立的笔迹层,工具栏「正在画」在两者之间切换:
| 层 | 画出来是什么 | 产出什么文件 |
|---|---|---|
| 掩码 | 恒为白(只看形状,笔刷颜色与它无关) | 与原图同尺寸的掩码 PNG |
| 笔迹 | 笔刷颜色(默认 #ff3b30) |
被编辑的图像:把这一层的笔迹烧进原图的合成 PNG |
产出什么完全由用户把笔迹画在哪一层决定——画了就是要,没画就是不要:
| 用户画了 | 落盘 |
|---|---|
| 只有掩码层 | 只落掩码 PNG |
| 只有笔迹层 | 只落合成图 PNG |
| 两层都画 | 两个文件都落 |
| 一层都没画就点保存 | 空保存=放弃这次编辑,不落任何文件(工具结果里说明,不是错误) |
mode 参数因此退化成纯意向:它只决定编辑器打开时「正在画」停在哪一层、以及卡片上给
用户看的那句「模型想要…」,不决定产出。结果里的 outputs 才是实数。
工具栏还有「转成笔迹 / 转成掩码」:把当前这一层的笔迹复制追加到另一层(另一层原有的 笔迹保留),因此「画一遍、两层都要」只需画一次再点一下。这一步可用「撤销」整批退掉。
两个输出各自的格式
- 掩码:24 位无 alpha 的 RGB PNG——掩码的形状写在 RGB 里,不靠透明通道,任何加载器
都不会看错。语义与
dsh-comfyui-studio-plugin的mask_from_transparency一致—— 白 = 涂抹区,黑 = 没涂;半透明笔刷留下的是灰度(灰度值就是笔刷覆盖强度),所以硬笔刷 出来就是纯黑白,柔笔刷自带羽化。可直接当edit_image的mask_image(白=重绘),或当 别的图像工具的选区输入。勾了「反相」就黑白对调(黑=涂抹区)。 - 合成图:原图 + 笔迹层笔迹,保留 alpha——原图本身带透明(抠图)时,合成结果不该把 它抹平。适合让用户在图上圈重点、写字、标出错处。
工具 canvas_editor
| 参数 | 必填 | 说明 |
|---|---|---|
image_path |
✅ | 原图路径(绝对路径,或相对会话工作目录)。PNG / JPEG / WebP / GIF / BMP;TIFF 不行——浏览器显示不了,与其在画布上开天窗不如直接拒绝 |
mode |
纯意向:mask(默认)/ paint / both。只决定初始「正在画」的层与「模型想要…」那句提示,不决定产出 |
|
output_path |
落盘基名(不是最终文件名):两种输出各自在它后面加后缀(掩码 → <基名>_mask.png,被编辑的图像 → <基名>_paint.png)。省略则基名取原图路径去掉扩展名(落在原图旁边) |
|
brush_size |
笔刷初始直径(原图坐标系像素),默认 48(可配置) | |
brush_color |
「笔迹」层的笔迹初始颜色(#RRGGBB),默认 #ff3b30 |
|
opacity |
笔刷初始不透明度 0–1,默认 1 | |
hardness |
笔刷初始硬度 0–1(1 = 硬边,越小边缘越柔),默认 1 | |
invert |
掩码输出保存时黑白对调 | |
note |
显示在编辑器顶部、给用户看的一句话(例如「请把主体之外的区域涂满」) |
结果字段
ok / cancelled / mode(你给的意向,原样回报)/ outputs(实际落盘了哪些:
['mask'] / ['paint'] / ['mask','paint'] / [])/ mask 与 paint 两格
(各含 path / width / height / bytes / strokes / coverage)/ detail(给模型看的
结论)/ mask_image 与 paint_image(结果图的附件引用,用户也能看到,两张都产出时两张都附),
外加可选的 user_message:用户在编辑器里补写的那句话(见下节「捎一句话」)。
产出与意向不一致时,结论里会点明(你这次想要掩码,但用户没有在对应的层上画… /
另外还多产出了合成图:用户在你没要的那层上也画了)。
等待、取消与中断
调用会一直等用户在网页上画完——默认上限 30 分钟(waitTimeoutMs,设 0 就是不限时)。
用户点「取消」、或等待超时、或这一轮被停止、或空保存,都会让工具正常返回一个「这次没有生成
任何文件」的结果(不是错误),模型据此决定下一步。
前端界面
画布出现在那条工具调用自己的卡片里(tool.call.toolview,键 canvas_editor,与 Host 注册的工具名逐字一致)——模型一调用,
用户就在对话流的原位看到它;用户画完点保存,工具返回,卡片自己切换成结果行(文字结论 +
保存过的预览缩略图,两张就两张都显示)。
| 控件 | 说明 |
|---|---|
| 画笔 / 橡皮 / 移动 | 橡皮只擦当前层已画的部分;移动是抓手(画布放大后拖视图),也可以随时用鼠标中键拖 |
| 正在画:笔迹 / 掩码 | 新笔迹落在哪一层。两层的位图同时显示,非当前层调淡到 35%,所以「刚才那几笔在哪层」一眼能看出来。默认取 Host 给的初始层 |
| 转成笔迹 / 转成掩码 | 把当前层的笔迹复制追加到另一层(画一遍、两层都要);可撤销 |
| 笔刷 | 直径 1–400 px(原图坐标系),[ ] 快捷键微调 |
| 不透明度 | 5%–100%,半透明在掩码里就是灰度 |
| 硬度 | 0%–100%(Chromium 的 canvas filter 实现柔边;不支持的环境自动退回硬边) |
| 颜色 | 只在「笔迹」层出现(掩码层恒为白) |
| 撤销 / 清空本层 | 撤销走一个操作栈(落笔 / 转层追加 / 清空),所以「撤销一次转层」是整批退掉,清空之后也还能撤销回来;只影响当前层 |
| 反相 | 掩码输出保存时黑白对调;开着时状态行会说明 |
| 只看掩码 | 把照片与笔迹层藏掉、底色转黑,所见即掩码层本身 |
| 缩小 / 放大 / 适应 / 1:1 | 适应 = 整图放进视口;1:1 = 一个原图像素对一个屏幕像素;滚轮也能缩放 |
| 捎一句话 | 可选的留言框:留了话就随保存一起交给模型,作为 user_message 回来——模型把它当作你对下一步的直接要求,所以「按这个位置放角色,别改背景」这种话写在这里最省事 |
| 完成并保存 / 取消 | 保存后按钮锁住,等工具返回。悬停保存按钮能看见这次会写到哪几个文件 |
状态行实时显示尺寸、两层各自的笔数与覆盖率、以及保存结果;Ctrl/⌘+Z 撤销,[ ] 调笔刷。
两层都还是空的时候,动作区上方会明说「现在点保存=放弃这次编辑,不会生成任何文件」。
原图超过 2500 万像素时会提示「可能有点卡」,但涂抹本身仍按原始分辨率记录。
模型的 note 只出现在卡片顶部那个提示盒子(pre-wrap,多行也放得下);动作行左边始终是
「怎么画」的通用提示,不会把 note 再重复一遍。
卡片会自己展开,会话信息会自己等
模型在等用户动手的时候,界面不该让用户先点一下才发现。两处专门为此调整:
自动展开:当前的工作详情模式(
transcriptView: standard→stepGrouping: "collapsed")会把工具 卡片折进「正在调用工具: …」那一行,只露出标题——「模型卡在等人」这件事就变得很隐蔽。现在卡片 一挂载就会把这层分组展开(用 harness 自己写在 DOM 上的data-step-process容器与头部按钮aria-expanded契约,外加 slot 交给我们的useDisclosure),并把卡片滚进视野。只做一次, 之后用户自己收起来就不再打扰。会话信息轮询着问:
tool/call事件比 Host 的execute()更早到浏览器(agent loop 先广播这次 调用,再prepare → dispatch才真正执行),卡片会在 Host 登记之前就来问「这次要画什么」。所以 Host 改成先登记、后校验(登记时ready: false,原图校验完再翻真),这期间 session 路由回409 + pending: true。前端分两种失败对待:情况 判据 处理 软等待 我们那条路由答了话(JSON),只是这次「还没登记 / 已经结束」 按 0.2s→2.5s 的阶梯重试约 14 秒;阶梯跑完就换成每 5 秒问一次、显示「还在等」的黄色提示与「重试」按钮——永远不糊红字,工具一结束卡片会自己卸载 硬失败 拿不到我们的 JSON(路由没挂上 / 网关报错 / 网络不通) 同样走阶梯,连续 6 次仍如此才给红字(这种情况等下去也没用)
配置(cordis.patch.yml 里覆盖同名行即可)
| 键 | 默认 | 说明 |
|---|---|---|
defaultBrushSize |
48 | 笔刷初始直径 |
defaultBrushColor |
#ff3b30 |
「笔迹」层的笔迹初始颜色 |
defaultOpacity |
1 | 笔刷初始不透明度 |
defaultHardness |
1 | 笔刷初始硬度 |
maskSuffix |
_mask |
掩码输出加在基名后的后缀 |
paintSuffix |
_paint |
合成图输出加在基名后的后缀(两个后缀不能相同,否则登记前就报错) |
waitTimeoutMs |
1800000 | 等用户画完的上限(毫秒),0 = 不限时 |
maxImageBytes |
67108864 | 愿意发给前端当底图的原图字节上限 |
maxOutputBytes |
67108864 | 单个输出的写盘上限;整包提交的上限是它的两倍 |
attachResultImage |
true | 是否把结果图作为图像块附在工具结果里(两张都产出就附两张) |
allowOverwriteSource |
false | 是否允许算出来的输出路径指向原图本身 |
实现要点
四条路由(挂在 Connection 的 Fetch 注册表上,浏览器会话 cookie 鉴权、与传输无关):
方法 路径 作用 GET /api/dsh-canvas-editor/session这次要画什么:模型意向、初始层、原图、默认笔刷、两个候选落盘路径、提示语;原图还没校验完时回 409 + pending: trueGET /api/dsh-canvas-editor/image原图字节 POST /api/dsh-canvas-editor/submit用户画完提交:multipart/form-data, mask/paint两段 PNG(缺哪层就不带哪段)+meta一段 JSON(尺寸、两层的笔数与覆盖率、留言)POST /api/dsh-canvas-editor/cancel用户点了取消 前端只用「文档相对」URL(
api/dsh-canvas-editor/...),所以挂在任意前缀下都对得上。 一次提交把两段字节一起交上来,所以「用户画完了」是原子的——不会出现「掩码写完了、合成图还没来」 的中间态。Host 用标准request.formData()解析,不引入任何依赖。谁在等谁:工具的第一件事就是把这次绘图登记进一张待办表(键是
exec.callId,另外留着 「该会话唯一待办」的兜底查找)——任何await之前,这样前端卡片再早也问得到;原图校验完 才把ready翻真。然后 await;前端按callId/sessionId拉到会话信息、画完把两段 PNG POST 回来,Host 落盘后工具才返回。前端拿不到任何 Host 没登记过的东西。合成在浏览器,落盘在 Host:两张输出都是浏览器 canvas 画好的(掩码 = 黑底 + 掩码层笔迹, 合成图 = 原图 + 笔迹层笔迹),Host 只校验 PNG 头(用 IHDR 复核宽高,报告里的尺寸以文件为准)、 写文件。Host 不引入任何图像解码依赖。
落盘路径先算好:
output_path是基名,Host 在登记前就把两个候选路径算好发给前端,提交 时按实际产出的那一层直接写。两条候选路径都与原图路径比对过,撞上就直接报错(除非把allowOverwriteSource打开);两个后缀相同时也在登记前报错。两条半边的开发节奏不同:改
client.js由客户端 HMR 热更新(浏览器里刷新页面就生效);改 Host 半边(src/mask-editor.ts→lib/mask-editor.js)需要重启 dsh 才会加载新的lib/mask-editor.js——Loaders 按模块 URL 缓存,同一个文件名改了内容不会重载(install_bundle对它会报ambiguous-install,因为它在 profile 里是 link 依赖)。
自测
npm test 一次跑完下面全部(三个都不需要浏览器):
npm test # = 两道类型检查 + 两个 node 脚本
tsc -p tsconfig.typecheck.json # Host 半边(src/*.ts)
tsc -p tsconfig.client.json # Client 半边(client.js,allowJs + checkJs)
node test\host-loop.mjs # Host 半边:假 ctx 装插件,按前端的顺序跟四条路由对话
node test\client-wiring.mjs # Client 半边:假 window/document/react 加载 client.js,验接线与协议名
host-loop.mjs 覆盖(62 项):挂载即问必须是 409 + pending(先登记后校验)、校验完拿到会话
(意向 / 初始层 / 两个候选路径 / 原图)、取原图、两层都画 → 两个文件都落盘、只画掩码层 →
只落掩码、只画笔迹层 → 只落合成图、两层都没画就保存=空保存(不落文件、结果说「放弃」)、
与意向不一致时结论点明、留言进结果、显式 output_path 是基名(两种输出各加后缀、基名自己不被
当成文件名写出来)、原图不存在时抛错且登记撤掉、非图片格式被拒、两个后缀相同与「算出来的路径
指向原图」在登记前被拒、坏提交(不是 multipart / 段里不是 PNG)被拒且不落盘、坏提交之后这张
待办还能正常提交。提交那段刻意自己拼 multipart 字节并自己带 boundary 头,与真实 carrier
(缓冲成一个 Buffer 再 new Request)走同一条路。
client-wiring.mjs 覆盖(48 项):工厂能跑、apply() 注册了对的 slot 键与中英语言包、样式注入、
slot 键与 Host 注册的工具名逐字一致、两半边协议名对得上(路由名、multipart 的字段名
mask/paint/meta、session 的字段名)、提交只交「画过东西的那几层」、两层各有自己的位图与矢量
数组、非当前层被调淡、「正在画」切换、转成另一层是深拷贝追加、撤销是操作栈(清空之后还能撤销回来)、
笔迹颜色由层决定、产出跟着笔数实时算、两层都空时明说「保存=放弃」、掩码仍用无 alpha 画布
(colortype 2)、结果行能显示两张预览、软等待/硬失败两种策略都在、note 只渲染一处。
tsconfig.client.json 那道检查不是摆设:它抓出过一个 node --check 看不见的真错误——一个新加的
常量在用到的地方没写声明(Cannot find name),光跑语法检查会一路绿灯,直到用户在浏览器里遇到
会话拉取失败时才抛 ReferenceError。手写的 Client 半边没有构建步骤,这层静态检查就是它的门。
它测不了「画面好不好看、两层调淡的比例顺不顺眼、自动展开在你浏览器里是否真的展开」——那要人点。
验证状态(本机实机跑通到 2026-10-11;两层版是 2026-10-12 改的)
- ✅ Host 半边:行
canvas-editor已激活(fiberPhase: active),工具出现在列表里(改名前后都 复核过运行中的定义:cordis_inspect_query的Tool.listTools——改名必须重启 dsh 才会生效, 见下面「生效方式」)。 - ✅ Client 半边:
tool.call.toolview的canvas_editor键已被本插件占用且active。 - ✅
tsc -p tsconfig.typecheck.json(Host)与tsc -p tsconfig.client.json(Client)都无错;tsdown构建通过。 - ✅
test/host-loop.mjs、test/client-wiring.mjs全绿(断言条数见「自测」一节)。 - ✅ 两层版实机跑过一轮(2026-10-13,用户亲手画):两层各画一笔(「笔迹」层左上、取色正好
#ff3b30;「掩码」层右上、纯白),落盘subject_mask.png(912×912、1 笔、1.8%、colorType 2) 与subject_paint.png(912×912、1 笔、1.9%、colorType 6)。逐像素复核:掩码的白区与合成图 相对原图的差异区重合 0 像素(两份输出各自只由自己那一层生成),掩码文件在红笔那一片是纯黑、 合成图在掩码那一笔的位置读出来就是原图本身。 - ⬜ 观感没人眼定过:两层调淡的比例、转层的手感、两张预览在结果行里的排布——这些要真人看过 才算数(功能性的那一轮已经跑通)。
旧版(单一模式)实机验证过的东西
- ✅
mask模式:落盘test-subject_mask.png/test-subject_mask2.png,912×912, 覆盖率 2.3% / 16.5%、笔数 2 / 3;文件头colorType 2(RGB、无 alpha)。 - ✅
paint模式:落盘test-subject_paint.png,912×912、3 笔、9.6%。 - ✅ 结果回流:结果作为图像块回到了模型与用户眼前(附件服务那一路也通)。
- ✅ 输出模式切换(两个方向都试过,现在换成「正在画哪层」+ 转层,那个交互已不存在)。
- ✅ 自动展开;
note只渲染一处;留言框 →user_message;原图不被碰。
历史缺陷(已修,改动后仍然防着)
- 浏览器默认的 canvas 带 alpha 通道,
toBlob('image/png')于是写出colorType 6(RGBA)—— 掩码的形状明明在 RGB 里,却还挂着一条「处处 255」的 alpha,按 alpha 取掩码的加载器(ComfyUI 的LoadImage就是)会把整张图读成「全选」。修法是掩码画布改用getContext('2d', { alpha: false })(现在client-wiring.mjs钉着这一行)。合成图保留 alpha。 - 「拿不到这次编辑器会话:这次绘制已经结束(或不属于当前会话)」(第二次调用
canvas_editor时 撞上,工具却还在等):tool/call事件比 Host 的execute()早到浏览器。修法是 Host 先登记后 校验(ready: false→ 校验完翻真,期间回409 + pending),前端把「我们路由回的 JSON」都当 「再等等」并走阶梯重试,阶梯跑完改 5 秒一次继续等;只有「拿不到我们的 JSON」才在连续 6 次后判为 连线问题。 note在卡片顶部与底部各渲染了一遍:现在底部固定显示通用的「怎么画」提示,note 只在顶部 盒子出现一次。- 一个没声明就被用到的常量(
SESSION_SOFT_POLL_MS):为此加了tsconfig.client.json(allowJs+checkJs),npm test现在先跑它。
生效方式
- 改
client.js:浏览器里刷新页面即可(客户端 bundle 直接读文件,有 HMR)。 - 改 Host 半边(
src/mask-editor.ts→lib/mask-editor.js):Loaders 按模块 URL 缓存,必须 重启 dsh 才会加载新的lib/mask-editor.js;只刷新页面不算数。对它在 profile 里是 link 依赖,install_bundle会回ambiguous-install、plugin_manager set_plugin要的是插件的入口行 id (这一行是include:canvas-editor,不是 patch 里的canvas-editor)。 实测过:plugin_manager set_plugin(把include:canvas-editor关掉再打开)不会重新导入同一个 模块 URL,改文件时间戳也没用——注册表里仍是旧定义。想免重启得让模块 URL 变(例如把入口文件改名), 这条路没试过。 - 改工具名(
canvas_editor←mask_editor):Host 与 Client 必须一起改——卡片靠 slot 键认领 「哪条工具调用的卡片」,键与注册名不一致时画布不会出现,而两边都不报错(client-wiring.mjs里 有一条断言逐字比对这两个名字)。副作用:旧会话里记录的mask_editor调用不再认领卡片,退回通用卡片。