diff --git a/docs/plans/2026-06-21-multi-reroute-design.md b/docs/plans/2026-06-21-multi-reroute-design.md new file mode 100644 index 0000000..0d7a92f --- /dev/null +++ b/docs/plans/2026-06-21-multi-reroute-design.md @@ -0,0 +1,88 @@ +# Multi-Reroute (Rail) — Design + +Date: 2026-06-21 +Status: Approved (brainstorming complete, ready for implementation plan) + +## 1. Purpose + +A single node holding **N parallel pass-through lanes** (a "rail"), so you can run tidy +bundles of wires across the graph instead of dropping many separate Reroute nodes. Each lane +forwards any type; you grow/shrink the rail with +/− at either end. + +Seventh node in the `ComfyUI-Datasete-Gates` suite. + +## 2. Approach + +A **real pass-through node** with **any-type lanes** (`AnyType("*")`). Lane `i`'s input is +forwarded to lane `i`'s output. An unconnected lane outputs an `ExecutionBlocker` so nothing +downstream of an unused lane runs. (Not the frontend-only virtual-reroute trick — simpler and +robust across all types; the trade-off is slots read as `*` instead of adapting to the wired +type.) + +## 3. IO + +- Up to `MAX_LANES` (32) lanes, each: optional input `in_` (`*`) → output `out_` (`*`). +- The node always returns a length-`MAX_LANES` tuple; the frontend shows only the active + lanes (default 4). Wired output indices are stable, so unshown trailing outputs are simply + unconnected. + +``` +RETURN_TYPES = (ANY,) * MAX_LANES RETURN_NAMES = ("out_1", …, "out_32") +INPUT_TYPES = {"optional": {"in_1": (ANY,), …}} +``` + +No `IS_CHANGED` override — a reroute should be transparent/cacheable (re-runs only when an +input value actually changes). + +## 4. Run logic + +```python +def run(self, **kwargs): + blocker = ExecutionBlocker(None) + return tuple( + kwargs.get(f"in_{i+1}") if kwargs.get(f"in_{i+1}") is not None else blocker + for i in range(MAX_LANES) + ) +``` + +Lane-count-agnostic: connected lanes forward their value; empty lanes block. The visible lane +count is purely a frontend concern. + +## 5. Frontend (`web/multi_reroute.js`) + +- Render `lanes` lane rows (input + output pair), default 4; persist the count in a hidden + widget so reload restores the rail (the "use raw widgets_values to add slots before link + rewiring" pattern already used in this repo). +- **+/− buttons**: + - **Bottom add/remove** (Phase 1): reveal/hide the next/last lane pair — trivial and + wiring-safe (only the end moves). + - **Top add/remove** (Phase 2): insert/remove a lane at the top while **preserving the + other lanes' wiring** — requires capturing links and re-mapping slot indices + (rgthree-style). Kept separate so a bug here can't scramble existing rails. +- Lanes use the shared `AnyType` so any wire connects. +- (Phase 3 polish) compact reroute-pill look / optional per-lane labels. + +## 6. Edge cases + +- Empty lane → `ExecutionBlocker` (downstream skipped). A legitimate `None` value is treated + as empty (reroute values are objects/tensors, effectively never `None`). +- Removing a lane is from the **end** in Phase 1 (indices stay stable → links intact). + Mid/top removal is Phase 2 with remap. +- More than `MAX_LANES` requested → capped (logged in UI). +- Mixed types across lanes is fine — each lane is independent `*`. + +## 7. Code shape + +- `gates/anytype.py` *(new)* — shared `AnyType("*")` + `ANY` (textgate can dedupe onto this + later; not touched now). +- `gates/reroute_node.py` *(new)* — pure `build_outputs(values, max_lanes, blocker)` + + `MultiReroute` node (lazy `ExecutionBlocker` import for testability). +- `web/multi_reroute.js` *(new)* — dynamic lane slots + +/− buttons + persistence. +- root `__init__.py` — additive merge of the node mapping. + +## 8. Testing + +- pytest: `anytype` equals-everything; `build_outputs` forwards connected lanes and blocks + empty ones (length == MAX_LANES); node `RETURN_TYPES` length + all-`*`. +- Manual (live): add/remove lanes (bottom, then top), wire mixed types through, confirm values + pass and reload restores the rail; empty lanes don't trigger downstream. diff --git a/docs/plans/2026-06-21-multi-reroute-implementation.md b/docs/plans/2026-06-21-multi-reroute-implementation.md new file mode 100644 index 0000000..beb8492 --- /dev/null +++ b/docs/plans/2026-06-21-multi-reroute-implementation.md @@ -0,0 +1,237 @@ +# 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.py` and `gates/reroute_node.py` import-safe without comfy (lazy + `ExecutionBlocker` inside `run`). +- `__init__.py` edit 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** + +```python +# 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** + +```python +# 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** + +```python +# 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** + +```python +# 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** + +```python +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)** + +```python +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_TYPES` entries are the `AnyType` instance (`== "*"`), so the test's `t == "*"` +> holds. No `IS_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__:`: + +```python + from .gates.reroute_node import NODE_CLASS_MAPPINGS as _RR_NODES, \ + NODE_DISPLAY_NAME_MAPPINGS as _RR_NAMES +``` +and merge into the final dicts: +```python + 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 shows `lanes` lane pairs (default 4). Keep a hidden + `lanes` widget (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 + raw `widgets_values` before link rewiring). +- Maintain exactly `lanes` visible input slots `in_1..in_lanes` and output slots + `out_1..out_lanes` (add/remove the trailing pair as the count changes). +- **Bottom +**: `lanes++` → add `in_{n}`/`out_{n}`. **Bottom −**: `lanes--` → remove the last + pair (only the end moves, so existing wiring is untouched). Clamp `1..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 -v` green; full `tests/` green. +- Manual checklist passes: lanes add/remove (bottom; top in P2), mixed-type pass-through, + empty-lane blocking, persistence.