Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3030531275 | ||
|
|
f76b5aa0fd | ||
|
|
175a063195 | ||
|
|
da4a26c982 | ||
|
|
a5bd9624b8 | ||
|
|
0f178a55f6 | ||
|
|
67d372e481 | ||
|
|
7f432d921c | ||
|
|
c406ef10d9 | ||
|
|
02b3ce2662 | ||
|
|
f5883dce8a | ||
|
|
5283ac99d0 | ||
|
|
aae35150c4 | ||
|
|
38fd6813f7 | ||
|
|
07ce762159 | ||
|
|
b668e9ace0 | ||
|
|
53bebffb83 | ||
|
|
24be45d996 | ||
|
|
9f208f61d7 | ||
|
|
f1dbaf0fb0 | ||
|
|
3c225ea242 | ||
|
|
8f128f5417 | ||
|
|
f822bb3382 | ||
|
|
51e8a94f14 | ||
|
|
af3de6eafb |
@@ -36,3 +36,10 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Check crop geometry
|
||||
run: |
|
||||
node --experimental-default-type=module tests/test_crop_geometry.mjs
|
||||
node --experimental-default-type=module tests/test_crop_dimensions.mjs
|
||||
node --experimental-default-type=module tests/test_legacy_feedback.mjs
|
||||
node --experimental-default-type=module tests/test_draft_guard.mjs
|
||||
|
||||
+122
@@ -1,5 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
## 0.6.8
|
||||
|
||||
- Add a Clear button to the bottom-left canvas bar: one click empties the mask of the active target, in both Mask and Crop & Blend modes, and it stays disabled until an image is loaded.
|
||||
- Move the selected-region painting help onto the repaint mask button, and remove hover text from the painting canvas entirely: hovering the image no longer shows any hint in any mode. The bottom controls and their button tooltips keep the instructions.
|
||||
- Remove the now-unused canvas and repaint mask hint entries from the locale dictionary.
|
||||
- Bump the inline crop module's asset version so the updated file is fetched instead of a cached copy.
|
||||
|
||||
## 0.6.7
|
||||
|
||||
- Before merging an adopted crop layer, hold the right mouse button to preview the original; release, pointer cancellation or window blur restores the workspace. Preview changes display only. After merge, right-click again releases the crop selection.
|
||||
- Add English/Chinese hover instructions for bottom controls and state-specific canvas gestures, including size/hardness shortcuts, masks, adoption, merging and Undo.
|
||||
|
||||
## 0.6.6
|
||||
|
||||
- Right-click releases a locked crop without painting; the next plain click selects a new region. Adopted layer effects are merged before reselection. Right-click no longer paints or erases in either mode; Alt remains the temporary eraser.
|
||||
- Remove the redundant reselect button. Move the existing mask Undo button from the inpaint controls to the inline toolbar, immediately after Merge, using a return-arrow icon. Undo also remains accessible in ordinary mask mode.
|
||||
|
||||
## 0.6.5
|
||||
|
||||
- Keep crop size presets, width/height swap and custom dimensions available after locking a region. Changing dimensions automatically unlocks it for a new selection.
|
||||
- Merge an adopted layer before changing the region, and retain the old selection if merging fails.
|
||||
|
||||
## 0.6.4
|
||||
|
||||
- Use a pure black dashed crop outline and automatically synchronize connected inpaint resolution when locking a crop. Linked resolution inputs remain under their upstream node's control.
|
||||
- Remove the Inline Mask result socket and obsolete feedback connections. Both modes now receive results exclusively through Image Compare adoption.
|
||||
- Crop adoption keeps the full source, crop location and repaint mask; the accepted patch remains a separate editable blend layer. Generation alone does not update it. Tag comparison previews with their originating crop to reject stale adoption.
|
||||
|
||||
## 0.6.3
|
||||
|
||||
- Check oversized embedded sources before workflow configuration, so the first autosave cannot disable browser draft storage before the mode-change guard runs. Upload exact source bytes before changing the file reference, including sources in subgraph definitions.
|
||||
- Keep the array and optional named widget snapshots synchronized when serializing Inline Mask; stale embedded copies no longer survive in the named snapshot. Keep repaint masks, fusion masks and layer references unchanged.
|
||||
- Apply the source-capacity check after uploading/replacing an embedded image too. Respect disabled browser persistence and retain embedded data if upload fails.
|
||||
- A page that already encountered the native draft quota error needs one reload to reset ComfyUI's disabled-storage flag: save the workflow with Ctrl+S first, then use Ctrl+F5. No browser drafts are cleared by the plugin.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
- Remove magic wand and quick selection, including their toolbar menu and background workers.
|
||||
- Locking a crop dims the outside and starts an empty repaint mask, ready for manual painting. Brush, shape and mask actions stay inside the locked crop.
|
||||
- Reduce presets to six sizes with 64-aligned dimensions from 512 through 2048; retain the width/height swap button. Custom sizing steps by 64 pixels.
|
||||
|
||||
## 0.6.1
|
||||
|
||||
- Add a collapsed painting-tools/mask-settings section in WebUI Inpaint and Inpaint Prepare. Keep the complete Undo/Redo/Invert/Fill/Clear/Import/Export row visible, and save the fold state in the workflow.
|
||||
- Reproduce browser draft quota failures with a large embedded source. Before a mode change or enabling embedding, probe available draft capacity using a temporary plugin-owned key; credit the existing current draft and preserve all other browser storage.
|
||||
- If the embedded source does not fit, upload an exact PNG copy first, then use the input-file reference without reloading pixels or clearing repaint masks, fusion masks, layers or undo commands. A failed upload retains the original embedded data and mode. Small embeddings remain embedded.
|
||||
- Share in-flight recovery across embedding/mode changes, respect disabled ComfyUI browser persistence, and keep the recovery notice above the crop toolbar.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
- Merge rectangular cropping and two-layer blending into Inline Mask, with a compact mode selector; ordinary mask mode ignores the result input.
|
||||
- Add 32-aligned crop presets, pointer-following selection, fixed-region repeat generation, exact full-image stitching and a third full_image output. Feedback wiring is split into an acyclic execution graph before validation.
|
||||
- Replace the proposed crop version history with original/result layers and a separate editable fusion mask. Brush reveals the new layer; Alt erases back to the original. Reselecting merges visible layers first.
|
||||
- Add a compact selection-tool menu: contiguous magic wand (W), brush-guided color quick selection (Q), tolerance and selection brush size. Alt subtracts, and both tools support undo/redo in either mode. Selection calculations run in a Web Worker.
|
||||
- Use arrow/fit/pin icons, Ctrl+wheel for size and Shift+wheel for hardness. Use source size follows the locked crop in crop mode.
|
||||
- Retain existing image/mask output indices, old workflows, comparison adoption and mask preservation. Restart ComfyUI and hard-refresh after updating.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,222 +1,262 @@
|
||||
<div align="center">
|
||||
|
||||
# Adachi-Inpaint-Workbench
|
||||
|
||||
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.
|
||||
**Paint. Inpaint. Compare. Keep going.**
|
||||
|
||||

