Compare commits

..
10 Commits
Author SHA1 Message Date
Adachi 0935b37b92 Prepare stable 1.0.0 release 2026-10-28 11:39:24 +08:00
Adachi f6e42474c0 Keep comparison previews in memory and show upload progress 2026-10-28 11:17:31 +08:00
Adachi 87fadd7197 Refresh replacement uploads and sync inpaint dimensions 2026-10-28 10:54:59 +08:00
Adachi 0b89b063d6 Expire comparison previews and download latest result 2026-10-28 10:44:33 +08:00
adachi 3030531275 Remove the ✓ merge step; right-click reselects directly
Adoption returns to the repaint mask (option A) and layer blending stays opt-in via ◐. Right-click/Escape now flattens any remaining layer and releases the region in one action, so the separate ✓ commit button is gone.
2026-10-17 14:44:16 +08:00
adachi f76b5aa0fd Fix the inline status hint staying visible
The earlier status() rework let non-error hints stay visible, so "Orange is the
painted mask" lingered on the canvas. Restore the simple rule that only errors
stay visible, keep an explicit notice() for the one case that must persist, and
bump the editor asset version so browsers fetch the corrected module.
2026-10-17 14:27:42 +08:00
adachi 175a063195 Restructure crop adoption and speed up change detection
Crop & Blend adoption now bakes the layer into the source at full quality and
returns to the repaint mask immediately, so the user can generate again right
away. Layer blending is entered explicitly through the blend button instead of
auto-entering after adoption.

The inline mask change fingerprint now uses the source file's size and mtime
instead of hashing its full high-resolution bytes, removing a full-file read on
every change/execution.
2026-10-17 14:08:54 +08:00
adachi da4a26c982 Reuse the decoded result and report silent adoption skips
Adopting a crop result previously downloaded the full-size image a second time
inside the crop layer. Pass the already-decoded image through, cutting one
decode round trip.

Adoption could also return without any feedback when the preview was still
loading, no target was selected, or the crop/layer state changed. Those cases now
show a visible message instead of failing silently.
2026-10-17 13:57:03 +08:00
adachi a5bd9624b8 Preserve the mask when uploading a new source image
Uploading previously reset the mask, history and viewport. It now goes through the
same replaceSource path that adopting a result uses, so only the source pixels
change and the painted mask, undo/redo history and current view survive.

If the new image has a different size the mask cannot be kept, so the upload falls
back to a fresh load and shows a notice instead of failing.
2026-10-17 13:34:00 +08:00
adachi 0f178a55f6 Add a download button and make the pin freeze the current view
The inline mask gains a download button beside upload that saves the source image
as a PNG at its own resolution.

