From 1008612fb2b236f41002705a6fd6dd1e2b6eba44 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 18:43:29 +0200 Subject: [PATCH 1/8] feat: gate_bus payload channel + should_cancel Co-Authored-By: Claude Opus 4.8 --- gates/gate_bus.py | 16 ++++++++++++++++ tests/test_gate_bus.py | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/gates/gate_bus.py b/gates/gate_bus.py index 4013ed1..fd735e2 100644 --- a/gates/gate_bus.py +++ b/gates/gate_bus.py @@ -9,12 +9,14 @@ class GateCancelled(Exception): class GateBus: messages = {} # node_id(str) -> chosen int (1-based) masks = {} # node_id(str) -> PNG bytes + payloads = {} # node_id(str) -> arbitrary payload (e.g., edited text) cancelled = False @classmethod def arm(cls, node_id): cls.messages.pop(str(node_id), None) cls.masks.pop(str(node_id), None) + cls.payloads.pop(str(node_id), None) cls.cancelled = False @classmethod @@ -41,3 +43,17 @@ class GateBus: @classmethod def pop_mask(cls, node_id): return cls.masks.pop(str(node_id), None) + + @classmethod + def put_payload(cls, node_id, value): + cls.payloads[str(node_id)] = value + + @classmethod + def wait_payload(cls, node_id, period=0.1, should_cancel=None): + sid = str(node_id) + while sid not in cls.payloads: + if cls.cancelled or (should_cancel is not None and should_cancel()): + cls.cancelled = False + raise GateCancelled() + time.sleep(period) + return cls.payloads.pop(sid) diff --git a/tests/test_gate_bus.py b/tests/test_gate_bus.py index 231b29b..0cd9962 100644 --- a/tests/test_gate_bus.py +++ b/tests/test_gate_bus.py @@ -36,3 +36,26 @@ def test_arm_clears_mask(): gb.GateBus.put_mask("9", b"x") gb.GateBus.arm("9") assert gb.GateBus.pop_mask("9") is None + +def test_payload_roundtrip(): + gb.GateBus.arm("p") + gb.GateBus.put_payload("p", "hello edited") + assert gb.GateBus.wait_payload("p") == "hello edited" + +def test_payload_consumed(): + gb.GateBus.arm("p") + gb.GateBus.put_payload("p", "x") + gb.GateBus.wait_payload("p") + assert "p" not in gb.GateBus.payloads + +def test_arm_clears_payload(): + gb.GateBus.put_payload("p", "stale") + gb.GateBus.arm("p") + assert "p" not in gb.GateBus.payloads + +def test_wait_payload_cancel_flag_raises(): + import pytest + gb.GateBus.arm("p") + gb.GateBus.cancelled = True + with pytest.raises(gb.GateCancelled): + gb.GateBus.wait_payload("p") From 3250aaa8284d5be7a9371eacefb3b826a1928c69 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 18:43:43 +0200 Subject: [PATCH 2/8] test: gate_bus wait_payload honors should_cancel Co-Authored-By: Claude Opus 4.8 --- tests/test_gate_bus.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_gate_bus.py b/tests/test_gate_bus.py index 0cd9962..46376a6 100644 --- a/tests/test_gate_bus.py +++ b/tests/test_gate_bus.py @@ -59,3 +59,9 @@ def test_wait_payload_cancel_flag_raises(): gb.GateBus.cancelled = True with pytest.raises(gb.GateCancelled): gb.GateBus.wait_payload("p") + +def test_wait_payload_should_cancel_raises(): + import pytest + gb.GateBus.arm("p") + with pytest.raises(gb.GateCancelled): + gb.GateBus.wait_payload("p", should_cancel=lambda: True) From 96912d47a471f1c05f418950b6c89ad63eb97ee2 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 18:44:07 +0200 Subject: [PATCH 3/8] feat: textgate AnyType wildcard Co-Authored-By: Claude Opus 4.8 --- gates/textgate.py | 14 ++++++++++++++ tests/test_textgate.py | 7 +++++++ 2 files changed, 21 insertions(+) create mode 100644 gates/textgate.py create mode 100644 tests/test_textgate.py diff --git a/gates/textgate.py b/gates/textgate.py new file mode 100644 index 0000000..c65c72a --- /dev/null +++ b/gates/textgate.py @@ -0,0 +1,14 @@ +# gates/textgate.py +from . import gate_bus + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + + +class AnyType(str): + """Type that compares equal to any other type (ComfyUI wildcard convention).""" + def __ne__(self, other): + return False + + +ANY = AnyType("*") diff --git a/tests/test_textgate.py b/tests/test_textgate.py new file mode 100644 index 0000000..90553e0 --- /dev/null +++ b/tests/test_textgate.py @@ -0,0 +1,7 @@ +# tests/test_textgate.py +from gates import textgate + +def test_anytype_is_compatible_with_everything(): + assert (textgate.ANY != "IMAGE") is False + assert (textgate.ANY != "LATENT") is False + assert isinstance(textgate.ANY, str) From f617c46aefb54739c657058bfb52f9ec3ee64e66 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 18:47:55 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20TextGate=20node=20=E2=80=94=20pause?= =?UTF-8?q?,=20editable=20pass-through,=20signal=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- gates/textgate.py | 40 ++++++++++++++++++++++++++++++++++++++++ tests/test_textgate.py | 11 +++++++++++ 2 files changed, 51 insertions(+) diff --git a/gates/textgate.py b/gates/textgate.py index c65c72a..3abcc7a 100644 --- a/gates/textgate.py +++ b/gates/textgate.py @@ -12,3 +12,43 @@ class AnyType(str): ANY = AnyType("*") + + +class TextGate: + CATEGORY = "Datasete Gates" + FUNCTION = "run" + RETURN_TYPES = ("STRING", ANY) + RETURN_NAMES = ("text", "signal") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"forceInput": True}), + }, + "optional": { + "signal": (ANY, {}), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + 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) + try: + edited = gate_bus.GateBus.wait_payload( + unique_id, should_cancel=mm.processing_interrupted) + except gate_bus.GateCancelled: + raise mm.InterruptProcessingException() + return (edited, signal) + + +NODE_CLASS_MAPPINGS = {"TextGate": TextGate} +NODE_DISPLAY_NAME_MAPPINGS = {"TextGate": "Text Gate (Manual Pass)"} diff --git a/tests/test_textgate.py b/tests/test_textgate.py index 90553e0..8feb4bf 100644 --- a/tests/test_textgate.py +++ b/tests/test_textgate.py @@ -1,7 +1,18 @@ # tests/test_textgate.py +import math + from gates import textgate def test_anytype_is_compatible_with_everything(): assert (textgate.ANY != "IMAGE") is False assert (textgate.ANY != "LATENT") is False assert isinstance(textgate.ANY, str) + +def test_textgate_io_shape(): + assert textgate.TextGate.RETURN_NAMES == ("text", "signal") + assert textgate.TextGate.RETURN_TYPES[0] == "STRING" + assert textgate.TextGate.RETURN_TYPES[1] == textgate.ANY + +def test_textgate_is_changed_nan(): + v = textgate.TextGate.IS_CHANGED(text="hi", unique_id="1") + assert math.isnan(v) From b1ac27def9722a3f5f0cfafeca498c7259b60164 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 18:48:26 +0200 Subject: [PATCH 5/8] feat: text gate server route + register TextGate Co-Authored-By: Claude Opus 4.8 --- __init__.py | 8 +++++--- gates/gate_server.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index be141f7..17800b4 100644 --- a/__init__.py +++ b/__init__.py @@ -14,11 +14,13 @@ if __package__: NODE_DISPLAY_NAME_MAPPINGS as _LOADER_NAMES from .gates.gate import NODE_CLASS_MAPPINGS as _GATE_NODES, \ NODE_DISPLAY_NAME_MAPPINGS as _GATE_NAMES + from .gates.textgate import NODE_CLASS_MAPPINGS as _TEXT_NODES, \ + NODE_DISPLAY_NAME_MAPPINGS as _TEXT_NAMES from .gates import routes # noqa: F401 (registers aiohttp routes on import) - from .gates import gate_server # noqa: F401 (registers /datasete_gate/* routes) + from .gates import gate_server # noqa: F401 (registers /datasete_gate/* + text routes) - NODE_CLASS_MAPPINGS = {**_POOL_NODES, **_LOADER_NODES, **_GATE_NODES} - NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES} + NODE_CLASS_MAPPINGS = {**_POOL_NODES, **_LOADER_NODES, **_GATE_NODES, **_TEXT_NODES} + NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES, **_TEXT_NAMES} else: # pragma: no cover - exercised only under pytest collection NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/gates/gate_server.py b/gates/gate_server.py index 43b020a..6dcce61 100644 --- a/gates/gate_server.py +++ b/gates/gate_server.py @@ -42,3 +42,16 @@ async def _mask(request): if node_id is not None: GateBus.put_mask(node_id, data) return web.json_response({}) + + +def send_text(node_id, text): + PromptServer.instance.send_sync( + "datasete-textgate-show", {"id": str(node_id), "text": text or ""} + ) + + +@routes.post("/datasete_text_gate/pass") +async def _text_pass(request): + post = await request.post() + GateBus.put_payload(post.get("id"), post.get("text", "")) + return web.json_response({}) From ef064db97238e3a873ee46276eb4e96d3b3824db Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 18:49:00 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20text=20gate=20frontend=20=E2=80=94?= =?UTF-8?q?=20editable=20textarea=20+=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- web/text_gate.js | 136 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 web/text_gate.js diff --git a/web/text_gate.js b/web/text_gate.js new file mode 100644 index 0000000..b2880cb --- /dev/null +++ b/web/text_gate.js @@ -0,0 +1,136 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +// Text Gate (Manual Pass) — pauses a running prompt, shows the incoming text in +// an editable textarea, and on Pass emits the (edited) text. The Python node +// blocks in run() on GateBus.wait_payload(); this extension renders the editor +// the server pushes via the "datasete-textgate-show" socket event and POSTs the +// edited text back. Outputs are static (text, signal) — no dynamic slots. + +const NODE = "TextGate"; +const R = "/datasete_text_gate"; + +const EDITOR_H = 160; // textarea area height +const BTN_ROW_H = 36; // Pass button row +const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other gate nodes + +// ---- server call ------------------------------------------------------------ + +async function postPass(node, text) { + const fd = new FormData(); + fd.append("id", String(node.id)); + fd.append("text", text); + await api.fetchApi(`${R}/pass`, { method: "POST", body: fd }); +} + +// ---- preview DOM widget ----------------------------------------------------- + +function previewHeight(node) { + return node._tgActive ? 2 * MARGIN + EDITOR_H + BTN_ROW_H : 0; +} + +function resizePreview(node) { + // Fully remove the editor from layout when idle so it never paints below the + // node frame (collapsing height to 0 alone wouldn't clip the textarea). + if (node._tg) node._tg.wrap.style.display = node._tgActive ? "flex" : "none"; + const w = node.size?.[0] || 240; + node.setSize([w, node.computeSize()[1]]); + node.setDirtyCanvas(true, true); +} + +function showEditor(node, text) { + node._tgActive = true; + node._tg.area.value = text || ""; + resizePreview(node); + try { node._tg.area.focus(); } catch (e) { /* ignore */ } +} + +function hideEditor(node) { + node._tgActive = false; + if (node._tg) node._tg.area.value = ""; + resizePreview(node); +} + +// ---- styles + node setup ---------------------------------------------------- + +function injectStyles() { + if (document.getElementById("tgate-styles")) return; + const css = ` + .tgate-wrap { display:flex; flex-direction:column; gap:6px; box-sizing:border-box; + height:100%; min-height:0; } + .tgate-area { flex:1 1 auto; min-height:0; width:100%; box-sizing:border-box; resize:none; + font-size:12px; line-height:1.4; padding:6px; border-radius:4px; + border:1px solid #555; background:rgba(0,0,0,0.25); color:#fff; + font-family:ui-monospace, monospace; overflow:auto; } + .tgate-btns { display:flex; gap:6px; align-items:center; flex:0 0 auto; } + .tgate-btns button { font-size:12px; padding:3px 14px; cursor:pointer; border-radius:3px; + 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); } + `; + const style = document.createElement("style"); + style.id = "tgate-styles"; + style.textContent = css; + document.head.appendChild(style); +} + +function setupTextGateNode(node) { + injectStyles(); + + const wrap = document.createElement("div"); + wrap.className = "tgate-wrap"; + + 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(); + + const btns = document.createElement("div"); + btns.className = "tgate-btns"; + const pass = document.createElement("button"); + pass.className = "tgate-pass"; + pass.textContent = "▶ Pass"; + pass.onclick = async () => { + await postPass(node, area.value); + hideEditor(node); + }; + btns.appendChild(pass); + + wrap.appendChild(area); + wrap.appendChild(btns); + node._tg = { wrap, area, btns }; + + node._previewWidget = node.addDOMWidget("textgate_editor", "div", wrap, { + serialize: false, + getMinHeight: () => previewHeight(node), + }); + + node._tgActive = false; + resizePreview(node); +} + +app.registerExtension({ + name: "datasete.gates.textgate", + + // one global socket listener: route the server's pause event to the node + setup() { + api.addEventListener("datasete-textgate-show", (e) => { + const d = e.detail || {}; + const node = app.graph?.getNodeById?.(parseInt(d.id, 10)); + if (!node || node.type !== NODE) return; + showEditor(node, d.text); + }); + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + setupTextGateNode(this); + return r; + }; + }, +}); From b46de4b0316be630767dc0d34b707c6bd7a0d6df Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 19:11:56 +0200 Subject: [PATCH 7/8] fix: text gate editor fills node + freely resizable (Image Pool sizing) Co-Authored-By: Claude Opus 4.8 --- web/text_gate.js | 77 +++++++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/web/text_gate.js b/web/text_gate.js index b2880cb..e2c27c9 100644 --- a/web/text_gate.js +++ b/web/text_gate.js @@ -6,13 +6,18 @@ import { api } from "../../scripts/api.js"; // blocks in run() on GateBus.wait_payload(); this extension renders the editor // the server pushes via the "datasete-textgate-show" socket event and POSTs the // edited text back. Outputs are static (text, signal) — no dynamic slots. +// +// 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. const NODE = "TextGate"; const R = "/datasete_text_gate"; -const EDITOR_H = 160; // textarea area height -const BTN_ROW_H = 36; // Pass button row -const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other gate nodes +const MIN_W = 320; // default node width (freely resizable) +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 // ---- server call ------------------------------------------------------------ @@ -23,32 +28,19 @@ async function postPass(node, text) { await api.fetchApi(`${R}/pass`, { method: "POST", body: fd }); } -// ---- preview DOM widget ----------------------------------------------------- +// ---- sizing (Image Pool pattern) -------------------------------------------- -function previewHeight(node) { - return node._tgActive ? 2 * MARGIN + EDITOR_H + BTN_ROW_H : 0; +// Only a min-height FLOOR — no max — so the DOM widget fills the node and grows +// when the user resizes it. (A fixed height, or forcing node height on every +// interaction, would lock the node and leave dead grey space below the editor.) +function widgetFloor() { + return 2 * MARGIN + MIN_EDITOR_H + BTN_ROW_H; } -function resizePreview(node) { - // Fully remove the editor from layout when idle so it never paints below the - // node frame (collapsing height to 0 alone wouldn't clip the textarea). - if (node._tg) node._tg.wrap.style.display = node._tgActive ? "flex" : "none"; - const w = node.size?.[0] || 240; - node.setSize([w, node.computeSize()[1]]); - node.setDirtyCanvas(true, true); -} - -function showEditor(node, text) { - node._tgActive = true; - node._tg.area.value = text || ""; - resizePreview(node); - try { node._tg.area.focus(); } catch (e) { /* ignore */ } -} - -function hideEditor(node) { - node._tgActive = false; - if (node._tg) node._tg.area.value = ""; - resizePreview(node); +// DomWidgets sizes the editor container from the widget width, which can lag +// node.size[0] on this frontend — pin it so the textarea reflows to fill. +function syncWidgetWidth(node) { + if (node._tgWidget) node._tgWidget.width = node.size?.[0] || MIN_W; } // ---- styles + node setup ---------------------------------------------------- @@ -67,6 +59,7 @@ 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-status { font-size:11px; opacity:0.6; margin-left:auto; } `; const style = document.createElement("style"); style.id = "tgate-styles"; @@ -91,23 +84,36 @@ function setupTextGateNode(node) { const pass = document.createElement("button"); pass.className = "tgate-pass"; pass.textContent = "▶ Pass"; + const status = document.createElement("span"); + status.className = "tgate-status"; pass.onclick = async () => { await postPass(node, area.value); - hideEditor(node); + status.textContent = "passed"; }; btns.appendChild(pass); + btns.appendChild(status); wrap.appendChild(area); wrap.appendChild(btns); - node._tg = { wrap, area, btns }; + node._tg = { wrap, area, status }; - node._previewWidget = node.addDOMWidget("textgate_editor", "div", wrap, { + // FILLS the node: floor-only min height, no max (Image Pool pattern). + node._tgWidget = node.addDOMWidget("textgate_editor", "div", wrap, { serialize: false, - getMinHeight: () => previewHeight(node), + getMinHeight: () => widgetFloor(), }); - node._tgActive = false; - resizePreview(node); + // keep the editor width synced on manual resize so the textarea reflows + const onResize = node.onResize; + node.onResize = function () { + const r = onResize?.apply(this, arguments); + syncWidgetWidth(node); + return r; + }; + + // 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); } app.registerExtension({ @@ -118,8 +124,11 @@ app.registerExtension({ api.addEventListener("datasete-textgate-show", (e) => { const d = e.detail || {}; const node = app.graph?.getNodeById?.(parseInt(d.id, 10)); - if (!node || node.type !== NODE) return; - showEditor(node, d.text); + if (!node || node.type !== NODE || !node._tg) return; + node._tg.area.value = d.text || ""; + node._tg.status.textContent = "edit, then Pass"; + try { node._tg.area.focus(); } catch (err) { /* ignore */ } + node.setDirtyCanvas?.(true, true); }); }, From 259a63f8c2560e880528ea067e746ec2826ac82c Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 19:18:28 +0200 Subject: [PATCH 8/8] fix: image gate preview fills node + freely resizable (Image Pool sizing) Co-Authored-By: Claude Opus 4.8 --- web/image_gate.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/web/image_gate.js b/web/image_gate.js index 6efc345..6e7f271 100644 --- a/web/image_gate.js +++ b/web/image_gate.js @@ -104,13 +104,26 @@ function previewHeight(node) { return 2 * MARGIN + computeImgH(node) + BTN_ROW_H; } +// DomWidgets sizes the preview container from the widget width, which can lag +// node.size[0] on this frontend — pin it so the image/buttons reflow to fill. +function syncWidgetWidth(node) { + if (node._previewWidget) node._previewWidget.width = node.size?.[0] || 220; +} + function resizePreview(node) { // Fully remove the preview element from layout when idle — collapsing the // widget height to 0 isn't enough: the would still paint below the node. const shown = node._gateState && node._gateState !== "idle"; if (node._gate) node._gate.wrap.style.display = shown ? "flex" : "none"; const w = node.size?.[0] || 220; - node.setSize([w, node.computeSize()[1]]); + // Image Pool pattern: grow to fit the content floor but preserve a larger + // user-set size (so the node stays freely resizable); collapse exactly when + // idle. Forcing the height on every call would lock the node. + const target = shown + ? Math.max(node.size?.[1] || 0, node.computeSize()[1]) + : node.computeSize()[1]; + node.setSize([w, target]); + syncWidgetWidth(node); node.setDirtyCanvas(true, true); } @@ -494,6 +507,14 @@ function setupGateNode(node) { getMinHeight: () => previewHeight(node), }); + // keep the preview width synced on manual resize so the image/buttons reflow + const onResize = node.onResize; + node.onResize = function () { + const r = onResize?.apply(this, arguments); + syncWidgetWidth(node); + return r; + }; + // sync visible route outputs to the routes widget, now and on change applyRouteCount(node, getRouteCount(node)); const rw = routesWidget(node);