Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6657bac72 | ||
|
|
977c352361 | ||
|
|
540f3c0c45 | ||
|
|
b2087ba565 | ||
|
|
fa644d9969 |
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Add Auto Exclusive Switch: two generic lazy inputs, no manual switch control, and exactly one enabled branch required.
|
||||
- Respect muted/bypassed input branches, including bypassed transform nodes that normally pass through data; branch changes also invalidate execution caches.
|
||||
- Widen the group preset list from 32% to 40% of its panel.
|
||||
- Keep the new nodes English-only as well: backend error messages and the group preset node description no longer carry inline Chinese, and the two new example workflows use English note text and group names. The group preset node keeps no saved title override, so it shows its locale-aware name in any language.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Preserve the painted mask, undo/redo history and viewport when adopting a comparison result as the source; an ordinary image upload still starts a new mask.
|
||||
- Add Group Bypass Presets: a connection-free controller with a preset list, per-group enabled/bypassed/unmanaged states, and a right-click preset menu.
|
||||
- Store presets in the workflow with stable group IDs, support renamed and nested groups, and leave newly added/unmanaged groups untouched.
|
||||
- Add a model-free example workflow for group presets.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- Make every user-facing string English by default: node display names, category, descriptions, tooltips and the canvas/panel labels.
|
||||
|
||||
@@ -105,11 +105,25 @@ Parameters driven by an external link disable the matching panel control; a modu
|
||||
|
||||
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 plain wheel zooms. 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, writes it back into the upstream inline editor and clears the old mask, so you can paint the next area straight away.
|
||||
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.
|
||||
Comparison previews live in ComfyUI's temp directory and may need a re-run after cleanup or restart; an adopted source image is stored in `input/adachi-inpaint/` and does not depend on temp previews.
|
||||
|
||||
## Group bypass presets
|
||||
|
||||
Search for **Group Bypass Presets** (Chinese: **组忽略预设**) under Adachi-Inpaint-Workbench. It needs no connections and does not require rgthree or Danbooru.
|
||||
|
||||
1. Click **+ New preset**, name it and save. The preset captures the current groups' enabled/bypassed state.
|
||||
2. Choose **Enabled**, **Bypassed** or **Keep current** for each group on the right. Changes update and apply the selected preset immediately.
|
||||
3. Add more presets. Clicking a name on the left applies that preset to all of its configured groups at once. You can also right-click the node and choose **Apply preset**.
|
||||
4. Use **Save current group states** to capture changes you made directly in the workflow, or rename/delete presets as needed.
|
||||
|
||||
Presets are stored in the workflow. Group renames retain their preset mapping; newly added groups stay unmanaged until configured. Loading a workflow restores its saved node modes without automatically applying a preset. If groups overlap, the smaller group's setting takes priority; the preset controller itself stays enabled.
|
||||
Bypass uses ComfyUI's standard bypass mode, so a bypassed node still needs compatible input/output types when it sits on an active data path.
|
||||
Try [examples/03-group-presets.json](examples/03-group-presets.json), which uses only empty images and colour inversion and needs no model.
|
||||
|
||||
## Fill, batch and Refiner
|
||||
|
||||
| `fill` | Behaviour |
|
||||
@@ -128,6 +142,21 @@ You can optionally attach a compatible `refiner_model` and use `refiner_switch`
|
||||
Encoding with the Refiner's own CLIP and wiring `refiner_positive`/`refiner_negative` is recommended; if left unconnected the original conditioning is reused and the VAE still comes from the base side.
|
||||
`refiner_switch=1` uses only the base model and `0` uses only the Refiner; a known-incompatible latent format raises an error.
|
||||
|
||||
## Auto Exclusive Switch
|
||||
|
||||
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; any routing nodes between them should remain enabled.
|
||||
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.
|
||||
@@ -182,3 +211,5 @@ python -m unittest discover -s tests -v
|
||||
英文用户看到的一直是英文(英文是内置默认值);某个词条若没有对应翻译,会自动回退为英文。
|
||||
两套语言共用同一个节点类 ID 和参数键名,所以已保存的工作流不受语言切换影响。
|
||||
|
||||
新增「自动二选一」:把两个正面条件(或图像、模型等同类型数据)接到输入 A/B,输出接共同下游;忽略其中一个分支,就自动使用另一个。两路都启用或都无输入会报错,不需要 switch。切换节点放在两个被控制分组之外,也能配合组忽略预设使用。
|
||||
|
||||
|
||||
+12
-8
@@ -1,21 +1,25 @@
|
||||
# Git versions and rollback
|
||||
|
||||
`v0.2.0` is the first UI rollback point; `v0.3.0` added the inline parameter panel, the result/original comparison change and the wheel routing fix; `v0.4.0` moved the painting tools and parameters into the WebUI Inpaint node; `v0.4.1` refreshed the eraser cursor and the public name.
|
||||
The current local version is **v0.5.1**. Both the installed plugin and the source repository retain local commits and tags. No GitHub push is part of this update.
|
||||
|
||||
Both the local installation folder and the source folder keep Git history; a normal release ZIP does not contain `.git`, so use the Git bundle to restore history separately.
|
||||
| Tag | Contents |
|
||||
|---|---|
|
||||
| `v0.5.1` | Auto Exclusive Switch, wider preset list, and all v0.5.0 features |
|
||||
| `v0.5.0` / `local-before-v0.5.1` | Preserve masks on adoption and group bypass presets |
|
||||
| `local-before-v0.5.0` | Exact baseline before mask adoption / group preset changes |
|
||||
|
||||
Run these commands in the plugin folder. Check there are no unsaved changes before switching versions.
|
||||
To undo only this update, run in the plugin folder:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git switch --detach v0.4.1
|
||||
git switch --detach v0.5.0
|
||||
```
|
||||
|
||||
Return to the current version with:
|
||||
Return to this version with:
|
||||
|
||||
```powershell
|
||||
git switch main
|
||||
git switch --detach v0.5.1
|
||||
```
|
||||
|
||||
Restart ComfyUI and refresh the browser with `Ctrl+F5` afterwards.
|
||||
If you have local modifications, commit or back them up first so they are not overwritten.
|
||||
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.
|
||||
|
||||
@@ -23,6 +23,13 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
|
||||
- 0.4.1: a real browser confirms the eraser SVG cursor and erase state after holding Alt, restoring the cross brush cursor on release; temporary erase, regular painting and the 18 backend CPU regression tests pass.
|
||||
- 0.4.1: the public name was unified to Adachi-Inpaint-Workbench while node class IDs were kept so existing workflows stay compatible.
|
||||
- 0.4.2: node display names, descriptions, tooltips and frontend labels were converted to English so the nodes are usable for the general Manager audience; the same 18 backend CPU tests pass and every touched script parses.
|
||||
- 0.5.0: in classic nodes and Nodes 2.0, adopting a real queued comparison result preserves mask PNG pixels, base-mask pixels and the current undo/redo command arrays. Undo/redo still work, workflow reload retains the mask, a mismatched result leaves the source/mask unchanged, and ordinary image upload still clears the mask.
|
||||
- 0.5.0: group preset tests cover real preset-button clicks, enabled/bypassed modes in executed prompts, exclusion of the virtual controller from backend execution, saved workflow reload, renamed and duplicate-title groups, unmanaged new groups, nested group priority, missing groups and protection of the controller itself.
|
||||
- 0.5.0: the native right-click preset submenu was clicked successfully in both renderers. The example workflow was loaded and all three presets queued successfully without a model.
|
||||
|
||||
- 0.5.1: all 23 CPU unit tests pass, including exclusive input validation and identity-preserving forwarding of conditioning, image/mask tensors, latents and falsey values.
|
||||
- 0.5.1: 26 real CPU queue cases across classic nodes and Nodes 2.0 cover A/B selection, bypassed transform pass-through, mute, both-enabled/both-bypassed/both-disconnected errors, reroutes, one connected input, group presets, cache changes, workflow reload and switching reloaded presets. Output pixels match the selected image; the unused branch was not requested by the selector. A raw API prompt works without frontend metadata.
|
||||
- 0.5.1: the 40%-width preset list retains internal scrolling and has no horizontal overflow in a 360px node at a 390px viewport.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+54
@@ -553,12 +553,65 @@ class AdachiImageCompare:
|
||||
return {"ui": {"adachi_originals": before, "adachi_results": after}, "result": (result,)}
|
||||
|
||||
|
||||
_MISSING_INPUT = object()
|
||||
|
||||
|
||||
class AdachiExclusiveSwitch:
|
||||
"""Forward exactly one enabled branch, without evaluating the ignored branch."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"input_a": ("*", {"lazy": True, "tooltip": "First branch; bypass either this branch or input_b."}),
|
||||
"input_b": ("*", {"lazy": True, "tooltip": "Second branch; exactly one branch must remain enabled."}),
|
||||
# The frontend computes this from the graph on every serialization.
|
||||
# Keeping it in the prompt also makes branch changes invalidate caches,
|
||||
# including bypassed transform nodes whose links otherwise pass through.
|
||||
"active_inputs": ("INT", {"default": -1, "min": -1, "max": 3}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("*",)
|
||||
RETURN_NAMES = ("output",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "Automatically forwards the only enabled input. Bypass one branch; both enabled or both absent is an error."
|
||||
SEARCH_ALIASES = ["exclusive switch", "auto switch", "自动二选一", "互斥", "自动切换", "adachi"]
|
||||
|
||||
@staticmethod
|
||||
def _selected_input(input_a, input_b, active_inputs):
|
||||
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."
|
||||
)
|
||||
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 select(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1):
|
||||
selected = self._selected_input(input_a, input_b, active_inputs)
|
||||
value = input_a if selected == "input_a" else input_b
|
||||
if value is None:
|
||||
raise ValueError(f"The enabled branch {selected} returned no data.")
|
||||
return (value,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AdachiInlineMask": AdachiInlineMask,
|
||||
"AdachiInpaintPrepare": AdachiInpaintPrepare,
|
||||
"AdachiInpaintComposite": AdachiInpaintComposite,
|
||||
"AdachiWebUIInpaint": AdachiWebUIInpaint,
|
||||
"AdachiImageCompare": AdachiImageCompare,
|
||||
"AdachiExclusiveSwitch": AdachiExclusiveSwitch,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -567,4 +620,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AdachiInpaintComposite": "Inpaint Composite",
|
||||
"AdachiWebUIInpaint": "WebUI Inpaint",
|
||||
"AdachiImageCompare": "Image Compare",
|
||||
"AdachiExclusiveSwitch": "Auto Exclusive Switch",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 6,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
-350,
|
||||
140
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "color"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1,
|
||||
8096934
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
120,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
48
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
510,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
48
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
920,
|
||||
140
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
76
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Adachi/GroupPresetExample"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "AdachiGroupPresets",
|
||||
"pos": [
|
||||
80,
|
||||
440
|
||||
],
|
||||
"size": [
|
||||
620,
|
||||
410
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"adachi_group_presets": {
|
||||
"version": 1,
|
||||
"activeId": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
|
||||
"presets": [
|
||||
{
|
||||
"id": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
|
||||
"name": "All enabled",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "Base",
|
||||
"mode": 0
|
||||
},
|
||||
"group:2": {
|
||||
"title": "Detail",
|
||||
"mode": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fe43bc1f-1a1e-4f49-a37b-c6e6ca7cbc8a",
|
||||
"name": "Skip detail",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "Base",
|
||||
"mode": 0
|
||||
},
|
||||
"group:2": {
|
||||
"title": "Detail",
|
||||
"mode": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b881a666-4643-4af6-8feb-f1d02c47f113",
|
||||
"name": "Bypass both",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "Base",
|
||||
"mode": 4
|
||||
},
|
||||
"group:2": {
|
||||
"title": "Detail",
|
||||
"mode": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
820,
|
||||
550
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
180
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Adachi-Inpaint-Workbench - Group Bypass Presets\nSelect a preset on the left to switch a whole group between enabled and bypassed; no connections required\nAdjust each group's state on the right, or click \"Save current group states\" to store the current workflow\nRight-click the node -> Apply preset to switch quickly\nThis example only inverts an image, so no model is needed"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Base",
|
||||
"bounding": [
|
||||
80,
|
||||
100,
|
||||
300,
|
||||
230
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Detail",
|
||||
"bounding": [
|
||||
470,
|
||||
100,
|
||||
300,
|
||||
230
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
0,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "color"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
64,
|
||||
64,
|
||||
1,
|
||||
16711680
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
0,
|
||||
450
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "color"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
64,
|
||||
64,
|
||||
1,
|
||||
255
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
300,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
300,
|
||||
450
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "AdachiExclusiveSwitch",
|
||||
"pos": [
|
||||
620,
|
||||
200
|
||||
],
|
||||
"size": [
|
||||
230,
|
||||
80
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input_a",
|
||||
"shape": 7,
|
||||
"type": "*",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "input_b",
|
||||
"shape": 7,
|
||||
"type": "*",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "output",
|
||||
"type": "*",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiExclusiveSwitch"
|
||||
},
|
||||
"widgets_values": [
|
||||
-1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
940,
|
||||
200
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"exclusive-test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "AdachiGroupPresets",
|
||||
"pos": [
|
||||
600,
|
||||
430
|
||||
],
|
||||
"size": [
|
||||
620,
|
||||
410
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"adachi_group_presets": {
|
||||
"version": 1,
|
||||
"activeId": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
|
||||
"presets": [
|
||||
{
|
||||
"id": "dd436f10-d4a4-46bc-aecb-fe185710f6e0",
|
||||
"name": "Use A only",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "A",
|
||||
"mode": 0
|
||||
},
|
||||
"group:2": {
|
||||
"title": "B",
|
||||
"mode": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
|
||||
"name": "Use B only",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "A",
|
||||
"mode": 4
|
||||
},
|
||||
"group:2": {
|
||||
"title": "B",
|
||||
"mode": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "A",
|
||||
"bounding": [
|
||||
270,
|
||||
70,
|
||||
310,
|
||||
210
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "B",
|
||||
"bounding": [
|
||||
270,
|
||||
400,
|
||||
310,
|
||||
210
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8,
|
||||
"offset": [
|
||||
100,
|
||||
20
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -228,5 +228,24 @@
|
||||
"name": "image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AdachiExclusiveSwitch": {
|
||||
"display_name": "Auto Exclusive Switch",
|
||||
"description": "Automatically forwards the only enabled input. Bypass one branch; both enabled or both absent is an error.",
|
||||
"inputs": {
|
||||
"input_a": {
|
||||
"name": "input_a",
|
||||
"tooltip": "First branch. Connect the same data type to both branches and the output."
|
||||
},
|
||||
"input_b": {
|
||||
"name": "input_b",
|
||||
"tooltip": "Second branch. Exactly one branch must remain enabled."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "output"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,5 +232,24 @@
|
||||
"name": "图像"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AdachiExclusiveSwitch": {
|
||||
"display_name": "自动二选一 / Auto Exclusive Switch",
|
||||
"description": "自动输出唯一启用的分支;忽略或断开另一路,两路都启用或都无输入时提示错误,无需 switch。",
|
||||
"inputs": {
|
||||
"input_a": {
|
||||
"name": "输入 A",
|
||||
"tooltip": "第一路;两路及下游应使用相同数据类型,如正面条件、图像、模型。"
|
||||
},
|
||||
"input_b": {
|
||||
"name": "输入 B",
|
||||
"tooltip": "第二路;两路中需要且只能启用一路,另一支设为忽略或断开。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "输出"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "0.4.2"
|
||||
version = "0.5.1"
|
||||
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The selector must preserve arbitrary values and never silently prefer a branch."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("exclusive_backend", Path(__file__).resolve().parents[1] / "backend.py")
|
||||
backend = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(backend)
|
||||
|
||||
|
||||
class ExclusiveSwitchTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = backend.AdachiExclusiveSwitch()
|
||||
|
||||
def test_single_connected_branch_is_requested(self):
|
||||
self.assertEqual(self.node.check_lazy_status(input_a=None), ["input_a"])
|
||||
self.assertEqual(self.node.check_lazy_status(input_b=None), ["input_b"])
|
||||
|
||||
def test_bypassed_passthrough_branch_is_not_requested(self):
|
||||
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=1), ["input_a"])
|
||||
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=2), ["input_b"])
|
||||
a, b = object(), object()
|
||||
self.assertIs(self.node.select(a, b, 1)[0], a)
|
||||
self.assertIs(self.node.select(a, b, 2)[0], b)
|
||||
|
||||
def test_ambiguous_and_empty_inputs_fail(self):
|
||||
for inputs in ({}, {"input_a": None, "input_b": None}, {"active_inputs": 0}, {"active_inputs": 3}):
|
||||
with self.subTest(inputs=inputs), self.assertRaisesRegex(ValueError, "exactly one"):
|
||||
self.node.check_lazy_status(**inputs)
|
||||
|
||||
def test_missing_selected_input_and_none_data_fail(self):
|
||||
with self.assertRaisesRegex(ValueError, "has no input"):
|
||||
self.node.check_lazy_status(input_b=None, active_inputs=1)
|
||||
with self.assertRaisesRegex(ValueError, "returned no data"):
|
||||
self.node.select(input_a=None)
|
||||
|
||||
def test_falsey_values_and_tensors_are_forwarded_by_identity(self):
|
||||
for value in (False, 0, "", [], {}, torch.zeros(1, 4, 4, 3), torch.ones(1, 4, 4), {"samples": torch.zeros(1, 4, 8, 8)}, [[torch.zeros(1, 2, 3), {}]]):
|
||||
with self.subTest(type=type(value)):
|
||||
self.assertIs(self.node.select(input_a=value)[0], value)
|
||||
self.assertIs(self.node.select(input_b=value)[0], value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -96,3 +96,25 @@
|
||||
.dd-comparer .dd-compare-captions { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; justify-content: space-between; pointer-events: none; }
|
||||
.dd-comparer .dd-compare-captions span { background: #171d27cc; color: #f1e3cf; padding: 3px 7px; border-radius: 3px; font-size: 11px; }
|
||||
@container (max-width: 380px) { .dd-inpaint .dd-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 12px; } }
|
||||
.dd-group-presets { border: 0; border-radius: 4px; container-type: inline-size; contain: size layout; }
|
||||
.dd-gp-toolbar, .dd-gp-form { display: flex; align-items: center; gap: 7px; padding: 9px 10px; flex: 0 0 auto; }
|
||||
.dd-gp-toolbar { justify-content: space-between; border-bottom: 1px solid var(--dd-line); }
|
||||
.dd-gp-toolbar strong { font-size: 12px; font-weight: 500; }
|
||||
.dd-group-presets button { min-height: 26px; padding: 4px 7px; font-size: 11px; }
|
||||
.dd-gp-form input { flex: 1; min-width: 0; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); padding: 5px 6px; font: inherit; border-radius: 3px; }
|
||||
.dd-gp-body { display: grid; grid-template-columns: minmax(130px, 40%) minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.dd-gp-presets { border-right: 1px solid var(--dd-line); padding: 7px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.dd-gp-presets button { text-align: left; flex: 0 0 auto; white-space: normal; overflow-wrap: anywhere; }
|
||||
.dd-gp-detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.dd-gp-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; }
|
||||
.dd-gp-detail-head strong { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-gp-groups { flex: 1; min-height: 0; padding: 0 8px; }
|
||||
.dd-gp-scroll { overflow-y: auto; overscroll-behavior: contain; }
|
||||
.dd-gp-group { display: flex; align-items: center; gap: 7px; padding: 7px 0; border-bottom: 1px solid #41495566; }
|
||||
.dd-gp-color { flex: 0 0 4px; width: 4px; height: 20px; border-radius: 2px; }
|
||||
.dd-gp-group-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-gp-group select { max-width: 118px; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); border-radius: 3px; padding: 4px; font: inherit; font-size: 11px; }
|
||||
.dd-gp-changed .dd-gp-group-name { color: var(--dd-accent); }
|
||||
.dd-gp-footer { display: flex; flex-wrap: wrap; gap: 5px; padding: 9px 8px; }
|
||||
.dd-gp-status { flex: 0 0 auto; padding: 5px 10px 7px; color: var(--dd-muted); font-size: 10px; border-top: 1px solid var(--dd-line); }
|
||||
.dd-gp-empty { color: var(--dd-muted); padding: 10px 4px; font-size: 11px; }
|
||||
|
||||
@@ -269,6 +269,22 @@ 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"));
|
||||
}
|
||||
// Replacing only the source preserves mask pixels, the base snapshot,
|
||||
// command history, redo history, active strokes and the current view.
|
||||
this.flush();
|
||||
this.image = image;
|
||||
this.filename.textContent = name || T("Embedded image");
|
||||
this.filename.title = this.filename.textContent;
|
||||
this.updateButtons(); this.schedule();
|
||||
}
|
||||
|
||||
importMask(ctx, image) {
|
||||
const scratch = makeCanvas();
|
||||
scratch.width = this.mask.width; scratch.height = this.mask.height;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
function branchIsEnabled(node, name) {
|
||||
const graph = node.graph;
|
||||
let input = node.inputs?.find(input => input.name === name);
|
||||
const visited = new Set();
|
||||
while (graph && input?.link != null) {
|
||||
const link = graph.getLink?.(input.link) ?? graph.links?.[input.link];
|
||||
const source = link && graph.getNodeById(link.origin_id);
|
||||
if (!source || visited.has(source.id)) return false;
|
||||
visited.add(source.id);
|
||||
// A bypassed transform normally passes its own input through in ComfyUI.
|
||||
// For this exclusive selector the original branch is explicitly disabled.
|
||||
if (source.mode === 2 || source.mode === 4) return false;
|
||||
if (source.type !== "Reroute") return true;
|
||||
input = source.inputs?.[0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "AdachiWorkbench.ExclusiveSwitch",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "AdachiExclusiveSwitch") return;
|
||||
const created = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = created?.apply(this, arguments);
|
||||
const widget = this.widgets?.find(widget => widget.name === "active_inputs");
|
||||
if (widget) {
|
||||
widget.type = "hidden";
|
||||
widget.hidden = true;
|
||||
widget.options ??= {};
|
||||
widget.options.hidden = true;
|
||||
widget.computeSize = () => [0, -4];
|
||||
widget.serializeValue = () => Number(branchIsEnabled(this, "input_a"))
|
||||
| (Number(branchIsEnabled(this, "input_b")) << 1);
|
||||
}
|
||||
const internalSlot = this.inputs?.findIndex(input => input.name === "active_inputs");
|
||||
if (internalSlot >= 0) this.removeInput(internalSlot);
|
||||
this.setSize([230, 80]);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/* Serializable group presets; no ComfyUI or third-party extension dependency. */
|
||||
export const GROUP_PRESETS_TYPE = "AdachiGroupPresets";
|
||||
const ACTIVE = 0, BYPASS = 4;
|
||||
const uuid = () => globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
function groupKey(group, persist = false) {
|
||||
if (group.id != null && group.id !== -1) return `group:${group.id}`;
|
||||
if (persist) { group.flags ??= {}; group.flags.adachi_preset_id ??= uuid(); }
|
||||
return group.flags?.adachi_preset_id ? `legacy:${group.flags.adachi_preset_id}` : `unsaved:${group.title}:${Array.from(group.pos || []).join(",")}`;
|
||||
}
|
||||
|
||||
function members(group, seen = new Set()) {
|
||||
if (seen.has(group)) return [];
|
||||
seen.add(group); group.recomputeInsideNodes?.();
|
||||
const result = new Set();
|
||||
for (const item of group._children || group._nodes || group.nodes || []) {
|
||||
if (typeof item.recomputeInsideNodes === "function") {
|
||||
for (const node of members(item, seen)) result.add(node);
|
||||
} else if (item.mode != null && item.type !== GROUP_PRESETS_TYPE) result.add(item);
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
export class GroupPresetState {
|
||||
constructor(node, change) { this.node = node; this.change = change; this.restore(); }
|
||||
restore() {
|
||||
const raw = this.node.properties.adachi_group_presets;
|
||||
const ids = new Set();
|
||||
const presets = (Array.isArray(raw?.presets) ? raw.presets : []).filter(p => p && typeof p.id === "string" && !ids.has(p.id) && ids.add(p.id)).map(p => ({
|
||||
id: p.id, name: String(p.name || "Preset"),
|
||||
groups: Object.fromEntries(Object.entries(p.groups || {}).filter(([, state]) => state && [ACTIVE, BYPASS].includes(state.mode)).map(([key, state]) => [key, { mode: state.mode, title: String(state.title || key) }])),
|
||||
}));
|
||||
this.node.properties.adachi_group_presets = { version: 1, activeId: presets.some(p => p.id === raw?.activeId) ? raw.activeId : presets[0]?.id || null, presets };
|
||||
}
|
||||
get data() { return this.node.properties.adachi_group_presets; }
|
||||
get active() { return this.data.presets.find(p => p.id === this.data.activeId) || null; }
|
||||
groups(persist = false) {
|
||||
return Array.from(this.node.graph?.groups || this.node.graph?._groups || []).map(group => {
|
||||
const nodes = members(group);
|
||||
const modes = new Set(nodes.map(node => node.mode));
|
||||
return { key: groupKey(group, persist), title: String(group.title || "Group"), color: group.color || "#64748b", group, nodes, mode: modes.size === 1 ? [...modes][0] : nodes.length ? -1 : ACTIVE,
|
||||
area: Math.abs((group.size?.[0] || 1) * (group.size?.[1] || 1)) };
|
||||
});
|
||||
}
|
||||
capture() {
|
||||
return Object.fromEntries(this.groups(true).map(group => [group.key, { title: group.title, mode: group.nodes.some(node => node.mode === ACTIVE) || !group.nodes.length ? ACTIVE : BYPASS }]));
|
||||
}
|
||||
add(name) {
|
||||
const title = String(name).trim(); if (!title) return null;
|
||||
let created;
|
||||
this.change(() => {
|
||||
created = { id: uuid(), name: title, groups: this.capture() };
|
||||
this.data.presets.push(created); this.data.activeId = created.id;
|
||||
});
|
||||
return created;
|
||||
}
|
||||
rename(id, name) {
|
||||
const preset = this.data.presets.find(p => p.id === id), title = String(name).trim();
|
||||
if (preset && title) this.change(() => { preset.name = title; });
|
||||
}
|
||||
saveCurrent() { if (this.active) this.change(() => { this.active.groups = this.capture(); }); }
|
||||
remove(id) {
|
||||
this.change(() => {
|
||||
this.data.presets = this.data.presets.filter(p => p.id !== id);
|
||||
if (this.data.activeId === id) { this.data.activeId = this.data.presets[0]?.id || null; if (this.active) this.applyStates(this.active); }
|
||||
});
|
||||
}
|
||||
setGroup(presetId, key, mode) {
|
||||
const preset = this.data.presets.find(p => p.id === presetId), group = this.groups().find(g => g.key === key);
|
||||
if (!preset || !group) return;
|
||||
this.change(() => {
|
||||
if (mode == null) delete preset.groups[key];
|
||||
else {
|
||||
const stableKey = groupKey(group.group, true);
|
||||
if (stableKey !== key) delete preset.groups[key];
|
||||
preset.groups[stableKey] = { title: group.title, mode: mode === BYPASS ? BYPASS : ACTIVE };
|
||||
}
|
||||
if (this.data.activeId === presetId) this.applyStates(preset);
|
||||
});
|
||||
}
|
||||
apply(id) {
|
||||
const preset = this.data.presets.find(p => p.id === id); if (!preset) return null;
|
||||
let result;
|
||||
this.change(() => { this.data.activeId = id; result = this.applyStates(preset); });
|
||||
return result;
|
||||
}
|
||||
planStates(preset, available = this.groups()) {
|
||||
const groups = available.filter(g => preset.groups[g.key]).sort((a, b) => b.area - a.area || a.key.localeCompare(b.key));
|
||||
const plan = new Map(), overlaps = new Set();
|
||||
// Smaller (more specific) groups override enclosing/overlapping groups.
|
||||
for (const group of groups) {
|
||||
const mode = preset.groups[group.key].mode;
|
||||
for (const node of group.nodes) {
|
||||
if (plan.has(node) && plan.get(node) !== mode) overlaps.add(node);
|
||||
plan.set(node, mode);
|
||||
}
|
||||
}
|
||||
return { plan, stats: { groups: groups.length, nodes: plan.size, overlaps: overlaps.size, missing: Object.keys(preset.groups).length - groups.length } };
|
||||
}
|
||||
applyStates(preset) {
|
||||
const { plan, stats } = this.planStates(preset);
|
||||
for (const [node, mode] of plan) node.mode = mode;
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { T } from "./i18n.js";
|
||||
import { GROUP_PRESETS_TYPE, GroupPresetState } from "./group_preset_state.js";
|
||||
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; };
|
||||
function ensureStyle() {
|
||||
if (document.getElementById("adachi-inpaint-style")) return;
|
||||
const link = el("link"); link.id = "adachi-inpaint-style"; link.rel = "stylesheet";
|
||||
link.href = new URL("./editor.css", import.meta.url).href; document.head.append(link);
|
||||
}
|
||||
|
||||
class GroupPresetsPanel {
|
||||
constructor(node) {
|
||||
this.node = node; this.root = el("div", "dd-inpaint dd-group-presets");
|
||||
this.model = new GroupPresetState(node, operation => {
|
||||
app.canvas?.emitBeforeChange?.();
|
||||
try { operation(); node.graph?.setDirtyCanvas(true, true); }
|
||||
finally { app.canvas?.emitAfterChange?.(); }
|
||||
this.refresh(true);
|
||||
});
|
||||
const toolbar = el("div", "dd-gp-toolbar"); toolbar.append(el("strong", "", T("Group presets")));
|
||||
this.add = this.button(toolbar, T("+ New preset"), () => this.showForm("add"));
|
||||
this.form = el("form", "dd-gp-form"); this.form.hidden = true;
|
||||
this.nameInput = el("input"); this.nameInput.type = "text"; this.nameInput.maxLength = 120;
|
||||
this.nameInput.setAttribute("aria-label", T("Preset name"));
|
||||
this.form.append(this.nameInput);
|
||||
const save = this.button(this.form, T("Save"), () => this.submitName()); save.classList.add("dd-primary");
|
||||
this.button(this.form, T("Cancel"), () => { this.form.hidden = true; });
|
||||
this.form.onsubmit = event => { event.preventDefault(); this.submitName(); };
|
||||
this.nameInput.onkeydown = event => { event.stopPropagation(); if (event.key === "Escape") { event.preventDefault(); this.form.hidden = true; } };
|
||||
const body = el("div", "dd-gp-body");
|
||||
this.presets = el("nav", "dd-gp-presets dd-gp-scroll"); this.presets.setAttribute("aria-label", T("Presets"));
|
||||
const detail = el("section", "dd-gp-detail");
|
||||
const head = el("div", "dd-gp-detail-head"); this.title = el("strong"); head.append(this.title);
|
||||
this.rename = this.button(head, T("Rename"), () => this.showForm("rename"));
|
||||
this.groups = el("div", "dd-gp-groups dd-gp-scroll"); this.groups.setAttribute("aria-label", T("Group states"));
|
||||
const footer = el("div", "dd-gp-footer");
|
||||
this.capture = this.button(footer, T("Save current group states"), () => this.model.saveCurrent());
|
||||
this.remove = this.button(footer, T("Delete preset"), () => { if (this.model.active) this.model.remove(this.model.active.id); });
|
||||
detail.append(head, this.groups, footer); body.append(this.presets, detail);
|
||||
this.status = el("div", "dd-gp-status"); this.status.setAttribute("role", "status");
|
||||
this.root.append(toolbar, this.form, body, this.status);
|
||||
this.onWheel = event => {
|
||||
if (!this.root.contains(event.target)) return;
|
||||
const scroll = event.target.closest(".dd-gp-scroll");
|
||||
if (scroll && ((event.deltaY < 0 && scroll.scrollTop > 0) || (event.deltaY > 0 && scroll.scrollTop + scroll.clientHeight < scroll.scrollHeight - 1))) {
|
||||
event.stopPropagation(); return;
|
||||
}
|
||||
event.preventDefault(); event.stopPropagation(); app.canvas?.processMouseWheel(event);
|
||||
};
|
||||
window.addEventListener("wheel", this.onWheel, { capture: true, passive: false });
|
||||
this.timer = setInterval(() => { if (this.root.isConnected && !document.hidden) this.refresh(); }, 500);
|
||||
this.refresh(true);
|
||||
}
|
||||
button(parent, label, callback) { const button = el("button", "", label); button.type = "button"; button.onclick = event => { event.stopPropagation(); callback(); }; parent.append(button); return button; }
|
||||
showForm(mode) {
|
||||
this.formMode = mode; this.form.hidden = false;
|
||||
this.nameInput.value = mode === "rename" ? this.model.active?.name || "" : `${T("Preset")} ${this.model.data.presets.length + 1}`;
|
||||
this.nameInput.focus({ preventScroll: true }); this.nameInput.select();
|
||||
}
|
||||
submitName() {
|
||||
const name = this.nameInput.value.trim(); if (!name) { this.nameInput.focus(); return; }
|
||||
if (this.formMode === "rename" && this.model.active) this.model.rename(this.model.active.id, name);
|
||||
else this.model.add(name);
|
||||
this.form.hidden = true;
|
||||
}
|
||||
apply(id) {
|
||||
this.model.apply(id);
|
||||
}
|
||||
refresh(force = false) {
|
||||
const active = this.model.active, groups = this.model.groups();
|
||||
const signature = JSON.stringify([this.model.data, groups.map(g => [g.key, g.title, g.color, g.nodes.map(n => [n.id, n.mode])]), T("Group presets")]);
|
||||
if (!force && signature === this.signature) return;
|
||||
this.signature = signature;
|
||||
const navScroll = this.presets.scrollTop, groupScroll = this.groups.scrollTop;
|
||||
this.presets.replaceChildren(); this.groups.replaceChildren();
|
||||
for (const preset of this.model.data.presets) {
|
||||
const button = this.button(this.presets, preset.name, () => this.apply(preset.id));
|
||||
button.dataset.presetId = preset.id; button.title = preset.name;
|
||||
button.classList.toggle("dd-active", preset.id === active?.id);
|
||||
button.setAttribute("aria-pressed", String(preset.id === active?.id));
|
||||
}
|
||||
if (!this.model.data.presets.length) this.presets.append(el("div", "dd-gp-empty", T("Add a preset to get started")));
|
||||
this.title.textContent = active?.name || T("Group states"); this.title.title = this.title.textContent;
|
||||
const expected = active ? this.model.planStates(active, groups) : { plan: new Map(), stats: {} };
|
||||
for (const group of groups) {
|
||||
const row = el("label", "dd-gp-group"); row.dataset.groupKey = group.key;
|
||||
const dot = el("span", "dd-gp-color"); dot.style.background = group.color;
|
||||
const label = el("span", "dd-gp-group-name", group.title); label.title = `${group.title} · ${group.key}`;
|
||||
const select = el("select"); select.setAttribute("aria-label", group.title); select.disabled = !active;
|
||||
for (const [value, name] of [["", "Keep current"], ["0", "Enabled"], ["4", "Bypassed"]]) {
|
||||
const option = el("option", "", T(name)); option.value = value; select.append(option);
|
||||
}
|
||||
const saved = active?.groups[group.key]; select.value = saved ? String(saved.mode) : "";
|
||||
if (saved && group.nodes.some(n => n.mode !== expected.plan.get(n))) { row.classList.add("dd-gp-changed"); label.title += ` · ${T("Current group state differs from the preset")}`; }
|
||||
select.onchange = () => { if (active) this.model.setGroup(active.id, group.key, select.value === "" ? null : Number(select.value)); };
|
||||
row.append(dot, label, select); this.groups.append(row);
|
||||
}
|
||||
if (!groups.length) this.groups.append(el("div", "dd-gp-empty", T("No groups in this workflow yet")));
|
||||
for (const button of [this.rename, this.capture, this.remove]) button.disabled = !active;
|
||||
const missing = active ? Object.keys(active.groups).filter(key => !groups.some(g => g.key === key)).length : 0;
|
||||
this.status.textContent = `${this.model.data.presets.length} ${T("presets")} · ${groups.length} ${T("groups")}${missing ? ` · ${missing} ${T("missing groups")}` : ""}${expected.stats.overlaps ? ` · ${T("Overlapping groups use the smaller group's setting")}` : ""}`;
|
||||
this.presets.scrollTop = navScroll; this.groups.scrollTop = groupScroll;
|
||||
}
|
||||
destroy() { clearInterval(this.timer); window.removeEventListener("wheel", this.onWheel, true); this.root.remove(); }
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Adachi.GroupPresets",
|
||||
registerCustomNodes() {
|
||||
ensureStyle();
|
||||
class GroupPresetsNode extends LGraphNode {
|
||||
constructor() {
|
||||
super(T("Group Bypass Presets"));
|
||||
this.isVirtualNode = true; this.comfyClass = GROUP_PRESETS_TYPE;
|
||||
this.serialize_widgets = false;
|
||||
this.properties ??= {};
|
||||
this.adachiGroupPresets = new GroupPresetsPanel(this);
|
||||
const widget = this.addDOMWidget("group_presets", "adachi_group_presets", this.adachiGroupPresets.root, {
|
||||
serialize: false, hideOnZoom: false, getMinHeight: () => 290, getMaxHeight: () => 1600,
|
||||
});
|
||||
widget.serialize = false; this.setSize([620, 410]);
|
||||
}
|
||||
onAdded() { this.adachiGroupPresets.refresh(true); }
|
||||
onConfigure() { this.adachiGroupPresets.model.restore(); this.adachiGroupPresets.refresh(true); }
|
||||
onRemoved() { this.adachiGroupPresets.destroy(); }
|
||||
getExtraMenuOptions(_, options) {
|
||||
const panel = this.adachiGroupPresets;
|
||||
options.push(null, { content: T("Apply preset"), has_submenu: true, disabled: !panel.model.data.presets.length,
|
||||
submenu: { options: panel.model.data.presets.map(p => ({ content: `${p.id === panel.model.data.activeId ? "✓ " : ""}${p.name}`, callback: () => panel.apply(p.id) })) } },
|
||||
{ content: T("+ New preset"), callback: () => panel.showForm("add") },
|
||||
{ content: T("Save current group states"), disabled: !panel.model.active, callback: () => panel.model.saveCurrent() });
|
||||
}
|
||||
}
|
||||
GroupPresetsNode.title = T("Group Bypass Presets");
|
||||
GroupPresetsNode.desc = T("Switch saved group enable/bypass presets; no connections required.");
|
||||
LiteGraph.registerNodeType(GROUP_PRESETS_TYPE, GroupPresetsNode);
|
||||
GroupPresetsNode.category = "Adachi-Inpaint-Workbench";
|
||||
},
|
||||
});
|
||||
+27
-1
@@ -6,6 +6,30 @@
|
||||
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
|
||||
|
||||
const ZH = {
|
||||
"Group Bypass Presets": "组忽略预设 / Group Bypass Presets",
|
||||
"Switch saved group enable/bypass presets; no connections required.": "切换已保存的分组启用/忽略预设,无需接线。",
|
||||
"Group presets": "分组预设",
|
||||
"+ New preset": "+ 添加预设",
|
||||
"Preset name": "预设名称",
|
||||
"Save": "保存",
|
||||
"Cancel": "取消",
|
||||
"Presets": "预设列表",
|
||||
"Group states": "分组状态",
|
||||
"Rename": "改名",
|
||||
"Save current group states": "记住当前分组状态",
|
||||
"Delete preset": "删除预设",
|
||||
"Preset": "预设",
|
||||
"Apply preset": "应用预设",
|
||||
"Add a preset to get started": "添加第一套预设",
|
||||
"No groups in this workflow yet": "当前工作流还没有分组",
|
||||
"Keep current": "保持当前",
|
||||
"Enabled": "打开",
|
||||
"Bypassed": "忽略",
|
||||
"Current group state differs from the preset": "当前分组状态与预设不同",
|
||||
"Overlapping groups use the smaller group's setting": "重叠分组以较小分组的设置为准",
|
||||
"presets": "套预设",
|
||||
"groups": "个分组",
|
||||
"missing groups": "个分组已不存在",
|
||||
// compare.js
|
||||
"Result": "结果",
|
||||
"Choose result": "选择重绘结果",
|
||||
@@ -71,7 +95,9 @@ const ZH = {
|
||||
|
||||
// inline_inpaint.js
|
||||
"The inline mask node was removed": "内联蒙版节点已移除",
|
||||
"Adopted the result as the new source image; the old mask was cleared": "已采用重绘结果作为新原图,旧蒙版已清空",
|
||||
"Adopted the result as the new source image; the mask and undo history were preserved": "已采用重绘结果作为新原图,蒙版与撤销记录已保留",
|
||||
"The result size must match the source image to preserve the mask": "为保留蒙版,重绘结果尺寸必须与原图一致",
|
||||
"Wait for the source image to finish loading before adopting a result": "请等待原图加载完成后再采用结果",
|
||||
"Drag to resize canvas height": "上下拖动调整画布高度",
|
||||
"Resize canvas height": "调整画布高度",
|
||||
"Upload failed": "上传失败",
|
||||
|
||||
+10
-8
@@ -91,7 +91,8 @@ app.registerExtension({
|
||||
onDirty() { markChanged(); },
|
||||
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
|
||||
onGraphWheel: event => app.canvas?.processMouseWheel(event),
|
||||
async onUpload(file) {
|
||||
async onUpload(file, { preserveMask = false } = {}) {
|
||||
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the source image to finish loading before adopting a result"));
|
||||
const version = ++uploadVersion;
|
||||
const body = new FormData();
|
||||
body.append("image", file, file.name);
|
||||
@@ -102,12 +103,13 @@ app.registerExtension({
|
||||
const uploaded = await response.json();
|
||||
if (version !== uploadVersion || editor.destroyed) return;
|
||||
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
|
||||
imageWidget.value = name;
|
||||
maskWidget.value = "";
|
||||
sourceWidget.value = "";
|
||||
restoring = false;
|
||||
await editor.setImage(imageUrl(name), "", file.name);
|
||||
if (editor.embed.checked) sourceWidget.value = editor.sourceData();
|
||||
if (preserveMask && editor.ready) await editor.replaceSource(imageUrl(name), file.name);
|
||||
else await editor.setImage(imageUrl(name), "", file.name);
|
||||
if (version !== uploadVersion || editor.destroyed) return;
|
||||
imageWidget.value = name;
|
||||
maskWidget.value = preserveMask ? editor.flush() : "";
|
||||
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
|
||||
markChanged();
|
||||
},
|
||||
onEmbed(enabled) {
|
||||
@@ -119,9 +121,9 @@ app.registerExtension({
|
||||
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
|
||||
node.adachiUseResult = async file => {
|
||||
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
|
||||
await editor.options.onUpload(file);
|
||||
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 old mask was cleared"));
|
||||
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
|
||||
};
|
||||
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
|
||||
serialize: false,
|
||||
|
||||
Reference in New Issue
Block a user