Compare commits

...
1 Commits
Author SHA1 Message Date
adachi f1dbaf0fb0 Restore dropped exclusive inputs from subgraph output routes 2026-10-16 15:09:46 +08:00
7 changed files with 139 additions and 6 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.5.13
- Restore a missing enabled execution input by resolving its saved workflow route through Get/Set, reroutes and subgraph output slots to an existing flattened execution node.
- Fix the captured remote failure where the selected positive branch existed in a tag subgraph but its input link was absent from the submitted exclusive node. Never invent missing backend nodes or connect a different node type.
- Include node ID, frontend/workflow flags and submitted-connection flags in selection errors to distinguish an ignored branch from lost execution wiring.
- Validate three actual failed remote request snapshots locally and two lightweight CPU queue probes with the same missing-link/subgraph mapping. Restart ComfyUI after this backend update.
## 0.5.12
- Trace enabled Get nodes back through their named Set and encoder in saved workflow metadata; ignored Sets disable their branch even when both Gets remain enabled and both execution links survive.
+1
View File
@@ -163,6 +163,7 @@ The ignored branch is not requested by this selector; any separate preview/save
For raw API prompts, omit the ignored input and omit the internal `active_inputs` field; exactly one of `input_a` / `input_b` must remain.
Without workflow metadata, the backend checks actual submitted connections after virtual routing: when only one input survives, it uses that branch even if cached frontend flags still report both enabled. With both connections present and no workflow metadata, the frontend flags remain necessary to distinguish bypassed transform pass-through from an enabled branch. Updating this backend logic requires a full ComfyUI restart as well as a browser refresh.
When workflow metadata accompanies the request, the server independently follows Get → named Set → source node. Both Gets can stay enabled: bypassing one branch's Set or encoder disables that branch. The server normalizes flags and removes ignored execution inputs before validation and cache lookup, while the saved workflow retains its connections. Without workflow metadata the submitted connections/frontend flags remain the fallback.
If the enabled input was dropped during frontend execution serialization, the server can restore its saved connection, including a subgraph output mapped to a flattened node ID. It only reconnects an existing submitted node whose type matches the saved source; it does not create missing model/encoder nodes.
## Using a standard sampling workflow
+5 -4
View File
@@ -1,10 +1,11 @@
# Git versions and rollback
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.
The current local version is **v0.5.13**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
| Tag | Contents |
|---|---|
| `v0.5.12` | Trace Get/Set source states server-side before prompt validation and caching |
| `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 |
@@ -24,13 +25,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach local-before-v0.5.12
git switch --detach local-before-v0.5.13
```
Return to this version with:
```powershell
git switch --detach v0.5.12
git switch --detach v0.5.13
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+2
View File
@@ -1,5 +1,7 @@
# Verification record
- 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.
+87 -1
View File
@@ -680,6 +680,86 @@ def _workflow_exclusive_flags(extra_pnginfo, unique_id):
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", {})
@@ -694,9 +774,12 @@ def normalize_exclusive_prompt(data):
continue
inputs = node.setdefault("inputs", {})
inputs["active_inputs"] = flags
sources = _workflow_exclusive_sources(extra, node_id, prompt) if flags in (1, 2) else {}
for name, bit in (("input_a", 1), ("input_b", 2)):
if not flags & bit:
inputs.pop(name, None)
elif name not in inputs and name in sources:
inputs[name] = sources[name]
return data
@@ -726,6 +809,7 @@ class AdachiExclusiveSwitch:
@staticmethod
def _selected_input(input_a, input_b, active_inputs, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
frontend_flags, submitted_flags = active_inputs, None
workflow_flags = _workflow_exclusive_flags(extra_pnginfo, unique_id)
if workflow_flags is not None:
active_inputs = workflow_flags
@@ -743,6 +827,7 @@ class AdachiExclusiveSwitch:
# 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:
@@ -752,7 +837,8 @@ class AdachiExclusiveSwitch:
raise ValueError(
"Auto Exclusive Switch requires exactly one enabled input. "
"Bypass one branch or disconnect it; both enabled / both absent are invalid. "
f"Received enabled-state={active_inputs}."
f"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:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.5.12"
version = "0.5.13"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+36
View File
@@ -135,6 +135,42 @@ class ExclusiveSwitchTests(unittest.TestCase):
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()