Compare commits
4 Commits
aa909448d7
...
fe95a9af3a
| Author | SHA1 | Date | |
|---|---|---|---|
| fe95a9af3a | |||
| 99a5ccac82 | |||
| f2ac5e37f3 | |||
| ce371ffe13 |
@@ -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_<i>` (`*`) → output `out_<i>` (`*`).
|
||||||
|
- 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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Text Gate — "Run from here" + sticky edit (design)
|
||||||
|
|
||||||
|
**Goal:** Bring the Text Gate to parity with the Image Gate's "Run from here"
|
||||||
|
affordance, plus a text-specific touch: keep the user's edited text across
|
||||||
|
re-runs ("start from there").
|
||||||
|
|
||||||
|
**Scope:** Frontend only — `web/text_gate.js`. No changes to `gates/textgate.py`,
|
||||||
|
`gates/gate_bus.py`, or `gates/gate_server.py`. The gate already re-arms and
|
||||||
|
re-pauses on every run (`GateBus.arm` → `wait_payload`) and `IS_CHANGED` returns
|
||||||
|
`NaN`, so re-queuing the prompt is enough to "resume": cached upstream means the
|
||||||
|
gate re-pauses near-instantly.
|
||||||
|
|
||||||
|
## State machine
|
||||||
|
|
||||||
|
The node currently has no explicit state. Add three:
|
||||||
|
|
||||||
|
- **idle** — before the first run. Pass shown, Run-from-here hidden.
|
||||||
|
- **paused** — socket `datasete-textgate-show` arrived. Textarea editable &
|
||||||
|
populated, **▶ Pass** shown, **Run from here** hidden, status `edit, then Pass`.
|
||||||
|
- **passed** — after Pass click. Textarea keeps the edited text, **Pass** hidden,
|
||||||
|
**▶ Run from here** shown, status `passed — Run from here to re-run`.
|
||||||
|
|
||||||
|
**Run from here** click → `app.queuePrompt(0, 1)` with `app.queuePrompt(0)`
|
||||||
|
fallback — copied verbatim from the Image Gate's `queueFromHere`.
|
||||||
|
|
||||||
|
## Sticky edited text
|
||||||
|
|
||||||
|
The Image Gate keeps its mask sticky; the Text Gate keeps its text. The live
|
||||||
|
textarea IS the sticky store, gated by the last-seen input:
|
||||||
|
|
||||||
|
- Track `node._tgInput` = the last incoming text the server pushed.
|
||||||
|
- On each re-pause with `incoming`:
|
||||||
|
- if `incoming === node._tgInput` (upstream unchanged — the Run-from-here
|
||||||
|
case) → **keep** the current textarea value, so the gate re-runs *your*
|
||||||
|
edited version (including any edits made after Pass).
|
||||||
|
- else (a genuine upstream recompute) → overwrite the textarea with `incoming`.
|
||||||
|
- always set `node._tgInput = incoming`.
|
||||||
|
|
||||||
|
Net: "Run from here" re-runs your version, but a real upstream change still
|
||||||
|
surfaces instead of hiding behind a stale edit. `_tgInput` is per-session
|
||||||
|
(not serialized) — a page reload starts fresh, which is fine.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `node --check web/text_gate.js` (no JS test harness in the repo — consistent
|
||||||
|
with the other `web/*.js`).
|
||||||
|
- Manual: pause → edit → Pass → button appears → Run-from-here re-pauses showing
|
||||||
|
your edited text → downstream re-runs; change something upstream → new input
|
||||||
|
shows.
|
||||||
|
|
||||||
|
## Dropped (YAGNI)
|
||||||
|
|
||||||
|
- A separate "↺ reset to input" button — the upstream-change detection covers the
|
||||||
|
stale-edit footgun.
|
||||||
|
- Any backend auto-pass / bypass mode — not requested.
|
||||||
+60
-7
@@ -7,6 +7,12 @@ import { api } from "../../scripts/api.js";
|
|||||||
// the server pushes via the "datasete-textgate-show" socket event and POSTs the
|
// the server pushes via the "datasete-textgate-show" socket event and POSTs the
|
||||||
// edited text back. Outputs are static (text, signal) — no dynamic slots.
|
// edited text back. Outputs are static (text, signal) — no dynamic slots.
|
||||||
//
|
//
|
||||||
|
// After Pass, a "▶ Run from here" button re-queues the prompt (Image Gate
|
||||||
|
// parity): the gate re-arms every run and IS_CHANGED is NaN, so cached upstream
|
||||||
|
// means it re-pauses near-instantly. The edited text is sticky — kept across
|
||||||
|
// re-runs while the upstream input is unchanged, so Run-from-here re-runs YOUR
|
||||||
|
// version; a genuine upstream change still surfaces the new input.
|
||||||
|
//
|
||||||
// Sizing follows the Image Pool node: the editor is always present and FILLS the
|
// Sizing follows the Image Pool node: the editor is always present and FILLS the
|
||||||
// node, with only a min-height floor (no max) so the node stays freely resizable
|
// node, with only a min-height floor (no max) so the node stays freely resizable
|
||||||
// and the textarea grows with it.
|
// and the textarea grows with it.
|
||||||
@@ -28,6 +34,30 @@ async function postPass(node, text) {
|
|||||||
await api.fetchApi(`${R}/pass`, { method: "POST", body: fd });
|
await api.fetchApi(`${R}/pass`, { method: "POST", body: fd });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- run-from-here + state --------------------------------------------------
|
||||||
|
// States: "idle" (pre-run), "paused" (waiting for Pass), "passed" (Run-from-here
|
||||||
|
// shown). Re-queuing the whole prompt is enough to "resume" — cached upstream
|
||||||
|
// re-pauses the gate, matching the Image Gate's queueFromHere.
|
||||||
|
|
||||||
|
async function queueFromHere(node) {
|
||||||
|
try {
|
||||||
|
await app.queuePrompt(0, 1);
|
||||||
|
} catch (e) {
|
||||||
|
try { await app.queuePrompt(0); } catch (e2) { console.error("[tgate] queue failed", e2); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(node, s) {
|
||||||
|
node._tgState = s;
|
||||||
|
const tg = node._tg;
|
||||||
|
if (!tg) return;
|
||||||
|
tg.pass.style.display = s === "passed" ? "none" : "";
|
||||||
|
tg.runHere.style.display = s === "passed" ? "" : "none";
|
||||||
|
if (s === "paused") tg.status.textContent = "edit, then Pass";
|
||||||
|
else if (s === "passed") tg.status.textContent = "passed — Run from here to re-run";
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- sizing (Image Pool pattern) --------------------------------------------
|
// ---- sizing (Image Pool pattern) --------------------------------------------
|
||||||
|
|
||||||
// Only a min-height FLOOR — no max — so the DOM widget fills the node and grows
|
// Only a min-height FLOOR — no max — so the DOM widget fills the node and grows
|
||||||
@@ -59,6 +89,8 @@ function injectStyles() {
|
|||||||
border:1px solid #555; color:#fff; }
|
border:1px solid #555; color:#fff; }
|
||||||
.tgate-pass { background:rgba(40,130,70,0.95); }
|
.tgate-pass { background:rgba(40,130,70,0.95); }
|
||||||
.tgate-pass:hover { background:rgba(55,160,90,0.98); }
|
.tgate-pass:hover { background:rgba(55,160,90,0.98); }
|
||||||
|
.tgate-run { background:rgba(40,90,140,0.95); }
|
||||||
|
.tgate-run:hover { background:rgba(60,120,180,0.98); }
|
||||||
.tgate-status { font-size:11px; opacity:0.6; margin-left:auto; }
|
.tgate-status { font-size:11px; opacity:0.6; margin-left:auto; }
|
||||||
`;
|
`;
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
@@ -81,21 +113,37 @@ function setupTextGateNode(node) {
|
|||||||
|
|
||||||
const btns = document.createElement("div");
|
const btns = document.createElement("div");
|
||||||
btns.className = "tgate-btns";
|
btns.className = "tgate-btns";
|
||||||
|
|
||||||
const pass = document.createElement("button");
|
const pass = document.createElement("button");
|
||||||
pass.className = "tgate-pass";
|
pass.className = "tgate-pass";
|
||||||
pass.textContent = "▶ Pass";
|
pass.textContent = "▶ Pass";
|
||||||
const status = document.createElement("span");
|
|
||||||
status.className = "tgate-status";
|
|
||||||
pass.onclick = async () => {
|
pass.onclick = async () => {
|
||||||
await postPass(node, area.value);
|
await postPass(node, area.value);
|
||||||
status.textContent = "passed";
|
setState(node, "passed");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-queue the prompt; cached upstream re-pauses the gate so you can run your
|
||||||
|
// edited text downstream again without recomputing the graph above it.
|
||||||
|
const runHere = document.createElement("button");
|
||||||
|
runHere.className = "tgate-run";
|
||||||
|
runHere.textContent = "▶ Run from here";
|
||||||
|
runHere.style.display = "none";
|
||||||
|
runHere.onclick = async () => {
|
||||||
|
node._tg.status.textContent = "re-running…";
|
||||||
|
await queueFromHere(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "tgate-status";
|
||||||
|
|
||||||
btns.appendChild(pass);
|
btns.appendChild(pass);
|
||||||
|
btns.appendChild(runHere);
|
||||||
btns.appendChild(status);
|
btns.appendChild(status);
|
||||||
|
|
||||||
wrap.appendChild(area);
|
wrap.appendChild(area);
|
||||||
wrap.appendChild(btns);
|
wrap.appendChild(btns);
|
||||||
node._tg = { wrap, area, status };
|
node._tg = { wrap, area, status, pass, runHere };
|
||||||
|
node._tgState = "idle";
|
||||||
|
|
||||||
// FILLS the node: floor-only min height, no max (Image Pool pattern).
|
// FILLS the node: floor-only min height, no max (Image Pool pattern).
|
||||||
node._tgWidget = node.addDOMWidget("textgate_editor", "div", wrap, {
|
node._tgWidget = node.addDOMWidget("textgate_editor", "div", wrap, {
|
||||||
@@ -125,10 +173,15 @@ app.registerExtension({
|
|||||||
const d = e.detail || {};
|
const d = e.detail || {};
|
||||||
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
|
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
|
||||||
if (!node || node.type !== NODE || !node._tg) return;
|
if (!node || node.type !== NODE || !node._tg) return;
|
||||||
node._tg.area.value = d.text || "";
|
const incoming = d.text || "";
|
||||||
node._tg.status.textContent = "edit, then Pass";
|
// Sticky edit: keep the current editor text when the upstream input is
|
||||||
|
// unchanged (the Run-from-here case, upstream cached), so the gate re-runs
|
||||||
|
// YOUR version. Only overwrite on a genuine upstream change.
|
||||||
|
const unchanged = node._tgInput !== undefined && incoming === node._tgInput;
|
||||||
|
if (!unchanged) node._tg.area.value = incoming;
|
||||||
|
node._tgInput = incoming;
|
||||||
|
setState(node, "paused");
|
||||||
try { node._tg.area.focus(); } catch (err) { /* ignore */ }
|
try { node._tg.area.focus(); } catch (err) { /* ignore */ }
|
||||||
node.setDirtyCanvas?.(true, true);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user