Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c225ea242 | ||
|
|
8f128f5417 |
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -161,6 +161,8 @@ Place the selector outside the two controlled groups and connect each group's fi
|
||||
KJNodes Get/Set routes and reroutes are supported: an ignored/muted Get, its corresponding Set, or the source behind those routing nodes disables that selector branch. Missing setters and virtual routing cycles also count as unavailable. Ordinary processing nodes remain independent active sources; the selector does not guess the meaning of arbitrary processing chains.
|
||||
The ignored branch is not requested by this selector; any separate preview/save outputs on that branch may still execute independently.
|
||||
For raw API prompts, omit the ignored input and omit the internal `active_inputs` field; exactly one of `input_a` / `input_b` must remain.
|
||||
Without workflow metadata, the backend checks actual submitted connections after virtual routing: when only one input survives, it uses that branch even if cached frontend flags still report both enabled. With both connections present and no workflow metadata, the frontend flags remain necessary to distinguish bypassed transform pass-through from an enabled branch. Updating this backend logic requires a full ComfyUI restart as well as a browser refresh.
|
||||
When workflow metadata accompanies the request, the server independently follows Get → named Set → source node. Both Gets can stay enabled: bypassing one branch's Set or encoder disables that branch. The server normalizes flags and removes ignored execution inputs before validation and cache lookup, while the saved workflow retains its connections. Without workflow metadata the submitted connections/frontend flags remain the fallback.
|
||||
|
||||
## Using a standard sampling workflow
|
||||
|
||||
|
||||
+6
-4
@@ -1,10 +1,12 @@
|
||||
# Git versions and rollback
|
||||
|
||||
The current local version is **v0.5.10**. 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 local version is **v0.5.12**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
|
||||
|
||||
| Tag | Contents |
|
||||
|---|---|
|
||||
| `v0.5.10` | Pin inline fit, bound/cache inline previews, Upload label and restart comparison rounds after adoption |
|
||||
| `v0.5.12` | 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 |
|
||||
@@ -22,13 +24,13 @@ To undo only this update, run in the plugin folder:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git switch --detach local-before-v0.5.10
|
||||
git switch --detach local-before-v0.5.12
|
||||
```
|
||||
|
||||
Return to this version with:
|
||||
|
||||
```powershell
|
||||
git switch --detach v0.5.10
|
||||
git switch --detach v0.5.12
|
||||
```
|
||||
|
||||
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Verification record
|
||||
|
||||
- 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.
|
||||
|
||||
+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"
|
||||
|
||||
|
||||
+109
-6
@@ -618,6 +618,88 @@ class AdachiImageCompare:
|
||||
_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 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
|
||||
for name, bit in (("input_a", 1), ("input_b", 2)):
|
||||
if not flags & bit:
|
||||
inputs.pop(name, None)
|
||||
return data
|
||||
|
||||
|
||||
class AdachiExclusiveSwitch:
|
||||
"""Forward exactly one enabled branch, without evaluating the ignored branch."""
|
||||
|
||||
@@ -632,6 +714,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,25 +725,45 @@ 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):
|
||||
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)
|
||||
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}."
|
||||
)
|
||||
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.")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "0.5.10"
|
||||
version = "0.5.12"
|
||||
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -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,87 @@ 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"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user