Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e05092d7 | ||
|
|
3f2fc7e033 | ||
|
|
9a60d9e174 | ||
|
|
b678071646 |
@@ -0,0 +1,6 @@
|
||||
.github/
|
||||
preview/
|
||||
tests/
|
||||
registry/
|
||||
VERIFICATION.md
|
||||
ROLLBACK.md
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Validate plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cpu-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install CPU test dependencies
|
||||
run: |
|
||||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
python -m pip install numpy Pillow
|
||||
- name: Run backend regression tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Check frontend syntax and metadata
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import pathlib, subprocess, tomllib
|
||||
config = tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8'))
|
||||
assert config['project']['name'] == 'adachi-inpaint-workbench'
|
||||
assert config['tool']['comfy']['DisplayName'] == 'Adachi-Inpaint-Workbench'
|
||||
for source in pathlib.Path('web').glob('*.js'):
|
||||
subprocess.run(['node', '--input-type=module', '--check'], input=source.read_bytes(), check=True)
|
||||
PY
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Publish to Comfy Registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Verify publisher configuration
|
||||
env:
|
||||
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, pathlib, tomllib
|
||||
config = tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8'))
|
||||
if not config['tool']['comfy']['PublisherId'].strip():
|
||||
raise SystemExit('Set tool.comfy.PublisherId to your registered publisher ID before publishing.')
|
||||
if not os.environ.get('REGISTRY_ACCESS_TOKEN'):
|
||||
raise SystemExit('Add your Registry API key as the REGISTRY_ACCESS_TOKEN repository secret.')
|
||||
PY
|
||||
- name: Publish custom node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- Rename the public node pack to **Adachi-Inpaint-Workbench**; keep existing node class IDs compatible with saved workflows.
|
||||
- Show an eraser cursor and a distinct dashed brush outline while Alt is held or the eraser tool is selected; restore the brush cursor on release.
|
||||
- Add Comfy Registry metadata, dependency declarations, a Manager registration entry, CPU CI and a manual Registry publishing workflow.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Move the original live editor controls and sampling controls into the WebUI Inpaint node.
|
||||
- Keep the inline node focused on the image and a small upload button.
|
||||
- Move comparison controls below the picture and remove the top toolbar band.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Add WebUI-style sliders, separate width/height rows and a source-size button.
|
||||
- Use result-left/original-right comparison and show the full result when the pointer leaves.
|
||||
- Route toolbar wheel events to the workflow and keep image wheel gestures separate.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Add temporary Alt erase, image comparison and result adoption.
|
||||
@@ -1,12 +1,28 @@
|
||||
# 安达工作台 / Adachi Workbench
|
||||
# Adachi-Inpaint-Workbench
|
||||
|
||||
直接在 ComfyUI 工作流节点里载入原图、涂抹蒙版、局部重绘,无需另开遮罩编辑器喵。
|
||||
|
||||

