Compare commits

..
28 Commits
Author SHA1 Message Date
adachi f1dbaf0fb0 Restore dropped exclusive inputs from subgraph output routes 2026-10-16 15:09:46 +08:00
adachi 3c225ea242 Resolve Get Set bypass states before validation and cache lookup 2026-10-16 14:48:44 +08:00
adachi 8f128f5417 Resolve exclusive inputs from submitted prompt connections 2026-10-16 14:33:42 +08:00
adachi f822bb3382 Pin inline fit and reset comparison rounds after adoption 2026-10-16 14:10:06 +08:00
adachi 51e8a94f14 Reduce comparison redraw cost at large workflow zoom 2026-10-16 13:52:29 +08:00
adachi af3de6eafb Deduplicate replayed comparison previews 2026-10-16 13:44:26 +08:00
adachi 9a38504ea7 Keep comparison fitted and retain results across runs 2026-10-16 13:29:43 +08:00
adachi a9e2b7c168 Follow Get Set routes when selecting the enabled input 2026-10-16 13:14:04 +08:00
adachi d5cb95d2e1 Bind editor and panel widget widths to node geometry 2026-10-16 12:16:21 +08:00
adachi 95b09ee484 Isolate Fit button clicks and version inline editor assets 2026-10-16 12:03:13 +08:00
adachi 75d42c44d1 Place Fit beside Load image in the inline mask 2026-10-16 11:30:11 +08:00
adachi 5fcb150384 Support single-frame 5D VAE latents for Anima and other image models 2026-10-16 11:27:13 +08:00
adachi fdc70ede61 Set the Comfy Registry publisher id
The publisher account adachi0130 exists on the registry, so fill in
tool.comfy.PublisherId to enable publishing this node pack there.
2026-10-16 10:54:27 +08:00
adachi a6657bac72 Translate the preset names and drop locale snapshots in examples 03/04
The group preset display names and the group titles stored inside each preset
were still Chinese, and the slots carried localized_name snapshots from the
author's machine. Translate the user-visible names to English and drop the
snapshots so every reader sees their own ComfyUI locale.
2026-10-16 10:47:33 +08:00
adachi 977c352361 Make the new example workflows English-consistent
Examples 03 and 04 kept Chinese note text, Chinese group names and a saved
Chinese node title, unlike examples 01 and 02. Translate the notes and group
names to English and drop the AdachiGroupPresets title override so the node
falls back to its locale-aware name, which keeps the workflows consistent and
still shows Chinese in a Chinese ComfyUI.
2026-10-16 10:45:43 +08:00
adachi 540f3c0c45 Keep the new nodes English-only
The exclusive switch and group preset work reintroduced Chinese text into
backend error messages and a frontend node description, which contradicts the
English-by-default rule that the Manager listing follows. Move those strings
back to English and route the frontend description through the translation
dictionary, so a Chinese ComfyUI still shows Chinese via locales/ and the
canvas dictionary.
2026-10-16 10:32:10 +08:00
adachi b2087ba565 Add automatic exclusive input switch and widen preset list 2026-10-16 10:25:29 +08:00
Adachi fa644d9969 Preserve masks on adoption and add group bypass presets 2026-10-16 09:38:55 +08:00
adachi f31e15ec9f Release 0.4.2 with English defaults and a zh locale
Bump the version and record the change: user-facing strings are English by
default and node class IDs, parameter identifiers and enum values are
unchanged. locales/en and locales/zh make the node pack follow the ComfyUI
language setting, so a Chinese ComfyUI shows Chinese labels while English
stays the built-in default.
2026-10-15 19:46:21 +08:00
adachi 340fc54b49 Document that the node pack follows the ComfyUI language setting 2026-10-15 19:33:42 +08:00
adachi 3664cbf1d8 Follow the ComfyUI language for the plugin's own canvas and panel labels
ComfyUI's locales/ feature only covers node definitions, so the custom painting
controls and parameter panel are translated here instead, by reading Comfy.Locale.

English stays the built-in default, which keeps the Manager listing usable for
the general audience. When the ComfyUI language is Chinese the canvas buttons and
the parameter panel labels switch to Chinese as well; any key without a
translation falls back to English.
2026-10-15 19:33:10 +08:00
adachi c132d1517b Add zh locale so the nodes can display Chinese again
English stays the source of truth in code so the ComfyUI Manager listing is
usable for the general audience; Chinese returns through ComfyUI's official
locale feature, where locales/<lang>/nodeDefs.json and main.json are merged
into the /i18n response (see app/custom_node_manager.py).