|
||||
An inline inpainting workspace for ComfyUI, with WebUI-style controls and tools for switching workflow branches.
|
||||
|
||||
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.
|
||||
**English** · [简体中文](README.zh-CN.md)
|
||||
|
||||
## How this differs from other nodes
|
||||
[](LICENSE)
|
||||
[](https://github.com/Comfy-Org/ComfyUI)
|
||||
[](pyproject.toml)
|
||||
[](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml)
|
||||
|
||||
| | 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) |
|
||||
[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Node reference](#node-reference) · [Examples](#example-workflows)
|
||||
|
||||
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
|
||||
*The original workbench preview. Earlier toolbar screenshots are retained below; v0.6 uses arrow / fit / pin icons.*
|
||||
|
||||
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.
|
||||
</div>
|
||||
|
||||
On Windows you can instead run the installer from the unzipped plugin folder, pointing it at your ComfyUI directory (the one containing `main.py`).
|
||||
## Why this workbench?
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_windows_portable\ComfyUI"
|
||||
```
|
||||
Inpainting usually means moving between the workflow, a mask editor and the generated result. This pack keeps that loop on the graph: paint directly on a node, generate, compare, then adopt a result as your next source **without losing the painted mask**.
|
||||
|
||||
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`.
|
||||
| Tool | What it adds |
|
||||
| --- | --- |
|
||||
| **Inline Mask** | Paint masks or crop, inpaint and blend two layers in the same node. |
|
||||
| **WebUI Inpaint** | Whole-image or masked-area sampling, familiar inpainting controls and compositing back at the source resolution. |
|
||||
| **Image Compare** | Hover comparison, result history and one-click adoption for another editing round. |
|
||||
| **Group Bypass Presets** | Save combinations of enabled/bypassed groups and apply them with one click. |
|
||||
| **Auto Exclusive Switch** | Route the only enabled branch to a shared output, without a manual switch widget. |
|
||||
|
||||
### ComfyUI Manager and Comfy Registry
|
||||
English and Simplified Chinese interfaces follow the ComfyUI language setting. The plugin uses the PyTorch, NumPy and Pillow already available in ComfyUI; no extra frontend build is required.
|
||||
|
||||
Manager **Git URL** installation can use `https://github.com/adachi0130/Adachi-Inpaint-Workbench`, or clone it into `ComfyUI/custom_nodes`:
|
||||
## Features
|
||||
|
||||
### 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.
|
||||
|
||||
- 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.
|
||||
- Resize the editor horizontally or vertically. The pin state and node dimensions survive workflow reload.
|
||||
- The connected **WebUI Inpaint** node hosts the painting controls and sampling parameters, leaving more space for the picture. Its painting tools, mask mode and fill content fold behind an arrow; **Undo / Redo / Invert / Fill / Clear / Import / Export** remain visible. Fold state is saved with the workflow.
|
||||
- Bounded display buffers and cached image/mask compositing reduce redraw cost at large graph zoom; the saved mask retains its source resolution.
|
||||
|
||||
<details>
|
||||
<summary>View the compact toolbar</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="preview/inline-mask-toolbar.png" alt="Inline Mask with Upload, Fit and the active Keep pin" width="300">
|
||||
</p>
|
||||
|
||||
*Toolbar reference supplied by the author; the source preview is unloaded in this screenshot.*
|
||||
|
||||
</details>
|
||||
|
||||
### Crop and blend in the same node
|
||||
|
||||
Choose **Mask** or **Crop & Blend** at the upper right of Inline Mask. Both modes receive images through **Adopt this result** in Image Compare; Inline Mask has no result input socket.
|
||||
|
||||
In Crop & Blend mode, choose one of six 64-aligned presets spanning 512–2048 pixels per side, including 1024 × 1536 (or custom dimensions in multiples of 64). The dashed rectangle follows the pointer by its upper-left corner; left-click locks it. The outside is darkened. A new region starts with an empty repaint mask. Paint inside the rectangle; brush and shape operations stay within its bounds. Use **◌ repaint mask** to return to repaint-mask editing. The position stays fixed for repeated passes. Size presets remain editable while locked; changing the preset, orientation or custom dimensions cancels the current selection and returns to pointer placement, merging any adopted layer first. The outline is a pure black dashed rectangle, and locking it automatically fills the connected Inpaint node's width and height from the crop, using the same rules as Use source size.
|
||||
|
||||
| Connection | Purpose |
|
||||
| --- | --- |
|
||||
| `Inline Mask.image` + `mask` → `WebUI Inpaint.image` + `mask` | Send the selected crop and its repaint mask. |
|
||||
| `Inline Mask.image` → `Image Compare.original`; `WebUI Inpaint.image` → `Image Compare.result` | Compare the crop before adopting it. |
|
||||
| `Inline Mask.full_image` → `Save Image.images` | Save the current full image with the **adopted** layer stitched in. |
|
||||
|
||||
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.
|
||||
|
||||
### Inpaint the whole image or just the masked region
|
||||
|
||||
| Sampling area | Behaviour |
|
||||
| --- | --- |
|
||||
| **Whole image** · `whole_image` | Resize the full source to the sampling canvas, generate, then composite through the mask. |
|
||||
| **Only masked** · `only_masked` | Crop the mask bounds with padding and aspect-ratio context, generate at the sampling size, then paste the region back into place. |
|
||||
|
||||
Both modes return a final `image` at the **source image's resolution**. Sampling `width` and `height` are independent controls; **Use source size** fills them from the uploaded image, rounded to the sampler's required multiple of 8.
|
||||
|
||||
The panel includes mask inside/outside, original/fill/noise/zero-latent content, denoise, sampler, scheduler, steps, CFG and seed. Advanced controls cover blur, growth, extra feathering, batch size and optional Refiner staging.
|
||||
|
||||
### Compare, adopt and refine
|
||||
|
||||
Move the pointer over **Image Compare**: **result on the left, original on the right**. Leave the picture to see the full result. The divider stays thin, the image stays fitted, and scrolling navigates the workflow background.
|
||||
|
||||
Results accumulate within an editing round, with the newest selected automatically. Each result retains its matching original; replayed preview notifications are not counted twice.
|
||||
|
||||
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
|
||||
|
||||
Save group states for your own setups: text-to-image, inpainting, model variants, ControlNet, upscaling, or any other grouped branches.
|
||||
|
||||

|
||||
|
||||
Choose a preset on the left, configure **Enabled / Bypassed / Keep current** on the right, or apply it from the node's right-click menu. Presets are stored in the workflow and need no wires or third-party group controller. Missing groups are reported; overlapping groups give the smaller group's setting priority.
|
||||
|
||||
### Automatically use the remaining branch
|
||||
|
||||
Connect two alternatives to **Auto Exclusive Switch** and bypass one. The remaining enabled branch flows to the shared output, with no `switch` toggle to maintain.
|
||||
|
||||

|
||||
|
||||
Use it for conditioning, strings, images, masks, models or latents; the two alternatives must use the same data type. Both enabled, or neither available, is an error rather than a silent preference for A.
|
||||
|
||||
KJNodes Get/Set routes and reroutes are supported. **Both Gets may stay enabled**: bypassing a branch's corresponding Set or source disables that branch. Workflow-aware server checks also handle saved subgraph output routes when the source node exists in the submitted execution graph.
|
||||
|
||||
## Installation
|
||||
|
||||
### Git clone
|
||||
|
||||
Run from your `ComfyUI/custom_nodes` directory:
|
||||
|
||||
```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).
|
||||
**Restart ComfyUI completely, then hard-refresh the browser with `Ctrl+F5`** (`Cmd+Shift+R` on macOS). Search for `Inline Mask`, `WebUI Inpaint` or `Adachi` in the node menu.
|
||||
|
||||
### 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).
|
||||
- **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`:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_windows_portable\ComfyUI"
|
||||
```
|
||||
|
||||
The installer backs up an existing installation to `user/adachi-workbench-backups/`. It does not restart ComfyUI.
|
||||
|
||||
For a Git installation, update with `git pull` inside the plugin folder, then restart ComfyUI and refresh the browser. Keep only one copy of this node pack in `custom_nodes`.
|
||||
|
||||
## 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.
|
||||
1. Drag [01-inline-inpaint.json](examples/01-inline-inpaint.json) into ComfyUI and select a compatible checkpoint, or connect your existing model, VAE and conditioning nodes.
|
||||
2. In **Inline Mask**, upload a picture and paint the region to change. **White repaints; black preserves.** Orange is only the on-screen overlay.
|
||||
3. Edit the upstream positive/negative prompts. Set the sampling area, width, height and denoise in **WebUI Inpaint**, then queue the workflow.
|
||||
4. Compare the output. Adopt a result when you want it to become the next source, and continue editing with the mask intact.
|
||||
|
||||
**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.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Source[Inline Mask] -->|image + mask| Inpaint[WebUI Inpaint]
|
||||
Inputs[Model · VAE · Conditioning] --> Inpaint
|
||||
Source -->|original| Compare[Image Compare]
|
||||
Inpaint -->|result| Compare
|
||||
Inpaint --> Save[Save Image]
|
||||
Compare -. Adopt this result .-> Source
|
||||
```
|
||||
|
||||
## The two sampling areas
|
||||
### Essential connections
|
||||
|
||||
| `area` | What it does | Behaviour |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| From | To |
|
||||
| --- | --- |
|
||||
| `Inline Mask.image` / `mask` | `WebUI Inpaint.image` / `mask` |
|
||||
| Your model / VAE / positive / negative conditioning | The matching inputs of `WebUI Inpaint` |
|
||||
| `Inline Mask.image` | `Image Compare.original` |
|
||||
| `WebUI Inpaint.image` | `Image Compare.result` and/or `Save Image.images` |
|
||||
|
||||
`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.
|
||||
The Refiner inputs are optional. Leave them disconnected unless you are using a compatible second-stage model.
|
||||
|
||||
## Canvas controls
|
||||
## Node reference
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| 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 |
|
||||
All nodes live under **Adachi-Inpaint-Workbench**. The group preset controller is frontend-only; the other six are user-facing backend nodes.
|
||||
|
||||
| Node | Purpose | Outputs |
|
||||
| --- | --- | --- |
|
||||
| **Inline Mask** | Mask painting or crop/inpaint/layer blending | `image`, `mask`, `full_image` |
|
||||
| **WebUI Inpaint** | Prepare, sample and composite in one node | `image` = full composite; `crop` = sampling canvas; `mask` = sampling mask |
|
||||
| **Image Compare** | Preview, compare and adopt results | `image` = unchanged result batch |
|
||||
| **Group Bypass Presets** | Apply saved group states | No connections needed |
|
||||
| **Auto Exclusive Switch** | Forward exactly one enabled alternative | `output` of the selected type |
|
||||
| **Inpaint Prepare** | Crop/resize pixels and mask for a custom sampler workflow | `pixels`, `mask`, `context` |
|
||||
| **Inpaint Composite** | Return a decoded local result to source coordinates | `image` |
|
||||
|
||||
For modular sampling: `Prepare.pixels → VAE Encode → Set Latent Noise Mask → sampler → VAE Decode → Composite`. Connect `Prepare.mask` to the latent mask node and **always connect `Prepare.context` to `Composite.context`**. See the [modular example](examples/02-modular-inpaint.json).
|
||||
|
||||
## Canvas shortcuts
|
||||
|
||||
| Action | Shortcut / control |
|
||||
| --- | --- |
|
||||
| Brush / eraser / rectangle / lasso | `B` / `E` / `R` / `L` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Adjust editor height | Drag the bottom `↕` handle |
|
||||
|
||||
Brush pressure and soft brushes are supported; the mouse uses a fixed pressure.
|
||||
Inline Mask keeps only the image editing area and small **Load image** / **Fit** 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.
|
||||
The painting panel also provides invert, fill, clear, PNG mask import/export, image/mask/overlay views and **Embed source in workflow**. Overlay opacity affects display only. Scrolling over buttons and parameter controls navigates the outer workflow; a gesture starting on the workflow keeps navigating it when the pointer crosses the picture.
|
||||
|
||||
## Painting tools and inpainting parameters
|
||||
## Example workflows
|
||||
|
||||
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.
|
||||
Drag a JSON file into ComfyUI:
|
||||
|
||||
## Comparison and chained inpainting
|
||||
| Example | What to try |
|
||||
| --- | --- |
|
||||
| [01 · Inline inpainting](examples/01-inline-inpaint.json) | The complete painting and inpainting loop; select your own compatible checkpoint. |
|
||||
| [02 · Modular inpainting](examples/02-modular-inpaint.json) | Use standard encode/sample/decode nodes with Prepare and Composite. |
|
||||
| [03 · Group presets](examples/03-group-presets.json) | Model-free preset switching with image branches. |
|
||||
| [04 · Exclusive switch](examples/04-exclusive-switch.json) | Model-free A/B selection controlled by group presets. |
|
||||
| [05 · Inline crop and blend](examples/05-inline-crop-blend.json) | One editor, crop presets, comparison adoption and accepted full-image output; select your checkpoint and upload a source. |
|
||||
|
||||
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 **Load image** 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, including when adopting results for further inpainting. Saving the workflow also saves this history's preview references and selection.
|
||||
## Compatibility and important behaviour
|
||||
|
||||
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.
|
||||
- **Static images:** ordinary 4D `[B,C,H,W]` latents and single-frame 5D `[B,C,1,H,W]` image latents are supported, including the Wan-derived VAE path used by Anima. Supply the model family's matching VAE and conditioning. This is not multi-frame video inpainting.
|
||||
- **Anima:** for ordinary masked img2img, leave **inpaint_model** disabled. That option is for dedicated inpainting architectures, not a requirement for painting a mask.
|
||||
- **Source size:** the final composite keeps the source resolution in both sampling areas. Use `crop` or a separate resize node for a target-size output.
|
||||
- **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/`.
|
||||
- **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.
|
||||
|
||||
## Group bypass presets
|
||||
<details>
|
||||
<summary>Sampling, fill and batch details</summary>
|
||||
|
||||
Search for **Group Bypass Presets** (Chinese: **组忽略预设**) under Adachi-Inpaint-Workbench. It needs no connections and does not require rgthree or Danbooru.
|
||||
- Sampling dimensions use multiples of 8. Padding, blur and mask growth are measured in source pixels; effective mask edges can change after blur/growth, while pixels with zero effective mask remain unchanged.
|
||||
- `mask_mode=outside` inverts the painted region before mask processing.
|
||||
- `resize_mode` controls whole-image resizing. Masked-area mode uses an aspect-preserving transform that retains the full mask, including at source edges.
|
||||
- Fill modes: **original**, surrounding-colour **fill**, **latent noise**, and **zero latent**. The latent modes belong to WebUI Inpaint; the modular Prepare node handles pixels and masks.
|
||||
- An empty mask or `denoise=0` skips VAE/sampling. `batch_size` is variants per input image, not ComfyUI queue count.
|
||||
- For modular multi-source batches, group variants per source (`A,A,B,B`), rather than interleaving them (`A,B,A,B`).
|
||||
- Optional Refiner uses `refiner_model` and `refiner_switch`; `0.8` runs the last 20% with the Refiner. Compatible Refiner conditioning is recommended. A switch of `1` uses only the base model; `0` uses only the Refiner.
|
||||
- Input limits: 64 MiB per file, 32 megapixels and 16384 pixels per image edge. Large-image painting and PNG encoding still depend on browser memory and hardware.
|
||||
|
||||
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.
|
||||
</details>
|
||||
|
||||
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.
|
||||
## Troubleshooting and development
|
||||
|
||||
## Fill, batch and Refiner
|
||||
**Old UI after updating?** Fully restart ComfyUI and hard-refresh the browser. Backend Python changes need a server restart; refreshing the page alone is insufficient.
|
||||
|
||||
| `fill` | Behaviour |
|
||||
|---|---|
|
||||
| `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 |
|
||||
**Exclusive switch reports no enabled input?** Check the real source and corresponding Set, not only the visible Get nodes. Keep the selector outside the groups it controls. If an integration still drops a subgraph output, placing the CLIP encoder outside that subgraph is a practical workaround. For a report, include the workflow, plugin/frontend versions and full error text; current errors show frontend, workflow and submitted-input flags.
|
||||
|
||||
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`.
|
||||
**An image is missing after opening someone else's workflow?** Supply the source at its relative input path, or have the sender save with source embedding enabled. Expired comparison previews require regeneration.
|
||||
|
||||
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.
|
||||
CPU regression tests:
|
||||
|
||||
## Auto Exclusive Switch
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
pixels ──> VAE Encode ──> Set Latent Noise Mask ──> KSampler
|
||||
mask ──────────────────────────┘ │
|
||||
context ──────────────────────────────────────┐ │
|
||||
│ ▼
|
||||
Inpaint Composite <── VAE Decode
|
||||
│
|
||||
Save Image
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Tests
|
||||
|
||||
Run the CPU tests from this plugin directory with your ComfyUI Python:
|
||||
|
||||
```powershell
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
node --experimental-default-type=module tests/test_exclusive_routes.mjs
|
||||
node --experimental-default-type=module tests/test_compare_history.mjs
|
||||
```
|
||||
|
||||
## 中文简介
|
||||
See [verification notes](VERIFICATION.md) for tested configurations and limits, [changelog](CHANGELOG.md) for updates, and [rollback instructions](ROLLBACK.md) for returning to an earlier version. Model-family and image-quality coverage is not exhaustive.
|
||||
|
||||
本插件把蒙版绘制放在工作流节点内部,**不需要弹出遮罩编辑器**,也省去每画一笔就保存、加载磁盘文件的往返。
|
||||
连接 **WebUI 局部重绘** 节点后,画笔工具与采样参数都在该节点内,支持整图范围与蒙版范围重绘、原尺寸回贴、Alt 临时橡皮擦、Ctrl+滚轮笔径、图像对比,以及把满意的结果一键回填为新的原图继续重绘。
|
||||
代码里的默认文案是英文,以便 Manager 用户直接使用;节点搜索可输入 `Inline Mask`,分类为 **Adachi-Inpaint-Workbench**。
|
||||
## Credits and license
|
||||
|
||||
想用中文界面时,把 ComfyUI 的语言切成中文即可,插件会自动跟随:
|
||||
在 ComfyUI 设置里找到 **Locale / 语言**(设置项 `Comfy.Locale`)选择 `zh`,然后刷新浏览器(`Ctrl+F5`)。
|
||||
节点名称、分类、输入输出名与提示由 `locales/zh/` 提供,通过 ComfyUI 的 `/i18n` 机制加载;
|
||||
画布按钮与参数面板由 `web/i18n.js` 读取同一个语言设置翻译,因此「宽度」「高度」「载入原图」等也会一起变回中文。
|
||||
英文用户看到的一直是英文(英文是内置默认值);某个词条若没有对应翻译,会自动回退为英文。
|
||||
两套语言共用同一个节点类 ID 和参数键名,所以已保存的工作流不受语言切换影响。
|
||||
Created by **Adachi / 安达**. Licensed under [MIT](LICENSE).
|
||||
|
||||
新增「自动二选一」:把两个正面条件(或图像、模型等同类型数据)接到输入 A/B,输出接共同下游;忽略其中一个分支,就自动使用另一个。两路都启用或都无输入会报错,不需要 switch。切换节点放在两个被控制分组之外,也能配合组忽略预设使用。
|
||||
Built for [ComfyUI](https://github.com/Comfy-Org/ComfyUI), with familiar inpainting controls inspired by WebUI and hover-comparison interaction inspired by [rgthree-comfy](https://github.com/rgthree/rgthree-comfy). This repository contains its own implementation; those projects are not bundled dependencies.
|
||||
|
||||
|
||||
Anima 兼容:支持其 Wan/Qwen VAE 的单帧五维潜空间,也保留普通四维 VAE;会同步适配蒙版、填充、批次和解码回贴。普通 Anima 图生图请保持“专用重绘模型”关闭,并使用配套模型、VAE 与文本条件;这不是多帧视频重绘功能。
|
||||
Bug reports and workflow examples are welcome in [Issues](https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues).
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
<div align="center">
|
||||
|
||||
# Adachi-Inpaint-Workbench
|
||||
|
||||
**绘制蒙版,局部重绘,对比结果,继续精修。**
|
||||
|
||||
直接在 ComfyUI 工作流内操作的重绘工作台,提供 WebUI 风格参数,以及分组预设、自动分支选择工具。
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/Comfy-Org/ComfyUI)
|
||||
[](pyproject.toml)
|
||||
[](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml)
|
||||
|
||||
[功能介绍](#功能介绍) · [安装](#安装) · [快速开始](#快速开始) · [节点一览](#节点一览) · [示例工作流](#示例工作流)
|
||||
|
||||

|
||||
|
||||
*保留工作台早期预览图;下方保留旧工具栏截图;v0.6 使用上传箭头、适应、图钉。*
|
||||
|
||||
</div>
|
||||
|
||||
## 为什么做这个工作台?
|
||||
|
||||
局部重绘时,常常需要在工作流、蒙版编辑器和生成结果之间来回切换。本插件把这段操作放回画布:直接在节点内涂蒙版,生成并对比结果,再把满意的图片作为下一轮原图,**保留已经画好的蒙版**继续精修。
|
||||
|
||||
| 工具 | 用途 |
|
||||
| --- | --- |
|
||||
| **内联蒙版 · Inline Mask** | 同一节点内涂蒙版,或裁切、重绘、双图层融合。 |
|
||||
| **WebUI 局部重绘 · WebUI Inpaint** | 整图或蒙版区域采样,使用熟悉的重绘参数,按原图尺寸回贴结果。 |
|
||||
| **图像对比 · Image Compare** | 鼠标划线对比、查看结果历史,一键采用后继续重绘。 |
|
||||
| **组忽略预设 · Group Bypass Presets** | 保存各组的打开/忽略组合,一键切换工作流配置。 |
|
||||
| **自动二选一 · Auto Exclusive Switch** | 自动输出唯一启用的分支,不用手动设置 switch。 |
|
||||
|
||||
界面支持英文和简体中文,跟随 ComfyUI 的语言设置。使用 ComfyUI 已有的 PyTorch、NumPy 和 Pillow,无需额外构建前端。
|
||||
|
||||
## 功能介绍
|
||||
|
||||
### 直接在工作流里画蒙版
|
||||
|
||||
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
|
||||
|
||||
- 支持画笔软边、硬度、不透明度、数位笔压感、临时擦除和形状工具。
|
||||
- 顶部使用**↑ 上传、⛶ 适应、图钉**,右上角切换模式。图钉的悬停提示为「保持」:开启后图片持续适应并居中,普通滚轮控制工作流背景,`Ctrl` + 滚轮调整笔径,`Shift` + 滚轮调整硬度。
|
||||
- 支持横向、纵向调整编辑区大小;图钉状态和节点尺寸都能随工作流保存。
|
||||
- 连到 **WebUI 局部重绘**后,画笔工具和采样参数显示在重绘节点内,让图片编辑区保持简洁。画笔工具、蒙版内外和填充内容收进箭头折叠区,**撤销、重做、反转、全选、清空、导入蒙版、导出**整行始终显示;折叠状态随工作流保存。
|
||||
- 放大工作流时限制显示画布的分配尺寸,并缓存原图/蒙版合成,减少反复重绘;实际保存的蒙版仍保持原图分辨率。
|
||||
|
||||
<details>
|
||||
<summary>查看简洁工具栏</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="preview/inline-mask-toolbar.png" alt="内联蒙版的上传、适应和已开启的保持图钉" width="300">
|
||||
</p>
|
||||
|
||||
*作者提供的工具栏示意图;截图中的原图预览尚未载入。*
|
||||
|
||||
</details>
|
||||
|
||||
### 一个节点内裁切、重绘和融合
|
||||
|
||||
在内联蒙版右上角选择**蒙版**或**裁切融合**。两种模式都通过图像对比节点的**采用此结果**回传图片;内联蒙版不再提供结果输入口。
|
||||
|
||||
裁切融合模式保留六组常用预设,宽高均为 64 的倍数,范围 512~2048(含两端);可用交换按钮切换横竖方向,自定义尺寸按 64 像素递增。虚线框的左上角跟随鼠标,左键点击锁定,框外压暗。新选区的蒙版为空,需要自己在框内涂抹;画笔和形状操作限制在选区内,点击 **◌ 重绘蒙版**可返回编辑重绘范围。选区框为纯黑虚线;点击锁定时,相连 Inpaint 节点会自动填写裁片宽高,等同于点一次「使用原图宽高」。选区位置固定,支持反复重绘同一处。选定后仍可切换预设、交换横竖或修改自定义宽高;修改会自动取消当前选区,重新跟随鼠标定位,已有采用图层会先合并保留。
|
||||
|
||||
| 连线 | 用途 |
|
||||
| --- | --- |
|
||||
| `内联蒙版.image`、`mask` → `WebUI 局部重绘.image`、`mask` | 把裁片和重绘蒙版送去生成。 |
|
||||
| `内联蒙版.image` → `图像对比.original`;`WebUI 局部重绘.image` → `图像对比.result` | 先对比裁片,再选择采用。 |
|
||||
| `内联蒙版.full_image` → `保存图像.images` | 保存包含**已采用图层**的完整图像。 |
|
||||
|
||||
先生成,在**图像对比**中查看,满意后点击**采用此结果**,才会嵌回当前裁切位置;没有回接输入线,也不会生成后自动替换。比较结果使用重绘节点的 **image** 输出;`crop` 是采样画布,尺寸可能不同。[已连好的示例](examples/05-inline-crop-blend.json)。
|
||||
|
||||
采用后自动进入 **◐ 图层融合**:底层原图、上层重绘结果,画笔保留新图,按住 **Alt** 擦回原图,软笔刷可融合接缝。融合蒙版与重绘蒙版相互独立,同一区域继续生成会保留融合蒙版。合并前**按住右键看原图,松开恢复工作区**,不改动蒙版或选区。**✓** 合并当前可见图层;合并后在图片内右键取消选定,再左键选择新区域。**↶ 撤销**位于 ✓ 右边,可撤销当前重绘蒙版或融合蒙版的涂抹。鼠标悬停底部控件可查看操作说明,画布提示也会跟随当前编辑阶段变化。没有裁切版本列表。批量生成时,在图像对比中选中想要的结果再采用;换到其他选区后,带有旧选区标记的结果会拒绝回贴,避免贴错位置。
|
||||
|
||||
### 整图重绘与蒙版区域重绘
|
||||
|
||||
| 采样区域 | 工作方式 |
|
||||
| --- | --- |
|
||||
| **整图范围** · `whole_image` | 将整张原图调整到采样尺寸,生成后通过蒙版合成回原图。 |
|
||||
| **仅蒙版区域** · `only_masked` | 按蒙版边界截取区域,加入 padding 和宽高比所需的上下文,采样后回贴到原位置。 |
|
||||
|
||||
两种模式最终输出的 `image` 都保持**原图尺寸**。采样宽、高独立设置;**使用原图宽高**按钮可一键填写,并按采样器要求对齐到 8 的倍数。
|
||||
|
||||
面板提供蒙版内/外、原图像素/填充/潜空间噪声/空白潜空间、重绘幅度、采样器、调度器、步数、CFG 和种子。高级设置包括蒙版模糊、扩张、额外羽化、批次数量和可选 Refiner。
|
||||
|
||||
### 对比结果,采用后继续精修
|
||||
|
||||
鼠标移入**图像对比**:**左边是重绘结果,右边是原图**;移开后显示完整结果。分隔线保持细线,图片持续适应编辑区,滚轮作用于工作流背景。
|
||||
|
||||
同一轮编辑中的结果会累计保存并默认选中最新一张,每张结果对应当时的原图;重复的预览通知不会重复计数。
|
||||
|
||||
点击**采用此结果**,把选中的图片回填为内联蒙版的新原图,同时保留蒙版和当前会话中的撤销/重做记录。结果列表立即回到 **1/1**,下一次生成开启新一轮。采用失败时保留原列表;采用操作不会自动开始生成。
|
||||
|
||||
### 一键切换整套分组配置
|
||||
|
||||
为自己的工作流保存多套组合,例如文生图、修图、不同模型、ControlNet、放大等分支的打开/忽略状态。
|
||||
|
||||

|
||||
|
||||
左侧点选预设,右侧配置各组**打开 / 忽略 / 保持当前**,也能通过节点右键菜单应用。预设随工作流保存,不需要连线,也不依赖第三方组控制节点。已删除的组会显示提示;分组重叠时,较小组的设置优先。
|
||||
|
||||
### 自动使用剩下的一路输入
|
||||
|
||||
将两路备选输入连接到**自动二选一**,忽略其中一路后,另一条有效分支自动流向共同输出,无需手动切换 `switch`。
|
||||
|
||||

|
||||
|
||||
可用于提示词条件、字符串、图像、蒙版、模型、潜空间等数据;两路输入应为同一类型。两路都启用或都不可用时会报错,不会擅自优先选择 A。
|
||||
|
||||
支持 KJNodes 的 Get/Set 和重路由节点。**两个 Get 可以一直开启**:忽略其中一路对应的 Set 或上游源节点,就会停用那条分支。请求带有工作流信息时,后端也会核对已保存的子图输出;执行请求中仍存在相应源节点时,可恢复丢失的有效连接。
|
||||
|
||||
## 安装
|
||||
|
||||
### Git 安装
|
||||
|
||||
在 `ComfyUI/custom_nodes` 目录运行:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
|
||||
```
|
||||
|
||||
**完整重启 ComfyUI,再按 `Ctrl+F5` 强制刷新网页**,macOS 可用 `Cmd+Shift+R`。在节点菜单搜索 `Inline Mask`、`WebUI Inpaint` 或 `Adachi`。
|
||||
|
||||
### 其他安装方式
|
||||
|
||||
- **ComfyUI Manager:**如果当前版本提供 Git URL 安装,可以填入本仓库地址。Git URL 安装与能否在 Manager 搜索索引中找到是两回事,收录说明见[发布文档](registry/README.md)。
|
||||
- **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 目录:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_windows_portable\ComfyUI"
|
||||
```
|
||||
|
||||
脚本会将已有安装备份到 `user/adachi-workbench-backups/`,不会自动重启 ComfyUI。
|
||||
|
||||
Git 安装用户可在插件目录运行 `git pull` 更新,然后重启 ComfyUI 并刷新网页。`custom_nodes` 中只保留一份本插件,避免重复加载。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 将 [01-inline-inpaint.json](examples/01-inline-inpaint.json) 拖入 ComfyUI,选择兼容的 checkpoint;也可以连接现有工作流的模型、VAE 和文本条件。
|
||||
2. 在**内联蒙版**上传图片,涂出需要修改的部分。**白色重绘、黑色保留**,橙色只是界面叠加显示。
|
||||
3. 编辑上游节点的正面/负面提示词,在 **WebUI 局部重绘**里设置采样区域、宽高和重绘幅度,运行工作流。
|
||||
4. 查看对比结果,满意时点击采用,将它作为新原图,保留蒙版继续修改。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Source[内联蒙版] -->|image + mask| Inpaint[WebUI 局部重绘]
|
||||
Inputs[模型 · VAE · 文本条件] --> Inpaint
|
||||
Source -->|original| Compare[图像对比]
|
||||
Inpaint -->|result| Compare
|
||||
Inpaint --> Save[保存图像]
|
||||
Compare -. 采用此结果 .-> Source
|
||||
```
|
||||
|
||||
### 必要连线
|
||||
|
||||
| 从哪里 | 连到哪里 |
|
||||
| --- | --- |
|
||||
| `内联蒙版.image` / `mask` | `WebUI 局部重绘.image` / `mask` |
|
||||
| 模型 / VAE / 正面条件 / 负面条件 | `WebUI 局部重绘`对应输入 |
|
||||
| `内联蒙版.image` | `图像对比.original` |
|
||||
| `WebUI 局部重绘.image` | `图像对比.result`,以及需要时的 `保存图像.images` |
|
||||
|
||||
Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接,否则留空即可。
|
||||
|
||||
## 节点一览
|
||||
|
||||
分类为 **Adachi-Inpaint-Workbench**。组忽略预设是前端控制节点,其余六个为用户使用的后端节点;
|
||||
|
||||
| 节点 | 用途 | 输出 |
|
||||
| --- | --- | --- |
|
||||
| **内联蒙版 · Inline Mask** | 普通蒙版或裁切重绘、双图层融合 | `image`、`mask`、`full_image` |
|
||||
| **WebUI 局部重绘 · WebUI Inpaint** | 一体化准备、采样、合成 | `image`:完整合成图;`crop`:采样画布;`mask`:采样蒙版 |
|
||||
| **图像对比 · Image Compare** | 预览、对比、采用结果 | `image`:原样传递结果批次 |
|
||||
| **组忽略预设 · Group Bypass Presets** | 应用已保存的组状态 | 无需连线 |
|
||||
| **自动二选一 · Auto Exclusive Switch** | 传递唯一启用的输入 | `output`:被选中分支的数据类型 |
|
||||
| **重绘准备 · Inpaint Prepare** | 为自定义采样流程裁剪、调整图像和蒙版 | `pixels`、`mask`、`context` |
|
||||
| **重绘合成 · Inpaint Composite** | 将局部解码结果回贴到原图 | `image` |
|
||||
|
||||
模块化流程可使用 `Prepare.pixels → VAE 编码 → 设置潜空间噪声蒙版 → 采样器 → VAE 解码 → Composite`。`Prepare.mask` 连接噪声蒙版节点,**务必把 `Prepare.context` 连到 `Composite.context`**,详见[模块化示例](examples/02-modular-inpaint.json)。
|
||||
|
||||
## 画布快捷操作
|
||||
|
||||
| 操作 | 快捷键或控件 |
|
||||
| --- | --- |
|
||||
| 画笔 / 橡皮擦 / 矩形 / 套索 | `B` / `E` / `R` / `L` |
|
||||
| 临时橡皮擦 | 按住 `Alt`,松开后恢复之前的工具 |
|
||||
| 调整笔径 | `Ctrl` + 滚轮,或 `[` / `]` |
|
||||
| 调整硬度 | `Shift` + 滚轮 |
|
||||
| 合并前临时看原图 | 按住右键;松开恢复当前工作区 |
|
||||
| 取消裁切选定 | 点击 ✓ 合并后(或没有回贴图层时),图片内右键,再左键选择新区域 |
|
||||
| 撤销 / 重做 | `Ctrl+Z` / `Ctrl+Shift+Z`,或 `Ctrl+Y` |
|
||||
| 未固定时的图片缩放 / 移动 | 滚轮 / 空格拖动、中键拖动,或移动工具 `H` |
|
||||
| 保持图片适应 | 点击「适应」旁的图钉,再点一次解除 |
|
||||
| 调整编辑区高度 | 拖动底部 `↕` 手柄 |
|
||||
|
||||
画笔面板还提供反转、全选、清空、PNG 蒙版导入/导出、原图/蒙版/叠加视图和**原图嵌入工作流**。叠加不透明度只影响显示。鼠标在按钮、参数区滚动时控制外部工作流;从工作流背景开始的连续滚轮手势,移入图片后仍控制工作流。
|
||||
|
||||
## 示例工作流
|
||||
|
||||
将对应 JSON 拖入 ComfyUI:
|
||||
|
||||
| 示例 | 内容 |
|
||||
| --- | --- |
|
||||
| [01 · 内联局部重绘](examples/01-inline-inpaint.json) | 完整绘制与重绘流程,需要自行选择兼容模型。 |
|
||||
| [02 · 模块化重绘](examples/02-modular-inpaint.json) | Prepare / Composite 配合常规编解码与采样节点。 |
|
||||
| [03 · 分组预设](examples/03-group-presets.json) | 使用图像分支演示预设切换,不需要模型。 |
|
||||
| [04 · 自动二选一](examples/04-exclusive-switch.json) | 分组预设控制两路输入,不需要模型。 |
|
||||
| [05 · 内联裁切融合](examples/05-inline-crop-blend.json) | 同一编辑器裁切、接收结果和输出完整回贴图,需要选择模型并上传原图。 |
|
||||
|
||||
## 兼容性与重要行为
|
||||
|
||||
- **静态图片:**支持常规四维 `[B,C,H,W]` 和单帧五维 `[B,C,1,H,W]` 图像潜空间,包括 Anima 使用的 Wan 衍生 VAE 路径;需提供该模型配套的 VAE 和文本条件,不是多帧视频重绘。
|
||||
- **Anima:**普通带蒙版图生图请关闭 **inpaint_model / 专用重绘模型**。该选项用于专用 inpaint 架构,并非画了蒙版就需要开启。
|
||||
- **原图尺寸:**两种采样区域最终都按原图尺寸合成;如果要目标采样尺寸的图片,使用 `crop` 或另接调整尺寸节点。
|
||||
- **裁切图层:**选区和融合蒙版会保存到工作流,最新重绘图层引用 `input/adachi-inpaint-layers/` 中的图片。跨机器移动尚未合并的编辑时需带上这张图,或先合并图层再嵌入原图。
|
||||
- **分享工作流:**蒙版会写入 JSON。原图默认只存 `input/` 下的相对路径,开启**原图嵌入工作流**才会把图片也写进去,文件会变大。加载工作流、上传图片、切换模式或勾选嵌入时,如果浏览器草稿空间不足,插件先保存一份完全相同的原图到 input 图片目录,再改用文件引用,保留蒙版、图层和笔画记录。若确实要把大图嵌入 JSON,可先关闭 ComfyUI 的浏览器工作流持久化,再勾选嵌入;导出文件不受浏览器草稿限额影响。若当前页面已经每次操作都提示草稿失败,请先按 **Ctrl+S** 保存,再按 **Ctrl+F5** 刷新一次,重置 ComfyUI 本页已停用的草稿状态。
|
||||
- **采用结果:**结果尺寸必须与原图一致,才能保留蒙版坐标。撤销/重做记录在本次会话中保留;保存重开保留蒙版,不保存逐笔命令历史。
|
||||
- **临时预览:**对比历史保存的是预览引用,ComfyUI 清理临时文件后可能失效;已采用的原图保存在 `input/adachi-inpaint/`。
|
||||
- **功能范围:**这是独立实现的 WebUI 风格工具,不保证与 WebUI 像素、脚本和扩展行为完全一致;额外软边羽化并非 WebUI 的完整 Soft Inpainting 算法。
|
||||
|
||||
<details>
|
||||
<summary>采样、填充与批次细节</summary>
|
||||
|
||||
- 采样宽高使用 8 的倍数。padding、模糊和蒙版扩张以原图像素计量;模糊/扩张会改变有效蒙版边界,有效蒙版为零的像素保持不变。
|
||||
- `mask_mode=outside` 会先反转涂抹区域,再进行蒙版处理。
|
||||
- `resize_mode` 控制整图采样的调整方式;仅蒙版区域使用保留完整蒙版的比例变换,也适用于蒙版碰到图像边缘的情况。
|
||||
- 填充模式包括**原图像素、周边颜色填充、潜空间噪声、空白潜空间**。潜空间模式属于一体化重绘节点;模块化 Prepare 只处理图像和蒙版。
|
||||
- 空蒙版或 `denoise=0` 会跳过 VAE 和采样。`batch_size` 表示每张原图生成的变体数,不是 ComfyUI 队列次数。
|
||||
- 模块化多原图批次需按原图分组变体,例如 `A,A,B,B`,而非交错的 `A,B,A,B`。
|
||||
- 可选 Refiner 使用 `refiner_model` 与 `refiner_switch`;`0.8` 表示最后 20% 步骤使用 Refiner,建议提供兼容的专用文本条件。设为 `1` 只用基础模型,`0` 只用 Refiner。
|
||||
- 输入限制为单文件 64 MiB、单图 3200 万像素、单边 16384 像素。大图绘制与 PNG 编码速度仍取决于浏览器内存和硬件。
|
||||
|
||||
</details>
|
||||
|
||||
## 排错与开发
|
||||
|
||||
**更新后还是旧界面?** 完整重启 ComfyUI,再强制刷新网页。Python 后端修改不能仅靠刷新网页加载。
|
||||
|
||||
**自动二选一提示没有有效输入?** 检查真正的上游源节点和对应 Set,不能只看 Get。将二选一放在受控分组之外。若其他集成仍会丢掉子图输出,可以先把 CLIP 编码器放到子图外。反馈时请附上工作流、插件/前端版本和完整错误;当前错误会显示前端、工作流与执行输入的状态标记。
|
||||
|
||||
**打开别人的工作流后图片丢失?** 按相对路径补齐原图,或请对方开启原图嵌入后再保存。临时对比预览失效则需要重新生成。
|
||||
|
||||
运行 CPU 回归测试:
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
node --experimental-default-type=module tests/test_exclusive_routes.mjs
|
||||
node --experimental-default-type=module tests/test_compare_history.mjs
|
||||
```
|
||||
|
||||
已测试配置和范围见[验证记录](VERIFICATION.md),版本变化见[更新日志](CHANGELOG.md),恢复旧版本见[回滚说明](ROLLBACK.md)。模型家族和实际画质测试并非全覆盖。
|
||||
|
||||
## 致谢与许可证
|
||||
|
||||
由 **Adachi / 安达** 制作,采用 [MIT 许可证](LICENSE)。
|
||||
|
||||
基于 [ComfyUI](https://github.com/Comfy-Org/ComfyUI) 构建,重绘交互参考 WebUI,鼠标划线对比体验参考 [rgthree-comfy](https://github.com/rgthree/rgthree-comfy)。代码由本仓库独立实现,这些项目不作为捆绑依赖。
|
||||
|
||||
欢迎通过 [Issues](https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues) 反馈问题、分享工作流用法。
|
||||
+25
-5
@@ -1,10 +1,30 @@
|
||||
# Git versions and rollback
|
||||
|
||||
The current local version is **v0.5.7**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
|
||||
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.
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| `v0.5.7` | Fixed comparison images; wheel navigates the workflow; retain result history across runs |
|
||||
| `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 |
|
||||
| `local-before-v0.6.6` | Source before right-click reselection and inline Undo |
|
||||
| `v0.6.5` | Change crop dimensions while selected, automatically return to selection |
|
||||
| `local-before-v0.6.5` | Source before editable locked-crop dimensions |
|
||||
| `v0.6.4` | Black crop frame, automatic dimensions and comparison-only result adoption |
|
||||
| `local-before-v0.6.4` | Source before the comparison-adoption update, including v0.6.3 draft preflight fixes |
|
||||
| `v0.6.2` | Draft recovery, collapsible controls, retired selection tools and revised crop behavior |
|
||||
| `local-before-v0.6.2` | Exact baseline before the combined draft/fold and crop updates |
|
||||
| `v0.6.0` | Merged crop/blend editor, magic wand and quick selection |
|
||||
| `local-before-v0.6.0` | Exact source baseline before the merged editor (includes bilingual README) |
|
||||
| `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 |
|
||||
@@ -19,14 +39,14 @@ To undo only this update, run in the plugin folder:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git switch --detach local-before-v0.5.7
|
||||
git switch --detach local-before-v0.6.7
|
||||
```
|
||||
|
||||
Return to this version with:
|
||||
|
||||
```powershell
|
||||
git switch --detach v0.5.7
|
||||
git switch --detach v0.6.7
|
||||
```
|
||||
|
||||
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.
|
||||
Commit or back up any local modifications before switching. A release ZIP does not contain `.git`; use the installer backup for a copied installation.
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Verification record
|
||||
|
||||
- 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.
|
||||
|
||||
- 0.6.5: classic and Nodes 2.0 browser checks on frontend 1.52.7 verify changing a locked preset, pointer-following replacement selection, automatic inpaint dimensions after relocking, preservation of an adopted patch by merging, retention of the old selection/resolution after an upload failure, and reselection through swap/custom controls. Frontend syntax and diff whitespace checks pass.
|
||||
|
||||
- 0.6.4 comparison-adoption update: 48 backend tests pass, including removal of the result socket, ignoring obsolete feedback links, and generating-region metadata on comparison previews. Resolution-sync checks cover multiple connected targets, linked-input protection and source-size bounds. Real CPU comparison queues and browser interactions on isolated port 8196 with frontend 1.52.7 pass in classic nodes and Nodes 2.0: black dashed frame, automatic inpaint dimensions, no automatic stitching, manual crop adoption with retained repaint mask, independent fusion editing, reload, stale-result rejection, normal-mode mask preservation, and migration of the old wired example. Existing draft preflight/storage, comparison history, geometry and frontend syntax checks also pass. No complete model generation is claimed by these lightweight image probes.
|
||||
|
||||
- 0.6.3: reproduced the sticky native draft failure on real frontend builds 1.42.11 and 1.52.7: after a 12 Mi-character draft fails, even a two-character draft returns false until the page reloads. Isolated lightweight API fixtures (no model queues or production writes) verify oversized workflow loading before configuration, exact source byte hashes, repaint mask pixels, stale named-snapshot cleanup, repeated mode changes and subsequent successful native draft writes in classic nodes and Nodes 2.0 on both builds. Unit checks additionally cover nested subgraph sources, named-only snapshots, fitting/blocked storage, failed uploads and unchanged masks/layers. This does not claim inspection of the user's live browser console or a complete model generation.
|
||||
|
||||
- 0.6.1 fixes (included in 0.6.2): reproduced the exact browser “failed to save workflow draft” toast on frontend 1.42.11 with an approximately 10-million-character embedded source and a real QuotaExceededError. The capacity guard now converts only a source that does not fit, uploading it before clearing embedded data. SHA-256 checks confirm the input PNG bytes are identical; repaint/fusion masks, layers and undo commands survive. Failed uploads preserve both embedding and mode, existing browser storage is untouched, fitting sources remain embedded and disabled workflow persistence is respected.
|
||||
- Combined 0.6.2: the quota/fold browser regression passes in classic nodes and Nodes 2.0 on frontends 1.42.11 and 1.52.7 (1.52.7 also tested after the concurrent crop/tool changes). The collapsed tools hide painting controls and mask/fill options while all seven action buttons remain visible and operational. Heights update, open/closed states survive workflow reload, and 1440/768/390 layouts pass. Real CPU feedback queues use image inversion rather than model inference; no complete Anima generation is claimed. All 47 backend tests and JavaScript tests for draft guarding, crop geometry, exclusive routing and comparison history pass.
|
||||
|
||||
- 0.6.2: all 47 backend tests, crop geometry checks and existing draft-guard checks pass; all frontend modules pass syntax checks. Real browser checks on an isolated 8194 CPU service with frontend 1.52.7 pass in classic nodes and Nodes 2.0: locked crops start empty, manual strokes paint normally, fill/rectangle/brush stay within the crop, undo/redo and reload preserve the mask, a fresh region clears previous strokes, ordinary mode remains unrestricted, and the toolbar contains no automatic-selection tools. Six presets include both 512 and 2048 endpoints and use only 64-aligned dimensions.
|
||||
|
||||
- 0.6.0: 47 CPU backend tests pass, including exact crop/mask slicing, soft fusion masks, batch composites, untouched outside pixels, content-addressed layer files, invalid bounds/result sizes, normal-mode ignored results and acyclic feedback normalization. Geometry, magic-wand/quick-selection, exclusive routing and comparison-history JavaScript checks also pass. All frontend modules pass syntax checks; example/localization JSON and project metadata parse successfully.
|
||||
- 0.6.0: real Chrome interaction and real CPU queues on isolated port 8191 with ComfyUI 0.19.3 and frontend 1.52.7 pass in classic nodes and Nodes 2.0. Tests cover a visible Inline Mask → ImageInvert → Inline Mask loop with full-image saving, normal-mode result ignoring, magic-wand add/subtract, quick-selection drag/subtract, independent repaint/fusion masks, soft-brush undo/redo, fixed-region repeat generation, saved-workflow restoration, merge/reselect, stale-job rejection, Ctrl/Shift wheel gestures, pinned graph scrolling and 1440/768/390 viewport layouts. No plugin script errors were observed. These are image-processing probes, not a complete Anima generation.
|
||||
- 0.6.0: the connected WebUI panel mounts the existing live painting controls, source-size filling uses locked crop dimensions, Escape closes the selection menu without reselecting, and the internal layer sink is hidden from the classic node menu. The old example loads with existing image/mask wires intact; the new eight-node crop/blend example is generated and reloaded through ComfyUI.
|
||||
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
@@ -49,6 +74,10 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,11 @@
|
||||
"""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"
|
||||
|
||||
|
||||
+361
-18
@@ -10,9 +10,11 @@ import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
@@ -310,31 +312,39 @@ class AdachiInlineMask:
|
||||
"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."}),
|
||||
}, "optional": {
|
||||
"edit_mode": (["mask", "crop"], {"default": "mask"}),
|
||||
"crop_state": ("STRING", {"default": "{}"}),
|
||||
"blend_data": ("STRING", {"default": ""}),
|
||||
"layer_image": ("STRING", {"default": ""}),
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
||||
RETURN_NAMES = ("image", "mask", "full_image")
|
||||
FUNCTION = "load"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "Paint an inpaint mask directly in the workflow."
|
||||
SEARCH_ALIASES = ["inline mask", "webui inpaint", "局部重绘", "安达", "adachi"]
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image="", mask_data="", source_data=""):
|
||||
def IS_CHANGED(cls, image="", mask_data="", source_data="", **kwargs):
|
||||
digest = hashlib.sha256()
|
||||
for value in (image, mask_data, source_data):
|
||||
for value in (image, mask_data, source_data, json.dumps(kwargs, sort_keys=True, default=str)):
|
||||
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()
|
||||
|
||||
def load(self, image="", mask_data="", source_data=""):
|
||||
def load(self, image="", mask_data="", source_data="", edit_mode="mask", crop_state="{}", blend_data="", layer_image="", result=None):
|
||||
raster = _data_image(source_data) if source_data else _read_raster(_input_path(image))
|
||||
rgba = raster.convert("RGBA")
|
||||
pixels = torch.from_numpy(np.asarray(rgba.convert("RGB"), dtype=np.float32).copy() / 255.0)[None]
|
||||
@@ -345,7 +355,14 @@ class AdachiInlineMask:
|
||||
mask = np.asarray(mask_image, dtype=np.float32).copy() / 255.0
|
||||
else:
|
||||
mask = 1.0 - np.asarray(rgba.getchannel("A"), dtype=np.float32).copy() / 255.0
|
||||
return pixels, torch.from_numpy(mask)[None]
|
||||
mask = torch.from_numpy(mask)[None]
|
||||
if edit_mode == "crop":
|
||||
state = _crop_state(crop_state)
|
||||
if state.get("locked"):
|
||||
x, y, width, height = _crop_box(state, pixels.shape[2], pixels.shape[1])
|
||||
composite = _blend_layer(pixels, _load_layer(layer_image), blend_data, state)
|
||||
return composite[:, y:y+height, x:x+width].clone(), mask[:, y:y+height, x:x+width].clone(), composite
|
||||
return pixels, mask, pixels
|
||||
|
||||
|
||||
class AdachiInpaintPrepare:
|
||||
@@ -589,7 +606,7 @@ class AdachiImageCompare:
|
||||
return {"required": {
|
||||
"original": ("IMAGE", {"tooltip": "Original image shown on the right."}),
|
||||
"result": ("IMAGE", {"tooltip": "Full-size generated image shown on the left; use image, not crop."}),
|
||||
}}
|
||||
}, "hidden": {"prompt": "PROMPT", "unique_id": "UNIQUE_ID", "dynprompt": "DYNPROMPT"}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
@@ -599,7 +616,7 @@ 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):
|
||||
def compare(self, original, result, prompt=None, unique_id=None, dynprompt=None):
|
||||
import nodes
|
||||
|
||||
original, result = _image_tensor(original), _image_tensor(result)
|
||||
@@ -612,12 +629,178 @@ class AdachiImageCompare:
|
||||
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"]
|
||||
return {"ui": {"adachi_originals": before, "adachi_results": after}, "result": (result,)}
|
||||
origin = _comparison_origin(prompt, unique_id, dynprompt)
|
||||
return {"ui": {"adachi_originals": before, "adachi_results": after, "adachi_origin": [origin] if origin else []}, "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 normalize_crop_prompt(data)
|
||||
|
||||
|
||||
class AdachiExclusiveSwitch:
|
||||
"""Forward exactly one enabled branch, without evaluating the ignored branch."""
|
||||
|
||||
@@ -632,6 +815,7 @@ class AdachiExclusiveSwitch:
|
||||
# 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 = ("*",)
|
||||
@@ -642,31 +826,188 @@ class AdachiExclusiveSwitch:
|
||||
SEARCH_ALIASES = ["exclusive switch", "auto switch", "自动二选一", "互斥", "自动切换", "adachi"]
|
||||
|
||||
@staticmethod
|
||||
def _selected_input(input_a, input_b, active_inputs):
|
||||
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."
|
||||
"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):
|
||||
return [self._selected_input(input_a, input_b, active_inputs)]
|
||||
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):
|
||||
selected = self._selected_input(input_a, input_b, active_inputs)
|
||||
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,)
|
||||
|
||||
|
||||
def _crop_state(value):
|
||||
try:
|
||||
state = json.loads(value or "{}")
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Invalid crop selection data.") from exc
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError("Invalid crop selection data.")
|
||||
return state
|
||||
|
||||
|
||||
def _crop_box(state, image_width, image_height):
|
||||
values = [state.get(k) for k in ("x", "y", "width", "height")]
|
||||
if any(type(v) is not int for v in values):
|
||||
raise ValueError("Crop coordinates and dimensions must be integers.")
|
||||
x, y, width, height = values
|
||||
if min(width, height) < 32 or width % 32 or height % 32:
|
||||
raise ValueError("Crop width and height must be positive multiples of 32.")
|
||||
if x < 0 or y < 0 or x + width > image_width or y + height > image_height:
|
||||
raise ValueError("The crop must fit entirely inside the source image.")
|
||||
return x, y, width, height
|
||||
|
||||
|
||||
def _load_layer(filename):
|
||||
if not filename:
|
||||
return None
|
||||
raster = _read_raster(_input_path(filename)).convert("RGB")
|
||||
return torch.from_numpy(np.asarray(raster, dtype=np.float32).copy() / 255)[None]
|
||||
|
||||
|
||||
def _blend_layer(original, generated, blend_data, state):
|
||||
if generated is None:
|
||||
return original
|
||||
x, y, width, height = _crop_box(state, original.shape[2], original.shape[1])
|
||||
generated = _image_tensor(generated).to(device=original.device, dtype=original.dtype)
|
||||
if generated.shape[1:3] != (height, width):
|
||||
raise ValueError(f"Inpaint result must match the selected crop ({width} x {height}); connect the inpaint node's image output.")
|
||||
alpha = torch.ones((1, height, width, 1), device=original.device, dtype=original.dtype)
|
||||
if blend_data:
|
||||
raster = _data_image(blend_data).convert("L")
|
||||
if raster.size != (width, height):
|
||||
raise ValueError("The layer mask dimensions must match the crop.")
|
||||
alpha = torch.from_numpy(np.asarray(raster, dtype=np.float32).copy() / 255)[None, ..., None].to(original)
|
||||
composite = original.expand(generated.shape[0], -1, -1, -1).clone()
|
||||
old = composite[:, y:y+height, x:x+width]
|
||||
composite[:, y:y+height, x:x+width] = old * (1-alpha) + generated * alpha
|
||||
return composite
|
||||
|
||||
|
||||
def _store_layer(image):
|
||||
import folder_paths
|
||||
pixels = (image.detach().cpu().clamp(0, 1).numpy()*255).round().astype(np.uint8)
|
||||
name = f"layer-{hashlib.sha256(str(pixels.shape).encode() + pixels.tobytes()).hexdigest()}.png"
|
||||
folder = Path(folder_paths.get_input_directory()) / "adachi-inpaint-layers"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
if not (folder / name).exists():
|
||||
# Readers never see an incomplete PNG when concurrent prompts reuse a layer.
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
Image.fromarray(pixels).save(temporary)
|
||||
temporary.replace(folder / name)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return {"filename": name, "subfolder": "adachi-inpaint-layers", "type": "input"}
|
||||
|
||||
|
||||
class AdachiLayerCommit:
|
||||
"""Execution-only sink; keeps the visible feedback loop acyclic at runtime."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {
|
||||
"result": ("IMAGE",), "image": ("STRING", {"default": ""}),
|
||||
"source_data": ("STRING", {"default": ""}), "crop_state": ("STRING", {"default": "{}"}),
|
||||
"blend_data": ("STRING", {"default": ""}), "target": ("STRING", {"default": ""}),
|
||||
}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "compose"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "_Adachi/internal"
|
||||
DESCRIPTION = "Internal layer sink. Use Inline Mask in Crop & Blend mode."
|
||||
|
||||
def compose(self, result, image="", source_data="", crop_state="{}", blend_data="", target=""):
|
||||
raster = (_data_image(source_data) if source_data else _read_raster(_input_path(image))).convert("RGB")
|
||||
original = torch.from_numpy(np.asarray(raster, dtype=np.float32).copy()/255)[None]
|
||||
state = _crop_state(crop_state)
|
||||
generated = _image_tensor(result).cpu()
|
||||
composite = _blend_layer(original, generated, blend_data, state)
|
||||
layer = _store_layer(generated[-1])
|
||||
return {"ui": {"adachi_layer": [{"target": target, "job": state.get("job", ""), "image": layer}]}, "result": (composite,)}
|
||||
|
||||
|
||||
def normalize_crop_prompt(data):
|
||||
"""Ignore obsolete feedback links; adoption now happens only in Image Compare."""
|
||||
prompt = data.get("prompt", {})
|
||||
if isinstance(prompt, dict):
|
||||
for node in prompt.values():
|
||||
if isinstance(node, dict) and node.get("class_type") == "AdachiInlineMask":
|
||||
node.setdefault("inputs", {}).pop("result", None)
|
||||
return data
|
||||
|
||||
|
||||
def _comparison_origin(prompt, unique_id, dynprompt=None):
|
||||
"""Tag previews with their generating crop, so later adoption cannot hit another region."""
|
||||
def get_node(node_id):
|
||||
if dynprompt is not None:
|
||||
try:
|
||||
return dynprompt.get_node(str(node_id))
|
||||
except KeyError:
|
||||
pass
|
||||
return prompt.get(str(node_id), prompt.get(node_id)) if isinstance(prompt, dict) else None
|
||||
node = get_node(unique_id)
|
||||
link = node.get("inputs", {}).get("original") if isinstance(node, dict) else None
|
||||
queue = [str(link[0])] if isinstance(link, list) and len(link) == 2 else []
|
||||
seen, sources = set(), {}
|
||||
while queue and len(seen) < 256:
|
||||
node_id = queue.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
current = get_node(node_id)
|
||||
if not isinstance(current, dict):
|
||||
continue
|
||||
inputs = current.get("inputs", {})
|
||||
if current.get("class_type") == "AdachiInlineMask":
|
||||
mode = inputs.get("edit_mode", "mask")
|
||||
state = _crop_state(inputs.get("crop_state", "{}")) if mode == "crop" else {}
|
||||
sources[node_id] = {"source": node_id, "mode": mode, "job": state.get("job", "")}
|
||||
continue
|
||||
for value in inputs.values():
|
||||
if isinstance(value, list) and len(value) == 2 and type(value[1]) is int:
|
||||
queue.append(str(value[0]))
|
||||
return next(iter(sources.values())) if len(sources) == 1 else None
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AdachiInlineMask": AdachiInlineMask,
|
||||
"AdachiInpaintPrepare": AdachiInpaintPrepare,
|
||||
@@ -674,6 +1015,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"AdachiWebUIInpaint": AdachiWebUIInpaint,
|
||||
"AdachiImageCompare": AdachiImageCompare,
|
||||
"AdachiExclusiveSwitch": AdachiExclusiveSwitch,
|
||||
"AdachiLayerCommit": AdachiLayerCommit,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -683,4 +1025,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AdachiWebUIInpaint": "WebUI Inpaint",
|
||||
"AdachiImageCompare": "Image Compare",
|
||||
"AdachiExclusiveSwitch": "Auto Exclusive Switch",
|
||||
"AdachiLayerCommit": "Internal Layer Commit",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
{
|
||||
"id": "58f1c513-1df0-4f53-af63-69da48ad43f0",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "AdachiInlineMask",
|
||||
"pos": [
|
||||
40,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
540,
|
||||
820
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_data",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "mask_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "source_data",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "source_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "edit_mode",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "edit_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "crop_state",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "crop_state"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "blend_data",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "blend_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "layer_image",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "layer_image"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
7,
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "full_image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiInlineMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"crop",
|
||||
"{\"job\":\"8d759783-b3cd-4dbc-b4ea-2c8e307be653\"}",
|
||||
"",
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"image": "",
|
||||
"mask_data": "",
|
||||
"source_data": "",
|
||||
"edit_mode": "crop",
|
||||
"crop_state": "{\"job\":\"8d759783-b3cd-4dbc-b4ea-2c8e307be653\"}",
|
||||
"blend_data": "",
|
||||
"layer_image": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
-300
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
130.65625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"CHOOSE_YOUR_CHECKPOINT.safetensors"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"ckpt_name": "CHOOSE_YOUR_CHECKPOINT.safetensors"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
420,
|
||||
-350
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
150
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "Positive",
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"A detailed natural photograph, coherent lighting, high quality"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "A detailed natural photograph, coherent lighting, high quality"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
800,
|
||||
-350
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
150
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "Negative",
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"blurry, distorted, artifacts"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "blurry, distorted, artifacts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "AdachiWebUIInpaint",
|
||||
"pos": [
|
||||
700,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
430,
|
||||
767
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "refiner_model",
|
||||
"shape": 7,
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_positive",
|
||||
"shape": 7,
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_negative",
|
||||
"shape": 7,
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "area",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "area"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "padding",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "padding"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_blur",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "mask_blur"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_grow",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "mask_grow"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "mask_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "fill",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "fill"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "resize_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "resize_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_switch",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "refiner_switch"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "inpaint_model",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "inpaint_model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "soft_edge",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "soft_edge"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "soft_strength",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "soft_strength"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "crop",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiWebUIInpaint"
|
||||
},
|
||||
"widgets_values": [
|
||||
"only_masked",
|
||||
1024,
|
||||
1024,
|
||||
32,
|
||||
4,
|
||||
0,
|
||||
"inside",
|
||||
"original",
|
||||
"stretch",
|
||||
42,
|
||||
"fixed",
|
||||
20,
|
||||
7,
|
||||
"dpmpp_2m",
|
||||
"karras",
|
||||
0.75,
|
||||
1,
|
||||
0.8,
|
||||
false,
|
||||
false,
|
||||
0.5
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"area": "only_masked",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"padding": 32,
|
||||
"mask_blur": 4,
|
||||
"mask_grow": 0,
|
||||
"mask_mode": "inside",
|
||||
"fill": "original",
|
||||
"resize_mode": "stretch",
|
||||
"seed": 42,
|
||||
"control_after_generate": "fixed",
|
||||
"steps": 20,
|
||||
"cfg": 7,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
"denoise": 0.75,
|
||||
"batch_size": 1,
|
||||
"refiner_switch": 0.8,
|
||||
"inpaint_model": false,
|
||||
"soft_edge": false,
|
||||
"soft_strength": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "AdachiImageCompare",
|
||||
"pos": [
|
||||
1300,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
590,
|
||||
620
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "original",
|
||||
"type": "IMAGE",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiImageCompare"
|
||||
},
|
||||
"widgets_values": [],
|
||||
"widgets_values_named": {}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1300,
|
||||
850
|
||||
],
|
||||
"size": [
|
||||
430,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Adachi/CropBlend"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"filename_prefix": "Adachi/CropBlend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
40,
|
||||
-190
|
||||
],
|
||||
"size": [
|
||||
540,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"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."
|
||||
],
|
||||
"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."
|
||||
},
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
4,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
5,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
10,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
13,
|
||||
1,
|
||||
2,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
15,
|
||||
1,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.5,
|
||||
"offset": [
|
||||
0,
|
||||
400
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -54,5 +54,14 @@ Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object {
|
||||
} | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $destination -Recurse -Force
|
||||
}
|
||||
# These tracked files were retired in 0.6.2. The previous installation ZIP above
|
||||
# retains them for rollback; remove them from an overwritten installation too.
|
||||
foreach ($retiredRelative in @('web\magic_wand.js', 'web\magic_wand_worker.js', 'web\wand_selection.js', 'tests\test_magic_wand.mjs')) {
|
||||
$retiredPath = [IO.Path]::GetFullPath((Join-Path $destination $retiredRelative))
|
||||
if (-not $retiredPath.StartsWith($destination.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Retired plugin file must stay within the install directory.'
|
||||
}
|
||||
if (Test-Path -LiteralPath $retiredPath -PathType Leaf) { Remove-Item -LiteralPath $retiredPath -Force }
|
||||
}
|
||||
Write-Host "Installed Adachi-Inpaint-Workbench: $destination"
|
||||
Write-Host 'Restart ComfyUI, then refresh the browser with Ctrl+F5.'
|
||||
|
||||
@@ -14,6 +14,18 @@
|
||||
"source_data": {
|
||||
"name": "嵌入原图",
|
||||
"tooltip": "嵌入原图,便于工作流在其他机器使用。"
|
||||
},
|
||||
"edit_mode": {
|
||||
"name": "编辑模式"
|
||||
},
|
||||
"crop_state": {
|
||||
"name": "裁切选区"
|
||||
},
|
||||
"blend_data": {
|
||||
"name": "融合蒙版"
|
||||
},
|
||||
"layer_image": {
|
||||
"name": "重绘图层"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -22,6 +34,9 @@
|
||||
},
|
||||
"1": {
|
||||
"name": "蒙版"
|
||||
},
|
||||
"2": {
|
||||
"name": "完整图像"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "0.5.7"
|
||||
version = "0.8.0"
|
||||
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
## 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).
|
||||
[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`.
|
||||
|
||||
## Comfy Registry
|
||||
@@ -14,9 +14,9 @@ Until it is listed you can still install through the Manager's Git URL option, u
|
||||
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.
|
||||
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 workflow is manual-only today and will not publish while no Publisher ID or API key is configured.
|
||||
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.
|
||||
A Registry version cannot be overwritten once published, so later releases have to bump the version in `pyproject.toml`.
|
||||
|
||||
@@ -177,13 +177,13 @@ class BackendTests(unittest.TestCase):
|
||||
def test_embedded_source_and_mask_convention(self):
|
||||
rgba = Image.new("RGBA", (8, 4), (10, 20, 30, 255))
|
||||
rgba.putpixel((0, 0), (10, 20, 30, 0))
|
||||
pixels, alpha_mask = backend.AdachiInlineMask().load(source_data=data_url(rgba))
|
||||
pixels, alpha_mask, _ = backend.AdachiInlineMask().load(source_data=data_url(rgba))
|
||||
self.assertEqual(tuple(pixels.shape), (1, 4, 8, 3))
|
||||
self.assertEqual(float(alpha_mask[0, 0, 0]), 1)
|
||||
self.assertEqual(float(alpha_mask[0, 1, 1]), 0)
|
||||
blackwhite = Image.new("L", (8, 4), 0)
|
||||
blackwhite.putpixel((2, 1), 255)
|
||||
_, explicit_mask = backend.AdachiInlineMask().load(source_data=data_url(rgba), mask_data=data_url(blackwhite))
|
||||
_, explicit_mask, _ = backend.AdachiInlineMask().load(source_data=data_url(rgba), mask_data=data_url(blackwhite))
|
||||
self.assertEqual(float(explicit_mask[0, 1, 2]), 1)
|
||||
self.assertEqual(float(explicit_mask[0, 0, 0]), 0)
|
||||
|
||||
|
||||
@@ -12,5 +12,12 @@ assert.deepEqual(appendComparison(legacy, {originals: ["c"], results: [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", "result-a"]);
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {syncCropDimensions} 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)=>{
|
||||
const n={id,type,graph,inputs:[{name:'image',link:id},...(linkedWidth?[{name:'width',link:999}]:[])],widgets:[{name:'width',value:640},{name:'height',value:640}]};
|
||||
nodes.set(id,n);graph._nodes.push(n);links[id]={origin_id:1};return n;
|
||||
};
|
||||
const sampler=make(2,'AdachiWebUIInpaint'),prepare=make(3,'AdachiInpaintPrepare',true);
|
||||
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');
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { CROP_PRESETS, cropAt } from "../web/crop_geometry.js";
|
||||
assert.ok(CROP_PRESETS.length <= 6);
|
||||
assert.ok(CROP_PRESETS.some(([w,h]) => w === 512 && h === 512));
|
||||
assert.ok(CROP_PRESETS.some(([w,h]) => w === 2048 && h === 2048));
|
||||
assert.ok(CROP_PRESETS.every(([w, h]) => w % 64 === 0 && h % 64 === 0 && Math.min(w,h) >= 512 && Math.max(w,h) <= 2048));
|
||||
assert.deepEqual(cropAt(2048, 3072, 1024, 1536, 400, 700), {
|
||||
x: 400,
|
||||
y: 700,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
});
|
||||
assert.deepEqual(cropAt(2048, 3072, 1024, 1536, 2040, 3070), {
|
||||
x: 1024,
|
||||
y: 1536,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
});
|
||||
assert.equal(cropAt(512, 512, 1024, 1536, 0, 0), null);
|
||||
assert.equal(cropAt(2048, 3072, 1025, 1536, 0, 0), null);
|
||||
assert.equal(cropAt(2048, 3072, 1024, 1536, NaN, 0), null);
|
||||
for (const x of [-100, 0, 12.9, 1000, 3000])
|
||||
for (const y of [-1, 0, 2200, 4000]) {
|
||||
const r = cropAt(2048, 3072, 1024, 1536, x, y);
|
||||
assert.ok(
|
||||
r.x >= 0 && r.y >= 0 && r.x + r.width <= 2048 && r.y + r.height <= 3072,
|
||||
);
|
||||
}
|
||||
console.log("Crop presets, source coordinates and edge clamping passed");
|
||||
@@ -0,0 +1,84 @@
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('inline_crop_tests',Path(__file__).resolve().parents[1]/'backend.py')
|
||||
backend=importlib.util.module_from_spec(spec);spec.loader.exec_module(backend)
|
||||
def url(image):
|
||||
b=io.BytesIO();image.save(b,format='PNG');return 'data:image/png;base64,'+base64.b64encode(b.getvalue()).decode()
|
||||
|
||||
class InlineCropTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
pixels=np.arange(96*128*3,dtype=np.uint8).reshape(96,128,3)
|
||||
self.source=url(Image.fromarray(pixels));self.original=torch.from_numpy(pixels.astype(np.float32)/255)[None]
|
||||
self.region=dict(x=32,y=16,width=64,height=64,locked=True,job='region-a')
|
||||
self.state=json.dumps(self.region)
|
||||
def test_legacy_mask_mode_ignores_result_and_keeps_original_ports(self):
|
||||
image,mask,full=backend.AdachiInlineMask().load(source_data=self.source,result=torch.ones(1,64,64,3))
|
||||
self.assertTrue(torch.equal(image,self.original));self.assertTrue(torch.equal(image,full));self.assertEqual(tuple(mask.shape),(1,96,128))
|
||||
def test_crop_source_and_generation_mask_are_sliced_at_exact_coordinates(self):
|
||||
pixels=np.zeros((96,128),dtype=np.uint8);pixels[20:24,35:40]=255
|
||||
image,mask,full=backend.AdachiInlineMask().load(source_data=self.source,mask_data=url(Image.fromarray(pixels)),edit_mode='crop',crop_state=self.state)
|
||||
self.assertTrue(torch.equal(image,self.original[:,16:80,32:96]));self.assertTrue(torch.equal(full,self.original))
|
||||
self.assertEqual(mask.sum().item(),20);self.assertEqual(mask[0,4,3].item(),1)
|
||||
def test_layer_blend_preserves_outside_and_supports_batch_and_soft_edges(self):
|
||||
alpha=np.zeros((64,64),dtype=np.uint8);alpha[:,:32]=255;alpha[:,32:48]=128
|
||||
result=torch.stack([torch.zeros(64,64,3),torch.ones(64,64,3)])
|
||||
blended=backend._blend_layer(self.original,result,url(Image.fromarray(alpha)),self.region)
|
||||
self.assertEqual(tuple(blended.shape),(2,96,128,3))
|
||||
outside=torch.ones(96,128,dtype=torch.bool);outside[16:80,32:96]=False
|
||||
for i in range(2):
|
||||
self.assertTrue(torch.equal(blended[i][outside],self.original[0][outside]))
|
||||
self.assertTrue(torch.equal(blended[i,16:80,32:64],result[i,:,:32]))
|
||||
self.assertTrue(torch.equal(blended[i,16:80,80:96],self.original[0,16:80,80:96]))
|
||||
self.assertTrue(torch.allclose(blended[i,16:80,64:80],self.original[0,16:80,64:80]*(127/255)+result[i,:,32:48]*(128/255)))
|
||||
def test_result_sink_persists_only_one_content_addressed_layer(self):
|
||||
with tempfile.TemporaryDirectory() as folder,patch.dict('sys.modules',{'folder_paths':types.SimpleNamespace(get_input_directory=lambda:folder)}):
|
||||
args=dict(result=torch.ones(2,64,64,3),source_data=self.source,crop_state=self.state,target='42')
|
||||
a=backend.AdachiLayerCommit().compose(**args);b=backend.AdachiLayerCommit().compose(**args)
|
||||
self.assertEqual(a['ui'],b['ui']);self.assertEqual(a['ui']['adachi_layer'][0]['target'],'42')
|
||||
self.assertEqual(a['ui']['adachi_layer'][0]['job'],'region-a')
|
||||
self.assertEqual(len(list(Path(folder).rglob('*.png'))),1)
|
||||
descriptor=a['ui']['adachi_layer'][0]['image'];name=descriptor['subfolder']+'/'+descriptor['filename']
|
||||
image,_,full=backend.AdachiInlineMask().load(source_data=self.source,edit_mode='crop',crop_state=self.state,layer_image=name)
|
||||
self.assertTrue(torch.equal(image,torch.ones(1,64,64,3)));self.assertTrue(torch.equal(full,a['result'][0][:1]))
|
||||
def test_invalid_regions_or_result_sizes_fail_before_writing(self):
|
||||
for change in ({'x':-1},{'y':80},{'width':65},{'height':0},{'x':True}):
|
||||
with self.subTest(change=change),self.assertRaises(ValueError):
|
||||
backend._blend_layer(self.original,torch.ones(1,64,64,3),'',{**self.region,**change})
|
||||
with patch.object(backend,'_store_layer') as store:
|
||||
with self.assertRaisesRegex(ValueError,'must match'):
|
||||
backend.AdachiLayerCommit().compose(torch.ones(1,32,64,3),source_data=self.source,crop_state=self.state)
|
||||
store.assert_not_called()
|
||||
def test_mask_mode_removes_feedback_even_when_connected(self):
|
||||
data={'prompt':{'1':{'class_type':'AdachiInlineMask','inputs':{'result':['2',0]}},'2':{'class_type':'ImageInvert','inputs':{'image':['1',0]}}}}
|
||||
backend.normalize_exclusive_prompt(data)
|
||||
self.assertNotIn('result',data['prompt']['1']['inputs']);self.assertEqual(len(data['prompt']),2)
|
||||
def test_crop_mode_has_no_result_socket_or_automatic_sink(self):
|
||||
self.assertNotIn('result', backend.AdachiInlineMask.INPUT_TYPES()['optional'])
|
||||
data={'prompt':{'1':{'class_type':'AdachiInlineMask','inputs':{'result':['2',0],'edit_mode':'crop','crop_state':self.state}},'2':{'class_type':'ImageInvert','inputs':{'image':['1',0]}},'3':{'class_type':'SaveImage','inputs':{'images':['1',2]}}}}
|
||||
backend.normalize_exclusive_prompt(data)
|
||||
self.assertEqual(len(data['prompt']),3)
|
||||
self.assertNotIn('result',data['prompt']['1']['inputs'])
|
||||
self.assertEqual(data['prompt']['3']['inputs']['images'],['1',2])
|
||||
def test_comparison_origin_tracks_exact_generating_region(self):
|
||||
prompt={'7':{'class_type':'AdachiInlineMask','inputs':{'edit_mode':'crop','crop_state':self.state}},'8':{'class_type':'Reroute','inputs':{'image':['7',0]}},'9':{'class_type':'AdachiImageCompare','inputs':{'original':['8',0]}}}
|
||||
self.assertEqual(backend._comparison_origin(prompt,'9'),{'source':'7','mode':'crop','job':'region-a'})
|
||||
prompt['7']['inputs']['crop_state']=json.dumps({**self.region,'job':'next-region'})
|
||||
self.assertEqual(backend._comparison_origin(prompt,'9')['job'],'next-region')
|
||||
self.assertIsNone(backend._comparison_origin({},'9'))
|
||||
def test_fingerprint_tracks_mode_blend_and_layer(self):
|
||||
first=backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,edit_mode='mask')
|
||||
for extra in ({'edit_mode':'crop'},{'blend_data':'new'},{'layer_image':'changed.png'}):
|
||||
self.assertNotEqual(first,backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,**extra))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets, inlineWidgetNames } from "../web/draft_guard.js";
|
||||
class Storage {
|
||||
constructor(limit = 10000) {
|
||||
this.limit = limit;
|
||||
this.data = new Map();
|
||||
this.removed = [];
|
||||
}
|
||||
getItem(k) {
|
||||
return this.data.get(k) ?? null;
|
||||
}
|
||||
setItem(k, v) {
|
||||
const used = [...this.data].reduce(
|
||||
(n, [key, value]) => n + (key === k ? 0 : value.length),
|
||||
0,
|
||||
);
|
||||
if (used + v.length > this.limit)
|
||||
throw Object.assign(new Error("full"), { name: "QuotaExceededError" });
|
||||
this.data.set(k, v);
|
||||
}
|
||||
removeItem(k) {
|
||||
this.removed.push(k);
|
||||
this.data.delete(k);
|
||||
}
|
||||
}
|
||||
const storage = new Storage();
|
||||
storage.setItem("unrelated", "keep");
|
||||
assert.equal(canStoreDraft({ nodes: [] }, { storage }), true);
|
||||
assert.equal(canStoreDraft({ data: "x".repeat(20000) }, { storage }), false);
|
||||
assert.equal(storage.getItem("unrelated"), "keep");
|
||||
assert.ok(storage.removed.every((key) => key.startsWith("Adachi.DraftProbe:")));
|
||||
assert.equal(storage.data.size, 1);
|
||||
// Replacing a draft must reuse its existing space, rather than testing two full copies.
|
||||
const path = "workflows/Test.json";
|
||||
let hash = 2166136261;
|
||||
for (const c of path) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619);
|
||||
const payloadKey = `Comfy.Workflow.Draft.v2:personal:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
||||
const oldGraph = { image: "x".repeat(6000) },
|
||||
oldRaw = JSON.stringify({
|
||||
data: JSON.stringify(oldGraph),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
storage.setItem(
|
||||
"Comfy.Workflow.DraftIndex.v2:personal",
|
||||
JSON.stringify({ entries: { one: { path } } }),
|
||||
);
|
||||
storage.setItem(payloadKey, oldRaw);
|
||||
assert.equal(
|
||||
canStoreDraft({ ...oldGraph, mode: "crop" }, { path, storage }),
|
||||
true,
|
||||
);
|
||||
assert.equal(storage.getItem(payloadKey), oldRaw);
|
||||
assert.equal(
|
||||
canStoreDraft({ image: "x".repeat(12000) }, { path, storage }),
|
||||
false,
|
||||
);
|
||||
assert.equal(storage.getItem(payloadKey), oldRaw);
|
||||
assert.equal(
|
||||
canStoreDraft(
|
||||
{},
|
||||
{
|
||||
storage: {
|
||||
getItem() {
|
||||
throw Error("blocked");
|
||||
},
|
||||
setItem() {
|
||||
throw Object.assign(Error("blocked"), { name: "SecurityError" });
|
||||
},
|
||||
removeItem() {},
|
||||
},
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
console.log(
|
||||
"Draft guard: quota detection, replacement credit, probe cleanup and preservation of existing storage passed",
|
||||
);
|
||||
|
||||
const large = "data:image/png;base64," + "a".repeat(300000);
|
||||
const node = () => ({ type: "AdachiInlineMask", widgets_values: ["old.png", "mask", large, "crop", "region", "blend", "layer.png"], widgets_values_named: { image: "stale.png", mask_data: "stale-mask", source_data: large, edit_mode: "mask" } });
|
||||
const first = node(), nested = node();
|
||||
const graph = { nodes: [first, {type:"Other", widgets_values:[large]}], definitions: {subgraphs:[{nodes:[nested]}]} };
|
||||
const original = structuredClone(graph);
|
||||
let uploads = 0;
|
||||
assert.equal(await prepareInlineDraft(graph, { fits: g => g.nodes[0].widgets_values[2].length < 100 && g.definitions.subgraphs[0].nodes[0].widgets_values[2].length < 100, upload: async bytes => { assert.equal(bytes,large); return `source-${++uploads}.png`; } }),2);
|
||||
assert.equal(uploads,2);
|
||||
for (const n of [first,nested]) {
|
||||
assert.equal(n.widgets_values[2],"");
|
||||
assert.equal(n.widgets_values_named.source_data,"");
|
||||
assert.equal(n.widgets_values_named.image,n.widgets_values[0]);
|
||||
assert.equal(n.widgets_values_named.mask_data,"mask");
|
||||
assert.deepEqual(n.widgets_values.slice(3),["crop","region","blend","layer.png"]);
|
||||
}
|
||||
assert.deepEqual(graph.nodes[1],original.nodes[1]);
|
||||
for (const fits of [() => true, () => null]) {
|
||||
const g = {nodes:[node()]};
|
||||
assert.equal(await prepareInlineDraft(g,{fits,upload:()=>{throw Error("unexpected upload")}}),0);
|
||||
assert.equal(g.nodes[0].widgets_values[2],large);
|
||||
}
|
||||
const failure = {nodes:[node()]};
|
||||
await assert.rejects(prepareInlineDraft(failure,{fits:()=>false,upload:async()=>{throw Error("upload failed")}}),/upload failed/);
|
||||
assert.equal(failure.nodes[0].widgets_values[2],large);
|
||||
assert.equal(failure.nodes[0].widgets_values[0],"old.png");
|
||||
const namedOnly = {nodes:[{type:"AdachiInlineMask", widgets_values_named:{source_data:large,mask_data:"keep"}}]};
|
||||
await prepareInlineDraft(namedOnly,{fits:()=>false,upload:async()=>"named.png"});
|
||||
assert.equal(namedOnly.nodes[0].widgets_values_named.source_data,"");
|
||||
assert.equal(namedOnly.nodes[0].widgets_values_named.mask_data,"keep");
|
||||
const widgets=inlineWidgetNames.map((name,index)=>({name,value:`live-${index}`}));
|
||||
widgets.splice(1,0,{name:"unrelated",value:"untouched"});
|
||||
const snapshot={widgets_values:Array(8).fill("old"),widgets_values_named:{source_data:large,unrelated:"keep"}};
|
||||
syncInlineWidgets(snapshot,widgets);
|
||||
assert.equal(snapshot.widgets_values[3],"live-2");
|
||||
assert.equal(snapshot.widgets_values[1],"old");
|
||||
assert.equal(snapshot.widgets_values_named.source_data,"live-2");
|
||||
assert.equal(snapshot.widgets_values_named.unrelated,"keep");
|
||||
const arrayOnly={widgets_values:[]}; syncInlineWidgets(arrayOnly,widgets);
|
||||
assert.equal(arrayOnly.widgets_values_named,undefined);
|
||||
console.log("Draft preflight: root/subgraph sources, dual snapshots, unchanged masks/layers, fitting sources and failed uploads passed");
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The selector must preserve arbitrary values and never silently prefer a branch."""
|
||||
import importlib.util
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
@@ -14,6 +15,17 @@ 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"])
|
||||
@@ -42,6 +54,123 @@ class ExclusiveSwitchTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {removeLegacyFeedback} from '../web/legacy_feedback.js';
|
||||
const make=()=>({nodes:[{id:1,type:'AdachiInlineMask',inputs:[{name:'result',link:10},{name:'image',link:11}],outputs:[]},{id:2,type:'Source',inputs:[],outputs:[{links:[10,11]}]}],links:[[10,2,0,1,0,'IMAGE'],[11,2,0,1,1,'STRING']]});
|
||||
const graph=make();graph.definitions={subgraphs:[make()]};removeLegacyFeedback(graph);
|
||||
for(const g of [graph,...graph.definitions.subgraphs]){
|
||||
assert.deepEqual(g.nodes[0].inputs,[{name:'image',link:11}]);assert.deepEqual(g.links,[[11,2,0,1,0,'STRING']]);assert.deepEqual(g.nodes[1].outputs[0].links,[11]);
|
||||
}
|
||||
removeLegacyFeedback(graph);assert.equal(graph.links.length,1);
|
||||
const modern=make();modern.links=modern.links.map(([id,origin_id,origin_slot,target_id,target_slot,type])=>({id,origin_id,origin_slot,target_id,target_slot,type}));removeLegacyFeedback(modern);assert.equal(modern.links.length,1);assert.equal(modern.links[0].target_slot,0);
|
||||
const orphan=make();orphan.nodes[0].inputs=[{name:'image',link:null}];removeLegacyFeedback(orphan);assert.equal(orphan.links.length,0);
|
||||
console.log('Legacy feedback migration: tuple/object links, slot reindexing, nested graphs, orphan cleanup and idempotence passed');
|
||||
+39
-12
@@ -35,7 +35,10 @@ export class ImageCompareView {
|
||||
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.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", T("Adopt into")));
|
||||
@@ -115,7 +118,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 {
|
||||
@@ -150,21 +155,43 @@ 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() {
|
||||
|
||||
+12
-1
@@ -11,5 +11,16 @@ export function appendComparison(previous, incoming) {
|
||||
};
|
||||
};
|
||||
const before = pairs(previous), next = pairs(incoming);
|
||||
return { originals: [...before.originals, ...next.originals], results: [...before.results, ...next.results] };
|
||||
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;
|
||||
}
|
||||
|
||||
+25
-7
@@ -1,7 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { ImageCompareView } from "./compare.js?v=0.5.7";
|
||||
import { appendComparison } from "./compare_history.js?v=0.5.7";
|
||||
import { ImageCompareView } from "./compare.js?v=0.7.2";
|
||||
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";
|
||||
@@ -48,7 +48,15 @@ app.registerExtension({
|
||||
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"));
|
||||
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }));
|
||||
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }), image.adachi_origin);
|
||||
// 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;
|
||||
@@ -62,18 +70,28 @@ app.registerExtension({
|
||||
node.onExecuted = function (output) {
|
||||
const result = executed?.apply(this, arguments);
|
||||
if (!output?.adachi_originals?.length || !output?.adachi_results?.length) return result;
|
||||
const data = appendComparison(node.properties.adachi_compare, {
|
||||
originals: output.adachi_originals, results: output.adachi_results,
|
||||
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.map(image => output.adachi_origin?.[0]
|
||||
? {...image, adachi_origin:output.adachi_origin[0]} : image),
|
||||
});
|
||||
node.properties.adachi_compare = data;
|
||||
node.properties.adachi_result_index = data.results.length - 1;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const CROP_PRESETS = [
|
||||
[1024, 1536],
|
||||
[512, 512],
|
||||
[768, 1024],
|
||||
[1024, 1024],
|
||||
[1536, 2048],
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
export function cropAt(width, height, cropWidth, cropHeight, x, y) {
|
||||
if (
|
||||
![width, height, cropWidth, cropHeight, x, y].every(Number.isFinite) ||
|
||||
cropWidth < 32 ||
|
||||
cropHeight < 32 ||
|
||||
cropWidth % 32 ||
|
||||
cropHeight % 32 ||
|
||||
cropWidth > width ||
|
||||
cropHeight > height
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
x: Math.max(0, Math.min(width - cropWidth, Math.floor(x))),
|
||||
y: Math.max(0, Math.min(height - cropHeight, Math.floor(y))),
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Probe only our own temporary key. Existing ComfyUI drafts are never edited or deleted.
|
||||
export const inlineWidgetNames = [
|
||||
"image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image",
|
||||
];
|
||||
|
||||
// Some extensions also serialize a named snapshot. Keep it in step with the
|
||||
// actual widgets, otherwise a removed embedding can survive in that snapshot.
|
||||
export function syncInlineWidgets(data, widgets) {
|
||||
for (const name of inlineWidgetNames) {
|
||||
const index = widgets.findIndex((widget) => widget.name === name);
|
||||
if (index < 0) continue;
|
||||
if (Array.isArray(data.widgets_values)) data.widgets_values[index] = widgets[index].value;
|
||||
if (data.widgets_values_named && typeof data.widgets_values_named === "object")
|
||||
data.widgets_values_named[name] = widgets[index].value;
|
||||
}
|
||||
}
|
||||
|
||||
function inlineNodes(graph, seen = new Set()) {
|
||||
if (!graph || seen.has(graph)) return [];
|
||||
seen.add(graph);
|
||||
return [
|
||||
...(graph.nodes || []).filter((node) => node.type === "AdachiInlineMask"),
|
||||
...(graph.definitions?.subgraphs || []).flatMap((subgraph) => inlineNodes(subgraph, seen)),
|
||||
];
|
||||
}
|
||||
|
||||
// Called before graph configuration: native autosave must never see the large
|
||||
// source first, since a quota failure disables its storage for the entire tab.
|
||||
export async function prepareInlineDraft(graph, { fits, upload }) {
|
||||
const nodes = inlineNodes(graph);
|
||||
for (const node of nodes) {
|
||||
if (!Array.isArray(node.widgets_values)) continue;
|
||||
for (const [index, name] of inlineWidgetNames.entries()) {
|
||||
if (index < node.widgets_values.length && node.widgets_values_named)
|
||||
node.widgets_values_named[name] = node.widgets_values[index];
|
||||
}
|
||||
}
|
||||
const candidates = nodes.filter((node) => {
|
||||
const embedded = node.widgets_values?.[2] ?? node.widgets_values_named?.source_data;
|
||||
return typeof embedded === "string" && embedded.startsWith("data:") && embedded.length >= 256 * 1024;
|
||||
}).sort((a, b) => (b.widgets_values?.[2] ?? b.widgets_values_named.source_data).length - (a.widgets_values?.[2] ?? a.widgets_values_named.source_data).length);
|
||||
let converted = 0;
|
||||
for (const node of candidates) {
|
||||
if (fits(graph) !== false) break;
|
||||
const embedded = node.widgets_values?.[2] ?? node.widgets_values_named.source_data;
|
||||
const filename = await upload(embedded);
|
||||
if (typeof filename !== "string" || !filename) throw new Error("Source upload returned no filename");
|
||||
// Commit only after a successful exact upload. All masks and layers stay intact.
|
||||
if (Array.isArray(node.widgets_values)) {
|
||||
node.widgets_values[0] = filename;
|
||||
node.widgets_values[2] = "";
|
||||
}
|
||||
if (node.widgets_values_named) {
|
||||
node.widgets_values_named.image = filename;
|
||||
node.widgets_values_named.source_data = "";
|
||||
}
|
||||
converted++;
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
export function canStoreDraft(
|
||||
graph,
|
||||
{ path = "", workspace = "personal", storage } = {},
|
||||
) {
|
||||
try {
|
||||
storage ??= globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const serialized = JSON.stringify({
|
||||
data: JSON.stringify(graph),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
let credit = 0;
|
||||
try {
|
||||
if (path) {
|
||||
const index = JSON.parse(
|
||||
storage.getItem(`Comfy.Workflow.DraftIndex.v2:${workspace}`) || "null",
|
||||
);
|
||||
if (
|
||||
Object.values(index?.entries || {}).some((entry) => entry.path === path)
|
||||
) {
|
||||
// Standard FNV-1a path hash used by the current ComfyUI draft format.
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < path.length; i++)
|
||||
hash = Math.imul(hash ^ path.charCodeAt(i), 16777619);
|
||||
const key = `Comfy.Workflow.Draft.v2:${workspace}:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
||||
credit = storage.getItem(key)?.length || 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Unknown metadata: a conservative probe still preserves all existing drafts. */
|
||||
}
|
||||
const needed = Math.max(0, serialized.length - credit);
|
||||
if (!needed) return true;
|
||||
const key = `Adachi.DraftProbe:${crypto.randomUUID()}`;
|
||||
try {
|
||||
storage.setItem(key, " ".repeat(needed));
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.name === "QuotaExceededError" ? false : null;
|
||||
} finally {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
/* Storage may be unavailable. */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
.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-inline-minimal.dd-crop-mode > .dd-status:not([hidden]) { bottom: 40px; }
|
||||
.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; }
|
||||
@@ -118,3 +119,26 @@
|
||||
.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; }
|
||||
|
||||
/* Compact controls shared by the mask and crop/blend modes. */
|
||||
.dd-inline-minimal .dd-mode-select { position:absolute; z-index:3; top:5px; right:5px; max-width:42%; color:var(--dd-text); background:#252b34e8; border:1px solid var(--dd-line); border-radius:4px; height:23px; font:11px "Segoe UI","Microsoft YaHei",sans-serif; padding:1px 3px; pointer-events:auto; }
|
||||
.dd-inline-minimal .dd-crop-bar { position:absolute; z-index:3; bottom:7px; left:5px; right:5px; display:flex; gap:3px; flex-wrap:wrap; align-items:center; width:fit-content; max-width:calc(100% - 10px); background:#20242be8; border-radius:4px; padding:3px; pointer-events:auto; }
|
||||
.dd-inline-minimal .dd-crop-bar button { min-height:23px; height:23px; padding:1px 6px; min-width:24px; }
|
||||
.dd-inline-minimal .dd-crop-bar select,.dd-inline-minimal .dd-crop-custom input { color:var(--dd-text); background:var(--dd-panel); border:1px solid var(--dd-line); border-radius:3px; height:23px; font:11px "Segoe UI","Microsoft YaHei",sans-serif; max-width:112px; }
|
||||
.dd-inline-minimal .dd-crop-custom { display:flex; gap:3px; }
|
||||
.dd-inline-minimal .dd-crop-custom input { width:66px; }
|
||||
.dd-inline-minimal > .dd-files { right:auto; }
|
||||
|
||||
|
||||
.dd-workbench .dd-workbench-fold {flex:0 0 auto;border-bottom:1px solid var(--dd-line);}
|
||||
.dd-workbench .dd-workbench-fold:not([open]) > :not(summary) {display:none !important;}
|
||||
.dd-workbench .dd-workbench-fold>summary {padding:6px 10px;color:var(--dd-muted);font-size:11px;cursor:pointer;list-style:none;display:flex;gap:6px;align-items:center;}
|
||||
.dd-workbench .dd-workbench-fold>summary::before {content:"▸";font-size:10px;color:var(--dd-accent);}
|
||||
.dd-workbench .dd-workbench-fold[open]>summary::before {content:"▾";}
|
||||
.dd-workbench .dd-workbench-fold>summary:hover {color:var(--dd-text);background:#ffffff05;}
|
||||
.dd-workbench .dd-workbench-fold>summary:focus-visible {outline:1px solid var(--dd-accent);outline-offset:-2px;}
|
||||
.dd-workbench .dd-param-mask-settings {display:flex;flex-direction:column;gap:8px;padding:7px 12px 9px;}
|
||||
.dd-workbench .dd-workbench-actions {flex:0 0 auto;}
|
||||
.dd-workbench .dd-workbench-actions:empty {display:none;}
|
||||
.dd-workbench .dd-workbench-actions .dd-actions {padding:6px 10px;gap:4px;}
|
||||
.dd-workbench .dd-workbench-actions .dd-actions button {min-height:24px;padding:3px 7px;}
|
||||
|
||||
+115
-31
@@ -24,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;
|
||||
@@ -92,7 +94,8 @@ export class InlineMaskEditor {
|
||||
head.append(this.sizeLabel);
|
||||
this.root.append(head);
|
||||
const files = el("div", "dd-row dd-files");
|
||||
this.loadButton = this.button(files, T("Load image"), T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
|
||||
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);
|
||||
@@ -119,7 +122,7 @@ export class InlineMaskEditor {
|
||||
const tools = el("div", "dd-row dd-tools");
|
||||
this.toolButtons = {};
|
||||
for (const [name, label, title] of [
|
||||
["brush", "Brush", "Brush (B)"], ["erase", "Eraser", "Eraser (E); hold Alt or right-click for temporary erase"],
|
||||
["brush", "Brush", "Brush (B)"], ["erase", "Eraser", "Eraser (E); hold Alt 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));
|
||||
@@ -137,7 +140,7 @@ export class InlineMaskEditor {
|
||||
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();
|
||||
this.canvas.oncontextmenu = event => { event.preventDefault(); event.stopPropagation(); };
|
||||
this.canvas.addEventListener("pointerdown", event => this.pointerDown(event));
|
||||
this.canvas.addEventListener("pointermove", event => this.pointerMove(event));
|
||||
this.canvas.addEventListener("pointerup", event => this.pointerUp(event));
|
||||
@@ -152,6 +155,7 @@ export class InlineMaskEditor {
|
||||
if (file) this.upload(file);
|
||||
});
|
||||
const actions = el("div", "dd-row dd-actions");
|
||||
this.actions = actions;
|
||||
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" }));
|
||||
@@ -170,7 +174,9 @@ export class InlineMaskEditor {
|
||||
footer.append(spacer);
|
||||
this.zoomLabel = el("span", "dd-zoom", "100%");
|
||||
footer.append(this.zoomLabel);
|
||||
this.fitButton = this.button(footer, T("Fit"), T("Fit the image to the canvas"), () => this.fit());
|
||||
this.fitButton = this.button(footer, "⛶", T("Fit the image to the canvas"), () => this.fit());
|
||||
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.
|
||||
const fitAction = this.fitButton.onclick;
|
||||
@@ -193,7 +199,10 @@ export class InlineMaskEditor {
|
||||
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);
|
||||
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.
|
||||
}
|
||||
@@ -218,18 +227,29 @@ 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; }
|
||||
try {
|
||||
this.status(T("Loading image…"));
|
||||
await this.options.onUpload?.(file);
|
||||
// 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 });
|
||||
} catch (error) { this.status(error.message, true); }
|
||||
}
|
||||
|
||||
async setImage(url, maskData = "", name = "") {
|
||||
this.stroke = null; this.pointerId = null;
|
||||
const version = ++this.loadVersion;
|
||||
this.ready = false;
|
||||
try {
|
||||
@@ -277,10 +297,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();
|
||||
@@ -288,6 +308,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) {
|
||||
@@ -315,6 +336,9 @@ 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);
|
||||
@@ -347,6 +371,7 @@ export class InlineMaskEditor {
|
||||
}
|
||||
|
||||
pointerDown(event) {
|
||||
if (event.button === 2) { event.preventDefault(); event.stopPropagation(); return; }
|
||||
if (!this.ready || this.pointerId != null) return;
|
||||
event.preventDefault(); event.stopPropagation(); this.root.focus({ preventScroll: true });
|
||||
this.altErase = !!event.altKey; this.updateButtons();
|
||||
@@ -358,7 +383,7 @@ export class InlineMaskEditor {
|
||||
return;
|
||||
}
|
||||
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.stroke = { type: 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();
|
||||
@@ -376,7 +401,7 @@ export class InlineMaskEditor {
|
||||
} else if (this.stroke) {
|
||||
const events = event.getCoalescedEvents?.() || [event];
|
||||
if (["brush", "erase"].includes(this.stroke.type)) {
|
||||
const type = event.altKey || (event.buttons & 2) ? "erase" : this.tool;
|
||||
const type = event.altKey ? "erase" : this.tool;
|
||||
if (["brush", "erase"].includes(type) && type !== this.stroke.type) {
|
||||
// Keep separate commands when Alt is pressed/released mid-stroke.
|
||||
const pivot = this.stroke.points.at(-1);
|
||||
@@ -415,7 +440,10 @@ export class InlineMaskEditor {
|
||||
}
|
||||
|
||||
paintSegment(ctx, command, a, b) {
|
||||
if (ctx.canvas === this.mask) this.maskRevision++;
|
||||
ctx.save();
|
||||
const bounds = this.options.paintBounds?.();
|
||||
if (bounds) { ctx.beginPath(); ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.clip(); }
|
||||
ctx.globalCompositeOperation = command.type === "erase" ? "destination-out" : "source-over";
|
||||
ctx.globalAlpha = command.opacity / 100;
|
||||
if (command.hardness >= 99 && a.p === 1 && b.p === 1) {
|
||||
@@ -442,6 +470,9 @@ export class InlineMaskEditor {
|
||||
}
|
||||
|
||||
drawCommand(ctx, command) {
|
||||
ctx.save();
|
||||
const bounds = this.options.paintBounds?.();
|
||||
if (bounds) { ctx.beginPath(); ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.clip(); }
|
||||
const w = this.mask.width, h = this.mask.height;
|
||||
if (["brush", "erase"].includes(command.type)) {
|
||||
this.paintSegment(ctx, command, command.points[0], command.points[0]);
|
||||
@@ -450,7 +481,14 @@ export class InlineMaskEditor {
|
||||
else if (command.type === "fill") { ctx.save(); ctx.fillStyle = "white"; ctx.fillRect(0, 0, w, h); ctx.restore(); }
|
||||
else if (command.type === "invert") {
|
||||
ctx.save(); ctx.globalCompositeOperation = "xor"; ctx.fillStyle = "white"; ctx.fillRect(0, 0, w, h); ctx.restore();
|
||||
} else if (command.type === "import") this.importMask(ctx, command.image);
|
||||
} else if (command.type === "import") {
|
||||
if (!bounds) this.importMask(ctx, command.image);
|
||||
else {
|
||||
const imported = makeCanvas(); imported.width = w; imported.height = h;
|
||||
this.importMask(imported.getContext("2d"), command.image);
|
||||
ctx.clearRect(0, 0, w, h); ctx.drawImage(imported, 0, 0); imported.width = imported.height = 0;
|
||||
}
|
||||
}
|
||||
else if (["rect", "lasso"].includes(command.type)) {
|
||||
const a = command.points[0], b = command.points.at(-1);
|
||||
ctx.save(); ctx.fillStyle = "white"; ctx.globalAlpha = command.opacity / 100;
|
||||
@@ -458,12 +496,18 @@ export class InlineMaskEditor {
|
||||
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); for (const p of command.points) ctx.lineTo(p.x, p.y); ctx.closePath(); ctx.fill(); }
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
remember(command) {
|
||||
this.history.push(command); this.future = [];
|
||||
// Keep commands rather than full-resolution pixel snapshots; memory stays small.
|
||||
if (this.history.length > 500) this.drawCommand(this.baseMask.getContext("2d"), this.history.shift());
|
||||
let bitmapBytes = this.history.reduce((total, item) => total + (item.image ? item.image.width * item.image.height * 4 : 0), 0);
|
||||
while (this.history.length > 500 || (this.history.length > 1 && bitmapBytes > 64 * 1024 * 1024)) {
|
||||
const first = this.history.shift();
|
||||
this.drawCommand(this.baseMask.getContext("2d"), first);
|
||||
bitmapBytes -= first.image ? first.image.width * first.image.height * 4 : 0;
|
||||
}
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
@@ -483,6 +527,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();
|
||||
@@ -523,8 +568,29 @@ 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;
|
||||
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"));
|
||||
@@ -533,6 +599,13 @@ export class InlineMaskEditor {
|
||||
wheel(event) {
|
||||
if (!this.ready) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
|
||||
this.hardnessInput.value = Math.max(0, Math.min(100, this.hardness + (delta < 0 ? 5 : delta > 0 ? -5 : 0)));
|
||||
this.hardnessInput.dispatchEvent(new Event("input"));
|
||||
this.cursor = this.point(event); this.schedule(); return;
|
||||
}
|
||||
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) {
|
||||
@@ -567,27 +640,38 @@ export class InlineMaskEditor {
|
||||
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
|
||||
|
||||
render() {
|
||||
const view = this.options.displayView?.() || this.view;
|
||||
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, view, this.overlayOpacity, this.options.sceneRevision?.() || 0].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 (view !== "mask") { if (!this.options.drawSource?.(ctx)) ctx.drawImage(this.image, 0, 0); }
|
||||
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
|
||||
if (view !== "image" && (view === "mask" || this.options.showMask?.() !== false)) {
|
||||
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 (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 = 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);
|
||||
this.options.drawOverlay?.(ctx, d);
|
||||
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]);
|
||||
const a = this.stroke.points[0], b = this.stroke.points.at(-1);
|
||||
@@ -595,7 +679,7 @@ export class InlineMaskEditor {
|
||||
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); for (const p of this.stroke.points) ctx.lineTo(p.x, p.y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
if (this.cursor && (this.altErase || ["brush", "erase"].includes(this.tool)) && !this.drag) {
|
||||
if (this.cursor && (this.altErase || ["brush", "erase"].includes(this.tool)) && !this.drag && this.options.showCursor?.() !== false) {
|
||||
const erasing = this.altErase || this.tool === "erase" || this.stroke?.type === "erase";
|
||||
ctx.save();
|
||||
if (erasing) ctx.setLineDash([4 / d.scale, 3 / d.scale]);
|
||||
@@ -613,7 +697,7 @@ export class InlineMaskEditor {
|
||||
window.removeEventListener("keydown", this.modifierDown, true);
|
||||
window.removeEventListener("keyup", this.modifierUp, true);
|
||||
window.removeEventListener("blur", this.modifierUp);
|
||||
for (const canvas of [this.mask, this.baseMask, this.overlay, this.canvas]) canvas.width = canvas.height = 0;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+34
-3
@@ -6,6 +6,33 @@
|
||||
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
|
||||
|
||||
const ZH = {
|
||||
"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.": "交换宽高并取消当前选定,再左键点击图片选择新区域。",
|
||||
"Edit layer blending: brush keeps the new result; Alt restores original pixels. Hold right-click to preview the original, release to return.": "编辑融合蒙版:画笔保留重绘结果,Alt 擦回原图。按住右键查看原图,松开恢复当前工作区。",
|
||||
"Merge the visible result and blend mask into the source image. After merging, right-click releases the selected region.": "将当前可见结果和融合蒙版合并到原图。合并后,右键恢复为取消选区。",
|
||||
"Undo the last stroke in the active repaint or blend mask (Ctrl+Z). Redo with Ctrl+Shift+Z.": "撤销当前重绘蒙版或融合蒙版的上一笔(Ctrl+Z);Ctrl+Shift+Z 可重做。",
|
||||
"Custom crop dimension in 64-pixel steps. Changing it releases the selected region.": "自定义裁切宽高,按 64 像素递增;修改尺寸会取消当前选定。",
|
||||
"Paint inside the selected region. Right-click releases the selection; left-click places a new one. Alt: erase. Ctrl+wheel: brush size; Shift+wheel: hardness.": "在选区内涂蒙版。右键取消选定,再左键选择新区域;Alt 擦除;Ctrl+滚轮调笔径,Shift+滚轮调硬度。",
|
||||
"Select a crop region before adopting a result": "请先选择裁切区域再采用结果",
|
||||
"Finish editing before adopting a result": "请结束当前编辑后再采用结果",
|
||||
"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": "编辑模式",
|
||||
"Crop & Blend": "裁切融合",
|
||||
"Crop size": "裁切尺寸",
|
||||
"Custom": "自定义",
|
||||
"Swap crop dimensions": "交换宽高",
|
||||
"Choose another region": "重新选区(自动合并当前图层)",
|
||||
"Repaint mask": "重绘蒙版",
|
||||
"Blend layers: brush keeps new pixels; Alt restores original": "图层融合:画笔保留新图,Alt 擦回原图",
|
||||
"Merge the visible layers": "合并当前可见图层",
|
||||
"Group Bypass Presets": "组忽略预设 / Group Bypass Presets",
|
||||
"Switch saved group enable/bypass presets; no connections required.": "切换已保存的分组启用/忽略预设,无需接线。",
|
||||
"Group presets": "分组预设",
|
||||
@@ -56,7 +83,7 @@ const ZH = {
|
||||
"Brush": "画笔",
|
||||
"Brush (B)": "画笔 (B)",
|
||||
"Eraser": "橡皮",
|
||||
"Eraser (E); hold Alt or right-click for temporary erase": "橡皮 (E),按住 Alt 或鼠标右键临时擦除",
|
||||
"Eraser (E); hold Alt for temporary erase": "橡皮 (E),按住 Alt 临时擦除",
|
||||
"Rectangle": "矩形",
|
||||
"Rectangle (R)": "矩形 (R)",
|
||||
"Lasso": "套索",
|
||||
@@ -86,6 +113,10 @@ const ZH = {
|
||||
"Image": "原图",
|
||||
"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",
|
||||
"Loading image…": "正在载入原图…",
|
||||
@@ -96,8 +127,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": "上传失败",
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
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.7.2";
|
||||
|
||||
const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"];
|
||||
const canvas = (w, h) =>
|
||||
Object.assign(document.createElement("canvas"), { width: w, height: h });
|
||||
const load = async (url) => {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
return image;
|
||||
};
|
||||
const make = (tag, cls) => {
|
||||
const e = document.createElement(tag);
|
||||
e.className = cls;
|
||||
return e;
|
||||
};
|
||||
|
||||
export class InlineCropController {
|
||||
constructor(
|
||||
node,
|
||||
editor,
|
||||
{ find, hide, changed, imageUrl, beforeModeChange },
|
||||
) {
|
||||
Object.assign(this, { node, editor, changed, imageUrl, beforeModeChange });
|
||||
this.widgets = Object.fromEntries(
|
||||
["edit_mode", "crop_state", "blend_data", "layer_image"].map((name) => [
|
||||
name,
|
||||
find(name),
|
||||
]),
|
||||
);
|
||||
Object.values(this.widgets).forEach(hide);
|
||||
this.contexts = {};
|
||||
this.target = "mask";
|
||||
this.layer = null;
|
||||
this.revision = 0;
|
||||
this.acceptVersion = 0;
|
||||
this.cropWidth = 1024;
|
||||
this.cropHeight = 1536;
|
||||
this.patchCanvas = canvas(1, 1);
|
||||
this.build();
|
||||
installResultAdoption(node, this);
|
||||
const originalFlush = editor.flush.bind(editor);
|
||||
editor.flush = () => {
|
||||
if (this.target !== "blend") return originalFlush();
|
||||
if (!editor.ready || !this.region?.locked)
|
||||
return this.widgets.blend_data.value;
|
||||
if (!editor.dirty && !editor.stroke) return this.widgets.blend_data.value;
|
||||
const r = this.region,
|
||||
out = canvas(r.width, r.height),
|
||||
ctx = out.getContext("2d");
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(0, 0, r.width, r.height);
|
||||
ctx.drawImage(
|
||||
editor.mask,
|
||||
r.x,
|
||||
r.y,
|
||||
r.width,
|
||||
r.height,
|
||||
0,
|
||||
0,
|
||||
r.width,
|
||||
r.height,
|
||||
);
|
||||
this.widgets.blend_data.value = out.toDataURL("image/png");
|
||||
out.width = out.height = 0;
|
||||
editor.dirty = false;
|
||||
return this.widgets.blend_data.value;
|
||||
};
|
||||
editor.options.sceneRevision = () =>
|
||||
`${this.mode}:${this.target}:${this.revision}`;
|
||||
editor.options.drawSource = (ctx) => this.drawSource(ctx, {preview:this.previewOriginal});
|
||||
editor.options.displayView = () => this.previewOriginal ? "image" : editor.view;
|
||||
editor.options.showCursor = () => !this.previewOriginal;
|
||||
editor.options.showMask = () =>
|
||||
this.mode !== "crop" || (this.target === "mask" && this.region?.locked);
|
||||
editor.options.drawOverlay = (ctx, d) => this.drawOverlay(ctx, d);
|
||||
editor.options.paintBounds = () =>
|
||||
this.mode === "crop" && this.region?.locked ? this.region : undefined;
|
||||
const down = editor.pointerDown.bind(editor),
|
||||
move = editor.pointerMove.bind(editor);
|
||||
editor.pointerDown = (e) => {
|
||||
if (!editor.ready) return;
|
||||
if (this.previewOriginal) { e.preventDefault(); e.stopPropagation(); return; }
|
||||
if (this.busy) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (this.mode === "crop" && e.button === 2) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
editor.root.focus({ preventScroll: true });
|
||||
// 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.region.locked) this.run(() => this.reselect());
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.mode === "crop" &&
|
||||
!this.region?.locked &&
|
||||
e.button === 0 &&
|
||||
!editor.space
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
editor.root.focus({ preventScroll: true });
|
||||
const p = editor.point(e),
|
||||
r = cropAt(
|
||||
editor.image.width,
|
||||
editor.image.height,
|
||||
this.cropWidth,
|
||||
this.cropHeight,
|
||||
p.x,
|
||||
p.y,
|
||||
);
|
||||
if (r) this.lock(r);
|
||||
return;
|
||||
}
|
||||
down(e);
|
||||
};
|
||||
editor.pointerMove = (e) => {
|
||||
if (this.previewOriginal) { editor.cursor = null; return; }
|
||||
if (
|
||||
this.mode === "crop" &&
|
||||
editor.ready &&
|
||||
!this.region?.locked &&
|
||||
!editor.drag
|
||||
) {
|
||||
const p = editor.point(e);
|
||||
this.hover = cropAt(
|
||||
editor.image.width,
|
||||
editor.image.height,
|
||||
this.cropWidth,
|
||||
this.cropHeight,
|
||||
p.x,
|
||||
p.y,
|
||||
);
|
||||
}
|
||||
move(e);
|
||||
};
|
||||
this.finishPreview = event => {
|
||||
if (event.pointerId !== this.originalPreviewPointer) return;
|
||||
if (event.type === "pointerup" && (event.buttons & 2)) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
this.stopOriginalPreview();
|
||||
};
|
||||
for (const name of ["pointerup", "pointercancel"]) {
|
||||
window.addEventListener(name, this.finishPreview, true);
|
||||
}
|
||||
editor.canvas.addEventListener("lostpointercapture", this.finishPreview, true);
|
||||
this.previewBlur = () => this.stopOriginalPreview();
|
||||
window.addEventListener("blur", this.previewBlur);
|
||||
editor.canvas.addEventListener("pointerleave", () => {
|
||||
this.hover = null;
|
||||
editor.schedule();
|
||||
});
|
||||
editor.root.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.mode === "crop" && !editor.stroke) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.reselect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.widgets.edit_mode.value || "mask";
|
||||
}
|
||||
stopOriginalPreview() {
|
||||
if (!this.previewOriginal) return;
|
||||
const pointer = this.originalPreviewPointer;
|
||||
this.previewOriginal = false;
|
||||
this.originalPreviewPointer = null;
|
||||
if (pointer != null && this.editor.canvas.hasPointerCapture(pointer)) {
|
||||
this.editor.canvas.releasePointerCapture(pointer);
|
||||
}
|
||||
this.revision++;
|
||||
this.editor.schedule();
|
||||
}
|
||||
get region() {
|
||||
try {
|
||||
return JSON.parse(this.widgets.crop_state.value || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
state(value) {
|
||||
this.widgets.crop_state.value = JSON.stringify(value);
|
||||
this.revision++;
|
||||
}
|
||||
snapshot() {
|
||||
return Object.fromEntries(fields.map((k) => [k, this.editor[k]]));
|
||||
}
|
||||
activate(target) {
|
||||
if (this.target === target) return;
|
||||
this.editor.flush();
|
||||
this.contexts[this.target] = this.snapshot();
|
||||
const next = this.contexts[target];
|
||||
if (!next) return;
|
||||
Object.assign(this.editor, next);
|
||||
this.target = target;
|
||||
this.editor.maskRevision++;
|
||||
this.editor.updateButtons();
|
||||
this.editor.schedule();
|
||||
this.sync();
|
||||
}
|
||||
build() {
|
||||
const e = this.editor;
|
||||
this.modeSelect = make("select", "dd-mode-select");
|
||||
this.modeSelect.setAttribute("aria-label", T("Editing mode"));
|
||||
this.modeSelect.title = T("Mask: paint the full image. Crop & Blend: select a region, inpaint it, then adopt and blend the result.");
|
||||
for (const [value, label] of [
|
||||
["mask", "Mask"],
|
||||
["crop", "Crop & Blend"],
|
||||
]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = T(label);
|
||||
this.modeSelect.append(option);
|
||||
}
|
||||
this.modeSelect.onchange = () => this.setMode(this.modeSelect.value);
|
||||
e.root.append(this.modeSelect);
|
||||
this.bar = make("div", "dd-crop-bar");
|
||||
this.preset = make("select", "");
|
||||
this.preset.setAttribute("aria-label", T("Crop size"));
|
||||
this.preset.title = T("Crop resolution presets. Changing size releases the current selection; left-click the image to place a new region. Inpaint dimensions follow when selected.");
|
||||
for (const [w, h] of CROP_PRESETS) {
|
||||
const o = document.createElement("option");
|
||||
o.value = `${w}x${h}`;
|
||||
o.textContent = `${w} × ${h}`;
|
||||
this.preset.append(o);
|
||||
}
|
||||
const custom = document.createElement("option");
|
||||
custom.value = "custom";
|
||||
custom.textContent = T("Custom");
|
||||
this.preset.append(custom);
|
||||
this.preset.onchange = () => this.run(() => {
|
||||
const custom = this.preset.value === "custom";
|
||||
const [width, height] = custom
|
||||
? [this.cropWidth, this.cropHeight]
|
||||
: this.preset.value.split("x").map(Number);
|
||||
return this.changeSize(width, height, {custom});
|
||||
});
|
||||
this.bar.append(this.preset);
|
||||
const button = (text, title, action) =>
|
||||
e.button(this.bar, text, T(title), () => this.run(action));
|
||||
this.swapButton = button("⇄", "Swap width and height, release the current selection, then left-click to place the new region.", () =>
|
||||
this.changeSize(this.cropHeight, this.cropWidth),
|
||||
);
|
||||
// Shown in both modes: one click clears the mask of the active target.
|
||||
// It sits right after the swap control in Crop & Blend, and is the only
|
||||
// visible button in Mask mode.
|
||||
this.clearButton = button("🗑", "Clear the mask", () => e.action({ type: "clear" }));
|
||||
this.swapButton.after(this.clearButton);
|
||||
this.maskButton = button("◌", "Paint inside the selected region. Right-click releases the selection; left-click places a new one. Alt: erase. Ctrl+wheel: brush size; Shift+wheel: hardness.", () => {
|
||||
this.activate("mask");
|
||||
this.changed();
|
||||
});
|
||||
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.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.custom = make("div", "dd-crop-custom");
|
||||
this.custom.hidden = true;
|
||||
for (const key of ["cropWidth", "cropHeight"]) {
|
||||
const input = make("input", "");
|
||||
input.type = "number";
|
||||
input.min = 64;
|
||||
input.max = 16384;
|
||||
input.step = 64;
|
||||
input.value = this[key];
|
||||
input.setAttribute(
|
||||
"aria-label",
|
||||
T(key === "cropWidth" ? "Width" : "Height"),
|
||||
);
|
||||
input.title = T("Custom crop dimension in 64-pixel steps. Changing it releases the selected region.");
|
||||
input.onchange = () => this.run(() => {
|
||||
const value = Math.max(64, Math.min(16384, Math.round(Number(input.value) / 64) * 64));
|
||||
return this.changeSize(key === "cropWidth" ? value : this.cropWidth,
|
||||
key === "cropHeight" ? value : this.cropHeight, {custom:true});
|
||||
});
|
||||
this.custom.append(input);
|
||||
}
|
||||
this.bar.append(this.custom);
|
||||
e.root.append(this.bar);
|
||||
this.sync();
|
||||
}
|
||||
async run(action) {
|
||||
if (
|
||||
this.busy ||
|
||||
this.editor.stroke
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
this.editor.status(error.message, true);
|
||||
}
|
||||
}
|
||||
sync() {
|
||||
this.editor.root.classList.toggle("dd-crop-mode", this.mode === "crop");
|
||||
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]) {
|
||||
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.maskButton.classList.toggle("dd-active", this.target === "mask");
|
||||
this.blendButton.classList.toggle("dd-active", this.target === "blend");
|
||||
this.modeSelect.disabled = !!this.busy;
|
||||
this.preset.disabled = !!this.busy;
|
||||
this.swapButton.disabled = !!this.busy;
|
||||
for (const input of this.custom.children)
|
||||
input.disabled = !!this.busy;
|
||||
}
|
||||
setMode(mode) {
|
||||
if (
|
||||
this.editor.stroke ||
|
||||
this.busy
|
||||
) {
|
||||
this.sync();
|
||||
return;
|
||||
}
|
||||
if (mode === this.mode) {
|
||||
this.sync();
|
||||
return;
|
||||
}
|
||||
const apply = () => {
|
||||
this.stopOriginalPreview();
|
||||
this.activate("mask");
|
||||
this.widgets.edit_mode.value = mode;
|
||||
this.state({ ...this.region, job: crypto.randomUUID() });
|
||||
this.editor.fit();
|
||||
this.sync();
|
||||
this.changed();
|
||||
};
|
||||
const recovery = this.beforeModeChange?.();
|
||||
if (!recovery?.then) {
|
||||
if (recovery !== false) apply();
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.sync();
|
||||
return recovery
|
||||
.then((ok) => {
|
||||
this.busy = false;
|
||||
if (ok !== false && !this.editor.destroyed) apply();
|
||||
else this.sync();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.busy = false;
|
||||
this.sync();
|
||||
this.editor.status(error.message, true);
|
||||
});
|
||||
}
|
||||
syncPreset() {
|
||||
const value = `${this.cropWidth}x${this.cropHeight}`;
|
||||
this.preset.value = [...this.preset.options].some((o) => o.value === value)
|
||||
? value
|
||||
: "custom";
|
||||
this.custom.hidden = this.preset.value !== "custom";
|
||||
[...this.custom.children].forEach(
|
||||
(e, i) => (e.value = i ? this.cropHeight : this.cropWidth),
|
||||
);
|
||||
}
|
||||
sizeChanged() {
|
||||
this.hover = null;
|
||||
this.editor.schedule();
|
||||
this.syncPreset();
|
||||
}
|
||||
async changeSize(width, height, {custom = false} = {}) {
|
||||
if (this.busy || this.editor.stroke) { this.syncPreset(); return; }
|
||||
try {
|
||||
// Use the same merge-before-reselect behavior as the reselect button.
|
||||
if (this.region.locked) await this.reselect();
|
||||
this.cropWidth = width;
|
||||
this.cropHeight = height;
|
||||
this.sizeChanged();
|
||||
if (custom) { this.preset.value = "custom"; this.custom.hidden = false; }
|
||||
this.sync();
|
||||
this.changed();
|
||||
} catch (error) {
|
||||
this.syncPreset();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
reset() {
|
||||
this.stopOriginalPreview();
|
||||
this.layer = null;
|
||||
this.widgets.layer_image.value = "";
|
||||
this.widgets.blend_data.value = "";
|
||||
this.contexts = {};
|
||||
this.target = "mask";
|
||||
this.hover = null;
|
||||
this.state({ locked: false, job: crypto.randomUUID() });
|
||||
const { width, height } = this.editor.image;
|
||||
for (const o of this.preset.options)
|
||||
if (o.value !== "custom") {
|
||||
const [w, h] = o.value.split("x").map(Number);
|
||||
o.disabled = w > width || h > height;
|
||||
}
|
||||
if (this.cropWidth > width || this.cropHeight > height) {
|
||||
this.cropWidth = Math.min(1024, Math.floor(width / 64) * 64);
|
||||
this.cropHeight = Math.min(1536, Math.floor(height / 64) * 64);
|
||||
}
|
||||
this.syncPreset();
|
||||
this.sync();
|
||||
this.editor.schedule();
|
||||
}
|
||||
lock(region) {
|
||||
this.activate("mask");
|
||||
this.contexts.blend = null;
|
||||
this.layer = null;
|
||||
this.widgets.layer_image.value = "";
|
||||
this.widgets.blend_data.value = "";
|
||||
this.state({ ...region, locked: true, job: crypto.randomUUID() });
|
||||
const e = this.editor;
|
||||
// A new crop only limits the painting area; the repaint mask starts empty.
|
||||
for (const c of [e.mask, e.baseMask])
|
||||
c.getContext("2d").clearRect(0, 0, c.width, c.height);
|
||||
e.history = [];
|
||||
e.future = [];
|
||||
e.changed();
|
||||
syncCropDimensions(this.node, region.width, region.height);
|
||||
this.sync();
|
||||
this.changed();
|
||||
}
|
||||
makeBlend(data = "") {
|
||||
const r = this.region,
|
||||
e = this.editor,
|
||||
mask = canvas(e.image.width, e.image.height),
|
||||
baseMask = canvas(e.image.width, e.image.height);
|
||||
const ctx = mask.getContext("2d");
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(r.x, r.y, r.width, r.height);
|
||||
baseMask.getContext("2d").drawImage(mask, 0, 0);
|
||||
this.contexts.blend = {
|
||||
mask,
|
||||
baseMask,
|
||||
history: [],
|
||||
future: [],
|
||||
lastMask: "",
|
||||
dirty: false,
|
||||
};
|
||||
if (!data) return Promise.resolve();
|
||||
return load(data).then((image) => {
|
||||
const out = canvas(r.width, r.height),
|
||||
c = out.getContext("2d");
|
||||
c.drawImage(image, 0, 0);
|
||||
const pixels = c.getImageData(0, 0, r.width, r.height);
|
||||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||||
pixels.data[i + 3] = pixels.data[i];
|
||||
pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = 255;
|
||||
}
|
||||
ctx.clearRect(0, 0, mask.width, mask.height);
|
||||
ctx.putImageData(pixels, r.x, r.y);
|
||||
const base = baseMask.getContext("2d");
|
||||
base.clearRect(0, 0, baseMask.width, baseMask.height);
|
||||
base.drawImage(mask, 0, 0);
|
||||
out.width = out.height = 0;
|
||||
});
|
||||
}
|
||||
async restore() {
|
||||
this.stopOriginalPreview();
|
||||
this.target = "mask";
|
||||
this.contexts = {};
|
||||
this.layer = null;
|
||||
const r = this.region;
|
||||
if (r.locked) {
|
||||
this.cropWidth = r.width;
|
||||
this.cropHeight = r.height;
|
||||
this.syncPreset();
|
||||
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);
|
||||
// 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, image = null} = {}) {
|
||||
if (
|
||||
this.editor.destroyed ||
|
||||
this.mode !== "crop" ||
|
||||
!this.region.locked ||
|
||||
record.job !== this.region.job ||
|
||||
(this.busy && !allowBusy)
|
||||
)
|
||||
return;
|
||||
if (this.editor.stroke) {
|
||||
this.pendingResult = record;
|
||||
clearTimeout(this.resultTimer);
|
||||
this.resultTimer = setTimeout(() => {
|
||||
const next = this.pendingResult;
|
||||
this.pendingResult = null;
|
||||
this.accept(next, {allowBusy}).catch((error) =>
|
||||
this.editor.status(error.message, true),
|
||||
);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
const name = record.image.subfolder
|
||||
? `${record.image.subfolder}/${record.image.filename}`
|
||||
: record.image.filename;
|
||||
if (name === this.widgets.layer_image.value) return;
|
||||
const version = ++this.acceptVersion;
|
||||
// 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 ||
|
||||
record.job !== this.region.job ||
|
||||
this.mode !== "crop"
|
||||
)
|
||||
return;
|
||||
this.layer = decoded;
|
||||
this.widgets.layer_image.value = name;
|
||||
// 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();
|
||||
this.changed();
|
||||
}
|
||||
drawSource(ctx, {preview = false} = {}) {
|
||||
if (this.mode !== "crop") return false;
|
||||
const e = this.editor,
|
||||
r = this.region;
|
||||
ctx.drawImage(e.image, 0, 0);
|
||||
if (!preview && this.layer && r.locked) {
|
||||
const key = `${this.revision}:${e.maskRevision}`;
|
||||
if (this.patchKey !== key) {
|
||||
this.patchCanvas.width = r.width;
|
||||
this.patchCanvas.height = r.height;
|
||||
const c = this.patchCanvas.getContext("2d");
|
||||
c.drawImage(this.layer, 0, 0);
|
||||
const mask =
|
||||
this.target === "blend" ? e.mask : this.contexts.blend?.mask;
|
||||
if (mask) {
|
||||
c.globalCompositeOperation = "destination-in";
|
||||
c.drawImage(
|
||||
mask,
|
||||
r.x,
|
||||
r.y,
|
||||
r.width,
|
||||
r.height,
|
||||
0,
|
||||
0,
|
||||
r.width,
|
||||
r.height,
|
||||
);
|
||||
c.globalCompositeOperation = "source-over";
|
||||
}
|
||||
this.patchKey = key;
|
||||
}
|
||||
ctx.drawImage(this.patchCanvas, r.x, r.y);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
drawOverlay(ctx, d) {
|
||||
if (this.mode !== "crop") return;
|
||||
const r = this.region.locked ? this.region : this.hover;
|
||||
if (!r) return;
|
||||
ctx.save();
|
||||
if (this.region.locked) {
|
||||
ctx.fillStyle = "#0008";
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, this.editor.image.width, this.editor.image.height);
|
||||
ctx.rect(r.x, r.y, r.width, r.height);
|
||||
ctx.fill("evenodd");
|
||||
}
|
||||
ctx.strokeStyle = "#000000";
|
||||
ctx.lineWidth = 1 / d.scale;
|
||||
ctx.setLineDash([6 / d.scale, 4 / d.scale]);
|
||||
ctx.strokeRect(r.x, r.y, r.width, r.height);
|
||||
ctx.restore();
|
||||
}
|
||||
async flatten() {
|
||||
if (!this.layer) return;
|
||||
this.stopOriginalPreview();
|
||||
this.editor.flush();
|
||||
this.busy = true;
|
||||
this.sync();
|
||||
try {
|
||||
const e = this.editor,
|
||||
c = canvas(e.image.width, e.image.height);
|
||||
this.drawSource(c.getContext("2d"));
|
||||
const blob = await new Promise((resolve) =>
|
||||
c.toBlob(resolve, "image/png"),
|
||||
);
|
||||
c.width = c.height = 0;
|
||||
this.activate("mask");
|
||||
await e.options.onUpload(
|
||||
new File([blob], `merged-${crypto.randomUUID()}.png`, {
|
||||
type: "image/png",
|
||||
}),
|
||||
{ preserveMask: true, preserveCrop: true },
|
||||
);
|
||||
this.layer = null;
|
||||
this.widgets.layer_image.value = "";
|
||||
this.widgets.blend_data.value = "";
|
||||
this.contexts.blend = null;
|
||||
this.state({ ...this.region, job: crypto.randomUUID() });
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.sync();
|
||||
this.editor.schedule();
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
async reselect() {
|
||||
if (
|
||||
this.busy ||
|
||||
this.editor.stroke
|
||||
)
|
||||
return;
|
||||
await this.flatten();
|
||||
this.activate("mask");
|
||||
this.state({ locked: false, job: crypto.randomUUID() });
|
||||
this.hover = null;
|
||||
this.sync();
|
||||
this.editor.schedule();
|
||||
this.changed();
|
||||
}
|
||||
destroy() {
|
||||
this.stopOriginalPreview();
|
||||
window.removeEventListener("blur", this.previewBlur);
|
||||
for (const name of ["pointerup", "pointercancel"]) {
|
||||
window.removeEventListener(name, this.finishPreview, true);
|
||||
}
|
||||
this.editor.canvas.removeEventListener("lostpointercapture", this.finishPreview, true);
|
||||
clearTimeout(this.resultTimer);
|
||||
this.pendingResult = null;
|
||||
this.acceptVersion++;
|
||||
for (const context of Object.values(this.contexts))
|
||||
for (const key of ["mask", "baseMask"])
|
||||
if (context?.[key] !== this.editor[key] && context?.[key])
|
||||
context[key].width = context[key].height = 0;
|
||||
this.patchCanvas.width = this.patchCanvas.height = 0;
|
||||
}
|
||||
}
|
||||
+91
-14
@@ -1,15 +1,18 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { InlineMaskEditor } from "./editor.js?v=0.5.5";
|
||||
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.5.5";
|
||||
import { InlineMaskEditor } from "./editor.js?v=0.8.0";
|
||||
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.6.1";
|
||||
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
import { InlineCropController } from "./inline_crop.js?v=0.8.1";
|
||||
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";
|
||||
|
||||
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.5.5", import.meta.url).href;
|
||||
stylesheet.href = new URL("./editor.css?v=0.6.2", import.meta.url).href;
|
||||
if (!stylesheet.isConnected) document.head.append(stylesheet);
|
||||
|
||||
function hide(widget) {
|
||||
@@ -36,9 +39,44 @@ function imageUrl(value) {
|
||||
return api.apiURL(`/view?${params}`);
|
||||
}
|
||||
|
||||
function draftOptions() {
|
||||
let workspace = "personal";
|
||||
try { const current = JSON.parse(sessionStorage.getItem("Comfy.Workspace.Current") || "null"); if (current?.type !== "personal" && current?.id) workspace = current.id; } catch { /* Personal workspace. */ }
|
||||
return { workspace, path: app.extensionManager?.workflow?.activeWorkflow?.path || "" };
|
||||
}
|
||||
|
||||
async function uploadEmbeddedSource(embedded) {
|
||||
const blob = await (await fetch(embedded)).blob();
|
||||
if (blob.size > 64 * 1024 * 1024) throw Error(T("File is too large (max 64 MB)"));
|
||||
const body = new FormData();
|
||||
body.append("image", blob, `source-${crypto.randomUUID()}.png`);
|
||||
body.append("type", "input"); body.append("subfolder", "adachi-inpaint");
|
||||
const response = await api.fetchApi("/upload/image", {method:"POST", body});
|
||||
if (!response.ok) throw Error(`${T("Upload failed")} (${response.status})`);
|
||||
const uploaded = await response.json();
|
||||
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
|
||||
return uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Adachi.InlineInpaint",
|
||||
async beforeConfigureGraph(graphData) {
|
||||
removeLegacyFeedback(graphData);
|
||||
if (app.ui.settings.getSettingValue("Comfy.Workflow.Persist") === false) return;
|
||||
try {
|
||||
// The active workflow can still be the previous tab at this point;
|
||||
// do not claim its replacement credit for an incoming workflow.
|
||||
const options = { ...draftOptions(), path: "" };
|
||||
const count = await prepareInlineDraft(graphData, {
|
||||
fits: graph => canStoreDraft(graph, options), upload: uploadEmbeddedSource,
|
||||
});
|
||||
if (count) console.info(`[Adachi Inpaint] Saved ${count} oversized embedded source(s) as exact input files before workflow draft initialization. Masks and layers were preserved.`);
|
||||
} catch (error) {
|
||||
console.warn("[Adachi Inpaint] Draft preflight failed; embedded source data was retained.", error);
|
||||
}
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === "AdachiLayerCommit") { nodeType.skip_list = true; return; }
|
||||
if (["AdachiWebUIInpaint", "AdachiInpaintPrepare"].includes(nodeData.name)) { installInpaintWorkbench(nodeType); return; }
|
||||
if (nodeData.name !== "AdachiInlineMask") return;
|
||||
const originalCreated = nodeType.prototype.onNodeCreated;
|
||||
@@ -56,6 +94,9 @@ app.registerExtension({
|
||||
const find = name => node.widgets?.find(widget => widget.name === name);
|
||||
const imageWidget = find("image"), maskWidget = find("mask_data"), sourceWidget = find("source_data");
|
||||
for (const widget of [imageWidget, maskWidget, sourceWidget]) hide(widget);
|
||||
let crop;
|
||||
let draftRecovery = null;
|
||||
let draftNoticeTimer = 0;
|
||||
let restoring = false;
|
||||
let restoreFrame = 0;
|
||||
let uploadVersion = 0;
|
||||
@@ -79,7 +120,7 @@ app.registerExtension({
|
||||
if (!embedded && !filename) return;
|
||||
restoring = true;
|
||||
editor.embed.checked = !!embedded;
|
||||
try { await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); }
|
||||
try { crop?.activate("mask"); await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); await crop?.restore(); }
|
||||
catch (error) { console.warn("[Adachi Inpaint]", error); }
|
||||
finally { restoring = false; }
|
||||
};
|
||||
@@ -87,10 +128,11 @@ app.registerExtension({
|
||||
compact: true,
|
||||
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
|
||||
onDirty() { markChanged(); },
|
||||
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 } = {}) {
|
||||
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 } = {}) {
|
||||
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);
|
||||
@@ -102,24 +144,58 @@ app.registerExtension({
|
||||
if (version !== uploadVersion || editor.destroyed) return;
|
||||
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
|
||||
restoring = false;
|
||||
if (preserveMask && editor.ready) await editor.replaceSource(imageUrl(name), file.name);
|
||||
crop?.activate("mask");
|
||||
let kept = false;
|
||||
if (preserveMask && editor.ready) kept = 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() : "";
|
||||
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) {
|
||||
if (sourceWidget) sourceWidget.value = enabled ? editor.sourceData() : "";
|
||||
const recovery = enabled ? beforeModeChange() : null;
|
||||
if (recovery?.then) return recovery.then(() => markChanged()).catch(error => editor.status(error.message, true));
|
||||
markChanged();
|
||||
},
|
||||
});
|
||||
editor.root.dataset.adachiVersion = "0.5.5";
|
||||
editor.root.dataset.adachiVersion = "0.6.7";
|
||||
const beforeModeChange = () => {
|
||||
if (draftRecovery) return draftRecovery;
|
||||
const embedded = sourceWidget?.value;
|
||||
if (!editor.ready || !embedded?.startsWith("data:") || embedded.length < 256 * 1024) return;
|
||||
if (app.ui.settings.getSettingValue("Comfy.Workflow.Persist") === false) return;
|
||||
const graph = app.rootGraph || node.graph;
|
||||
if (canStoreDraft(graph.serialize(), draftOptions()) !== false) return;
|
||||
// Persist a fresh, exact copy before dropping any embedded bytes.
|
||||
// This also repairs portable workflows whose original input path is absent.
|
||||
draftRecovery = (async () => {
|
||||
const version = editor.loadVersion;
|
||||
const name = await uploadEmbeddedSource(embedded);
|
||||
if (editor.destroyed || editor.loadVersion !== version || sourceWidget.value !== embedded) return false;
|
||||
imageWidget.value = name;
|
||||
sourceWidget.value = ""; editor.embed.checked = false;
|
||||
editor.message.textContent = T("Browser draft storage was full. The source was saved as an input file; the image, masks and layers are preserved.");
|
||||
editor.message.classList.remove("dd-error"); editor.message.hidden = false;
|
||||
const notice = editor.message.textContent;
|
||||
clearTimeout(draftNoticeTimer);
|
||||
draftNoticeTimer = setTimeout(() => { if (!editor.destroyed && editor.message.textContent === notice) editor.message.hidden = true; }, 8000);
|
||||
return true;
|
||||
})().finally(() => { draftRecovery = null; });
|
||||
return draftRecovery;
|
||||
};
|
||||
crop = new InlineCropController(node, editor, {find, hide, changed:markChanged, imageUrl, beforeModeChange});
|
||||
node.adachiCrop = crop;
|
||||
node.adachiEditor = editor;
|
||||
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
|
||||
node.adachiUseResult = async file => {
|
||||
node.adachiUseResult = async (file, origin) => {
|
||||
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
|
||||
if (crop.mode === "crop") return node.adachiAdoptCrop(file, origin);
|
||||
await editor.options.onUpload(file, { preserveMask: true });
|
||||
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
|
||||
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
|
||||
@@ -182,23 +258,24 @@ app.registerExtension({
|
||||
};
|
||||
const previousSerializeNode = node.onSerialize;
|
||||
node.onSerialize = function (data) {
|
||||
const result = previousSerializeNode?.apply(this, arguments);
|
||||
editor.flush();
|
||||
const index = node.widgets.indexOf(maskWidget);
|
||||
if (data?.widgets_values && index >= 0) data.widgets_values[index] = maskWidget.value;
|
||||
return previousSerializeNode?.apply(this, arguments);
|
||||
if (data) syncInlineWidgets(data, node.widgets);
|
||||
return result;
|
||||
};
|
||||
const previousQueued = maskWidget.beforeQueued;
|
||||
maskWidget.beforeQueued = function () { editor.flush(); return previousQueued?.apply(this, arguments); };
|
||||
const previousConfigure = node.onConfigure;
|
||||
node.onConfigure = function () {
|
||||
const configured = previousConfigure?.apply(this, arguments);
|
||||
editor.setKeepFit(!!node.properties.adachi_keep_fit, false);
|
||||
cancelAnimationFrame(restoreFrame); restoreFrame = requestAnimationFrame(restore);
|
||||
return configured;
|
||||
};
|
||||
const previousImageCallback = imageWidget.callback;
|
||||
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
|
||||
const previousRemoved = node.onRemoved;
|
||||
node.onRemoved = function () { cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
|
||||
node.onRemoved = function () { clearTimeout(draftNoticeTimer); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); crop.destroy(); 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;
|
||||
|
||||
+28
-8
@@ -1,5 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { InpaintParameterPanel } from "./inpaint_panel.js";
|
||||
import { InpaintParameterPanel } from "./inpaint_panel.js?v=0.6.1";
|
||||
import { connectedEditor, connectedSamplers, relocateWidgets } from "./parameter_bridge.js";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
import { T } from "./i18n.js";
|
||||
@@ -10,10 +10,17 @@ export function installInpaintWorkbench(nodeType) {
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = created?.apply(this, arguments), node = this;
|
||||
const root = document.createElement("div"); root.className = "dd-inpaint dd-workbench";
|
||||
const fold = document.createElement("details"); fold.className = "dd-workbench-fold";
|
||||
const summary = document.createElement("summary"); summary.textContent = T("Painting tools and mask settings");
|
||||
summary.addEventListener("pointerdown", event => event.stopPropagation());
|
||||
summary.addEventListener("click", event => event.stopPropagation());
|
||||
fold.append(summary);
|
||||
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);
|
||||
tools.append(placeholder); fold.append(tools);
|
||||
const actionHost = document.createElement("div"); actionHost.className = "dd-workbench-actions";
|
||||
root.append(fold, actionHost);
|
||||
let source = null, mountedEditor = null, frame = 0, removed = false;
|
||||
const markChanged = () => {
|
||||
app.canvas?.emitBeforeChange?.(); app.canvas?.emitAfterChange?.();
|
||||
@@ -28,14 +35,15 @@ export function installInpaintWorkbench(nodeType) {
|
||||
const next = canMount ? editor : null;
|
||||
if (next !== mountedEditor) {
|
||||
if (mountedEditor?.controls.parentElement === tools) mountedEditor.controls.remove();
|
||||
if (mountedEditor?.actions.parentElement === actionHost) mountedEditor.actions.remove();
|
||||
mountedEditor = next;
|
||||
if (next) tools.append(next.controls);
|
||||
if (next) { tools.append(next.controls); actionHost.append(next.actions); }
|
||||
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 contentHeight = () => fold.offsetHeight + actionHost.offsetHeight + (panel?.root.scrollHeight || 620);
|
||||
const scheduleSize = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
@@ -46,29 +54,41 @@ export function installInpaintWorkbench(nodeType) {
|
||||
});
|
||||
};
|
||||
let panel;
|
||||
fold.open = !!node.properties.adachi_tools_open;
|
||||
fold.ontoggle = () => {
|
||||
if (node.properties.adachi_tools_open !== fold.open) {
|
||||
node.properties.adachi_tools_open = fold.open; markChanged();
|
||||
}
|
||||
scheduleSize();
|
||||
};
|
||||
panel = new InpaintParameterPanel({
|
||||
getTargets: () => { refreshEditor(); return [node]; },
|
||||
getImage: () => source?.adachiEditor?.ready ? source.adachiEditor.image : null,
|
||||
getImage: () => source?.adachiEditor?.ready ? (source.adachiCrop?.mode === "crop" && source.adachiCrop.region.locked ? source.adachiCrop.region : source.adachiEditor.image) : null,
|
||||
onTargetChange: (previous, next) => relocateWidgets(node, previous, next),
|
||||
onDirty: markChanged, onHeight: scheduleSize,
|
||||
onMaskSettings: element => {
|
||||
fold.querySelector(".dd-param-mask-settings")?.remove();
|
||||
fold.append(element); scheduleSize();
|
||||
},
|
||||
});
|
||||
root.append(panel.root);
|
||||
node.adachiParameters = panel; node.adachiWorkbench = { root, tools, get source() { return source; } };
|
||||
node.adachiParameters = panel; node.adachiWorkbench = { root, tools, fold, actionHost, 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 observer = new ResizeObserver(scheduleSize); observer.observe(tools); observer.observe(fold); observer.observe(actionHost);
|
||||
const configured = node.onConfigure;
|
||||
node.onConfigure = function () { const result = configured?.apply(this, arguments); queueMicrotask(() => { if (!removed) { panel.refresh(); scheduleSize(); } }); return result; };
|
||||
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; };
|
||||
const previousRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
removed = true; cancelAnimationFrame(frame); observer.disconnect();
|
||||
if (mountedEditor?.controls.parentElement === tools) mountedEditor.controls.remove();
|
||||
if (mountedEditor?.actions.parentElement === actionHost) mountedEditor.actions.remove();
|
||||
panel.destroy(); stopWheelRouting(); root.remove();
|
||||
return previousRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
@@ -114,8 +114,11 @@ export class InpaintParameterPanel {
|
||||
grid(parent) { const grid = el("div", "dd-param-grid"); parent.append(grid); return grid; }
|
||||
|
||||
build() {
|
||||
this.enum(this.body, "mask_mode", "Mask mode", true);
|
||||
this.enum(this.body, "fill", "Fill content", true);
|
||||
this.maskSettings = el("div", "dd-param-mask-settings");
|
||||
this.enum(this.maskSettings, "mask_mode", "Mask mode", true);
|
||||
this.enum(this.maskSettings, "fill", "Fill content", true);
|
||||
this.body.append(this.maskSettings);
|
||||
this.options.onMaskSettings?.(this.maskSettings);
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Remove obsolete feedback edges before LiteGraph assigns saved socket indices.
|
||||
export function removeLegacyFeedback(graph, seen = new Set()) {
|
||||
if (!graph || seen.has(graph)) return;
|
||||
seen.add(graph);
|
||||
const nodes = new Map((graph.nodes || []).map(node => [String(node.id), node]));
|
||||
const removed = new Set(), indices = new Map();
|
||||
for (const node of nodes.values()) {
|
||||
if (node.type !== "AdachiInlineMask") continue;
|
||||
const slots = (node.inputs || []).flatMap((input, index) => input.name === "result" ? [index] : []);
|
||||
indices.set(String(node.id), slots);
|
||||
for (const slot of slots) if (node.inputs[slot].link != null) removed.add(String(node.inputs[slot].link));
|
||||
}
|
||||
const entries = Array.isArray(graph.links) ? graph.links.map((link, index) => [index, link]) : Object.entries(graph.links || {});
|
||||
for (const [, link] of entries) {
|
||||
const id = Array.isArray(link) ? link[0] : link.id;
|
||||
const target = String(Array.isArray(link) ? link[3] : link.target_id);
|
||||
const slot = Array.isArray(link) ? link[4] : link.target_slot;
|
||||
if (!indices.has(target)) continue;
|
||||
const input = nodes.get(target).inputs?.[slot], slots = indices.get(target);
|
||||
// Also repair orphaned feedback edges from a previously migrated save.
|
||||
if (slots.includes(slot) || String(input?.link) !== String(id)) removed.add(String(id));
|
||||
else if (Array.isArray(link)) link[4] -= slots.filter(index => index < slot).length;
|
||||
else link.target_slot -= slots.filter(index => index < slot).length;
|
||||
}
|
||||
if (Array.isArray(graph.links)) graph.links = graph.links.filter(link => !removed.has(String(Array.isArray(link) ? link[0] : link.id)));
|
||||
else for (const [key, link] of entries) if (removed.has(String(Array.isArray(link) ? link[0] : link.id))) delete graph.links[key];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.type === "AdachiInlineMask") node.inputs = (node.inputs || []).filter(input => input.name !== "result");
|
||||
for (const output of node.outputs || []) if (Array.isArray(output.links)) output.links = output.links.filter(id => !removed.has(String(id)));
|
||||
}
|
||||
for (const subgraph of graph.definitions?.subgraphs || []) removeLegacyFeedback(subgraph, seen);
|
||||
}
|
||||
@@ -35,6 +35,23 @@ export function connectedSamplers(source) {
|
||||
.sort((a, b) => Number(b.type === "AdachiWebUIInpaint") - Number(a.type === "AdachiWebUIInpaint"));
|
||||
}
|
||||
|
||||
// Use the same rounding, range and linked-input rules as Use source size.
|
||||
export function syncCropDimensions(source, width, height) {
|
||||
const aligned = value => Math.max(64, Math.min(8192, Math.round(value / 8) * 8));
|
||||
for (const target of connectedSamplers(source)) {
|
||||
target.adachiParameters?.refresh();
|
||||
for (const [name, value] of [["width", width], ["height", height]]) {
|
||||
if (target.inputs?.some(input => input.name === name && input.link != null)) continue;
|
||||
const widget = target.widgets?.find(widget => widget.name === name);
|
||||
if (widget && widget.value !== aligned(value)) {
|
||||
widget.value = aligned(value);
|
||||
widget.callback?.(widget.value);
|
||||
}
|
||||
}
|
||||
target.adachiParameters?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
export function relocateWidgets(owner, previous, next) {
|
||||
if (previous) {
|
||||
for (const widget of previous.widgets || []) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { T } from "./i18n.js";
|
||||
|
||||
export function installResultAdoption(node, crop) {
|
||||
// Saved workflows may still contain the old feedback socket and wire.
|
||||
const removeLegacyInput = () => {
|
||||
const index = node.inputs?.findIndex(input => input.name === "result");
|
||||
if (index >= 0) node.removeInput(index);
|
||||
};
|
||||
removeLegacyInput();
|
||||
const configured = node.onConfigure;
|
||||
node.onConfigure = function () {
|
||||
const result = configured?.apply(this, arguments);
|
||||
removeLegacyInput();
|
||||
return result;
|
||||
};
|
||||
node.adachiAdoptCrop = async (file, origin) => {
|
||||
const editor = crop.editor, region = crop.region;
|
||||
if (!editor.ready || !region.locked) throw Error(T("Select a crop region before adopting a result"));
|
||||
if (crop.busy || editor.stroke) throw Error(T("Finish editing before adopting a result"));
|
||||
if (origin && (origin.mode !== "crop" || String(origin.source) !== String(node.id) || origin.job !== region.job)) {
|
||||
throw Error(T("This result belongs to another crop; generate a result for the current region"));
|
||||
}
|
||||
if (file.size > 64 * 1024 * 1024) throw Error(T("File is too large (max 64 MB)"));
|
||||
crop.busy = true; crop.sync();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = new Image(); image.src = objectUrl; await image.decode();
|
||||
if (image.width !== region.width || image.height !== region.height) {
|
||||
throw Error(T("Result dimensions must match the selected crop"));
|
||||
}
|
||||
const body = new FormData();
|
||||
body.append("image", file, `adopted-${crypto.randomUUID()}.png`);
|
||||
body.append("type", "input"); body.append("subfolder", "adachi-inpaint-layers");
|
||||
const response = await api.fetchApi("/upload/image", {method:"POST", body});
|
||||
if (!response.ok) throw Error(`${T("Upload failed")} (${response.status})`);
|
||||
const uploaded = await response.json();
|
||||
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
|
||||
if (editor.destroyed || crop.mode !== "crop" || crop.region.job !== region.job) {
|
||||
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, image});
|
||||
const name = descriptor.subfolder ? `${descriptor.subfolder}/${descriptor.filename}` : descriptor.filename;
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user