The pin toggle previously forced the image back to a fitted view on every
measurement, so it could not hold a deliberately panned or zoomed position. It now
freezes whatever position is currently drawn, keeps panning and zooming available,
and still routes the plain wheel to the workflow canvas.
2026-10-17 13:30:26 +08:00
29 changed files with 704 additions and 101 deletions
+3
View File
@@ -37,6 +37,9 @@ jobs:
- name: Check comparison result history
run: node --experimental-default-type=module tests/test_compare_history.mjs
- name: Check upload transport
run: node --experimental-default-type=module tests/test_upload_transport.mjs
- name: Check crop geometry
run: |
node --experimental-default-type=module tests/test_crop_geometry.mjs
+43
View File
@@ -1,5 +1,48 @@
# Changelog
## 1.0.0
- First stable release of the inline inpainting workspace, comparison, crop blending and workflow switching tools.
- Includes in-memory comparisons and source upload progress from 0.9.0. Update the bilingual crop instructions and example to match the current editing controls.
## 0.9.0
- Keep new comparison originals and results in a bounded in-memory cache instead of creating preview files. Existing disk-backed preview references remain supported; adopted sources and layers remain persistent input files. The cache defaults to 512 MiB, adjustable with `ADACHI_COMPARE_MEMORY_MIB` (64–4096 MiB); restart or eviction expires old previews.
- Show actual browser upload progress for source images, followed by a loading indication. Authentication fallback uses an indeterminate indicator rather than inventing a percentage.
## 0.8.2
- Actually display a newly uploaded image when its dimensions differ; the incompatible old mask is reset. Same-size replacement continues to preserve the mask.
- Automatically fill connected inpaint dimensions from an uploaded image in Mask mode and from a locked crop in Crop & Blend mode. Existing saved workflow dimensions and linked parameter inputs are respected.
## 0.8.1
- Expire only this plugin's comparison preview PNGs after 24 hours, checking hourly even in a long-running ComfyUI. Adopted input images and unrelated temporary files are preserved. Cached comparisons refresh before the expiry deadline.
- Add a compact download button for the latest generated image, including the last image in a batch, independently of the history selection.
## 0.8.0
- Restructure adoption in Crop & Blend: after "Adopt this result" the layer is baked into the source at full quality and the editor returns to the repaint mask immediately, so you can generate again right away. The ◐ button now enters layer blending explicitly instead of auto-entering after adoption.
- Remove the ✓ merge step entirely: right-click (or Escape) now releases the selected region and flattens any remaining layer in one action, so there is no intermediate commit button between adopting a result and picking the next region.
- Speed up inline mask change detection: the source image is fingerprinted by size and mtime instead of hashing its full high-resolution bytes on every change/execution.
## 0.7.2
- Adopting a result no longer decodes the full-size image a second time: the already-decoded image is reused for the crop layer, cutting one download/decode round trip.
- Show a visible message when adoption is skipped because the preview is still loading, no target is selected, or the crop/layer state changed, instead of failing silently.
- Bump the asset versions for the crop, comparison and result-adoption modules.
## 0.7.1
- Uploading a new source image now keeps the painted mask, undo/redo history and viewport, the same way adopting a result does; only the source pixels change. A first upload still starts with an empty mask.
- When the new image has a different size the mask cannot be kept, so the image loads fresh and a notice explains that the mask was reset instead of failing the upload.
## 0.7.0
- Add a download button next to the inline upload button: it saves the current source image as a PNG at its own resolution. It stays disabled until an image is loaded.
- Change the pin toggle so it freezes the view exactly as it is drawn instead of re-fitting the image: panning and zooming stay available while pinned, and the pinned view survives canvas resizes.
- Keep routing the plain wheel to the workflow canvas while pinned, as before.
## 0.6.9
- Place the Clear button directly after the swap width/height control in Crop & Blend; in Mask mode it is the only visible button of that bar and stays at the bottom left of the canvas.
+10 -6
View File
@@ -39,7 +39,9 @@ English and Simplified Chinese interfaces follow the ComfyUI language setting. T
### Paint directly on the graph
Upload, drop or paste a source image and paint on **Inline Mask** itself. Mask strokes stay in the browser and are saved with the workflow; painting does not require a disk save/load round-trip for every stroke.
Upload, drop or paste a source image and paint on **Inline Mask** itself. In Mask mode, uploading an image automatically fills the connected inpaint dimensions; a replacement of the same size keeps the mask, while a different-sized image starts with a fresh mask. Mask strokes stay in the browser and are saved with the workflow; painting does not require a disk save/load round-trip for every stroke.
While a source image uploads, a progress bar reports the bytes actually sent and then shows when the image is loading. On platforms requiring a special authentication fallback, the indicator stays indeterminate rather than showing an estimated percentage.
- Brush, soft edges, hardness, opacity, pen pressure, temporary erasing and shape tools.
- A compact **↑ upload / ⛶ fit / pin** toolbar, with a mode selector at the upper right. Enable the pin (**Keep**) to keep the image fitted and centered; plain wheel then navigates the workflow, while `Ctrl` + wheel still changes brush size and `Shift` + wheel changes hardness.
@@ -72,7 +74,7 @@ In Crop & Blend mode, choose one of six 64-aligned presets spanning 512–2048 p
Generate, inspect the result in **Image Compare**, then click **Adopt this result** to return it. There is no feedback wire and generation does not automatically replace the editor image. Use the inpaint node's **image** output for comparison; its `crop` output is the sampling canvas and can have a different size. [Open the ready-wired example](examples/05-inline-crop-blend.json).
After adoption, **◐ blend layers** shows the original under the new result. Paint to keep new pixels, hold **Alt** to restore original pixels, and use soft brush edges to blend seams. This layer mask is independent of the repaint mask and is kept for another pass at the same coordinates. Before merging, **hold the right mouse button** to preview the original; release it to restore the workspace without changing either mask or the selection. **✓** merges the visible layers. Once merged, right-click releases the selection, then the next left-click locks a new region. The **↶ Undo** button sits to the right of ✓ and undoes mask strokes in the active mask or blend layer. Hover the bottom controls for instructions; canvas tooltips follow the current editing stage. There is no crop version timeline. For a generated batch, choose the desired result in Image Compare before adopting it. A result tagged with an older crop is rejected after selecting another region.
After adoption, the result appears in the selected area and the editor returns to the repaint mask, ready for another pass. Select **◐ blend layers** to paint the layer mask: brush keeps new pixels, **Alt** restores original pixels, and soft edges blend seams. The blend mask is independent of the repaint mask. While editing the blend mask, **hold the right mouse button** to preview the original and release it to return to the workspace. To select a new region, switch to **◌ repaint mask** and right-click (or press Escape); the visible result is merged before the selection is released. Left-click places the next region. The **↶ Undo** button undoes strokes in the active mask. Hover the bottom controls for instructions. For a generated batch, select the desired result in Image Compare before adopting it. A result tagged with an older crop is rejected after selecting another region.
### Inpaint the whole image or just the masked region
@@ -91,6 +93,8 @@ Move the pointer over **Image Compare**: **result on the left, original on the r
Results accumulate within an editing round, with the newest selected automatically. Each result retains its matching original; replayed preview notifications are not counted twice.
Use the **↓** button to download the latest generated result at its original resolution, even when an older result is selected in the list. For a batch, it downloads the last image in that batch.
Click **Adopt this result** to replace the inline source while preserving its mask and current-session undo/redo history. The comparison list resets to **1/1**; the next generation starts a new round. Failed adoption leaves the list intact. Adoption never queues a generation automatically.
### Switch entire workflow setups with presets
@@ -125,7 +129,7 @@ git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
### Other installation options
- **ComfyUI Manager:** use its Git URL installation option, if available, with this repository's URL. Git URL installation and visibility in Manager's search index are separate; see the [publishing notes](registry/README.md).
- **ComfyUI Manager:** search for `Adachi-Inpaint-Workbench` in the Registry-backed Manager. Version availability depends on Registry validation; check the [Registry listing](https://registry.comfy.org/nodes/adachi-inpaint-workbench). You can also use the Manager's Git URL installation option with this repository's URL.
- **ZIP:** [download the repository](https://github.com/adachi0130/Adachi-Inpaint-Workbench/archive/refs/heads/main.zip), extract it, and place the plugin folder at `ComfyUI/custom_nodes/Adachi-Inpaint-Workbench/`. Keep `web/`, `backend.py` and `__init__.py` together.
- **Windows installer:** from the extracted plugin folder, run the following, pointing to the ComfyUI directory containing `main.py`:
@@ -189,8 +193,8 @@ For modular sampling: `Prepare.pixels → VAE Encode → Set Latent Noise Mask
| Temporary eraser | Hold `Alt`; release to return to the previous tool |
| Brush size | `Ctrl` + wheel, or `[` / `]` |
| Brush hardness | `Shift` + wheel |
| Preview original before merging | Hold right mouse button; release to restore the workspace |
| Release crop selection | After merging with ✓ (or with no adopted layer), right-click, then left-click to select a new region |
| Preview original while blending | In **◐ blend layers**, hold right mouse button; release to restore the workspace |
| Release crop selection | In **◌ repaint mask**, right-click or press Escape; left-click to select a new region |
| Undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z`, or `Ctrl+Y` |
| Image zoom / pan, when unpinned | Wheel / Space + drag, middle-button drag, or the pan tool (`H`) |
| Keep the image fitted | Pin beside **Fit**; click again to unlock |
@@ -218,7 +222,7 @@ Drag a JSON file into ComfyUI:
- **Crop layers:** the fusion mask and crop coordinates are saved in the workflow; the latest layer is a relative image reference under `input/adachi-inpaint-layers/`. Include that file when moving an unmerged edit between machines, or merge layers and embed the source first.
- **Sharing:** masks are embedded in saved workflows. Source images use a relative `input/` path by default; enable **Embed source in workflow** to include the picture too. Embedded images increase JSON size. If an embedded source would exceed browser draft storage when loading a workflow, uploading an image, changing modes or enabling embedding, the plugin first uploads an exact source copy and switches to an input-file reference, preserving masks and layers. To intentionally keep a very large source embedded, disable ComfyUI browser workflow persistence before embedding; exported files are not subject to the browser draft quota. If the page already reports draft failures on every edit, save with **Ctrl+S**, then reload with **Ctrl+F5** to reset ComfyUI's disabled draft-storage state.
- **Adoption:** source and result dimensions must match to preserve mask coordinates. Undo/redo commands survive adoption in the current session; saving/reloading retains the mask, not the command history.
- **Temporary previews:** comparison history stores preview references. ComfyUI temp cleanup may expire them; adopted images are stored under `input/adachi-inpaint/`.
- **Comparison previews:** new originals and results are held in server memory instead of temporary image files. The default cache is 512 MiB; set `ADACHI_COMPARE_MEMORY_MIB` to 64–4096 before starting ComfyUI to change it. Cache eviction or server restart expires old comparisons and their download references; rerun the workflow to refresh them. A single batch larger than the limit reports an error. Previous disk-based preview references remain supported while available, and expired old plugin preview files are still cleaned up. Images adopted into the editor are saved separately under `input/adachi-inpaint/` or `input/adachi-inpaint-layers/`.
- **Scope:** this is an original WebUI-style implementation. It does not promise identical WebUI pixels, scripts or extension behaviour. Extra soft-edge feathering is not WebUI's full Soft Inpainting algorithm.
<details>
+10 -6
View File
@@ -39,7 +39,9 @@
### 直接在工作流里画蒙版
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。普通蒙版模式上传图片时会自动填写已连接的局部重绘宽高;替换同尺寸图片时保留蒙版,尺寸不同则载入新图并重置蒙版。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
上传原图时,进度条显示浏览器实际发送的数据比例,上传完成后提示正在加载图片。需要特殊认证回退的平台会显示不定进度,不会虚构百分比。
- 支持画笔软边、硬度、不透明度、数位笔压感、临时擦除和形状工具。
- 顶部使用**↑ 上传、⛶ 适应、图钉**,右上角切换模式。图钉的悬停提示为「保持」:开启后图片持续适应并居中,普通滚轮控制工作流背景,`Ctrl` + 滚轮调整笔径,`Shift` + 滚轮调整硬度。
@@ -72,7 +74,7 @@
先生成,在**图像对比**中查看,满意后点击**采用此结果**,才会嵌回当前裁切位置;没有回接输入线,也不会生成后自动替换。比较结果使用重绘节点的 **image** 输出;`crop` 是采样画布,尺寸可能不同。[已连好的示例](examples/05-inline-crop-blend.json)。
采用后自动进入 **◐ 图层融合**:底层原图、上层重绘结果,画笔保留新图,按住 **Alt** 擦回原图,软笔刷可融合接缝。融合蒙版与重绘蒙版相互独立,同一区域继续生成会保留融合蒙版。合并前**按住右键看原图,松开恢复工作区**,不改动蒙版或选区。**✓** 合并当前可见图层;合并后在图片内右键取消选定,再左键选择新区域。**↶ 撤销**位于 ✓ 右边,可撤销当前重绘蒙版或融合蒙版的涂抹。鼠标悬停底部控件可查看操作说明,画布提示也会跟随当前编辑阶段变化。没有裁切版本列表。批量生成时,在图像对比中选中想要的结果再采用;换到其他选区后,带有旧选区标记的结果会拒绝回贴,避免贴错位置。
采用后结果显示在选区中,编辑器回到重绘蒙版,可直接继续生成。点击 **◐ 图层融合**可编辑融合蒙版:画笔保留新图,按住 **Alt** 擦回原图,软笔刷可融合接缝;融合蒙版与重绘蒙版相互独立。编辑融合蒙版时,**按住右键看原图,松开恢复工作区**。要选择新区域,请切回 **◌ 重绘蒙版**后右键,或按 Escape;当前可见结果会先合并,再取消选定,接着左键选择新区域。**↶ 撤销**可撤销当前蒙版的涂抹,鼠标悬停底部控件可查看说明。批量生成时,在图像对比中选中想要的结果再采用;换到其他选区后,带有旧选区标记的结果会拒绝回贴。
### 整图重绘与蒙版区域重绘
@@ -91,6 +93,8 @@
同一轮编辑中的结果会累计保存并默认选中最新一张,每张结果对应当时的原图;重复的预览通知不会重复计数。
点击 **↓** 可以按原始分辨率下载最新生成的结果,即使列表中正在查看旧图也会下载最新一张;批量生成时下载该批最后一张。
点击**采用此结果**,把选中的图片回填为内联蒙版的新原图,同时保留蒙版和当前会话中的撤销/重做记录。结果列表立即回到 **1/1**,下一次生成开启新一轮。采用失败时保留原列表;采用操作不会自动开始生成。
### 一键切换整套分组配置
@@ -125,7 +129,7 @@ git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
### 其他安装方式
- **ComfyUI Manager:**如果当前版本提供 Git URL 安装,可以填入本仓库地址。Git URL 安装与能否在 Manager 搜索索引中找到是两回事,收录说明见[发布文档](registry/README.md)。
- **ComfyUI Manager:**可搜索 `Adachi-Inpaint-Workbench`;各版本的可用性取决于注册表验证结果,参见[注册表页面](https://registry.comfy.org/nodes/adachi-inpaint-workbench)。也可以使用 Manager 的 Git URL 安装方式填写本仓库地址。
- **ZIP:**[下载仓库压缩包](https://github.com/adachi0130/Adachi-Inpaint-Workbench/archive/refs/heads/main.zip),解压后放到 `ComfyUI/custom_nodes/Adachi-Inpaint-Workbench/`,保留完整的 `web/`、`backend.py` 和 `__init__.py`。
- **Windows 安装脚本:**在解压后的插件目录运行以下命令,路径指向包含 `main.py` 的 ComfyUI 目录:
@@ -189,8 +193,8 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
| 临时橡皮擦 | 按住 `Alt`,松开后恢复之前的工具 |
| 调整笔径 | `Ctrl` + 滚轮,或 `[` / `]` |
| 调整硬度 | `Shift` + 滚轮 |
| 合并前临时看原图 | 按住右键;松开恢复当前工作区 |
| 取消裁切选定 | 点击 ✓ 合并后(或没有回贴图层时),图片内右键,再左键选择新区域 |
| 融合时临时看原图 | 在 **◐ 图层融合**中按住右键,松开恢复工作区 |
| 取消裁切选定 | 在 **◌ 重绘蒙版**中右键或按 Escape,再左键选择新区域 |
| 撤销 / 重做 | `Ctrl+Z` / `Ctrl+Shift+Z`,或 `Ctrl+Y` |
| 未固定时的图片缩放 / 移动 | 滚轮 / 空格拖动、中键拖动,或移动工具 `H` |
| 保持图片适应 | 点击「适应」旁的图钉,再点一次解除 |
@@ -218,7 +222,7 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
- **裁切图层:**选区和融合蒙版会保存到工作流,最新重绘图层引用 `input/adachi-inpaint-layers/` 中的图片。跨机器移动尚未合并的编辑时需带上这张图,或先合并图层再嵌入原图。
- **分享工作流:**蒙版会写入 JSON。原图默认只存 `input/` 下的相对路径,开启**原图嵌入工作流**才会把图片也写进去,文件会变大。加载工作流、上传图片、切换模式或勾选嵌入时,如果浏览器草稿空间不足,插件先保存一份完全相同的原图到 input 图片目录,再改用文件引用,保留蒙版、图层和笔画记录。若确实要把大图嵌入 JSON,可先关闭 ComfyUI 的浏览器工作流持久化,再勾选嵌入;导出文件不受浏览器草稿限额影响。若当前页面已经每次操作都提示草稿失败,请先按 **Ctrl+S** 保存,再按 **Ctrl+F5** 刷新一次,重置 ComfyUI 本页已停用的草稿状态。
- **采用结果:**结果尺寸必须与原图一致,才能保留蒙版坐标。撤销/重做记录在本次会话中保留;保存重开保留蒙版,不保存逐笔命令历史。
- **临时预览:**对比历史保存的是预览引用,ComfyUI 清理临时文件后可能失效;已采用的原图保存在 `input/adachi-inpaint/`。
- **对比预览:**新的原图和重绘结果保存在服务器内存中,不再生成对比临时图片。默认缓存上限为 512 MiB,可在启动 ComfyUI 前设置 `ADACHI_COMPARE_MEMORY_MIB` 为 64~4096 调整。内存淘汰或服务器重启后,旧对比和下载引用会失效,重新运行工作流即可恢复;单批超过上限会提示错误。旧版磁盘预览文件存在时仍可读取,其过期清理也继续保留。采用到编辑器的图片另存于 `input/adachi-inpaint/` 或 `input/adachi-inpaint-layers/`。
- **功能范围:**这是独立实现的 WebUI 风格工具,不保证与 WebUI 像素、脚本和扩展行为完全一致;额外软边羽化并非 WebUI 的完整 Soft Inpainting 算法。
<details>
+11 -3
View File
@@ -1,11 +1,19 @@
# Git versions and rollback
The current version is **v0.6.7**. Git history lives in the source repository. The installed custom-node folder may be a ZIP/file copy without its own `.git`; do not run rollback commands in ComfyUI's parent repository.
The current version is **v1.0.0**. Git history lives in the source repository. The installed custom-node folder may be a ZIP/file copy without its own `.git`; do not run rollback commands in ComfyUI's parent repository.
Before installing, the installer saves the previous plugin directory under `ComfyUI/user/adachi-workbench-backups/`. Restore that ZIP to roll back a copied installation. For a Git clone, use the tags below.
| Tag | Contents |
|---|---|
| `v1.0.0` | First stable release with current bilingual crop instructions |
| `local-before-v1.0.0` / `v0.9.0` | Source before stable-release documentation and metadata |
| `v0.9.0` | In-memory comparison previews and real upload progress |
| `local-before-v0.9.0` / `v0.8.2` | Source before memory previews and upload progress |
| `v0.8.2` | Refresh different-sized uploads and synchronize inpaint dimensions |
| `local-before-v0.8.2` / `v0.8.1` | Source before upload and dimension synchronization fixes |
| `v0.8.1` | Expire old comparison previews and download the latest result |
| `local-before-v0.8.1` / `v0.8.0` | Source before comparison-preview cleanup and latest-result download |
| `v0.6.7` | Hold right mouse to preview the original before merging; contextual hover help |
| `local-before-v0.6.7` | Source before original-layer preview and expanded hover instructions |
| `v0.6.6` | Right-click releases crops; move mask Undo beside Merge |
@@ -39,13 +47,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach local-before-v0.6.7
git switch --detach local-before-v1.0.0
```
Return to this version with:
```powershell
git switch --detach v0.6.7
git switch --detach v1.0.0
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+8
View File
@@ -1,5 +1,13 @@
# Verification record
- 1.0.0: all 54 CPU backend tests and all seven JavaScript regression suites pass; every frontend module passes syntax checking and all bundled examples parse as JSON. The release updates documentation and metadata on top of 0.9.0; no full model generation was run for this release.
- 0.9.0: 54 backend CPU tests cover in-memory PNG pixels, atomic batch storage, LRU eviction, cached execution invalidation and oversized batch rejection. Actual CPU image queues in frontend 1.52.7 and both node renderers verify comparison history, download bytes, ordinary and crop adoption, mask retention, layer blending, workflow reload and legacy disk-backed references; no new comparison temp files are created. Real Chrome network throttling confirms intermediate upload percentages, loading indication, user header, failure and concurrent upload behavior in both renderers. Proxy paths and auth fallback were separately tested; the user's actual cloud environment and full model inference were not tested.
- 0.8.2: Chrome interaction with an isolated ComfyUI and frontend 1.52.7 passes in classic nodes and Nodes 2.0. Actual uploads confirm different-size source pixels and canvas display update, same-size masks are preserved, connected inpaint dimensions follow ordinary uploads and crop selection, switching back restores image dimensions, a newly connected inpaint node receives the size, and reopening a saved workflow preserves manually saved dimensions. No model inference was run.
- 0.8.1: 51 backend CPU tests pass, including expiry, preservation of unrelated/active/input files and comparison cache renewal. On an isolated local ComfyUI with frontend 1.52.7, actual lightweight image queues in classic nodes and Nodes 2.0 verify two comparison results, selection of an older result, downloading the newest PNG byte-for-byte, and cached notification deduplication. Browser checks also cover the Chinese tooltip, absent result and an expired-preview error. No full model generation was performed.
- 0.6.7: real Chrome mouse interactions on frontend 1.52.7 pass in classic nodes and Nodes 2.0. Display pixel checks verify hold-right original preview and release restoration, including release outside the picture, pointer cancellation and window blur. Preview overrides mask-only display temporarily, leaves serialized image/mask/crop state unchanged, and does not affect composite export pixels. After Merge, right-click still releases the selection; normal-mode right-click does not erase. Bottom controls have hover instructions and canvas help follows the editing stage. These checks use solid-color image fixtures, not model generation.
- 0.6.6: real mouse checks in classic nodes and Nodes 2.0 on frontend 1.52.7 verify right-click release without erasing, right-click while unselected, left-click relocking, preserved adopted pixels after merge, Alt temporary erase in both modes, and a functional live Undo button directly after Merge. The old reselect button and inpaint-panel Undo are absent. Canvas context menus are stopped from bubbling to the outer node. Frontend syntax and diff whitespace checks pass.
+19
View File
@@ -1,12 +1,31 @@
"""Adachi-Inpaint-Workbench custom-node package for ComfyUI."""
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, normalize_exclusive_prompt
from .preview_cleanup import start_preview_cleanup
from .preview_store import PREVIEWS
from aiohttp import web
from server import PromptServer
import re
if not getattr(PromptServer.instance, "_adachi_exclusive_normalizer", False):
PromptServer.instance.add_on_prompt_handler(normalize_exclusive_prompt)
PromptServer.instance._adachi_exclusive_normalizer = True
if not getattr(PromptServer.instance, "_adachi_preview_route", False):
@PromptServer.instance.routes.get("/adachi-inpaint/compare/{filename}")
async def adachi_comparison_preview(request):
name = request.match_info["filename"]
if not re.fullmatch(r"[A-Za-z0-9_-]{32}\.png", name):
raise web.HTTPNotFound()
data = PREVIEWS.get(name[:-4])
if data is None:
raise web.HTTPNotFound(text="Comparison preview expired; run the workflow again.")
return web.Response(body=data, content_type="image/png", headers={"Cache-Control": "no-store"})
PromptServer.instance._adachi_preview_route = True
start_preview_cleanup()
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+30 -8
View File
@@ -22,6 +22,11 @@ from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError
import torch
import torch.nn.functional as F
if __package__:
from .preview_store import PREVIEWS
else: # Standalone CPU tests load this module directly.
from preview_store import PREVIEWS
CATEGORY = "Adachi-Inpaint-Workbench"
MAX_IMAGE_PIXELS = 32_000_000
@@ -333,10 +338,13 @@ class AdachiInlineMask:
digest.update(str(value).encode("utf-8"))
digest.update(b"\x00")
if not source_data:
# The source is a stable input file; hashing its size and mtime is
# enough to detect a replacement and avoids reading a full high-res
# PNG on every change/execution. Content-hash the bytes only when
# the metadata cannot be read (the loader will then raise anyway).
try:
with _input_path(image).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
stat = _input_path(image).stat()
digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("utf-8"))
except (ValueError, OSError):
return float("nan")
return digest.hexdigest()
@@ -613,9 +621,13 @@ class AdachiImageCompare:
DESCRIPTION = "Hover to compare; adopt a result into the upstream inline editor."
SEARCH_ALIASES = ["compare", "before after", "图像对比", "adachi", "安达工作台"]
def compare(self, original, result, prompt=None, unique_id=None, dynprompt=None):
import nodes
@classmethod
def IS_CHANGED(cls, original, result, **kwargs):
# All evictions invalidate old cached UI descriptors; the store also
# identifies its process so a restarted server cannot replay stale URLs.
return PREVIEWS.fingerprint()
def compare(self, original, result, prompt=None, unique_id=None, dynprompt=None):
original, result = _image_tensor(original), _image_tensor(result)
if original.shape[1:3] != result.shape[1:3]:
raise ValueError("Image sizes must match. Connect the final image output, not crop.")
@@ -623,9 +635,19 @@ class AdachiImageCompare:
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()
before = preview.save_images(original, filename_prefix="adachi_compare_original")["ui"]["images"]
after = preview.save_images(result, filename_prefix="adachi_compare_result")["ui"]["images"]
images, encoded_size = [], 0
for batch in (original, result):
for pixels in batch:
rgb = np.clip(255.0 * pixels.cpu().numpy(), 0, 255).astype(np.uint8)
stream = io.BytesIO()
Image.fromarray(rgb).save(stream, format="PNG", compress_level=1)
data = stream.getvalue()
encoded_size += len(data)
if encoded_size > PREVIEWS.limit:
raise ValueError("The comparison batch exceeds the in-memory preview limit. Reduce batch size or increase ADACHI_COMPARE_MEMORY_MIB (64–4096 MiB).")
images.append(data)
refs = PREVIEWS.put_batch(images, unique_id)
before, after = refs[:original.shape[0]], refs[original.shape[0]:]
origin = _comparison_origin(prompt, unique_id, dynprompt)
return {"ui": {"adachi_originals": before, "adachi_results": after, "adachi_origin": [origin] if origin else []}, "result": (result,)}
+2 -2
View File
@@ -673,10 +673,10 @@
"outputs": [],
"properties": {},
"widgets_values": [
"Inline Mask: Crop & Blend\n1. Select your checkpoint and upload the full source.\n2. Choose Crop & Blend and a size preset, then left-click to lock the black dashed frame. Inpaint width/height follow the crop automatically.\n3. Paint inside the empty crop mask and run the workflow.\n4. Inspect Image Compare and click Adopt this result. The patch returns to this crop; Inline Mask has no result input.\n5. Paint the blend layer mask to keep new pixels; Alt restores original pixels. The repaint mask is retained.\n6. full_image is the current adopted full composite. Before Merge, hold right-click to preview the original and release to restore the workspace. Click the check mark to merge; then right-click releases the selection and left-click chooses a new area. Undo is the return arrow after Merge. Hover the bottom controls for instructions."
"Inline Mask: Crop & Blend\n1. Select your checkpoint and upload the full source.\n2. Choose Crop & Blend and a size preset, then left-click to lock the black dashed frame. Inpaint width/height follow the crop automatically.\n3. Paint inside the empty crop mask and run the workflow.\n4. Inspect Image Compare and click Adopt this result. The patch returns to this crop; Inline Mask has no result input.\n5. Click the blend button to edit the blend mask: paint keeps new pixels and Alt restores original pixels. The repaint mask is retained.\n6. full_image is the current adopted full composite. In blend mode, hold right-click to preview the original. Switch to the repaint mask and right-click or press Escape to merge the visible result and release the selection, then left-click a new area. Hover the bottom controls for instructions."
],
"widgets_values_named": {
"text": "Inline Mask: Crop & Blend\n1. Select your checkpoint and upload the full source.\n2. Choose Crop & Blend and a size preset, then left-click to lock the black dashed frame. Inpaint width/height follow the crop automatically.\n3. Paint inside the empty crop mask and run the workflow.\n4. Inspect Image Compare and click Adopt this result. The patch returns to this crop; Inline Mask has no result input.\n5. Paint the blend layer mask to keep new pixels; Alt restores original pixels. The repaint mask is retained.\n6. full_image is the current adopted full composite. Reselect merges it before choosing a new area."
"text": "Inline Mask: Crop & Blend\n1. Select your checkpoint and upload the full source.\n2. Choose Crop & Blend and a size preset, then left-click to lock the black dashed frame. Inpaint width/height follow the crop automatically.\n3. Paint inside the empty crop mask and run the workflow.\n4. Inspect Image Compare and click Adopt this result. The patch returns to this crop; Inline Mask has no result input.\n5. Click the blend button to edit the blend mask: paint keeps new pixels and Alt restores original pixels. The repaint mask is retained.\n6. full_image is the current adopted full composite. Reselect merges it before choosing a new area."
},
"color": "#432",
"bgcolor": "#653"
+55
View File
@@ -0,0 +1,55 @@
"""Expire only this extension's comparison previews in ComfyUI's temp folder."""
import logging
import os
import re
import threading
import time
PREVIEW_MAX_AGE_SECONDS = 24 * 60 * 60
SWEEP_INTERVAL_SECONDS = 60 * 60
_PREVIEW_NAME = re.compile(r"adachi_compare_(?:original|result)_temp_[a-z]{5}_[0-9]{5,}_\.png\Z")
_started = False
_start_lock = threading.Lock()
def cleanup_comparison_previews(directory, *, now=None, max_age=PREVIEW_MAX_AGE_SECONDS):
"""Remove expired, regular preview files, leaving all other files untouched."""
cutoff = (time.time() if now is None else now) - max_age
removed = 0
try:
with os.scandir(directory) as entries:
for entry in entries:
if not _PREVIEW_NAME.fullmatch(entry.name):
continue
try:
if not entry.is_file(follow_symlinks=False):
continue
if entry.stat(follow_symlinks=False).st_mtime >= cutoff:
continue
os.unlink(entry.path)
removed += 1
except OSError as exc:
logging.debug("Could not remove expired comparison preview %s: %s", entry.name, exc)
except FileNotFoundError:
pass
except OSError as exc:
logging.warning("Could not inspect comparison preview directory: %s", exc)
return removed
def start_preview_cleanup():
"""Sweep in the background so a long-running ComfyUI does not accumulate previews."""
global _started
with _start_lock:
if _started:
return
_started = True
def run():
import folder_paths
while True:
cleanup_comparison_previews(folder_paths.get_temp_directory())
time.sleep(SWEEP_INTERVAL_SECONDS)
threading.Thread(target=run, name="adachi-preview-cleanup", daemon=True).start()
+82
View File
@@ -0,0 +1,82 @@
"""Bounded in-memory comparison PNGs. No preview is written to disk."""
from collections import OrderedDict
import hashlib
import os
import secrets
import threading
def _limit():
try:
mib = int(os.environ.get("ADACHI_COMPARE_MEMORY_MIB", "512"))
except ValueError:
mib = 512
return max(64, min(4096, mib)) * 1024 * 1024
class PreviewStore:
def __init__(self, limit=None):
self.limit = _limit() if limit is None else limit
self.nonce = secrets.token_hex(12)
self.epoch = 0
self.size = 0
self.groups = OrderedDict()
self.keys = {}
self.latest = {}
self.lock = threading.RLock()
def fingerprint(self):
with self.lock:
return f"{self.nonce}:{self.epoch}"
def put_batch(self, images, owner):
"""Atomically retain a whole comparison batch, or explicitly reject it."""
total = sum(len(image) for image in images)
if total > self.limit:
raise ValueError("The comparison batch exceeds the in-memory preview limit. Reduce batch size or increase ADACHI_COMPARE_MEMORY_MIB (64–4096 MiB).")
digest = hashlib.sha256()
for image in images:
digest.update(len(image).to_bytes(8, "big")); digest.update(image)
signature = digest.digest()
owner = str(owner)
with self.lock:
before = self.epoch
last = self.latest.get(owner)
# Cache fingerprinting happens before execution. If eviction of an
# unrelated or older batch caused a rerun but pixels are unchanged,
# keep the existing descriptors and let the fingerprint stabilise.
if last and last[1] != before and last[0] in self.groups:
group = self.groups[last[0]]
if group["signature"] == signature:
self.groups.move_to_end(last[0])
self.latest[owner] = (last[0], before)
return list(group["descriptors"])
while self.groups and self.size + total > self.limit:
group_id, group = self.groups.popitem(last=False)
self.size -= group["size"]
for key in group["keys"]:
self.keys.pop(key, None)
if self.latest.get(group["owner"], (None,))[0] == group_id:
self.latest.pop(group["owner"], None)
self.epoch += 1
group_id = secrets.token_urlsafe(24)
keys = [secrets.token_urlsafe(24) for _ in images]
descriptors = [{"type": "adachi_memory", "subfolder": "", "filename": key + ".png"} for key in keys]
self.groups[group_id] = {"owner": owner, "size": total, "keys": keys, "signature": signature, "descriptors": descriptors}
for key, image in zip(keys, images):
self.keys[key] = (group_id, image)
self.size += total
self.latest[owner] = (group_id, before)
return list(descriptors)
def get(self, key):
with self.lock:
found = self.keys.get(key)
if not found:
return None
self.groups.move_to_end(found[0])
return found[1]
PREVIEWS = PreviewStore()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.6.9"
version = "1.0.0"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+5 -6
View File
@@ -1,22 +1,21 @@
# 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 previous Manager listing submission is [PR #3378](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3378). It was closed without merging; it is not confirmation that the plugin is searchable. Check the live Registry/Manager index for current availability.
The node is registered under publisher `adachi0130` in [Comfy Registry](https://registry.comfy.org/nodes/adachi-inpaint-workbench). Version visibility and installation can depend on Registry validation, so check the current status there.
## Manager listing
[manager-entry.json](manager-entry.json) records the legacy list entry submitted to [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager).
For current registration requirements, follow the [official Manager instructions](https://github.com/Comfy-Org/ComfyUI-Manager#how-to-register-your-custom-node-into-comfyui-manager) and the Registry publishing guide below.
Until it is listed you can still install through the Manager's Git URL option, using `https://github.com/adachi0130/Adachi-Inpaint-Workbench`.
You can also 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.
Per the [official publishing guide](https://docs.comfy.org/registry/publishing), publishing requires the registered Publisher and its API key.
1. Sign in at [Comfy Registry](https://registry.comfy.org/) and create a Publisher, then note the actual Publisher ID.
1. Sign in at [Comfy Registry](https://registry.comfy.org/) as the owner of publisher `adachi0130`.
2. Check `[tool.comfy].PublisherId` in `pyproject.toml` (currently `adachi0130`) and confirm it matches the Publisher you own before publishing.
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 publishing workflow is manual-only. A Publisher ID in project metadata does not prove that an API key is configured or that a Registry release has been published.
The publishing workflow is manual-only. Check its result and the Registry version status after publishing.
A Registry version cannot be overwritten once published, so later releases have to bump the version in `pyproject.toml`.
+8 -9
View File
@@ -61,18 +61,17 @@ class BackendTests(unittest.TestCase):
def test_comparison_previews_and_result_passthrough(self):
image, _ = source()
result = image.repeat_interleave(3, 0)
calls = []
class Preview:
def save_images(self, images, filename_prefix):
calls.append((images.clone(), filename_prefix))
return {"ui": {"images": [{"filename": f"{filename_prefix}_{i}.png", "subfolder": "", "type": "temp"} for i in range(len(images))]}}
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(PreviewImage=Preview)}):
output = backend.AdachiImageCompare().compare(image, result)
output = backend.AdachiImageCompare().compare(image, result, unique_id="test")
self.assertTrue(torch.equal(output["result"][0], result))
self.assertEqual(len(output["ui"]["adachi_originals"]), 1)
self.assertEqual(len(output["ui"]["adachi_results"]), 3)
self.assertTrue(torch.equal(calls[0][0], image))
self.assertTrue(torch.equal(calls[1][0], result))
for preview in output["ui"]["adachi_originals"] + output["ui"]["adachi_results"]:
self.assertEqual(preview["type"], "adachi_memory")
raw = backend.PREVIEWS.get(preview["filename"][:-4])
with Image.open(io.BytesIO(raw)) as png:
actual = np.asarray(png)
expected = np.clip(255 * image[0].numpy(), 0, 255).astype(np.uint8)
np.testing.assert_array_equal(actual, expected)
def test_comparison_rejects_crop_and_incompatible_batch(self):
image, _ = source(batch=2)
+12 -2
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import {syncCropDimensions} from '../web/parameter_bridge.js';
import {syncCropDimensions,syncCurrentDimensions} from '../web/parameter_bridge.js';
const nodes=new Map(),links={},graph={links,getNodeById:id=>nodes.get(id),_nodes:[]};
const source={id:1,type:'AdachiInlineMask',graph};nodes.set(1,source);graph._nodes.push(source);
const make=(id,type,linkedWidth=false)=>{
@@ -10,4 +10,14 @@ const sampler=make(2,'AdachiWebUIInpaint'),prepare=make(3,'AdachiInpaintPrepare'
syncCropDimensions(source,1024,1536);
assert.deepEqual(sampler.widgets.map(w=>w.value),[1024,1536]);assert.deepEqual(prepare.widgets.map(w=>w.value),[640,1536]);
syncCropDimensions(source,9000,17);assert.deepEqual(sampler.widgets.map(w=>w.value),[8192,64]);
console.log('Crop resolution sync: connected targets, linked-input protection and source-size limits passed');
source.adachiEditor={ready:true,image:{width:1001,height:1551}};
source.adachiCrop={mode:'mask',region:{locked:false}};
syncCurrentDimensions(source,sampler);
assert.deepEqual(sampler.widgets.map(w=>w.value),[1000,1552]);
assert.deepEqual(prepare.widgets.map(w=>w.value),[640,64]);
source.adachiCrop={mode:'crop',region:{locked:false,width:512,height:768}};
syncCurrentDimensions(source);assert.deepEqual(sampler.widgets.map(w=>w.value),[1000,1552]);
source.adachiCrop.region.locked=true;
syncCurrentDimensions(source);assert.deepEqual(sampler.widgets.map(w=>w.value),[512,768]);
assert.deepEqual(prepare.widgets.map(w=>w.value),[640,768]);
console.log('Resolution sync: source/crop modes, connection target, linked-input protection and alignment passed');
+48
View File
@@ -0,0 +1,48 @@
"""Regression checks for comparison preview cleanup boundaries."""
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
SPEC = importlib.util.spec_from_file_location("preview_cleanup_under_test", Path(__file__).resolve().parents[1] / "preview_cleanup.py")
cleanup = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(cleanup)
class PreviewCleanupTests(unittest.TestCase):
def test_only_expired_comparison_previews_in_temp_are_removed(self):
now = 2_000_000
with tempfile.TemporaryDirectory() as root:
temp = Path(root) / "temp"
inputs = Path(root) / "input"
temp.mkdir()
inputs.mkdir()
old_original = temp / "adachi_compare_original_temp_abcde_00001_.png"
old_result = temp / "adachi_compare_result_temp_abcde_00002_.png"
recent = temp / "adachi_compare_result_temp_abcde_00003_.png"
unrelated = temp / "ComfyUI_temp_abcde_00001_.png"
wrong_name = temp / "adachi_compare_result_mine.png"
adopted = inputs / old_original.name
nested = temp / "nested"
nested.mkdir()
nested_preview = nested / old_original.name
for path in (old_original, old_result, recent, unrelated, wrong_name, adopted, nested_preview):
path.write_bytes(b"test")
os.utime(path, (now - 100_000, now - 100_000))
os.utime(recent, (now - 60, now - 60))
removed = cleanup.cleanup_comparison_previews(temp, now=now)
self.assertEqual(removed, 2)
for path in (old_original, old_result):
self.assertFalse(path.exists())
for path in (recent, unrelated, wrong_name, adopted, nested_preview):
self.assertTrue(path.exists())
def test_missing_directory_is_harmless(self):
with tempfile.TemporaryDirectory() as root:
self.assertEqual(cleanup.cleanup_comparison_previews(Path(root) / "missing"), 0)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
import unittest
from preview_store import PreviewStore
def key(descriptor):
return descriptor["filename"][:-4]
class PreviewStoreTests(unittest.TestCase):
def test_whole_batch_lru_and_cache_generation(self):
store = PreviewStore(10)
first = store.put_batch([b"aaa", b"bbb"], "1")
before = store.fingerprint()
second = store.put_batch([b"ccc", b"ddd"], "2")
self.assertNotEqual(store.fingerprint(), before)
self.assertIsNone(store.get(key(first[0])))
self.assertIsNone(store.get(key(first[1])))
self.assertEqual(store.get(key(second[0])), b"ccc")
self.assertEqual(store.get(key(second[1])), b"ddd")
self.assertNotEqual(store.fingerprint(), PreviewStore(10).fingerprint())
def test_eviction_rerun_reuses_current_batch_without_continuous_eviction(self):
store = PreviewStore(10)
store.put_batch([b"aaa", b"bbb"], "node")
current = store.put_batch([b"ccc", b"ddd"], "node")
after_eviction = store.fingerprint()
reused = store.put_batch([b"ccc", b"ddd"], "node")
self.assertEqual(reused, current)
self.assertEqual(store.fingerprint(), after_eviction)
# A true new execution with no intervening eviction has fresh identity.
regenerated = store.put_batch([b"ccc", b"ddd"], "node")
self.assertNotEqual(regenerated, current)
self.assertNotEqual(store.fingerprint(), after_eviction)
def test_oversized_batch_never_discards_prior_previews(self):
store = PreviewStore(6)
existing = store.put_batch([b"aa", b"bb"], "x")
generation = store.fingerprint()
with self.assertRaisesRegex(ValueError, "preview limit"):
store.put_batch([b"aaa", b"bbbb"], "x")
self.assertEqual(store.get(key(existing[0])), b"aa")
self.assertEqual(store.get(key(existing[1])), b"bb")
self.assertEqual(store.fingerprint(), generation)
def test_alternating_inputs_invalidates_the_old_cached_descriptors(self):
store = PreviewStore(6)
first = store.put_batch([b"aaa", b"bbb"], "node")
token = store.fingerprint()
store.put_batch([b"ccc", b"ddd"], "node")
self.assertNotEqual(token, store.fingerprint())
self.assertIsNone(store.get(key(first[0])))
restored = store.put_batch([b"aaa", b"bbb"], "node")
self.assertEqual(store.get(key(restored[0])), b"aaa")
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import {uploadImageWithProgress} from "../web/upload_transport.js";
let current, status = 200;
globalThis.XMLHttpRequest = class {
constructor() { current = this; this.upload = {}; this.headers = {}; }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(name, value) { this.headers[name] = value; }
send(body) {
this.body = body;
this.upload.onprogress({loaded: 40, total: 100, lengthComputable: true});
this.upload.onload();
this.status = status;
this.responseText = '{"name":"test.png"}';
this.onload();
}
};
const body = new FormData();
let fallback = 0, phases = [];
const api = {
user: "alice",
apiURL: path => `/cloud/proxy/api${path}`,
async fetchApi(path, options) {
fallback++;
assert.equal(path, "/upload/image"); assert.equal(options.body, body);
return {ok:true, json:async()=>({name:"fallback.png"})};
},
};
assert.deepEqual(await uploadImageWithProgress(api, body, event => phases.push(event)), {name:"test.png"});
assert.equal(current.method, "POST");
assert.equal(current.url, "/cloud/proxy/api/upload/image");
assert.equal(current.headers["Comfy-User"], "alice");
assert.deepEqual(phases, [{phase:"upload", loaded:40, total:100}, {phase:"processing"}]);
assert.equal(fallback, 0);
status = 401; phases = [];
assert.deepEqual(await uploadImageWithProgress(api, body, event => phases.push(event)), {name:"fallback.png"});
assert.equal(fallback, 1);
assert.equal(phases.at(-2).phase, "authenticated");
assert.equal(phases.at(-1).phase, "processing");
status = 500;
await assert.rejects(uploadImageWithProgress(api, body), /500/);
console.log("Upload transport: real byte progress, proxy URL, multi-user header, auth fallback and server error passed");
+31 -2
View File
@@ -27,6 +27,11 @@ export class ImageCompareView {
this.resultSelect.onchange = () => { this.index = Number(this.resultSelect.value); this.options.onSelect?.(this.index); this.loadSelected(); };
toolbar.append(this.resultSelect, this.dimensionsLabel);
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
this.downloadButton = element("button", "", "↓"); this.downloadButton.type = "button";
this.downloadButton.title = T("Download the latest generated image");
this.downloadButton.setAttribute("aria-label", this.downloadButton.title);
this.downloadButton.onclick = event => { event.preventDefault(); event.stopPropagation(); this.downloadLatest(); };
toolbar.append(this.downloadButton);
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");
@@ -83,7 +88,29 @@ export class ImageCompareView {
this.updateButton();
}
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; this.adoptButton.textContent = this.busy ? T("Adopting…") : T("Adopt this result"); }
updateButton() {
this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy;
this.adoptButton.textContent = this.busy ? T("Adopting…") : T("Adopt this result");
this.downloadButton.disabled = !this.latestResult || !!this.downloading;
}
setLatestResult(result) { this.latestResult = result || null; this.updateButton(); }
async downloadLatest() {
if (!this.latestResult || this.downloading) return;
const result = this.latestResult;
this.downloading = true; this.updateButton();
try {
const blob = await this.options.fetchResult(result);
if (this.destroyed) return;
const url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = "adachi-inpaint-result.png";
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
this.status("");
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally { this.downloading = false; if (!this.destroyed) this.updateButton(); }
}
async setData(data, index = this.index) {
this.originals = Array.isArray(data?.originals) ? data.originals : [];
@@ -118,7 +145,9 @@ export class ImageCompareView {
async adopt() {
this.refreshTargets();
if (!this.ready || !this.targetSelect.value || this.busy) return;
if (this.busy) return;
if (!this.ready) { this.status(T("The result preview is still loading"), true); return; }
if (!this.targetSelect.value) { this.status(T("Select an inline mask to adopt into"), true); return; }
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
try {
+14 -4
View File
@@ -1,15 +1,17 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js?v=0.5.9";
import { ImageCompareView } from "./compare.js?v=0.8.1";
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) {
function viewPath(image) {
if (image?.type === "adachi_memory") return `/adachi-inpaint/compare/${encodeURIComponent(image.filename || "")}`;
const type = ["input", "output", "temp"].includes(image?.type) ? image.type : "temp";
return api.apiURL(`/view?${new URLSearchParams({ filename: image?.filename || "", subfolder: image?.subfolder || "", type })}`);
return `/view?${new URLSearchParams({ filename: image?.filename || "", subfolder: image?.subfolder || "", type })}`;
}
const viewUrl = image => api.apiURL(viewPath(image));
function targets(node) {
const graph = node.graph;
@@ -41,10 +43,15 @@ app.registerExtension({
onGraphWheel: event => app.canvas?.processMouseWheel(event),
getTargets: () => targets(node),
onSelect: index => { node.properties.adachi_result_index = index; },
async fetchResult(image) {
const response = await api.fetchApi(viewPath(image));
if (!response.ok) throw new Error(T("Result preview expired; run the workflow again"));
return response.blob();
},
async onAdopt(id, image) {
const target = node.graph?.getNodeById(id);
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" })}`);
const response = await api.fetchApi(viewPath(image));
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(T("Result image exceeds 64 MiB; downscale it first"));
@@ -78,6 +85,8 @@ app.registerExtension({
? {...image, adachi_origin:output.adachi_origin[0]} : image),
});
node.properties.adachi_compare = data;
node.properties.adachi_latest_result = data.results[data.results.length - 1];
compare.setLatestResult(node.properties.adachi_latest_result);
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));
@@ -89,6 +98,7 @@ app.registerExtension({
const result = configured?.apply(this, arguments);
if (node.properties.adachi_compare) {
node.properties.adachi_compare = appendComparison(node.properties.adachi_compare);
compare.setLatestResult(node.properties.adachi_latest_result || node.properties.adachi_compare.results.at(-1));
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);
}
+3
View File
@@ -41,6 +41,9 @@
.dd-inpaint .dd-embed { display: flex; align-items: center; gap: 5px; padding: 4px 10px 0; font-size: 11px; color: var(--dd-muted); cursor: pointer; }
.dd-inpaint .dd-embed input { accent-color: #e9ab68; margin: 0; }
.dd-inpaint .dd-status { padding: 5px 11px 8px; color: var(--dd-muted); font-size: 10px; min-height: 27px; overflow-wrap: anywhere; }
.dd-inpaint .dd-upload-progress { display: flex; flex-direction: column; gap: 4px; padding: 7px 9px; color: var(--dd-text); background: #20242bee; border-radius: 4px; font-size: 11px; pointer-events: none; }
.dd-inpaint .dd-upload-bar { width: 100%; height: 6px; accent-color: #f3b06a; }
.dd-inline-minimal > .dd-upload-progress { position: absolute; z-index: 3; top: 34px; left: 5px; width: min(240px, calc(100% - 10px)); }
.dd-inpaint .dd-error { color: #ff9c92; }
.dd-inpaint [hidden] { display: none !important; }
.dd-inpaint .dd-parameters { flex: 0 0 auto; padding: 10px 12px 8px; border-top: 1px solid var(--dd-line); }
+64 -11
View File
@@ -95,6 +95,7 @@ export class InlineMaskEditor {
this.root.append(head);
const files = el("div", "dd-row dd-files");
this.loadButton = this.button(files, "↑", T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
this.downloadButton = this.button(files, "↓", T("Download the source image"), () => this.downloadSource(), "dd-primary");
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
files.append(this.filename);
this.root.append(files);
@@ -138,7 +139,16 @@ export class InlineMaskEditor {
this.empty = el("div", "dd-empty");
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.uploadIndicator = el("div", "dd-upload-progress");
this.uploadIndicator.hidden = true;
this.uploadIndicator.setAttribute("role", "status");
this.uploadIndicator.setAttribute("aria-live", "polite");
this.uploadLabel = el("span", "dd-upload-label");
this.uploadBar = el("progress", "dd-upload-bar");
this.uploadBar.max = 100;
this.uploadBar.setAttribute("aria-label", T("Image upload progress"));
this.uploadIndicator.append(this.uploadLabel, this.uploadBar);
this.root.append(this.stage, settings, this.uploadIndicator);
this.canvas.oncontextmenu = event => { event.preventDefault(); event.stopPropagation(); };
this.canvas.addEventListener("pointerdown", event => this.pointerDown(event));
this.canvas.addEventListener("pointermove", event => this.pointerMove(event));
@@ -174,7 +184,7 @@ export class InlineMaskEditor {
this.zoomLabel = el("span", "dd-zoom", "100%");
footer.append(this.zoomLabel);
this.fitButton = this.button(footer, "⛶", 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 = this.button(footer, "", T("Pin the current view"), 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.
@@ -201,6 +211,7 @@ export class InlineMaskEditor {
actions.addEventListener("keydown", event => this.keydown(event));
this.loadButton.after(this.fitButton);
this.fitButton.after(this.keepButton);
this.loadButton.after(this.downloadButton);
// These are the original live controls; the sampler mounts this
// same DOM tree so brush state and callbacks are preserved.
}
@@ -225,15 +236,43 @@ export class InlineMaskEditor {
status(message, error = false) {
this.message.textContent = message;
this.message.classList.toggle("dd-error", error);
// Only errors stay visible; transient hints and progress lines hide.
this.message.hidden = !error;
}
notice(message) {
// An explicit non-error notice that should remain visible.
this.message.textContent = message;
this.message.classList.remove("dd-error");
this.message.hidden = false;
}
async upload(file) {
if (file.size > 64 * 1024 * 1024) { this.status(T("File is too large (max 64 MB)"), true); return; }
const version = this.uploadUiVersion = (this.uploadUiVersion || 0) + 1;
const progress = ({phase, loaded = 0, total = 0}) => {
if (version !== this.uploadUiVersion || this.destroyed) return;
this.uploadIndicator.hidden = false;
if (phase === "upload" && total > 0) {
const percent = Math.max(0, Math.min(100, Math.round(100 * loaded / total)));
this.uploadLabel.textContent = `${T("Uploading image")} ${percent}%`;
this.uploadBar.value = percent;
} else {
this.uploadLabel.textContent = T(phase === "processing" ? "Upload complete, loading image…" : phase === "authenticated" ? "Uploading with authentication…" : "Uploading image…");
this.uploadBar.removeAttribute("value");
}
};
progress({phase: "upload"});
try {
this.status(T("Loading image…"));
await this.options.onUpload?.(file);
} catch (error) { this.status(error.message, true); }
// Keep the painted mask when a new source replaces the current one,
// the same way adopting a result does. A first upload starts empty.
await this.options.onUpload?.(file, { preserveMask: !!this.image, onProgress: progress });
} catch (error) {
if (version === this.uploadUiVersion && !this.destroyed) this.status(error.message, true);
} finally {
if (version === this.uploadUiVersion && !this.destroyed) this.uploadIndicator.hidden = true;
}
}
async setImage(url, maskData = "", name = "") {
@@ -285,10 +324,10 @@ export class InlineMaskEditor {
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"));
}
if (version !== this.loadVersion || this.destroyed) return false;
// A different size cannot keep the painted mask; report it so the caller
// can fall back to a fresh load instead of failing the upload.
if (image.width !== this.mask.width || image.height !== this.mask.height) return false;
// Replacing only the source preserves mask pixels, the base snapshot,
// command history, redo history, active strokes and the current view.
this.flush();
@@ -296,6 +335,7 @@ export class InlineMaskEditor {
this.filename.textContent = name || T("Embedded image");
this.filename.title = this.filename.textContent;
this.updateButtons(); this.schedule();
return true;
}
importMask(ctx, image) {
@@ -325,6 +365,7 @@ export class InlineMaskEditor {
updateButtons() {
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
if (this.downloadButton) this.downloadButton.disabled = !this.image;
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);
@@ -341,7 +382,6 @@ 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;
@@ -360,7 +400,6 @@ export class InlineMaskEditor {
pointerDown(event) {
if (event.button === 2) { event.preventDefault(); event.stopPropagation(); return; }
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;
@@ -556,11 +595,25 @@ export class InlineMaskEditor {
const link = el("a"); link.download = "adachi-mask.png"; link.href = this.flush(); link.click();
}
downloadSource() {
if (!this.image) return;
const out = makeCanvas();
out.width = this.image.naturalWidth || this.image.width;
out.height = this.image.naturalHeight || this.image.height;
out.getContext("2d").drawImage(this.image, 0, 0);
const label = String(this.filename.textContent || "").split(/[\\/]/).pop().replace(/\.[^.]+$/, "");
const link = el("a");
link.download = `${label || "adachi-source"}.png`;
link.href = out.toDataURL("image/png");
link.click();
out.width = out.height = 0;
}
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
setKeepFit(enabled, notify = true) {
// Pin freezes the view exactly as drawn; it must not re-fit the image.
this.keepFit = !!enabled;
if (this.keepFit) this.fit();
this.updateButtons(); this.schedule();
if (notify) this.options.onKeepFit?.(this.keepFit);
}
+16 -2
View File
@@ -6,6 +6,15 @@
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
const ZH = {
"Image upload progress": "图片上传进度",
"Uploading image": "正在上传图片",
"Uploading image…": "正在上传图片…",
"Upload complete, loading image…": "上传完成,正在加载图片…",
"Uploading with authentication…": "正在通过身份验证上传…",
"Network error while uploading image": "上传图片时网络连接失败",
"Upload was cancelled": "上传已取消",
"Invalid upload response": "上传响应无效",
"Download the latest generated image": "下载最新生成的图片",
"Mask: paint the full image. Crop & Blend: select a region, inpaint it, then adopt and blend the result.": "普通蒙版:在整图涂抹。裁切融合:选定局部、重绘,再从图像对比采用结果并融合图层。",
"Crop resolution presets. Changing size releases the current selection; left-click the image to place a new region. Inpaint dimensions follow when selected.": "裁切分辨率预设。切换尺寸会取消当前选定,再左键点击图片定位新区域;选定后自动填写 Inpaint 宽高。",
"Swap width and height, release the current selection, then left-click to place the new region.": "交换宽高并取消当前选定,再左键点击图片选择新区域。",
@@ -19,6 +28,9 @@ const ZH = {
"This result belongs to another crop; generate a result for the current region": "该结果来自其他选区,请为当前选区重新生成",
"Result dimensions must match the selected crop": "结果宽高必须与当前裁切区域一致",
"The crop changed while adopting the result": "采用过程中选区已改变,请重新生成结果",
"The result was not applied; the crop or layer state changed": "结果未应用,选区或图层状态已改变",
"The result preview is still loading": "结果预览仍在加载中",
"Select an inline mask to adopt into": "请选择要回填的内联蒙版",
"Painting tools and mask settings": "绘图工具与蒙版设置",
"Browser draft storage was full. The source was saved as an input file; the image, masks and layers are preserved.": "浏览器草稿空间不足,已将原图保存为图片文件;图像、蒙版和图层均已保留。",
"Editing mode": "编辑模式",
@@ -111,6 +123,8 @@ const ZH = {
"Mask": "蒙版",
"Fit": "适应",
"Keep": "保持",
"Pin the current view": "固定当前视图",
"Download the source image": "下载原图",
"Upload": "上传",
"Fit the image to the canvas": "适应画布",
"File is too large (max 64 MB)": "图片文件不能超过 64 MB",
@@ -122,8 +136,8 @@ const ZH = {
// 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": "请等待原图加载完成后再采用结果",
"The new image has a different size, so the mask was reset": "新图尺寸与原图不同,蒙版已重置",
"Wait for the current image to finish loading": "请等待当前图片加载完成",
"Drag to resize canvas height": "上下拖动调整画布高度",
"Resize canvas height": "调整画布高度",
"Upload failed": "上传失败",
+42 -22
View File
@@ -1,7 +1,7 @@
import { CROP_PRESETS, cropAt } from "./crop_geometry.js?v=0.6.2";
import { T } from "./i18n.js";
import { syncCropDimensions } from "./parameter_bridge.js?v=0.6.4";
import { installResultAdoption } from "./result_adoption.js?v=0.6.4";
import { syncCropDimensions, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { installResultAdoption } from "./result_adoption.js?v=0.7.2";
const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"];
const canvas = (w, h) =>
@@ -42,6 +42,16 @@ export class InlineCropController {
this.patchCanvas = canvas(1, 1);
this.build();
installResultAdoption(node, this);
// Only user uploads go through editor.upload; internal layer merges and
// adopting an already-sized result call onUpload directly instead.
const upload = editor.upload.bind(editor);
editor.upload = async file => {
const previous = editor.image;
await upload(file);
if (!editor.destroyed && editor.ready && editor.image !== previous && this.mode === "mask") {
syncCurrentDimensions(node);
}
};
const originalFlush = editor.flush.bind(editor);
editor.flush = () => {
if (this.target !== "blend") return originalFlush();
@@ -93,14 +103,17 @@ export class InlineCropController {
e.preventDefault();
e.stopPropagation();
editor.root.focus({ preventScroll: true });
if (this.layer && this.region.locked && !editor.stroke && editor.pointerId == null) {
// Hold-right preview only makes sense while adjusting the blend layer.
// After adoption the layer is already baked, so right-click reselects
// (reselect flattens any remaining layer first).
if (this.target === "blend" && this.layer && this.region.locked && !editor.stroke && editor.pointerId == null) {
this.previewOriginal = true;
this.originalPreviewPointer = e.pointerId;
editor.canvas.setPointerCapture(e.pointerId);
editor.cursor = null;
this.revision++;
editor.schedule();
} else if (!this.layer && this.region.locked) this.run(() => this.reselect());
} else if (this.region.locked) this.run(() => this.reselect());
return;
}
if (
@@ -267,19 +280,19 @@ export class InlineCropController {
this.blendButton = button(
"◐",
"Edit layer blending: brush keeps the new result; Alt restores original pixels. Hold right-click to preview the original, release to return.",
() => {
this.activate("blend");
this.changed();
},
);
this.commitButton = button("✓", "Merge the visible result and blend mask into the source image. After merging, right-click releases the selected region.", () =>
this.flatten(),
() =>
this.run(async () => {
// Blending is opt-in after adoption: build the blend mask on first
// use, then switch the editor into the blend layer.
if (!this.contexts.blend) await this.makeBlend(this.widgets.blend_data.value);
this.activate("blend");
this.changed();
}),
);
// Move the original live button, retaining its history and disabled state.
this.undoButton = e.undoButton;
this.undoButton.textContent = "↶";
this.undoButton.title = T("Undo the last stroke in the active repaint or blend mask (Ctrl+Z). Redo with Ctrl+Shift+Z.");
this.commitButton.after(this.undoButton);
this.custom = make("div", "dd-crop-custom");
this.custom.hidden = true;
for (const key of ["cropWidth", "cropHeight"]) {
@@ -322,14 +335,13 @@ export class InlineCropController {
this.modeSelect.value = this.mode;
// No hover text on the painting canvas; the bottom controls carry the hints.
this.editor.canvas.removeAttribute("title");
for (const control of [this.preset, this.swapButton, this.maskButton, this.blendButton, this.commitButton]) {
for (const control of [this.preset, this.swapButton, this.maskButton, this.blendButton]) {
control.hidden = this.mode !== "crop";
}
this.custom.hidden = this.mode !== "crop" || this.preset.value !== "custom";
this.undoButton.disabled = !!this.busy || !this.editor.history.length;
this.clearButton.disabled = !!this.busy || !this.editor.ready;
this.blendButton.disabled = !this.layer;
this.commitButton.disabled = !this.layer;
this.maskButton.classList.toggle("dd-active", this.target === "mask");
this.blendButton.classList.toggle("dd-active", this.target === "blend");
this.modeSelect.disabled = !!this.busy;
@@ -357,6 +369,7 @@ export class InlineCropController {
this.state({ ...this.region, job: crypto.randomUUID() });
this.editor.fit();
this.sync();
if (mode === "mask") syncCurrentDimensions(this.node);
this.changed();
};
const recovery = this.beforeModeChange?.();
@@ -498,14 +511,17 @@ export class InlineCropController {
if (this.widgets.layer_image.value) {
this.layer = await load(this.imageUrl(this.widgets.layer_image.value));
await this.makeBlend(this.widgets.blend_data.value);
if (this.mode === "crop") this.activate("blend");
// Restore the state the user left: a non-empty blend mask means they were
// still adjusting the blend layer, otherwise the adopted layer is already
// baked and we stay on the repaint mask.
if (this.widgets.blend_data.value && this.mode === "crop") this.activate("blend");
}
}
this.revision++;
this.sync();
this.editor.schedule();
}
async accept(record, {allowBusy = false} = {}) {
async accept(record, {allowBusy = false, image = null} = {}) {
if (
this.editor.destroyed ||
this.mode !== "crop" ||
@@ -520,7 +536,7 @@ export class InlineCropController {
this.resultTimer = setTimeout(() => {
const next = this.pendingResult;
this.pendingResult = null;
this.accept(next).catch((error) =>
this.accept(next, {allowBusy}).catch((error) =>
this.editor.status(error.message, true),
);
}, 50);
@@ -531,7 +547,9 @@ export class InlineCropController {
: record.image.filename;
if (name === this.widgets.layer_image.value) return;
const version = ++this.acceptVersion;
const image = await load(this.imageUrl(name));
// The adoption path already decoded this image once; reuse it instead of
// downloading and decoding the full-size result a second time.
const decoded = image || await load(this.imageUrl(name));
if (
this.editor.destroyed ||
version !== this.acceptVersion ||
@@ -539,11 +557,13 @@ export class InlineCropController {
this.mode !== "crop"
)
return;
this.layer = image;
this.layer = decoded;
this.widgets.layer_image.value = name;
if (!this.contexts.blend && this.target !== "blend")
await this.makeBlend(this.widgets.blend_data.value);
this.activate("blend");
// Adopt then return: with an empty blend mask the backend hard-composites
// the whole crop from layer_image, so the result is immediately baked into
// the source and the repaint mask is reused on the next generation. Blending
// is no longer entered automatically; the ◐ button opens it on demand.
this.activate("mask");
this.revision++;
this.sync();
this.editor.schedule();
+25 -12
View File
@@ -1,18 +1,19 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js?v=0.6.7";
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.6.1";
import { InlineMaskEditor } from "./editor.js?v=0.9.0";
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.8.2";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { InlineCropController } from "./inline_crop.js?v=0.6.9";
import { InlineCropController } from "./inline_crop.js?v=0.8.2";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=0.6.3";
import { T } from "./i18n.js";
import { removeLegacyFeedback } from "./legacy_feedback.js?v=0.6.4";
import { uploadImageWithProgress } from "./upload_transport.js?v=0.9.0";
const styleId = "adachi-inpaint-style";
const stylesheet = document.getElementById(styleId) || document.createElement("link");
stylesheet.id = styleId; stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./editor.css?v=0.6.2", import.meta.url).href;
stylesheet.href = new URL("./editor.css?v=0.9.0", import.meta.url).href;
if (!stylesheet.isConnected) document.head.append(stylesheet);
function hide(widget) {
@@ -131,28 +132,40 @@ app.registerExtension({
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, { preserveMask = false, preserveCrop = false } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the source image to finish loading before adopting a result"));
async onUpload(file, { preserveMask = false, preserveCrop = false, onProgress = null } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the current image to finish loading"));
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(`${T("Upload failed")} (${response.status})`);
const uploaded = await response.json();
let uploaded;
if (onProgress) uploaded = await uploadImageWithProgress(api, body, onProgress);
else {
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`${T("Upload failed")} (${response.status})`);
uploaded = await response.json();
}
if (typeof uploaded?.name !== "string" || !uploaded.name) throw new Error(T("Upload failed"));
if (version !== uploadVersion || editor.destroyed) return;
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
restoring = false;
crop?.activate("mask");
if (preserveMask && editor.ready) await editor.replaceSource(imageUrl(name), file.name);
else await editor.setImage(imageUrl(name), "", file.name);
let kept = false;
if (preserveMask && editor.ready) {
kept = await editor.replaceSource(imageUrl(name), file.name);
if (version !== uploadVersion || editor.destroyed) return;
// A different-sized replacement cannot reuse the old mask.
// Actually load it as a fresh image before clearing state.
if (!kept) await editor.setImage(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() : "";
maskWidget.value = kept ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
if (!preserveCrop) crop?.reset();
await beforeModeChange();
if (preserveMask && !kept) editor.notice(T("The new image has a different size, so the mask was reset"));
markChanged();
},
onEmbed(enabled) {
+13 -2
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { InpaintParameterPanel } from "./inpaint_panel.js?v=0.6.1";
import { connectedEditor, connectedSamplers, relocateWidgets } from "./parameter_bridge.js";
import { connectedEditor, connectedSamplers, relocateWidgets, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
@@ -83,7 +83,18 @@ export function installInpaintWorkbench(nodeType) {
const configured = node.onConfigure;
node.onConfigure = function () { const result = configured?.apply(this, arguments); fold.open = !!node.properties.adachi_tools_open; 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; };
node.onConnectionsChange = function (type, slot, connected) {
const result = connections?.apply(this, arguments);
// Graph restoration also connects wires: never overwrite the sizes
// saved in a workflow while it is being configured.
const userImageConnection = !app.configuringGraph && type === 1 && connected && node.inputs?.[slot]?.name === "image";
queueMicrotask(() => {
if (removed) return;
panel.refresh();
if (userImageConnection) syncCurrentDimensions(connectedEditor(node), node);
});
return result;
};
const previousRemoved = node.onRemoved;
node.onRemoved = function () {
removed = true; cancelAnimationFrame(frame); observer.disconnect();
+12 -1
View File
@@ -36,9 +36,10 @@ export function connectedSamplers(source) {
}
// Use the same rounding, range and linked-input rules as Use source size.
export function syncCropDimensions(source, width, height) {
export function syncCropDimensions(source, width, height, onlyTarget = null) {
const aligned = value => Math.max(64, Math.min(8192, Math.round(value / 8) * 8));
for (const target of connectedSamplers(source)) {
if (onlyTarget && target !== onlyTarget) continue;
target.adachiParameters?.refresh();
for (const [name, value] of [["width", width], ["height", height]]) {
if (target.inputs?.some(input => input.name === name && input.link != null)) continue;
@@ -52,6 +53,16 @@ export function syncCropDimensions(source, width, height) {
}
}
// The crop output does not have dimensions until the user locks a region.
export function syncCurrentDimensions(source, onlyTarget = null) {
const editor = source?.adachiEditor, crop = source?.adachiCrop;
if (!editor?.ready || !editor.image) return;
const region = crop?.mode === "crop" ? crop.region : null;
if (crop?.mode === "crop" && !region?.locked) return;
const image = region || editor.image;
syncCropDimensions(source, image.naturalWidth || image.width, image.naturalHeight || image.height, onlyTarget);
}
export function relocateWidgets(owner, previous, next) {
if (previous) {
for (const widget of previous.widgets || []) {
+5 -2
View File
@@ -40,9 +40,12 @@ export function installResultAdoption(node, crop) {
throw Error(T("The crop changed while adopting the result"));
}
const descriptor = {filename:uploaded.name, subfolder:uploaded.subfolder || "", type:"input"};
await crop.accept({job:region.job, image:descriptor}, {allowBusy:true});
await crop.accept({job:region.job, image:descriptor}, {allowBusy:true, image});
const name = descriptor.subfolder ? `${descriptor.subfolder}/${descriptor.filename}` : descriptor.filename;
if (crop.widgets.layer_image.value !== name) throw Error(T("The crop changed while adopting the result"));
if (crop.widgets.layer_image.value !== name) {
// accept() may have skipped silently (busy / changed job / duplicate).
throw Error(T("The result was not applied; the crop or layer state changed"));
}
editor.status("");
} finally {
URL.revokeObjectURL(objectUrl); crop.busy = false; crop.sync();
+31
View File
@@ -0,0 +1,31 @@
import { T } from "./i18n.js";
// XMLHttpRequest reports bytes sent, unlike fetch. Keep ComfyUI's API URL and
// per-user header; the browser automatically sends same-origin session cookies.
export async function uploadImageWithProgress(api, body, onProgress) {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", api.apiURL("/upload/image"));
if (api.user) xhr.setRequestHeader("Comfy-User", api.user);
xhr.upload.onprogress = event => onProgress?.({phase: "upload", loaded: event.loaded, total: event.lengthComputable ? event.total : 0});
xhr.upload.onload = () => onProgress?.({phase: "processing"});
xhr.onload = () => resolve(xhr);
xhr.onerror = () => reject(new Error(T("Network error while uploading image")));
xhr.onabort = () => reject(new Error(T("Upload was cancelled")));
xhr.send(body);
});
// Some cloud frontends wrap fetchApi with an authentication mechanism that
// is not exposed to XHR. Retry rejected authentication through that same
// frontend API, without inventing upload percentages for the fallback.
if (response.status === 401 || response.status === 403) {
onProgress?.({phase: "authenticated"});
const fallback = await api.fetchApi("/upload/image", {method: "POST", body});
if (!fallback.ok) throw new Error(`${T("Upload failed")} (${fallback.status})`);
onProgress?.({phase: "processing"});
return fallback.json();
}
if (response.status < 200 || response.status >= 300) throw new Error(`${T("Upload failed")} (${response.status})`);
try { return JSON.parse(response.responseText); }
catch { throw new Error(T("Invalid upload response")); }
}