Set the ComfyUI language to zh and hard-refresh to see the Chinese node names,
socket names and tooltips again; other languages keep the English defaults.
Node class IDs, parameter identifiers and enum values are unchanged, so
existing workflows are unaffected by switching language.
2026-10-15 19:19:01 +08:00
Adachi c49cae39e2 Make user-facing strings English for ComfyUI Manager listing
Node display names, category, descriptions, tooltips and all frontend labels are
now English so the nodes are usable for the general Manager audience. Node class
IDs, parameter identifiers and enum values are unchanged, so existing workflows
stay compatible. Also documents how this pack differs from ComfyUI's built-in
modal mask editor and Painter node.
2026-10-15 19:07:41 +08:00
adachi cf70590406 Remove informal tone suffix from user-facing text and docs 2026-10-15 17:41:59 +08:00
adachi a84b897d76 Refresh the README demo image 2026-10-15 17:40:34 +08:00
Adachi 40e05092d7 Document pending ComfyUI Manager listing submission 2026-10-15 17:22:00 +08:00
Adachi 3f2fc7e033 Prepare public Adachi-Inpaint-Workbench release and distinct eraser cursor 2026-10-15 17:17:04 +08:00
Codex 9a60d9e174 Move editor controls to inpaint node and minimize image node chrome 2026-10-15 16:47:45 +08:00
46 changed files with 3574 additions and 567 deletions
+6
View File
@@ -0,0 +1,6 @@
.github/
preview/
tests/
registry/
VERIFICATION.md
ROLLBACK.md
+38
View File
@@ -0,0 +1,38 @@
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
- name: Check exclusive routing behavior
run: node --experimental-default-type=module tests/test_exclusive_routes.mjs
- name: Check comparison result history
run: node --experimental-default-type=module tests/test_compare_history.mjs
+29
View File
@@ -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 }}
+123
View File
@@ -0,0 +1,123 @@
# Changelog
## 0.5.13
- Restore a missing enabled execution input by resolving its saved workflow route through Get/Set, reroutes and subgraph output slots to an existing flattened execution node.
- Fix the captured remote failure where the selected positive branch existed in a tag subgraph but its input link was absent from the submitted exclusive node. Never invent missing backend nodes or connect a different node type.
- Include node ID, frontend/workflow flags and submitted-connection flags in selection errors to distinguish an ignored branch from lost execution wiring.
- Validate three actual failed remote request snapshots locally and two lightweight CPU queue probes with the same missing-link/subgraph mapping. Restart ComfyUI after this backend update.
## 0.5.12
- Trace enabled Get nodes back through their named Set and encoder in saved workflow metadata; ignored Sets disable their branch even when both Gets remain enabled and both execution links survive.
- Normalize exclusive branch flags and remove only ignored execution inputs before ComfyUI validation/cache lookup. Keep all original workflow wires and normal cache reuse.
- Repeat the workflow check during lazy selection, support scoped subgraph IDs and both serialized link formats, and retain raw-API behavior without workflow metadata.
- Validate the supplied remote workflow's routing states using lightweight real image queues, including reverse switching, cache reuse and strict invalid states. A full ComfyUI restart is required after updating.
## 0.5.11
- Resolve exclusive selection against the actual submitted prompt connections, overriding stale frontend flags when only one branch survives Get/Set, mute or bypass resolution.
- Support dynamically expanded subgraph node IDs through ComfyUI's dynamic prompt, while retaining explicit branch flags for bypassed transforms that keep both physical connections.
- Keep both-enabled/both-absent errors and include the received branch state in their diagnostic message. Restart ComfyUI after updating this backend change.
## 0.5.10
- Add a pin toggle beside Fit in Inline Mask: keep the image fitted, route plain wheel to the workflow, retain Ctrl+wheel brush sizing and save the toggle with the workflow.
- Shorten the inline load button to Upload (上传).
- Bound inline preview bitmap allocation to source detail and an 8 Mi-pixel budget; cache the source/mask composite during cursor movement. Full-resolution mask data remains unchanged.
- Successful adoption resets comparison history to the accepted image as 1/1. The next generation starts a fresh result list; failed adoption preserves history.
## 0.5.9
- Bound comparison bitmap resolution to source detail, 8192 pixels per edge and a 16 Mi-pixel allocation budget, avoiding quadratic bitmap growth at large workflow zoom.
- Redraw only the strip swept by the comparison divider during pointer movement; redraw fully after image, size or hover changes.
- Draw the divider as a separate overlay so it remains one screen pixel wide even when magnifying the workflow.
## 0.5.8
- Count each comparison preview file once, even when cached execution notifications replay it; keep the selected result on replay.
- Remove duplicate preview entries when reloading older saved comparison histories.
## 0.5.7
- Accumulate comparison results across runs, select the newest result and preserve each result's matching original, including after workflow reload.
- Keep comparison images permanently fitted and centered in the canvas; remove independent wheel zoom and middle/Space image panning.
- Route wheel events over the full comparison node, including the picture, to ComfyUI workflow navigation.
- Preserve hover comparison, the full-result view on pointer leave, batch selection and adoption with mask/history retention.
## 0.5.6
- Follow KJNodes Get/Set routes and reroutes when deciding which exclusive input is enabled, including the setter's and real source node's bypass/mute states.
- Support virtual routers using the current resolveVirtualOutput API, and safely reject routing cycles or missing setters.
- Exclude inactive inputs from detached execution DTOs before frontend link resolution, avoiding missing-input errors when a virtual Get itself is bypassed; retain all editable/saved workflow wires.
- Add dependency-free routing regression checks to CI and validate actual KJ Get/Set workflows with real CPU queue output pixels in both supported frontend versions/renderers.
## 0.5.5
- Bind the inline canvas, inpaint parameter panel and comparison widget width directly to the node's logical graph width. Cached screen-space measurements cannot shrink the widgets a second time at reduced graph zoom.
- Keep native widget margins and vertical sizing; horizontal and vertical node resizing remain available.
- Calculate source-image automatic canvas height from untransformed client dimensions rather than dividing screen measurements by graph zoom.
- Verify stale-width fault injection, horizontal/vertical resize, zoom, Fit/mask preservation and workflow reload on frontend 1.42.11 and 1.52.7 in both renderers.
## 0.5.4
- Prevent the inline Fit button click from bubbling into ComfyUI node selection / bring-to-front handlers, which can remeasure a saved node's DOM editor.
- Keep node and editor dimensions unchanged when fitting the image. Fit still resets only image zoom and pan.
- Version the inline editor module and stylesheet URLs to refresh imported assets on upgrades; update an existing shared stylesheet link as well.
- Verify actual clicks on saved workflow node geometry in frontend 1.42.11 and 1.52.7, with classic nodes and Nodes 2.0.
## 0.5.3
- Move the image Fit button from the inpaint painting controls to the inline mask, immediately after Load image.
- Keep viewport reset and mask/history behavior unchanged. Includes the 0.5.2 single-frame 5D VAE compatibility fix.
## 0.5.2
- Support still-image inpainting with both 4D `[B,C,H,W]` and single-frame 5D `[B,C,1,H,W]` VAE latents, including the Wan-derived VAE used by Anima/Qwen-image pipelines.
- Preserve the independent image/variant batch axis when a video-derived VAE would otherwise interpret the batch as frames.
- Match noise/fill masks to latent rank and convert single-frame decoded images back to ordinary IMAGE batches before compositing.
- Keep native inpaint conditioning and its batch/mask metadata compatible with single-frame 5D latents.
- Add regression coverage and verify actual ComfyUI Wan VAE encode/decode and native mask preparation with tiny random test weights; no real Anima checkpoint was available locally.
## 0.5.1
- Add Auto Exclusive Switch: two generic lazy inputs, no manual switch control, and exactly one enabled branch required.
- Respect muted/bypassed input branches, including bypassed transform nodes that normally pass through data; branch changes also invalidate execution caches.
- Widen the group preset list from 32% to 40% of its panel.
- Keep the new nodes English-only as well: backend error messages and the group preset node description no longer carry inline Chinese, and the two new example workflows use English note text and group names. The group preset node keeps no saved title override, so it shows its locale-aware name in any language.
## 0.5.0
- Preserve the painted mask, undo/redo history and viewport when adopting a comparison result as the source; an ordinary image upload still starts a new mask.
- Add Group Bypass Presets: a connection-free controller with a preset list, per-group enabled/bypassed/unmanaged states, and a right-click preset menu.
- Store presets in the workflow with stable group IDs, support renamed and nested groups, and leave newly added/unmanaged groups untouched.
- Add a model-free example workflow for group presets.
## 0.4.2
- Make every user-facing string English by default: node display names, category, descriptions, tooltips and the canvas/panel labels.
- Keep node class IDs, parameter identifiers and enum values unchanged, so existing workflows stay compatible.
- Add `locales/en` and `locales/zh` and follow the ComfyUI language setting, so a Chinese ComfyUI shows Chinese labels while English stays the built-in default. Any missing translation falls back to English.
- Document how this pack differs from ComfyUI's built-in modal mask editor and `Painter` node.
## 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.
+176 -114
View File
@@ -1,109 +1,173 @@
# 安达工作台 / Adachi Workbench
# Adachi-Inpaint-Workbench
直接在 ComfyUI 工作流节点里载入原图、涂抹蒙版、局部重绘,无需另开遮罩编辑器喵。
Load an image, paint a mask and inpaint **directly inside the workflow node** — no modal mask editor and no save/load round-trip on every stroke.
## 安装
![Inline mask painting with WebUI-style inpainting](preview/nodes2-inline.png)
将整个 `ComfyUI-Adachi-Workbench` 文件夹复制到 `ComfyUI/custom_nodes/`,保留其中的 `web/`、`backend.py`、`__init__.py` 喵。
重启 ComfyUI,然后在浏览器按 `Ctrl+F5` 刷新前端缓存喵。
在节点搜索中输入 `Inline Mask` 或 `WebUI Inpaint`,分类是 **安达工作台 / Adachi Workbench** 喵。
MIT licensed, by Adachi.
Supports whole-image and masked-area sampling, an Alt temporary eraser, Ctrl+wheel brush size, image comparison and one-click adoption of a result as the next source image.
Windows 也可以从解压后的插件文件夹运行安装脚本,将参数改为自己的、包含 `main.py` 的 ComfyUI 目录喵。
## How this differs from other nodes
| | ComfyUI's built-in mask editing | This node pack |
|---|---|---|
| Where you paint | A **modal editor** opens over the graph (`MaskEditor`); you close it to get back to the workflow | You paint **on the node's own canvas**, and the mask lives in the workflow |
| Per-stroke cost | Commit the mask in the dialog, then re-run | Every stroke updates the mask in the browser and is stored with the workflow; no disk save/load per stroke |
| Sampling | Left to the workflow you build | The connected **WebUI Inpaint** node carries the sampling controls (`area`, `width/height`, `padding`, `mask_blur`, `mask_grow`, fill mode, sampler, Refiner) |
The built-in `Painter` node draws an image and exposes a mask, but it stops there — it has no inpaint pipeline. The built-in `MaskEditor` edits masks in a modal dialog. `ComfyUI-Inpaint-CropAndStitch` does crop-and-stitch well but does not paint. This pack connects the whole loop in one place: **paint → sample → composite at original resolution → compare → adopt the result as the next source and keep painting.**
## Install
Copy the whole `Adachi-Inpaint-Workbench` folder into `ComfyUI/custom_nodes/`, keeping `web/`, `backend.py` and `__init__.py`.
Restart ComfyUI, then hard-refresh the browser with `Ctrl+F5`.
Search for `Inline Mask` or `WebUI Inpaint`; the category is **Adachi-Inpaint-Workbench**.
The node pack follows the ComfyUI language setting: node names, sockets and tooltips come from `locales/`, and the canvas buttons and parameter panel from `web/i18n.js`, so a Chinese ComfyUI shows the Chinese labels. English is the built-in default, and any missing entry falls back to English.
On Windows you can instead run the installer from the unzipped plugin folder, pointing it at your ComfyUI directory (the one containing `main.py`).
```powershell
powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_windows_portable\ComfyUI"
```
脚本只复制本插件;更新时会把旧插件压缩备份到所选 ComfyUI 的 `user/adachi-workbench-backups/`,不会自动重启服务喵。
插件使用 ComfyUI 已有的 PyTorch、NumPy、Pillow,不需要额外 pip/npm 安装喵。
卸载时关闭 ComfyUI,再删除 `custom_nodes/ComfyUI-Adachi-Workbench` 即可喵。
The installer only copies this plugin; on update it zips the previous installation into `user/adachi-workbench-backups/` and does not restart the server.
The plugin uses ComfyUI's existing PyTorch, NumPy and Pillow — no extra pip or npm packages.
To uninstall, close ComfyUI and delete `custom_nodes/Adachi-Inpaint-Workbench`.
## 最快上手
### ComfyUI Manager and Comfy Registry
1. 将 `examples/01-inline-inpaint.json` 拖入 ComfyUI,选择自己的 checkpoint 喵。
2. 在 **内联蒙版 / Inline Mask** 节点载入、拖放或粘贴图片,直接在画布上涂抹需要重绘的部分喵。
3. 修改正负提示词,在 **内联蒙版** 图片下方选择重绘范围、采样分辨率、采样器与重绘幅度,然后运行喵。
4. `image` 输出回贴后的完整原图,`crop` 输出实际采样画布,`mask` 输出该采样画布对应的蒙版喵。
Manager **Git URL** installation can use `https://github.com/adachi0130/Adachi-Inpaint-Workbench`, or clone it into `ComfyUI/custom_nodes`:
**白色蒙版重绘,黑色保护;画布中橙色代表绘制区域** 喵。
当 `mask_mode=outside` 时会反相,重绘橙色之外的部分喵。
`mask_blur`、`mask_grow`、额外羽化会改变有效蒙版边界,边界内的混合像素也会变化;有效蒙版为零的像素逐值保留喵。
```bash
git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
```
## 两种重绘范围
A Manager **listing** submission is open at [ComfyUI-Manager PR #3378](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3378) and still needs the maintainer to merge it and refresh the index, so search visibility is not guaranteed yet. A Comfy Registry publisher is not configured; see [the publishing notes](registry/README.md).
| `area` | 对应操作 | 实际行为 |
## Quick start
1. Drag `examples/01-inline-inpaint.json` into ComfyUI and pick your checkpoint.
2. In **Inline Mask**, upload, drop or paste an image and paint the areas to repaint directly on the canvas.
3. Edit the positive/negative prompts, then set the painting tools, sampling area, sampling resolution, sampler and denoise in the **WebUI Inpaint** node, and run.
4. `image` outputs the full composited picture, `crop` outputs the actual sampling canvas, and `mask` outputs that canvas's mask.
**White repaints, black preserves; orange is the painted area on the canvas.**
With `mask_mode=outside` this inverts and everything outside the painted area is repainted.
`mask_blur`, `mask_grow` and extra feathering change the effective mask boundary, so blend pixels at that boundary change too; pixels where the effective mask is zero are preserved exactly.
## The two sampling areas
| `area` | What it does | Behaviour |
|---|---|---|
| `whole_image` | 以原图范围重绘 | 将整张图缩放到 `width × height` 采样,再按照蒙版回贴原图 |
| `only_masked` | 以蒙版范围重绘 | 取蒙版包围框,加 `padding` 上下文,尽量扩展到目标比例,将局部放大到采样尺寸,生成后贴回原位置 |
| `whole_image` | Repaint at the source extent | Scales the whole image to `width × height`, samples, then composites back through the mask |
| `only_masked` | Repaint at the mask extent | Takes the mask bounding box, adds `padding` context, expands toward the target ratio, upscales that region to the sampling size, and pastes the result back in place |
`width/height` 指采样画布尺寸,须为 8 的倍数;两种模式的最终 `image` 都保持上传原图的分辨率喵。
这一点与 WebUI 整图模式常以目标宽高输出的行为有区别;需要目标尺寸的图时,可取 `crop`,或在完整输出后连接缩放节点喵。
`padding`、`mask_blur`、`mask_grow` 的单位都是原图像素喵。
蒙版贴近图像边缘时,局部模式用保比例填充保住整块蒙版,不会为匹配比例切掉蒙版喵。
`resize_mode` 的拉伸、居中裁剪、保比例填充作用于 `whole_image`;局部模式自动采用保留全部蒙版的变换喵。
`width/height` are the sampling canvas size and must be multiples of 8; the final `image` keeps the uploaded image's resolution in both modes.
That differs from the WebUI whole-image mode, which usually outputs at the target size — take `crop`, or add a resize node after the full output, if you want a target-size image.
`padding`, `mask_blur` and `mask_grow` are all measured in source pixels.
When the mask touches an image edge, masked-area mode uses aspect-preserving fill to keep the entire mask instead of cropping it to match the ratio.
`resize_mode` (stretch, center crop, aspect fill) applies to `whole_image`; masked-area mode automatically uses the transform that keeps the whole mask.
## 画布操作
## Canvas controls
| 操作 | 使用方式 |
| Action | How |
|---|---|
| 画笔 / 橡皮 | 工具栏切换,`B` / `E`;按住 `Alt` 或鼠标右键临时擦除,松开恢复原工具 |
| 矩形 / 自由套索 | `R` / `L`,拖动画出要重绘的区域 |
| 笔径 / 硬度 / 笔触 | 分别控制原图像素笔径、边缘硬度、绘制不透明度 |
| 橙色叠加 | 仅调节显示透明度,不改变真实蒙版 |
| 撤销 / 重做 | `Ctrl+Z` / `Ctrl+Shift+Z`,或 `Ctrl+Y` |
| 缩放 / 移动 | 滚轮缩放;空格拖动、鼠标中键或 `H` 移动;「适应」复位 |
| 笔径快捷键 | `[` / `]` |
| 快速调整笔径 | 鼠标放在画布上,按住 `Ctrl` 向上滚轮增大、向下滚轮减小;普通滚轮仍缩放图片 |
| 编辑器高度 | 拖动编辑器底部 `↕` 条上下拉伸;尺寸随工作流保存 |
| 反转 / 全选 / 清空 | 作用于当前绘制蒙版,可撤销 |
| 导入 / 导出蒙版 | 原始分辨率 PNG;黑白图按亮度导入,带透明度的图按 alpha 导入 |
| 原图 / 蒙版 / 叠加 | 切换预览,不改变输出 |
| Brush / eraser | Toolbar toggle, `B` / `E`; hold `Alt` or use the right mouse button to erase temporarily and release to restore the previous tool; the eraser cursor and a pale pink dashed outline show the erase mode |
| Rectangle / freehand lasso | `R` / `L`, drag out the region to repaint |
| Brush size / hardness / brush opacity | Source-pixel brush size, edge hardness and painting opacity |
| Orange overlay | Display opacity only; it never changes the real mask |
| Undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z`, or `Ctrl+Y` |
| Zoom / pan | Wheel to zoom; drag with Space, the middle button or `H` to pan; **Fit** to reset |
| Brush size keys | `[` / `]` |
| Fast brush size | With the pointer over the canvas, hold `Ctrl` and wheel up/down; the plain wheel still zooms the image |
| Editor height | Drag the `↕` bar at the bottom of the editor; the size is saved with the workflow |
| Invert / fill / clear | Act on the current painted mask and are undoable |
| Import / export mask | Full-resolution PNG; greyscale images import by brightness, images with alpha import by alpha |
| Image / mask / overlay | Preview toggles only; they do not change the output |
支持画笔压力和软笔刷;鼠标使用固定压力喵。
笔径、硬度、笔触和叠加四个滑条位于图片编辑区下方喵。
图片上传一次,之后每笔在浏览器内更新蒙版,并在工作流及运行参数中保存 PNG 数据,无需每画一笔都保存、重新加载磁盘文件喵。
鼠标位于图片区域时滚轮缩放图片;位于工具栏、按钮、滑条或参数面板时,滚轮遵循 ComfyUI 的画布导航设置喵。
从 ComfyUI 画布开始的连续滚轮手势移入图片时,继续导航外部画布;停下后再在图片里滚动才缩放图片喵。
超大图仍会消耗浏览器内存,PNG 编码耗时取决于分辨率和设备,不能保证所有图像都瞬间完成喵。
Brush pressure and soft brushes are supported; the mouse uses a fixed pressure.
Inline Mask keeps the image editing area and small **Upload** / **Fit** / pin buttons in the corner, and adjusts the canvas height to the image ratio after loading to reduce empty margins.
The brush, eraser, rectangle, lasso, pan, four brush sliders, undo/redo, mask actions, view toggles, fit and the embed option all live in the connected **WebUI Inpaint** node and drive that same inline canvas.
You upload the image once; every stroke then updates the mask in the browser and is stored as PNG data in the workflow and in the executed parameters, so there is no saving and reloading of files per stroke.
Wheel events over the picture zoom it; over the toolbar, buttons, sliders or the parameter panel they follow ComfyUI's canvas navigation settings.
A continuous wheel gesture that starts on the ComfyUI canvas keeps navigating the outer canvas when it moves over the picture; scrolling inside the picture again after stopping zooms the image.
Very large images still use browser memory, and PNG encoding time depends on resolution and hardware, so instant completion is not guaranteed for every image.
## 内联重绘参数
## Painting tools and inpainting parameters
内联蒙版连接 **WebUI 局部重绘** 的 `image` 输入后,采样参数自动移到内联蒙版底部,直接控制原采样节点的真实参数喵。
宽度和高度各占一行,支持滑条及数值输入;重绘幅度也使用滑条,点击 **使用原图宽高** 可填入原图尺寸,按采样要求取最近的 8 的倍数喵。
蒙版内外、填充、重绘范围、采样器、调度器、步数、CFG 和种子直接显示,蒙版处理、批量、Refiner 切换等位于 **蒙版、批量与高级设置** 中喵。
如果一个内联蒙版连接多个采样节点,可在参数面板上方切换控制目标;断开连接后,采样节点恢复显示自身参数喵。
外部连线控制的参数会禁用对应面板控件;模块化工作流仍使用各独立节点的参数喵。
The Inline Mask header has **Upload**, **Fit** and a pin toggle (**Keep** / **保持**). Enable the pin to keep the picture fitted and centered: plain wheel navigates the workflow, Ctrl+wheel still adjusts the brush, and image panning is disabled. Disable the pin to resume image zoom and pan. The pin state is saved with the workflow. Display buffers are bounded during workflow zoom; the saved mask retains its original resolution.
## 图像对比与连续重绘
Once Inline Mask is connected to the `image` input of **WebUI Inpaint**, the original painting controls appear in that inpaint node, and the parameter panel below drives the node's real sampling parameters.
Width and height each get their own row with both a slider and a number field; denoise is a slider too, and **Use source size** fills in the source dimensions, rounded to the nearest multiple of 8 the sampler requires.
Mask mode, fill, sampling area, sampler, scheduler, steps, CFG and seed are shown directly; mask processing, batch and Refiner switching live under **Mask, batch and advanced settings**.
If one inline source feeds several inpaint nodes, the painting controls go to the first WebUI Inpaint node and each node keeps its own parameter panel; disconnecting or deleting that node moves the tools to the next available one.
Parameters driven by an external link disable the matching panel control; a modular workflow puts the painting tools in **Inpaint Prepare** and keeps the sampling parameters in the separate sampler node.
添加 **图像对比 / Image Compare** 节点,将内联蒙版的 `image` 接到 `original`,最终完整重绘图 `image` 接到 `result`,运行后即可对比喵。
鼠标移入图片时,细竖线左边显示重绘结果、右边显示原图;移出后隐藏分割线,完整显示重绘结果,普通滚轮缩放图片;批量结果可在上方选择喵。
满意后点击 **采用此结果**,插件会把当前选中的结果上传为新的原图,回填到上游内联蒙版,并清空旧蒙版,随后可以继续涂抹下一处喵。
原图连接经过中间 IMAGE 节点时会向上寻找内联蒙版;若存在多个目标,可以在“回填到”中明确选择喵。
回填只在点击按钮时发生,不会自动开始下一轮生成;`image` 输出仍传递整个结果批次,可接保存图像喵。
对比预览保存在 ComfyUI 临时目录,清理或重启后可能需要重新运行;点击采用后的原图保存在 `input/adachi-inpaint/`,不依赖临时预览文件喵。
## Comparison and chained inpainting
## 填充、批量与 Refiner
Add an **Image Compare** node and connect Inline Mask's `image` to `original` and the final full-size generated `image` to `result`, then run.
With the pointer over the picture, the thin divider shows the generated result on the left and the original on the right; leaving the picture hides the divider and shows the full result, and the image always remains fitted and centered in its canvas. Scrolling over the comparison image navigates the ComfyUI workflow background using the current navigation settings; comparison images do not zoom or pan independently. Batch selection, dimensions and the fit button sit below the picture.
When you are happy, click **Adopt this result**: the plugin uploads the selected result as the new source image and writes it back into the upstream inline editor while preserving the painted mask, current undo/redo history and viewport. Use **Clear** when you want to start a new mask; a normal **Upload** still starts a fresh mask.
To preserve mask coordinates, an adopted result must match the source dimensions; a mismatched result is rejected without changing the current source or mask. The mask remains embedded in saved workflow data; command-level undo history is retained during adoption in the current session.
If the original is routed through intermediate IMAGE nodes the plugin walks upstream to find the inline editor; when several candidates exist you can pick one explicitly under **Adopt into**.
Adoption only happens when you click the button and never starts the next generation automatically; the `image` output still passes the whole result batch, so you can feed a Save Image node.
The result list accumulates images across runs and selects the newest result automatically. Each result keeps its original from that run. Successful adoption clears the old list and displays the accepted image as 1/1; the next generation starts a fresh list using the new source. Failed adoption leaves the old list intact. Saving the workflow also saves preview references, selection and the pending new-round state.
| `fill` | 行为 |
Comparison previews live in ComfyUI's temp directory and may need a re-run after cleanup or restart; an adopted source image is stored in `input/adachi-inpaint/` and does not depend on temp previews.
## Group bypass presets
Search for **Group Bypass Presets** (Chinese: **组忽略预设**) under Adachi-Inpaint-Workbench. It needs no connections and does not require rgthree or Danbooru.
1. Click **+ New preset**, name it and save. The preset captures the current groups' enabled/bypassed state.
2. Choose **Enabled**, **Bypassed** or **Keep current** for each group on the right. Changes update and apply the selected preset immediately.
3. Add more presets. Clicking a name on the left applies that preset to all of its configured groups at once. You can also right-click the node and choose **Apply preset**.
4. Use **Save current group states** to capture changes you made directly in the workflow, or rename/delete presets as needed.
Presets are stored in the workflow. Group renames retain their preset mapping; newly added groups stay unmanaged until configured. Loading a workflow restores its saved node modes without automatically applying a preset. If groups overlap, the smaller group's setting takes priority; the preset controller itself stays enabled.
Bypass uses ComfyUI's standard bypass mode, so a bypassed node still needs compatible input/output types when it sits on an active data path.
Try [examples/03-group-presets.json](examples/03-group-presets.json), which uses only empty images and colour inversion and needs no model.
## Fill, batch and Refiner
| `fill` | Behaviour |
|---|---|
| `original` | 使用原图像素编码 latent |
| `fill` | 按蒙版周围颜色进行多尺度填充,再编码 latent |
| `latent_noise` | 在 masked latent 内填入与 seed 相关的噪声 |
| `latent_nothing` | 将 masked latent 置零 |
| `original` | Encode the source pixels into the latent |
| `fill` | Multi-scale fill from the colours surrounding the mask, then encode |
| `latent_noise` | Fill the masked latent with seed-dependent noise |
| `latent_nothing` | Zero the masked latent |
后两种 latent 填充由 **WebUI 局部重绘** 一体节点实现;独立 **重绘预处理** 只处理像素与蒙版,使用标准节点组合时请选择 `original` 或 `fill` 喵。
`denoise=0` 与空蒙版会直接返回原图,跳过 VAE 和采样;`batch_size` 为每张输入图的变体数,使用相同 seed 可重现同一批次喵。
多次批量运行可使用 ComfyUI 的队列数量以及 seed 的生成后控制;`batch_size` 控制单次运行数量喵。
普通 SD/SDXL checkpoint 保持 `inpaint_model=false`;专用 inpainting 模型才开启,届时调用 ComfyUI 原生 `InpaintModelConditioning` 喵。
The two latent fills are implemented by the all-in-one **WebUI Inpaint** node; standalone **Inpaint Prepare** only handles pixels and masks, so choose `original` or `fill` in a standard-node pipeline.
`denoise=0` and an empty mask return the source image directly and skip VAE and sampling; `batch_size` is the number of variants per input image, and the same seed reproduces the same batch.
For repeated batches use ComfyUI's queue count together with the seed's after-generate control; `batch_size` controls a single run.
Keep `inpaint_model=false` for ordinary SD/SDXL checkpoints; enable it only for dedicated inpainting models, which switches to ComfyUI's native `InpaintModelConditioning`.
可选接入兼容的 `refiner_model`,用 `refiner_switch` 指定 base 采样步骤占比;`0.8` 表示约前 80% 步骤由 base 执行喵。
建议用 Refiner 自身的 CLIP 编码并接入 `refiner_positive/refiner_negative`,未接入则沿用原条件,VAE 仍使用 base 侧的 VAE 喵。
`refiner_switch=1` 只用 base,`0` 只用 Refiner;已知 latent 格式不兼容时会报错喵。
You can optionally attach a compatible `refiner_model` and use `refiner_switch` for the share of base sampling steps; `0.8` means roughly the first 80% of steps run on the base model.
Encoding with the Refiner's own CLIP and wiring `refiner_positive`/`refiner_negative` is recommended; if left unconnected the original conditioning is reused and the VAE still comes from the base side.
`refiner_switch=1` uses only the base model and `0` uses only the Refiner; a known-incompatible latent format raises an error.
## 使用标准采样工作流
## Auto Exclusive Switch
`examples/02-modular-inpaint.json` 展示可自由更换采样器的连接方式喵。
Search for **Auto Exclusive Switch** / **自动二选一** under **Adachi-Inpaint-Workbench**.
Connect two alternative branches to `input_a` / `input_b` and connect `output` to the common downstream input.
For example, connect the CONDITIONING outputs of two positive CLIP Text Encode nodes to A/B, then connect `output` to the sampler's positive input.
Bypass one encoder (Ctrl+B), or use a Group Bypass Preset to enable one group and bypass the other: the remaining branch is selected automatically.
Both enabled, or neither connected/enabled, produces an error instead of silently preferring A.
The node accepts conditioning, strings, images, masks, models, latents and other ComfyUI data types; both alternatives and the downstream socket should use the same data type.
The model-free `examples/04-exclusive-switch.json` demonstrates two branches with two group presets.
It has no switch widget. A small internal value is computed from the graph when queuing, including when a bypassed image/model transform would otherwise pass its input through.
Place the selector outside the two controlled groups and connect each group's final node to it.
KJNodes Get/Set routes and reroutes are supported: an ignored/muted Get, its corresponding Set, or the source behind those routing nodes disables that selector branch. Missing setters and virtual routing cycles also count as unavailable. Ordinary processing nodes remain independent active sources; the selector does not guess the meaning of arbitrary processing chains.
The ignored branch is not requested by this selector; any separate preview/save outputs on that branch may still execute independently.
For raw API prompts, omit the ignored input and omit the internal `active_inputs` field; exactly one of `input_a` / `input_b` must remain.
Without workflow metadata, the backend checks actual submitted connections after virtual routing: when only one input survives, it uses that branch even if cached frontend flags still report both enabled. With both connections present and no workflow metadata, the frontend flags remain necessary to distinguish bypassed transform pass-through from an enabled branch. Updating this backend logic requires a full ComfyUI restart as well as a browser refresh.
When workflow metadata accompanies the request, the server independently follows Get → named Set → source node. Both Gets can stay enabled: bypassing one branch's Set or encoder disables that branch. The server normalizes flags and removes ignored execution inputs before validation and cache lookup, while the saved workflow retains its connections. Without workflow metadata the submitted connections/frontend flags remain the fallback.
If the enabled input was dropped during frontend execution serialization, the server can restore its saved connection, including a subgraph output mapped to a flattened node ID. It only reconnects an existing submitted node whose type matches the saved source; it does not create missing model/encoder nodes.
## Using a standard sampling workflow
`examples/02-modular-inpaint.json` shows a wiring you can swap samplers into.
```text
Inline Mask ── image/mask ──> Inpaint Prepare
@@ -116,50 +180,48 @@ Inline Mask ── image/mask ──> Inpaint Prepare
Save Image
```
务必将 `Prepare.context` 接回 **原图回贴 / Inpaint Composite**,这样局部结果会回到原图坐标和尺寸喵。
标准示例针对普通 checkpoint;专用 inpainting checkpoint 应改用其需要的 conditioning 节点,或直接使用一体节点喵。
多个输入图各生成多个变体时,Composite 需要变体按原图分组,例如 `A,A,B,B`;一体节点已按此排列,外部重复 latent 节点如果输出 `A,B,A,B`,需先调整批次顺序喵。
Always wire `Prepare.context` back into **Inpaint Composite** so the local result returns to the source coordinates and size.
The modular example targets ordinary checkpoints; a dedicated inpainting checkpoint should use the conditioning nodes it needs, or the all-in-one node directly.
When several input images each produce several variants, Composite needs the variants grouped per source image (for example `A,A,B,B`); the all-in-one node already orders them that way, while an external latent-repeat node that outputs `A,B,A,B` needs its batch order adjusted first.
## 分享与兼容性
## Sharing and compatibility
绘制蒙版自动写入工作流;默认原图只保存 ComfyUI `input/` 内的相对路径喵。
要让别人直接打开同一张图,勾选画布下方 **原图嵌入工作流** 后再保存工作流 JSON;对方安装本插件并选择自己的模型即可继续编辑喵。
不嵌入时,需要一并提供原图,并保持 `input/` 下的相对路径一致喵。
嵌入会增大工作流文件,原图和蒙版也会随工作流分享,因此分享前请确认图片内容喵。
The painted mask is written into the workflow automatically; by default only a relative path inside ComfyUI's `input/` is stored for the source image.
To hand someone a workflow that opens on the same picture, tick **Embed source in workflow** below the canvas before saving the JSON; they install this plugin, pick their own model and can keep editing.
Without embedding you have to supply the source image as well and keep the same relative path under `input/`.
Embedding grows the workflow file, and the source and mask then travel with the shared workflow, so check the picture content before sharing.
这是原创实现的 WebUI 风格工作流,不承诺与 AUTOMATIC1111/Forge 的结果逐像素一致喵。
`soft_edge` 是额外高斯羽化,`soft_strength` 控制额外半径,**并未复刻 WebUI 的 Soft Inpainting 原算法及其完整参数** 喵。
采样器、调度器和 Refiner 使用当前 ComfyUI 的实现,填充算法也为本插件独立实现喵。
目前支持静态图像与 4 维 latent;不支持视频 VAE、WebUI 脚本/扩展和自动读取 WebUI 插件配置喵。
文件最大 64 MiB、图像最大 3200 万像素、单边最大 16384 像素;不附带模型或下载服务喵。
已在本机 ComfyUI 的隔离 CPU 服务验证节点加载、画布编辑、工作流/运行参数保存与恢复,并用 mock VAE/采样器验证后端;尚未用实际 checkpoint 验证生成质量和各模型家族/Refiner 的兼容性喵。
This is an original WebUI-style implementation and does not promise pixel-perfect agreement with AUTOMATIC1111/Forge results.
`soft_edge` is extra Gaussian feathering with `soft_strength` controlling the extra radius; it **does not reimplement WebUI's Soft Inpainting algorithm or its full parameter set**.
Samplers, schedulers and Refiner use the current ComfyUI implementations, and the fill algorithms are this plugin's own.
Static-image pipelines support ordinary 4D `[B,C,H,W]` latents and single-frame 5D `[B,C,1,H,W]` latents, without hardcoding a model name or channel count. This includes the Wan-derived VAE used by Anima and Qwen-image pipelines. Independent images/variants stay in the batch dimension, with their own masks; they are not encoded as consecutive video frames.
Use the matching model, VAE and text conditioning for the model family. For ordinary Anima img2img, keep **inpaint_model** disabled; that option invokes native conditioning for dedicated inpainting architectures, and is not required merely because a mask is painted.
Actual multi-frame video, audio/3D latents, model-specific control pipelines, WebUI scripts/extensions and automatic WebUI plugin configuration are not implemented.
Limits are 64 MiB per file, 32 megapixels per image and 16384 px per side; no models are bundled and nothing is downloaded.
Node loading, canvas editing, workflow/executed-parameter save and restore have been verified in an isolated CPU ComfyUI service on the development machine, and the backend is verified with mock VAE/samplers; real-checkpoint image quality and compatibility across every model family and Refiner have not been tested.
## English quick start
## Tests
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**喵.
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喵.
White repaints and black preserves; orange is the visual mask overlay喵.
Use `whole_image` to sample the full source extent, or `only_masked` to enlarge the padded mask bounding box for detailed local inpainting喵.
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喵.
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 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喵.
`examples/02-modular-inpaint.json` demonstrates Prepare → standard VAE/KSampler nodes → Composite; use `original` or `fill` in that modular pipeline喵.
`soft_edge` adds Gaussian feathering; it does not implement AUTOMATIC1111 Soft Inpainting or its complete parameter set喵.
The plugin is an original MIT-licensed implementation and does not bundle WebUI/Forge source, models or extensions喵.
Real-checkpoint image quality and every model/Refiner family have not been tested; browser integration and CPU/mock-sampler regression tests have been run喵.
Run the CPU tests from this plugin directory with your ComfyUI Python喵.
Run the CPU tests from this plugin directory with your ComfyUI Python:
```powershell
python tests/test_backend.py -v
python -m unittest discover -s tests -v
```
## 中文简介
本插件把蒙版绘制放在工作流节点内部,**不需要弹出遮罩编辑器**,也省去每画一笔就保存、加载磁盘文件的往返。
连接 **WebUI 局部重绘** 节点后,画笔工具与采样参数都在该节点内,支持整图范围与蒙版范围重绘、原尺寸回贴、Alt 临时橡皮擦、Ctrl+滚轮笔径、图像对比,以及把满意的结果一键回填为新的原图继续重绘。
代码里的默认文案是英文,以便 Manager 用户直接使用;节点搜索可输入 `Inline Mask`,分类为 **Adachi-Inpaint-Workbench**。
想用中文界面时,把 ComfyUI 的语言切成中文即可,插件会自动跟随:
在 ComfyUI 设置里找到 **Locale / 语言**(设置项 `Comfy.Locale`)选择 `zh`,然后刷新浏览器(`Ctrl+F5`)。
节点名称、分类、输入输出名与提示由 `locales/zh/` 提供,通过 ComfyUI 的 `/i18n` 机制加载;
画布按钮与参数面板由 `web/i18n.js` 读取同一个语言设置翻译,因此「宽度」「高度」「上传」等也会一起变回中文。
英文用户看到的一直是英文(英文是内置默认值);某个词条若没有对应翻译,会自动回退为英文。
两套语言共用同一个节点类 ID 和参数键名,所以已保存的工作流不受语言切换影响。
新增「自动二选一」:把两个正面条件(或图像、模型等同类型数据)接到输入 A/B,输出接共同下游;忽略其中一个分支,就自动使用另一个。两路都启用或都无输入会报错,不需要 switch。切换节点放在两个被控制分组之外,也能配合组忽略预设使用。
Anima 兼容:支持其 Wan/Qwen VAE 的单帧五维潜空间,也保留普通四维 VAE;会同步适配蒙版、填充、批次和解码回贴。普通 Anima 图生图请保持“专用重绘模型”关闭,并使用配套模型、VAE 与文本条件;这不是多帧视频重绘功能。
+27 -9
View File
@@ -1,20 +1,38 @@
# Git 版本与回滚
# Git versions and rollback
`v0.2.0` 是本次界面调整前的回滚点,`v0.3.0` 包含内联参数面板、左右对比调整和滚轮路由修复喵。
本机安装目录与源码目录均保留 Git 历史;普通发布 ZIP 不含 `.git`,可另用 Git bundle 恢复历史喵。
The current local version is **v0.5.13**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
在插件目录运行以下命令,先确认没有未保存改动,再切换版本喵。
| Tag | Contents |
|---|---|
| `v0.5.13` | Restore dropped enabled inputs through saved subgraph output routes |
| `v0.5.12` / `local-before-v0.5.13` | Trace Get/Set source states server-side before prompt validation and caching |
| `v0.5.11` / `local-before-v0.5.12` | Resolve exclusive selection from actual submitted connections despite stale frontend flags |
| `v0.5.10` / `local-before-v0.5.11` | Pin inline fit, bound/cache inline previews, Upload label and restart comparison rounds after adoption |
| `v0.5.9` / `local-before-v0.5.10` | Bound comparison bitmap allocations and redraw only the swept divider strip |
| `v0.5.8` / `local-before-v0.5.9` | Deduplicate replayed comparison previews and repair duplicate saved history |
| `v0.5.7` / `local-before-v0.5.8` | Fixed comparison images; wheel navigates the workflow; retain result history across runs |
| `v0.5.6` / `local-before-v0.5.7` | Exclusive selector follows Get/Set and virtual routes correctly |
| `v0.5.5` / `local-before-v0.5.6` | Bind canvas/panel widths to node geometry and retain vertical resizing |
| `v0.5.4` / `local-before-v0.5.5` | Isolate Fit clicks and refresh editor assets on upgrade |
| `v0.5.3` / `local-before-v0.5.4` | Fit beside Load image in the inline mask, plus the 5D VAE fix |
| `v0.5.2` / `local-before-v0.5.3` | Single-frame 5D VAE support for image models, plus all previous features |
| `local-before-v0.5.2` | Exact baseline before the VAE compatibility fix in each local repository |
| `v0.5.1` | Auto Exclusive Switch, wider preset list, and all v0.5.0 features |
| `v0.5.0` / `local-before-v0.5.1` | Preserve masks on adoption and group bypass presets |
| `local-before-v0.5.0` | Exact baseline before mask adoption / group preset changes |
To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach v0.2.0
git switch --detach local-before-v0.5.13
```
恢复本版使用以下命令喵。
Return to this version with:
```powershell
git switch --detach v0.3.0
git switch --detach v0.5.13
```
切换后重启 ComfyUI 并在浏览器按 `Ctrl+F5` 刷新喵。
已有本地修改时请先提交或备份,避免覆盖自己的改动喵。
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
Commit or back up any local modifications before switching. A release ZIP does not contain `.git`; local history is kept in the source and installation repositories.
+66 -24
View File
@@ -1,30 +1,72 @@
# 验证记录
# Verification record
验证环境:Windows、本机 ComfyUI 0.19.3、ComfyUI frontend 1.42.11、PyTorch 2.8.0、Pillow 11.0.0、Chrome 155,使用独立的 8190 端口、CPU、测试输入输出目录和内存数据库喵。
- 0.5.13: the remote history was read without modifying its workflow or queue. Three actual failed request snapshots contained selector 532 with a selected flag but no data input; local normalization restores its saved tag-subgraph connection to `349:412`, leaving selector 533's `16` source intact. Two real local CPU queue probes reproduce the missing-input shape and subgraph mapping with lightweight image sources, verify both selector outputs and upstream changes, and avoid model generation. All 39 backend unit tests pass, including Get/Set recovery, flattened subgraph output slots and refusal to reconnect absent or mismatched source types. The remote's complete Anima generation was not executed by these tests.
- 后端 16 项 CPU 测试通过,覆盖两种范围、边缘蒙版、反相、增长、羽化、周边颜色填充、批量映射、原像素保护、空蒙版、denoise=0、潜空间噪声/置零、专用 inpaint conditioning、不同 VAE 压缩比和 Refiner 分段喵。
- 浏览器 12 组交互测试通过,覆盖真实鼠标绘画、右键擦除、矩形/套索、撤销重做、导入持久化、半压力硬笔刷、活动形状保存、缩放坐标、1600/768/390 像素窗口布局喵。
- 经典节点与 Nodes 2.0 均完成真实画布拖画、工作流序列化、运行参数序列化与重新载入测试,蒙版像素一致且正确触发工作流修改状态,无插件 JavaScript 错误喵。
- 实际 ComfyUI API 队列执行了两种范围的载入→预处理→回贴→保存图像工作流,输出尺寸正确,有效蒙版外 RGB 像素逐值相等喵。
- 两份示例由真实节点生成、重新加载并构造执行参数,节点和连线有效,模型文件名为用户选择占位符喵。
- 安装脚本在临时目录完成首次安装、再次安装及旧版备份测试喵。
- 0.1.1 在经典节点和 Nodes 2.0 用真实鼠标拖动底部高度条,画布增高 160px、缩小 80px,保存并重开后尺寸和蒙版均保留喵。
- 0.1.2 用真实浏览器 Ctrl+滚轮测试笔径增减、1–512px 上下限、滑条数值同步和普通滚轮缩放,确认四个滑条位于画布下方;浏览器共 14 组交互验证通过喵。
- 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.5.12: all 37 CPU unit tests pass. The supplied remote workflow was used as request metadata with lightweight red/blue image probes replacing model generation: positive selector 532 chooses B, negative 533 chooses A, all four Gets remain enabled. Ten real queue cases cover reverse Set/source states, switching back, encoder-only and Set-only bypass, unchanged-workflow preservation, cache reuse, and both-enabled/both-ignored errors. No complete Anima model generation is claimed by these routing probes. Sixteen native KJ Get/Set browser queue cases pass on frontend 1.52.7 in both renderers, and all 15 metadata-free stale-state API queue cases still pass.
采样路径还使用本机 ComfyUI 的真实 common_ksampler/KSampler 函数进行了噪声调度衔接检查;尚未加载实际 checkpoint 测试 GPU 生成质量或所有模型家族,不能将 CPU/接口测试视为模型效果验证喵。
- 0.5.11: all 32 backend CPU tests pass, including stale branch flags with either single submitted input, strict empty/ambiguous input errors, both-connected passthrough selection and dynamic subgraph node lookup. Fifteen real CPU queue cases verify single-branch A/B output pixels for flags -1 through 3, both-connected A/B selection, and invalid cases. Sixteen real KJ Get/Set queue cases on frontend 1.52.7 cover both renderers, source/Set/Get ignore and mute states, reverse switching, both-invalid states and workflow reload.
实现参考资料:
Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.8.0, Pillow 11.0.0, Chrome 155, using a separate port 8190 CPU service with its own input/output directories and an in-memory database.
- [ComfyUI 前端扩展接口](https://docs.comfy.org/custom-nodes/js/javascript_overview)
- [ComfyUI widget 序列化说明](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/WIDGET_SERIALIZATION.md)
- [WebUI 重绘预处理语义](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/master/modules/processing.py)
- [rgthree 图像对比交互参考](https://github.com/rgthree/rgthree-comfy/blob/main/py/image_comparer.py)
- 16 backend CPU tests pass, covering both sampling areas, edge masks, inversion, growth, feathering, surrounding-colour fill, batch mapping, source-pixel protection, empty masks, `denoise=0`, latent noise/zero modes, dedicated inpaint conditioning, different VAE compression ratios and Refiner staging.
- 12 browser interaction tests pass, covering real mouse painting, right-button erase, rectangle/lasso, undo/redo, import persistence, half-pressure hard brush, active-shape saving, zoom coordinates and 1600/768/390 pixel window layouts.
- Painting on the real canvas, workflow serialization, executed-parameter serialization and reload were verified in both classic nodes and Nodes 2.0: mask pixels match and the workflow correctly enters the modified state, with no plugin JavaScript errors.
- The real ComfyUI API queue ran load → prepare → composite → save-image workflows in both sampling areas, with correct output sizes and RGB pixels outside the effective mask equal value-by-value.
- Both example workflows were generated by the real nodes, reloaded and used to build executed parameters; nodes and links are valid and model filenames are user-selectable placeholders.
- The installer was tested in a temp directory for first install, repeat install and previous-version backup.
- 0.1.1: in classic nodes and Nodes 2.0 the bottom height bar was dragged with a real mouse, growing the canvas by 160 px and shrinking it by 80 px; size and mask both survived save and reopen.
- 0.1.2: real-browser Ctrl+wheel tests covered brush size growth and shrink, the 1–512 px limits, slider value sync and plain-wheel zoom, confirming the four sliders sit below the canvas; 14 browser interaction checks passed in total.
- 0.2.0: added Alt temporary erase and the image comparison node; 18 backend tests pass. Real mouse/keyboard use confirms releasing Alt restores the previous tool.
- 0.2.0: the real API queue in classic nodes and Nodes 2.0 verified left/right pixels, the mouse divider, wheel zoom, Ctrl+wheel brush size, batch result selection, upstream editor discovery with several candidates, click-to-adopt, old mask clearing and reopen after save, with no plugin script errors.
- Picture wheel events in both node modes never trigger whole-workflow zoom by mistake, and adopted results are stored in the input directory and can be embedded in the workflow.
- 0.3.0: in classic nodes and Nodes 2.0 the bottom inline parameters were verified to drive the real sampling node, width/height on separate rows, source-size filling, no repeated growth on workflow reopen, disconnect restoring the original parameters, and the original parameter rows actually hidden in Nodes 2.0.
- 0.3.0: wheel events over the toolbar and parameter area pass through to the outer canvas, a continuous outer wheel gesture does not change image zoom when it enters the picture, and wheel events inside the picture after stopping zoom only the picture.
- 0.3.0: the comparison node was measured with the result on the left and the original on the right, showing the full result on exit; the divider is a single physical pixel, the round handle, corner labels and bottom caption were removed, and adoption plus batch selection still pass.
- 0.3.0: 15 brush browser interaction checks pass, vertical stretching and save/restore pass in both rendering modes, and the two updated example workflows pass link and executed-parameter validation.
- 0.4.0: in classic nodes and Nodes 2.0 the inline canvas was verified to keep only the load button with the original painting DOM controls moved into the WebUI node; real mouse painting can then be undone from the inpaint node, the tools and sliders drive the original canvas and the sampling parameters correctly reach the execution request.
- 0.4.0: canvas margins shrinking to the image ratio, thin height-bar stretching, size retention across save/reopen, source-size filling, disconnect/reconnect, deleting and recreating the inpaint node, and toolbar-area wheel events continuing to drive the outer canvas were all verified.
- 0.4.0: the comparison node no longer has a top toolbar, with result selection, dimensions and fit moved to the bottom; left/right comparison in the real API queue, full result on exit, batch selection and result adoption all pass.
- 0.4.1: a real browser confirms the eraser SVG cursor and erase state after holding Alt, restoring the cross brush cursor on release; temporary erase, regular painting and the 18 backend CPU regression tests pass.
- 0.4.1: the public name was unified to Adachi-Inpaint-Workbench while node class IDs were kept so existing workflows stay compatible.
- 0.4.2: node display names, descriptions, tooltips and frontend labels were converted to English so the nodes are usable for the general Manager audience; the same 18 backend CPU tests pass and every touched script parses.
- 0.5.0: in classic nodes and Nodes 2.0, adopting a real queued comparison result preserves mask PNG pixels, base-mask pixels and the current undo/redo command arrays. Undo/redo still work, workflow reload retains the mask, a mismatched result leaves the source/mask unchanged, and ordinary image upload still clears the mask.
- 0.5.0: group preset tests cover real preset-button clicks, enabled/bypassed modes in executed prompts, exclusion of the virtual controller from backend execution, saved workflow reload, renamed and duplicate-title groups, unmanaged new groups, nested group priority, missing groups and protection of the controller itself.
- 0.5.0: the native right-click preset submenu was clicked successfully in both renderers. The example workflow was loaded and all three presets queued successfully without a model.
插件代码为独立实现,发布许可见 LICENSE,未附带上述项目源码喵。
- 0.5.1: all 23 CPU unit tests pass, including exclusive input validation and identity-preserving forwarding of conditioning, image/mask tensors, latents and falsey values.
- 0.5.1: 26 real CPU queue cases across classic nodes and Nodes 2.0 cover A/B selection, bypassed transform pass-through, mute, both-enabled/both-bypassed/both-disconnected errors, reroutes, one connected input, group presets, cache changes, workflow reload and switching reloaded presets. Output pixels match the selected image; the unused branch was not requested by the selector. A raw API prompt works without frontend metadata.
- 0.5.1: the 40%-width preset list retains internal scrolling and has no horizontal overflow in a 360px node at a 390px viewport.
- 0.5.2: all 29 CPU tests pass. New cases cover single-frame 5D image latents, per-source/variant masks, both crop modes, noise/zero fill, third-party VAE wrappers, native inpaint conditioning, refiner continuation, and explicit rejection of real multi-frame output.
- 0.5.2: actual ComfyUI `VAE` + `WanVAE` encode/decode and `prepare_mask` were exercised with a small randomly initialized Wan architecture (16 latent channels). Four cases cover whole-image/original, masked-area/noise, zero-latent fill, and native inpaint conditioning, with two independent sources and two variants each. Latents were `[4,16,1,8,8]`, masks `[4,1,1,8,8]`, decoded images `[4,64,64,3]`, and all protected source pixels remained exact.
- Anima checkpoint image generation was not performed: this machine does not have the user's Anima/Qwen VAE checkpoint files. The workflow was inspected read-only, confirming node 501 uses VAE node 358 (`qwen_image_vae.safetensors`) and the Anima model branch.
- 0.5.3: in classic nodes and Nodes 2.0, the inline Fit button is on the same row immediately to the right of Load image, and is absent from the connected inpaint controls. Actual browser clicks reset zoom/pan and preserve the painted mask PNG exactly.
- 0.5.4: frontend 1.52.7 was downloaded as a wheel and extracted into an isolated test directory, leaving the installed frontend unchanged. With saved node dimensions from the user's workflow, the previous bubbling Fit click changed the classic editor viewport from 396x625 to 396x594. Stopping propagation keeps it at 396x625. Nodes 2.0 also retains its viewport and node dimensions.
- 0.5.4: actual browser clicks pass with the saved workflow geometry on frontend 1.42.11 and 1.52.7 in both renderers. Fit resets zoom/pan, remains immediately after Load image, and the inpaint panel occupies over 94% of its node's logical width. The remote server and its installed extension set were not accessed, so the separate narrow-panel screenshot cannot be claimed reproduced/resolved remotely.
- 0.5.5: injecting a cached widget width equal to 35% of its node width reproduces the narrow-column shape on frontend 1.52.7 classic nodes. Before the fix, a 380px inline node had a 113px editor; afterwards its editor is 360px wide, with the remaining 20px reserved for native margins. Inpaint and comparison widgets recover their full widths as well.
- 0.5.5: browser checks on frontend 1.42.11 and 1.52.7, classic nodes and Nodes 2.0, cover attempted stale width writes, resizing node widths to 800/450/1050px, independent height expansion, graph zoom at 0.3/0.6/1.15, Fit with mask preservation, and saved workflow reload. The simulated failure is covered; the remote server itself was not accessed.
- 0.5.6: inspected the supplied workflow read-only: selectors 532/533 are fed by KJ Get nodes p/p_0 and n/n_0, while the p_0/n_0 setters are bypassed. The previous direct-node check therefore incorrectly classified both branches as enabled.
- 0.5.6: actual local KJNodes Set/Get frontend definitions were loaded only into isolated QA browsers (no KJ code is bundled). Across frontend 1.42.11/1.52.7 and classic/Nodes 2.0, 32 real CPU queue cases verify ignored setters, swapped states, ignored/muted real sources, ignored Get, explicit both/neither errors, and switching after workflow reload. Output image pixels match the selected branch; generating prompts does not change editable connections.
- 0.5.6: dependency-free routing tests cover Get/Set, reroutes, current virtual-output APIs, mute/bypass, missing setters/inputs and cycles. All 29 backend CPU tests continue to pass.
- 0.5.7: actual browser wheel events over a real queued comparison image change workflow navigation while the comparison transform stays fitted and centered. Verified on frontends 1.42.11 and 1.52.7 in classic nodes and Nodes 2.0, with left-result/right-original pixels, hover/leave behavior, batch selection, adoption preserving mask/history, workflow reload, rejected mismatched adoption and ordinary-upload mask reset.
- 0.5.7: three real queued two-image batches accumulate 2, 4 and 6 results and select the newest result. After adoption, old results retain their old original and new results use the adopted original; all six results survive workflow reload in both frontend versions/renderers. Dependency-free tests additionally cover legacy batch mapping, empty outputs and immutable appending.
- 0.5.8: real single-image queues on frontend 1.52.7 in classic nodes and Nodes 2.0 show 1/1 then 2/2. Replaying the same execution output twice does not increase the count or reset a manually selected result. Reloading a workflow containing duplicate preview references removes duplicates. Unit checks distinguish filenames, subfolders and file types.
- 0.5.9: on frontend 1.52.7 in classic nodes and Nodes 2.0, a 1024x1536 comparison was measured at workflow zoom 1x, 2x and 4x. At 4x, 24 synchronous paints with readback dropped from 483 to 36 ms (classic) and 453 to 35 ms (Nodes 2.0). These are isolated browser measurements, not end-to-end workflow FPS guarantees. The divider measured one screen pixel at every zoom. Dirty-strip redraws matched full-redraw pixels exactly across forward/reverse/unchanged divider movements and edge positions; pointer exit hides the divider.
- 0.5.10: frontends 1.42.11 and 1.52.7, both classic nodes and Nodes 2.0, verify the Upload label and pin icon beside Fit, pin persistence across workflow reload, plain wheel navigating the workflow, Ctrl+wheel brush sizing while pinned, and restored image wheel zoom after unpinning. At 4x graph zoom the display allocation remains bounded, the original mask dimensions remain 320x200, and 20 cursor-only redraws never recomposite the mask overlay. All 15 standalone editor regression groups pass, including real painting, Alt/right-button erase, undo/redo, shapes, pressure, serialization and zoom/pan coordinate mapping.
- 0.5.10: actual adoption reduces the comparison list to 1/1, preserves mask PNG and undo/redo identities, and saves the pending new-round state. Reloading before generating retains that state; the next two-image batches start at 2/2 and then accumulate to 4/4. Rejected adoption leaves history unchanged. These checks pass in both frontend versions/renderers.
The sampling path was also checked for noise-schedule continuity against the real `common_ksampler`/`KSampler` functions in the local ComfyUI; an actual checkpoint has not been loaded to test GPU image quality or every model family, so CPU/interface tests must not be treated as model-quality verification.
References used during implementation:
- [ComfyUI frontend extension APIs](https://docs.comfy.org/custom-nodes/js/javascript_overview)
- [ComfyUI widget serialization notes](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/WIDGET_SERIALIZATION.md)
- [WebUI inpainting pre-processing semantics](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/master/modules/processing.py)
- [rgthree image comparison interaction reference](https://github.com/rgthree/rgthree-comfy/blob/main/py/image_comparer.py)
The plugin code is an independent implementation; see LICENSE for the release licence. No source from the projects above is bundled.
+7 -2
View File
@@ -1,6 +1,11 @@
"""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
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, normalize_exclusive_prompt
from server import PromptServer
if not getattr(PromptServer.instance, "_adachi_exclusive_normalizer", False):
PromptServer.instance.add_on_prompt_handler(normalize_exclusive_prompt)
PromptServer.instance._adachi_exclusive_normalizer = True
WEB_DIRECTORY = "./web"
+356 -51
View File
@@ -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
@@ -289,17 +289,17 @@ def _composite(generated, context):
def _prepare_inputs():
return {
"image": ("IMAGE", {"tooltip": "原图 / Original image; output is restored to this exact size."}),
"mask": ("MASK", {"tooltip": "白色重绘,黑色保护 / White repaints; black preserves."}),
"area": (["whole_image", "only_masked"], {"default": "only_masked", "tooltip": "whole_image:整张原图缩放采样;only_masked:蒙版包围框加边距放大采样,再贴回原图 / Whole image or mask bounding-box inpainting."}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "采样画布宽度;最终输出仍为原图尺寸 / Sampling canvas width, independent of final image size."}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "采样画布高度 / Sampling canvas height."}),
"padding": ("INT", {"default": 32, "min": 0, "max": 2048, "step": 1, "tooltip": "仅蒙版区域:原图像素单位的上下文边距 / Mask-only context padding in original pixels."}),
"mask_blur": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 128.0, "step": 0.5, "tooltip": "原图像素单位的高斯羽化半径 / Gaussian mask feathering in original pixels."}),
"mask_grow": ("INT", {"default": 0, "min": -128, "max": 128, "step": 1, "tooltip": "正数扩张,负数收缩 / Positive dilates; negative erodes the mask."}),
"mask_mode": (["inside", "outside"], {"default": "inside", "tooltip": "inside:蒙版内;outside:蒙版外 / Repaint inside or invert the mask."}),
"fill": (["original", "fill", "latent_noise", "latent_nothing"], {"default": "original", "tooltip": "原图像素、周围颜色填充、潜空间噪声、空白潜空间;后两项由一体采样节点在 latent 层实现 / Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."}),
"resize_mode": (["stretch", "crop", "fill"], {"default": "stretch", "tooltip": "整图模式:拉伸、居中裁剪、保比例填充;仅蒙版模式自动保留完整蒙版 / Whole-image resize mode; mask-only mode always retains the entire mask."}),
"image": ("IMAGE", {"tooltip": "Original image; output is restored to this exact size."}),
"mask": ("MASK", {"tooltip": "White repaints; black preserves."}),
"area": (["whole_image", "only_masked"], {"default": "only_masked", "tooltip": "Whole image or mask bounding-box inpainting."}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "Sampling canvas width, independent of final image size."}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "Sampling canvas height."}),
"padding": ("INT", {"default": 32, "min": 0, "max": 2048, "step": 1, "tooltip": "Mask-only context padding in original pixels."}),
"mask_blur": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 128.0, "step": 0.5, "tooltip": "Gaussian mask feathering in original pixels."}),
"mask_grow": ("INT", {"default": 0, "min": -128, "max": 128, "step": 1, "tooltip": "Positive dilates; negative erodes the mask."}),
"mask_mode": (["inside", "outside"], {"default": "inside", "tooltip": "Repaint inside or invert the mask."}),
"fill": (["original", "fill", "latent_noise", "latent_nothing"], {"default": "original", "tooltip": "Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."}),
"resize_mode": (["stretch", "crop", "fill"], {"default": "stretch", "tooltip": "Whole-image resize mode; mask-only mode always retains the entire mask."}),
}
@@ -307,16 +307,16 @@ class AdachiInlineMask:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"image": ("STRING", {"default": "", "tooltip": "ComfyUI input 图片路径,由内联编辑器上传 / Input image path populated by inline upload."}),
"mask_data": ("STRING", {"default": "", "multiline": False, "tooltip": "编辑器保存的白底重绘蒙版 PNG / Editor mask PNG data URL; white repaints."}),
"source_data": ("STRING", {"default": "", "multiline": False, "tooltip": "嵌入原图以便工作流在其他机器使用 / Embedded source image for portable workflows."}),
"image": ("STRING", {"default": "", "tooltip": "Input image path populated by inline upload."}),
"mask_data": ("STRING", {"default": "", "multiline": False, "tooltip": "Editor mask PNG data URL; white repaints."}),
"source_data": ("STRING", {"default": "", "multiline": False, "tooltip": "Embedded source image for portable workflows."}),
}}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "load"
CATEGORY = CATEGORY
DESCRIPTION = "直接在工作流节点内涂抹蒙版,无需打开独立编辑器 / Paint an inpaint mask directly in the workflow."
DESCRIPTION = "Paint an inpaint mask directly in the workflow."
SEARCH_ALIASES = ["inline mask", "webui inpaint", "局部重绘", "安达", "adachi"]
@classmethod
@@ -352,15 +352,15 @@ class AdachiInpaintPrepare:
@classmethod
def INPUT_TYPES(cls):
return {"required": _prepare_inputs(), "optional": {
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "额外羽化;不是 A1111 Soft Inpainting 算法 / Extra feathering, not AUTOMATIC1111's Soft Inpainting algorithm."}),
"soft_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "额外增加 0–16 像素羽化 / Adds 0–16 pixels of Gaussian feathering."}),
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "Extra feathering, not AUTOMATIC1111's Soft Inpainting algorithm."}),
"soft_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "Adds 0–16 pixels of Gaussian feathering."}),
}}
RETURN_TYPES = ("IMAGE", "MASK", "ADACHI_INPAINT_CONTEXT")
RETURN_NAMES = ("pixels", "mask", "context")
FUNCTION = "prepare"
CATEGORY = CATEGORY
DESCRIPTION = "局部重绘裁剪、蒙版处理与精确回贴上下文;latent 填充请使用一体采样节点 / Crop and prepare masks with exact compositing context; use the all-in-one sampler for latent fill modes."
DESCRIPTION = "Crop and prepare masks with exact compositing context; use the all-in-one sampler for latent fill modes."
SEARCH_ALIASES = ["inpaint crop", "局部重绘预处理", "webui", "adachi"]
def prepare(self, image, mask, **kwargs):
@@ -371,15 +371,15 @@ class AdachiInpaintComposite:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"generated": ("IMAGE", {"tooltip": "采样后的裁剪图 / Generated sampling canvas."}),
"context": ("ADACHI_INPAINT_CONTEXT", {"tooltip": "预处理节点的裁剪和原图上下文 / Context from Adachi Inpaint Prepare."}),
"generated": ("IMAGE", {"tooltip": "Generated sampling canvas."}),
"context": ("ADACHI_INPAINT_CONTEXT", {"tooltip": "Context from Adachi Inpaint Prepare."}),
}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "composite"
CATEGORY = CATEGORY
DESCRIPTION = "还原原图尺寸,软蒙版外保持原像素不变 / Restore original size and preserve pixels outside the effective mask exactly."
DESCRIPTION = "Restore original size and preserve pixels outside the effective mask exactly."
SEARCH_ALIASES = ["inpaint paste", "局部重绘回贴", "adachi"]
def composite(self, generated, context):
@@ -396,6 +396,58 @@ def _vae_ratio(vae):
return int(ratio) if isinstance(ratio, (int, float)) and ratio >= 1 else 8
def _check_image_latents(samples, batch):
if not isinstance(samples, torch.Tensor) or samples.ndim not in (4, 5) or min(samples.shape) < 1:
raise ValueError("Image VAE must return [B,C,H,W] or [B,C,1,H,W] latents.")
if samples.shape[0] != batch:
raise ValueError("VAE latent batch must match the number of independent input images.")
if samples.ndim == 5 and samples.shape[2] != 1:
raise ValueError("Still-image inpainting requires one latent frame per image (T=1); multi-frame video is not supported.")
return samples
def _encode_still_images(vae, pixels):
# Video-derived VAEs can interpret a 4D IMAGE batch as consecutive frames.
# Encode those VAEs one image at a time so sources/variants remain independent.
if getattr(vae, "latent_dim", None) == 3 and pixels.shape[0] > 1:
samples = torch.cat([_check_image_latents(vae.encode(frame[None]), 1) for frame in pixels], dim=0)
else:
samples = vae.encode(pixels)
# Also support third-party VAE wrappers without a latent_dim attribute.
if isinstance(samples, torch.Tensor) and samples.ndim == 5 and pixels.shape[0] > 1:
if samples.shape[0] != pixels.shape[0] or samples.shape[2] != 1:
samples = torch.cat([_check_image_latents(vae.encode(frame[None]), 1) for frame in pixels], dim=0)
return _check_image_latents(samples, pixels.shape[0])
class _StillImageVAE:
"""Keep native inpaint conditioning while making its VAE encodes batch-safe."""
def __init__(self, vae):
self.vae = vae
def __getattr__(self, name):
return getattr(self.vae, name)
def encode(self, pixels):
return _encode_still_images(self.vae, pixels)
def _decode_still_images(vae, samples, batch):
_check_image_latents(samples, batch)
decoded = vae.decode(samples)
if isinstance(decoded, tuple):
decoded = decoded[0]
if isinstance(decoded, torch.Tensor) and decoded.ndim == 5:
if decoded.shape[1] != 1:
raise ValueError("VAE decoded multiple frames for a still image; expected [B,1,H,W,C].")
decoded = decoded[:, 0]
decoded = _image_tensor(decoded)
if decoded.shape[0] != batch:
raise ValueError("VAE decoded image batch does not match the input image batch.")
return decoded
def _check_refiner(model, refiner):
"""Reject known incompatible latent formats while allowing SDXL base/refiner."""
first = getattr(getattr(model, "model", None), "latent_format", None)
@@ -430,29 +482,29 @@ class AdachiWebUIInpaint:
required = {
"model": ("MODEL",), "positive": ("CONDITIONING",), "negative": ("CONDITIONING",), "vae": ("VAE",),
**_prepare_inputs(),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True, "tooltip": "可复现批量随机种子 / Seed for a reproducible batch of distinct samples."}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True, "tooltip": "Seed for a reproducible batch of distinct samples."}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
"denoise": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "0 原样返回原图,1 完全重绘 / Zero returns the original exactly; one fully denoises."}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 16, "tooltip": "每张输入图生成数量 / Number of generated variants per input image."}),
"refiner_switch": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "接入 Refiner 时的采样切换比例;0全程refiner,1全程base / Fraction of steps on the base model when a refiner is connected."}),
"denoise": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Zero returns the original exactly; one fully denoises."}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 16, "tooltip": "Number of generated variants per input image."}),
"refiner_switch": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Fraction of steps on the base model when a refiner is connected."}),
}
return {"required": required, "optional": {
"inpaint_model": ("BOOLEAN", {"default": False, "tooltip": "仅专用 inpaint 模型开启,添加原生 inpaint conditioning / Enable only for dedicated inpainting models; uses ComfyUI's native InpaintModelConditioning."}),
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "额外羽化,并非 A1111 Soft Inpainting 原算法 / Extra feathering, not A1111 Soft Inpainting."}),
"inpaint_model": ("BOOLEAN", {"default": False, "tooltip": "Enable only for dedicated inpainting models; uses ComfyUI's native InpaintModelConditioning."}),
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "Extra feathering, not A1111 Soft Inpainting."}),
"soft_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}),
"refiner_model": ("MODEL", {"tooltip": "可选的兼容 Refiner 模型(例如 SDXL refiner) / Optional compatible refiner model, e.g. SDXL refiner."}),
"refiner_positive": ("CONDITIONING", {"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始正面条件 / Encode using the refiner CLIP; defaults to original positive conditioning."}),
"refiner_negative": ("CONDITIONING", {"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始负面条件 / Encode using the refiner CLIP; defaults to original negative conditioning."}),
"refiner_model": ("MODEL", {"tooltip": "Optional compatible refiner model, e.g. SDXL refiner."}),
"refiner_positive": ("CONDITIONING", {"tooltip": "Encode using the refiner CLIP; defaults to original positive conditioning."}),
"refiner_negative": ("CONDITIONING", {"tooltip": "Encode using the refiner CLIP; defaults to original negative conditioning."}),
}}
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK")
RETURN_NAMES = ("image", "crop", "mask")
FUNCTION = "sample"
CATEGORY = CATEGORY
DESCRIPTION = "WebUI 式局部重绘:整图/蒙版区域、填充、采样和原尺寸回贴 / WebUI-style inpainting, crop, latent fill, sampling and original-size compositing."
DESCRIPTION = "WebUI-style inpainting, crop, latent fill, sampling and original-size compositing."
SEARCH_ALIASES = ["webui", "img2img", "inpaint", "局部重绘", "安达", "adachi"]
def sample(self, model, positive, negative, vae, image, mask, seed=0, steps=20, cfg=7.0,
@@ -478,13 +530,26 @@ class AdachiWebUIInpaint:
masks = _resize_mask(masks, target_height, target_width)
if inpaint_model:
positive, negative, latent = nodes.InpaintModelConditioning().encode(
positive=positive, negative=negative, pixels=pixels, vae=vae, mask=masks, noise_mask=True)
positive=positive, negative=negative, pixels=pixels, vae=_StillImageVAE(vae), mask=masks, noise_mask=True)
else:
latent = {"samples": vae.encode(pixels)}
samples = latent["samples"]
if not isinstance(samples, torch.Tensor) or samples.ndim != 4:
raise ValueError("This node supports still-image VAEs with 4D image latents.")
latent = {"samples": _encode_still_images(vae, pixels)}
samples = _check_image_latents(latent["samples"], pixels.shape[0])
latent_mask = _resize_mask(masks.to(device=samples.device, dtype=samples.dtype), samples.shape[-2], samples.shape[-1])[:, None]
if samples.ndim == 5:
latent_mask = latent_mask.unsqueeze(2)
if inpaint_model:
# Native conditioning provides a pixel-space [B,1,H,W] mask;
# retain the independent batch axis for volumetric model inputs.
def volumetric_conditioning(conditioning):
result = []
for embedding, metadata in conditioning:
metadata = metadata.copy()
concat_mask = metadata.get("concat_mask")
if isinstance(concat_mask, torch.Tensor) and concat_mask.ndim == 4:
metadata["concat_mask"] = concat_mask.unsqueeze(2)
result.append([embedding, metadata])
return result
positive, negative = volumetric_conditioning(positive), volumetric_conditioning(negative)
latent["noise_mask"] = latent_mask
fill = kwargs.get("fill", "original")
if fill in {"latent_noise", "latent_nothing"}:
@@ -512,10 +577,7 @@ class AdachiWebUIInpaint:
else:
sampled = nodes.common_ksampler(model, int(seed), int(steps), float(cfg), sampler_name, scheduler,
positive, negative, latent, denoise=float(denoise))[0]
decoded = vae.decode(sampled["samples"])
if isinstance(decoded, tuple):
decoded = decoded[0]
decoded = _image_tensor(decoded)
decoded = _decode_still_images(vae, sampled["samples"], pixels.shape[0])
return _composite(decoded, context), decoded, masks
@@ -525,8 +587,8 @@ class AdachiImageCompare:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"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."}),
"original": ("IMAGE", {"tooltip": "Original image shown on the right."}),
"result": ("IMAGE", {"tooltip": "Full-size generated image shown on the left; use image, not crop."}),
}}
RETURN_TYPES = ("IMAGE",)
@@ -534,7 +596,7 @@ class AdachiImageCompare:
FUNCTION = "compare"
OUTPUT_NODE = True
CATEGORY = CATEGORY
DESCRIPTION = "鼠标竖线左右对比原图与重绘结果;点击采用此结果可送回上游内联蒙版 / Hover to compare; adopt a result into the upstream inline editor."
DESCRIPTION = "Hover to compare; adopt a result into the upstream inline editor."
SEARCH_ALIASES = ["compare", "before after", "图像对比", "adachi", "安达工作台"]
def compare(self, original, result):
@@ -542,9 +604,9 @@ class AdachiImageCompare:
original, result = _image_tensor(original), _image_tensor(result)
if original.shape[1:3] != result.shape[1:3]:
raise ValueError("原图与结果尺寸须一致,请连接最终 image 输出而非 crop / Image sizes must match. Connect the final image output, not crop.")
raise ValueError("Image sizes must match. Connect the final image output, not crop.")
if result.shape[0] % original.shape[0]:
raise ValueError("结果批量须为原图批量的整数倍 / Result batch must be a multiple of the original batch.")
raise ValueError("Result batch must be a multiple of the original batch.")
if original.shape[1] * original.shape[2] > MAX_IMAGE_PIXELS or max(original.shape[1:3]) > 16384:
raise ValueError("Comparison images exceed the inline editor's 32 megapixel / 16384px limit.")
preview = nodes.PreviewImage()
@@ -553,18 +615,261 @@ class AdachiImageCompare:
return {"ui": {"adachi_originals": before, "adachi_results": after}, "result": (result,)}
_MISSING_INPUT = object()
def _workflow_exclusive_flags(extra_pnginfo, unique_id):
"""Recover routing states from the editable graph, before virtual links vanish."""
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
if not isinstance(workflow, dict) or unique_id is None:
return None
definitions = {str(g.get("id")): g for g in workflow.get("definitions", {}).get("subgraphs", [])}
graph = workflow
parts = str(unique_id).split(":")
for parent_id in parts[:-1]:
parent = next((n for n in graph.get("nodes", []) if str(n.get("id")) == parent_id), None)
graph = definitions.get(str(parent.get("type"))) if parent else None
if graph is None:
return None
nodes = {str(n.get("id")): n for n in graph.get("nodes", [])}
selector = nodes.get(parts[-1])
if not selector or selector.get("type") != "AdachiExclusiveSwitch":
return None
links = {}
for link in graph.get("links", []):
if isinstance(link, dict):
links[str(link["id"])] = (str(link["origin_id"]), link.get("origin_slot", 0))
else:
links[str(link[0])] = (str(link[1]), link[2])
def visit(node_id, slot, seen):
if node_id in seen:
return False
node = nodes.get(node_id)
if node is None:
return None # e.g. a subgraph's external input placeholder
if node.get("mode", 0) in (2, 4):
return False
seen = seen | {node_id}
kind = node.get("type")
if kind == "GetNode":
values = node.get("widgets_values", [])
name = values[0] if isinstance(values, list) and values else None
setter = next((n for n in nodes.values() if n.get("type") == "SetNode"
and isinstance(n.get("widgets_values"), list)
and n["widgets_values"] and n["widgets_values"][0] == name), None) if name else None
return visit(str(setter["id"]), slot, seen) if setter else False
if kind in ("SetNode", "Reroute"):
inputs = node.get("inputs", [])
if not inputs:
return False
source = inputs[slot] if isinstance(slot, int) and 0 <= slot < len(inputs) else inputs[0]
link = links.get(str(source.get("link")))
return visit(*link, seen) if link else False
return True
flags = 0
for name, bit in (("input_a", 1), ("input_b", 2)):
source = next((i for i in selector.get("inputs", []) if i.get("name") == name), {})
link = links.get(str(source.get("link")))
enabled = visit(*link, set()) if link else False
if enabled is None:
return None
if enabled:
flags |= bit
return flags
def _workflow_exclusive_sources(extra_pnginfo, unique_id, prompt):
"""Resolve saved graph outputs to existing flattened execution nodes only."""
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
if not isinstance(workflow, dict):
return {}
definitions = {str(g.get("id")): g for g in workflow.get("definitions", {}).get("subgraphs", [])}
def index(graph):
nodes = {str(n.get("id")): n for n in graph.get("nodes", [])}
links = {}
for link in graph.get("links", []):
if isinstance(link, dict):
links[str(link["id"])] = (str(link["origin_id"]), link.get("origin_slot", 0))
else:
links[str(link[0])] = (str(link[1]), link[2])
return nodes, links
graph, path = workflow, []
parts = str(unique_id).split(":")
for parent_id in parts[:-1]:
nodes, _ = index(graph)
parent = nodes.get(parent_id)
graph = definitions.get(str(parent.get("type"))) if parent else None
if graph is None:
return {}
path.append(parent_id)
nodes, links = index(graph)
selector = nodes.get(parts[-1])
if not selector or selector.get("type") != "AdachiExclusiveSwitch":
return {}
def resolve(current, node_id, slot, prefix, seen):
key = (id(current), node_id, slot)
if key in seen or len(seen) > 64:
return None
seen = seen | {key}
current_nodes, current_links = index(current)
node = current_nodes.get(node_id)
if not node or node.get("mode", 0) in (2, 4):
return None
kind = node.get("type")
if kind == "GetNode":
values = node.get("widgets_values", [])
name = values[0] if isinstance(values, list) and values else None
setter = next((n for n in current_nodes.values() if n.get("type") == "SetNode"
and isinstance(n.get("widgets_values"), list)
and n["widgets_values"] and n["widgets_values"][0] == name), None) if name else None
return resolve(current, str(setter["id"]), slot, prefix, seen) if setter else None
if kind in ("SetNode", "Reroute"):
inputs = node.get("inputs", [])
source = inputs[slot] if isinstance(slot, int) and 0 <= slot < len(inputs) else inputs[0] if inputs else {}
link = current_links.get(str(source.get("link")))
return resolve(current, *link, prefix, seen) if link else None
if kind in definitions:
child = definitions[kind]
outputs = child.get("outputs", [])
if not isinstance(slot, int) or not 0 <= slot < len(outputs):
return None
_, child_links = index(child)
output_links = [child_links[str(i)] for i in outputs[slot].get("linkIds", []) if str(i) in child_links]
if len(output_links) != 1:
return None
return resolve(child, *output_links[0], prefix + [node_id], seen)
execution_id = ":".join(prefix + [node_id])
execution_node = prompt.get(execution_id)
if isinstance(execution_node, dict) and execution_node.get("class_type") == kind:
return [execution_id, slot]
return None
result = {}
for source in selector.get("inputs", []):
name = source.get("name")
link = links.get(str(source.get("link")))
if name in ("input_a", "input_b") and link:
resolved = resolve(graph, *link, path, set())
if resolved is not None:
result[name] = resolved
return result
def normalize_exclusive_prompt(data):
"""Resolve flags before validation/cache keys, without changing workflow wires."""
prompt = data.get("prompt", {})
extra = data.get("extra_data", {}).get("extra_pnginfo")
if not isinstance(prompt, dict):
return data
for node_id, node in prompt.items():
if not isinstance(node, dict) or node.get("class_type") != "AdachiExclusiveSwitch":
continue
flags = _workflow_exclusive_flags(extra, node_id)
if flags is None:
continue
inputs = node.setdefault("inputs", {})
inputs["active_inputs"] = flags
sources = _workflow_exclusive_sources(extra, node_id, prompt) if flags in (1, 2) else {}
for name, bit in (("input_a", 1), ("input_b", 2)):
if not flags & bit:
inputs.pop(name, None)
elif name not in inputs and name in sources:
inputs[name] = sources[name]
return data
class AdachiExclusiveSwitch:
"""Forward exactly one enabled branch, without evaluating the ignored branch."""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"input_a": ("*", {"lazy": True, "tooltip": "First branch; bypass either this branch or input_b."}),
"input_b": ("*", {"lazy": True, "tooltip": "Second branch; exactly one branch must remain enabled."}),
# The frontend computes this from the graph on every serialization.
# Keeping it in the prompt also makes branch changes invalidate caches,
# including bypassed transform nodes whose links otherwise pass through.
"active_inputs": ("INT", {"default": -1, "min": -1, "max": 3}),
},
"hidden": {"prompt": "PROMPT", "dynprompt": "DYNPROMPT", "unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("*",)
RETURN_NAMES = ("output",)
FUNCTION = "select"
CATEGORY = CATEGORY
DESCRIPTION = "Automatically forwards the only enabled input. Bypass one branch; both enabled or both absent is an error."
SEARCH_ALIASES = ["exclusive switch", "auto switch", "自动二选一", "互斥", "自动切换", "adachi"]
@staticmethod
def _selected_input(input_a, input_b, active_inputs, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
frontend_flags, submitted_flags = active_inputs, None
workflow_flags = _workflow_exclusive_flags(extra_pnginfo, unique_id)
if workflow_flags is not None:
active_inputs = workflow_flags
execution_node = None
if dynprompt is not None and unique_id is not None:
try:
execution_node = dynprompt.get_node(unique_id)
except KeyError:
pass
if execution_node is None and isinstance(prompt, dict) and unique_id is not None:
execution_node = prompt.get(str(unique_id), prompt.get(unique_id))
if isinstance(execution_node, dict) and isinstance(execution_node.get("inputs"), dict):
# Get/Set and mute/bypass resolution may remove a connection after
# widgets serialize. Trust the actual submitted connections when
# only one (or none) survives, regardless of stale frontend flags.
connected = execution_node["inputs"]
actual = int("input_a" in connected) | (int("input_b" in connected) << 1)
submitted_flags = actual
if actual != 3:
active_inputs = actual if workflow_flags is None else actual & workflow_flags
if active_inputs == -1:
# Raw API prompts need no frontend state: simply omit the unused input.
active_inputs = int(input_a is not _MISSING_INPUT) | (int(input_b is not _MISSING_INPUT) << 1)
if active_inputs not in (1, 2):
raise ValueError(
"Auto Exclusive Switch requires exactly one enabled input. "
"Bypass one branch or disconnect it; both enabled / both absent are invalid. "
f"Received enabled-state={active_inputs}. "
f"node={unique_id}, frontend={frontend_flags}, workflow={workflow_flags}, submitted={submitted_flags}."
)
name = "input_a" if active_inputs == 1 else "input_b"
if (input_a if active_inputs == 1 else input_b) is _MISSING_INPUT:
raise ValueError(f"The enabled branch {name} has no input.")
return name
def check_lazy_status(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
return [self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id, extra_pnginfo)]
def select(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
selected = self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id, extra_pnginfo)
value = input_a if selected == "input_a" else input_b
if value is None:
raise ValueError(f"The enabled branch {selected} returned no data.")
return (value,)
NODE_CLASS_MAPPINGS = {
"AdachiInlineMask": AdachiInlineMask,
"AdachiInpaintPrepare": AdachiInpaintPrepare,
"AdachiInpaintComposite": AdachiInpaintComposite,
"AdachiWebUIInpaint": AdachiWebUIInpaint,
"AdachiImageCompare": AdachiImageCompare,
"AdachiExclusiveSwitch": AdachiExclusiveSwitch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AdachiInlineMask": "内联蒙版 / Inline Mask",
"AdachiInpaintPrepare": "重绘预处理 / Inpaint Prepare",
"AdachiInpaintComposite": "原图回贴 / Inpaint Composite",
"AdachiWebUIInpaint": "WebUI 局部重绘 / WebUI Inpaint",
"AdachiImageCompare": "图像对比 / Image Compare",
"AdachiInlineMask": "Inline Mask",
"AdachiInpaintPrepare": "Inpaint Prepare",
"AdachiInpaintComposite": "Inpaint Composite",
"AdachiWebUIInpaint": "WebUI Inpaint",
"AdachiImageCompare": "Image Compare",
"AdachiExclusiveSwitch": "Auto Exclusive Switch",
}
+7 -60
View File
@@ -13,14 +13,13 @@
],
"size": [
660,
1276
700
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "STRING",
"widget": {
@@ -29,7 +28,6 @@
"link": null
},
{
"localized_name": "mask_data",
"name": "mask_data",
"type": "STRING",
"widget": {
@@ -38,7 +36,6 @@
"link": null
},
{
"localized_name": "source_data",
"name": "source_data",
"type": "STRING",
"widget": {
@@ -49,7 +46,6 @@
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
@@ -58,7 +54,6 @@
]
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": [
@@ -67,8 +62,7 @@
}
],
"properties": {
"Node name for S&R": "AdachiInlineMask",
"adachi_inline_parameters": true
"Node name for S&R": "AdachiInlineMask"
},
"widgets_values": [
"",
@@ -92,7 +86,6 @@
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
@@ -103,7 +96,6 @@
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
@@ -111,7 +103,6 @@
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
@@ -120,7 +111,6 @@
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
@@ -151,13 +141,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
@@ -168,7 +156,6 @@
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
@@ -176,7 +163,7 @@
]
}
],
"title": "正面提示词 / Positive",
"title": "Positive",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
@@ -200,13 +187,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
@@ -217,7 +202,6 @@
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
@@ -225,7 +209,7 @@
]
}
],
"title": "负面提示词 / Negative",
"title": "Negative",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
@@ -242,71 +226,61 @@
],
"size": [
430,
244
1020
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 3
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 5
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 4
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 7
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 8
},
{
"localized_name": "refiner_model",
"name": "refiner_model",
"shape": 7,
"type": "MODEL",
"link": null
},
{
"localized_name": "refiner_positive",
"name": "refiner_positive",
"shape": 7,
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "refiner_negative",
"name": "refiner_negative",
"shape": 7,
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "area",
"name": "area",
"type": "COMBO",
"widget": {
@@ -315,7 +289,6 @@
"link": null
},
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
@@ -324,7 +297,6 @@
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
@@ -333,7 +305,6 @@
"link": null
},
{
"localized_name": "padding",
"name": "padding",
"type": "INT",
"widget": {
@@ -342,7 +313,6 @@
"link": null
},
{
"localized_name": "mask_blur",
"name": "mask_blur",
"type": "FLOAT",
"widget": {
@@ -351,7 +321,6 @@
"link": null
},
{
"localized_name": "mask_grow",
"name": "mask_grow",
"type": "INT",
"widget": {
@@ -360,7 +329,6 @@
"link": null
},
{
"localized_name": "mask_mode",
"name": "mask_mode",
"type": "COMBO",
"widget": {
@@ -369,7 +337,6 @@
"link": null
},
{
"localized_name": "fill",
"name": "fill",
"type": "COMBO",
"widget": {
@@ -378,7 +345,6 @@
"link": null
},
{
"localized_name": "resize_mode",
"name": "resize_mode",
"type": "COMBO",
"widget": {
@@ -387,7 +353,6 @@
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
@@ -396,7 +361,6 @@
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
@@ -405,7 +369,6 @@
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
@@ -414,7 +377,6 @@
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
@@ -423,7 +385,6 @@
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
@@ -432,7 +393,6 @@
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
@@ -441,7 +401,6 @@
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
@@ -450,7 +409,6 @@
"link": null
},
{
"localized_name": "refiner_switch",
"name": "refiner_switch",
"type": "FLOAT",
"widget": {
@@ -459,7 +417,6 @@
"link": null
},
{
"localized_name": "inpaint_model",
"name": "inpaint_model",
"shape": 7,
"type": "BOOLEAN",
@@ -469,7 +426,6 @@
"link": null
},
{
"localized_name": "soft_edge",
"name": "soft_edge",
"shape": 7,
"type": "BOOLEAN",
@@ -479,7 +435,6 @@
"link": null
},
{
"localized_name": "soft_strength",
"name": "soft_strength",
"shape": 7,
"type": "FLOAT",
@@ -491,7 +446,6 @@
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
@@ -499,13 +453,11 @@
]
},
{
"localized_name": "crop",
"name": "crop",
"type": "IMAGE",
"links": null
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": null
@@ -554,13 +506,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "original",
"name": "original",
"type": "IMAGE",
"link": 9
},
{
"localized_name": "result",
"name": "result",
"type": "IMAGE",
"link": 10
@@ -568,7 +518,6 @@
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
@@ -597,13 +546,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 11
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
@@ -636,7 +583,7 @@
"outputs": [],
"properties": {},
"widgets_values": [
"安达工作台 / Adachi Workbench\n1. 选择自己的 checkpoint 喵\n2. 在左侧节点载入原图并直接涂抹喵\n3. 在内联蒙版底部调整重绘参数喵\n4. width/height 是采样分辨率,最终 image 回贴为原图尺寸喵\n分享时勾选「原图嵌入工作流」喵"
"Adachi-Inpaint-Workbench\n1. Pick your own checkpoint\n2. Load an image in the left node and paint directly on it\n3. Select the brush and adjust inpainting parameters in the WebUI Inpaint node\n4. width/height is the sampling resolution; the final image is composited back at the source size\nTick \"Embed source in workflow\" in the inpaint node before sharing"
],
"color": "#432",
"bgcolor": "#653"
@@ -744,4 +691,4 @@
}
},
"version": 0.4
}
}
+6 -65
View File
@@ -20,7 +20,6 @@
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "STRING",
"widget": {
@@ -29,7 +28,6 @@
"link": null
},
{
"localized_name": "mask_data",
"name": "mask_data",
"type": "STRING",
"widget": {
@@ -38,7 +36,6 @@
"link": null
},
{
"localized_name": "source_data",
"name": "source_data",
"type": "STRING",
"widget": {
@@ -49,7 +46,6 @@
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
@@ -58,7 +54,6 @@
]
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": [
@@ -91,7 +86,6 @@
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
@@ -102,7 +96,6 @@
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
@@ -110,7 +103,6 @@
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
@@ -119,7 +111,6 @@
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
@@ -151,13 +142,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
@@ -168,7 +157,6 @@
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
@@ -176,7 +164,7 @@
]
}
],
"title": "正面提示词 / Positive",
"title": "Positive",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
@@ -200,13 +188,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
@@ -217,7 +203,6 @@
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
@@ -225,7 +210,7 @@
]
}
],
"title": "负面提示词 / Negative",
"title": "Negative",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
@@ -242,26 +227,23 @@
],
"size": [
350,
420
763
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 3
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 4
},
{
"localized_name": "area",
"name": "area",
"type": "COMBO",
"widget": {
@@ -270,7 +252,6 @@
"link": null
},
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
@@ -279,7 +260,6 @@
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
@@ -288,7 +268,6 @@
"link": null
},
{
"localized_name": "padding",
"name": "padding",
"type": "INT",
"widget": {
@@ -297,7 +276,6 @@
"link": null
},
{
"localized_name": "mask_blur",
"name": "mask_blur",
"type": "FLOAT",
"widget": {
@@ -306,7 +284,6 @@
"link": null
},
{
"localized_name": "mask_grow",
"name": "mask_grow",
"type": "INT",
"widget": {
@@ -315,7 +292,6 @@
"link": null
},
{
"localized_name": "mask_mode",
"name": "mask_mode",
"type": "COMBO",
"widget": {
@@ -324,7 +300,6 @@
"link": null
},
{
"localized_name": "fill",
"name": "fill",
"type": "COMBO",
"widget": {
@@ -333,7 +308,6 @@
"link": null
},
{
"localized_name": "resize_mode",
"name": "resize_mode",
"type": "COMBO",
"widget": {
@@ -342,7 +316,6 @@
"link": null
},
{
"localized_name": "soft_edge",
"name": "soft_edge",
"shape": 7,
"type": "BOOLEAN",
@@ -352,7 +325,6 @@
"link": null
},
{
"localized_name": "soft_strength",
"name": "soft_strength",
"shape": 7,
"type": "FLOAT",
@@ -364,7 +336,6 @@
],
"outputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"links": [
@@ -372,7 +343,6 @@
]
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": [
@@ -380,7 +350,6 @@
]
},
{
"localized_name": "context",
"name": "context",
"type": "ADACHI_INPAINT_CONTEXT",
"links": [
@@ -421,13 +390,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "像素",
"name": "pixels",
"type": "IMAGE",
"link": 5
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 6
@@ -435,7 +402,6 @@
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
@@ -464,13 +430,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "遮罩",
"name": "mask",
"type": "MASK",
"link": 8
@@ -478,7 +442,6 @@
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
@@ -500,38 +463,33 @@
],
"size": [
330,
310
317.3125
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "模型",
"name": "model",
"type": "MODEL",
"link": 9
},
{
"localized_name": "正面条件",
"name": "positive",
"type": "CONDITIONING",
"link": 10
},
{
"localized_name": "负面条件",
"name": "negative",
"type": "CONDITIONING",
"link": 11
},
{
"localized_name": "Latent图像",
"name": "latent_image",
"type": "LATENT",
"link": 12
},
{
"localized_name": "种子",
"name": "seed",
"type": "INT",
"widget": {
@@ -540,7 +498,6 @@
"link": null
},
{
"localized_name": "步数",
"name": "steps",
"type": "INT",
"widget": {
@@ -549,7 +506,6 @@
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
@@ -558,7 +514,6 @@
"link": null
},
{
"localized_name": "采样器名称",
"name": "sampler_name",
"type": "COMBO",
"widget": {
@@ -567,7 +522,6 @@
"link": null
},
{
"localized_name": "调度器",
"name": "scheduler",
"type": "COMBO",
"widget": {
@@ -576,7 +530,6 @@
"link": null
},
{
"localized_name": "降噪",
"name": "denoise",
"type": "FLOAT",
"widget": {
@@ -587,7 +540,6 @@
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
@@ -624,13 +576,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 13
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 14
@@ -638,7 +588,6 @@
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
@@ -667,13 +616,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "generated",
"name": "generated",
"type": "IMAGE",
"link": 15
},
{
"localized_name": "context",
"name": "context",
"type": "ADACHI_INPAINT_CONTEXT",
"link": 16
@@ -681,7 +628,6 @@
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
@@ -710,13 +656,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "original",
"name": "original",
"type": "IMAGE",
"link": 17
},
{
"localized_name": "result",
"name": "result",
"type": "IMAGE",
"link": 18
@@ -724,7 +668,6 @@
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
@@ -753,13 +696,11 @@
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 19
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
@@ -792,7 +733,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 - standard node combination\nPick a model, load an image, paint, then run\nPrepare -> VAE Encode -> Set Latent Noise Mask -> KSampler -> VAE Decode -> Composite\nFor standalone Prepare, choose fill = original or fill\nlatent_noise / latent_nothing are implemented by the all-in-one sampler; standalone Prepare does not touch the latent\nWith a dedicated inpaint checkpoint, use native InpaintModelConditioning or the all-in-one node's inpaint_model"
],
"color": "#432",
"bgcolor": "#653"
@@ -964,4 +905,4 @@
}
},
"version": 0.4
}
}
+335
View File
@@ -0,0 +1,335 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 6,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
-350,
140
],
"size": [
270,
160
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"name": "color",
"type": "INT",
"widget": {
"name": "color"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
512,
512,
1,
8096934
]
},
{
"id": 2,
"type": "ImageInvert",
"pos": [
120,
160
],
"size": [
225,
48
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
},
"widgets_values": []
},
{
"id": 3,
"type": "ImageInvert",
"pos": [
510,
160
],
"size": [
225,
48
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
},
"widgets_values": []
},
{
"id": 4,
"type": "SaveImage",
"pos": [
920,
140
],
"size": [
270,
76
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"Adachi/GroupPresetExample"
]
},
{
"id": 5,
"type": "AdachiGroupPresets",
"pos": [
80,
440
],
"size": [
620,
410
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"adachi_group_presets": {
"version": 1,
"activeId": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
"presets": [
{
"id": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
"name": "All enabled",
"groups": {
"group:1": {
"title": "Base",
"mode": 0
},
"group:2": {
"title": "Detail",
"mode": 0
}
}
},
{
"id": "fe43bc1f-1a1e-4f49-a37b-c6e6ca7cbc8a",
"name": "Skip detail",
"groups": {
"group:1": {
"title": "Base",
"mode": 0
},
"group:2": {
"title": "Detail",
"mode": 4
}
}
},
{
"id": "b881a666-4643-4af6-8feb-f1d02c47f113",
"name": "Bypass both",
"groups": {
"group:1": {
"title": "Base",
"mode": 4
},
"group:2": {
"title": "Detail",
"mode": 4
}
}
}
]
}
}
},
{
"id": 6,
"type": "Note",
"pos": [
820,
550
],
"size": [
350,
180
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Adachi-Inpaint-Workbench - Group Bypass Presets\nSelect a preset on the left to switch a whole group between enabled and bypassed; no connections required\nAdjust each group's state on the right, or click \"Save current group states\" to store the current workflow\nRight-click the node -> Apply preset to switch quickly\nThis example only inverts an image, so no model is needed"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
],
[
3,
3,
0,
4,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Base",
"bounding": [
80,
100,
300,
230
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Detail",
"bounding": [
470,
100,
300,
230
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+423
View File
@@ -0,0 +1,423 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
0,
120
],
"size": [
270,
130
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"name": "color",
"type": "INT",
"widget": {
"name": "color"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
64,
64,
1,
16711680
]
},
{
"id": 2,
"type": "EmptyImage",
"pos": [
0,
450
],
"size": [
270,
130
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"name": "color",
"type": "INT",
"widget": {
"name": "color"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
64,
64,
1,
255
]
},
{
"id": 3,
"type": "ImageInvert",
"pos": [
300,
120
],
"size": [
140,
26
],
"flags": {},
"order": 3,
"mode": 4,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
}
},
{
"id": 4,
"type": "ImageInvert",
"pos": [
300,
450
],
"size": [
140,
26
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
}
},
{
"id": 5,
"type": "AdachiExclusiveSwitch",
"pos": [
620,
200
],
"size": [
230,
80
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "input_a",
"shape": 7,
"type": "*",
"link": 3
},
{
"name": "input_b",
"shape": 7,
"type": "*",
"link": 4
}
],
"outputs": [
{
"name": "output",
"type": "*",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "AdachiExclusiveSwitch"
},
"widgets_values": [
-1
]
},
{
"id": 6,
"type": "SaveImage",
"pos": [
940,
200
],
"size": [
270,
58
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 5
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"exclusive-test"
]
},
{
"id": 7,
"type": "AdachiGroupPresets",
"pos": [
600,
430
],
"size": [
620,
410
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"adachi_group_presets": {
"version": 1,
"activeId": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
"presets": [
{
"id": "dd436f10-d4a4-46bc-aecb-fe185710f6e0",
"name": "Use A only",
"groups": {
"group:1": {
"title": "A",
"mode": 0
},
"group:2": {
"title": "B",
"mode": 4
}
}
},
{
"id": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
"name": "Use B only",
"groups": {
"group:1": {
"title": "A",
"mode": 4
},
"group:2": {
"title": "B",
"mode": 0
}
}
}
]
}
}
}
],
"links": [
[
1,
1,
0,
3,
0,
"IMAGE"
],
[
2,
2,
0,
4,
0,
"IMAGE"
],
[
3,
3,
0,
5,
0,
"IMAGE"
],
[
4,
4,
0,
5,
1,
"IMAGE"
],
[
5,
5,
0,
6,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "A",
"bounding": [
270,
70,
310,
210
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "B",
"bounding": [
270,
400,
310,
210
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.8,
"offset": [
100,
20
]
}
},
"version": 0.4
}
+21 -9
View File
@@ -16,24 +16,36 @@ 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.'
}
if ($destination.TrimEnd('\') -eq $packageRoot.TrimEnd('\')) {
Write-Host 'This package is already in the selected ComfyUI custom_nodes directory. 喵'
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
Write-Host "Previous installation backed up: $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 'Restart ComfyUI, then refresh the browser with Ctrl+F5. 喵'
Write-Host "Installed Adachi-Inpaint-Workbench: $destination"
Write-Host 'Restart ComfyUI, then refresh the browser with Ctrl+F5.'
+5
View File
@@ -0,0 +1,5 @@
{
"nodeCategories": {
"Adachi-Inpaint-Workbench": "Adachi-Inpaint-Workbench"
}
}
+251
View File
@@ -0,0 +1,251 @@
{
"AdachiInlineMask": {
"display_name": "Inline Mask",
"description": "Paint an inpaint mask directly in the workflow.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Input image path populated by inline upload."
},
"mask_data": {
"name": "mask_data",
"tooltip": "Editor mask PNG data URL; white repaints."
},
"source_data": {
"name": "source_data",
"tooltip": "Embedded source image for portable workflows."
}
},
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "mask"
}
}
},
"AdachiInpaintPrepare": {
"display_name": "Inpaint Prepare",
"description": "Crop and prepare masks with exact compositing context; use the all-in-one sampler for latent fill modes.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Original image; output is restored to this exact size."
},
"mask": {
"name": "mask",
"tooltip": "White repaints; black preserves."
},
"area": {
"name": "area",
"tooltip": "Whole image or mask bounding-box inpainting."
},
"width": {
"name": "width",
"tooltip": "Sampling canvas width, independent of final image size."
},
"height": {
"name": "height",
"tooltip": "Sampling canvas height."
},
"padding": {
"name": "padding",
"tooltip": "Mask-only context padding in original pixels."
},
"mask_blur": {
"name": "mask_blur",
"tooltip": "Gaussian mask feathering in original pixels."
},
"mask_grow": {
"name": "mask_grow",
"tooltip": "Positive dilates; negative erodes the mask."
},
"mask_mode": {
"name": "mask_mode",
"tooltip": "Repaint inside or invert the mask."
},
"fill": {
"name": "fill",
"tooltip": "Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."
},
"resize_mode": {
"name": "resize_mode",
"tooltip": "Whole-image resize mode; mask-only mode always retains the entire mask."
},
"soft_edge": {
"name": "soft_edge",
"tooltip": "Extra feathering, not AUTOMATIC1111's Soft Inpainting algorithm."
},
"soft_strength": {
"name": "soft_strength",
"tooltip": "Adds 0-16 pixels of Gaussian feathering."
}
},
"outputs": {
"0": {
"name": "pixels"
},
"1": {
"name": "mask"
},
"2": {
"name": "context"
}
}
},
"AdachiInpaintComposite": {
"display_name": "Inpaint Composite",
"description": "Restore original size and preserve pixels outside the effective mask exactly.",
"inputs": {
"generated": {
"name": "generated",
"tooltip": "Generated sampling canvas."
},
"context": {
"name": "context",
"tooltip": "Context from Adachi Inpaint Prepare."
}
},
"outputs": {
"0": {
"name": "image"
}
}
},
"AdachiWebUIInpaint": {
"display_name": "WebUI Inpaint",
"description": "WebUI-style inpainting, crop, latent fill, sampling and original-size compositing.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Original image; output is restored to this exact size."
},
"mask": {
"name": "mask",
"tooltip": "White repaints; black preserves."
},
"area": {
"name": "area",
"tooltip": "Whole image or mask bounding-box inpainting."
},
"width": {
"name": "width",
"tooltip": "Sampling canvas width, independent of final image size."
},
"height": {
"name": "height",
"tooltip": "Sampling canvas height."
},
"padding": {
"name": "padding",
"tooltip": "Mask-only context padding in original pixels."
},
"mask_blur": {
"name": "mask_blur",
"tooltip": "Gaussian mask feathering in original pixels."
},
"mask_grow": {
"name": "mask_grow",
"tooltip": "Positive dilates; negative erodes the mask."
},
"mask_mode": {
"name": "mask_mode",
"tooltip": "Repaint inside or invert the mask."
},
"fill": {
"name": "fill",
"tooltip": "Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."
},
"resize_mode": {
"name": "resize_mode",
"tooltip": "Whole-image resize mode; mask-only mode always retains the entire mask."
},
"seed": {
"name": "seed",
"tooltip": "Seed for a reproducible batch of distinct samples."
},
"denoise": {
"name": "denoise",
"tooltip": "Zero returns the original exactly; one fully denoises."
},
"batch_size": {
"name": "batch_size",
"tooltip": "Number of generated variants per input image."
},
"refiner_switch": {
"name": "refiner_switch",
"tooltip": "Fraction of steps on the base model when a refiner is connected."
},
"inpaint_model": {
"name": "inpaint_model",
"tooltip": "Enable only for dedicated inpainting models; uses ComfyUI's native InpaintModelConditioning."
},
"soft_edge": {
"name": "soft_edge",
"tooltip": "Extra feathering, not A1111 Soft Inpainting."
},
"refiner_model": {
"name": "refiner_model",
"tooltip": "Optional compatible refiner model, e.g. SDXL refiner."
},
"refiner_positive": {
"name": "refiner_positive",
"tooltip": "Encode using the refiner CLIP; defaults to original positive conditioning."
},
"refiner_negative": {
"name": "refiner_negative",
"tooltip": "Encode using the refiner CLIP; defaults to original negative conditioning."
}
},
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "crop"
},
"2": {
"name": "mask"
}
}
},
"AdachiImageCompare": {
"display_name": "Image Compare",
"description": "Hover to compare; adopt a result into the upstream inline editor.",
"inputs": {
"original": {
"name": "original",
"tooltip": "Original image shown on the right."
},
"result": {
"name": "result",
"tooltip": "Full-size generated image shown on the left; use image, not crop."
}
},
"outputs": {
"0": {
"name": "image"
}
}
},
"AdachiExclusiveSwitch": {
"display_name": "Auto Exclusive Switch",
"description": "Automatically forwards the only enabled input. Bypass one branch; both enabled or both absent is an error.",
"inputs": {
"input_a": {
"name": "input_a",
"tooltip": "First branch. Connect the same data type to both branches and the output."
},
"input_b": {
"name": "input_b",
"tooltip": "Second branch. Exactly one branch must remain enabled."
}
},
"outputs": {
"0": {
"name": "output"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"nodeCategories": {
"Adachi-Inpaint-Workbench": "安达工作台 / Adachi-Inpaint-Workbench"
}
}
+255
View File
@@ -0,0 +1,255 @@
{
"AdachiInlineMask": {
"display_name": "内联蒙版 / Inline Mask",
"description": "直接在工作流节点内涂抹蒙版,无需打开独立编辑器。",
"inputs": {
"image": {
"name": "原图",
"tooltip": "由内联编辑器上传填充的 ComfyUI input 图片路径。"
},
"mask_data": {
"name": "蒙版数据",
"tooltip": "编辑器保存的蒙版 PNG 数据;白色重绘。"
},
"source_data": {
"name": "嵌入原图",
"tooltip": "嵌入原图,便于工作流在其他机器使用。"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "蒙版"
}
}
},
"AdachiInpaintPrepare": {
"display_name": "重绘预处理 / Inpaint Prepare",
"description": "局部重绘裁剪、蒙版处理与精确回贴上下文;latent 填充请使用一体采样节点。",
"inputs": {
"image": {
"name": "原图",
"tooltip": "原图;输出会还原为这个尺寸。"
},
"mask": {
"name": "蒙版",
"tooltip": "白色重绘,黑色保护。"
},
"area": {
"name": "重绘范围",
"tooltip": "整张原图重绘,或仅蒙版包围框区域重绘。"
},
"width": {
"name": "宽度",
"tooltip": "采样画布宽度;最终输出仍为原图尺寸。"
},
"height": {
"name": "高度",
"tooltip": "采样画布高度。"
},
"padding": {
"name": "外扩边距",
"tooltip": "仅蒙版区域:原图像素单位的上下文边距。"
},
"mask_blur": {
"name": "蒙版模糊",
"tooltip": "原图像素单位的高斯羽化半径。"
},
"mask_grow": {
"name": "蒙版扩张",
"tooltip": "正数扩张,负数收缩。"
},
"mask_mode": {
"name": "蒙版内外",
"tooltip": "重绘蒙版内,或反相重绘蒙版外。"
},
"fill": {
"name": "填充方式",
"tooltip": "原图像素、周围颜色填充、潜空间噪声或空白潜空间;后两项由一体采样节点在 latent 层实现。"
},
"resize_mode": {
"name": "缩放方式",
"tooltip": "整图模式的缩放方式;仅蒙版模式始终保留完整蒙版。"
},
"soft_edge": {
"name": "额外羽化",
"tooltip": "额外羽化;不是 A1111 的 Soft Inpainting 原算法。"
},
"soft_strength": {
"name": "羽化强度",
"tooltip": "额外增加 0–16 像素的高斯羽化。"
}
},
"outputs": {
"0": {
"name": "像素"
},
"1": {
"name": "蒙版"
},
"2": {
"name": "上下文"
}
}
},
"AdachiInpaintComposite": {
"display_name": "原图回贴 / Inpaint Composite",
"description": "还原原图尺寸,有效蒙版外保持原像素不变。",
"inputs": {
"generated": {
"name": "采样图",
"tooltip": "采样后的裁剪图。"
},
"context": {
"name": "上下文",
"tooltip": "预处理节点输出的裁剪与原图上下文。"
}
},
"outputs": {
"0": {
"name": "图像"
}
}
},
"AdachiWebUIInpaint": {
"display_name": "WebUI 局部重绘 / WebUI Inpaint",
"description": "WebUI 式局部重绘:整图/蒙版区域、填充、采样与原尺寸回贴。",
"inputs": {
"image": {
"name": "原图",
"tooltip": "原图;输出会还原为这个尺寸。"
},
"mask": {
"name": "蒙版",
"tooltip": "白色重绘,黑色保护。"
},
"area": {
"name": "重绘范围",
"tooltip": "整张原图重绘,或仅蒙版包围框区域重绘。"
},
"width": {
"name": "宽度",
"tooltip": "采样画布宽度;最终输出仍为原图尺寸。"
},
"height": {
"name": "高度",
"tooltip": "采样画布高度。"
},
"padding": {
"name": "外扩边距",
"tooltip": "仅蒙版区域:原图像素单位的上下文边距。"
},
"mask_blur": {
"name": "蒙版模糊",
"tooltip": "原图像素单位的高斯羽化半径。"
},
"mask_grow": {
"name": "蒙版扩张",
"tooltip": "正数扩张,负数收缩。"
},
"mask_mode": {
"name": "蒙版内外",
"tooltip": "重绘蒙版内,或反相重绘蒙版外。"
},
"fill": {
"name": "填充方式",
"tooltip": "原图像素、周围颜色填充、潜空间噪声或空白潜空间。"
},
"resize_mode": {
"name": "缩放方式",
"tooltip": "整图模式的缩放方式;仅蒙版模式始终保留完整蒙版。"
},
"seed": {
"name": "随机种子",
"tooltip": "可复现的批量随机种子。"
},
"denoise": {
"name": "重绘幅度",
"tooltip": "0 原样返回原图,1 完全重绘。"
},
"batch_size": {
"name": "每张生成数量",
"tooltip": "每张输入图生成的变体数量。"
},
"refiner_switch": {
"name": "Refiner 切换",
"tooltip": "接入 Refiner 时 base 模型所占的采样步骤比例。"
},
"inpaint_model": {
"name": "专用重绘模型",
"tooltip": "仅专用 inpaint 模型开启;使用 ComfyUI 原生 InpaintModelConditioning。"
},
"soft_edge": {
"name": "额外羽化",
"tooltip": "额外羽化,并非 A1111 Soft Inpainting 原算法。"
},
"soft_strength": {
"name": "羽化强度",
"tooltip": "额外增加的高斯羽化强度。"
},
"refiner_model": {
"name": "Refiner 模型",
"tooltip": "可选的兼容 Refiner 模型,例如 SDXL refiner。"
},
"refiner_positive": {
"name": "Refiner 正面",
"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始正面条件。"
},
"refiner_negative": {
"name": "Refiner 负面",
"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始负面条件。"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "裁剪图"
},
"2": {
"name": "蒙版"
}
}
},
"AdachiImageCompare": {
"display_name": "图像对比 / Image Compare",
"description": "鼠标悬停左右对比原图与重绘结果;点击可将结果回填到上游内联蒙版。",
"inputs": {
"original": {
"name": "原图",
"tooltip": "显示在右侧的原图,连接内联蒙版的 image。"
},
"result": {
"name": "重绘结果",
"tooltip": "显示在左侧的完整重绘图;请连接最终 image 而非 crop。"
}
},
"outputs": {
"0": {
"name": "图像"
}
}
},
"AdachiExclusiveSwitch": {
"display_name": "自动二选一 / Auto Exclusive Switch",
"description": "自动输出唯一启用的分支;忽略或断开另一路,两路都启用或都无输入时提示错误,无需 switch。",
"inputs": {
"input_a": {
"name": "输入 A",
"tooltip": "第一路;两路及下游应使用相同数据类型,如正面条件、图像、模型。"
},
"input_b": {
"name": "输入 B",
"tooltip": "第二路;两路中需要且只能启用一路,另一支设为忽略或断开。"
}
},
"outputs": {
"0": {
"name": "输出"
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 676 KiB

+16 -2
View File
@@ -1,11 +1,25 @@
[project]
name = "comfyui-adachi-workbench"
version = "0.3.0"
name = "adachi-inpaint-workbench"
version = "0.5.13"
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]
PublisherId = "adachi0130"
DisplayName = "Adachi-Inpaint-Workbench"
[tool.adachi-workbench]
frontend = "web"
+22
View File
@@ -0,0 +1,22 @@
# Manager / Registry publishing
A public GitHub repository does not by itself mean the plugin is listed in the Manager search index or in the Comfy Registry.
The Manager listing submission for this plugin is [PR #3378](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3378), currently waiting on the upstream maintainer.
## Manager listing
[manager-entry.json](manager-entry.json) is the entry submitted to [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager); it is added to the `custom-node-list.json` at the repository root in a pull request, and the maintainer merges it and refreshes the index.
Follow the [official Manager instructions](https://github.com/Comfy-Org/ComfyUI-Manager#how-to-register-your-custom-node-into-comfyui-manager).
Until it is listed you can still install through the Manager's Git URL option, using `https://github.com/adachi0130/Adachi-Inpaint-Workbench`.
## Comfy Registry
Per the [official publishing guide](https://docs.comfy.org/registry/publishing), the first publish requires you to create your own Publisher and API key.
1. Sign in at [Comfy Registry](https://registry.comfy.org/) and create a Publisher, then note the actual Publisher ID.
2. Replace the empty `[tool.comfy].PublisherId` in `pyproject.toml` with that ID and push it to GitHub.
3. In the repository Settings → Secrets and variables → Actions, add `REGISTRY_ACCESS_TOKEN` with the Registry publishing API key.
4. Run **Publish to Comfy Registry** manually from the Actions tab and check the publish and platform validation results.
The workflow is manual-only today and will not publish while no Publisher ID or API key is configured.
A Registry version cannot be overwritten once published, so later releases have to bump the version in `pyproject.toml`.
+10
View File
@@ -0,0 +1,10 @@
{
"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": "Paint the inpaint mask directly on the node canvas instead of a modal mask editor, with no save/load round-trip per stroke. The connected inpaint node hosts the live painting tools and WebUI-style sampling controls, and supports whole-image or padded masked-area sampling with original-resolution compositing, Alt temporary erase, Ctrl+wheel brush size, hover image comparison and one-click adoption of a result as the next source image. MIT licensed; reuses ComfyUI's NumPy, Pillow and PyTorch."
}
+3
View File
@@ -0,0 +1,3 @@
numpy
Pillow
torch
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { appendComparison } from "../web/compare_history.js";
const first = {originals: ["source-a"], results: ["result-a"]};
const second = appendComparison(first, {originals: ["source-b"], results: ["result-b", "result-c"]});
assert.deepEqual(second, {originals: ["source-a", "source-b", "source-b"], results: ["result-a", "result-b", "result-c"]});
assert.deepEqual(first, {originals: ["source-a"], results: ["result-a"]}, "Do not mutate saved history");
const legacy = {originals: ["a", "b"], results: [1, 2, 3, 4]};
assert.deepEqual(appendComparison(legacy, {originals: ["c"], results: [5]}), {
originals: ["a", "a", "b", "b", "c"], results: [1, 2, 3, 4, 5],
});
assert.deepEqual(appendComparison(undefined, first), first);
assert.deepEqual(appendComparison(first, {}), first);
assert.deepEqual(appendComparison({}, {results: ["orphan"]}), {originals: [], results: []});
assert.deepEqual(appendComparison(JSON.parse(JSON.stringify(second)), first).results, ["result-a", "result-b", "result-c"], "Cached execution replay must not count twice");
const preview = {filename: "preview_001.png", subfolder: "", type: "temp"};
const one = {originals: ["source-a"], results: [preview]};
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview}]}).results.length, 1);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, filename: "preview_002.png"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, subfolder: "other"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, type: "output"}]}).results.length, 2);
assert.deepEqual(appendComparison({originals: ["source-a", "source-a"], results: [preview, {...preview}]}), one, "Repair duplicates already saved in workflows");
console.log("Comparison history checks passed");
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { branchIsEnabled } from "../web/exclusive_routes.js";
const nodes = new Map(), links = new Map();
const graph = {getNodeById: id => nodes.get(id), getLink: id => links.get(id)};
let sequence = 0;
function node(type, options = {}) {
const value = {id: ++sequence, type, mode: 0, inputs: [], graph, ...options};
nodes.set(value.id, value); return value;
}
function connect(source, target, name = "value") {
const id = ++sequence;
links.set(id, {origin_id: source.id, origin_slot: 0});
target.inputs.push({name, link: id});
}
const a = node("CLIPTextEncode"), b = node("CLIPTextEncode");
const sa = node("SetNode", {isVirtualNode: true}), sb = node("SetNode", {isVirtualNode: true});
connect(a, sa); connect(b, sb);
const ga = node("GetNode", {isVirtualNode: true, findSetter: () => sa});
const gb = node("GetNode", {isVirtualNode: true, findSetter: () => sb});
const switchNode = node("AdachiExclusiveSwitch");
connect(ga, switchNode, "input_a"); connect(gb, switchNode, "input_b");
const state = () => Number(branchIsEnabled(switchNode, "input_a")) | (Number(branchIsEnabled(switchNode, "input_b")) << 1);
assert.equal(state(), 3, "both real branches enabled");
sa.mode = 4; assert.equal(state(), 2, "ignored Set disables A despite enabled Get");
sa.mode = 0; sb.mode = 4; assert.equal(state(), 1, "reverse ignored Set selects A");
sb.mode = 0; a.mode = 4; assert.equal(state(), 2, "ignored encoder is found beyond Get/Set");
a.mode = 0; b.mode = 2; assert.equal(state(), 1, "muted encoder is also disabled");
b.mode = 0; ga.mode = 4; assert.equal(state(), 2, "ignored Get is disabled"); ga.mode = 0;
const reroute = node("Reroute"); connect(a, reroute); sa.inputs = []; connect(reroute, sa);
reroute.mode = 4; assert.equal(state(), 2, "ignored reroute behind Set is disabled");
reroute.mode = 0; a.mode = 4; assert.equal(state(), 2, "enabled reroute follows ignored source"); a.mode = 0;
sa.inputs = []; connect(ga, sa); assert.equal(state(), 2, "virtual routing cycle is disabled safely");
ga.findSetter = () => null; assert.equal(state(), 2, "missing setter is disabled");
const modern = node("VirtualRouter", {isVirtualNode: true, resolveVirtualOutput: () => ({node: a, slot: 0})});
const routed = node("AdachiExclusiveSwitch"); connect(modern, routed, "input_a");
assert.equal(branchIsEnabled(routed, "input_a"), true, "modern virtual output API is followed");
a.mode = 4;
assert.equal(branchIsEnabled(routed, "input_a"), false, "modern virtual output respects ignored source");
assert.equal(branchIsEnabled({...switchNode, graph: null}, "input_a"), false);
assert.equal(branchIsEnabled(switchNode, "not_connected"), false);
console.log("Exclusive routing: Get/Set, bypass/mute, reroutes, cycles and missing inputs passed");
+176
View File
@@ -0,0 +1,176 @@
"""The selector must preserve arbitrary values and never silently prefer a branch."""
import importlib.util
import copy
from pathlib import Path
import unittest
import torch
spec = importlib.util.spec_from_file_location("exclusive_backend", Path(__file__).resolve().parents[1] / "backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class ExclusiveSwitchTests(unittest.TestCase):
def setUp(self):
self.node = backend.AdachiExclusiveSwitch()
def workflow(self):
return {"nodes": [
{"id": 1, "type": "CLIPTextEncode", "mode": 4},
{"id": 2, "type": "CLIPTextEncode", "mode": 0},
{"id": 3, "type": "SetNode", "mode": 4, "widgets_values": ["p_0"], "inputs": [{"link": 1}]},
{"id": 4, "type": "SetNode", "mode": 0, "widgets_values": ["p"], "inputs": [{"link": 2}]},
{"id": 5, "type": "GetNode", "mode": 0, "widgets_values": ["p_0"]},
{"id": 6, "type": "GetNode", "mode": 0, "widgets_values": ["p"]},
{"id": 532, "type": "AdachiExclusiveSwitch", "inputs": [{"name": "input_a", "link": 3}, {"name": "input_b", "link": 4}]},
], "links": [[1, 1, 0, 3, 0, "*"], [2, 2, 0, 4, 0, "*"], [3, 5, 0, 532, 0, "*"], [4, 6, 0, 532, 1, "*"]]}
def test_single_connected_branch_is_requested(self):
self.assertEqual(self.node.check_lazy_status(input_a=None), ["input_a"])
self.assertEqual(self.node.check_lazy_status(input_b=None), ["input_b"])
def test_bypassed_passthrough_branch_is_not_requested(self):
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=1), ["input_a"])
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=2), ["input_b"])
a, b = object(), object()
self.assertIs(self.node.select(a, b, 1)[0], a)
self.assertIs(self.node.select(a, b, 2)[0], b)
def test_ambiguous_and_empty_inputs_fail(self):
for inputs in ({}, {"input_a": None, "input_b": None}, {"active_inputs": 0}, {"active_inputs": 3}):
with self.subTest(inputs=inputs), self.assertRaisesRegex(ValueError, "exactly one"):
self.node.check_lazy_status(**inputs)
def test_missing_selected_input_and_none_data_fail(self):
with self.assertRaisesRegex(ValueError, "has no input"):
self.node.check_lazy_status(input_b=None, active_inputs=1)
with self.assertRaisesRegex(ValueError, "returned no data"):
self.node.select(input_a=None)
def test_falsey_values_and_tensors_are_forwarded_by_identity(self):
for value in (False, 0, "", [], {}, torch.zeros(1, 4, 4, 3), torch.ones(1, 4, 4), {"samples": torch.zeros(1, 4, 8, 8)}, [[torch.zeros(1, 2, 3), {}]]):
with self.subTest(type=type(value)):
self.assertIs(self.node.select(input_a=value)[0], value)
self.assertIs(self.node.select(input_b=value)[0], value)
def test_submitted_single_connection_overrides_stale_flags(self):
for name in ("input_a", "input_b"):
prompt = {"532": {"inputs": {name: ["source", 0], "active_inputs": 3}}}
for stale in (-1, 0, 1, 2, 3):
with self.subTest(name=name, stale=stale):
args = {name: None, "active_inputs": stale, "prompt": prompt, "unique_id": "532"}
self.assertEqual(self.node.check_lazy_status(**args), [name])
value = object()
args[name] = value
self.assertIs(self.node.select(**args)[0], value)
def test_submitted_empty_or_ambiguous_connections_still_fail(self):
for inputs in ({}, {"input_a": ["a", 0], "input_b": ["b", 0]}):
with self.subTest(inputs=inputs), self.assertRaisesRegex(ValueError, "exactly one"):
self.node.check_lazy_status(active_inputs=3, prompt={"1": {"inputs": inputs}}, unique_id="1")
prompt = {"1": {"inputs": {"input_a": ["a", 0], "input_b": ["b", 0]}}}
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=2, prompt=prompt, unique_id="1"), ["input_b"])
def test_dynamic_subgraph_connection_is_authoritative(self):
class DynamicPrompt:
def get_node(self, node_id):
self_id = "subgraph:532"
if node_id != self_id:
raise KeyError(node_id)
return {"inputs": {"input_b": ["nested-source", 0]}}
self.assertEqual(self.node.check_lazy_status(input_b=None, active_inputs=3,
prompt={}, dynprompt=DynamicPrompt(), unique_id="subgraph:532"), ["input_b"])
def test_enabled_gets_follow_ignored_set_and_encoder_in_workflow(self):
workflow = self.workflow()
args = dict(input_a=None, input_b=None, active_inputs=3, unique_id="532", extra_pnginfo={"workflow": workflow})
self.assertEqual(self.node.check_lazy_status(**args), ["input_b"])
for stale in range(-1, 4):
self.assertEqual(self.node.check_lazy_status(**{**args, "active_inputs": stale}), ["input_b"])
for node in workflow["nodes"][:4]:
node["mode"] = 0 if node["mode"] == 4 else 4
self.assertEqual(self.node.check_lazy_status(**args), ["input_a"])
def test_ignored_set_alone_or_encoder_alone_disables_route(self):
for source_mode, set_mode in ((4, 0), (0, 4), (2, 0), (0, 2)):
workflow = self.workflow()
workflow["nodes"][0]["mode"] = source_mode
workflow["nodes"][2]["mode"] = set_mode
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
def test_workflow_ambiguous_empty_cycle_missing_set_are_not_selected(self):
workflow = self.workflow()
for node in workflow["nodes"][:4]:
node["mode"] = 0
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 3)
for node in workflow["nodes"][:4]:
node["mode"] = 4
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 0)
workflow = self.workflow()
workflow["nodes"][2]["mode"] = 0
workflow["links"][0][1] = 5
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
workflow["nodes"][4]["widgets_values"] = ["missing"]
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
def test_prompt_normalization_before_cache_preserves_workflow(self):
workflow = self.workflow()
saved = copy.deepcopy(workflow)
request = {"prompt": {"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"input_a": ["a", 0], "input_b": ["b", 0], "active_inputs": 3}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
self.assertIs(backend.normalize_exclusive_prompt(request), request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"input_b": ["b", 0], "active_inputs": 2})
self.assertEqual(workflow, saved)
plain = {"prompt": {"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"input_a": ["a", 0]}}}}
original = copy.deepcopy(plain)
backend.normalize_exclusive_prompt(plain)
self.assertEqual(plain, original)
def test_scoped_subgraph_and_dictionary_links(self):
child = self.workflow()
child["id"] = "child"
child["links"] = [dict(zip(("id", "origin_id", "origin_slot", "target_id", "target_slot", "type"), link)) for link in child["links"]]
workflow = {"nodes": [{"id": 349, "type": "child"}], "definitions": {"subgraphs": [child]}}
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "349:532"), 2)
self.assertIsNone(backend._workflow_exclusive_flags({"workflow": workflow}, "532"))
def test_missing_selected_link_is_restored_through_get_set(self):
workflow = self.workflow()
request = {"prompt": {"2": {"class_type": "CLIPTextEncode", "inputs": {}},
"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"active_inputs": 0}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
backend.normalize_exclusive_prompt(request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"active_inputs": 2, "input_b": ["2", 0]})
def test_missing_subgraph_output_link_is_restored_to_flattened_node(self):
workflow = {"nodes": [
{"id": 349, "type": "child", "mode": 0},
{"id": 521, "type": "CLIPTextEncode", "mode": 4},
{"id": 532, "type": "AdachiExclusiveSwitch", "inputs": [{"name": "input_a", "link": 1}, {"name": "input_b", "link": 2}]},
], "links": [[1, 349, 2, 532, 0, "CONDITIONING"], [2, 521, 0, 532, 1, "CONDITIONING"]],
"definitions": {"subgraphs": [{"id": "child", "nodes": [{"id": 412, "type": "CLIPTextEncode", "mode": 0}],
"outputs": [{"linkIds": []}, {"linkIds": []}, {"linkIds": [2019]}],
"links": [{"id": 2019, "origin_id": 412, "origin_slot": 0, "target_id": -20, "target_slot": 2}]}]}}
request = {"prompt": {"349:412": {"class_type": "CLIPTextEncode", "inputs": {}},
"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"active_inputs": 1}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
saved = copy.deepcopy(workflow)
backend.normalize_exclusive_prompt(request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"active_inputs": 1, "input_a": ["349:412", 0]})
self.assertEqual(workflow, saved)
# Do not invent a source that was never submitted, or use a mismatched
# node type that happens to have the same execution ID.
for source in (None, {"class_type": "OtherNode", "inputs": {}}):
bad = copy.deepcopy(request)
bad["prompt"]["532"]["inputs"].pop("input_a")
if source is None:
bad["prompt"].pop("349:412")
else:
bad["prompt"]["349:412"] = source
backend.normalize_exclusive_prompt(bad)
self.assertNotIn("input_a", bad["prompt"]["532"]["inputs"])
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
"""Regression coverage for image models using single-frame video-derived VAEs."""
import importlib.util
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
import torch
import torch.nn.functional as F
spec = importlib.util.spec_from_file_location("vae_format_backend", Path(__file__).resolve().parents[1] / "backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class VideoDerivedVAE:
latent_dim = 3
downscale_ratio = (lambda t: (t + 3) // 4, 8, 8)
def __init__(self):
self.encoded_batches = []
def spacial_compression_encode(self):
return 8
def encode(self, pixels):
self.encoded_batches.append(pixels.shape[0])
# Like Wan/Qwen VAE, an IMAGE batch would otherwise become time.
frames = F.interpolate(pixels.mean(-1)[:, None], scale_factor=1 / 8, mode="bilinear", align_corners=False)
return frames.permute(1, 0, 2, 3)[None].repeat(1, 16, 1, 1, 1)
def decode(self, samples):
image = F.interpolate(samples[:, :1, 0], scale_factor=8, mode="nearest")
return image.repeat(1, 3, 1, 1).permute(0, 2, 3, 1)[:, None]
class ImageVAEFormatTests(unittest.TestCase):
def inputs(self):
image = torch.stack([torch.full((64, 64, 3), .2), torch.full((64, 64, 3), .8)])
masks = torch.zeros(2, 64, 64)
masks[0, :, :32] = 1
masks[1, :, 32:] = 1
return image, masks
def test_5d_latents_preserve_batch_masks_compositing_and_variants(self):
for area in ("whole_image", "only_masked"):
with self.subTest(area=area):
image, masks = self.inputs()
vae, captured = VideoDerivedVAE(), []
def sample(*args, **kwargs):
captured.append(args[8])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
result, crop, mask = backend.AdachiWebUIInpaint().sample(None, [], [], vae, image, masks,
width=64, height=64, area=area, mask_blur=0, batch_size=2)
self.assertEqual(vae.encoded_batches, [1, 1, 1, 1])
self.assertEqual(captured[0]["samples"].shape, (4, 16, 1, 8, 8))
self.assertEqual(captured[0]["noise_mask"].shape, (4, 1, 1, 8, 8))
self.assertTrue(torch.allclose(captured[0]["samples"].mean((1, 2, 3, 4)), torch.tensor([.2, .2, .8, .8])))
originals, masks = image.repeat_interleave(2, 0), masks.repeat_interleave(2, 0)
self.assertEqual(result.shape, originals.shape)
self.assertEqual(crop.shape, (4, 64, 64, 3))
self.assertEqual(mask.shape, (4, 64, 64))
self.assertTrue(torch.equal(result[masks == 0], originals[masks == 0]))
def test_5d_noise_and_zero_fill_only_modify_masked_latents(self):
image, masks = self.inputs()
captured = []
def sample(*args, **kwargs):
captured.append(args[8])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
for fill in ("latent_noise", "latent_noise", "latent_nothing"):
backend.AdachiWebUIInpaint().sample(None, [], [], VideoDerivedVAE(), image, masks,
width=64, height=64, area="whole_image", mask_blur=0, fill=fill, seed=42)
self.assertTrue(torch.equal(captured[0]["samples"], captured[1]["samples"]))
for latent in captured:
protected = (latent["noise_mask"] == 0).expand_as(latent["samples"])
expected = torch.tensor([.2, .8])[:, None, None, None, None].expand_as(latent["samples"])
self.assertTrue(torch.allclose(latent["samples"][protected], expected[protected]))
selected = (captured[2]["noise_mask"] == 1).expand_as(captured[2]["samples"])
self.assertTrue(torch.equal(captured[2]["samples"][selected], torch.zeros_like(captured[2]["samples"][selected])))
self.assertFalse(torch.equal(captured[0]["samples"][0], captured[0]["samples"][1]))
def test_unknown_vae_wrapper_recovers_independent_batch(self):
vae = VideoDerivedVAE()
wrapper = types.SimpleNamespace(encode=vae.encode)
samples = backend._encode_still_images(wrapper, self.inputs()[0])
self.assertEqual(samples.shape, (2, 16, 1, 8, 8))
self.assertEqual(vae.encoded_batches, [2, 1, 1])
def test_native_inpaint_conditioning_keeps_5d_batch_axis(self):
image, masks = self.inputs()
original_meta = {"marker": "retained"}
conditioning = [[torch.zeros(1, 2, 3), original_meta]]
class Native:
def encode(self, positive, negative, pixels, vae, mask, noise_mask):
metadata = dict(original_meta, concat_latent_image=vae.encode(pixels), concat_mask=mask[:, None])
return [[positive[0][0], metadata]], [[negative[0][0], metadata]], {"samples": vae.encode(pixels), "native_field": True}
def sample(*args, **kwargs):
self.assertEqual(args[6][0][1]["concat_mask"].shape, (2, 1, 1, 64, 64))
self.assertEqual(args[6][0][1]["concat_latent_image"].shape, (2, 16, 1, 8, 8))
self.assertTrue(args[8]["native_field"])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample, InpaintModelConditioning=Native)}):
backend.AdachiWebUIInpaint().sample(None, conditioning, conditioning, VideoDerivedVAE(), image, masks,
width=64, height=64, mask_blur=0, inpaint_model=True)
self.assertEqual(original_meta, {"marker": "retained"})
def test_refiner_preserves_single_frame_shape(self):
image, masks = self.inputs()
calls = []
def sample(*args, **kwargs):
calls.append(args[8]["samples"].shape)
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
backend.AdachiWebUIInpaint().sample(None, [], [], VideoDerivedVAE(), image, masks, refiner_model=object(),
width=64, height=64, mask_blur=0)
self.assertEqual(calls, [(2, 16, 1, 8, 8)] * 2)
def test_actual_video_or_invalid_vae_outputs_fail_explicitly(self):
for samples in (torch.zeros(1, 16, 2, 8, 8), torch.zeros(1, 8, 8), "bad"):
with self.subTest(type=type(samples)), self.assertRaises(ValueError):
backend._check_image_latents(samples, 1)
with self.assertRaisesRegex(ValueError, "batch"):
backend._check_image_latents(torch.zeros(1, 4, 8, 8), 2)
vae = types.SimpleNamespace(decode=lambda latent: torch.zeros(1, 2, 64, 64, 3))
with self.assertRaisesRegex(ValueError, "multiple frames"):
backend._decode_still_images(vae, torch.zeros(1, 16, 1, 8, 8), 1)
if __name__ == "__main__":
unittest.main()
+64 -68
View File
@@ -1,5 +1,5 @@
/* Original hover-comparison UI, MIT licensed. */
import { registerImageWheel } from "./wheel_gestures.js";
import { T } from "./i18n.js";
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -9,7 +9,7 @@ const element = (tag, className, text) => {
const imageFrom = url => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("预览已过期或无法加载,请重新运行 / Preview unavailable; run the workflow again"));
image.onerror = () => reject(new Error(T("Preview unavailable; run the workflow again")));
image.src = url;
});
@@ -18,62 +18,52 @@ export class ImageCompareView {
this.options = options;
this.root = element("div", "dd-inpaint dd-comparer");
this.originals = []; this.results = []; this.index = 0;
this.zoom = 1; this.pan = { x: 0, y: 0 }; this.split = .5;
this.split = .5;
this.version = 0; this.ready = false;
const head = element("div", "dd-head");
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");
toolbar.append(element("span", "dd-compare-label", T("Result")));
this.resultSelect = element("select"); this.resultSelect.setAttribute("aria-label", T("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);
const fit = element("button", "", T("Fit")); fit.type = "button";
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); 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", "左重绘结果、右原图,移动鼠标竖线对比 / Result left, original right; hover to compare");
this.canvas.setAttribute("aria-label", T("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);
this.empty = element("div", "dd-empty", "连接原图与重绘结果,然后运行工作流"); this.stage.append(this.empty);
this.divider = element("div");
Object.assign(this.divider.style, { position: "absolute", background: "#ffffffb3", pointerEvents: "none" });
this.divider.hidden = true;
this.stage.append(this.canvas, this.divider);
this.empty = element("div", "dd-empty", T("Connect an original image and a result, then run the workflow")); this.stage.append(this.empty);
const adoption = element("div", "dd-row dd-adoption");
adoption.append(element("span", "dd-compare-label", "回填到"));
this.targetSelect = element("select"); this.targetSelect.setAttribute("aria-label", "接收结果的内联蒙版 / Target inline editor");
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
this.targetSelect = element("select"); this.targetSelect.setAttribute("aria-label", T("Target inline editor"));
this.targetSelect.onfocus = () => this.refreshTargets();
this.targetSelect.onchange = () => { this.manualTarget = true; this.updateButton(); };
adoption.append(this.targetSelect);
this.adoptButton = element("button", "dd-primary", "采用此结果"); this.adoptButton.type = "button";
this.adoptButton = element("button", "dd-primary", T("Adopt this result")); this.adoptButton.type = "button";
this.adoptButton.onclick = () => this.adopt(); adoption.append(this.adoptButton);
this.message = element("div", "dd-status", "鼠标移动竖线对比 · 滚轮缩放 · 中键拖动");
this.message = element("div", "dd-status", T("Result left, original right; hover to compare"));
this.message.hidden = true; this.message.setAttribute("role", "alert");
this.root.append(head, toolbar, this.stage, adoption, this.message);
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;
if (!this.ready || event.button !== 0) return;
event.preventDefault(); event.stopPropagation(); this.canvas.focus({ preventScroll: true });
if (event.button === 1 || this.space) {
this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, pan: { ...this.pan } };
this.canvas.setPointerCapture(event.pointerId);
} else this.move(event);
this.move(event);
});
const finish = event => {
if (!this.drag || this.drag.id !== event.pointerId) return;
const id = this.drag.id; this.drag = null;
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
};
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) this.canvas.addEventListener(name, finish);
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
this.canvas.addEventListener("keydown", event => {
if (event.code === "Space") { event.preventDefault(); event.stopPropagation(); this.space = true; }
else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault(); event.stopPropagation();
this.split = Math.max(0, Math.min(1, this.split + (event.key === "ArrowRight" ? .02 : -.02))); this.schedule();
}
});
this.canvas.addEventListener("keyup", event => { if (event.code === "Space") this.space = false; });
this.canvas.addEventListener("blur", () => { this.space = false; });
this.observer = new ResizeObserver(() => this.schedule()); this.observer.observe(this.stage);
this.refreshTargets(); this.updateButton();
}
@@ -84,7 +74,7 @@ export class ImageCompareView {
const selected = this.targetSelect.value;
const { editors = [], preferred = "" } = this.options.getTargets?.() || {};
this.targetSelect.replaceChildren();
const placeholder = element("option", "", "选择内联蒙版"); placeholder.value = ""; this.targetSelect.append(placeholder);
const placeholder = element("option", "", T("Select an inline mask")); placeholder.value = ""; this.targetSelect.append(placeholder);
for (const editor of editors) {
const option = element("option", "", editor.label); option.value = String(editor.id); this.targetSelect.append(option);
}
@@ -93,7 +83,7 @@ export class ImageCompareView {
this.updateButton();
}
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; this.adoptButton.textContent = this.busy ? "采用中…" : "采用此结果"; }
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; this.adoptButton.textContent = this.busy ? T("Adopting…") : T("Adopt this result"); }
async setData(data, index = this.index) {
this.originals = Array.isArray(data?.originals) ? data.originals : [];
@@ -130,46 +120,31 @@ export class ImageCompareView {
this.refreshTargets();
if (!this.ready || !this.targetSelect.value || this.busy) return;
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status("正在将结果设为新原图…");
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
try {
await this.options.onAdopt(target, result);
if (!this.destroyed) this.status("已采用:回到内联蒙版,可继续涂抹重绘");
if (!this.destroyed) this.status(T("Adopted: back in the inline mask, keep painting to inpaint again"));
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally { this.busy = false; if (!this.destroyed) this.updateButton(); }
}
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 };
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) : 1;
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2, y: (rect.height - (this.before?.height || 0) * scale) / 2 };
}
position(event) { const rect = this.canvas.getBoundingClientRect(), t = this.transform(); return { x: (event.clientX - rect.left - t.x) / t.scale, y: (event.clientY - rect.top - t.y) / t.scale }; }
wheel(event) {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation();
const before = this.position(event), rect = this.canvas.getBoundingClientRect();
this.zoom = Math.max(.1, Math.min(32, this.zoom * Math.exp(-event.deltaY * .0015)));
const t = this.transform();
this.pan.x += event.clientX - rect.left - (t.x + before.x * t.scale);
this.pan.y += event.clientY - rect.top - (t.y + before.y * t.scale);
this.schedule();
this.options.onGraphWheel?.(event);
}
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;
this.pan.y = this.drag.pan.y + event.clientY - this.drag.y;
} else this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.schedule();
}
@@ -178,26 +153,47 @@ export class ImageCompareView {
render() {
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
if (!t.width || !t.height) return;
const width = Math.round(t.width * ratio), height = Math.round(t.height * ratio);
// Graph zoom changes the screen rect, not the amount of source detail.
// Never allocate a huge upscaled bitmap just to magnify existing pixels.
const rasterRatio = Math.min(ratio, this.ready ? 1 / t.scale : ratio,
8192 / t.width, 8192 / t.height, Math.sqrt(16 * 1024 * 1024 / (t.width * t.height)));
const width = Math.max(1, Math.round(t.width * rasterRatio)), height = Math.max(1, Math.round(t.height * rasterRatio));
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
const ctx = this.canvas.getContext("2d");
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.after, 0, 0, this.before.width, this.before.height);
if (!this.hovered) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.divider.hidden = !this.ready || !this.hovered;
if (!this.ready) { ctx.clearRect(0, 0, width, height); this.lastPaint = null; return; }
const split = this.split * this.before.width;
const lineX = Math.round((t.x + split * t.scale) * rasterRatio) + .5;
if (this.hovered) {
const screenScale = t.width / this.canvas.clientWidth || 1;
Object.assign(this.divider.style, {
left: `${(t.x + split * t.scale) / screenScale}px`, top: `${t.y / screenScale}px`,
height: `${this.before.height * t.scale / screenScale}px`, width: `${1 / screenScale}px`,
});
}
const key = [this.version, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
const previous = this.lastPaint;
ctx.save();
// While hovering, pixels outside the old/new divider positions are
// unchanged. Clip the redraw to that strip rather than repainting all.
if (previous?.key === key && previous.hovered && this.hovered) {
const left = Math.max(0, Math.floor(Math.min(previous.lineX, lineX)) - 2);
const right = Math.min(width, Math.ceil(Math.max(previous.lineX, lineX)) + 2);
ctx.beginPath(); ctx.rect(left, 0, right - left, height); ctx.clip();
}
ctx.clearRect(0, 0, width, height);
ctx.setTransform(t.scale * rasterRatio, 0, 0, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
this.lastPaint = { key, hovered: this.hovered, lineX };
if (!this.hovered) { ctx.restore(); return; }
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
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();
ctx.restore();
}
destroy() {
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
this.stopWheelCapture();
this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
}
}
+26
View File
@@ -0,0 +1,26 @@
// Store one matching original per result, including batches from older workflows.
export function appendComparison(previous, incoming) {
const pairs = data => {
const originals = Array.isArray(data?.originals) ? data.originals : [];
const results = Array.isArray(data?.results) ? data.results : [];
if (!originals.length || !results.length) return { originals: [], results: [] };
const repetitions = results.length / originals.length;
return {
originals: results.map((_, index) => originals[Math.min(originals.length - 1, Math.floor(index / repetitions))]),
results,
};
};
const before = pairs(previous), next = pairs(incoming);
const history = { originals: [], results: [] }, seen = new Set();
for (const data of [before, next]) data.results.forEach((result, index) => {
// Cached execution notifications can replay the same preview. Its file
// identity is stable, while a genuinely new generation gets a new file.
const key = typeof result === "object" && result !== null
? JSON.stringify([result.type || "temp", result.subfolder || "", result.filename])
: JSON.stringify(result);
if (seen.has(key)) return;
seen.add(key);
history.originals.push(data.originals[index]); history.results.push(result);
});
return history;
}
+33 -9
View File
@@ -1,7 +1,10 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js";
import { ImageCompareView } from "./compare.js?v=0.5.9";
import { appendComparison } from "./compare_history.js?v=0.5.8";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
function viewUrl(image) {
const type = ["input", "output", "temp"].includes(image?.type) ? image.type : "temp";
@@ -11,7 +14,7 @@ function viewUrl(image) {
function targets(node) {
const graph = node.graph;
const editors = (graph?._nodes || []).filter(candidate => candidate.type === "AdachiInlineMask" && candidate.adachiUseResult)
.map(candidate => ({ id: candidate.id, label: `${candidate.title || "内联蒙版"} #${candidate.id}` }));
.map(candidate => ({ id: candidate.id, label: `${candidate.title || T("Inline Mask")} #${candidate.id}` }));
const sourceLink = graph?.links?.[node.inputs?.find(input => input.name === "original")?.link];
const queue = sourceLink ? [sourceLink.origin_id] : [], seen = new Set(), upstream = new Set();
while (queue.length && seen.size < 256) {
@@ -40,32 +43,53 @@ app.registerExtension({
onSelect: index => { node.properties.adachi_result_index = index; },
async onAdopt(id, image) {
const target = node.graph?.getNodeById(id);
if (!target?.adachiUseResult) throw new Error("找不到接收结果的内联蒙版节点");
if (!target?.adachiUseResult) throw new Error(T("No inline mask node is available to receive the result"));
const response = await api.fetchApi(`/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || "", type: image.type || "temp" })}`);
if (!response.ok) throw new Error("结果预览已过期,请重新运行工作流");
if (!response.ok) throw new Error(T("Result preview expired; run the workflow again"));
const blob = await response.blob();
if (blob.size > 64 * 1024 * 1024) throw new Error("结果图片超过 64 MiB,请先缩小");
if (blob.size > 64 * 1024 * 1024) throw new Error(T("Result image exceeds 64 MiB; downscale it first"));
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }));
// Adoption starts a new round. Keep the accepted image visible
// as 1/1, then replace it with the next round's first outputs.
const data = { originals: [image], results: [image] };
node.properties.adachi_compare = data;
node.properties.adachi_result_index = 0;
node.properties.adachi_compare_new_round = true;
await compare.setData(data, 0);
app.graph?.change?.();
},
});
node.adachiCompare = compare;
const stopWheelRouting = routeWheelOutsideImage(compare.root, compare.canvas, () => compare.ready);
const stopWheelRouting = routeWheelOutsideImage(compare.root, null, () => false);
const widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", compare.root, {
serialize: false, hideOnZoom: false, getMinHeight: () => 420, getMaxHeight: () => 4096,
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, compare.root);
const executed = node.onExecuted;
node.onExecuted = function (output) {
const result = executed?.apply(this, arguments);
const data = { originals: output?.adachi_originals || [], results: output?.adachi_results || [] };
if (!output?.adachi_originals?.length || !output?.adachi_results?.length) return result;
const previous = node.properties.adachi_compare_new_round
? appendComparison() : appendComparison(node.properties.adachi_compare);
const data = appendComparison(previous, {
originals: output.adachi_originals, results: output.adachi_results,
});
node.properties.adachi_compare = data;
compare.setData(data, node.properties.adachi_result_index || 0);
node.properties.adachi_compare_new_round = false;
node.properties.adachi_result_index = data.results.length > previous.results.length
? data.results.length - 1 : Math.max(0, Math.min(data.results.length - 1, node.properties.adachi_result_index || 0));
compare.setData(data, node.properties.adachi_result_index);
return result;
};
const configured = node.onConfigure;
node.onConfigure = function () {
const result = configured?.apply(this, arguments);
if (node.properties.adachi_compare) compare.setData(node.properties.adachi_compare, node.properties.adachi_result_index || 0);
if (node.properties.adachi_compare) {
node.properties.adachi_compare = appendComparison(node.properties.adachi_compare);
node.properties.adachi_result_index = Math.max(0, Math.min(node.properties.adachi_compare.results.length - 1, node.properties.adachi_result_index || 0));
compare.setData(node.properties.adachi_compare, node.properties.adachi_result_index);
}
return result;
};
const removed = node.onRemoved;
+43 -1
View File
@@ -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; }
@@ -55,7 +56,7 @@
.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-field:has(input[aria-label="Seed"]) 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; }
@@ -73,6 +74,47 @@
.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; } }
.dd-group-presets { border: 0; border-radius: 4px; container-type: inline-size; contain: size layout; }
.dd-gp-toolbar, .dd-gp-form { display: flex; align-items: center; gap: 7px; padding: 9px 10px; flex: 0 0 auto; }
.dd-gp-toolbar { justify-content: space-between; border-bottom: 1px solid var(--dd-line); }
.dd-gp-toolbar strong { font-size: 12px; font-weight: 500; }
.dd-group-presets button { min-height: 26px; padding: 4px 7px; font-size: 11px; }
.dd-gp-form input { flex: 1; min-width: 0; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); padding: 5px 6px; font: inherit; border-radius: 3px; }
.dd-gp-body { display: grid; grid-template-columns: minmax(130px, 40%) minmax(0, 1fr); flex: 1; min-height: 0; }
.dd-gp-presets { border-right: 1px solid var(--dd-line); padding: 7px; display: flex; flex-direction: column; gap: 5px; }
.dd-gp-presets button { text-align: left; flex: 0 0 auto; white-space: normal; overflow-wrap: anywhere; }
.dd-gp-detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.dd-gp-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; }
.dd-gp-detail-head strong { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-gp-groups { flex: 1; min-height: 0; padding: 0 8px; }
.dd-gp-scroll { overflow-y: auto; overscroll-behavior: contain; }
.dd-gp-group { display: flex; align-items: center; gap: 7px; padding: 7px 0; border-bottom: 1px solid #41495566; }
.dd-gp-color { flex: 0 0 4px; width: 4px; height: 20px; border-radius: 2px; }
.dd-gp-group-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-gp-group select { max-width: 118px; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); border-radius: 3px; padding: 4px; font: inherit; font-size: 11px; }
.dd-gp-changed .dd-gp-group-name { color: var(--dd-accent); }
.dd-gp-footer { display: flex; flex-wrap: wrap; gap: 5px; padding: 9px 8px; }
.dd-gp-status { flex: 0 0 auto; padding: 5px 10px 7px; color: var(--dd-muted); font-size: 10px; border-top: 1px solid var(--dd-line); }
.dd-gp-empty { color: var(--dd-muted); padding: 10px 4px; font-size: 11px; }
+144 -52
View File
@@ -1,5 +1,6 @@
/* Original implementation, MIT licensed. No WebUI source is bundled. */
import { registerImageWheel } from "./wheel_gestures.js";
import { T } from "./i18n.js";
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
@@ -10,7 +11,7 @@ const makeCanvas = () => document.createElement("canvas");
const loadImage = (url) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("无法读取图像 / Cannot load image"));
image.onerror = () => reject(new Error(T("Cannot load image")));
image.src = url;
});
@@ -23,6 +24,8 @@ export class InlineMaskEditor {
this.mask = makeCanvas();
this.baseMask = makeCanvas();
this.overlay = makeCanvas();
this.scene = makeCanvas();
this.maskRevision = 0;
this.tool = "brush";
this.view = "overlay";
this.brushSize = 64;
@@ -42,16 +45,37 @@ export class InlineMaskEditor {
// Nodes 2.0 captures wheel events before widget handlers. Route only
// this canvas at window capture so image gestures stay inside it.
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 +90,12 @@ export class InlineMaskEditor {
build() {
const head = el("div", "dd-head");
this.sizeLabel = el("span", "dd-dimensions", "内联蒙版 · INLINE MASK");
this.sizeLabel = el("span", "dd-dimensions", T("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.filename = el("span", "dd-filename", "拖放图片到画布");
this.loadButton = this.button(files, T("Upload"), T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
files.append(this.filename);
this.root.append(files);
this.imageInput = el("input");
@@ -97,22 +121,22 @@ export class InlineMaskEditor {
const tools = el("div", "dd-row dd-tools");
this.toolButtons = {};
for (const [name, label, title] of [
["brush", "画笔", "画笔 B / Brush"], ["erase", "橡皮", "橡皮 E,按住 Alt 或鼠标右键临时擦除 / Eraser"],
["rect", "矩形", "矩形 R / Rectangle"], ["lasso", "套索", "自由套索 L / Freehand lasso"],
["pan", "移动", "移动 H,或按住空格 / Pan"],
]) this.toolButtons[name] = this.button(tools, label, title, () => this.setTool(name));
["brush", "Brush", "Brush (B)"], ["erase", "Eraser", "Eraser (E); hold Alt or right-click for temporary erase"],
["rect", "Rectangle", "Rectangle (R)"], ["lasso", "Lasso", "Freehand lasso (L)"],
["pan", "Pan", "Pan (H), or hold Space"],
]) this.toolButtons[name] = this.button(tools, T(label), T(title), () => this.setTool(name));
this.root.append(tools);
const settings = el("div", "dd-settings");
this.addSlider(settings, "笔径", "brushSize", 1, 512, "px");
this.addSlider(settings, "硬度", "hardness", 0, 100, "%");
this.addSlider(settings, "笔触", "brushOpacity", 1, 100, "%");
this.addSlider(settings, "叠加", "overlayOpacity", 0, 100, "%");
this.addSlider(settings, T("Brush size"), "brushSize", 1, 512, "px");
this.addSlider(settings, T("Hardness"), "hardness", 0, 100, "%");
this.addSlider(settings, T("Brush opacity"), "brushOpacity", 1, 100, "%");
this.addSlider(settings, T("Overlay"), "overlayOpacity", 0, 100, "%");
this.stage = el("div", "dd-stage");
this.canvas = makeCanvas();
this.canvas.setAttribute("aria-label", "直接涂抹蒙版;橙色区域将被重绘 / Inline mask painting canvas");
this.canvas.setAttribute("aria-label", T("Inline mask painting canvas; orange areas are repainted"));
this.stage.append(this.canvas);
this.empty = el("div", "dd-empty");
this.empty.append(el("span", "dd-empty-symbol", "+"), el("strong", "", "把原图拖到这里"), el("span", "", "直接在节点中涂抹需要重绘的区域"));
this.empty.append(el("span", "dd-empty-symbol", "+"), el("strong", "", T("Drop an image here")), el("span", "", T("Paint the areas to repaint directly in this node")));
this.stage.append(this.empty);
this.root.append(this.stage, settings);
this.canvas.oncontextmenu = event => event.preventDefault();
@@ -130,35 +154,54 @@ export class InlineMaskEditor {
if (file) this.upload(file);
});
const actions = el("div", "dd-row dd-actions");
this.undoButton = this.button(actions, "撤销", "撤销 Ctrl+Z / Undo", () => this.undo());
this.redoButton = this.button(actions, "重做", "重做 Ctrl+Shift+Z / Redo", () => this.redo());
this.button(actions, "反转", "反转绘制的蒙版 / Invert painted mask", () => this.action({ type: "invert" }));
this.button(actions, "全选", "重绘整张图 / Fill mask", () => this.action({ type: "fill" }));
this.button(actions, "清空", "清空蒙版 / Clear mask", () => this.action({ type: "clear" }));
this.button(actions, "导入蒙版", "白色重绘,黑色保护 / Import mask", () => { if (this.ready) this.maskInput.click(); });
this.button(actions, "导出", "导出原始分辨率黑白蒙版 / Export mask PNG", () => this.download());
this.undoButton = this.button(actions, T("Undo"), T("Undo (Ctrl+Z)"), () => this.undo());
this.redoButton = this.button(actions, T("Redo"), T("Redo (Ctrl+Shift+Z)"), () => this.redo());
this.button(actions, T("Invert"), T("Invert the painted mask"), () => this.action({ type: "invert" }));
this.button(actions, T("Fill"), T("Fill the whole image with mask"), () => this.action({ type: "fill" }));
this.button(actions, T("Clear"), T("Clear the mask"), () => this.action({ type: "clear" }));
this.button(actions, T("Import mask"), T("White repaints, black preserves"), () => { if (this.ready) this.maskInput.click(); });
this.button(actions, T("Export"), T("Export the mask PNG at full resolution"), () => this.download());
this.root.append(actions);
const footer = el("div", "dd-row dd-viewbar");
this.viewButtons = {};
for (const [name, label] of [["overlay", "叠加"], ["image", "原图"], ["mask", "蒙版"]]) {
this.viewButtons[name] = this.button(footer, label, `${label} / ${name}`, () => { this.view = name; this.updateButtons(); this.schedule(); });
for (const [name, label] of [["overlay", "Overlay"], ["image", "Image"], ["mask", "Mask"]]) {
const text = T(label);
this.viewButtons[name] = this.button(footer, text, `${text} / ${name}`, () => { this.view = name; this.updateButtons(); this.schedule(); });
}
const spacer = el("span", "dd-spacer");
footer.append(spacer);
this.zoomLabel = el("span", "dd-zoom", "100%");
footer.append(this.zoomLabel);
this.button(footer, "适应", "适应画布 / Fit image", () => this.fit());
this.fitButton = this.button(footer, T("Fit"), T("Fit the image to the canvas"), () => this.fit());
this.keepButton = this.button(footer, "", T("Keep"), event => { event.stopPropagation(); this.setKeepFit(!this.keepFit); });
this.keepButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-4 1-4 4 1 4-3 3-3-6-6-3 3-3 4 1 4-4zM8 16l-6 6"/></svg>';
// The inline button must not select/bring forward the ComfyUI node:
// newer frontends can remeasure its DOM widget on that bubbling click.
const fitAction = this.fitButton.onclick;
this.fitButton.onclick = event => { event.stopPropagation(); fitAction(event); };
this.root.append(footer);
const embedRow = el("label", "dd-embed");
this.embed = el("input");
this.embed.type = "checkbox";
this.embed.onchange = () => this.options.onEmbed?.(this.embed.checked);
embedRow.append(this.embed, document.createTextNode("原图嵌入工作流 · 分享时无需另传原图"));
embedRow.append(this.embed, document.createTextNode(T("Embed the source image in the workflow; no separate file needed when sharing")));
this.root.append(embedRow);
this.message = el("div", "dd-status", "Alt 临时擦除 · Ctrl+滚轮调笔径 · 滚轮缩放 / 空格拖动");
this.message = el("div", "dd-status", T("Alt = temporary erase · Ctrl+wheel = brush size · wheel = zoom · Space = pan"));
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);
this.loadButton.after(this.fitButton);
this.fitButton.after(this.keepButton);
// 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();
}
@@ -184,9 +227,9 @@ export class InlineMaskEditor {
}
async upload(file) {
if (file.size > 64 * 1024 * 1024) { this.status("图片文件不能超过 64 MB / File is too large", true); return; }
if (file.size > 64 * 1024 * 1024) { this.status(T("File is too large (max 64 MB)"), true); return; }
try {
this.status("正在载入原图… / Loading image…");
this.status(T("Loading image…"));
await this.options.onUpload?.(file);
} catch (error) { this.status(error.message, true); }
}
@@ -198,7 +241,7 @@ export class InlineMaskEditor {
const [image, loadedMask] = await Promise.all([loadImage(url), maskData ? loadImage(maskData) : Promise.resolve(null)]);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width * image.height > 32 * 1024 * 1024 || Math.max(image.width, image.height) > 16384) {
throw new Error("图像超过 3200 万像素或单边 16384px,请先缩小 / Image is too large");
throw new Error(T("Image is too large; keep it under 32 MP and 16384 px per side"));
}
this.image = image;
for (const canvas of [this.mask, this.baseMask]) { canvas.width = image.width; canvas.height = image.height; }
@@ -220,7 +263,7 @@ export class InlineMaskEditor {
if (version !== this.loadVersion || this.destroyed) return;
this.baseMask.getContext("2d").drawImage(this.mask, 0, 0);
this.sizeLabel.textContent = `${image.width} × ${image.height}`;
this.filename.textContent = name || "内嵌原图 / Embedded image";
this.filename.textContent = name || T("Embedded image");
this.filename.title = this.filename.textContent;
this.empty.hidden = true;
this.ready = true;
@@ -229,13 +272,29 @@ export class InlineMaskEditor {
this.fit();
this.updateButtons();
this.options.onImage?.(image);
this.status("橙色为绘制蒙版 · Alt 临时擦除 · Ctrl+滚轮调笔径");
this.status(T("Orange is the painted mask · Alt = temporary erase · Ctrl+wheel = brush size"));
} catch (error) {
if (version === this.loadVersion && !this.destroyed) this.status(error.message, true);
throw error;
}
}
async replaceSource(url, name = "") {
const version = ++this.loadVersion;
const image = await loadImage(url);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width !== this.mask.width || image.height !== this.mask.height) {
throw new Error(T("The result size must match the source image to preserve the mask"));
}
// Replacing only the source preserves mask pixels, the base snapshot,
// command history, redo history, active strokes and the current view.
this.flush();
this.image = image;
this.filename.textContent = name || T("Embedded image");
this.filename.title = this.filename.textContent;
this.updateButtons(); this.schedule();
}
importMask(ctx, image) {
const scratch = makeCanvas();
scratch.width = this.mask.width; scratch.height = this.mask.height;
@@ -261,6 +320,9 @@ export class InlineMaskEditor {
}
updateButtons() {
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
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);
@@ -276,6 +338,7 @@ export class InlineMaskEditor {
dimensions() {
const r = this.canvas.getBoundingClientRect();
if (this.keepFit) { this.zoom = 1; this.pan = { x: 0, y: 0 }; }
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;
@@ -293,6 +356,7 @@ export class InlineMaskEditor {
pointerDown(event) {
if (!this.ready || this.pointerId != null) return;
if (this.keepFit && (this.space || (!event.altKey && this.tool === "pan") || event.button === 1)) return;
event.preventDefault(); event.stopPropagation(); this.root.focus({ preventScroll: true });
this.altErase = !!event.altKey; this.updateButtons();
this.pointerId = event.pointerId;
@@ -304,6 +368,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();
}
@@ -354,10 +419,12 @@ 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();
}
paintSegment(ctx, command, a, b) {
if (ctx.canvas === this.mask) this.maskRevision++;
ctx.save();
ctx.globalCompositeOperation = command.type === "erase" ? "destination-out" : "source-over";
ctx.globalAlpha = command.opacity / 100;
@@ -426,6 +493,7 @@ export class InlineMaskEditor {
redo() { if (this.future.length && !this.stroke) { this.history.push(this.future.pop()); this.replay(); } }
changed() {
this.maskRevision++;
this.dirty = true;
this.flush();
this.schedule();
@@ -468,6 +536,13 @@ export class InlineMaskEditor {
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
setKeepFit(enabled, notify = true) {
this.keepFit = !!enabled;
if (this.keepFit) this.fit();
this.updateButtons(); this.schedule();
if (notify) this.options.onKeepFit?.(this.keepFit);
}
setBrushSize(value) {
this.brushSizeInput.value = Math.max(1, Math.min(512, Math.round(value)));
this.brushSizeInput.dispatchEvent(new Event("input"));
@@ -476,6 +551,7 @@ export class InlineMaskEditor {
wheel(event) {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation();
if (this.keepFit && !event.ctrlKey) { this.options.onGraphWheel?.(event); return; }
if (event.ctrlKey) {
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
if (delta) {
@@ -512,24 +588,33 @@ export class InlineMaskEditor {
render() {
const rect = this.canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const ratio = Math.min(devicePixelRatio || 1, 2);
const w = Math.round(rect.width * ratio), h = Math.round(rect.height * ratio);
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
if (!this.ready) return;
const d = this.dimensions();
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
if (this.view !== "mask") ctx.drawImage(this.image, 0, 0);
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
if (this.view !== "image") {
const overlay = this.overlay.getContext("2d");
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
overlay.drawImage(this.mask, 0, 0);
if (this.view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = this.view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
const ratio = Math.min(devicePixelRatio || 1, 2, this.ready ? 1 / d.scale : 2,
8192 / rect.width, 8192 / rect.height, Math.sqrt(8 * 1024 * 1024 / (rect.width * rect.height)));
const w = Math.max(1, Math.round(rect.width * ratio)), h = Math.max(1, Math.round(rect.height * ratio));
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
const display = this.canvas.getContext("2d");
if (!this.ready) { display.clearRect(0, 0, w, h); this.sceneKey = null; return; }
const key = [this.loadVersion, this.maskRevision, w, h, d.scale * ratio, d.x * ratio, d.y * ratio, this.view, this.overlayOpacity].join(":");
if (key !== this.sceneKey) {
if (this.scene.width !== w || this.scene.height !== h) { this.scene.width = w; this.scene.height = h; }
const ctx = this.scene.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
if (this.view !== "mask") ctx.drawImage(this.image, 0, 0);
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
if (this.view !== "image") {
const overlay = this.overlay.getContext("2d");
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
overlay.drawImage(this.mask, 0, 0);
if (this.view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = this.view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
}
this.sceneKey = key;
}
const ctx = display;
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.drawImage(this.scene, 0, 0);
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
if (this.stroke && ["rect", "lasso"].includes(this.stroke.type)) {
ctx.save(); ctx.strokeStyle = "#ffcf85"; ctx.lineWidth = 1.5 / d.scale; ctx.setLineDash([5 / d.scale, 4 / d.scale]);
@@ -539,8 +624,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)}%`;
}
@@ -549,7 +638,10 @@ export class InlineMaskEditor {
this.destroyed = true; ++this.loadVersion;
cancelAnimationFrame(this.frame); this.observer.disconnect();
this.stopWheelCapture();
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();
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.scene, this.canvas]) canvas.width = canvas.height = 0;
this.history = []; this.future = []; this.image = null; this.controls?.remove(); this.root.remove();
}
}
+35
View File
@@ -0,0 +1,35 @@
const disabled = node => node.mode === 2 || node.mode === 4;
const inputLink = (graph, input) => input?.link == null ? null
: graph.getLink?.(input.link) ?? graph.links?.[input.link];
/** Follow routing nodes without treating ordinary processing nodes as routers. */
export function branchIsEnabled(node, name) {
const graph = node.graph;
if (!graph) return false;
const seen = new Set();
function visit(source, slot = 0) {
if (!source || seen.has(source) || disabled(source)) return false;
seen.add(source);
const sourceGraph = source.graph || graph;
const follow = link => link ? visit(sourceGraph.getNodeById(link.origin_id), link.origin_slot) : false;
// KJ Get nodes have no physical inputs. Follow their actual setter so
// its ignored state is checked too; getInputLink alone skips the Set.
if (source.isVirtualNode && typeof source.findSetter === "function") {
return visit(source.findSetter(sourceGraph), slot);
}
if (source.type === "Reroute" || source.type === "SetNode") {
return follow(inputLink(sourceGraph, source.inputs?.[slot] || source.inputs?.[0]));
}
if (source.isVirtualNode && typeof source.resolveVirtualOutput === "function") {
const resolved = source.resolveVirtualOutput(slot);
if (resolved?.node) return visit(resolved.node, resolved.slot ?? 0);
}
if (source.isVirtualNode && typeof source.getInputLink === "function") {
return follow(source.getInputLink(slot));
}
return true;
}
const link = inputLink(graph, node.inputs?.find(input => input.name === name));
return link ? visit(graph.getNodeById(link.origin_id), link.origin_slot) : false;
}
+41
View File
@@ -0,0 +1,41 @@
import { app } from "../../scripts/app.js";
import { branchIsEnabled } from "./exclusive_routes.js?v=0.5.6";
app.registerExtension({
name: "AdachiWorkbench.ExclusiveSwitch",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "AdachiExclusiveSwitch") return;
const created = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = created?.apply(this, arguments);
const widget = this.widgets?.find(widget => widget.name === "active_inputs");
if (widget) {
widget.type = "hidden";
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
widget.serializeValue = executionNode => {
const flags = Number(branchIsEnabled(this, "input_a"))
| (Number(branchIsEnabled(this, "input_b")) << 1);
// Recent frontends resolve ignored virtual Get nodes before
// backend lazy execution, sometimes trying a nonexistent input.
// Drop only inactive links in the detached execution DTO;
// the editable graph and saved workflow keep all their wires.
if (executionNode !== this && executionNode?.node === this) {
for (const input of executionNode.inputs || []) {
const bit = input.name === "input_a" ? 1 : input.name === "input_b" ? 2 : 0;
if (bit && !(flags & bit) && "linkId" in input
&& !this.inputs.includes(input)) input.linkId = null;
}
}
return flags;
};
}
const internalSlot = this.inputs?.findIndex(input => input.name === "active_inputs");
if (internalSlot >= 0) this.removeInput(internalSlot);
this.setSize([230, 80]);
return result;
};
},
});
+105
View File
@@ -0,0 +1,105 @@
/* Serializable group presets; no ComfyUI or third-party extension dependency. */
export const GROUP_PRESETS_TYPE = "AdachiGroupPresets";
const ACTIVE = 0, BYPASS = 4;
const uuid = () => globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
function groupKey(group, persist = false) {
if (group.id != null && group.id !== -1) return `group:${group.id}`;
if (persist) { group.flags ??= {}; group.flags.adachi_preset_id ??= uuid(); }
return group.flags?.adachi_preset_id ? `legacy:${group.flags.adachi_preset_id}` : `unsaved:${group.title}:${Array.from(group.pos || []).join(",")}`;
}
function members(group, seen = new Set()) {
if (seen.has(group)) return [];
seen.add(group); group.recomputeInsideNodes?.();
const result = new Set();
for (const item of group._children || group._nodes || group.nodes || []) {
if (typeof item.recomputeInsideNodes === "function") {
for (const node of members(item, seen)) result.add(node);
} else if (item.mode != null && item.type !== GROUP_PRESETS_TYPE) result.add(item);
}
return [...result];
}
export class GroupPresetState {
constructor(node, change) { this.node = node; this.change = change; this.restore(); }
restore() {
const raw = this.node.properties.adachi_group_presets;
const ids = new Set();
const presets = (Array.isArray(raw?.presets) ? raw.presets : []).filter(p => p && typeof p.id === "string" && !ids.has(p.id) && ids.add(p.id)).map(p => ({
id: p.id, name: String(p.name || "Preset"),
groups: Object.fromEntries(Object.entries(p.groups || {}).filter(([, state]) => state && [ACTIVE, BYPASS].includes(state.mode)).map(([key, state]) => [key, { mode: state.mode, title: String(state.title || key) }])),
}));
this.node.properties.adachi_group_presets = { version: 1, activeId: presets.some(p => p.id === raw?.activeId) ? raw.activeId : presets[0]?.id || null, presets };
}
get data() { return this.node.properties.adachi_group_presets; }
get active() { return this.data.presets.find(p => p.id === this.data.activeId) || null; }
groups(persist = false) {
return Array.from(this.node.graph?.groups || this.node.graph?._groups || []).map(group => {
const nodes = members(group);
const modes = new Set(nodes.map(node => node.mode));
return { key: groupKey(group, persist), title: String(group.title || "Group"), color: group.color || "#64748b", group, nodes, mode: modes.size === 1 ? [...modes][0] : nodes.length ? -1 : ACTIVE,
area: Math.abs((group.size?.[0] || 1) * (group.size?.[1] || 1)) };
});
}
capture() {
return Object.fromEntries(this.groups(true).map(group => [group.key, { title: group.title, mode: group.nodes.some(node => node.mode === ACTIVE) || !group.nodes.length ? ACTIVE : BYPASS }]));
}
add(name) {
const title = String(name).trim(); if (!title) return null;
let created;
this.change(() => {
created = { id: uuid(), name: title, groups: this.capture() };
this.data.presets.push(created); this.data.activeId = created.id;
});
return created;
}
rename(id, name) {
const preset = this.data.presets.find(p => p.id === id), title = String(name).trim();
if (preset && title) this.change(() => { preset.name = title; });
}
saveCurrent() { if (this.active) this.change(() => { this.active.groups = this.capture(); }); }
remove(id) {
this.change(() => {
this.data.presets = this.data.presets.filter(p => p.id !== id);
if (this.data.activeId === id) { this.data.activeId = this.data.presets[0]?.id || null; if (this.active) this.applyStates(this.active); }
});
}
setGroup(presetId, key, mode) {
const preset = this.data.presets.find(p => p.id === presetId), group = this.groups().find(g => g.key === key);
if (!preset || !group) return;
this.change(() => {
if (mode == null) delete preset.groups[key];
else {
const stableKey = groupKey(group.group, true);
if (stableKey !== key) delete preset.groups[key];
preset.groups[stableKey] = { title: group.title, mode: mode === BYPASS ? BYPASS : ACTIVE };
}
if (this.data.activeId === presetId) this.applyStates(preset);
});
}
apply(id) {
const preset = this.data.presets.find(p => p.id === id); if (!preset) return null;
let result;
this.change(() => { this.data.activeId = id; result = this.applyStates(preset); });
return result;
}
planStates(preset, available = this.groups()) {
const groups = available.filter(g => preset.groups[g.key]).sort((a, b) => b.area - a.area || a.key.localeCompare(b.key));
const plan = new Map(), overlaps = new Set();
// Smaller (more specific) groups override enclosing/overlapping groups.
for (const group of groups) {
const mode = preset.groups[group.key].mode;
for (const node of group.nodes) {
if (plan.has(node) && plan.get(node) !== mode) overlaps.add(node);
plan.set(node, mode);
}
}
return { plan, stats: { groups: groups.length, nodes: plan.size, overlaps: overlaps.size, missing: Object.keys(preset.groups).length - groups.length } };
}
applyStates(preset) {
const { plan, stats } = this.planStates(preset);
for (const [node, mode] of plan) node.mode = mode;
return stats;
}
}
+140
View File
@@ -0,0 +1,140 @@
import { app } from "../../scripts/app.js";
import { T } from "./i18n.js";
import { GROUP_PRESETS_TYPE, GroupPresetState } from "./group_preset_state.js";
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; };
function ensureStyle() {
if (document.getElementById("adachi-inpaint-style")) return;
const link = el("link"); link.id = "adachi-inpaint-style"; link.rel = "stylesheet";
link.href = new URL("./editor.css", import.meta.url).href; document.head.append(link);
}
class GroupPresetsPanel {
constructor(node) {
this.node = node; this.root = el("div", "dd-inpaint dd-group-presets");
this.model = new GroupPresetState(node, operation => {
app.canvas?.emitBeforeChange?.();
try { operation(); node.graph?.setDirtyCanvas(true, true); }
finally { app.canvas?.emitAfterChange?.(); }
this.refresh(true);
});
const toolbar = el("div", "dd-gp-toolbar"); toolbar.append(el("strong", "", T("Group presets")));
this.add = this.button(toolbar, T("+ New preset"), () => this.showForm("add"));
this.form = el("form", "dd-gp-form"); this.form.hidden = true;
this.nameInput = el("input"); this.nameInput.type = "text"; this.nameInput.maxLength = 120;
this.nameInput.setAttribute("aria-label", T("Preset name"));
this.form.append(this.nameInput);
const save = this.button(this.form, T("Save"), () => this.submitName()); save.classList.add("dd-primary");
this.button(this.form, T("Cancel"), () => { this.form.hidden = true; });
this.form.onsubmit = event => { event.preventDefault(); this.submitName(); };
this.nameInput.onkeydown = event => { event.stopPropagation(); if (event.key === "Escape") { event.preventDefault(); this.form.hidden = true; } };
const body = el("div", "dd-gp-body");
this.presets = el("nav", "dd-gp-presets dd-gp-scroll"); this.presets.setAttribute("aria-label", T("Presets"));
const detail = el("section", "dd-gp-detail");
const head = el("div", "dd-gp-detail-head"); this.title = el("strong"); head.append(this.title);
this.rename = this.button(head, T("Rename"), () => this.showForm("rename"));
this.groups = el("div", "dd-gp-groups dd-gp-scroll"); this.groups.setAttribute("aria-label", T("Group states"));
const footer = el("div", "dd-gp-footer");
this.capture = this.button(footer, T("Save current group states"), () => this.model.saveCurrent());
this.remove = this.button(footer, T("Delete preset"), () => { if (this.model.active) this.model.remove(this.model.active.id); });
detail.append(head, this.groups, footer); body.append(this.presets, detail);
this.status = el("div", "dd-gp-status"); this.status.setAttribute("role", "status");
this.root.append(toolbar, this.form, body, this.status);
this.onWheel = event => {
if (!this.root.contains(event.target)) return;
const scroll = event.target.closest(".dd-gp-scroll");
if (scroll && ((event.deltaY < 0 && scroll.scrollTop > 0) || (event.deltaY > 0 && scroll.scrollTop + scroll.clientHeight < scroll.scrollHeight - 1))) {
event.stopPropagation(); return;
}
event.preventDefault(); event.stopPropagation(); app.canvas?.processMouseWheel(event);
};
window.addEventListener("wheel", this.onWheel, { capture: true, passive: false });
this.timer = setInterval(() => { if (this.root.isConnected && !document.hidden) this.refresh(); }, 500);
this.refresh(true);
}
button(parent, label, callback) { const button = el("button", "", label); button.type = "button"; button.onclick = event => { event.stopPropagation(); callback(); }; parent.append(button); return button; }
showForm(mode) {
this.formMode = mode; this.form.hidden = false;
this.nameInput.value = mode === "rename" ? this.model.active?.name || "" : `${T("Preset")} ${this.model.data.presets.length + 1}`;
this.nameInput.focus({ preventScroll: true }); this.nameInput.select();
}
submitName() {
const name = this.nameInput.value.trim(); if (!name) { this.nameInput.focus(); return; }
if (this.formMode === "rename" && this.model.active) this.model.rename(this.model.active.id, name);
else this.model.add(name);
this.form.hidden = true;
}
apply(id) {
this.model.apply(id);
}
refresh(force = false) {
const active = this.model.active, groups = this.model.groups();
const signature = JSON.stringify([this.model.data, groups.map(g => [g.key, g.title, g.color, g.nodes.map(n => [n.id, n.mode])]), T("Group presets")]);
if (!force && signature === this.signature) return;
this.signature = signature;
const navScroll = this.presets.scrollTop, groupScroll = this.groups.scrollTop;
this.presets.replaceChildren(); this.groups.replaceChildren();
for (const preset of this.model.data.presets) {
const button = this.button(this.presets, preset.name, () => this.apply(preset.id));
button.dataset.presetId = preset.id; button.title = preset.name;
button.classList.toggle("dd-active", preset.id === active?.id);
button.setAttribute("aria-pressed", String(preset.id === active?.id));
}
if (!this.model.data.presets.length) this.presets.append(el("div", "dd-gp-empty", T("Add a preset to get started")));
this.title.textContent = active?.name || T("Group states"); this.title.title = this.title.textContent;
const expected = active ? this.model.planStates(active, groups) : { plan: new Map(), stats: {} };
for (const group of groups) {
const row = el("label", "dd-gp-group"); row.dataset.groupKey = group.key;
const dot = el("span", "dd-gp-color"); dot.style.background = group.color;
const label = el("span", "dd-gp-group-name", group.title); label.title = `${group.title} · ${group.key}`;
const select = el("select"); select.setAttribute("aria-label", group.title); select.disabled = !active;
for (const [value, name] of [["", "Keep current"], ["0", "Enabled"], ["4", "Bypassed"]]) {
const option = el("option", "", T(name)); option.value = value; select.append(option);
}
const saved = active?.groups[group.key]; select.value = saved ? String(saved.mode) : "";
if (saved && group.nodes.some(n => n.mode !== expected.plan.get(n))) { row.classList.add("dd-gp-changed"); label.title += ` · ${T("Current group state differs from the preset")}`; }
select.onchange = () => { if (active) this.model.setGroup(active.id, group.key, select.value === "" ? null : Number(select.value)); };
row.append(dot, label, select); this.groups.append(row);
}
if (!groups.length) this.groups.append(el("div", "dd-gp-empty", T("No groups in this workflow yet")));
for (const button of [this.rename, this.capture, this.remove]) button.disabled = !active;
const missing = active ? Object.keys(active.groups).filter(key => !groups.some(g => g.key === key)).length : 0;
this.status.textContent = `${this.model.data.presets.length} ${T("presets")} · ${groups.length} ${T("groups")}${missing ? ` · ${missing} ${T("missing groups")}` : ""}${expected.stats.overlaps ? ` · ${T("Overlapping groups use the smaller group's setting")}` : ""}`;
this.presets.scrollTop = navScroll; this.groups.scrollTop = groupScroll;
}
destroy() { clearInterval(this.timer); window.removeEventListener("wheel", this.onWheel, true); this.root.remove(); }
}
app.registerExtension({
name: "Adachi.GroupPresets",
registerCustomNodes() {
ensureStyle();
class GroupPresetsNode extends LGraphNode {
constructor() {
super(T("Group Bypass Presets"));
this.isVirtualNode = true; this.comfyClass = GROUP_PRESETS_TYPE;
this.serialize_widgets = false;
this.properties ??= {};
this.adachiGroupPresets = new GroupPresetsPanel(this);
const widget = this.addDOMWidget("group_presets", "adachi_group_presets", this.adachiGroupPresets.root, {
serialize: false, hideOnZoom: false, getMinHeight: () => 290, getMaxHeight: () => 1600,
});
widget.serialize = false; this.setSize([620, 410]);
}
onAdded() { this.adachiGroupPresets.refresh(true); }
onConfigure() { this.adachiGroupPresets.model.restore(); this.adachiGroupPresets.refresh(true); }
onRemoved() { this.adachiGroupPresets.destroy(); }
getExtraMenuOptions(_, options) {
const panel = this.adachiGroupPresets;
options.push(null, { content: T("Apply preset"), has_submenu: true, disabled: !panel.model.data.presets.length,
submenu: { options: panel.model.data.presets.map(p => ({ content: `${p.id === panel.model.data.activeId ? "✓ " : ""}${p.name}`, callback: () => panel.apply(p.id) })) } },
{ content: T("+ New preset"), callback: () => panel.showForm("add") },
{ content: T("Save current group states"), disabled: !panel.model.active, callback: () => panel.model.saveCurrent() });
}
}
GroupPresetsNode.title = T("Group Bypass Presets");
GroupPresetsNode.desc = T("Switch saved group enable/bypass presets; no connections required.");
LiteGraph.registerNodeType(GROUP_PRESETS_TYPE, GroupPresetsNode);
GroupPresetsNode.category = "Adachi-Inpaint-Workbench";
},
});
+192
View File
@@ -0,0 +1,192 @@
// Locale-aware labels for this plugin's own canvas UI.
//
// The English string in the call site is the default and the dictionary key, so any
// missing translation falls back to English. ComfyUI's locales/ files only cover node
// definitions (display name, category, sockets, tooltips); the custom panel and canvas
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
const ZH = {
"Group Bypass Presets": "组忽略预设 / Group Bypass Presets",
"Switch saved group enable/bypass presets; no connections required.": "切换已保存的分组启用/忽略预设,无需接线。",
"Group presets": "分组预设",
"+ New preset": "+ 添加预设",
"Preset name": "预设名称",
"Save": "保存",
"Cancel": "取消",
"Presets": "预设列表",
"Group states": "分组状态",
"Rename": "改名",
"Save current group states": "记住当前分组状态",
"Delete preset": "删除预设",
"Preset": "预设",
"Apply preset": "应用预设",
"Add a preset to get started": "添加第一套预设",
"No groups in this workflow yet": "当前工作流还没有分组",
"Keep current": "保持当前",
"Enabled": "打开",
"Bypassed": "忽略",
"Current group state differs from the preset": "当前分组状态与预设不同",
"Overlapping groups use the smaller group's setting": "重叠分组以较小分组的设置为准",
"presets": "套预设",
"groups": "个分组",
"missing groups": "个分组已不存在",
// compare.js
"Result": "结果",
"Choose result": "选择重绘结果",
"Connect an original image and a result, then run the workflow": "连接原图与重绘结果,然后运行工作流",
"Adopt into": "回填到",
"Target inline editor": "接收结果的内联蒙版",
"Adopt this result": "采用此结果",
"Move to compare · wheel to zoom · middle-drag to pan": "鼠标移动对比 · 滚轮缩放 · 中键拖动",
"Select an inline mask": "选择内联蒙版",
"Adopting…": "采用中…",
"Setting the result as the new source image…": "正在将结果设为新原图…",
"Adopted: back in the inline mask, keep painting to inpaint again": "已采用:回到内联蒙版,可继续涂抹重绘",
"Inline Mask": "内联蒙版",
"No inline mask node is available to receive the result": "找不到接收结果的内联蒙版节点",
"Result preview expired; run the workflow again": "结果预览已过期,请重新运行工作流",
"Result image exceeds 64 MiB; downscale it first": "结果图片超过 64 MiB,请先缩小",
// editor.js
"Cannot load image": "无法读取图像",
"INLINE MASK": "内联蒙版 · INLINE MASK",
"Load image": "载入原图",
"Upload an image, or drop / paste one": "上传图片,也可拖放或粘贴",
"Drop an image onto the canvas": "拖放图片到画布",
"Brush": "画笔",
"Brush (B)": "画笔 (B)",
"Eraser": "橡皮",
"Eraser (E); hold Alt or right-click for temporary erase": "橡皮 (E),按住 Alt 或鼠标右键临时擦除",
"Rectangle": "矩形",
"Rectangle (R)": "矩形 (R)",
"Lasso": "套索",
"Freehand lasso (L)": "自由套索 (L)",
"Pan": "移动",
"Pan (H), or hold Space": "移动 (H),或按住空格",
"Brush size": "笔径",
"Hardness": "硬度",
"Brush opacity": "笔触",
"Overlay": "叠加",
"Drop an image here": "把原图拖到这里",
"Paint the areas to repaint directly in this node": "直接在节点中涂抹需要重绘的区域",
"Undo": "撤销",
"Undo (Ctrl+Z)": "撤销 (Ctrl+Z)",
"Redo": "重做",
"Redo (Ctrl+Shift+Z)": "重做 (Ctrl+Shift+Z)",
"Invert": "反转",
"Invert the painted mask": "反转绘制的蒙版",
"Fill": "全选",
"Fill the whole image with mask": "重绘整张图",
"Clear": "清空",
"Clear the mask": "清空蒙版",
"Import mask": "导入蒙版",
"White repaints, black preserves": "白色重绘,黑色保护",
"Export": "导出",
"Export the mask PNG at full resolution": "导出原始分辨率黑白蒙版",
"Image": "原图",
"Mask": "蒙版",
"Fit": "适应",
"Keep": "保持",
"Upload": "上传",
"Fit the image to the canvas": "适应画布",
"File is too large (max 64 MB)": "图片文件不能超过 64 MB",
"Loading image…": "正在载入原图…",
"Embedded image": "内嵌原图",
"Orange is the painted mask · Alt = temporary erase · Ctrl+wheel = brush size": "橙色为绘制蒙版 · Alt 临时擦除 · Ctrl+滚轮调笔径",
"Image is too large; keep it under 32 MP and 16384 px per side": "图像超过 3200 万像素或单边 16384px,请先缩小",
// inline_inpaint.js
"The inline mask node was removed": "内联蒙版节点已移除",
"Adopted the result as the new source image; the mask and undo history were preserved": "已采用重绘结果作为新原图,蒙版与撤销记录已保留",
"The result size must match the source image to preserve the mask": "为保留蒙版,重绘结果尺寸必须与原图一致",
"Wait for the source image to finish loading before adopting a result": "请等待原图加载完成后再采用结果",
"Drag to resize canvas height": "上下拖动调整画布高度",
"Resize canvas height": "调整画布高度",
"Upload failed": "上传失败",
// inpaint_node.js
"Connect an inline mask to show the painting tools": "连接内联蒙版后显示画笔工具",
"The painting tools live in the other inpaint node connected to this image": "画笔工具位于连接此原图的另一个重绘节点",
// inpaint_panel.js
"WebUI Inpaint": "WebUI 局部重绘",
"Controlled WebUI inpaint node": "控制的局部重绘节点",
"Connect a WebUI inpaint node to edit its parameters here": "连接 WebUI 局部重绘节点后,在这里调整参数",
"Mask mode": "重绘区域",
"Fill content": "重绘参考内容",
"Sampling area": "重绘画布",
"Sampling resolution": "采样分辨率",
"Use source size": "使用原图宽高",
"Fill width and height from the current source image; aligned to 8 px and clamped to 64-8192 px": "填写当前原图宽高;按 8 的倍数对齐,并限制在 64–8192 像素",
"Width": "宽度",
"Height": "高度",
"Denoise": "重绘幅度",
"Sampler": "采样器",
"Scheduler": "调度器",
"Steps": "采样步数",
"CFG scale": "提示词引导 CFG",
"Seed": "随机种子",
"After generate": "生成后种子",
"Mask, batch and advanced settings": "蒙版、批量与高级设置",
"Context padding": "蒙版外扩边距",
"Mask blur": "蒙版模糊",
"Mask grow / shrink": "蒙版扩张 / 收缩",
"Batch size": "每张生成数量",
"Resize mode": "缩放方式",
"Use dedicated inpaint model": "使用专用 Inpaint 模型",
"Extra edge feathering": "额外羽化边缘",
"Feather strength": "额外羽化强度",
"Refiner switch ratio": "Refiner 切换比例",
"Preview unavailable; run the workflow again": "预览已过期或无法加载,请重新运行",
"Result left, original right; hover to compare": "左重绘结果、右原图,移动鼠标竖线对比",
"Inline mask painting canvas; orange areas are repainted": "直接涂抹蒙版;橙色区域将被重绘",
"Embed the source image in the workflow; no separate file needed when sharing": "原图嵌入工作流 · 分享时无需另传原图",
"Alt = temporary erase · Ctrl+wheel = brush size · wheel = zoom · Space = pan": "Alt 临时擦除 · Ctrl+滚轮调笔径 · 滚轮缩放 / 空格拖动",
// enum values
"enum:Inside mask": "蒙版内",
"enum:Outside mask": "蒙版外",
"enum:Fill": "填充",
"enum:Original pixels": "原图像素",
"enum:Latent noise": "潜空间噪声",
"enum:Empty latent": "空白潜空间",
"enum:Whole image": "整图重绘",
"enum:Masked area only": "仅蒙版区域",
"enum:Stretch": "拉伸",
"enum:Center crop": "居中裁剪",
"enum:Aspect fill": "保比例填充",
"enum:Fixed": "固定种子",
"enum:Increment": "生成后递增",
"enum:Decrement": "生成后递减",
"enum:Randomize": "生成后随机",
};
const DICTS = { zh: ZH };
function readLocale() {
try {
const api = globalThis.comfyAPI;
const value = api?.app?.ui?.settings?.getSettingValue?.("Comfy.Locale");
if (value) return String(value);
} catch (_) { /* settings unavailable before the app boots */ }
try {
const value = globalThis.app?.ui?.settings?.getSettingValue?.("Comfy.Locale");
if (value) return String(value);
} catch (_) { /* ignore */ }
return "en";
}
export function localeDict() {
const base = readLocale().split("-")[0].toLowerCase();
return DICTS[base] || null;
}
// Translate a user-facing string. `context` disambiguates identical English strings
// that need different translations (for example the Fill button and the Fill enum).
export function T(text, context) {
const dict = localeDict();
if (!dict) return text;
const key = context ? `${context}:${text}` : text;
return dict[key] ?? text;
}
+49 -62
View File
@@ -1,17 +1,16 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js";
import { InpaintParameterPanel } from "./inpaint_panel.js";
import { connectedSamplers, relocateWidgets } from "./parameter_bridge.js";
import { InlineMaskEditor } from "./editor.js?v=0.5.10";
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.5.5";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
const styleId = "adachi-inpaint-style";
if (!document.getElementById(styleId)) {
const link = document.createElement("link");
link.id = styleId; link.rel = "stylesheet";
link.href = new URL("./editor.css", import.meta.url).href;
document.head.append(link);
}
const stylesheet = document.getElementById(styleId) || document.createElement("link");
stylesheet.id = styleId; stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./editor.css?v=0.5.5", import.meta.url).href;
if (!stylesheet.isConnected) document.head.append(stylesheet);
function hide(widget) {
if (!widget) return;
@@ -40,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 () {
@@ -59,39 +59,56 @@ app.registerExtension({
let restoring = false;
let restoreFrame = 0;
let uploadVersion = 0;
let panelBaseHeight = null;
let fitFrame = 0;
const fitNodeToImage = () => {
cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
if (!editor.ready || editor.destroyed || !node.graph) return;
const width = editor.canvas.clientWidth, height = editor.canvas.clientHeight;
if (!width || !height) return;
const overhead = node.size[1] - height;
const fittedHeight = overhead + width * editor.image.height / editor.image.width;
node.setSize([node.size[0], Math.max(240, Math.min(4200, Math.ceil(fittedHeight)))]);
node.properties.adachi_compact_ui = true;
editor.schedule();
});
};
const restore = async () => {
const embedded = sourceWidget?.value;
const filename = imageWidget?.value;
if (!embedded && !filename) return;
restoring = true;
editor.embed.checked = !!embedded;
try { await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? "内嵌原图 / Embedded image" : filename); }
try { await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); }
catch (error) { console.warn("[Adachi Inpaint]", error); }
finally { restoring = false; }
};
const editor = new InlineMaskEditor({
compact: true,
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
onDirty() { markChanged(); },
onImage() { node.adachiParameters?.refresh(); },
onKeepFit(enabled) { node.properties.adachi_keep_fit = enabled; markChanged(); },
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
onGraphWheel: event => app.canvas?.processMouseWheel(event),
async onUpload(file) {
async onUpload(file, { preserveMask = false } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the source image to finish loading before adopting a result"));
const version = ++uploadVersion;
const body = new FormData();
body.append("image", file, file.name);
body.append("type", "input");
body.append("subfolder", "adachi-inpaint");
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`上传失败 / Upload failed (${response.status})`);
if (!response.ok) throw new Error(`${T("Upload failed")} (${response.status})`);
const uploaded = await response.json();
if (version !== uploadVersion || editor.destroyed) return;
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
imageWidget.value = name;
maskWidget.value = "";
sourceWidget.value = "";
restoring = false;
await editor.setImage(imageUrl(name), "", file.name);
if (editor.embed.checked) sourceWidget.value = editor.sourceData();
if (preserveMask && editor.ready) await editor.replaceSource(imageUrl(name), file.name);
else await editor.setImage(imageUrl(name), "", file.name);
if (version !== uploadVersion || editor.destroyed) return;
imageWidget.value = name;
maskWidget.value = preserveMask ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
markChanged();
},
onEmbed(enabled) {
@@ -99,62 +116,37 @@ app.registerExtension({
markChanged();
},
});
editor.root.dataset.adachiVersion = "0.5.10";
node.adachiEditor = editor;
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
const panel = new InpaintParameterPanel({
getTargets: () => connectedSamplers(node),
getPreferred: () => node.properties.adachi_settings_target,
onPreferred: id => { node.properties.adachi_settings_target = id; markChanged(); },
getImage: () => editor.ready ? editor.image : null,
onTargetChange: (previous, next) => {
if (next && !node.properties.adachi_inline_parameters) panelBaseHeight = node.size[1];
relocateWidgets(node, previous, next);
},
onDirty: markChanged,
onHeight: () => {
const parameters = node.adachiParameters;
const panelHeight = parameters?.root.scrollHeight || 40;
if (node.graph) {
// Add the panel once when migrating an older workflow.
// Reopening an already-migrated workflow must keep its saved size.
const migrate = parameters?.target && panelHeight > 100 && !node.properties.adachi_inline_parameters;
const migratedHeight = migrate ? (panelBaseHeight ?? node.size[1]) + Math.max(0, panelHeight - 40) : node.size[1];
if (migrate) node.properties.adachi_inline_parameters = true;
const height = Math.max(620 + panelHeight, migratedHeight);
if (Math.abs(node.size[1] - height) > 1) node.setSize([node.size[0], height]);
}
editor.schedule();
},
});
node.adachiParameters = panel;
editor.root.append(panel.root);
node.adachiUseResult = async file => {
if (editor.destroyed) throw new Error("内联蒙版节点已移除");
await editor.options.onUpload(file);
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
await editor.options.onUpload(file, { preserveMask: true });
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status("已采用重绘结果作为新原图,旧蒙版已清空");
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
};
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 520 + (panel.root.scrollHeight || 40),
getMinHeight: () => 170,
getMaxHeight: () => 4096,
});
domWidget.serialize = false;
bindWidgetToNodeWidth(node, domWidget, editor.root);
// Leave computeLayoutSize in charge: a fixed computeSize height
// prevents the DOM canvas from growing with the node vertically.
const resizeHandle = document.createElement("div");
resizeHandle.className = "dd-resize-handle";
resizeHandle.textContent = "↕";
resizeHandle.title = "上下拖动调整画布高度 / Drag to resize canvas height";
resizeHandle.title = T("Drag to resize canvas height");
resizeHandle.tabIndex = 0;
resizeHandle.setAttribute("role", "separator");
resizeHandle.setAttribute("aria-orientation", "horizontal");
resizeHandle.setAttribute("aria-label", "调整画布高度 / Resize canvas height");
resizeHandle.setAttribute("aria-label", T("Resize canvas height"));
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();
};
@@ -201,20 +193,15 @@ app.registerExtension({
const previousConfigure = node.onConfigure;
node.onConfigure = function () {
const configured = previousConfigure?.apply(this, arguments);
cancelAnimationFrame(restoreFrame); restoreFrame = requestAnimationFrame(() => { restore(); panel.refresh(); });
editor.setKeepFit(!!node.properties.adachi_keep_fit, false);
cancelAnimationFrame(restoreFrame); restoreFrame = requestAnimationFrame(restore);
return configured;
};
const previousConnections = node.onConnectionsChange;
node.onConnectionsChange = function () {
const result = previousConnections?.apply(this, arguments);
queueMicrotask(() => { if (!editor.destroyed) panel.refresh(); });
return result;
};
const previousImageCallback = imageWidget.callback;
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
const previousRemoved = node.onRemoved;
node.onRemoved = function () { cancelAnimationFrame(restoreFrame); panel.destroy(); stopWheelRouting(); 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;
};
+78
View File
@@ -0,0 +1,78 @@
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";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
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 = T("Connect an inline mask to show the painting tools");
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 ? T("The painting tools live in the other inpaint node connected to this image") : T("Connect an inline mask to show the painting tools");
};
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;
bindWidgetToNodeWidth(node, widget, root);
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;
};
}
+37 -33
View File
@@ -1,22 +1,23 @@
/* Inline controls edit the connected sampler's real widgets, not a second set of parameters. */
import { T } from "./i18n.js";
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: "生成后随机" },
mask_mode: { inside: "Inside mask", outside: "Outside mask" },
fill: { fill: "Fill", original: "Original pixels", latent_noise: "Latent noise", latent_nothing: "Empty latent" },
area: { whole_image: "Whole image", only_masked: "Masked area only" },
resize_mode: { stretch: "Stretch", crop: "Center crop", fill: "Aspect fill" },
control_after_generate: { fixed: "Fixed", increment: "Increment", decrement: "Decrement", randomize: "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", "控制的局部重绘节点");
const header = el("div", "dd-param-header"); header.append(el("strong", "", T("WebUI Inpaint")));
this.targetSelect = el("select"); this.targetSelect.setAttribute("aria-label", T("Controlled WebUI inpaint node"));
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.placeholder = el("div", "dd-param-placeholder", T("Connect a WebUI inpaint node to edit its parameters here"));
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);
@@ -30,7 +31,7 @@ export class InpaintParameterPanel {
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); }
for (const n of targets) { const option = el("option", "", `${n.title || T("WebUI Inpaint")} #${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);
@@ -56,20 +57,22 @@ export class InpaintParameterPanel {
enum(parent, name, label, pills = false) {
const widget = this.widget(name); if (!widget) return;
label = T(label);
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];
const display = value => choices[name]?.[value] ? T(choices[name][value], "enum") : String(value);
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";
const button = el("button", "", display(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); }
for (const value of values) { const option = el("option", "", display(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); } });
}
@@ -78,6 +81,7 @@ export class InpaintParameterPanel {
number(parent, name, label, { min, max, step = 1, range = false, rangeMax = max } = {}) {
const widget = this.widget(name); if (!widget) return;
label = T(label);
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);
@@ -90,7 +94,7 @@ export class InpaintParameterPanel {
};
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 = el("input"); slider.type = "range"; slider.min = min; slider.max = rangeMax ?? max; slider.step = step; slider.setAttribute("aria-label", `${label} slider`);
slider.oninput = () => { update(slider.value); input.value = widget.value; }; slider.onchange = () => this.options.onDirty?.(); wrapper.append(slider);
}
this.bindings.push({ sync: () => {
@@ -103,19 +107,19 @@ export class InpaintParameterPanel {
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);
input.onchange = () => this.write(name, input.checked, true); wrapper.append(input, document.createTextNode(T(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.enum(this.body, "mask_mode", "Mask mode", true);
this.enum(this.body, "fill", "Fill content", true);
this.enum(this.body, "area", "Sampling area", true);
const resolution = el("div", "dd-param-resolution"); resolution.append(el("span", "dd-param-label", T("Sampling resolution")));
this.sourceSize = el("button", "", T("Use source size")); this.sourceSize.type = "button";
this.sourceSize.title = T("Fill width and height from the current source image; aligned to 8 px and clamped to 64-8192 px");
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));
@@ -123,23 +127,23 @@ export class InpaintParameterPanel {
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", "调度器");
this.number(this.body, "width", "Width", { min: 64, max: 8192, step: 8, range: true });
this.number(this.body, "height", "Height", { min: 64, max: 8192, step: 8, range: true });
this.number(this.body, "denoise", "Denoise", { min: 0, max: 1, step: .01, range: true });
const sampling = this.grid(this.body); this.enum(sampling, "sampler_name", "Sampler"); this.enum(sampling, "scheduler", "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);
this.number(scale, "steps", "Steps", { min: 1, max: 10000, range: true, rangeMax: 150 });
this.number(scale, "cfg", "CFG scale", { min: 0, max: 100, step: .1, range: true, rangeMax: 30 });
const seed = this.grid(this.body); this.number(seed, "seed", "Seed", { min: 0, max: Number.MAX_SAFE_INTEGER }); this.enum(seed, "control_after_generate", "After generate");
const advanced = el("details", "dd-param-advanced"), summary = el("summary", "", T("Mask, batch and advanced settings")); advanced.append(summary);
const content = el("div", "dd-param-advanced-body"); advanced.append(content);
this.number(content, "padding", "蒙版外扩边距", { min: 0, max: 2048 });
this.number(content, "padding", "Context 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);
this.number(masks, "mask_blur", "Mask blur", { min: 0, max: 128, step: .5 }); this.number(masks, "mask_grow", "Mask grow / shrink", { min: -128, max: 128 });
const batch = this.grid(content); this.number(batch, "batch_size", "Batch size", { min: 1, max: 16 }); this.enum(batch, "resize_mode", "Resize mode");
this.toggle(content, "inpaint_model", "Use dedicated inpaint model"); this.toggle(content, "soft_edge", "Extra edge feathering");
this.number(content, "soft_strength", "Feather strength", { min: 0, max: 1, step: .05, range: true });
this.number(content, "refiner_switch", "Refiner switch ratio", { 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(); }
+9 -6
View File
@@ -12,9 +12,8 @@ function setHiddenOption(widget, value) {
if (value === undefined) delete widget.options.hidden; else widget.options.hidden = value;
}
export function connectedSamplers(source) {
const graph = source.graph; if (!graph) return [];
const originatesFromSource = target => {
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) {
@@ -27,9 +26,13 @@ export function connectedSamplers(source) {
const next = graph.links?.[input.link]; if (next) queue.push(next.origin_id);
}
}
return sources.size === 1 && sources.has(source.id);
};
return (graph._nodes || []).filter(node => node.type === "AdachiWebUIInpaint" && originatesFromSource(node));
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) {
+16
View File
@@ -0,0 +1,16 @@
/** Fill the node in graph coordinates, independently of cached DOM measurements. */
export function bindWidgetToNodeWidth(node, widget, element) {
// Classic ComfyUI uses widget.width before node.width for its DOM host.
// A cached screen-space width is scaled a second time when zoomed out.
// These single-column widgets always occupy the node's available width;
// the frontend still applies its own widget margins and vertical layout.
Object.defineProperty(widget, "width", {
configurable: true,
enumerable: true,
get: () => Math.max(1, Number(node.size[0]) || 1),
set: () => {},
});
element.style.width = "100%";
element.style.maxWidth = "100%";
element.style.minWidth = "0";
}