Compare commits

...

29 Commits

Author SHA1 Message Date
Ethanfel 690278b592 Merge diag/textgate-build-marker: console build tag 2026-07-03 11:07:58 +02:00
Ethanfel 3ee14819b7 diag: text gate build marker in console (confirm loaded JS)
Loading a workflow does not re-fetch extension JS, and aiohttp serves
web/*.js with only Last-Modified (no no-store), so an open tab can keep
running a cached old text_gate.js. Log a build tag on setup so we can
tell from the devtools console whether the persistence/weighting build
is actually loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:07:58 +02:00
Ethanfel d6d2c98a58 Merge fix/textgate-prompt-weighting: prompt weighting in text gate editor 2026-07-03 10:17:58 +02:00
Ethanfel 36dd5c91ee fix: text gate supports prompt weighting (Ctrl/Cmd+↑/↓) in the editor
ComfyUI's "edit attention" (wrap selection in (token:weight)) is a global
window keydown listener that acts when a <textarea> is focused. The text
gate editor is a textarea, but its keydown handler called stopPropagation
on EVERY key, so the event never bubbled to window and weighting never
fired — notably when using the node as a prompt text node in protected mode.

Now stopPropagation is skipped for the weighting shortcut (Ctrl/Cmd + ↑/↓)
so it reaches the global handler; all other keys are still stopped so
typing/space can't trigger litegraph canvas shortcuts. The weighting edit
goes through execCommand, which fires our oninput -> stored_text stays synced.

Verified against the verbatim editAttention from the shipped frontend:
whole-word weighting, existing-weight decrement, and no-selection word
expansion all round-trip; plain keys stay stopped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:17:58 +02:00
Ethanfel 954b9ec2e6 Merge fix/textgate-persist-editor: text gate editor survives reload 2026-07-03 00:22:33 +02:00
Ethanfel 1881aa727f fix: text gate persists editor text across refresh/reload
The editor content was only restored on reload in protected mode, and
stored_text was only synced on keystroke (oninput). So in the default
pause mode edited text came back empty after refresh/reload-workflow,
and upstream text passed without a keystroke was never captured.

Now applyPersistedMode restores the editor from stored_text in BOTH
modes, and syncStored also fires when upstream text arrives (socket)
and on Pass — so whatever text is shown/edited survives a reload.
Verified against the shipped litegraph serialize()/configure() widget
semantics: default-mode + pass-without-typing round-trips now restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:22:28 +02:00
Ethanfel 78b1b85a11 Merge fix/imagegate-interrupt: image gate honors ComfyUI Interrupt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:17:20 +02:00
Ethanfel b50718f7fb fix: image gate reacts to ComfyUI Interrupt
GateBus.wait() only checked the gate's own Stop flag, so pressing ComfyUI's
Interrupt left the image gate blocked. Add should_cancel to wait() (mirroring
wait_payload) and pass mm.processing_interrupted from gate.py, matching the
text gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:17:20 +02:00
Ethanfel d9134b4e9b Merge fix/stored-text-hidden: hide text gate stored_text widget
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:14:55 +02:00
Ethanfel 3fb63e44a3 fix: fully hide text gate stored_text widget (widget.hidden)
computeSize alone left the collapsed pill visible in the 1.47 frontend.
Set widget.hidden=true (what getVisibleWidgets filters on), matching the
pool node's hideWidget. Value still serializes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:14:55 +02:00
Ethanfel 00c8c6a790 Merge fix/category-typo: node CATEGORY Datasete -> Dataset Gates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:08:33 +02:00
Ethanfel 726cd7bf17 fix: correct node CATEGORY typo Datasete -> Dataset Gates
Menu category on all nodes now reads 'Dataset Gates', matching the repo name.
Internal identifiers (routes, socket events, extension ids) left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:08:33 +02:00
Ethanfel 5b92e9b338 Merge feat/sidecar-save: Save Image + chainable sidecar text/json nodes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:05:59 +02:00
Ethanfel b2f5850b46 feat: Sidecar + Save Image (Sidecars) nodes + registration
Sidecar chains {content,name,ext} specs over a SIDECAR-typed link; the save node
mirrors SaveImageKJ (folder_paths.get_save_image_path) and writes each sidecar as
base+name+ext next to the image. build_plan validates the whole chain before any
I/O so duplicates/bad extensions write nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:00:38 +02:00
Ethanfel 31a7112052 feat: sidecar planning logic (filename resolution, allowlist, dedup)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:00:38 +02:00
Ethanfel 66e664247c docs: save image + chainable sidecars design
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:58:29 +02:00
Ethanfel 5419366bde Merge feat/textgate-protected: Text Gate protected mode (standalone text node)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:49:15 +02:00
Ethanfel d0dafa1d39 feat: text gate protected mode — frontend toggle + hidden stored_text
Adds a '🔒 Protected (text node)' toggle. When on, the DOM editor is a free text
box whose value mirrors into the hidden stored_text widget; the node outputs that
text and ignores upstream (no pause, socket events ignored). Persists via the
protected + stored_text widgets; restored on configure. stored_text is single-line
so it hides cleanly (pool_id trick).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:48:33 +02:00
Ethanfel b4639a73d3 feat: text gate protected mode — standalone text node (backend)
protected=True makes run() emit stored_text and ignore upstream with no pause;
IS_CHANGED caches on stored_text when protected (NaN otherwise). text input is
now optional so the node can run standalone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:44:50 +02:00
Ethanfel 84fc4f1cf1 docs: Text Gate protected-mode (standalone text node) design
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:43:52 +02:00
Ethanfel 58a48d67e5 Merge feat/textgate-runbutton-fix: Run-from-here fires Comfy.QueuePrompt (text + image gate)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:15:28 +02:00
Ethanfel c869ecee2a fix: image gate Run-from-here fires Comfy.QueuePrompt command too
Same latent bug as the text gate: a bare app.queuePrompt(0,1) enqueues but
doesn't kick off execution in the 1.47 frontend. Execute the Comfy.QueuePrompt
command (the Run button's path), with app.queuePrompt as a legacy fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:15:19 +02:00
Ethanfel 8d785f5ca2 fix: text gate Run-from-here fires Comfy.QueuePrompt command so it actually runs
A bare app.queuePrompt(0,1) enqueues but skips the command-level run setup in
the 1.47 frontend, so the prompt never kicked off — you had to press Run
manually. Execute the Comfy.QueuePrompt command (same path as the Run button /
Ctrl+Enter) instead, with app.queuePrompt as a legacy fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:06:32 +02:00
Ethanfel 0ace20a1bc Merge feat/textgate-sticky-fix: intent-based sticky edit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:59:49 +02:00
Ethanfel b90d1befe6 fix: text gate sticky edit by intent, not upstream-text comparison
Run-from-here now preserves the edited text via an explicit _tgKeepEdit flag
set when the button is pressed, instead of comparing incoming vs last text.
A non-deterministic upstream (random/seeded prompt) regenerates text on every
re-queue, which made the old comparison clobber the edit. Normal toolbar Queue
still shows fresh upstream text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:58:32 +02:00
Ethanfel fe95a9af3a Merge feat/textgate-run-from-here: Text Gate run-from-here + sticky edit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:41:44 +02:00
Ethanfel 99a5ccac82 feat: text gate Run-from-here button + sticky edited text
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:39:51 +02:00
Ethanfel f2ac5e37f3 docs: Text Gate run-from-here + sticky edit design
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:38:17 +02:00
Ethanfel ce371ffe13 Add Multi-Reroute (Rail) design + implementation plan
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>
2026-06-21 23:27:05 +02:00
21 changed files with 1047 additions and 29 deletions
+6 -2
View File
@@ -20,14 +20,18 @@ if __package__:
NODE_DISPLAY_NAME_MAPPINGS as _PROF_NAMES
from .gates.bucket_node import NODE_CLASS_MAPPINGS as _BUCKET_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _BUCKET_NAMES
from .gates.sidecar_node import NODE_CLASS_MAPPINGS as _SC_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _SC_NAMES
from .gates import routes # noqa: F401 (registers aiohttp routes on import)
from .gates import gate_server # noqa: F401 (registers /datasete_gate/* + text routes)
from .gates import profiles_routes # noqa: F401 (registers /grid_pool/profiles/*)
NODE_CLASS_MAPPINGS = {**_POOL_NODES, **_LOADER_NODES, **_GATE_NODES,
**_TEXT_NODES, **_PROF_NODES, **_BUCKET_NODES}
**_TEXT_NODES, **_PROF_NODES, **_BUCKET_NODES,
**_SC_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES,
**_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES}
**_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES,
**_SC_NAMES}
else: # pragma: no cover - exercised only under pytest collection
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -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 13 wired, Top + adds an empty lane at top and 13 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,64 @@
# 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 → executes the `Comfy.QueuePrompt` command via
`app.extensionManager.command.execute(...)` — the same path the Run button and
Ctrl+Enter use, so the prompt actually starts. A bare `app.queuePrompt(0, 1)`
enqueues but skips the command's run setup, so the 1.47 frontend doesn't kick off
execution (you'd have to press Run yourself). `app.queuePrompt` remains a fallback
for older frontends without the command registry.
## Sticky edited text (by intent, not text comparison)
The Image Gate keeps its mask sticky; the Text Gate keeps its text. Stickiness is
keyed off **which action triggered the run**, not a text comparison — because the
upstream feeding `text` is often non-deterministic (random/seeded prompts), so a
text comparison would wrongly clobber the edit on every Run-from-here.
- The "Run from here" button sets `node._tgKeepEdit = true` before re-queuing.
- On the next re-pause (`datasete-textgate-show`):
- if `node._tgKeepEdit`**keep** the current textarea value and clear the
flag, so the gate re-emits *your* edited text downstream.
- else (a normal toolbar Queue) → overwrite the textarea with the incoming
upstream text.
Net: Run-from-here always preserves your edit; a deliberate full Queue shows the
fresh upstream text. `_tgKeepEdit` is per-session (not serialized).
**Out of scope:** re-queuing still recomputes non-cacheable upstream nodes — that
is inherent to ComfyUI and identical for the Image Gate. With intent-based
stickiness the regenerated text is simply ignored, so it can't change the result;
to skip the compute, Bypass (Ctrl+B) the upstream node manually.
## 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.
@@ -0,0 +1,60 @@
# Save Image + chainable sidecars (design)
**Goal:** A save-image node (like KJ's `SaveImageKJ`) that, instead of a single
caption, writes any number of **sidecar** text/JSON files alongside the image,
each sharing the image's base name so associations never break.
Decisions (from brainstorming):
- **One unified `Sidecar` node** (content + name + extension), not per-type nodes.
- **JSON is just a string** — content is a STRING written verbatim; the extension
decides `.txt` vs `.json`.
## Nodes (backend-only — standard widgets/slots, no web JS)
### `Sidecar` — one link in the chain
- Inputs: `content` (STRING, forceInput) · `name` (STRING, default `""`) ·
`extension` (STRING, default `.txt`) · `sidecar` (optional `SIDECAR` chain-in).
- Output: `sidecar` (`SIDECAR`) — a list; appends `{content, name, ext}` to the
incoming chain and passes it on. Pure, no comfy imports.
- `SIDECAR` is a custom type so only sidecar/save ports interconnect.
### `Save Image (Sidecars)` — end of the chain
- Inputs: `images` (IMAGE) · `filename_prefix` (default `ComfyUI`) ·
`output_folder` (default `output`; absolute or under the ComfyUI output dir) ·
`sidecar` (optional `SIDECAR`). `OUTPUT_NODE`, returns the image preview.
- `folder_paths.get_save_image_path()``base = f"{filename}_{counter:05}_"`
(mirrors `SaveImageKJ`). Saves `base.png`, then each sidecar as `base + name + ext`.
## Filename rule
`base` already ends in `_`, so it is the separator:
| name | ext | file |
|---|---|---|
| `""` | `.txt` | `ComfyUI_00001_.txt` (caption, shares the image base) |
| `""` | `.json` | `ComfyUI_00001_.json` |
| `variant_a` | `.txt` | `ComfyUI_00001_variant_a.txt` |
Image: `ComfyUI_00001_.png`. Batch > 1 writes each sidecar per image.
## Validation (all before any file is written → no partial output)
- **Duplicate → error:** two sidecars resolving to the same `name+ext`
(two empty-name `.txt`, or two `variant_a.json`) raise a clear `ValueError`.
- **Extension allowlist:** `.txt .caption .json .yaml .yml .md .csv .tsv .xml .log
.ini .toml`. `name`/`extension` sanitized to a basename; per-file `commonpath`
path-traversal guard. (All copied from `SaveImageKJ`.)
## Code layout / testing
- `gates/sidecar.py` — pure logic: `ALLOWED_EXTENSIONS`, `normalize_ext`,
`sanitize_name`, `append_spec`, `build_plan`. Unit-tested (chain build, filename
resolution, duplicate raises, bad-ext raises) — no torch/comfy.
- `gates/sidecar_node.py` — the two node classes; torch/PIL/`folder_paths`
imported lazily inside `save()`. `build_plan` runs before any I/O.
- `__init__.py` — additive registration.
## Rejected / deferred
- Per-type text/json nodes (unified node covers both).
- Structured JSON/dict input (string-JSON is enough).
@@ -0,0 +1,60 @@
# Text Gate — "Protected" mode (standalone text node)
**Goal:** A `protected` switch that turns the Text Gate into a standalone text
node: no pause, it outputs the text you typed every run, ignoring upstream. Toggle
off → back to the normal pause/edit/Pass gate using the upstream text.
Decisions (from brainstorming):
- Protected = **plain-text-node behavior** (no pause), not a "still-pause-but-lock".
- The upstream wire is **kept but its value ignored** while protected (toggle off
resumes upstream seamlessly — no reconnecting).
## Backend (`gates/textgate.py`)
The authored text and the flag must reach `run()`, so:
- `text` input: `required`**`optional`** (`forceInput` kept), so the node runs
standalone. Existing connections still work.
- New serializing widgets:
- `protected` (BOOLEAN, default `False`) — the switch.
- `stored_text` (STRING) — the authored text, hidden in the UI behind the DOM
editor; the textarea syncs into it.
- `run(self, unique_id=None, text=None, signal=None, protected=False, stored_text="")`:
- `protected``return (stored_text, signal)` immediately — no `GateBus`, no
pause, upstream ignored. (Returns early *before* importing comfy, so it stays
import-safe/unit-testable.)
- else → current pause flow, guarding an unconnected input with `text or ""`.
- `IS_CHANGED`: `protected` → return `stored_text` (cache-friendly like a real text
node; downstream only re-runs when the text changes). Else → `float("nan")` (so
the existing NaN test still passes).
## Frontend (`web/text_gate.js`)
- Hide the auto-created `stored_text` widget (`computeSize → [0,-4]`, the pool
node's trick); the DOM textarea stays the single editor and writes its value into
`stored_text` on every edit (persists + reaches the backend).
- Read the `protected` boolean toggle (label "🔒 Protected (text node)"). On **ON**:
snapshot the current textarea into `stored_text`, hide Pass / Run-from-here, show
status "🔒 protected — outputs this text, upstream ignored", keep the textarea
editable. On **OFF**: revert to the normal pause UI.
- Ignore the `datasete-textgate-show` socket while protected. On load, populate the
textarea from `stored_text`.
## Persistence & compat
`protected` + `stored_text` are real widgets → save/reload restores mode + text.
Old saved TextGates get `protected=false`, `stored_text=""` defaults (the DOM editor
is `serialize:false`, so old nodes carry no conflicting widgets_values).
## Testing
- Unit: `run(protected=True, stored_text="hi")``("hi", signal)` without touching
`GateBus`; `IS_CHANGED(protected=True, stored_text="hi")``"hi"`;
`IS_CHANGED(protected=False)``NaN`; `text` is in `INPUT_TYPES()["optional"]`.
- Frontend: `node --check`; manual — toggle protect, edit freely, Run doesn't
overwrite, save/reload keeps text, toggle off resumes upstream.
## Rejected
A frontend-only "lock" that still pauses — doesn't give true text-node behavior
(you'd still click Pass each run), which is the point of the switch.
+1 -1
View File
@@ -39,7 +39,7 @@ def fit_mask(mask, W, H):
class BucketResize:
CATEGORY = "Datasete Gates"
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "mask", "width", "height", "label")
+4 -3
View File
@@ -25,7 +25,7 @@ def mask_from_stash(data, image):
class ImageGate:
CATEGORY = "Datasete Gates"
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("MASK",) + ("IMAGE",) * MAX_ROUTES
RETURN_NAMES = ("mask",) + tuple(f"route_{i + 1}" for i in range(MAX_ROUTES))
@@ -47,13 +47,14 @@ class ImageGate:
def run(self, image, routes, unique_id):
from comfy_execution.graph_utils import ExecutionBlocker
from . import gate_server
import comfy.model_management as mm
gate_bus.GateBus.arm(unique_id)
gate_server.send_preview(unique_id, image, routes)
try:
chosen_1 = gate_bus.GateBus.wait(unique_id)
chosen_1 = gate_bus.GateBus.wait(
unique_id, should_cancel=mm.processing_interrupted)
except gate_bus.GateCancelled:
import comfy.model_management as mm
raise mm.InterruptProcessingException()
mask = mask_from_stash(gate_bus.GateBus.pop_mask(unique_id), image)
+2 -2
View File
@@ -27,10 +27,10 @@ class GateBus:
cls.messages[str(node_id)] = int(message)
@classmethod
def wait(cls, node_id, period=0.1):
def wait(cls, node_id, period=0.1, should_cancel=None):
sid = str(node_id)
while sid not in cls.messages:
if cls.cancelled:
if cls.cancelled or (should_cancel is not None and should_cancel()):
cls.cancelled = False
raise GateCancelled()
time.sleep(period)
+1 -1
View File
@@ -27,7 +27,7 @@ def load_image_and_mask(path):
class FolderImageLoader:
CATEGORY = "Datasete Gates"
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "STRING", "MASK", "STRING", "INT")
RETURN_NAMES = ("image", "text", "mask", "filename", "index")
+1 -1
View File
@@ -8,7 +8,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
class GridImagePool:
CATEGORY = "Datasete Gates"
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "mask", "index", "count", "label")
+1 -1
View File
@@ -4,7 +4,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
class PoolProfile:
CATEGORY = "Datasete Gates"
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("POOL_PROFILE",)
RETURN_NAMES = ("profile",)
+54
View File
@@ -0,0 +1,54 @@
"""Pure sidecar-file planning: filename resolution, extension allowlist, and
duplicate detection. No comfy/torch imports so it stays unit-testable."""
import os
# Plain-text / data formats only — never image or executable extensions.
ALLOWED_EXTENSIONS = {
".txt", ".caption", ".json", ".yaml", ".yml", ".md",
".csv", ".tsv", ".xml", ".log", ".ini", ".toml",
}
def normalize_ext(ext):
"""Sanitize an extension to a leading-dot basename and allowlist it."""
ext = os.path.basename((ext or "").strip())
if ext and not ext.startswith("."):
ext = "." + ext
if ext.lower() not in ALLOWED_EXTENSIONS:
raise ValueError(
f"Disallowed sidecar extension {ext!r}. "
f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
return ext
def sanitize_name(name):
"""Reduce a name field to a bare filename token (no path traversal)."""
return os.path.basename((name or "").strip())
def append_spec(chain, content, name, ext):
"""Return a new chain list with this sidecar spec appended (no mutation)."""
out = list(chain) if chain else []
out.append({"content": content, "name": name, "ext": ext})
return out
def build_plan(specs):
"""Resolve specs to a list of (suffix, content), where the file written is
`<image_base> + suffix` and suffix is `name + ext`. Validates extensions and
rejects duplicate filenames, raising ValueError *before* any I/O so a bad
chain writes nothing."""
seen = set()
plan = []
for s in specs or []:
ext = normalize_ext(s.get("ext"))
name = sanitize_name(s.get("name"))
suffix = f"{name}{ext}"
if suffix in seen:
raise ValueError(
f"Duplicate sidecar file '<base>{suffix}': two sidecars resolve "
f"to the same name (name={name!r}, ext={ext}). "
f"Give one a distinct name.")
seen.add(suffix)
plan.append((suffix, s.get("content", "")))
return plan
+126
View File
@@ -0,0 +1,126 @@
"""Save Image + chainable sidecar text/JSON files.
`Sidecar` nodes chain a list of {content, name, ext} specs (SIDECAR type);
`SaveImageSidecars` saves the image and writes each sidecar next to it sharing
the image's base name. Heavy deps (torch/PIL/folder_paths) are imported lazily
inside save() so this module imports without comfy for unit tests."""
from . import sidecar as sc
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
class Sidecar:
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("SIDECAR",)
RETURN_NAMES = ("sidecar",)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"content": ("STRING", {"forceInput": True}),
"name": ("STRING", {"default": ""}),
"extension": ("STRING", {"default": ".txt"}),
},
"optional": {
"sidecar": ("SIDECAR",), # chain-in from a previous Sidecar
},
}
def run(self, content, name, extension, sidecar=None):
return (sc.append_spec(sidecar, content, name, extension),)
class SaveImageSidecars:
CATEGORY = "Dataset Gates"
FUNCTION = "save"
RETURN_TYPES = ()
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
"output_folder": ("STRING", {"default": "output"}),
},
"optional": {
"sidecar": ("SIDECAR",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
@staticmethod
def _safe_path(folder, filename):
import os
path = os.path.join(folder, filename)
root = os.path.abspath(folder)
if os.path.commonpath((root, os.path.abspath(path))) != root:
raise ValueError(f"Refusing to write outside the target folder: {path}")
return path
def save(self, images, filename_prefix, output_folder, sidecar=None,
prompt=None, extra_pnginfo=None):
import json
import os
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import folder_paths
from comfy.cli_args import args
# Validate the entire sidecar plan BEFORE writing anything, so a bad
# chain (duplicate name, disallowed extension) writes no files at all.
plan = sc.build_plan(sidecar)
h, w = int(images[0].shape[0]), int(images[0].shape[1])
if os.path.isabs(output_folder):
os.makedirs(output_folder, exist_ok=True)
output_dir = output_folder
else:
output_dir = folder_paths.get_output_directory()
full_output_folder, filename, counter, subfolder, filename_prefix = \
folder_paths.get_save_image_path(filename_prefix, output_dir, w, h)
results = []
for batch_number, image in enumerate(images):
arr = (255.0 * image.cpu().numpy()).clip(0, 255).astype(np.uint8)
img = Image.fromarray(arr)
metadata = None
if not args.disable_metadata:
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for k in extra_pnginfo:
metadata.add_text(k, json.dumps(extra_pnginfo[k]))
base = f"{filename.replace('%batch_num%', str(batch_number))}_{counter:05}_"
img.save(self._safe_path(full_output_folder, base + ".png"),
pnginfo=metadata, compress_level=4)
for suffix, content in plan:
with open(self._safe_path(full_output_folder, base + suffix),
"w", encoding="utf-8") as f:
f.write(content if content is not None else "")
results.append({"filename": base + ".png",
"subfolder": subfolder, "type": "output"})
counter += 1
return {"ui": {"images": results}}
NODE_CLASS_MAPPINGS = {
"Sidecar": Sidecar,
"SaveImageSidecars": SaveImageSidecars,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Sidecar": "Sidecar (text/json)",
"SaveImageSidecars": "Save Image (Sidecars)",
}
+21 -8
View File
@@ -15,33 +15,46 @@ ANY = AnyType("*")
class TextGate:
CATEGORY = "Datasete Gates"
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("STRING", ANY)
RETURN_NAMES = ("text", "signal")
@classmethod
def INPUT_TYPES(cls):
# `text` is optional so the node can run standalone in protected mode.
# `protected` + `stored_text` are serializing widgets carrying the
# authored text-node state (stored_text is hidden by the frontend).
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
"optional": {
"text": ("STRING", {"forceInput": True}),
"signal": (ANY, {}),
"protected": ("BOOLEAN", {"default": False}),
# single-line so the frontend can fully hide it (the DOM editor
# is the real text box); the value still holds arbitrary text.
"stored_text": ("STRING", {"default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
def IS_CHANGED(cls, protected=False, stored_text="", **kwargs):
# Protected = plain text node: cache on the authored text so downstream
# only re-runs when it changes. Otherwise never cache (always pause).
return stored_text if protected else float("nan")
def run(self, unique_id=None, text=None, signal=None,
protected=False, stored_text=""):
if protected:
# Standalone text node: emit the authored text, ignore upstream, no
# pause. Returns before importing comfy, so it stays import-safe.
return (stored_text, signal)
def run(self, text, unique_id, signal=None):
from . import gate_server
import comfy.model_management as mm
gate_bus.GateBus.arm(unique_id)
gate_server.send_text(unique_id, text)
gate_server.send_text(unique_id, text or "")
try:
edited = gate_bus.GateBus.wait_payload(
unique_id, should_cancel=mm.processing_interrupted)
+7
View File
@@ -65,3 +65,10 @@ def test_wait_payload_should_cancel_raises():
gb.GateBus.arm("p")
with pytest.raises(gb.GateCancelled):
gb.GateBus.wait_payload("p", should_cancel=lambda: True)
def test_wait_should_cancel_raises():
# image gate: ComfyUI Interrupt (should_cancel) must abort the wait too
gb.GateBus.arm("7")
with pytest.raises(gb.GateCancelled):
gb.GateBus.wait("7", should_cancel=lambda: True)
assert gb.GateBus.cancelled is False
+67
View File
@@ -0,0 +1,67 @@
import pytest
from gates import sidecar
def test_append_spec_builds_chain_without_mutating():
c1 = sidecar.append_spec(None, "hello", "", ".txt")
c2 = sidecar.append_spec(c1, "{}", "meta", ".json")
assert c1 == [{"content": "hello", "name": "", "ext": ".txt"}]
assert len(c2) == 2
assert c2[1] == {"content": "{}", "name": "meta", "ext": ".json"}
assert len(c1) == 1 # original chain untouched
def test_normalize_ext_adds_dot_and_allowlists():
assert sidecar.normalize_ext("txt") == ".txt"
assert sidecar.normalize_ext(".json") == ".json"
with pytest.raises(ValueError):
sidecar.normalize_ext(".png")
with pytest.raises(ValueError):
sidecar.normalize_ext(".exe")
def test_sanitize_name_strips_path_and_space():
assert sidecar.sanitize_name(" variant_a ") == "variant_a"
assert sidecar.sanitize_name("../evil") == "evil"
assert sidecar.sanitize_name("a/b") == "b"
assert sidecar.sanitize_name("") == ""
def test_build_plan_resolves_suffixes():
specs = [
{"content": "cap", "name": "", "ext": ".txt"},
{"content": "{}", "name": "", "ext": ".json"},
{"content": "v", "name": "variant_a", "ext": ".txt"},
]
assert sidecar.build_plan(specs) == [
(".txt", "cap"),
(".json", "{}"),
("variant_a.txt", "v"),
]
def test_build_plan_duplicate_empty_names_raises():
specs = [
{"content": "a", "name": "", "ext": ".txt"},
{"content": "b", "name": "", "ext": ".txt"},
]
with pytest.raises(ValueError):
sidecar.build_plan(specs)
def test_build_plan_empty_txt_and_json_do_not_collide():
specs = [
{"content": "a", "name": "", "ext": ".txt"},
{"content": "b", "name": "", "ext": ".json"},
]
assert len(sidecar.build_plan(specs)) == 2
def test_build_plan_bad_extension_raises():
with pytest.raises(ValueError):
sidecar.build_plan([{"content": "x", "name": "", "ext": ".png"}])
def test_build_plan_none_is_empty():
assert sidecar.build_plan(None) == []
+34
View File
@@ -0,0 +1,34 @@
from gates import sidecar_node as sn
def test_sidecar_run_builds_chain():
node = sn.Sidecar()
(chain,) = node.run(content="hello", name="", extension=".txt", sidecar=None)
assert chain == [{"content": "hello", "name": "", "ext": ".txt"}]
(chain2,) = node.run(content="{}", name="meta", extension=".json", sidecar=chain)
assert len(chain2) == 2
assert chain2[1] == {"content": "{}", "name": "meta", "ext": ".json"}
def test_sidecar_io_shape():
assert sn.Sidecar.RETURN_TYPES == ("SIDECAR",)
it = sn.Sidecar.INPUT_TYPES()
assert "content" in it["required"]
assert "name" in it["required"]
assert "extension" in it["required"]
assert "sidecar" in it["optional"]
def test_save_node_io_shape():
assert sn.SaveImageSidecars.OUTPUT_NODE is True
assert sn.SaveImageSidecars.RETURN_TYPES == ()
it = sn.SaveImageSidecars.INPUT_TYPES()
for k in ("images", "filename_prefix", "output_folder"):
assert k in it["required"]
assert "sidecar" in it["optional"]
def test_mappings_present():
assert "Sidecar" in sn.NODE_CLASS_MAPPINGS
assert "SaveImageSidecars" in sn.NODE_CLASS_MAPPINGS
assert sn.NODE_DISPLAY_NAME_MAPPINGS["SaveImageSidecars"]
+28
View File
@@ -16,3 +16,31 @@ def test_textgate_io_shape():
def test_textgate_is_changed_nan():
v = textgate.TextGate.IS_CHANGED(text="hi", unique_id="1")
assert math.isnan(v)
def test_textgate_text_input_is_optional():
it = textgate.TextGate.INPUT_TYPES()
assert "text" in it["optional"]
assert "protected" in it["optional"]
assert "stored_text" in it["optional"]
def test_textgate_protected_returns_stored_text_without_pause():
# protected mode must return the stored text directly — no GateBus, no comfy
out = textgate.TextGate().run(
unique_id="1", text="from upstream", signal="sig",
protected=True, stored_text="my authored text",
)
assert out == ("my authored text", "sig")
def test_textgate_is_changed_protected_returns_stored_text():
v = textgate.TextGate.IS_CHANGED(
unique_id="1", protected=True, stored_text="frozen")
assert v == "frozen"
def test_textgate_is_changed_not_protected_is_nan():
v = textgate.TextGate.IS_CHANGED(
unique_id="1", protected=False, stored_text="ignored")
assert math.isnan(v)
+10
View File
@@ -215,6 +215,16 @@ function showResolved(node, choiceLabel) {
}
async function queueFromHere(node) {
// Fire the same command the Run button / Ctrl+Enter use, so the prompt
// actually EXECUTES. A bare app.queuePrompt(...) enqueues but skips the
// command's run setup, so the 1.47 frontend doesn't kick off the run (you'd
// have to press Run yourself). Fall back to app.queuePrompt on older
// frontends without the command registry.
const cmd = app.extensionManager?.command;
if (cmd?.execute) {
try { await cmd.execute("Comfy.QueuePrompt"); return; }
catch (e) { /* fall through to the legacy path */ }
}
try {
await app.queuePrompt(0, 1);
} catch (e) {
+175 -10
View File
@@ -7,6 +7,15 @@ import { api } from "../../scripts/api.js";
// the server pushes via the "datasete-textgate-show" socket event and POSTs the
// 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 it re-pauses
// each run. The edited text is sticky by INTENT: a Run-from-here re-queue keeps
// YOUR edited text (even if a non-deterministic upstream regenerates it), while
// a normal toolbar Queue shows whatever the upstream produced. Keying off which
// button ran — not a text comparison — means a random/seeded upstream can't
// clobber the edit on re-run. (Re-queuing still recomputes non-cacheable
// upstream, as in any ComfyUI run; that regenerated text is simply ignored.)
//
// 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
// and the textarea grows with it.
@@ -19,6 +28,48 @@ const MIN_EDITOR_H = 140; // textarea floor
const BTN_ROW_H = 34; // Pass button row
const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other nodes
// ---- protected-mode widgets -------------------------------------------------
// `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend
// widgets. When protected, the node acts as a plain text node: it outputs
// stored_text and ignores upstream (no pause). The DOM textarea is the visible
// editor and mirrors its value into stored_text on EVERY change (typing, upstream
// arrival, Pass) — so the editor content survives refresh / workflow reload in
// BOTH modes (stored_text also reaches run() when protected).
function widgetByName(node, name) {
return node.widgets?.find((w) => w.name === name);
}
function isProtected(node) {
return !!widgetByName(node, "protected")?.value;
}
// mirror the editor text into the hidden stored_text widget (persist + backend)
function syncStored(node) {
const w = widgetByName(node, "stored_text");
if (w) w.value = node._tg?.area?.value ?? "";
}
// fully hide the auto-created stored_text widget (same as the pool node's
// pool_id): getVisibleWidgets() filters on `hidden`, so it's dropped from both
// draw and layout — computeSize alone (or type="hidden") does NOT hide it.
// Serialization still iterates all widgets, so stored_text is saved/sent.
function hideStoredWidget(node) {
const w = widgetByName(node, "stored_text");
if (!w) return;
w.hidden = true;
w.computeSize = () => [0, -4];
}
// reflect the persisted stored_text + mode into the editor + UI. The editor text
// is restored in BOTH modes so it survives a refresh / workflow reload; the mode
// only selects the UI state (protected vs idle waiting-for-a-run).
function applyPersistedMode(node) {
if (!node._tg) return;
node._tg.area.value = widgetByName(node, "stored_text")?.value ?? "";
setState(node, isProtected(node) ? "protected" : "idle");
}
// ---- server call ------------------------------------------------------------
async function postPass(node, text) {
@@ -28,6 +79,47 @@ async function postPass(node, text) {
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) {
// Fire the same command the Run button / Ctrl+Enter use, so the prompt
// actually EXECUTES. A bare app.queuePrompt(...) enqueues but skips the
// command's run setup, so the 1.47 frontend doesn't kick off the run (you'd
// have to press Run yourself). Fall back to app.queuePrompt on older
// frontends without the command registry.
const cmd = app.extensionManager?.command;
if (cmd?.execute) {
try { await cmd.execute("Comfy.QueuePrompt"); return; }
catch (e) { /* fall through to the legacy path */ }
}
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;
// Pass is hidden once passed AND in protected mode (no pause there);
// Run-from-here only in the passed state.
tg.pass.style.display = (s === "passed" || s === "protected") ? "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";
else if (s === "protected") tg.status.textContent = "🔒 protected — outputs this text (upstream ignored)";
else tg.status.textContent = "";
tg.area.placeholder = s === "protected"
? "type text (used as a text node)…"
: "waiting for a run…";
node.setDirtyCanvas?.(true, true);
}
// ---- sizing (Image Pool pattern) --------------------------------------------
// Only a min-height FLOOR — no max — so the DOM widget fills the node and grows
@@ -59,6 +151,8 @@ function injectStyles() {
border:1px solid #555; color:#fff; }
.tgate-pass { background:rgba(40,130,70,0.95); }
.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; }
`;
const style = document.createElement("style");
@@ -76,26 +170,55 @@ function setupTextGateNode(node) {
const area = document.createElement("textarea");
area.className = "tgate-area";
area.placeholder = "waiting for a run…";
// don't let typing/space toggle node selection or graph shortcuts
area.onkeydown = (e) => e.stopPropagation();
// Stop keys from reaching litegraph (so typing/space can't toggle node
// selection or fire canvas shortcuts) — EXCEPT ComfyUI's prompt-weighting
// shortcut (Ctrl/Cmd+↑/↓). That handler is a global `window` keydown listener
// that wraps the selection in (token:weight); a blanket stopPropagation here
// kept it from ever bubbling up, so weighting didn't work in this editor.
// Its execCommand edit fires our oninput, so the weighted text still syncs.
area.onkeydown = (e) => {
const isWeight = (e.ctrlKey || e.metaKey) &&
(e.key === "ArrowUp" || e.key === "ArrowDown");
if (!isWeight) e.stopPropagation();
};
// keep the hidden stored_text widget mirrored so edits persist + reach run()
area.oninput = () => syncStored(node);
const btns = document.createElement("div");
btns.className = "tgate-btns";
const pass = document.createElement("button");
pass.className = "tgate-pass";
pass.textContent = "▶ Pass";
pass.onclick = async () => {
syncStored(node); // persist the passed text so a reload keeps it
await postPass(node, area.value);
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._tgKeepEdit = true; // tell the next re-pause to preserve this edit
node._tg.status.textContent = "re-running…";
await queueFromHere(node);
};
const status = document.createElement("span");
status.className = "tgate-status";
pass.onclick = async () => {
await postPass(node, area.value);
status.textContent = "passed";
};
btns.appendChild(pass);
btns.appendChild(runHere);
btns.appendChild(status);
wrap.appendChild(area);
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).
node._tgWidget = node.addDOMWidget("textgate_editor", "div", wrap, {
@@ -111,24 +234,57 @@ function setupTextGateNode(node) {
return r;
};
// protected-mode wiring: hide the stored_text widget, label + react to the
// toggle, and reflect the persisted mode/text into the editor.
hideStoredWidget(node);
const pw = widgetByName(node, "protected");
if (pw) {
pw.label = "🔒 Protected (text node)";
const prev = pw.callback;
pw.callback = function () {
const r = prev?.apply(this, arguments);
if (isProtected(node)) { syncStored(node); setState(node, "protected"); }
else setState(node, "idle");
return r;
};
}
applyPersistedMode(node);
// sensible default size; the node stays freely resizable (no width floor lock)
node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]);
syncWidgetWidth(node);
}
// Build marker — lets you confirm the browser loaded THIS build (not a cached
// old copy). If the editor comes back empty after reload but you don't see this
// line in the devtools console, your tab is running stale JS: hard-refresh
// (Ctrl/Cmd+Shift+R).
const BUILD = "2026-07-03 persist+weight";
app.registerExtension({
name: "datasete.gates.textgate",
// one global socket listener: route the server's pause event to the node
setup() {
console.info(`[datasete.textgate] loaded build ${BUILD}`);
api.addEventListener("datasete-textgate-show", (e) => {
const d = e.detail || {};
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
if (!node || node.type !== NODE || !node._tg) return;
node._tg.area.value = d.text || "";
node._tg.status.textContent = "edit, then Pass";
if (isProtected(node)) return; // protected = no pause; ignore stray events
// Sticky edit by intent: a Run-from-here re-queue (the _tgKeepEdit flag)
// keeps YOUR edited text so the gate re-emits it downstream; a normal
// Queue shows whatever the upstream produced. Keying off the button —
// not a text comparison — means a non-deterministic upstream can't
// clobber the edit on re-run.
if (node._tgKeepEdit) {
node._tgKeepEdit = false;
} else {
node._tg.area.value = d.text || "";
}
syncStored(node); // persist the shown text so a refresh/reload keeps it
setState(node, "paused");
try { node._tg.area.focus(); } catch (err) { /* ignore */ }
node.setDirtyCanvas?.(true, true);
});
},
@@ -141,5 +297,14 @@ app.registerExtension({
setupTextGateNode(this);
return r;
};
// loaded workflows restore protected + stored_text after create — re-apply
// the mode so the editor + UI match the saved state.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
if (this._tg) applyPersistedMode(this);
return r;
};
},
});