Multi-lane any-type pass-through node ("rail"): in_i -> out_i, empty lane
-> ExecutionBlocker; +/- to add/remove lanes (bottom in P1, top with
wiring-preserving remap in P2). Pure build_outputs + shared AnyType; lazy
comfy import keeps it unit-testable. No IS_CHANGED (transparent passthrough).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.8 KiB
Multi-Reroute (Rail) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: A MultiReroute node — N parallel any-type pass-through lanes ("rail") with +/− to add/remove lanes — so wire bundles stay tidy without many separate Reroute nodes.
Architecture: A real pass-through node with AnyType("*") lanes: in_i → out_i, empty lane → ExecutionBlocker. Pure build_outputs is unit-tested; ExecutionBlocker is imported lazily. The frontend manages dynamic lane slots and persists the lane count. Bottom add/remove is Phase 1 (wiring-safe); top add/remove (link-preserving) is Phase 2.
Tech Stack: Python 3.12 (stdlib), pytest 9; vanilla JS frontend.
Conventions (read once)
- Test python:
/media/p5/miniforge3/bin/python(PY=...). - Run tests:
cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_anytype.py tests/test_reroute.py -v gates/anytype.pyandgates/reroute_node.pyimport-safe without comfy (lazyExecutionBlockerinsiderun).__init__.pyedit is additive — re-Read first, extend the mappings.MAX_LANES = 32, default visible lanes = 4.- Commit style: Conventional Commits + repo Co-Authored-By; stage only this node's paths.
Task 1: anytype.py — shared wildcard
Files: Create gates/anytype.py; Test tests/test_anytype.py
Step 1: Failing test
# tests/test_anytype.py
from gates import anytype
def test_any_equals_everything():
assert (anytype.ANY != "IMAGE") is False
assert (anytype.ANY != "LATENT") is False
assert isinstance(anytype.ANY, str)
Step 2: Run → FAIL.
Step 3: Implement
# gates/anytype.py
"""Shared ComfyUI wildcard type."""
class AnyType(str):
def __ne__(self, other):
return False
ANY = AnyType("*")
Step 4: Run → PASS. Step 5: Commit feat: shared AnyType wildcard
Task 2: reroute_node.py — build_outputs
Files: Create gates/reroute_node.py; Test tests/test_reroute.py
Step 1: Failing test
# tests/test_reroute.py
from gates import reroute_node as rr
def test_build_outputs_forwards_and_blocks():
B = object() # blocker sentinel
vals = {"in_1": "A", "in_3": "C"}
out = rr.build_outputs(vals, max_lanes=4, blocker=B)
assert out == ("A", B, "C", B)
def test_build_outputs_length():
B = object()
assert len(rr.build_outputs({}, max_lanes=rr.MAX_LANES, blocker=B)) == rr.MAX_LANES
Step 2: Run → FAIL.
Step 3: Implement
# gates/reroute_node.py
from .anytype import ANY
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
MAX_LANES = 32
def build_outputs(values, max_lanes, blocker):
out = []
for i in range(max_lanes):
v = values.get(f"in_{i + 1}")
out.append(v if v is not None else blocker)
return tuple(out)
Step 4: Run → PASS. Step 5: Commit feat: multi-reroute build_outputs
Task 3: reroute_node.py — MultiReroute node
Files: Modify gates/reroute_node.py, tests/test_reroute.py
Step 1: Failing test
def test_io_shape():
assert len(rr.MultiReroute.RETURN_TYPES) == rr.MAX_LANES
assert all(t == "*" for t in rr.MultiReroute.RETURN_TYPES)
assert rr.MultiReroute.RETURN_NAMES[0] == "out_1"
it = rr.MultiReroute.INPUT_TYPES()
assert "in_1" in it["optional"] and f"in_{rr.MAX_LANES}" in it["optional"]
Step 2: Run → FAIL.
Step 3: Implement (append)
class MultiReroute:
CATEGORY = "Datasete Gates"
FUNCTION = "run"
RETURN_TYPES = (ANY,) * MAX_LANES
RETURN_NAMES = tuple(f"out_{i + 1}" for i in range(MAX_LANES))
@classmethod
def INPUT_TYPES(cls):
return {"optional": {f"in_{i + 1}": (ANY,) for i in range(MAX_LANES)}}
def run(self, **kwargs):
from comfy_execution.graph_utils import ExecutionBlocker
return build_outputs(kwargs, MAX_LANES, ExecutionBlocker(None))
NODE_CLASS_MAPPINGS = {"MultiReroute": MultiReroute}
NODE_DISPLAY_NAME_MAPPINGS = {"MultiReroute": "Multi Reroute (Rail)"}
RETURN_TYPESentries are theAnyTypeinstance (== "*"), so the test'st == "*"holds. NoIS_CHANGED(transparent/cacheable passthrough).
Step 4: Run → PASS. Step 5: Commit feat: MultiReroute node (any-type pass-through lanes)
Task 4: Register in __init__.py (MERGE)
Files: Modify __init__.py
Step 1: Re-Read __init__.py; add inside if __package__::
from .gates.reroute_node import NODE_CLASS_MAPPINGS as _RR_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _RR_NAMES
and merge into the final dicts:
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_RR_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_RR_NAMES}
Step 2: $PY -c "import gates.reroute_node; print(gates.reroute_node.NODE_CLASS_MAPPINGS)".
Step 3: Full suite green: $PY -m pytest tests/ -v.
Step 4: Commit feat: register MultiReroute
Task 5: web/multi_reroute.js — dynamic lanes + bottom +/− (Phase 1)
Files: Create web/multi_reroute.js
app.registerExtension for MultiReroute:
- On
nodeCreated: ensure the node showslaneslane pairs (default 4). Keep a hiddenlaneswidget (count) so the rail count persists across save/reload — restore by re-adding that many slot pairs (mirror the repo's existing dynamic-slot restore that reads rawwidgets_valuesbefore link rewiring). - Maintain exactly
lanesvisible input slotsin_1..in_lanesand output slotsout_1..out_lanes(add/remove the trailing pair as the count changes). - Bottom +:
lanes++→ addin_{n}/out_{n}. Bottom −:lanes--→ remove the last pair (only the end moves, so existing wiring is untouched). Clamp1..MAX_LANES. - Buttons via on-node widgets (or the node context menu) labeled
+ lane/− lane. - Slots are any-type (
*) so any wire connects.
Manual verify: add/remove lanes from the bottom; wire IMAGE through lane 1 and LATENT through lane 2; values pass; save+reload restores the lane count and wiring.
Commit feat: multi-reroute frontend — dynamic lanes + bottom add/remove
Task 6: web/multi_reroute.js — top +/− with wiring preservation (Phase 2)
Files: Modify web/multi_reroute.js
- Top +: insert a new lane at the top. Because slots are positional and links bind to slot index, capture all current input/output links, add a pair, and re-map links so the existing lanes keep their connections and the new empty lane is visually first.
- Top −: remove the top lane, remap the rest up.
- Implement against a small lane-model (list of logical lanes ↔ slot indices); rebuild slots and reconnect from the model so a failure can't silently drop wires.
Manual verify: with lanes 1–3 wired, Top + adds an empty lane at top and 1–3 stay wired; Top − removes it cleanly. Save/reload still consistent.
Commit feat: multi-reroute top add/remove (wiring-preserving)
Task 7: Live smoke test in ComfyUI
Restart ComfyUI. Verify:
- "Multi Reroute (Rail)" appears under "Datasete Gates" with 4 lanes.
- Bottom +/− add/remove lanes; Top +/− (Phase 2) keep existing wiring.
- Route mixed types (IMAGE, MASK, LATENT, STRING) through separate lanes → all pass intact.
- An unconnected lane doesn't trigger its downstream (ExecutionBlocker).
- Save + reload restores lane count and all connections.
Commit (if fixes) fix: multi-reroute live-test adjustments
Definition of done
$PY -m pytest tests/test_anytype.py tests/test_reroute.py -vgreen; fulltests/green.- Manual checklist passes: lanes add/remove (bottom; top in P2), mixed-type pass-through, empty-lane blocking, persistence.