|
||||
|
||||
由 Adachi(安达)开发,MIT 开源喵。
|
||||
支持整图范围与蒙版范围重绘、Alt 临时橡皮擦、Ctrl+滚轮笔径、图像对比及一键采用重绘结果喵。
|
||||
|
||||
## ComfyUI Manager 与安装
|
||||
|
||||
Manager 的 Git URL 安装可使用 `https://github.com/adachi0130/Adachi-Inpaint-Workbench`;或在 `ComfyUI/custom_nodes` 中克隆喵。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
|
||||
```
|
||||
|
||||
Manager 搜索收录申请已提交:[ComfyUI-Manager PR #3378](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3378),仍需维护者合并及索引更新,尚不能保证搜索可见喵。
|
||||
当前尚未配置 Comfy Registry 发布者,Registry 发布流程见 [发布说明](registry/README.md) 喵。
|
||||
|
||||
## 安装
|
||||
|
||||
将整个 `ComfyUI-Adachi-Workbench` 文件夹复制到 `ComfyUI/custom_nodes/`,保留其中的 `web/`、`backend.py`、`__init__.py` 喵。
|
||||
将整个 `Adachi-Inpaint-Workbench` 文件夹复制到 `ComfyUI/custom_nodes/`,保留其中的 `web/`、`backend.py`、`__init__.py` 喵。
|
||||
重启 ComfyUI,然后在浏览器按 `Ctrl+F5` 刷新前端缓存喵。
|
||||
在节点搜索中输入 `Inline Mask` 或 `WebUI Inpaint`,分类是 **安达工作台 / Adachi Workbench** 喵。
|
||||
在节点搜索中输入 `Inline Mask` 或 `WebUI Inpaint`,分类是 **安达工作台 / Adachi-Inpaint-Workbench** 喵。
|
||||
|
||||
Windows 也可以从解压后的插件文件夹运行安装脚本,将参数改为自己的、包含 `main.py` 的 ComfyUI 目录喵。
|
||||
|
||||
@@ -16,13 +32,13 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_
|
||||
|
||||
脚本只复制本插件;更新时会把旧插件压缩备份到所选 ComfyUI 的 `user/adachi-workbench-backups/`,不会自动重启服务喵。
|
||||
插件使用 ComfyUI 已有的 PyTorch、NumPy、Pillow,不需要额外 pip/npm 安装喵。
|
||||
卸载时关闭 ComfyUI,再删除 `custom_nodes/ComfyUI-Adachi-Workbench` 即可喵。
|
||||
卸载时关闭 ComfyUI,再删除 `custom_nodes/Adachi-Inpaint-Workbench` 即可喵。
|
||||
|
||||
## 最快上手
|
||||
|
||||
1. 将 `examples/01-inline-inpaint.json` 拖入 ComfyUI,选择自己的 checkpoint 喵。
|
||||
2. 在 **内联蒙版 / Inline Mask** 节点载入、拖放或粘贴图片,直接在画布上涂抹需要重绘的部分喵。
|
||||
3. 修改正负提示词,在 **WebUI 局部重绘 / WebUI Inpaint** 中选择 `area`、采样分辨率、采样器与重绘幅度,然后运行喵。
|
||||
3. 修改正负提示词,在 **WebUI 局部重绘** 节点中选择画笔工具、重绘范围、采样分辨率、采样器与重绘幅度,然后运行喵。
|
||||
4. `image` 输出回贴后的完整原图,`crop` 输出实际采样画布,`mask` 输出该采样画布对应的蒙版喵。
|
||||
|
||||
**白色蒙版重绘,黑色保护;画布中橙色代表绘制区域** 喵。
|
||||
@@ -46,7 +62,7 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_
|
||||
|
||||
| 操作 | 使用方式 |
|
||||
|---|---|
|
||||
| 画笔 / 橡皮 | 工具栏切换,`B` / `E`;按住 `Alt` 或鼠标右键临时擦除,松开恢复原工具 |
|
||||
| 画笔 / 橡皮 | 工具栏切换,`B` / `E`;按住 `Alt` 或鼠标右键临时擦除,松开恢复原工具;擦除时显示橡皮擦图标与淡粉色虚线范围 |
|
||||
| 矩形 / 自由套索 | `R` / `L`,拖动画出要重绘的区域 |
|
||||
| 笔径 / 硬度 / 笔触 | 分别控制原图像素笔径、边缘硬度、绘制不透明度 |
|
||||
| 橙色叠加 | 仅调节显示透明度,不改变真实蒙版 |
|
||||
@@ -60,14 +76,25 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_
|
||||
| 原图 / 蒙版 / 叠加 | 切换预览,不改变输出 |
|
||||
|
||||
支持画笔压力和软笔刷;鼠标使用固定压力喵。
|
||||
笔径、硬度、笔触和叠加四个滑条位于图片编辑区下方喵。
|
||||
内联蒙版仅保留图片编辑区与左上角的小型 **载入原图** 按钮,载入后按原图比例调整画布高度以减少空边喵。
|
||||
画笔、橡皮、矩形、套索、移动、四个笔刷滑条、撤销重做、蒙版操作、显示切换、适应与原图嵌入选项,均显示在连接的 **WebUI 局部重绘** 节点中,直接控制原来的内联画布喵。
|
||||
图片上传一次,之后每笔在浏览器内更新蒙版,并在工作流及运行参数中保存 PNG 数据,无需每画一笔都保存、重新加载磁盘文件喵。
|
||||
鼠标位于图片区域时滚轮缩放图片;位于工具栏、按钮、滑条或参数面板时,滚轮遵循 ComfyUI 的画布导航设置喵。
|
||||
从 ComfyUI 画布开始的连续滚轮手势移入图片时,继续导航外部画布;停下后再在图片里滚动才缩放图片喵。
|
||||
超大图仍会消耗浏览器内存,PNG 编码耗时取决于分辨率和设备,不能保证所有图像都瞬间完成喵。
|
||||
|
||||
## 画笔工具与重绘参数
|
||||
|
||||
内联蒙版连接 **WebUI 局部重绘** 的 `image` 输入后,原画笔控件自动显示在该重绘节点中;下方参数面板直接控制该节点的真实采样参数喵。
|
||||
宽度和高度各占一行,支持滑条及数值输入;重绘幅度也使用滑条,点击 **使用原图宽高** 可填入原图尺寸,按采样要求取最近的 8 的倍数喵。
|
||||
蒙版内外、填充、重绘范围、采样器、调度器、步数、CFG 和种子直接显示,蒙版处理、批量、Refiner 切换等位于 **蒙版、批量与高级设置** 中喵。
|
||||
如果一张内联原图连接多个重绘节点,画笔控件放在第一个 WebUI 重绘节点中,各节点保留自己的参数面板;断开或删除该节点后,工具自动迁移到下一个可用节点喵。
|
||||
外部连线控制的参数会禁用对应面板控件;模块化工作流将画笔工具放在 **重绘预处理 / Inpaint Prepare** 节点中,采样参数仍在独立采样节点中调整喵。
|
||||
|
||||
## 图像对比与连续重绘
|
||||
|
||||
添加 **图像对比 / Image Compare** 节点,将内联蒙版的 `image` 接到 `original`,最终完整重绘图 `image` 接到 `result`,运行后即可对比喵。
|
||||
鼠标移动时,竖线左边显示原图、右边显示重绘结果,普通滚轮缩放图片;批量结果可在上方选择喵。
|
||||
鼠标移入图片时,细竖线左边显示重绘结果、右边显示原图;移出后隐藏分割线,完整显示重绘结果,普通滚轮缩放图片;批量结果选择、尺寸和适应按钮位于图片下方喵。
|
||||
满意后点击 **采用此结果**,插件会把当前选中的结果上传为新的原图,回填到上游内联蒙版,并清空旧蒙版,随后可以继续涂抹下一处喵。
|
||||
原图连接经过中间 IMAGE 节点时会向上寻找内联蒙版;若存在多个目标,可以在“回填到”中明确选择喵。
|
||||
回填只在点击按钮时发生,不会自动开始下一轮生成;`image` 输出仍传递整个结果批次,可接保存图像喵。
|
||||
@@ -126,8 +153,10 @@ Inline Mask ── image/mask ──> Inpaint Prepare
|
||||
|
||||
## English quick start
|
||||
|
||||
Copy the entire `ComfyUI-Adachi-Workbench` folder into `ComfyUI/custom_nodes`, restart ComfyUI, then hard-refresh your browser喵.
|
||||
Search for `Inline Mask` or `WebUI Inpaint` under **安达工作台 / Adachi Workbench**喵.
|
||||
**Adachi-Inpaint-Workbench** is an MIT-licensed ComfyUI custom node pack for direct mask painting, WebUI-style inpainting, result/original comparison, and adopting results for another edit pass喵.
|
||||
|
||||
Copy the entire `Adachi-Inpaint-Workbench` folder into `ComfyUI/custom_nodes`, restart ComfyUI, then hard-refresh your browser喵.
|
||||
Search for `Inline Mask` or `WebUI Inpaint` under **安达工作台 / Adachi-Inpaint-Workbench**喵.
|
||||
No extra pip or npm packages are required beyond ComfyUI's PyTorch, NumPy and Pillow喵.
|
||||
|
||||
Drag `examples/01-inline-inpaint.json` into ComfyUI, select a checkpoint, upload an image inside **Inline Mask**, paint, and run the workflow喵.
|
||||
@@ -136,10 +165,11 @@ Use `whole_image` to sample the full source extent, or `only_masked` to enlarge
|
||||
Sampling uses `width × height`; the final `image` output is composited at the original image resolution in both modes, while `crop` exposes the generated sampling canvas喵.
|
||||
|
||||
The editor supports brush/eraser, rectangles, freehand lasso, undo/redo, invert, mask import/export, brush size/hardness/opacity, zoom, pan, drag-and-drop and clipboard images喵.
|
||||
Inline Mask shows only the image and a small upload button; its original live painting controls and the sampling parameter panel are hosted in the connected WebUI Inpaint node (or Inpaint Prepare for modular workflows)喵.
|
||||
Paint with the left button; hold Alt or use the right button to erase temporarily, use Ctrl+wheel for brush size, zoom with the plain wheel, and pan while holding Space喵.
|
||||
Enable **Embed source in workflow** before saving JSON to share the source image along with its mask喵.
|
||||
|
||||
The **Image Compare** node takes `original` from Inline Mask and `result` from the final full-size generated image; hover to compare original on the left and result on the right喵.
|
||||
The **Image Compare** node takes `original` from Inline Mask and `result` from the final full-size generated image; hover to compare result on the left and original on the right, and leave the picture to see the full result喵.
|
||||
Select a batch result if needed, then click **采用此结果 / Adopt this result** to upload it as the upstream editor's new source and clear the old mask for another pass喵.
|
||||
|
||||
The all-in-one node adds original/color-fill/noise/zero-latent modes, native ComfyUI sampling, per-image batch variants, dedicated inpaint conditioning and an optional compatible Refiner stage喵.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Git 版本与回滚
|
||||
|
||||
`v0.2.0` 是最早的界面回滚点,`v0.3.0` 包含内联参数面板、左右对比调整和滚轮路由修复;`v0.4.0` 将画笔与参数集中到 WebUI 重绘节点;`v0.4.1` 更新橡皮擦光标及公开名称喵。
|
||||
本机安装目录与源码目录均保留 Git 历史;普通发布 ZIP 不含 `.git`,可另用 Git bundle 恢复历史喵。
|
||||
|
||||
在插件目录运行以下命令,先确认没有未保存改动,再切换版本喵。
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git switch --detach v0.4.0
|
||||
```
|
||||
|
||||
恢复本版使用以下命令喵。
|
||||
|
||||
```powershell
|
||||
git switch --detach v0.4.1
|
||||
```
|
||||
|
||||
切换后重启 ComfyUI 并在浏览器按 `Ctrl+F5` 刷新喵。
|
||||
已有本地修改时请先提交或备份,避免覆盖自己的改动喵。
|
||||
@@ -13,6 +13,15 @@
|
||||
- 0.2.0 新增 Alt 临时擦除和图像对比节点,后端 18 项测试通过;真实鼠标/键盘操作确认松开 Alt 恢复原工具喵。
|
||||
- 0.2.0 在经典节点与 Nodes 2.0 的实际 API 队列验证左右像素、鼠标分割线、滚轮缩放、Ctrl+滚轮笔径、批量结果选择、多编辑器上游定位、点击回填、旧蒙版清空和保存后重新打开,无插件脚本错误喵。
|
||||
- 两种节点模式中的图片滚轮事件均不会误触发整张工作流缩放,回填结果保存在 input 目录并可嵌入工作流喵。
|
||||
- 0.3.0 在经典节点与 Nodes 2.0 验证底部内联参数控制真实采样节点、宽高分行、原图尺寸填写、工作流重开不重复增高、断开连接恢复原参数,以及 Nodes 2.0 原节点参数行实际隐藏喵。
|
||||
- 0.3.0 验证工具栏和参数区域滚轮传递给外部画布,连续外部滚轮手势进入图片后不改变图片缩放,停下后的图片滚轮只缩放图片喵。
|
||||
- 0.3.0 对比节点实测左侧为结果、右侧为原图,移出后完整显示结果;分割线为一个物理像素,移除了圆形手柄、角标和底部说明,回填与批量选择仍通过喵。
|
||||
- 0.3.0 画笔浏览器交互 15 组通过,两种渲染模式的纵向拉伸和保存恢复通过,两个更新后的示例工作流连线及执行参数验证通过喵。
|
||||
- 0.4.0 在经典节点与 Nodes 2.0 验证内联画布只保留载入按钮,原画笔 DOM 控件迁移到 WebUI 节点;真实鼠标涂抹后可从重绘节点撤销,工具和滑条控制原画布,采样参数正确进入执行请求喵。
|
||||
- 0.4.0 验证按原图比例缩减画布空边、细高度条拉伸、保存重开保持尺寸、原图尺寸填写、断开重连、删除重建重绘节点,以及工具区域滚轮继续操作外部画布喵。
|
||||
- 0.4.0 图像对比顶部不再有工具栏,结果选择、尺寸和适应移到底部;真实 API 队列中的左右对比、移出显示完整结果、批量选择及结果回填通过喵。
|
||||
- 0.4.1 真实浏览器按住 Alt 后确认橡皮擦 SVG 光标及擦除状态生效,松开后恢复十字画笔光标;临时擦除、常规绘画和 18 项后端 CPU 回归通过喵。
|
||||
- 0.4.1 统一公开名称为 Adachi-Inpaint-Workbench,保留节点类 ID 以兼容已有工作流喵。
|
||||
|
||||
采样路径还使用本机 ComfyUI 的真实 common_ksampler/KSampler 函数进行了噪声调度衔接检查;尚未加载实际 checkpoint 测试 GPU 生成质量或所有模型家族,不能将 CPU/接口测试视为模型效果验证喵。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Adachi Inline Inpaint custom-node package for ComfyUI."""
|
||||
"""Adachi-Inpaint-Workbench custom-node package for ComfyUI."""
|
||||
|
||||
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
CATEGORY = "安达工作台 / Adachi Workbench"
|
||||
CATEGORY = "安达工作台 / Adachi-Inpaint-Workbench"
|
||||
MAX_IMAGE_PIXELS = 32_000_000
|
||||
MAX_IMAGE_BYTES = 64 * 1024 * 1024
|
||||
MAX_DATA_URL_CHARS = 4 * ((MAX_IMAGE_BYTES + 2) // 3) + 128
|
||||
@@ -525,8 +525,8 @@ class AdachiImageCompare:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {
|
||||
"original": ("IMAGE", {"tooltip": "左侧原图,连接内联蒙版的 image / Original image shown on the left."}),
|
||||
"result": ("IMAGE", {"tooltip": "右侧完整重绘图,请连接最终 image 而非 crop / Full-size generated image shown on the right; use image, not crop."}),
|
||||
"original": ("IMAGE", {"tooltip": "右侧原图,连接内联蒙版的 image / Original image shown on the right."}),
|
||||
"result": ("IMAGE", {"tooltip": "左侧完整重绘图,请连接最终 image 而非 crop / Full-size generated image shown on the left; use image, not crop."}),
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
110
|
||||
130.65625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
@@ -241,7 +241,7 @@
|
||||
],
|
||||
"size": [
|
||||
430,
|
||||
780
|
||||
1020
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
@@ -635,7 +635,7 @@
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"安达工作台 / Adachi Workbench\n1. 选择自己的 checkpoint 喵\n2. 在左侧节点载入原图并直接涂抹喵\n3. only_masked = 蒙版区域放大采样,whole_image = 整图采样喵\n4. width/height 是采样分辨率,最终 image 回贴为原图尺寸喵\n分享时勾选「原图嵌入工作流」喵"
|
||||
"安达工作台 / Adachi-Inpaint-Workbench\n1. 选择自己的 checkpoint 喵\n2. 在左侧节点载入原图并直接涂抹喵\n3. 在 WebUI 重绘节点中选择画笔并调整重绘参数喵\n4. width/height 是采样分辨率,最终 image 回贴为原图尺寸喵\n分享时在重绘节点勾选「原图嵌入工作流」喵"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
420
|
||||
763
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
@@ -445,7 +445,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -487,7 +488,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SetLatentNoiseMask"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
@@ -498,7 +500,7 @@
|
||||
],
|
||||
"size": [
|
||||
330,
|
||||
310
|
||||
317.3125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
@@ -646,7 +648,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
@@ -688,7 +691,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiInpaintComposite"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
@@ -788,7 +792,7 @@
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"安达工作台 / Adachi Workbench — 标准节点组合\n选择模型,载入原图并涂抹,然后运行喵\nPrepare → VAE Encode → Set Latent Noise Mask → KSampler → VAE Decode → Composite 喵\n独立 Prepare 的 fill 请选择 original 或 fill 喵\nlatent_noise / latent_nothing 由一体采样节点实现,独立预处理不修改 latent 喵\n使用专用 inpaint checkpoint 时请改用原生 InpaintModelConditioning 或一体节点 inpaint_model 喵"
|
||||
"安达工作台 / Adachi-Inpaint-Workbench — 标准节点组合\n选择模型,载入原图并涂抹,然后运行喵\nPrepare → VAE Encode → Set Latent Noise Mask → KSampler → VAE Decode → Composite 喵\n独立 Prepare 的 fill 请选择 original 或 fill 喵\nlatent_noise / latent_nothing 由一体采样节点实现,独立预处理不修改 latent 喵\n使用专用 inpaint checkpoint 时请改用原生 InpaintModelConditioning 或一体节点 inpaint_model 喵"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
|
||||
@@ -16,7 +16,8 @@ if (-not (Test-Path -LiteralPath $customRoot)) {
|
||||
New-Item -ItemType Directory -Path $customRoot | Out-Null
|
||||
}
|
||||
$customRoot = (Resolve-Path -LiteralPath $customRoot).ProviderPath
|
||||
$destination = [IO.Path]::GetFullPath((Join-Path $customRoot 'ComfyUI-Adachi-Workbench'))
|
||||
$destination = [IO.Path]::GetFullPath((Join-Path $customRoot 'Adachi-Inpaint-Workbench'))
|
||||
$legacyPath = [IO.Path]::GetFullPath((Join-Path $customRoot 'ComfyUI-Adachi-Workbench'))
|
||||
if (-not $destination.StartsWith($customRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Install destination must stay within the selected custom_nodes directory.'
|
||||
}
|
||||
@@ -24,16 +25,27 @@ if ($destination.TrimEnd('\') -eq $packageRoot.TrimEnd('\')) {
|
||||
Write-Host 'This package is already in the selected ComfyUI custom_nodes directory. 喵'
|
||||
exit 0
|
||||
}
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
$existing = Get-Item -LiteralPath $destination
|
||||
if ((Test-Path -LiteralPath $destination) -and (Test-Path -LiteralPath $legacyPath)) {
|
||||
throw 'Both current and legacy plugin folders exist. Keep one installation to avoid duplicate node registrations.'
|
||||
}
|
||||
$existingPath = if (Test-Path -LiteralPath $destination) { $destination } elseif (Test-Path -LiteralPath $legacyPath) { $legacyPath } else { $null }
|
||||
if ($existingPath) {
|
||||
$existing = Get-Item -LiteralPath $existingPath
|
||||
if ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw 'Refusing to overwrite a junction or symbolic-link installation. Use a normal folder.'
|
||||
}
|
||||
$backupRoot = Join-Path $comfyRoot 'user\adachi-workbench-backups'
|
||||
New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
|
||||
$backupFile = Join-Path $backupRoot ('ComfyUI-Adachi-Workbench-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '.zip')
|
||||
Compress-Archive -LiteralPath $destination -DestinationPath $backupFile
|
||||
$backupFile = Join-Path $backupRoot ('Adachi-Inpaint-Workbench-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '.zip')
|
||||
Compress-Archive -LiteralPath $existingPath -DestinationPath $backupFile
|
||||
Write-Host "Previous installation backed up: $backupFile 喵"
|
||||
if ($existingPath -ne $destination) {
|
||||
# Verify both resolved absolute paths before moving the legacy folder.
|
||||
if ([IO.Path]::GetDirectoryName($existingPath) -ne $customRoot -or [IO.Path]::GetDirectoryName($destination) -ne $customRoot) {
|
||||
throw 'Migration paths must remain directly inside the selected custom_nodes directory.'
|
||||
}
|
||||
Move-Item -LiteralPath $existingPath -Destination $destination
|
||||
}
|
||||
} else {
|
||||
New-Item -ItemType Directory -Path $destination | Out-Null
|
||||
}
|
||||
@@ -42,5 +54,5 @@ Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object {
|
||||
} | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $destination -Recurse -Force
|
||||
}
|
||||
Write-Host "Installed Adachi Workbench: $destination 喵"
|
||||
Write-Host "Installed Adachi-Inpaint-Workbench: $destination 喵"
|
||||
Write-Host 'Restart ComfyUI, then refresh the browser with Ctrl+F5. 喵'
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 128 KiB |
@@ -1,11 +1,27 @@
|
||||
[project]
|
||||
name = "comfyui-adachi-workbench"
|
||||
version = "0.2.0"
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "0.4.1"
|
||||
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Adachi" }]
|
||||
dependencies = ["numpy", "Pillow", "torch"]
|
||||
classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/adachi0130/Adachi-Inpaint-Workbench"
|
||||
Documentation = "https://github.com/adachi0130/Adachi-Inpaint-Workbench#readme"
|
||||
"Bug Tracker" = "https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues"
|
||||
|
||||
[tool.comfy]
|
||||
# Set this to your actual Comfy Registry publisher ID before Registry publishing.
|
||||
# GitHub publication and the Manager legacy-list submission do not require it.
|
||||
PublisherId = ""
|
||||
DisplayName = "Adachi-Inpaint-Workbench"
|
||||
|
||||
[tool.adachi-workbench]
|
||||
frontend = "web"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Manager / Registry 发布
|
||||
|
||||
GitHub 仓库公开并不代表 Manager 搜索清单或 Comfy Registry 已收录喵。
|
||||
本插件的 Manager 收录申请见 [PR #3378](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3378),当前等待上游处理喵。
|
||||
|
||||
## Manager 清单
|
||||
|
||||
[manager-entry.json](manager-entry.json) 是提交给 [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager) 的条目,加入其根目录的 `custom-node-list.json` 并提交 PR,等待维护者合并及索引更新喵。
|
||||
提交方式以 [Manager 官方说明](https://github.com/Comfy-Org/ComfyUI-Manager#how-to-register-your-custom-node-into-comfyui-manager) 为准喵。
|
||||
收录前仍可使用 Manager 的 Git URL 安装,仓库地址为 `https://github.com/adachi0130/Adachi-Inpaint-Workbench` 喵。
|
||||
|
||||
## Comfy Registry
|
||||
|
||||
根据 [官方发布指南](https://docs.comfy.org/registry/publishing),首次发布需要开发者建立自己的 Publisher 及 API Key 喵。
|
||||
|
||||
1. 登录 [Comfy Registry](https://registry.comfy.org/),创建自己的 Publisher,记录实际 Publisher ID 喵。
|
||||
2. 将 `pyproject.toml` 中 `[tool.comfy].PublisherId` 的空值改成该 ID,提交到 GitHub 喵。
|
||||
3. 在仓库 Settings → Secrets and variables → Actions 中添加 `REGISTRY_ACCESS_TOKEN`,值为 Registry 的发布 API Key 喵。
|
||||
4. 在 Actions 中手动运行 **Publish to Comfy Registry**,查看发布和平台校验结果喵。
|
||||
|
||||
当前工作流仅手动触发,不会在未配置 Publisher 或 API Key 时自动发布喵。
|
||||
Registry 的版本发布后不可覆盖,后续发布需要先递增 `pyproject.toml` 版本号喵。
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"author": "adachi0130",
|
||||
"title": "Adachi-Inpaint-Workbench",
|
||||
"reference": "https://github.com/adachi0130/Adachi-Inpaint-Workbench",
|
||||
"files": ["https://github.com/adachi0130/Adachi-Inpaint-Workbench"],
|
||||
"install_type": "git-clone",
|
||||
"description": "Inline mask painting with a compact image node, WebUI-style inpainting controls, whole-image and masked-area sampling, hover image comparison, and one-click result adoption. 内联蒙版、WebUI 风格局部重绘、整图/蒙版区域重绘、图像对比与结果回填。"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
numpy
|
||||
Pillow
|
||||
torch
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Original hover-comparison UI, MIT licensed. */
|
||||
import { registerImageWheel } from "./wheel_gestures.js";
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -19,24 +20,22 @@ export class ImageCompareView {
|
||||
this.originals = []; this.results = []; this.index = 0;
|
||||
this.zoom = 1; this.pan = { x: 0, y: 0 }; this.split = .5;
|
||||
this.version = 0; this.ready = false;
|
||||
const head = element("div", "dd-head");
|
||||
head.append(element("span", "dd-brand", "ADACHI / COMPARE"));
|
||||
this.dimensionsLabel = element("span", "dd-dimensions", "原图 / 重绘结果"); head.append(this.dimensionsLabel);
|
||||
this.dimensionsLabel = element("span", "dd-dimensions");
|
||||
const toolbar = element("div", "dd-row dd-compare-toolbar");
|
||||
toolbar.append(element("span", "dd-compare-label", "结果"));
|
||||
this.resultSelect = element("select"); this.resultSelect.setAttribute("aria-label", "选择重绘结果 / Choose result");
|
||||
this.resultSelect.onchange = () => { this.index = Number(this.resultSelect.value); this.options.onSelect?.(this.index); this.loadSelected(); };
|
||||
toolbar.append(this.resultSelect);
|
||||
toolbar.append(this.resultSelect, this.dimensionsLabel);
|
||||
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
|
||||
const fit = element("button", "", "适应"); fit.type = "button";
|
||||
fit.onclick = () => { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }; toolbar.append(fit);
|
||||
this.stage = element("div", "dd-stage dd-compare-stage");
|
||||
this.canvas = document.createElement("canvas"); this.canvas.tabIndex = 0;
|
||||
this.canvas.setAttribute("aria-label", "左原图、右重绘结果,移动鼠标竖线对比 / Original left, result right; hover to compare");
|
||||
this.canvas.setAttribute("aria-label", "左重绘结果、右原图,移动鼠标竖线对比 / Result left, original right; hover to compare");
|
||||
this.canvas.oncontextmenu = event => event.preventDefault();
|
||||
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
|
||||
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
|
||||
this.stage.append(this.canvas);
|
||||
const captions = element("div", "dd-compare-captions");
|
||||
captions.append(element("span", "", "原图"), element("span", "", "重绘结果")); this.stage.append(captions);
|
||||
this.empty = element("div", "dd-empty", "连接原图与重绘结果,然后运行工作流"); this.stage.append(this.empty);
|
||||
const adoption = element("div", "dd-row dd-adoption");
|
||||
adoption.append(element("span", "dd-compare-label", "回填到"));
|
||||
@@ -47,7 +46,8 @@ export class ImageCompareView {
|
||||
this.adoptButton = element("button", "dd-primary", "采用此结果"); this.adoptButton.type = "button";
|
||||
this.adoptButton.onclick = () => this.adopt(); adoption.append(this.adoptButton);
|
||||
this.message = element("div", "dd-status", "鼠标移动竖线对比 · 滚轮缩放 · 中键拖动");
|
||||
this.root.append(head, toolbar, this.stage, adoption, this.message);
|
||||
this.message.hidden = true; this.message.setAttribute("role", "alert");
|
||||
this.root.append(this.stage, toolbar, adoption, this.message);
|
||||
this.canvas.addEventListener("pointermove", event => this.move(event));
|
||||
this.canvas.addEventListener("pointerdown", event => {
|
||||
if (!this.ready) return;
|
||||
@@ -63,9 +63,7 @@ export class ImageCompareView {
|
||||
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
|
||||
};
|
||||
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) this.canvas.addEventListener(name, finish);
|
||||
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
|
||||
this.captureWheel = event => { if (event.target === this.canvas) this.wheel(event); };
|
||||
window.addEventListener("wheel", this.captureWheel, { capture: true, passive: false });
|
||||
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
|
||||
this.canvas.addEventListener("keydown", event => {
|
||||
if (event.code === "Space") { event.preventDefault(); event.stopPropagation(); this.space = true; }
|
||||
else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
@@ -79,7 +77,7 @@ export class ImageCompareView {
|
||||
this.refreshTargets(); this.updateButton();
|
||||
}
|
||||
|
||||
status(text, error = false) { this.message.textContent = text; this.message.classList.toggle("dd-error", error); }
|
||||
status(text, error = false) { this.message.textContent = text; this.message.classList.toggle("dd-error", error); this.message.hidden = !error; }
|
||||
|
||||
refreshTargets() {
|
||||
const selected = this.targetSelect.value;
|
||||
@@ -94,7 +92,7 @@ export class ImageCompareView {
|
||||
this.updateButton();
|
||||
}
|
||||
|
||||
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; }
|
||||
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; this.adoptButton.textContent = this.busy ? "采用中…" : "采用此结果"; }
|
||||
|
||||
async setData(data, index = this.index) {
|
||||
this.originals = Array.isArray(data?.originals) ? data.originals : [];
|
||||
@@ -122,7 +120,7 @@ export class ImageCompareView {
|
||||
this.before = images[0]; this.after = images[1];
|
||||
this.ready = true; this.empty.hidden = true;
|
||||
this.dimensionsLabel.textContent = `${this.before.width} × ${this.before.height}`;
|
||||
this.status("左原图 / 右重绘 · 滚轮缩放 · 满意后点击采用此结果");
|
||||
this.status("");
|
||||
this.updateButton(); this.schedule();
|
||||
} catch (error) { if (version === this.version && !this.destroyed) this.status(error.message, true); }
|
||||
}
|
||||
@@ -141,6 +139,11 @@ export class ImageCompareView {
|
||||
|
||||
transform() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
if (this.viewport?.width && this.viewport?.height && rect.width && rect.height) {
|
||||
this.pan.x *= rect.width / this.viewport.width;
|
||||
this.pan.y *= rect.height / this.viewport.height;
|
||||
}
|
||||
this.viewport = { width: rect.width, height: rect.height };
|
||||
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) * this.zoom : 1;
|
||||
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2 + this.pan.x, y: (rect.height - (this.before?.height || 0) * scale) / 2 + this.pan.y };
|
||||
}
|
||||
@@ -160,6 +163,7 @@ export class ImageCompareView {
|
||||
|
||||
move(event) {
|
||||
if (!this.ready) return;
|
||||
this.hovered = true;
|
||||
if (this.drag) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
this.pan.x = this.drag.pan.x + event.clientX - this.drag.x;
|
||||
@@ -179,20 +183,20 @@ export class ImageCompareView {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height);
|
||||
if (!this.ready) return;
|
||||
ctx.setTransform(t.scale * ratio, 0, 0, t.scale * ratio, t.x * ratio, t.y * ratio);
|
||||
ctx.drawImage(this.before, 0, 0);
|
||||
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
|
||||
if (!this.hovered) return;
|
||||
const split = this.split * this.before.width;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
|
||||
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height); ctx.restore();
|
||||
ctx.beginPath(); ctx.moveTo(split, 0); ctx.lineTo(split, this.before.height);
|
||||
ctx.strokeStyle = "#141820"; ctx.lineWidth = 3 / t.scale; ctx.stroke();
|
||||
ctx.strokeStyle = "#ffe0b8"; ctx.lineWidth = 1 / t.scale; ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(split, this.before.height / 2, 7 / t.scale, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#272c35"; ctx.fill(); ctx.stroke();
|
||||
ctx.drawImage(this.before, 0, 0); ctx.restore();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const lineX = Math.round((t.x + split * t.scale) * ratio) + .5;
|
||||
ctx.beginPath(); ctx.moveTo(lineX, t.y * ratio); ctx.lineTo(lineX, (t.y + this.before.height * t.scale) * ratio);
|
||||
ctx.strokeStyle = "#ffffffb3"; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
|
||||
window.removeEventListener("wheel", this.captureWheel, true);
|
||||
this.stopWheelCapture();
|
||||
this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { ImageCompareView } from "./compare.js";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
|
||||
function viewUrl(image) {
|
||||
const type = ["input", "output", "temp"].includes(image?.type) ? image.type : "temp";
|
||||
@@ -34,6 +35,7 @@ app.registerExtension({
|
||||
const value = created?.apply(this, arguments), node = this;
|
||||
const compare = new ImageCompareView({
|
||||
resolveUrl: viewUrl,
|
||||
onGraphWheel: event => app.canvas?.processMouseWheel(event),
|
||||
getTargets: () => targets(node),
|
||||
onSelect: index => { node.properties.adachi_result_index = index; },
|
||||
async onAdopt(id, image) {
|
||||
@@ -47,6 +49,7 @@ app.registerExtension({
|
||||
},
|
||||
});
|
||||
node.adachiCompare = compare;
|
||||
const stopWheelRouting = routeWheelOutsideImage(compare.root, compare.canvas, () => compare.ready);
|
||||
const widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", compare.root, {
|
||||
serialize: false, hideOnZoom: false, getMinHeight: () => 420, getMaxHeight: () => 4096,
|
||||
});
|
||||
@@ -66,7 +69,7 @@ app.registerExtension({
|
||||
return result;
|
||||
};
|
||||
const removed = node.onRemoved;
|
||||
node.onRemoved = function () { compare.destroy(); return removed?.apply(this, arguments); };
|
||||
node.onRemoved = function () { stopWheelRouting(); compare.destroy(); return removed?.apply(this, arguments); };
|
||||
node.setSize([Math.max(550, node.size[0]), Math.max(550, node.size[1])]);
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
.dd-inpaint *, .dd-inpaint *::before, .dd-inpaint *::after { box-sizing: border-box; }
|
||||
.dd-inpaint:focus-within { border-color: #9f7950; }
|
||||
.dd-inpaint .dd-head { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; padding: 11px 12px 6px; align-items: center; }
|
||||
.dd-inpaint .dd-head { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; padding: 6px 12px 2px; align-items: center; }
|
||||
.dd-inpaint .dd-brand { font-size: 11px; letter-spacing: 1.5px; font-weight: 700; color: var(--dd-accent); }
|
||||
.dd-inpaint .dd-dimensions, .dd-inpaint .dd-zoom { font-size: 11px; color: var(--dd-muted); font-variant-numeric: tabular-nums; }
|
||||
.dd-inpaint .dd-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 5px 10px; }
|
||||
@@ -27,6 +27,7 @@
|
||||
.dd-inpaint .dd-stage { position: relative; flex: 1 1 290px; min-height: 170px; overflow: hidden; border-top: 1px solid var(--dd-line); border-bottom: 1px solid var(--dd-line); background-color: #161a20; background-image: linear-gradient(45deg, #222831 25%, transparent 25%), linear-gradient(-45deg, #222831 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #222831 75%), linear-gradient(-45deg, transparent 75%, #222831 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; }
|
||||
.dd-inpaint .dd-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: crosshair; }
|
||||
.dd-inpaint .dd-stage.dd-pan canvas { cursor: grab; }
|
||||
.dd-inpaint .dd-stage.dd-erasing canvas { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cpath d='M6 19 18 7a3 3 0 0 1 4 0l5 5a3 3 0 0 1 0 4L16 27h-5l-5-5a2 2 0 0 1 0-3Z' fill='%23ffb3c0' stroke='%23151a22' stroke-width='3' stroke-linejoin='round'/%3E%3Cpath d='m7 19 7-7 10 10-5 5h-8l-4-4a3 3 0 0 1 0-4Z' fill='%23fff4f6'/%3E%3Cpath d='m13 13 10 10M11 28h17' fill='none' stroke='%23151a22' stroke-width='2'/%3E%3C/svg%3E") 16 16, crosshair; }
|
||||
.dd-inpaint .dd-stage.dd-drop { outline: 3px solid var(--dd-accent); outline-offset: -3px; }
|
||||
.dd-inpaint .dd-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; gap: 6px; color: var(--dd-muted); padding: 15px; text-align: center; }
|
||||
.dd-inpaint .dd-empty[hidden] { display: none; }
|
||||
@@ -41,6 +42,28 @@
|
||||
.dd-inpaint .dd-embed input { accent-color: #e9ab68; margin: 0; }
|
||||
.dd-inpaint .dd-status { padding: 5px 11px 8px; color: var(--dd-muted); font-size: 10px; min-height: 27px; overflow-wrap: anywhere; }
|
||||
.dd-inpaint .dd-error { color: #ff9c92; }
|
||||
.dd-inpaint [hidden] { display: none !important; }
|
||||
.dd-inpaint .dd-parameters { flex: 0 0 auto; padding: 10px 12px 8px; border-top: 1px solid var(--dd-line); }
|
||||
.dd-inpaint .dd-param-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 7px; }
|
||||
.dd-inpaint .dd-param-header strong { font-weight: 500; font-size: 12px; }
|
||||
.dd-inpaint .dd-param-placeholder { color: var(--dd-muted); padding-block: 4px; font-size: 11px; }
|
||||
.dd-inpaint .dd-param-body { display: flex; flex-direction: column; gap: 8px; }
|
||||
.dd-inpaint .dd-param-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.dd-inpaint .dd-param-label { color: var(--dd-muted); font-size: 11px; }
|
||||
.dd-inpaint .dd-param-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.dd-inpaint .dd-param-pills { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.dd-inpaint .dd-param-pills button { flex: 1; font-size: 11px; padding: 5px 7px; }
|
||||
.dd-inpaint .dd-param-number-label { display: flex; align-items: center; justify-content: space-between; gap: 5px; color: var(--dd-muted); font-size: 11px; }
|
||||
.dd-inpaint .dd-param-field input[type="number"] { width: 86px; min-width: 0; padding: 4px 5px; border: 1px solid var(--dd-line); border-radius: 4px; background: var(--dd-panel); color: var(--dd-text); font: inherit; font-variant-numeric: tabular-nums; }
|
||||
.dd-inpaint .dd-param-field select, .dd-inpaint .dd-param-header select { min-width: 0; max-width: 100%; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); border-radius: 4px; padding: 5px; font: inherit; }
|
||||
.dd-inpaint .dd-param-field:has(input[aria-label="随机种子"]) input { width: 130px; }
|
||||
.dd-inpaint .dd-param-resolution { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.dd-inpaint .dd-param-resolution button { font-size: 11px; }
|
||||
.dd-inpaint .dd-param-advanced { border-top: 1px solid var(--dd-line); padding-top: 7px; }
|
||||
.dd-inpaint .dd-param-advanced summary { color: var(--dd-muted); cursor: pointer; font-size: 11px; }
|
||||
.dd-inpaint .dd-param-advanced-body { display: flex; flex-direction: column; gap: 8px; padding-top: 10px; }
|
||||
.dd-inpaint .dd-param-toggle { display: flex; gap: 6px; align-items: center; color: var(--dd-muted); font-size: 11px; }
|
||||
.dd-inpaint .dd-param-toggle input { margin: 0; accent-color: #e9ab68; }
|
||||
.dd-inpaint .dd-resize-handle { flex: 0 0 18px; display: flex; align-items: center; justify-content: center; color: var(--dd-muted); background: var(--dd-panel); border-top: 1px solid var(--dd-line); cursor: ns-resize; touch-action: none; font-size: 14px; line-height: 1; }
|
||||
.dd-inpaint .dd-resize-handle:hover, .dd-inpaint .dd-resize-handle:focus-visible { color: var(--dd-accent); background: #39424e; outline: none; }
|
||||
.dd-comparer .dd-compare-toolbar, .dd-comparer .dd-adoption { flex-wrap: nowrap; }
|
||||
@@ -51,6 +74,25 @@
|
||||
.dd-comparer .dd-adoption select { flex: 1; }
|
||||
.dd-comparer .dd-compare-stage { min-height: 220px; }
|
||||
.dd-comparer .dd-compare-stage canvas { cursor: ew-resize; }
|
||||
.dd-inline-minimal { position: relative; border: 0; border-radius: 0; background: transparent; }
|
||||
.dd-inline-minimal .dd-stage { border: 0; min-height: 170px; flex-basis: 0; }
|
||||
.dd-inline-minimal > .dd-files { position: absolute; z-index: 2; left: 5px; top: 5px; padding: 0; }
|
||||
.dd-inline-minimal > .dd-files button { min-height: 23px; padding: 2px 6px; font-size: 11px; opacity: .85; }
|
||||
.dd-inline-minimal > .dd-files button:hover { opacity: 1; }
|
||||
.dd-inline-minimal > .dd-resize-handle { position: absolute; z-index: 2; bottom: 0; width: 100%; height: 5px; min-height: 5px; border: 0; color: transparent; background: transparent; }
|
||||
.dd-inline-minimal > .dd-resize-handle:hover { background: #ffc07966; }
|
||||
.dd-inline-minimal > .dd-status:not([hidden]) { position: absolute; bottom: 6px; left: 0; right: 0; background: #20242bee; }
|
||||
.dd-editor-controls { height: auto; flex: 0 0 auto; border: 0; border-radius: 0; background: transparent; overflow: visible; }
|
||||
.dd-editor-controls .dd-source-info { justify-content: space-between; }
|
||||
.dd-editor-controls .dd-filename { padding-left: 0; flex: 1; }
|
||||
.dd-workbench { height: 100%; border: 0; border-radius: 0; }
|
||||
.dd-workbench-tools { flex: 0 0 auto; }
|
||||
.dd-tools-placeholder { padding: 8px 12px; }
|
||||
.dd-comparer { border: 0; border-radius: 0; }
|
||||
.dd-comparer .dd-compare-stage { border: 0; }
|
||||
.dd-comparer .dd-compare-toolbar { padding-top: 5px; padding-bottom: 2px; }
|
||||
.dd-comparer .dd-compare-toolbar .dd-dimensions { margin-left: 5px; }
|
||||
.dd-comparer .dd-adoption { padding-top: 3px; padding-bottom: 5px; }
|
||||
.dd-comparer .dd-compare-captions { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; justify-content: space-between; pointer-events: none; }
|
||||
.dd-comparer .dd-compare-captions span { background: #171d27cc; color: #f1e3cf; padding: 3px 7px; border-radius: 3px; font-size: 11px; }
|
||||
@container (max-width: 380px) { .dd-inpaint .dd-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 12px; } }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Original implementation, MIT licensed. No WebUI source is bundled. */
|
||||
import { registerImageWheel } from "./wheel_gestures.js";
|
||||
const el = (tag, cls, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (cls) node.className = cls;
|
||||
@@ -40,18 +41,38 @@ export class InlineMaskEditor {
|
||||
this.observer.observe(this.stage);
|
||||
// Nodes 2.0 captures wheel events before widget handlers. Route only
|
||||
// this canvas at window capture so image gestures stay inside it.
|
||||
this.captureWheel = event => { if (event.target === this.canvas) this.wheel(event); };
|
||||
window.addEventListener("wheel", this.captureWheel, { capture: true, passive: false });
|
||||
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
|
||||
this.modifierDown = event => {
|
||||
if (event.key !== "Alt" || !this.ready || !this.canvas.matches(":hover")) return;
|
||||
event.preventDefault(); this.altErase = true; this.updateButtons(); this.schedule();
|
||||
};
|
||||
this.modifierUp = event => {
|
||||
if (event.type !== "blur" && event.key !== "Alt") return;
|
||||
this.altErase = false; this.updateButtons(); this.schedule();
|
||||
};
|
||||
window.addEventListener("keydown", this.modifierDown, true);
|
||||
window.addEventListener("keyup", this.modifierUp, true);
|
||||
window.addEventListener("blur", this.modifierUp);
|
||||
this.root.addEventListener("keydown", (event) => this.keydown(event));
|
||||
this.root.addEventListener("keyup", (event) => {
|
||||
if (event.code === "Space") { this.space = false; this.stage.classList.remove("dd-pan"); }
|
||||
if (event.key === "Alt") { this.altErase = false; this.updateButtons(); this.schedule(); }
|
||||
});
|
||||
this.root.addEventListener("blur", () => { this.space = false; this.altErase = false; this.updateButtons(); this.schedule(); }, true);
|
||||
this.root.addEventListener("paste", (event) => {
|
||||
const paste = event => {
|
||||
const file = [...(event.clipboardData?.files || [])].find(f => f.type.startsWith("image/"));
|
||||
if (file) { event.preventDefault(); event.stopPropagation(); this.upload(file); }
|
||||
});
|
||||
};
|
||||
this.root.addEventListener("paste", paste);
|
||||
if (this.controls) {
|
||||
this.controls.addEventListener("paste", paste);
|
||||
this.controls.addEventListener("blur", () => { this.space = false; this.altErase = false; this.updateButtons(); this.schedule(); }, true);
|
||||
this.controls.addEventListener("keydown", event => this.keydown(event));
|
||||
this.controls.addEventListener("keyup", event => {
|
||||
if (event.code === "Space") { this.space = false; this.stage.classList.remove("dd-pan"); }
|
||||
if (event.key === "Alt") { this.altErase = false; this.updateButtons(); this.schedule(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
button(parent, label, title, action, cls = "") {
|
||||
@@ -66,12 +87,11 @@ export class InlineMaskEditor {
|
||||
|
||||
build() {
|
||||
const head = el("div", "dd-head");
|
||||
head.append(el("span", "dd-brand", "ADACHI WORKBENCH"));
|
||||
this.sizeLabel = el("span", "dd-dimensions", "内联蒙版 · INLINE MASK");
|
||||
head.append(this.sizeLabel);
|
||||
this.root.append(head);
|
||||
const files = el("div", "dd-row dd-files");
|
||||
this.button(files, "载入原图", "上传图片,也可拖放或粘贴 / Upload image", () => this.imageInput.click(), "dd-primary");
|
||||
this.loadButton = this.button(files, "载入原图", "上传图片,也可拖放或粘贴 / Upload image", () => this.imageInput.click(), "dd-primary");
|
||||
this.filename = el("span", "dd-filename", "拖放图片到画布");
|
||||
files.append(this.filename);
|
||||
this.root.append(files);
|
||||
@@ -123,7 +143,6 @@ export class InlineMaskEditor {
|
||||
this.canvas.addEventListener("pointercancel", event => this.pointerUp(event));
|
||||
this.canvas.addEventListener("lostpointercapture", event => this.pointerUp(event));
|
||||
this.canvas.addEventListener("pointerleave", () => { this.cursor = null; this.schedule(); });
|
||||
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
|
||||
this.stage.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); this.stage.classList.add("dd-drop"); });
|
||||
this.stage.addEventListener("dragleave", () => this.stage.classList.remove("dd-drop"));
|
||||
this.stage.addEventListener("drop", event => {
|
||||
@@ -158,7 +177,19 @@ export class InlineMaskEditor {
|
||||
embedRow.append(this.embed, document.createTextNode("原图嵌入工作流 · 分享时无需另传原图"));
|
||||
this.root.append(embedRow);
|
||||
this.message = el("div", "dd-status", "Alt 临时擦除 · Ctrl+滚轮调笔径 · 滚轮缩放 / 空格拖动");
|
||||
this.message.hidden = true;
|
||||
this.message.setAttribute("role", "alert");
|
||||
this.root.append(this.message);
|
||||
if (this.options.compact) {
|
||||
this.root.classList.add("dd-inline-minimal");
|
||||
this.empty.hidden = true;
|
||||
this.controls = el("div", "dd-inpaint dd-editor-controls");
|
||||
const info = el("div", "dd-row dd-source-info"); info.append(this.filename, this.sizeLabel);
|
||||
head.remove();
|
||||
this.controls.append(info, tools, settings, actions, footer, embedRow);
|
||||
// These are the original live controls; the sampler mounts this
|
||||
// same DOM tree so brush state and callbacks are preserved.
|
||||
}
|
||||
this.setTool("brush");
|
||||
this.updateButtons();
|
||||
}
|
||||
@@ -180,6 +211,7 @@ export class InlineMaskEditor {
|
||||
status(message, error = false) {
|
||||
this.message.textContent = message;
|
||||
this.message.classList.toggle("dd-error", error);
|
||||
this.message.hidden = !error;
|
||||
}
|
||||
|
||||
async upload(file) {
|
||||
@@ -227,6 +259,7 @@ export class InlineMaskEditor {
|
||||
this.lastMask = maskData;
|
||||
this.fit();
|
||||
this.updateButtons();
|
||||
this.options.onImage?.(image);
|
||||
this.status("橙色为绘制蒙版 · Alt 临时擦除 · Ctrl+滚轮调笔径");
|
||||
} catch (error) {
|
||||
if (version === this.loadVersion && !this.destroyed) this.status(error.message, true);
|
||||
@@ -259,6 +292,7 @@ export class InlineMaskEditor {
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
this.stage?.classList.toggle("dd-erasing", !!(this.altErase || this.tool === "erase" || this.stroke?.type === "erase"));
|
||||
for (const [name, button] of Object.entries(this.toolButtons || {})) {
|
||||
const active = name === (this.altErase ? "erase" : this.tool);
|
||||
button.classList.toggle("dd-active", active);
|
||||
@@ -274,6 +308,11 @@ export class InlineMaskEditor {
|
||||
|
||||
dimensions() {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
if (this.viewport?.width && this.viewport?.height && r.width && r.height) {
|
||||
this.pan.x *= r.width / this.viewport.width;
|
||||
this.pan.y *= r.height / this.viewport.height;
|
||||
}
|
||||
this.viewport = { width: r.width, height: r.height };
|
||||
const scale = this.image ? Math.min(r.width / this.image.width, r.height / this.image.height) * this.zoom : 1;
|
||||
return { width: r.width, height: r.height, scale, x: (r.width - (this.image?.width || 0) * scale) / 2 + this.pan.x, y: (r.height - (this.image?.height || 0) * scale) / 2 + this.pan.y };
|
||||
}
|
||||
@@ -297,6 +336,7 @@ export class InlineMaskEditor {
|
||||
}
|
||||
if (this.view === "image") { this.view = "overlay"; this.updateButtons(); }
|
||||
this.stroke = { type: event.button === 2 || event.altKey ? "erase" : this.tool, size: this.brushSize, hardness: this.hardness, opacity: this.brushOpacity, points: [this.cursor] };
|
||||
this.updateButtons();
|
||||
if (["brush", "erase"].includes(this.stroke.type)) this.paintSegment(this.mask.getContext("2d"), this.stroke, this.cursor, this.cursor);
|
||||
this.schedule();
|
||||
}
|
||||
@@ -347,6 +387,7 @@ export class InlineMaskEditor {
|
||||
this.drag = null;
|
||||
const id = this.pointerId; this.pointerId = null;
|
||||
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
|
||||
this.updateButtons();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
@@ -532,8 +573,12 @@ export class InlineMaskEditor {
|
||||
ctx.restore();
|
||||
}
|
||||
if (this.cursor && (this.altErase || ["brush", "erase"].includes(this.tool)) && !this.drag) {
|
||||
const erasing = this.altErase || this.tool === "erase" || this.stroke?.type === "erase";
|
||||
ctx.save();
|
||||
if (erasing) ctx.setLineDash([4 / d.scale, 3 / d.scale]);
|
||||
ctx.beginPath(); ctx.arc(this.cursor.x, this.cursor.y, this.brushSize * this.cursor.p / 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#15191f"; ctx.lineWidth = 3 / d.scale; ctx.stroke(); ctx.strokeStyle = "white"; ctx.lineWidth = 1 / d.scale; ctx.stroke();
|
||||
ctx.strokeStyle = "#15191f"; ctx.lineWidth = 3 / d.scale; ctx.stroke(); ctx.strokeStyle = erasing ? "#ffd0d7" : "white"; ctx.lineWidth = 1 / d.scale; ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
}
|
||||
@@ -541,8 +586,11 @@ export class InlineMaskEditor {
|
||||
destroy() {
|
||||
this.destroyed = true; ++this.loadVersion;
|
||||
cancelAnimationFrame(this.frame); this.observer.disconnect();
|
||||
window.removeEventListener("wheel", this.captureWheel, true);
|
||||
this.stopWheelCapture();
|
||||
window.removeEventListener("keydown", this.modifierDown, true);
|
||||
window.removeEventListener("keyup", this.modifierUp, true);
|
||||
window.removeEventListener("blur", this.modifierUp);
|
||||
for (const canvas of [this.mask, this.baseMask, this.overlay, this.canvas]) canvas.width = canvas.height = 0;
|
||||
this.history = []; this.future = []; this.image = null; this.root.remove();
|
||||
this.history = []; this.future = []; this.image = null; this.controls?.remove(); this.root.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { InlineMaskEditor } from "./editor.js";
|
||||
import { installInpaintWorkbench } from "./inpaint_node.js";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
|
||||
const styleId = "adachi-inpaint-style";
|
||||
if (!document.getElementById(styleId)) {
|
||||
@@ -37,6 +39,7 @@ function imageUrl(value) {
|
||||
app.registerExtension({
|
||||
name: "Adachi.InlineInpaint",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (["AdachiWebUIInpaint", "AdachiInpaintPrepare"].includes(nodeData.name)) { installInpaintWorkbench(nodeType); return; }
|
||||
if (nodeData.name !== "AdachiInlineMask") return;
|
||||
const originalCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
@@ -56,6 +59,21 @@ app.registerExtension({
|
||||
let restoring = false;
|
||||
let restoreFrame = 0;
|
||||
let uploadVersion = 0;
|
||||
let fitFrame = 0;
|
||||
const fitNodeToImage = () => {
|
||||
cancelAnimationFrame(fitFrame);
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
if (!editor.ready || editor.destroyed || !node.graph) return;
|
||||
const rect = editor.canvas.getBoundingClientRect();
|
||||
const scale = app.canvas?.ds?.scale || 1;
|
||||
if (!rect.width || !rect.height) return;
|
||||
const overhead = node.size[1] - rect.height / scale;
|
||||
const height = overhead + rect.width / scale * editor.image.height / editor.image.width;
|
||||
node.setSize([node.size[0], Math.max(240, Math.min(4200, Math.ceil(height)))]);
|
||||
node.properties.adachi_compact_ui = true;
|
||||
editor.schedule();
|
||||
});
|
||||
};
|
||||
const restore = async () => {
|
||||
const embedded = sourceWidget?.value;
|
||||
const filename = imageWidget?.value;
|
||||
@@ -67,8 +85,11 @@ app.registerExtension({
|
||||
finally { restoring = false; }
|
||||
};
|
||||
const editor = new InlineMaskEditor({
|
||||
compact: true,
|
||||
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
|
||||
onDirty() { markChanged(); },
|
||||
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
|
||||
onGraphWheel: event => app.canvas?.processMouseWheel(event),
|
||||
async onUpload(file) {
|
||||
const version = ++uploadVersion;
|
||||
const body = new FormData();
|
||||
@@ -94,6 +115,7 @@ app.registerExtension({
|
||||
},
|
||||
});
|
||||
node.adachiEditor = editor;
|
||||
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
|
||||
node.adachiUseResult = async file => {
|
||||
if (editor.destroyed) throw new Error("内联蒙版节点已移除");
|
||||
await editor.options.onUpload(file);
|
||||
@@ -103,7 +125,7 @@ app.registerExtension({
|
||||
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => 520,
|
||||
getMinHeight: () => 170,
|
||||
getMaxHeight: () => 4096,
|
||||
});
|
||||
domWidget.serialize = false;
|
||||
@@ -120,7 +142,7 @@ app.registerExtension({
|
||||
editor.root.append(resizeHandle);
|
||||
let resizing = null;
|
||||
const resizeHeight = height => {
|
||||
node.setSize([node.size[0], Math.max(620, Math.min(4200, height))]);
|
||||
node.setSize([node.size[0], Math.max(240, Math.min(4200, height))]);
|
||||
node.graph?.setDirtyCanvas(true, true);
|
||||
editor.schedule();
|
||||
};
|
||||
@@ -173,8 +195,8 @@ app.registerExtension({
|
||||
const previousImageCallback = imageWidget.callback;
|
||||
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
|
||||
const previousRemoved = node.onRemoved;
|
||||
node.onRemoved = function () { cancelAnimationFrame(restoreFrame); editor.destroy(); return previousRemoved?.apply(this, arguments); };
|
||||
node.setSize([Math.max(510, node.size[0]), Math.max(650, node.size[1])]);
|
||||
node.onRemoved = function () { cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
|
||||
node.setSize([Math.max(380, node.size[0]), Math.max(480, node.size[1])]);
|
||||
restoreFrame = requestAnimationFrame(restore);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { InpaintParameterPanel } from "./inpaint_panel.js";
|
||||
import { connectedEditor, connectedSamplers, relocateWidgets } from "./parameter_bridge.js";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
|
||||
export function installInpaintWorkbench(nodeType) {
|
||||
const created = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = created?.apply(this, arguments), node = this;
|
||||
const root = document.createElement("div"); root.className = "dd-inpaint dd-workbench";
|
||||
const tools = document.createElement("div"); tools.className = "dd-workbench-tools";
|
||||
const placeholder = document.createElement("div"); placeholder.className = "dd-param-placeholder dd-tools-placeholder";
|
||||
placeholder.textContent = "连接内联蒙版后显示画笔工具";
|
||||
tools.append(placeholder); root.append(tools);
|
||||
let source = null, mountedEditor = null, frame = 0, removed = false;
|
||||
const markChanged = () => {
|
||||
app.canvas?.emitBeforeChange?.(); app.canvas?.emitAfterChange?.();
|
||||
node.graph?.setDirtyCanvas(true, true);
|
||||
};
|
||||
const refreshEditor = () => {
|
||||
source = connectedEditor(node);
|
||||
const editor = source?.adachiEditor;
|
||||
// A live control tree has one host. Additional samplers still get
|
||||
// their own parameters and use the same source image dimensions.
|
||||
const canMount = editor?.controls && connectedSamplers(source)[0] === node;
|
||||
const next = canMount ? editor : null;
|
||||
if (next !== mountedEditor) {
|
||||
if (mountedEditor?.controls.parentElement === tools) mountedEditor.controls.remove();
|
||||
mountedEditor = next;
|
||||
if (next) tools.append(next.controls);
|
||||
scheduleSize();
|
||||
}
|
||||
placeholder.hidden = !!next;
|
||||
placeholder.textContent = editor ? "画笔工具位于连接此原图的另一个重绘节点" : "连接内联蒙版后显示画笔工具";
|
||||
};
|
||||
const contentHeight = () => (tools.scrollHeight || 30) + (panel?.root.scrollHeight || 620);
|
||||
const scheduleSize = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (removed || !node.graph || !root.isConnected) return;
|
||||
const required = node.computeSize()[1];
|
||||
if (Math.abs(node.size[1] - required) > 1) node.setSize([node.size[0], required]);
|
||||
node.graph.setDirtyCanvas(true, true);
|
||||
});
|
||||
};
|
||||
let panel;
|
||||
panel = new InpaintParameterPanel({
|
||||
getTargets: () => { refreshEditor(); return [node]; },
|
||||
getImage: () => source?.adachiEditor?.ready ? source.adachiEditor.image : null,
|
||||
onTargetChange: (previous, next) => relocateWidgets(node, previous, next),
|
||||
onDirty: markChanged, onHeight: scheduleSize,
|
||||
});
|
||||
root.append(panel.root);
|
||||
node.adachiParameters = panel; node.adachiWorkbench = { root, tools, get source() { return source; } };
|
||||
const stopWheelRouting = routeWheelOutsideImage(root, null, () => false);
|
||||
const widget = node.addDOMWidget("adachi_workbench", "adachi_inpaint_controls", root, {
|
||||
serialize: false, hideOnZoom: false, getMinHeight: contentHeight, getMaxHeight: () => Math.max(4096, contentHeight()),
|
||||
});
|
||||
widget.serialize = false;
|
||||
const observer = new ResizeObserver(scheduleSize); observer.observe(tools);
|
||||
const configured = node.onConfigure;
|
||||
node.onConfigure = function () { const result = configured?.apply(this, arguments); queueMicrotask(() => { if (!removed) { panel.refresh(); scheduleSize(); } }); return result; };
|
||||
const connections = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function () { const result = connections?.apply(this, arguments); queueMicrotask(() => { if (!removed) panel.refresh(); }); return result; };
|
||||
const previousRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
removed = true; cancelAnimationFrame(frame); observer.disconnect();
|
||||
if (mountedEditor?.controls.parentElement === tools) mountedEditor.controls.remove();
|
||||
panel.destroy(); stopWheelRouting(); root.remove();
|
||||
return previousRemoved?.apply(this, arguments);
|
||||
};
|
||||
node.setSize([Math.max(510, node.size[0]), node.size[1]]); scheduleSize();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* Inline controls edit the connected sampler's real widgets, not a second set of parameters. */
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; };
|
||||
export const INPAINT_FIELDS = ["mask_mode", "fill", "area", "padding", "width", "height", "denoise", "sampler_name", "scheduler", "steps", "cfg", "seed", "control_after_generate", "batch_size", "mask_blur", "mask_grow", "resize_mode", "inpaint_model", "soft_edge", "soft_strength", "refiner_switch"];
|
||||
const choices = {
|
||||
mask_mode: { inside: "蒙版内", outside: "蒙版外" },
|
||||
fill: { fill: "填充", original: "原图像素", latent_noise: "潜空间噪声", latent_nothing: "空白潜空间" },
|
||||
area: { whole_image: "整图重绘", only_masked: "仅蒙版区域" },
|
||||
resize_mode: { stretch: "拉伸", crop: "居中裁剪", fill: "保比例填充" },
|
||||
control_after_generate: { fixed: "固定种子", increment: "生成后递增", decrement: "生成后递减", randomize: "生成后随机" },
|
||||
};
|
||||
|
||||
export class InpaintParameterPanel {
|
||||
constructor(options) {
|
||||
this.options = options; this.bindings = []; this.root = el("section", "dd-parameters");
|
||||
const header = el("div", "dd-param-header"); header.append(el("strong", "", "局部重绘"));
|
||||
this.targetSelect = el("select"); this.targetSelect.setAttribute("aria-label", "控制的局部重绘节点");
|
||||
this.targetSelect.onchange = () => { this.preferred = this.targetSelect.value; this.options.onPreferred?.(this.preferred); this.refresh(); };
|
||||
header.append(this.targetSelect); this.root.append(header);
|
||||
this.placeholder = el("div", "dd-param-placeholder", "连接 WebUI 局部重绘节点后,在这里调整参数");
|
||||
this.body = el("div", "dd-param-body"); this.root.append(this.placeholder, this.body);
|
||||
this.observer = new ResizeObserver(() => options.onHeight?.()); this.observer.observe(this.root);
|
||||
this.timer = setInterval(() => { if (this.root.isConnected && !document.hidden) this.refresh(); }, 350);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
const targets = this.options.getTargets?.() || [];
|
||||
const preferred = this.preferred || this.options.getPreferred?.();
|
||||
const next = targets.find(n => String(n.id) === String(preferred)) || targets[0] || null;
|
||||
const signature = targets.map(n => `${n.id}:${n.title}`).join("|");
|
||||
if (signature !== this.signature) {
|
||||
this.signature = signature; this.targetSelect.replaceChildren();
|
||||
for (const n of targets) { const option = el("option", "", `${n.title || "局部重绘"} #${n.id}`); option.value = String(n.id); this.targetSelect.append(option); }
|
||||
}
|
||||
this.targetSelect.hidden = targets.length < 2;
|
||||
if (next) this.targetSelect.value = String(next.id);
|
||||
if (next !== this.target) {
|
||||
const previous = this.target; this.target = next;
|
||||
this.options.onTargetChange?.(previous, next);
|
||||
this.bindings = []; this.body.replaceChildren();
|
||||
if (next) this.build();
|
||||
this.options.onHeight?.();
|
||||
}
|
||||
this.placeholder.hidden = !!next; this.body.hidden = !next;
|
||||
for (const binding of this.bindings) binding.sync();
|
||||
if (this.sourceSize) this.sourceSize.disabled = !this.options.getImage?.();
|
||||
}
|
||||
|
||||
widget(name) { return this.target?.widgets?.find(w => w.name === name); }
|
||||
linked(name) { return this.target?.inputs?.some(input => input.name === name && input.link != null); }
|
||||
write(name, value, commit = false) {
|
||||
const widget = this.widget(name); if (!widget || this.linked(name)) return;
|
||||
if (widget.value !== value) { widget.value = value; widget.callback?.(value); }
|
||||
if (commit) this.options.onDirty?.();
|
||||
}
|
||||
|
||||
enum(parent, name, label, pills = false) {
|
||||
const widget = this.widget(name); if (!widget) return;
|
||||
const wrapper = el("div", "dd-param-field"); wrapper.append(el("span", "dd-param-label", label));
|
||||
let values = widget.options?.values; if (typeof values === "function") values = values();
|
||||
if (!Array.isArray(values)) values = Object.keys(choices[name] || {});
|
||||
if (!values.includes(widget.value)) values = [widget.value, ...values];
|
||||
if (pills) {
|
||||
const row = el("div", "dd-param-pills");
|
||||
const buttons = values.map(value => {
|
||||
const button = el("button", "", choices[name]?.[value] || String(value)); button.type = "button";
|
||||
button.onclick = () => { this.write(name, value, true); this.refresh(); }; row.append(button); return { button, value };
|
||||
});
|
||||
wrapper.append(row); this.bindings.push({ sync: () => { for (const {button,value} of buttons) { button.classList.toggle("dd-active", widget.value === value); button.setAttribute("aria-pressed", String(widget.value === value)); button.disabled = this.linked(name); } } });
|
||||
} else {
|
||||
const select = el("select"); select.setAttribute("aria-label", label);
|
||||
for (const value of values) { const option = el("option", "", choices[name]?.[value] || String(value)); option.value = value; select.append(option); }
|
||||
select.onchange = () => this.write(name, select.value, true); wrapper.append(select);
|
||||
this.bindings.push({ sync: () => { if (document.activeElement !== select) select.value = widget.value; select.disabled = this.linked(name); } });
|
||||
}
|
||||
parent.append(wrapper);
|
||||
}
|
||||
|
||||
number(parent, name, label, { min, max, step = 1, range = false, rangeMax = max } = {}) {
|
||||
const widget = this.widget(name); if (!widget) return;
|
||||
min ??= widget.options?.min ?? 0; max ??= widget.options?.max ?? 1000000;
|
||||
const wrapper = el("label", "dd-param-field"); const row = el("span", "dd-param-number-label"); row.append(el("span", "", label));
|
||||
const input = el("input"); input.type = "number"; input.min = min; input.max = max; input.step = step; input.setAttribute("aria-label", label); row.append(input); wrapper.append(row);
|
||||
let slider;
|
||||
const update = raw => {
|
||||
if (raw === "" || !Number.isFinite(Number(raw))) return;
|
||||
const value = Math.max(min, Math.min(max, Math.round(Number(raw) / step) * step));
|
||||
this.write(name, Number(value.toFixed(step < 1 ? Math.ceil(-Math.log10(step)) : 0)));
|
||||
if (slider) slider.value = widget.value;
|
||||
};
|
||||
input.oninput = () => update(input.value); input.onchange = () => { update(input.value); input.value = widget.value; this.options.onDirty?.(); };
|
||||
if (range) {
|
||||
slider = el("input"); slider.type = "range"; slider.min = min; slider.max = rangeMax ?? max; slider.step = step; slider.setAttribute("aria-label", `${label}滑条`);
|
||||
slider.oninput = () => { update(slider.value); input.value = widget.value; }; slider.onchange = () => this.options.onDirty?.(); wrapper.append(slider);
|
||||
}
|
||||
this.bindings.push({ sync: () => {
|
||||
if (document.activeElement !== input) input.value = widget.value;
|
||||
if (slider && document.activeElement !== slider) slider.value = widget.value;
|
||||
input.disabled = this.linked(name); if (slider) slider.disabled = input.disabled;
|
||||
} }); parent.append(wrapper);
|
||||
}
|
||||
|
||||
toggle(parent, name, label) {
|
||||
const widget = this.widget(name); if (!widget) return;
|
||||
const wrapper = el("label", "dd-param-toggle"), input = el("input"); input.type = "checkbox";
|
||||
input.onchange = () => this.write(name, input.checked, true); wrapper.append(input, document.createTextNode(label)); parent.append(wrapper);
|
||||
this.bindings.push({ sync: () => { input.checked = !!widget.value; input.disabled = this.linked(name); } });
|
||||
}
|
||||
|
||||
grid(parent) { const grid = el("div", "dd-param-grid"); parent.append(grid); return grid; }
|
||||
|
||||
build() {
|
||||
this.enum(this.body, "mask_mode", "重绘区域", true);
|
||||
this.enum(this.body, "fill", "重绘参考内容", true);
|
||||
this.enum(this.body, "area", "重绘画布", true);
|
||||
const resolution = el("div", "dd-param-resolution"); resolution.append(el("span", "dd-param-label", "采样分辨率"));
|
||||
this.sourceSize = el("button", "", "使用原图宽高"); this.sourceSize.type = "button";
|
||||
this.sourceSize.title = "填写当前原图宽高;按 8 的倍数对齐,并限制在 64–8192 像素";
|
||||
this.sourceSize.onclick = () => {
|
||||
const image = this.options.getImage?.(); if (!image) return;
|
||||
const aligned = value => Math.max(64, Math.min(8192, Math.round(value / 8) * 8));
|
||||
this.write("width", aligned(image.naturalWidth || image.width)); this.write("height", aligned(image.naturalHeight || image.height));
|
||||
this.options.onDirty?.(); this.refresh();
|
||||
};
|
||||
resolution.append(this.sourceSize); this.body.append(resolution);
|
||||
this.number(this.body, "width", "宽度", { min: 64, max: 8192, step: 8, range: true });
|
||||
this.number(this.body, "height", "高度", { min: 64, max: 8192, step: 8, range: true });
|
||||
this.number(this.body, "denoise", "重绘幅度", { min: 0, max: 1, step: .01, range: true });
|
||||
const sampling = this.grid(this.body); this.enum(sampling, "sampler_name", "采样器"); this.enum(sampling, "scheduler", "调度器");
|
||||
const scale = this.grid(this.body);
|
||||
this.number(scale, "steps", "采样步数", { min: 1, max: 10000, range: true, rangeMax: 150 });
|
||||
this.number(scale, "cfg", "提示词引导 CFG", { min: 0, max: 100, step: .1, range: true, rangeMax: 30 });
|
||||
const seed = this.grid(this.body); this.number(seed, "seed", "随机种子", { min: 0, max: Number.MAX_SAFE_INTEGER }); this.enum(seed, "control_after_generate", "生成后种子");
|
||||
const advanced = el("details", "dd-param-advanced"), summary = el("summary", "", "蒙版、批量与高级设置"); advanced.append(summary);
|
||||
const content = el("div", "dd-param-advanced-body"); advanced.append(content);
|
||||
this.number(content, "padding", "蒙版外扩边距", { min: 0, max: 2048 });
|
||||
const masks = this.grid(content);
|
||||
this.number(masks, "mask_blur", "蒙版模糊", { min: 0, max: 128, step: .5 }); this.number(masks, "mask_grow", "蒙版扩张 / 收缩", { min: -128, max: 128 });
|
||||
const batch = this.grid(content); this.number(batch, "batch_size", "每张生成数量", { min: 1, max: 16 }); this.enum(batch, "resize_mode", "缩放方式");
|
||||
this.toggle(content, "inpaint_model", "使用专用 Inpaint 模型"); this.toggle(content, "soft_edge", "额外羽化边缘");
|
||||
this.number(content, "soft_strength", "额外羽化强度", { min: 0, max: 1, step: .05, range: true });
|
||||
this.number(content, "refiner_switch", "Refiner 切换比例", { min: 0, max: 1, step: .01, range: true }); this.body.append(advanced);
|
||||
}
|
||||
|
||||
destroy() { clearInterval(this.timer); this.observer.disconnect(); this.options.onTargetChange?.(this.target, null); this.target = null; this.root.remove(); }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { INPAINT_FIELDS } from "./inpaint_panel.js";
|
||||
|
||||
const hiddenWidgets = new WeakMap();
|
||||
const targetSizes = new WeakMap();
|
||||
|
||||
function setHiddenOption(widget, value) {
|
||||
widget.options ??= {};
|
||||
// Recent frontends keep a reactive options proxy on BaseWidget state.
|
||||
// Write through that proxy first so Nodes 2.0 observes runtime relocation.
|
||||
const options = widget._state?.options || widget.options;
|
||||
if (value === undefined) delete options.hidden; else options.hidden = value;
|
||||
if (value === undefined) delete widget.options.hidden; else widget.options.hidden = value;
|
||||
}
|
||||
|
||||
export function connectedEditor(target) {
|
||||
const graph = target.graph; if (!graph) return null;
|
||||
const input = target.inputs?.find(input => input.name === "image");
|
||||
const link = graph.links?.[input?.link]; const queue = link ? [link.origin_id] : [], seen = new Set(), sources = new Set();
|
||||
while (queue.length && seen.size < 256) {
|
||||
const id = queue.shift(); if (seen.has(id)) continue; seen.add(id);
|
||||
const current = graph.getNodeById(id); if (!current) continue;
|
||||
if (current.type === "AdachiInlineMask") { sources.add(current.id); continue; }
|
||||
for (const input of current.inputs || []) {
|
||||
if (current.type === "AdachiImageCompare" && input.name !== "result") continue;
|
||||
if (!["IMAGE", "*"].includes(input.type)) continue;
|
||||
const next = graph.links?.[input.link]; if (next) queue.push(next.origin_id);
|
||||
}
|
||||
}
|
||||
return sources.size === 1 ? graph.getNodeById([...sources][0]) : null;
|
||||
}
|
||||
|
||||
export function connectedSamplers(source) {
|
||||
return (source.graph?._nodes || [])
|
||||
.filter(node => ["AdachiWebUIInpaint", "AdachiInpaintPrepare"].includes(node.type) && connectedEditor(node) === source)
|
||||
.sort((a, b) => Number(b.type === "AdachiWebUIInpaint") - Number(a.type === "AdachiWebUIInpaint"));
|
||||
}
|
||||
|
||||
export function relocateWidgets(owner, previous, next) {
|
||||
if (previous) {
|
||||
for (const widget of previous.widgets || []) {
|
||||
const state = hiddenWidgets.get(widget); if (!state) continue;
|
||||
state.owners.delete(owner); if (state.owners.size) continue;
|
||||
widget.type = state.type; widget.hidden = state.hidden;
|
||||
setHiddenOption(widget, state.optionHidden);
|
||||
if (state.ownComputeSize) widget.computeSize = state.computeSize; else delete widget.computeSize;
|
||||
if (state.element) state.element.style.display = state.display;
|
||||
hiddenWidgets.delete(widget);
|
||||
}
|
||||
const size = targetSizes.get(previous); if (size) { previous.setSize([previous.size[0], Math.max(size[1], previous.computeSize()[1])]); targetSizes.delete(previous); }
|
||||
}
|
||||
if (next) {
|
||||
if (!targetSizes.has(next)) targetSizes.set(next, [...next.size]);
|
||||
for (const widget of next.widgets || []) {
|
||||
if (!INPAINT_FIELDS.includes(widget.name)) continue;
|
||||
let state = hiddenWidgets.get(widget);
|
||||
if (!state) {
|
||||
const element = widget.element || widget.inputEl;
|
||||
state = { type: widget.type, hidden: widget.hidden, optionHidden: widget.options?.hidden, ownComputeSize: Object.hasOwn(widget, "computeSize"), computeSize: widget.computeSize, element, display: element?.style.display || "", owners: new Set() };
|
||||
hiddenWidgets.set(widget, state);
|
||||
widget.type = "hidden"; widget.hidden = true; setHiddenOption(widget, true);
|
||||
widget.computeSize = () => [0, -4]; if (element) element.style.display = "none";
|
||||
}
|
||||
state.owners.add(owner);
|
||||
}
|
||||
next.setSize([next.size[0], next.computeSize()[1]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Keep a continuous wheel gesture with its original viewport. */
|
||||
const canvases = new Map();
|
||||
let owner = null, lastTime = 0, modifiers = "";
|
||||
const IDLE_MS = 180;
|
||||
const reset = () => { owner = null; lastTime = 0; modifiers = ""; };
|
||||
|
||||
function capture(event) {
|
||||
// Ignore synthetic events forwarded by ComfyUI; they are not new gestures.
|
||||
if (!event.isTrusted) return;
|
||||
const entry = canvases.get(event.target);
|
||||
const rect = entry?.canvas.getBoundingClientRect();
|
||||
const inside = rect && event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
|
||||
const canvas = entry && inside && entry.ready() ? entry.canvas : null;
|
||||
const now = performance.now(), keys = `${event.ctrlKey}:${event.altKey}:${event.metaKey}:${event.shiftKey}`;
|
||||
if (!owner || now - lastTime > IDLE_MS || keys !== modifiers) owner = canvas || "graph";
|
||||
else if (owner !== "graph" && owner !== canvas) owner = "graph";
|
||||
lastTime = now; modifiers = keys;
|
||||
if (!entry || !inside || !entry.ready()) return;
|
||||
if (owner === canvas) entry.wheel(event);
|
||||
else if (entry.graphWheel) {
|
||||
event.preventDefault(); event.stopPropagation(); entry.graphWheel(event);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerImageWheel(canvas, ready, wheel, graphWheel) {
|
||||
if (!canvases.size) {
|
||||
window.addEventListener("wheel", capture, { capture: true, passive: false });
|
||||
window.addEventListener("blur", reset);
|
||||
}
|
||||
canvases.set(canvas, { canvas, ready, wheel, graphWheel });
|
||||
return () => {
|
||||
canvases.delete(canvas);
|
||||
if (!canvases.size) { window.removeEventListener("wheel", capture, true); window.removeEventListener("blur", reset); reset(); }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
export function routeWheelOutsideImage(root, canvas, isReady) {
|
||||
const route = event => {
|
||||
if (!root.contains(event.target)) return;
|
||||
if (event.target === canvas && isReady()) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
// Preserve the user's ComfyUI navigation mode (zoom or pan).
|
||||
app.canvas?.processMouseWheel(event);
|
||||
};
|
||||
window.addEventListener("wheel", route, { capture: true, passive: false });
|
||||
return () => window.removeEventListener("wheel", route, true);
|
||||
}
|
||||