Compare commits

...

12 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
10 changed files with 55 additions and 24 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ def fit_mask(mask, W, H):
class BucketResize: class BucketResize:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING") RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "mask", "width", "height", "label") RETURN_NAMES = ("image", "mask", "width", "height", "label")
+4 -3
View File
@@ -25,7 +25,7 @@ def mask_from_stash(data, image):
class ImageGate: class ImageGate:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("MASK",) + ("IMAGE",) * MAX_ROUTES RETURN_TYPES = ("MASK",) + ("IMAGE",) * MAX_ROUTES
RETURN_NAMES = ("mask",) + tuple(f"route_{i + 1}" for i in range(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): def run(self, image, routes, unique_id):
from comfy_execution.graph_utils import ExecutionBlocker from comfy_execution.graph_utils import ExecutionBlocker
from . import gate_server from . import gate_server
import comfy.model_management as mm
gate_bus.GateBus.arm(unique_id) gate_bus.GateBus.arm(unique_id)
gate_server.send_preview(unique_id, image, routes) gate_server.send_preview(unique_id, image, routes)
try: 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: except gate_bus.GateCancelled:
import comfy.model_management as mm
raise mm.InterruptProcessingException() raise mm.InterruptProcessingException()
mask = mask_from_stash(gate_bus.GateBus.pop_mask(unique_id), image) 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) cls.messages[str(node_id)] = int(message)
@classmethod @classmethod
def wait(cls, node_id, period=0.1): def wait(cls, node_id, period=0.1, should_cancel=None):
sid = str(node_id) sid = str(node_id)
while sid not in cls.messages: while sid not in cls.messages:
if cls.cancelled: if cls.cancelled or (should_cancel is not None and should_cancel()):
cls.cancelled = False cls.cancelled = False
raise GateCancelled() raise GateCancelled()
time.sleep(period) time.sleep(period)
+1 -1
View File
@@ -27,7 +27,7 @@ def load_image_and_mask(path):
class FolderImageLoader: class FolderImageLoader:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "STRING", "MASK", "STRING", "INT") RETURN_TYPES = ("IMAGE", "STRING", "MASK", "STRING", "INT")
RETURN_NAMES = ("image", "text", "mask", "filename", "index") RETURN_NAMES = ("image", "text", "mask", "filename", "index")
+1 -1
View File
@@ -8,7 +8,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
class GridImagePool: class GridImagePool:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING") RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "mask", "index", "count", "label") RETURN_NAMES = ("image", "mask", "index", "count", "label")
+1 -1
View File
@@ -4,7 +4,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
class PoolProfile: class PoolProfile:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("POOL_PROFILE",) RETURN_TYPES = ("POOL_PROFILE",)
RETURN_NAMES = ("profile",) RETURN_NAMES = ("profile",)
+2 -2
View File
@@ -11,7 +11,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
class Sidecar: class Sidecar:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("SIDECAR",) RETURN_TYPES = ("SIDECAR",)
RETURN_NAMES = ("sidecar",) RETURN_NAMES = ("sidecar",)
@@ -34,7 +34,7 @@ class Sidecar:
class SaveImageSidecars: class SaveImageSidecars:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "save" FUNCTION = "save"
RETURN_TYPES = () RETURN_TYPES = ()
OUTPUT_NODE = True OUTPUT_NODE = True
+1 -1
View File
@@ -15,7 +15,7 @@ ANY = AnyType("*")
class TextGate: class TextGate:
CATEGORY = "Datasete Gates" CATEGORY = "Dataset Gates"
FUNCTION = "run" FUNCTION = "run"
RETURN_TYPES = ("STRING", ANY) RETURN_TYPES = ("STRING", ANY)
RETURN_NAMES = ("text", "signal") RETURN_NAMES = ("text", "signal")
+7
View File
@@ -65,3 +65,10 @@ def test_wait_payload_should_cancel_raises():
gb.GateBus.arm("p") gb.GateBus.arm("p")
with pytest.raises(gb.GateCancelled): with pytest.raises(gb.GateCancelled):
gb.GateBus.wait_payload("p", should_cancel=lambda: True) 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
+34 -11
View File
@@ -32,7 +32,9 @@ const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other nodes
// `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend // `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend
// widgets. When protected, the node acts as a plain text node: it outputs // 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 // stored_text and ignores upstream (no pause). The DOM textarea is the visible
// editor and mirrors its value into stored_text so it persists and reaches run(). // 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) { function widgetByName(node, name) {
return node.widgets?.find((w) => w.name === name); return node.widgets?.find((w) => w.name === name);
@@ -48,21 +50,24 @@ function syncStored(node) {
if (w) w.value = node._tg?.area?.value ?? ""; if (w) w.value = node._tg?.area?.value ?? "";
} }
// collapse the auto-created stored_text widget out of the layout (pool_id trick) // 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) { function hideStoredWidget(node) {
const w = widgetByName(node, "stored_text"); const w = widgetByName(node, "stored_text");
if (w) w.computeSize = () => [0, -4]; if (!w) return;
w.hidden = true;
w.computeSize = () => [0, -4];
} }
// reflect the persisted protected/stored_text state into the editor + UI // 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) { function applyPersistedMode(node) {
if (!node._tg) return; if (!node._tg) return;
if (isProtected(node)) {
node._tg.area.value = widgetByName(node, "stored_text")?.value ?? ""; node._tg.area.value = widgetByName(node, "stored_text")?.value ?? "";
setState(node, "protected"); setState(node, isProtected(node) ? "protected" : "idle");
} else {
setState(node, "idle");
}
} }
// ---- server call ------------------------------------------------------------ // ---- server call ------------------------------------------------------------
@@ -165,8 +170,17 @@ function setupTextGateNode(node) {
const area = document.createElement("textarea"); const area = document.createElement("textarea");
area.className = "tgate-area"; area.className = "tgate-area";
area.placeholder = "waiting for a run…"; area.placeholder = "waiting for a run…";
// don't let typing/space toggle node selection or graph shortcuts // Stop keys from reaching litegraph (so typing/space can't toggle node
area.onkeydown = (e) => e.stopPropagation(); // 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() // keep the hidden stored_text widget mirrored so edits persist + reach run()
area.oninput = () => syncStored(node); area.oninput = () => syncStored(node);
@@ -177,6 +191,7 @@ function setupTextGateNode(node) {
pass.className = "tgate-pass"; pass.className = "tgate-pass";
pass.textContent = "▶ Pass"; pass.textContent = "▶ Pass";
pass.onclick = async () => { pass.onclick = async () => {
syncStored(node); // persist the passed text so a reload keeps it
await postPass(node, area.value); await postPass(node, area.value);
setState(node, "passed"); setState(node, "passed");
}; };
@@ -240,11 +255,18 @@ function setupTextGateNode(node) {
syncWidgetWidth(node); 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({ app.registerExtension({
name: "datasete.gates.textgate", name: "datasete.gates.textgate",
// one global socket listener: route the server's pause event to the node // one global socket listener: route the server's pause event to the node
setup() { setup() {
console.info(`[datasete.textgate] loaded build ${BUILD}`);
api.addEventListener("datasete-textgate-show", (e) => { api.addEventListener("datasete-textgate-show", (e) => {
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));
@@ -260,6 +282,7 @@ app.registerExtension({
} else { } else {
node._tg.area.value = d.text || ""; node._tg.area.value = d.text || "";
} }
syncStored(node); // persist the shown text so a refresh/reload keeps it
setState(node, "paused"); setState(node, "paused");
try { node._tg.area.focus(); } catch (err) { /* ignore */ } try { node._tg.area.focus(); } catch (err) { /* ignore */ }
}); });