Compare commits

...

77 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
Ethanfel aa909448d7 Merge feat/bucket-resize: Bucket Resize (Klein 9B) node
Auto-snaps images onto ÷64 ≤1.64MP training buckets (cover + center-crop,
Lanczos), transforms an optional mask identically, outputs width/height/label.
Pure bucket math tested against KLEIN_BUCKET_SIZES.md. 99 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:08:45 +02:00
Ethanfel 037cbf27db feat: register BucketResize
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:52:45 +02:00
Ethanfel 969463a4e9 fix: drop deprecated Pillow mode= arg in fit_mask
uint8 2D arrays infer "L" automatically; silences Pillow 13 deprecation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:52:45 +02:00
Ethanfel 7f90b6878f feat: BucketResize node (cover-crop onto Klein buckets)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:49:01 +02:00
Ethanfel 0413e25571 test: bucket cover_crop_params geometry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:48:13 +02:00
Ethanfel cdd742c950 feat: bucket selection matching Klein 9B table
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:47:46 +02:00
Ethanfel 628a945514 Add Bucket Resize (Klein 9B) design + implementation plan
Auto-snap images onto ai-toolkit training buckets (W×H ÷64, ≤1.64MP) via
cover-scale + center-crop (Lanczos), per KLEIN_BUCKET_SIZES.md. Pure stdlib
bucket math (reproduces the spec table) + a torch node that also transforms
an optional mask identically and outputs width/height/label. No frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:45:05 +02:00
Ethanfel 95b3417ff6 Add Image Gate send/get bus design + implementation plan
Disk-backed image bus (input/gate_bus/<id>/): gates auto-publish image+mask
to a named send_id on pass; when image input is empty they load from get_id
(dropdown) — wireless, cycle-free "restart from the gate point" across runs.
Making image optional implements ignore-on-normal-path. TDD plan with a pure
stdlib imagebus + tensor savers; comfy imports stay lazy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:45:15 +02:00
Ethanfel 0ba3d81fbf docs: expand README to cover the full node suite
Document all five nodes (Image Pool, Pool Profile, Folder Image Loader,
Image Gate, Text Gate) with IO tables and behavior, plus shared concepts
(blocking gates, mask polarity, storage/profiles layout) and dev layout.
Refresh the stale pyproject description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:23:52 +02:00
Ethanfel e304b39009 Merge feat/pool-profiles: Pool Profile companion node + portable profiles
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:20:26 +02:00
Ethanfel 10c2ea6d60 fix: pool profiles never auto-switch on connect; seed empty profile from current pool
Connecting a Pool Profile no longer overwrites the pool's pool_id. The pool is
switched only when the user actively selects a profile in the dropdown; picking
an empty profile while a pool with images is connected offers to copy those
images into it (new seed_profile op + /grid_pool/profiles/seed route), so the
current pool is never silently lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:14:44 +02:00
Ethanfel 0215bcb8f3 feat: pool grid exposes refresh hook for profile sync
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:03:37 +02:00
Ethanfel accd3230a6 feat: pool profile frontend — dropdown, actions, cross-node propagation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:02:59 +02:00
Ethanfel ad85b002fc feat: profiles routes + register PoolProfile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:00:16 +02:00
Ethanfel 42138857a9 feat: Image Pool accepts optional POOL_PROFILE (profile or pool_id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:59:26 +02:00
Ethanfel b7e064508a feat: PoolProfile companion node
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:58:34 +02:00
Ethanfel ac3ad07b17 feat: profiles export/import (portable zip)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:56:00 +02:00
Ethanfel e974413085 feat: profiles duplicate_profile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:55:10 +02:00
Ethanfel d3bb7834a4 feat: profiles delete_profile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:54:32 +02:00
Ethanfel 71462071e4 feat: profiles rename_profile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:51:52 +02:00
Ethanfel 9a0128b5fa feat: profiles create_profile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:51:21 +02:00
Ethanfel 0725a46f97 feat: profiles registry read/write + find
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:50:52 +02:00
Ethanfel 1b5ac98936 Add Pool Profiles design + implementation plan
Named, portable profiles for the Image Pool: a Pool Profile companion node
(create/select/rename/delete/duplicate/export-import) outputs a POOL_PROFILE
id into the pool's new optional input; profile or pool_id wins. Registry
(name->id) in profiles.json; live edit-time grid switch via cross-node
propagation. TDD plan with a pure stdlib profiles layer incl. zip round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:48:39 +02:00
Ethanfel d3358c8a75 Merge feat/text-gate: Text Gate (Manual Pass) node
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:37:45 +02:00
Ethanfel 259a63f8c2 fix: image gate preview fills node + freely resizable (Image Pool sizing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:18:28 +02:00
Ethanfel b46de4b031 fix: text gate editor fills node + freely resizable (Image Pool sizing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:11:56 +02:00
Ethanfel ef064db972 feat: text gate frontend — editable textarea + pass
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:49:00 +02:00
Ethanfel b1ac27def9 feat: text gate server route + register TextGate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:48:26 +02:00
Ethanfel f617c46aef feat: TextGate node — pause, editable pass-through, signal passthrough
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:47:55 +02:00
Ethanfel 96912d47a4 feat: textgate AnyType wildcard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:44:07 +02:00
Ethanfel 3250aaa828 test: gate_bus wait_payload honors should_cancel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:43:43 +02:00
Ethanfel 1008612fb2 feat: gate_bus payload channel + should_cancel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:43:29 +02:00
Ethanfel 32f616e067 Add Text Gate (Manual Pass) design + implementation plan
Blocking text gate: pauses, shows incoming text in an editable box, Pass
emits the edited text. Optional any-type signal input + signal passthrough
output for ordering. Reuses gate_bus via an additive string payload channel
with a should_cancel hook so the Pass-only gate still honors global Cancel
(processing_interrupted). TDD plan; comfy imports stay lazy for testability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:41:17 +02:00
Ethanfel ec8e1b9598 Merge feat/image-gate: Image Gate (Manual Router) node
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:33:23 +02:00
Ethanfel 6e27da0dce feat: show painted mask as a translucent red overlay on the gate preview
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:26:33 +02:00
Ethanfel f9f924942e feat: sticky mask + keep preview after routing + width-scaled preview
After a route choice the node now keeps the image and shows a 'Run from here'
re-queue button instead of blanking. The last painted mask is remembered and
auto-re-stashed on each new pause (with a Clear control) so it is not lost
between runs. The preview image area now scales with the node width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:17:37 +02:00
Ethanfel 45e16e1134 fix: hide gate preview element when idle (no stray black box)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:59:04 +02:00
Ethanfel 63647d2488 feat: image gate frontend — preview, dynamic outputs, route/stop/mask
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:48:55 +02:00
Ethanfel 8e8eb317f7 feat: gate server routes + preview + register ImageGate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:43:53 +02:00
Ethanfel d8dbc4fb4b feat: ImageGate node — pause, route via ExecutionBlocker, mask out
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:42:54 +02:00
Ethanfel ea3438567a feat: gate mask_from_stash (paint or zeros)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:42:04 +02:00
Ethanfel f0f8676eaa feat: gate route_tuple helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:35:51 +02:00
Ethanfel 11772bc29d feat: gate_bus mask stash
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:35:28 +02:00
Ethanfel 9148dfec25 feat: gate_bus blocking choice waiter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:35:04 +02:00
Ethanfel 7e8878bade Add Image Gate (Manual Router) design + implementation plan
Interactive chooser/router: pauses execution, shows the image with up to
10 labeled route buttons + edit-mask + stop. Chosen route gets the image,
others ExecutionBlocker-ed; gate-painted mask on a fixed output; stop
raises InterruptProcessingException. TDD plan with a pure torch-free
gate_bus; lazy comfy imports keep node logic unit-testable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:09:26 +02:00
Ethanfel 8d45a101e7 Merge feat/folder-image-loader: Folder Image Loader node
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:36:30 +02:00
Ethanfel 6751fe5b26 feat: register FolderImageLoader in node mappings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:28:38 +02:00
Ethanfel af96155cd1 feat: FolderImageLoader node (image/text/mask/filename/index)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:28:07 +02:00
46 changed files with 5790 additions and 91 deletions
+222 -80
View File
@@ -1,79 +1,20 @@
# ComfyUI Datasete Gates # ComfyUI Datasete Gates
Custom nodes for curating image datasets in ComfyUI. A suite of custom nodes for **curating, loading, and gating image datasets** in
ComfyUI — built for human-in-the-loop inpaint/sort pipelines where you review
images, route them, and reuse them across workflows without rewiring.
## Image Pool (Grid) All nodes appear under the **“Datasete Gates”** category.
A node that holds a curated **pool of images** — each with its own remembered ## Nodes at a glance
mask and editable label — shown as an in-node thumbnail grid. One image is
selectable as the node's output (image + mask + index + count + label), so you
can switch which image flows downstream **without rewiring**.
![category: Datasete Gates] | Node | Class | What it does |
|------|-------|--------------|
### What it does | **Image Pool (Grid)** | `GridImagePool` | Holds a curated pool of images (each with a remembered mask + label) as an in-node grid; outputs the selected one — switch images without rewiring. |
| **Pool Profile** | `PoolProfile` | Companion node: create/select/manage **named profiles** so a pool's images can be reused in any workflow and moved between machines. |
- **In-node grid** of the pooled images. Ingest by **paste** (Ctrl+V), | **Folder Image Loader** | `FolderImageLoader` | Loads an image by index from a folder (fixed or auto-advancing), with its sidecar `.txt` caption and alpha mask. |
**drag-and-drop**, or the **Upload** button. | **Image Gate (Manual Router)** | `ImageGate` | Pauses the run and lets you **click a button to route** the image down one of up to 10 outputs; optional gate-time mask; Stop cancels. |
- **Click a thumbnail** to make it the active output. No rewiring needed to | **Text Gate (Manual Pass)** | `TextGate` | Pauses the run, shows the incoming text in an **editable** box, and passes it on a click; any-type `signal` in/out for ordering. |
switch images.
- **Per-slot mask**: click the 🖌 button to paint a mask in ComfyUI's
MaskEditor. The mask is remembered per image and never redrawn when you switch
between images.
- **Per-slot label**: type a label under each thumbnail; it's saved with the
pool and exposed on the `label` output.
- **Drag to reorder** thumbnails; **✕** to delete.
- The pool is stored on disk, so it **survives a ComfyUI restart** and travels
with the workflow (via a per-node pool id).
### Inputs
| Input | Type | Description |
|-----------|--------|-------------|
| `index` | INT | `-1` (default) outputs the in-node **selected** image. `0+` forces that slot index (clamped to the pool size). |
| `pool_id` | STRING | Per-node pool identifier. Managed automatically by the UI (a UUID minted per node and hidden); you normally never touch it. |
### Outputs
| Output | Type | Description |
|---------|--------|-------------|
| `image` | IMAGE | The selected image, `[1, H, W, 3]` float 0..1. A 1×1 black image when the pool is empty. |
| `mask` | MASK | The selected image's mask, `[1, H, W]` float 0..1. **All zeros** when the slot has no mask. |
| `index` | INT | The resolved slot index that was output. |
| `count` | INT | Number of images in the pool. |
| `label` | STRING | The selected slot's label. |
### Mask polarity
A mask is a grayscale PNG where **white (1.0) = the painted region of interest**
(the area you painted in the MaskEditor — i.e. the area to inpaint). No mask file
means an all-zeros MASK output. The MaskEditor stores the painted region in the
image's alpha channel; the extension bakes that alpha into a grayscale mask on
save so that white = painted.
### Managed pool folder
Each pool lives under ComfyUI's input directory:
```
input/grid_pool/<pool_id>/
├── manifest.json # {active, slots:[{image, mask, label, added}], next_seq}
├── img_0001.png # an image
├── img_0001.mask.png # its mask (sidecar; optional)
├── img_0002.png
└── ...
```
- Images are named monotonically (`img_0001.png`, `img_0002.png`, …).
- A mask is stored as a `*.mask.png` sidecar next to its image.
- `manifest.json` is written atomically. If it's missing or corrupt, it is
rebuilt from the files on disk.
### Cloning nodes
Copy/paste of a node shares the source node's `pool_id` (both show the same
pool). To give a clone its **own** independent pool, right-click it →
**“Detach pool (new id)”**.
## Install ## Install
@@ -85,15 +26,212 @@ git clone <repo-url> /path/to/ComfyUI/custom_nodes/ComfyUI-Datasete-Gates
ln -sfn /media/p5/ComfyUI-Datasete-Gates /path/to/ComfyUI/custom_nodes/ComfyUI-Datasete-Gates ln -sfn /media/p5/ComfyUI-Datasete-Gates /path/to/ComfyUI/custom_nodes/ComfyUI-Datasete-Gates
``` ```
Restart ComfyUI. The node appears under the **“Datasete Gates”** category as Restart ComfyUI. Dependencies (torch, Pillow, numpy, aiohttp) are already
**“Image Pool (Grid)”**. provided by ComfyUI.
Dependencies (torch, Pillow, numpy, aiohttp) are already provided by ComfyUI. ---
## Image Pool (Grid)
Holds a curated **pool of images** — each with its own remembered mask and
editable label — shown as an in-node thumbnail grid. One image is selectable as
the node's output, so you can switch which image flows downstream **without
rewiring**.
### What it does
- **In-node grid** of pooled images. Ingest by **paste** (Ctrl+V),
**drag-and-drop**, or the **Upload** button.
- **Click a thumbnail** to make it the active output. No rewiring to switch.
- **Per-slot mask**: click 🖌 to paint a mask in ComfyUI's MaskEditor. The mask
is remembered per image and never redrawn when you switch between images.
- **Per-slot label**: type a label under each thumbnail; saved with the pool and
exposed on the `label` output.
- **Drag to reorder** thumbnails; **✕** to delete.
- Stored on disk, so the pool **survives a restart**. Pair with **Pool Profile**
to reuse it across workflows.
### Inputs
| Input | Type | Description |
|-----------|---------------|-------------|
| `index` | INT | `-1` (default) outputs the in-node **selected** image. `0+` forces that slot index (clamped to the pool size). |
| `pool_id` | STRING | Per-node pool identifier; managed by the UI (a hidden per-node UUID). You normally never touch it. |
| `profile` | POOL_PROFILE | *Optional.* When connected to a **Pool Profile** node, the pool uses that profile instead of its own `pool_id` (`profile or pool_id`). |
### Outputs
| Output | Type | Description |
|---------|--------|-------------|
| `image` | IMAGE | The selected image, `[1, H, W, 3]` float 0..1. A 1×1 black image when the pool is empty. |
| `mask` | MASK | The selected image's mask, `[1, H, W]` float 0..1. **All zeros** when the slot has no mask. |
| `index` | INT | The resolved slot index that was output. |
| `count` | INT | Number of images in the pool. |
| `label` | STRING | The selected slot's label. |
### Cloning nodes
Copy/paste shares the source node's `pool_id` (both show the same pool). To give
a clone its **own** independent pool, right-click → **“Detach pool (new id)”**.
---
## Pool Profile
Companion to the Image Pool. Turns a pool's fragile per-node UUID into a
**named, reusable profile**, so the same images (with masks/labels) can be loaded
in any workflow — and exported to another machine.
Wire its `profile` output into a pool's `profile` input. Selecting a profile
**live-switches** the connected pool's grid to that profile's images, and any
adds/masks land in it.
### Actions
- **Create** a new named profile, **Select** an existing one (dropdown).
- **Rename**, **Delete** (removes its images), **Duplicate / Save-as** (snapshot
to a new profile).
- **Export** a profile to a `.zip`, **Import** one back — pools become portable.
### Inputs / Outputs
| Port | Type | Description |
|------|------|-------------|
| `profile` (widget) | STRING | The selected profile name (UI renders a dropdown). |
| `profile_id` (widget) | STRING | Hidden, UI-managed stable id. |
| `profile` (output) | POOL_PROFILE | The selected profile's id → connect to a pool's `profile` input. |
### Registry
`input/grid_pool/profiles.json` maps friendly **name → stable id**; each
profile's data lives in the existing `input/grid_pool/<id>/` layout. Existing
unnamed pools keep working unchanged — they're just unregistered ids.
---
## Folder Image Loader
Loads an image by index from a folder, plus its sidecar caption text and alpha
mask. Built for sequential, one-image-per-run dataset processing.
### Inputs
| Input | Type | Description |
|----------|------|-------------|
| `folder` | STRING | Absolute path to a folder of images. |
| `index` | INT | Which image (after natural sort). Has **`control_after_generate`** → set it to fixed / increment / decrement; auto-advances after each run. |
| `depth` | INT | `0` = top-level only; `N` = recurse up to N levels; `-1` = unlimited. |
### Outputs
| Output | Type | Description |
|------------|--------|-------------|
| `image` | IMAGE | The loaded image. |
| `text` | STRING | Contents of the sidecar `<stem>.txt`, or `""` if none. |
| `mask` | MASK | From the image's alpha channel (`1 - alpha`); zeros sized to the image if no alpha. |
| `filename` | STRING | The file **stem** (no extension). |
| `index` | INT | The resolved index actually loaded. |
### Notes
- Files are **natural-sorted** (`img2` before `img10`); extensions
`.png/.jpg/.jpeg/.webp/.bmp/.tif/.tiff`.
- Walking past the end (or below 0) **raises** — a clean end-of-batch stop signal
when running in increment mode. Empty folder / bad path raise too.
---
## Image Gate (Manual Router)
Pauses the running prompt and shows the image with a row of labeled **route
buttons**. Click a route to send the image down that output; every other route is
silently skipped. Built for manual dataset sorting.
### Inputs
| Input | Type | Description |
|----------|-------|-------------|
| `image` | IMAGE | The image (or batch, routed as one unit). |
| `routes` | INT | Number of visible route buttons/outputs (110). |
### Outputs
| Output | Type | Description |
|-------------------|------|-------------|
| `mask` | MASK | Painted at the gate (🖌), or zeros. Always emitted. |
| `route_1``route_10` | IMAGE | The chosen route carries the image; the rest return an `ExecutionBlocker` so only the chosen branch runs. The UI shows only `routes` of them, with editable labels. |
### How it works
- During execution the node **blocks** until you click. **Route K** → image to
output K (others blocked). **🖌 Edit mask** → opens the MaskEditor; the result
comes out on `mask`. **■ Stop** → cancels the whole run cleanly.
- Because any `ExecutionBlocker` input skips a node, a non-chosen route's
downstream never runs — as long as it also consumes the routed image (the
normal wiring). It re-pauses on every run (never cached).
---
## Text Gate (Manual Pass)
Pauses the run, shows the incoming text in an **editable** box, and emits it
(edited) when you click **Pass**. An optional any-type `signal` lets you force
execution order.
### Inputs
| Input | Type | Description |
|----------|------|-------------|
| `text` | STRING (`forceInput`) | The incoming text to review/edit. |
| `signal` | `*` (any) | *Optional.* Accepts any type; only used to sequence this node after its source. |
### Outputs
| Output | Type | Description |
|----------|------|-------------|
| `text` | STRING | The text you passed (possibly edited). |
| `signal` | `*` (any) | Passthrough of the input signal — chain gates in a fixed order. |
Pauses every run; ComfyUI's global **Cancel** unblocks it cleanly (no deadlock).
---
## Concepts
### Human-in-the-loop gates
Image/Text Gate **block the executor thread** during a run and wait for a click
(a small server-side waiter + a `/datasete_gate/*` route the UI posts to). Stop /
Cancel raise ComfyUI's `InterruptProcessingException`. These nodes always
re-execute (`IS_CHANGED = nan`) so they pause every time.
### Mask polarity
A mask is a grayscale PNG where **white (1.0) = the painted region of interest**
(the area to inpaint). No mask → an all-zeros MASK. The MaskEditor stores the
painted region in the image's alpha channel; the extension bakes that alpha into
a grayscale mask on save so that white = painted.
### Storage layout
```
input/grid_pool/
├── profiles.json # {profiles:[{id, name, created}]} (Pool Profile)
└── <pool_id or profile_id>/
├── manifest.json # {active, slots:[{image, mask, label, added}], next_seq}
├── img_0001.png # an image (named monotonically)
├── img_0001.mask.png # its mask (sidecar; optional)
└── ...
```
`manifest.json` is written atomically; if missing or corrupt it is rebuilt from
the files on disk.
---
## Development ## Development
The storage layer (`gates/pool.py`) is pure stdlib and fully unit-tested without The pure storage/scan layers are stdlib-only and unit-tested without ComfyUI:
ComfyUI. Run the tests with:
```bash ```bash
python -m pytest tests/ -v python -m pytest tests/ -v
@@ -101,8 +239,12 @@ python -m pytest tests/ -v
Layout: Layout:
- `gates/pool.py` — pure storage (manifest, add/remove/reorder/active/label/mask). Stdlib only. - `gates/pool.py` — pure pool storage (manifest, add/remove/reorder/active/label/mask).
- `gates/profiles.py` — pure profile registry + dir ops + zip export/import.
- `gates/scan.py` — pure folder scan (natural sort, depth, sidecar, index).
- `gates/gate_bus.py` — pure blocking choice/text/mask waiter for the gates.
- `gates/imaging.py` — torch/PIL tensor loaders. - `gates/imaging.py` — torch/PIL tensor loaders.
- `gates/node.py` — the `GridImagePool` node. - `gates/node.py` · `loader.py` · `gate.py` · `textgate.py` · `profile_node.py` — the nodes.
- `gates/handlers.py` / `gates/routes.py` — pure handlers + aiohttp routes (`/grid_pool/*`). - `gates/handlers.py` · `routes.py` · `gate_server.py` · `profiles_routes.py` — aiohttp glue
- `web/grid_image_pool.js` — the in-node grid UI + MaskEditor integration. (`/grid_pool/*`, `/datasete_gate/*`, `/grid_pool/profiles/*`).
- `web/*.js` — the in-node UIs (grid + MaskEditor, gate previews, profile dropdown).
+23 -1
View File
@@ -8,8 +8,30 @@ WEB_DIRECTORY = "./web"
# setup — in that case the relative imports would raise. Guard on __package__ # setup — in that case the relative imports would raise. Guard on __package__
# so the test suite can import `gates.*` without dragging in aiohttp/comfy. # so the test suite can import `gates.*` without dragging in aiohttp/comfy.
if __package__: if __package__:
from .gates.node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS from .gates.node import NODE_CLASS_MAPPINGS as _POOL_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _POOL_NAMES
from .gates.loader import NODE_CLASS_MAPPINGS as _LOADER_NODES, \
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.profile_node import NODE_CLASS_MAPPINGS as _PROF_NODES, \
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 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,
**_SC_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES,
**_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES,
**_SC_NAMES}
else: # pragma: no cover - exercised only under pytest collection else: # pragma: no cover - exercised only under pytest collection
NODE_CLASS_MAPPINGS = {} NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -0,0 +1,80 @@
# Bucket Resize (Klein 9B) — Design
Date: 2026-06-21
Status: Approved (brainstorming complete, ready for implementation plan)
Spec: `/media/unraid/davinci/comics-lora/dataset/KLEIN_BUCKET_SIZES.md`
## 1. Purpose
Automatically resize any image so it lands **exactly on its training bucket** — W×H
multiples of 64 within a ~1.64 MP area budget (FLUX.2 [klein] 9B, ai-toolkit
`resolution: [1280]`). Resize-to-cover + center-crop, with slight Lanczos upscale only when
needed. Outputs the bucketed image (+ identically transformed mask) and the chosen size.
Sixth node in the `ComfyUI-Datasete-Gates` suite. **No custom frontend** — standard widgets.
## 2. Bucket selection (generated grid)
Budget = `resolution²` (default 1280 → 1,638,400 px). For an image of aspect `a = iw/ih`:
- Enumerate widths `w` in multiples of `divisible` (default 64). For each, take the **largest**
on-grid height within budget: `h = floor(budget / w / divisible) * divisible` (skip if
`h < divisible`). This is the max-area frontier per width.
- Pick the candidate minimizing **log-aspect distance** `|ln(w/h) ln(a)|`; tie-break by
larger area. This reproduces the doc's 13 rows for normal aspects (square→1280×1280,
0.5→896×1792, 2.0→1792×896, …) and extends to extreme aspects (≈0.092.67).
## 3. Fit: cover + center-crop
For chosen bucket `(W, H)` and image `(iw, ih)`:
- `scale = max(W/iw, H/ih)` (cover). `new = (round(iw*scale), round(ih*scale))`.
- Resize with **Lanczos** (good for up- and down-scale), then **center-crop** to exactly
`W×H`: `left=(new_wW)//2`, `top=(new_hH)//2`.
- If `scale > max_upscale` (default 1.5), still fit but **log a warning** (the doc warns big
upscales soften texture).
The optional **mask** gets the identical scale+crop (so it stays aligned); absent → zeros
sized to the bucket.
## 4. IO
| dir | name | type | notes |
|-----|------|------|-------|
| in | `image` | IMAGE | required |
| in (opt) | `mask` | MASK | transformed identically; zeros if absent |
| widget | `resolution` | INT (default 1280, min 64) | area budget = `resolution²` |
| widget | `divisible` | INT (default 64, min 8) | grid step |
| widget | `max_upscale` | FLOAT (default 1.5, min 1.0) | warn above this cover-scale |
| out | `image` | IMAGE | exactly bucket `W×H`, `[1,H,W,3]` |
| out | `mask` | MASK | `[1,H,W]` |
| out | `width` | INT | chosen bucket width |
| out | `height` | INT | chosen bucket height |
| out | `label` | STRING | `"WxH"` (e.g. `1280x1280`) |
## 5. Code shape
- `gates/buckets.py` *(new, pure stdlib + math)*`pick_bucket(iw, ih, resolution, divisible)`
`(W, H)`; `cover_crop_params(iw, ih, W, H)``(new_w, new_h, left, top, scale)`.
Fully unit-testable; **tested against the doc's table**.
- `gates/bucket_node.py` *(new, torch/PIL)* — tensor↔PIL resize/crop using `buckets`, the
`BucketResize` node. `run()` is pure compute (no comfy, no blocking) → fully unit-testable.
- root `__init__.py` — additive merge of the node mapping.
## 6. Edge cases
- Batch `B>1`: bucket is chosen from the **first** image's aspect and applied to all (keeps a
uniform output tensor); documented. (Dataset flow is typically one image per run.)
- Image already exactly on a bucket → `scale≈1`, no crop.
- Tiny/extreme aspect → handled by the generated grid (nearest of the frontier).
- `max_upscale` only warns; it never refuses (the node always returns an on-grid image).
- Mask resized with the same geometry (Lanczos), then clamped to [0,1].
## 7. Testing
- pytest `tests/test_buckets.py`: `pick_bucket` reproduces the doc rows for a set of aspects
(1.0→1280×1280, 0.5→896×1792, 0.58→960×1664, 2.0→1792×896, …); all outputs are ÷divisible
and ≤ budget; `cover_crop_params` math (cover scale, centered crop, exact target).
- pytest `tests/test_bucket_node.py`: feed known tensor sizes → output is exactly the bucket
shape; mask aligned; `label`/`width`/`height` correct; no-mask → zeros.
- Manual (live): drop node after a loader, confirm odd-sized inputs come out on-grid and the
label matches the table.
@@ -0,0 +1,300 @@
# Bucket Resize (Klein 9B) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** A `BucketResize` node that snaps any image onto its ai-toolkit training bucket (W×H ÷64, ≤ ~1.64 MP) via cover-scale + center-crop (Lanczos), transforms an optional mask identically, and outputs the bucketed image + chosen `width`/`height`/`label`.
**Architecture:** A pure stdlib+math `gates/buckets.py` selects the bucket and computes the cover-crop geometry — fully unit-testable against the spec's table. `gates/bucket_node.py` (torch/PIL) does the actual tensor resize/crop; its `run()` is pure compute (no comfy, no blocking) so it unit-tests end-to-end. No custom frontend.
**Spec:** `/media/unraid/davinci/comics-lora/dataset/KLEIN_BUCKET_SIZES.md`
**Tech Stack:** Python 3.12, torch 2.8, Pillow, numpy; pytest 9.
---
## Conventions (read once)
- **Test python:** `/media/p5/miniforge3/bin/python` (`PY=...`).
- **Run tests:** `cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_buckets.py tests/test_bucket_node.py -v`
- `gates/buckets.py` is pure (stdlib + `math`); no torch/comfy.
- IMAGE tensors are `[B,H,W,3]` float 0..1; MASK is `[B,H,W]`.
- `__init__.py` edit is **additive** — re-Read first, extend the mappings.
- Commit style: Conventional Commits + repo Co-Authored-By; stage only this node's paths.
---
### Task 1: `buckets.py` — `pick_bucket` (reproduce the spec table)
**Files:** Create `gates/buckets.py`; Test `tests/test_buckets.py`
**Step 1: Failing test**
```python
# tests/test_buckets.py
from gates import buckets
# (iw, ih) -> expected (W, H) from KLEIN_BUCKET_SIZES.md, budget 1280, ÷64
CASES = [
(1000, 1000, 1280, 1280), # square
(1000, 2000, 896, 1792), # a=0.50 portrait
(1000, 1730, 960, 1664), # a≈0.58
(1000, 1100, 1216, 1344), # a≈0.90 -> portrait-leaning
(2000, 1000, 1792, 896), # a=2.00 landscape
(1500, 1000, 1536, 1024), # a=1.50
]
def test_pick_bucket_matches_table():
for iw, ih, W, H in CASES:
assert buckets.pick_bucket(iw, ih, 1280, 64) == (W, H)
def test_buckets_are_on_grid_and_within_budget():
for iw, ih, *_ in CASES:
W, H = buckets.pick_bucket(iw, ih, 1280, 64)
assert W % 64 == 0 and H % 64 == 0
assert W * H <= 1280 * 1280
def test_square_is_exactly_1280():
assert buckets.pick_bucket(512, 512, 1280, 64) == (1280, 1280)
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# gates/buckets.py
"""Pure bucket math for KLEIN_BUCKET_SIZES.md. Stdlib only."""
import math
def pick_bucket(iw, ih, resolution=1280, divisible=64):
"""Choose the on-grid bucket (W,H), area <= resolution^2, nearest to the
image aspect (log distance; tie-break larger area)."""
budget = resolution * resolution
target = iw / ih
best = None
w = divisible
w_max = budget // divisible
while w <= w_max:
h = (budget // w // divisible) * divisible # largest on-grid h within budget
if h >= divisible:
err = abs(math.log(w / h) - math.log(target))
cand = (err, -(w * h), w, h) # min err, then max area
if best is None or cand < best:
best = cand
w += divisible
return best[2], best[3]
def cover_crop_params(iw, ih, W, H):
"""Cover-scale + centered crop to land (iw,ih) exactly on (W,H)."""
scale = max(W / iw, H / ih)
new_w = max(W, round(iw * scale))
new_h = max(H, round(ih * scale))
left = (new_w - W) // 2
top = (new_h - H) // 2
return new_w, new_h, left, top, scale
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: bucket selection matching Klein 9B table`
---
### Task 2: `buckets.py` — `cover_crop_params`
**Files:** Modify `tests/test_buckets.py`
**Step 1: Failing test**
```python
def test_cover_crop_exact_aspect_no_crop():
# a=2.0 image onto 1792x896 bucket -> scale 0.896, no crop
new_w, new_h, left, top, scale = buckets.cover_crop_params(2000, 1000, 1792, 896)
assert (new_w, new_h) == (1792, 896)
assert (left, top) == (0, 0)
assert round(scale, 3) == 0.896
def test_cover_crop_square_into_landscape_crops_height():
new_w, new_h, left, top, scale = buckets.cover_crop_params(1000, 1000, 1792, 896)
assert new_w == 1792 and new_h >= 896
assert left == 0 and top == (new_h - 896) // 2 # centered vertical crop
assert scale > 1.0 # upscaled to cover width
def test_cover_crop_upscale_square():
*_, scale = buckets.cover_crop_params(1000, 1000, 1280, 1280)
assert round(scale, 2) == 1.28
```
**Step 2: Run → PASS** (implemented in Task 1). If it fails, fix `cover_crop_params`.
**Step 3:** (no new code — locks the geometry with tests.)
**Step 4: Commit** `test: bucket cover_crop_params geometry`
---
### Task 3: `bucket_node.py` — fit helpers + `BucketResize` node
**Files:** Create `gates/bucket_node.py`; Test `tests/test_bucket_node.py`
**Step 1: Failing test**
```python
# tests/test_bucket_node.py
import torch
from gates import bucket_node as bn
def test_square_to_1280():
out, m, w, h, label = bn.BucketResize().run(image=torch.rand((1, 1000, 1000, 3)))
assert (w, h) == (1280, 1280)
assert out.shape == (1, 1280, 1280, 3)
assert m.shape == (1, 1280, 1280) and float(m.max()) == 0.0 # no mask -> zeros
assert label == "1280x1280"
def test_landscape_bucket_shapes():
# tensor [B,H,W,3] with H=1000,W=2000 -> aspect 2.0 -> 1792x896
out, m, w, h, label = bn.BucketResize().run(image=torch.rand((1, 1000, 2000, 3)))
assert (w, h) == (1792, 896)
assert out.shape == (1, 896, 1792, 3)
assert label == "1792x896"
def test_mask_resized_and_aligned():
out, m, w, h, _ = bn.BucketResize().run(
image=torch.rand((1, 1000, 1000, 3)), mask=torch.ones((1, 1000, 1000)))
assert m.shape == (1, 1280, 1280) and float(m.min()) > 0.9
def test_outputs_are_on_grid():
out, m, w, h, _ = bn.BucketResize().run(
image=torch.rand((1, 777, 1333, 3)), resolution=1280, divisible=64)
assert w % 64 == 0 and h % 64 == 0
assert out.shape[1] == h and out.shape[2] == w
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# gates/bucket_node.py
import numpy as np
import torch
from PIL import Image
from . import buckets
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def _resize_crop_pil(pil, new_w, new_h, left, top, W, H):
pil = pil.resize((new_w, new_h), Image.LANCZOS)
return pil.crop((left, top, left + W, top + H))
def fit_image(image, W, H):
"""image [B,H,W,3] -> [B,H,W,3] at (W,H) using the first image's geometry."""
b, ih, iw = image.shape[0], image.shape[1], image.shape[2]
new_w, new_h, left, top, scale = buckets.cover_crop_params(iw, ih, W, H)
out = []
for i in range(b):
arr = (image[i].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
pil = _resize_crop_pil(Image.fromarray(arr), new_w, new_h, left, top, W, H)
out.append(torch.from_numpy(np.array(pil, dtype=np.float32) / 255.0))
return torch.stack(out, 0), scale
def fit_mask(mask, W, H):
b, ih, iw = mask.shape[0], mask.shape[1], mask.shape[2]
new_w, new_h, left, top, _ = buckets.cover_crop_params(iw, ih, W, H)
out = []
for i in range(b):
arr = (mask[i].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
pil = _resize_crop_pil(Image.fromarray(arr, mode="L"), new_w, new_h, left, top, W, H)
out.append(torch.from_numpy(np.array(pil, dtype=np.float32) / 255.0))
return torch.stack(out, 0)
class BucketResize:
CATEGORY = "Datasete Gates"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "mask", "width", "height", "label")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"resolution": ("INT", {"default": 1280, "min": 64, "max": 8192}),
"divisible": ("INT", {"default": 64, "min": 8, "max": 256}),
"max_upscale": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 8.0, "step": 0.1}),
},
"optional": {"mask": ("MASK",)},
}
def run(self, image, resolution=1280, divisible=64, max_upscale=1.5, mask=None):
ih, iw = int(image.shape[1]), int(image.shape[2])
W, H = buckets.pick_bucket(iw, ih, resolution, divisible)
out_img, scale = fit_image(image, W, H)
if scale > max_upscale:
print(f"[BucketResize] cover scale {scale:.2f}x exceeds max_upscale "
f"{max_upscale} for {iw}x{ih} -> {W}x{H}")
out_mask = fit_mask(mask, W, H) if mask is not None \
else torch.zeros((out_img.shape[0], H, W), dtype=torch.float32)
return (out_img, out_mask, W, H, f"{W}x{H}")
NODE_CLASS_MAPPINGS = {"BucketResize": BucketResize}
NODE_DISPLAY_NAME_MAPPINGS = {"BucketResize": "Bucket Resize (Klein 9B)"}
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: BucketResize node (cover-crop onto Klein buckets)`
---
### Task 4: Register in `__init__.py` (MERGE)
**Files:** Modify `__init__.py`
**Step 1:** Re-Read `__init__.py`, then add inside the `if __package__:` block:
```python
from .gates.bucket_node import NODE_CLASS_MAPPINGS as _BUCKET_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _BUCKET_NAMES
```
and merge:
```python
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_BUCKET_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_BUCKET_NAMES}
```
(No routes/web — standard widgets only.)
**Step 2:** `$PY -c "import gates.bucket_node; print(gates.bucket_node.NODE_CLASS_MAPPINGS)"`.
**Step 3:** Full suite green: `$PY -m pytest tests/ -v`.
**Step 4: Commit** `feat: register BucketResize`
---
### Task 5: Live smoke test in ComfyUI
Restart ComfyUI. Build: `Folder Image Loader → Bucket Resize → PreviewImage` (+ a SaveImage
using `label` for the filename). Verify:
- [ ] "Bucket Resize (Klein 9B)" appears under "Datasete Gates".
- [ ] A square-ish image → `1280x1280`; a 2:1 image → `1792x896`; a tall image → a portrait
bucket — all ÷64, output exactly bucket-sized.
- [ ] An odd size (e.g. 1333×777) lands on-grid with a clean center-crop.
- [ ] Feeding a mask (e.g. from the loader's alpha) → mask comes out aligned at bucket size.
- [ ] `width`/`height`/`label` outputs match the preview.
- [ ] A small input triggers the console `max_upscale` warning but still outputs on-grid.
**Commit** (if fixes) `fix: bucket resize live-test adjustments`
---
## Definition of done
- `$PY -m pytest tests/test_buckets.py tests/test_bucket_node.py -v` green; full `tests/` green.
- `pick_bucket` reproduces the spec table; outputs are always ÷divisible and ≤ budget.
- Manual checklist passes: on-grid output, aligned mask, correct label, upscale warning.
@@ -0,0 +1,95 @@
# Image Gate — Send/Get Bus (teleport + checkpoint) — Design
Date: 2026-06-21
Status: Approved (brainstorming complete, ready for implementation plan)
## 1. Purpose
Let Image Gates pass images to each other by **name** through a disk-backed bus, so you can
**re-enter the pipeline at a gate** after manual editing/looking — without dragging wires and
without creating graph cycles. A gate **auto-publishes** its passed image (+ mask) to a named
slot; another gate (or a fresh workflow) **loads** that slot to resume from that point.
This is an enhancement to the existing `Image Gate (Manual Router)` — no new node.
## 2. Why no wire / no cycle
ComfyUI graphs must be acyclic; a real wire from a downstream gate's output back into an
upstream gate is a cycle and is rejected at validation. The bus links sender↔receiver by a
**string id**, so there is no live wire and no cycle. "Ignore on the normal path" falls out
naturally from making `image` optional (see §4).
## 3. Changes to the Image Gate
New ports/widgets (all backward compatible):
| Port | Type | Description |
|------|------|-------------|
| `image` | IMAGE | **now optional.** Wired → normal path. Empty → load from `get_id`. |
| `send_id` | STRING (widget) | If non-empty, on every **pass** the chosen image + mask are written to the bus slot `send_id` (latest-wins). Empty = don't publish. |
| `get_id` | STRING (widget, dropdown) | Used only when `image` is **not** connected: load the latest image + mask from this bus slot, then gate as usual. Dropdown lists existing bus ids. |
Existing inputs (`routes`) and outputs (`mask`, `route_1..route_10`) are unchanged.
## 4. Run logic
```
base = input/gate_bus
image, loaded_mask = resolve_source(base, image, get_id)
# image given -> (image, None) [normal path; get ignored]
# else get_id -> load (image, mask) from bus slot [re-entry]
# else -> nothing: block all routes silently, return zero mask
pause + wait (Stop -> InterruptProcessingException) [unchanged]
mask = painted-at-gate OR loaded_mask OR zeros [precedence]
if send_id: write image+mask to bus[send_id] [auto-publish on pass]
return (mask,) + route_tuple(chosen) [unchanged routing]
```
`IS_CHANGED` stays `nan` (always pauses). A gate with no image and no valid `get_id` is a
silent no-op (all routes `ExecutionBlocker`, zero mask) so it never breaks a graph.
## 5. Bus storage
```
input/gate_bus/<id>/
├── image.png # latest passed image for this slot
└── mask.png # its mask (white = painted)
```
Latest-wins (overwrite). `id` is the human-chosen name. Survives restart → cross-run resume.
## 6. Frontend (`web/image_gate.js`)
- Make the `image` input optional (litegraph) — the node works with it empty.
- `send_id`: a plain text widget.
- `get_id`: render as a **dropdown** populated from `GET /datasete_gate/bus/list`
(refresh when opened), plus free-text entry.
- Pause/preview UI unchanged — `send_preview` runs after the source is resolved, so
get-loaded images preview correctly.
## 7. Code shape
- `gates/imagebus.py` *(new, stdlib)* — slot paths, `has`, `ensure_dir`, `list_ids`,
`delete_id`. Unit-testable.
- `gates/imaging.py` *(additive)*`save_image_tensor`, `save_mask_tensor` (mirror the
existing loaders). Unit-testable with torch.
- `gates/gate.py` *(additive)*`bus_save`/`bus_load`, pure `resolve_source`, and the
`run()` wiring (optional image, publish on pass). comfy imports stay lazy.
- `gates/gates_compat.py` *(additive)*`gate_bus_base()``input/gate_bus`.
- `gates/gate_server.py` *(additive)*`GET /datasete_gate/bus/list`.
## 8. Edge cases
- `image` empty + `get_id` empty/missing → silent no-op (no pause, all blocked).
- Mask precedence: gate-painted > loaded-from-bus > zeros.
- Same `send_id` from multiple gates → latest pass wins (documented).
- `get_id` referencing a deleted slot → treated as missing (no-op).
- Cross-run: publish in run A, load in run B (even after restart) — that's the whole point.
## 9. Testing
- pytest: `imagebus` (paths/has/list/delete); `imaging` save→load round-trip (shapes, mask
polarity); `gate.resolve_source` (image wins / get loads / nothing → None); `bus_save`+
`bus_load` round-trip.
- Manual (live): publish at gate A (`send_id=cp1`), then a gate with empty image +
`get_id=cp1` loads it (even in a new workflow), edit mask, route onward; dropdown lists ids;
normal wired path ignores the bus.
@@ -0,0 +1,376 @@
# Image Gate Send/Get Bus Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Extend `Image Gate (Manual Router)` so it can auto-publish a passed image+mask to a named disk bus (`send_id`) and, when its `image` input is empty, load from a named slot (`get_id`) — enabling wireless, cycle-free "restart from the gate point" across runs.
**Architecture:** A pure stdlib `gates/imagebus.py` manages slot dirs under `input/gate_bus/<id>/`. `gates/imaging.py` gains tensor PNG savers mirroring its loaders. `gates/gate.py` gains `bus_save`/`bus_load` + a pure `resolve_source`, and `run()` makes `image` optional, loads from `get_id` when absent, and publishes to `send_id` on pass. A `GET /datasete_gate/bus/list` route feeds the `get_id` dropdown.
**Tech Stack:** Python 3.12, torch 2.8, Pillow, numpy, aiohttp; pytest 9; vanilla JS.
---
## Conventions (read once)
- **Test python:** `/media/p5/miniforge3/bin/python` (`PY=...`).
- **Run tests:** `cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_imagebus.py tests/test_gate.py tests/test_imaging.py -v`
- All edits to `gate.py`, `imaging.py`, `gates_compat.py`, `gate_server.py` are **additive**
re-Read first, keep the existing Image Gate behavior, run full suite after.
- `gates/imagebus.py` stays stdlib-only. `gate.py` keeps comfy imports lazy (inside `run`).
- Bus base dir = `gates_compat.gate_bus_base()` = `input/gate_bus`.
- Commit style: Conventional Commits + repo Co-Authored-By trailer; stage only this feature's paths.
---
### Task 1: `gates_compat.py` — `gate_bus_base()`
**Files:** Modify `gates/gates_compat.py`
**Step 1:** Re-Read the file, then append (mirrors `grid_pool_base`):
```python
def gate_bus_base():
import folder_paths
return os.path.join(folder_paths.get_input_directory(), "gate_bus")
```
**Step 2:** Verify import: `$PY -c "import gates.gates_compat as c; print(hasattr(c,'gate_bus_base'))"``True`.
**Step 3: Commit** `feat: gate_bus_base() path helper`
---
### Task 2: `imagebus.py` — slot paths + list/has/delete
**Files:** Create `gates/imagebus.py`; Test `tests/test_imagebus.py`
**Step 1: Failing test**
```python
# tests/test_imagebus.py
from gates import imagebus as ib
def test_paths(tmp_path):
base = str(tmp_path)
assert ib.image_path(base, "cp1").name == "image.png"
assert ib.mask_path(base, "cp1").name == "mask.png"
assert ib.bus_dir(base, "cp1").name == "cp1"
def test_has_and_ensure(tmp_path):
base = str(tmp_path)
assert ib.has(base, "cp1") is False
ib.ensure_dir(base, "cp1")
ib.image_path(base, "cp1").write_bytes(b"x")
assert ib.has(base, "cp1") is True
def test_list_ids_only_populated(tmp_path):
base = str(tmp_path)
ib.ensure_dir(base, "empty") # dir but no image.png
ib.ensure_dir(base, "cp1"); ib.image_path(base, "cp1").write_bytes(b"x")
ib.ensure_dir(base, "cp2"); ib.image_path(base, "cp2").write_bytes(b"y")
assert ib.list_ids(base) == ["cp1", "cp2"]
def test_delete(tmp_path):
base = str(tmp_path)
ib.ensure_dir(base, "cp1"); ib.image_path(base, "cp1").write_bytes(b"x")
ib.delete_id(base, "cp1")
assert not ib.bus_dir(base, "cp1").exists()
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# gates/imagebus.py
"""Disk-backed image bus for Image Gate send/get. Stdlib only."""
import shutil
from pathlib import Path
def bus_dir(base, bus_id):
return Path(base) / bus_id
def image_path(base, bus_id):
return bus_dir(base, bus_id) / "image.png"
def mask_path(base, bus_id):
return bus_dir(base, bus_id) / "mask.png"
def has(base, bus_id):
return image_path(base, bus_id).exists()
def ensure_dir(base, bus_id):
d = bus_dir(base, bus_id)
d.mkdir(parents=True, exist_ok=True)
return d
def list_ids(base):
p = Path(base)
if not p.is_dir():
return []
return sorted(d.name for d in p.iterdir() if d.is_dir() and (d / "image.png").exists())
def delete_id(base, bus_id):
d = bus_dir(base, bus_id)
if d.exists():
shutil.rmtree(d)
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: imagebus slot store`
---
### Task 3: `imaging.py` — tensor PNG savers
**Files:** Modify `gates/imaging.py`; Test `tests/test_imaging.py`
**Step 1: Failing test** (add)
```python
import torch
from gates import imaging
def test_save_load_image_roundtrip(tmp_path):
img = torch.zeros((1, 6, 4, 3), dtype=torch.float32)
img[0, 0, 0, 0] = 1.0 # red corner
p = str(tmp_path / "image.png")
imaging.save_image_tensor(p, img)
back = imaging.load_image_tensor(p)
assert back.shape == (1, 6, 4, 3)
assert float(back[0, 0, 0, 0]) > 0.99
def test_save_load_mask_roundtrip(tmp_path):
mask = torch.ones((1, 6, 4), dtype=torch.float32)
p = str(tmp_path / "mask.png")
imaging.save_mask_tensor(p, mask)
back = imaging.load_mask_tensor(p, 6, 4)
assert back.shape == (1, 6, 4)
assert float(back.min()) > 0.99
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append to `imaging.py`)**
```python
def save_image_tensor(path, image):
arr = (image[0].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
Image.fromarray(arr).save(path)
def save_mask_tensor(path, mask):
arr = (mask[0].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
Image.fromarray(arr, mode="L").save(path)
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: imaging tensor PNG savers`
---
### Task 4: `gate.py` — `bus_save` / `bus_load` / `resolve_source`
**Files:** Modify `gates/gate.py`, `tests/test_gate.py`
**Step 1: Failing test**
```python
import torch
from gates import gate
def _img(r=1.0):
t = torch.zeros((1, 6, 4, 3), dtype=torch.float32)
t[0, 0, 0, 0] = r
return t
def test_bus_save_load_roundtrip(tmp_path):
base = str(tmp_path)
gate.bus_save(base, "cp1", _img(1.0), torch.ones((1, 6, 4)))
img, mask = gate.bus_load(base, "cp1")
assert img.shape == (1, 6, 4, 3) and float(img[0, 0, 0, 0]) > 0.99
assert mask.shape == (1, 6, 4) and float(mask.min()) > 0.99
def test_resolve_source_image_wins(tmp_path):
img = _img()
out_img, out_mask = gate.resolve_source(str(tmp_path), img, "cp1")
assert out_img is img and out_mask is None # given image ignores the bus
def test_resolve_source_loads_from_get(tmp_path):
base = str(tmp_path)
gate.bus_save(base, "cp1", _img(1.0), torch.zeros((1, 6, 4)))
out_img, out_mask = gate.resolve_source(base, None, "cp1")
assert out_img.shape == (1, 6, 4, 3) and out_mask.shape == (1, 6, 4)
def test_resolve_source_nothing(tmp_path):
assert gate.resolve_source(str(tmp_path), None, "") == (None, None)
assert gate.resolve_source(str(tmp_path), None, "missing") == (None, None)
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append to `gate.py`; add `from . import imagebus, imaging` at top)**
```python
def bus_save(base, bus_id, image, mask):
imagebus.ensure_dir(base, bus_id)
imaging.save_image_tensor(str(imagebus.image_path(base, bus_id)), image)
imaging.save_mask_tensor(str(imagebus.mask_path(base, bus_id)), mask)
def bus_load(base, bus_id):
img = imaging.load_image_tensor(str(imagebus.image_path(base, bus_id)))
h, w = int(img.shape[1]), int(img.shape[2])
mp = imagebus.mask_path(base, bus_id)
mask = imaging.load_mask_tensor(str(mp) if mp.exists() else None, h, w)
return img, mask
def resolve_source(base, image, get_id):
if image is not None:
return image, None
if get_id and imagebus.has(base, get_id):
return bus_load(base, get_id)
return None, None
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: gate bus_save/bus_load/resolve_source`
---
### Task 5: `gate.py` — wire send/get into `ImageGate` (MERGE)
**Files:** Modify `gates/gate.py`, `tests/test_gate.py`
**Step 1: Failing test** (input shape)
```python
def test_image_gate_optional_inputs():
it = gate.ImageGate.INPUT_TYPES()
assert "image" in it["optional"]
assert "send_id" in it["optional"] and "get_id" in it["optional"]
assert "routes" in it["required"]
```
**Step 2: Run → FAIL.**
**Step 3: Implement** — re-Read `gate.py`, then:
- `INPUT_TYPES`:
```python
return {
"required": {"routes": ("INT", {"default": 2, "min": 1, "max": MAX_ROUTES})},
"optional": {
"image": ("IMAGE",),
"send_id": ("STRING", {"default": ""}),
"get_id": ("STRING", {"default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
```
- `run` signature + body:
```python
def run(self, routes, unique_id, image=None, send_id="", get_id=""):
from comfy_execution.graph_utils import ExecutionBlocker
from . import gate_server
from .gates_compat import gate_bus_base
base = gate_bus_base()
image, loaded_mask = resolve_source(base, image, get_id)
blocker = ExecutionBlocker(None)
if image is None: # nothing to gate -> silent no-op
return (torch.zeros((1, 1, 1), dtype=torch.float32),) + tuple(
blocker for _ in range(MAX_ROUTES))
gate_bus.GateBus.arm(unique_id)
gate_server.send_preview(unique_id, image, routes)
try:
chosen_1 = gate_bus.GateBus.wait(unique_id)
except gate_bus.GateCancelled:
import comfy.model_management as mm
raise mm.InterruptProcessingException()
painted = gate_bus.GateBus.pop_mask(unique_id)
if painted:
mask = mask_from_stash(painted, image)
elif loaded_mask is not None:
mask = loaded_mask
else:
mask = mask_from_stash(None, image)
if send_id:
bus_save(base, send_id, image, mask)
chosen = max(0, min(chosen_1 - 1, routes - 1))
return (mask,) + route_tuple(chosen, image, blocker, MAX_ROUTES)
```
**Step 4: Run → PASS** (existing gate tests still pass).
**Step 5: Commit** `feat: Image Gate send_id/get_id bus (optional image, publish on pass)`
---
### Task 6: `gate_server.py` — bus list route
**Files:** Modify `gates/gate_server.py`
**Step 1:** Re-Read, then append (additive):
```python
@routes.get("/datasete_gate/bus/list")
async def _bus_list(request):
from .gates_compat import gate_bus_base
from . import imagebus
return web.json_response({"ids": imagebus.list_ids(gate_bus_base())})
```
**Step 2:** Full suite green: `$PY -m pytest tests/ -v`.
**Step 3: Commit** `feat: gate bus/list route for get_id dropdown`
---
### Task 7: `web/image_gate.js` — optional image + send/get widgets
**Files:** Modify `web/image_gate.js`
- Ensure the node tolerates an **empty `image` input** (it's optional now).
- `send_id`: leave as a plain text widget.
- `get_id`: turn into a **dropdown** populated from `GET /datasete_gate/bus/list` (fetch on
node create and when the widget is opened/clicked); allow free-text too.
- No change to the pause/preview flow — preview still arrives from the server after the
source is resolved (so get-loaded images preview fine).
**Manual note:** verify the dropdown lists published ids and refreshes after a pass elsewhere.
**Commit** `feat: image gate frontend — send_id widget + get_id dropdown`
---
### Task 8: Live smoke test in ComfyUI
Restart ComfyUI. Verify:
- [ ] Existing gate with a wired `image` works exactly as before (bus ignored).
- [ ] Set `send_id=cp1` on a gate, pass an image → `input/gate_bus/cp1/{image,mask}.png` appear.
- [ ] A second gate with **no image wired** and `get_id=cp1` → loads that image (+ mask),
pauses, and routes onward.
- [ ] Works in a **new workflow** / after a restart (cross-run resume).
- [ ] `get_id` dropdown lists existing bus ids.
- [ ] Gate with no image and no/invalid `get_id` → silent no-op (nothing downstream runs).
- [ ] Mask precedence: paint at the get-gate overrides the loaded mask.
**Commit** (if fixes) `fix: image gate bus live-test adjustments`
---
## Definition of done
- `$PY -m pytest tests/test_imagebus.py tests/test_imaging.py tests/test_gate.py -v` green;
full `tests/` green (existing gate/pool/loader/text unaffected).
- Manual checklist passes: publish on pass, get-load (incl. cross-run), dropdown, optional
image, mask precedence, silent no-op.
@@ -0,0 +1,83 @@
# Image Gate (Manual Router) — Design
Date: 2026-06-21
Status: Approved (brainstorming complete, ready for implementation plan)
## 1. Purpose
An interactive "image chooser on steroids": during a prompt run the node **pauses**,
shows the incoming image with a row of labeled **route buttons**, and waits for a human
click. Clicking **route K** sends the image down output K (all other route branches are
silently skipped). A **Stop** button cancels the whole run. Optionally, an **Edit mask**
button opens ComfyUI's MaskEditor on the image and the painted mask is emitted on a
single `mask` output. Built for manual dataset sorting/gating in the "Dataset Gates" suite.
Third node in the `ComfyUI-Datasete-Gates` package.
## 2. IO
| dir | name | type | notes |
|---|---|---|---|
| in | `image` | IMAGE | the image (or batch, routed as one unit) |
| widget | `routes` | INT, default 2, 1..10 | number of visible route buttons/outputs |
| widget | per-route labels | (frontend) | editable, default `1..N`; rename the visible output slots |
| hidden | `unique_id` | UNIQUE_ID | node id, used to key the pause/choice |
| out | `mask` | MASK | **fixed slot 0**; painted at the gate, zeros (sized to image) if none |
| out | `route_1 … route_10` | IMAGE | dynamic; JS shows only `routes` of them, labeled |
`RETURN_TYPES = ("MASK",) + ("IMAGE",)*10`. The node always returns all 11 outputs; the
chosen route carries the image, every other route returns `ExecutionBlocker(None)`. JS
hides the unused slots (>`routes`); their `ExecutionBlocker` returns are harmless.
## 3. Behavior (the pause)
On execute:
1. Push the image to the UI (`PromptServer.send_sync`, base64 or temp file) so the node
body shows the preview + the N labeled route buttons + **🖌 Edit mask** + **■ Stop**.
2. **Block** the executor thread on our own `GateBus.wait(unique_id)` (a `MessageHolder`-
style singleton in a `sleep(0.1)` loop; separate namespace from cg-image-picker).
3. Resolve:
- **route K** → image to output `K`, `ExecutionBlocker(None)` to the other routes;
`mask` = the painted mask (or zeros).
- **🖌 Edit mask** → opens MaskEditor (reuse the pool node's clipspace flow); the mask
is POSTed to `/datasete_gate/mask` keyed by `unique_id` and picked up on resume.
- **■ Stop** → cancel the prompt cleanly via
`comfy.model_management.InterruptProcessingException` (confirm exact symbol in plan).
`IS_CHANGED` returns `nan` → the gate pauses on **every** run (never cached).
## 4. Why the global mask is safe
Verified in `execution.py:257-266` + `305-306`: if **any** input of a node is an
`ExecutionBlocker`, the node is skipped and the blocker propagates to all its outputs.
So a non-chosen route's downstream (which consumes the blocked routed image) never runs,
regardless of the live `mask` value. Caveat: a node wired to `mask` *only* (no routed
image) would run unconditionally — not the intended wiring.
## 5. Code shape (same package)
- `gates/gate.py``ImageGate` node: `INPUT_TYPES`, `IS_CHANGED=nan`, `run()` (push
preview → block → route via `ExecutionBlocker`). Pure helper `route_tuple(chosen, image,
blocker, max_routes)` for unit testing.
- `gates/gate_server.py``GateBus` (start/put/wait/cancel) + mask stash; aiohttp routes
`/datasete_gate/choice` and `/datasete_gate/mask`; `send_preview()` helper.
- `web/image_gate.js` — dynamic labeled outputs (show `routes` of 10), preview render,
route/stop/mask buttons, posts the choice; reuses the pool's MaskEditor helper.
## 6. Edge cases
- `routes` changed between runs → JS re-syncs visible slots; Python clamps `chosen` to
`routes`.
- Stop while no mask painted → clean interrupt, no output.
- Multiple gates in one graph → execute sequentially (single executor thread), so only one
blocks at a time; still keyed by `unique_id`.
- Batch input → previewed as the first image / small grid; routed as one unit.
- External queue-cancel → `GateBus` honors the cancel flag and raises.
## 7. Testing
- pytest: `route_tuple` (image at chosen, blocker elsewhere, correct length); `GateBus`
(pre-seeded message returns; cancel raises; `start` resets); mask zero-fallback sizing.
- Manual (live): pause appears, buttons labeled, click routes image to the right branch
and only that branch runs; Edit mask round-trips and feeds `mask`; Stop cancels cleanly;
changing `routes` adds/removes slots.
@@ -0,0 +1,431 @@
# Image Gate (Manual Router) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a ComfyUI custom node `Image Gate (Manual Router)` that pauses a running prompt, shows the image with up to 10 labeled route buttons + a mask-edit + a stop button, and routes the image down the clicked output (others `ExecutionBlocker`-ed), emitting any gate-painted mask on a fixed `mask` output.
**Architecture:** A pure, torch-free `gates/gate_bus.py` (a `MessageHolder`-style blocking waiter + mask stash) is unit-testable without ComfyUI. `gates/gate.py` holds the node plus pure helpers (`route_tuple`, `mask_from_stash`); it imports `ExecutionBlocker`/`model_management` lazily so tests don't need comfy. `gates/gate_server.py` is the aiohttp glue (choice/mask routes + `send_preview`). `web/image_gate.js` renders preview + dynamic labeled outputs + buttons and posts the choice; it reuses the pool node's MaskEditor helper.
**Tech Stack:** Python 3.12, torch 2.8, Pillow, numpy, aiohttp; 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_gate_bus.py tests/test_gate.py -v`
- **Concurrency:** other sessions may share this working tree. Stage only this node's paths
when committing; re-Read `__init__.py` before editing (Task 6) and *extend*, don't overwrite.
- `gates/gate_bus.py` MUST be import-safe without comfy/torch (stdlib only).
- `gates/gate.py` MUST import `ExecutionBlocker` and `comfy.model_management` **lazily inside
`run()`** (and `send_preview` lazily) so `import gates.gate` works under pytest.
- Mask convention: grayscale `L`, white = painted; zeros sized to the image if none.
- Commit style: Conventional Commits + repo Co-Authored-By trailer.
- `MAX_ROUTES = 10`.
---
### Task 1: `gate_bus.py` — `GateBus` (arm/put/wait/cancel)
**Files:** Create `gates/gate_bus.py`; Test `tests/test_gate_bus.py`
**Step 1: Failing test**
```python
# tests/test_gate_bus.py
import pytest
from gates import gate_bus as gb
def test_put_and_wait_returns_choice():
gb.GateBus.arm("7")
gb.GateBus.put("7", "3")
assert gb.GateBus.wait("7") == 3
def test_wait_consumes_message():
gb.GateBus.arm("7")
gb.GateBus.put("7", "2")
gb.GateBus.wait("7")
assert "7" not in gb.GateBus.messages
def test_cancel_raises_and_resets():
gb.GateBus.arm("7")
gb.GateBus.put("7", "__cancel__")
with pytest.raises(gb.GateCancelled):
gb.GateBus.wait("7")
assert gb.GateBus.cancelled is False # reset after raising
def test_arm_clears_stale_state():
gb.GateBus.put("1", "5")
gb.GateBus.cancelled = True
gb.GateBus.arm("1")
assert "1" not in gb.GateBus.messages
assert gb.GateBus.cancelled is False
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# gates/gate_bus.py
"""Blocking choice bus for the Image Gate node. Stdlib only — no comfy/torch."""
import time
class GateCancelled(Exception):
pass
class GateBus:
messages = {} # node_id(str) -> chosen int (1-based)
masks = {} # node_id(str) -> PNG bytes
cancelled = False
@classmethod
def arm(cls, node_id):
cls.messages.pop(str(node_id), None)
cls.masks.pop(str(node_id), None)
cls.cancelled = False
@classmethod
def put(cls, node_id, message):
if message == "__cancel__":
cls.cancelled = True
else:
cls.messages[str(node_id)] = int(message)
@classmethod
def wait(cls, node_id, period=0.1):
sid = str(node_id)
while sid not in cls.messages:
if cls.cancelled:
cls.cancelled = False
raise GateCancelled()
time.sleep(period)
return cls.messages.pop(sid)
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: gate_bus blocking choice waiter`
---
### Task 2: `gate_bus.py` — mask stash
**Files:** Modify `gates/gate_bus.py`, `tests/test_gate_bus.py`
**Step 1: Failing test**
```python
def test_mask_stash_roundtrip():
gb.GateBus.put_mask("9", b"PNGDATA")
assert gb.GateBus.pop_mask("9") == b"PNGDATA"
assert gb.GateBus.pop_mask("9") is None # popped
def test_arm_clears_mask():
gb.GateBus.put_mask("9", b"x")
gb.GateBus.arm("9")
assert gb.GateBus.pop_mask("9") is None
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append to `GateBus`)**
```python
@classmethod
def put_mask(cls, node_id, data):
cls.masks[str(node_id)] = data
@classmethod
def pop_mask(cls, node_id):
return cls.masks.pop(str(node_id), None)
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: gate_bus mask stash`
---
### Task 3: `gate.py` — `route_tuple` pure helper
**Files:** Create `gates/gate.py`; Test `tests/test_gate.py`
**Step 1: Failing test**
```python
# tests/test_gate.py
from gates import gate
def test_route_tuple_places_image_at_chosen():
B = object()
t = gate.route_tuple(2, "IMG", B, max_routes=5)
assert t == (B, B, "IMG", B, B)
def test_route_tuple_length_is_max():
B = object()
assert len(gate.route_tuple(0, "IMG", B, max_routes=10)) == 10
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# gates/gate.py
import io
import math
import numpy as np
import torch
from PIL import Image
from . import gate_bus
MAX_ROUTES = 10
def route_tuple(chosen, image, blocker, max_routes=MAX_ROUTES):
return tuple(image if i == chosen else blocker for i in range(max_routes))
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: gate route_tuple helper`
---
### Task 4: `gate.py` — `mask_from_stash`
**Files:** Modify `gates/gate.py`, `tests/test_gate.py`
**Step 1: Failing test**
```python
import io, torch
from PIL import Image
def test_mask_from_stash_none_is_zeros():
img = torch.zeros((1, 6, 4, 3))
m = gate.mask_from_stash(None, img)
assert m.shape == (1, 6, 4) and float(m.max()) == 0.0
def test_mask_from_stash_decodes_png():
buf = io.BytesIO(); Image.new("L", (4, 6), 255).save(buf, "PNG")
img = torch.zeros((1, 6, 4, 3))
m = gate.mask_from_stash(buf.getvalue(), img)
assert m.shape == (1, 6, 4) and float(m.min()) > 0.99
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append)**
```python
def mask_from_stash(data, image):
b, h, w = image.shape[0], image.shape[1], image.shape[2]
if not data:
return torch.zeros((b, h, w), dtype=torch.float32)
m = Image.open(io.BytesIO(data)).convert("L")
arr = np.array(m, dtype=np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: gate mask_from_stash (paint or zeros)`
---
### Task 5: `gate.py` — `ImageGate` node class
**Files:** Modify `gates/gate.py`, `tests/test_gate.py`
**Step 0: Verify the interrupt symbol** (so Stop cancels cleanly):
`grep -n "class InterruptProcessingException\|def interrupt_current_processing" /media/p5/Comfyui/comfy/model_management.py`
Use whatever exists (expected: `InterruptProcessingException`).
**Step 1: Failing test**
```python
import math
def test_is_changed_always_nan():
v = gate.ImageGate.IS_CHANGED(image=None, routes=2, unique_id="1")
assert math.isnan(v)
def test_return_types_shape():
assert gate.ImageGate.RETURN_TYPES[0] == "MASK"
assert len(gate.ImageGate.RETURN_TYPES) == gate.MAX_ROUTES + 1
assert all(t == "IMAGE" for t in gate.ImageGate.RETURN_TYPES[1:])
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append)**
```python
class ImageGate:
CATEGORY = "Datasete Gates"
FUNCTION = "run"
RETURN_TYPES = ("MASK",) + ("IMAGE",) * MAX_ROUTES
RETURN_NAMES = ("mask",) + tuple(f"route_{i + 1}" for i in range(MAX_ROUTES))
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"routes": ("INT", {"default": 2, "min": 1, "max": MAX_ROUTES}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan") # always pause; never cached
def run(self, image, routes, unique_id):
from comfy_execution.graph_utils import ExecutionBlocker
from . import gate_server
gate_bus.GateBus.arm(unique_id)
gate_server.send_preview(unique_id, image, routes)
try:
chosen_1 = gate_bus.GateBus.wait(unique_id)
except gate_bus.GateCancelled:
import comfy.model_management as mm
raise mm.InterruptProcessingException() # confirm symbol in Step 0
mask = mask_from_stash(gate_bus.GateBus.pop_mask(unique_id), image)
chosen = max(0, min(chosen_1 - 1, routes - 1))
blocker = ExecutionBlocker(None)
return (mask,) + route_tuple(chosen, image, blocker, MAX_ROUTES)
NODE_CLASS_MAPPINGS = {"ImageGate": ImageGate}
NODE_DISPLAY_NAME_MAPPINGS = {"ImageGate": "Image Gate (Manual Router)"}
```
**Step 4: Run → PASS.** (`run()` itself is covered by the live smoke test, not unit tests.)
**Step 5: Commit** `feat: ImageGate node — pause, route via ExecutionBlocker, mask out`
---
### Task 6: `gate_server.py` — routes + preview, and register (MERGE)
**Files:** Create `gates/gate_server.py`; Modify `__init__.py`
**Step 1: Implement `gates/gate_server.py`** (aiohttp glue — verified live, not unit-tested)
```python
# gates/gate_server.py
import base64
import io
import numpy as np
from aiohttp import web
from PIL import Image
from server import PromptServer
from .gate_bus import GateBus
routes = PromptServer.instance.routes
def send_preview(node_id, image, n_routes):
arr = (image[0].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
buf = io.BytesIO()
Image.fromarray(arr).save(buf, "PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
PromptServer.instance.send_sync(
"datasete-gate-show",
{"id": str(node_id), "image": b64, "routes": int(n_routes)},
)
@routes.post("/datasete_gate/choice")
async def _choice(request):
post = await request.post()
GateBus.put(post.get("id"), post.get("message"))
return web.json_response({})
@routes.post("/datasete_gate/mask")
async def _mask(request):
reader = await request.multipart()
node_id, data = None, None
async for part in reader:
if part.name == "id":
node_id = await part.text()
elif part.name == "mask":
data = await part.read(decode=False)
if node_id is not None:
GateBus.put_mask(node_id, data)
return web.json_response({})
```
**Step 2: Re-Read `__init__.py`** and extend the `if __package__:` block to merge the gate
node and import its server (registers routes):
```python
from .gates.gate import NODE_CLASS_MAPPINGS as _GATE_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _GATE_NAMES
from .gates import gate_server # noqa: F401 (registers /datasete_gate/* routes)
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_GATE_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_GATE_NAMES}
```
(Adapt to the file's current merge structure; the only requirement is the gate node ends up
in the mappings and `gate_server` is imported.)
**Step 3:** `$PY -c "import gates.gate; print(gates.gate.NODE_CLASS_MAPPINGS)"` → shows ImageGate.
**Step 4:** Full suite green: `$PY -m pytest tests/ -v`
**Step 5: Commit** `feat: gate server routes + preview + register ImageGate`
---
### Task 7: `web/image_gate.js` — preview, dynamic outputs, buttons
**Files:** Create `web/image_gate.js`
Implement an `app.registerExtension` for `ImageGate`:
- **Dynamic outputs:** on `nodeCreated` and when the `routes` widget changes, show only the
first `routes` of the 10 `route_*` outputs (hide/remove the rest); give each visible output
an editable label (default `1..N`) persisted in `widgets_values`; keep the `mask` output
(slot 0) always visible. (Reuse your existing dynamic-slot pattern.)
- **Preview + buttons:** listen for the `datasete-gate-show` socket event
(`api.addEventListener`); when it fires for this node's id, render the image in a DOM widget
with: one button per visible route (labeled), an **🖌 Edit mask** button, and a **■ Stop**
button.
- **Choice:** route button → POST `/datasete_gate/choice` `{id, message: <1-based index>}`.
Stop → POST `{id, message: "__cancel__"}`.
- **Mask:** 🖌 → open MaskEditor on the previewed image (reuse the pool node's clipspace
helper); on save, export the grayscale mask PNG and POST it to `/datasete_gate/mask`
(multipart `id`, `mask`) **before** clicking a route.
**Manual verification (live, Task 8 covers the run):** node shows N labeled outputs that
track the `routes` widget; labels persist across reload.
**Commit** `feat: image gate frontend — preview, dynamic outputs, route/stop/mask`
---
### Task 8: Live smoke test in ComfyUI
Restart ComfyUI (repo already symlinked into `custom_nodes`). Build: `Folder Image Loader →
Image Gate`, wire `route_1`/`route_2` to two `PreviewImage`/`SaveImage` nodes, `mask` to a
`MaskPreview`. Verify:
- [ ] "Image Gate (Manual Router)" appears under "Datasete Gates".
- [ ] Queue → execution **pauses**, image preview + labeled buttons + 🖌 + ■ appear.
- [ ] Click route 1 → only route-1's downstream runs; route-2's does not.
- [ ] Click route 2 → only route-2's downstream runs.
- [ ] 🖌 Edit mask → MaskEditor opens; paint, save; then click a route → `mask` output carries the painted mask; no mask painted → zeros.
- [ ] ■ Stop → the run cancels cleanly (no scary traceback; queue stops).
- [ ] Change `routes` from 2→4 → two more labeled outputs appear; reload keeps labels.
- [ ] Run twice in a row → it pauses **both** times (not cached).
**Commit** (if fixes) `fix: image gate live-test adjustments`
---
## Definition of done
- `$PY -m pytest tests/test_gate_bus.py tests/test_gate.py -v` green; full `tests/` green.
- Manual checklist passes: pause, route isolation (ExecutionBlocker), mask round-trip, clean Stop, dynamic labeled outputs.
@@ -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,84 @@
# Pool Profiles (companion node) — Design
Date: 2026-06-21
Status: Approved (brainstorming complete, ready for implementation plan)
## 1. Purpose
Make Image Pool contents durable and reusable across workflows via named **profiles**.
A companion node `Pool Profile` creates/selects/manages named profiles and feeds the chosen
one into an Image Pool node, so the same set of images (with their masks/labels) can be
reloaded in any workflow by picking the profile. Profiles are also portable (zip
export/import) to move between machines.
## 2. Storage / registry
`input/grid_pool/profiles.json` maps a friendly **name → stable id**:
```json
{ "profiles": [ {"id": "<uuid>", "name": "characters_A", "created": 1718960000} ] }
```
Each profile's data stays in the existing layout `input/grid_pool/<id>/` (manifest.json +
images + masks). **Backward compatible:** existing random-UUID pools are simply unregistered
ids and keep working unchanged.
## 3. Nodes
### `Pool Profile` (companion, new)
- Widgets: `profile` (dropdown of names, JS-populated) + hidden `profile_id` (JS-owned, like
`pool_id`).
- Buttons: **Create, Rename, Delete, Duplicate/Save-as, Export, Import**.
- Output: `POOL_PROFILE` = the selected profile id.
- `run()` returns `profile_id or "default"`; `IS_CHANGED` returns `profile_id` (so a
selection change re-runs downstream).
### `Image Pool (Grid)` (existing, change — backward compatible)
- New **optional input `profile`** (`POOL_PROFILE`).
- `run()`/`_resolve()`/`IS_CHANGED` use `effective = profile or pool_id` (connected id wins).
- With nothing connected, behaves exactly as today (per-node UUID).
## 4. Live edit-time sync (key UX)
Modeled on `ComfyUI-JSON-Manager/web/project_key.js`: when the companion's selection changes
(or on connect), it walks its `POOL_PROFILE` output links, sets each connected pool node's
hidden `pool_id` widget to the profile id, and calls that pool's refresh. So selecting a
profile instantly shows its images in the grid, and adds/masks land in that profile. The
pool JS exposes a `node._datasetePoolRefresh()` hook for the companion to call.
## 5. Server routes (`/grid_pool/profiles/*`)
`list` (GET), `create` `{name}`, `rename` `{id,name}`, `delete` `{id}`, `duplicate`
`{id,name}`, `export` (GET `?id=` → streams a zip), `import` (multipart zip [+name] → new id).
The route layer generates UUIDs; the pure layer takes ids as params (testable).
## 6. Code shape
- `gates/profiles.py` — pure stdlib: registry read/write (atomic), `find_by_id/name`,
`create/rename/delete/duplicate`, and `export_profile`/`import_profile` (zipfile). Unit-
testable with tmp dirs; no comfy/torch.
- `gates/profiles_routes.py` — aiohttp glue (uuid gen, file streaming).
- `gates/profile_node.py` — the `PoolProfile` node.
- `web/pool_profile.js` — dropdown + action buttons + cross-node propagation.
- `gates/node.py` + `web/grid_image_pool.js` — small additive tweak: optional `profile`
input, `effective` id, and the `_datasetePoolRefresh` hook.
## 7. Edge cases
- Duplicate/import name collision → auto-suffix `name (2)`; create/rename reject duplicates.
- Delete removes the dir (`shutil.rmtree`) and the registry entry.
- Corrupt/missing `profiles.json` → treated as empty registry.
- Import zip carries a `profile_meta.json` (original name) under an internal `pool/` prefix;
imported under a fresh id so it never clobbers an existing profile.
- Profile connected then disconnected → pool keeps the last id (the profile); no data loss.
## 8. Phasing & testing
- **Phase 1**: `profiles.py` (registry + create/select) + `PoolProfile` node + routes +
frontend dropdown + live sync into the pool.
- **Phase 2**: rename / delete / duplicate.
- **Phase 3**: export / import (portable zip).
- **Phase 4 (optional)**: "adopt" an existing unnamed pool into a profile.
Testing: pytest for `profiles.py` (CRUD, duplicate copies images, export→import round-trips a
profile with its files); manual for dropdown, cross-node propagation, and the zip UI.
@@ -0,0 +1,626 @@
# Pool Profiles Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add named, portable **profiles** to the Image Pool — a `Pool Profile` companion node (create/select/rename/delete/duplicate/export/import) that feeds a `POOL_PROFILE` id into the Image Pool node, with live edit-time grid switching.
**Architecture:** A pure stdlib `gates/profiles.py` manages a `profiles.json` registry (name→id) plus per-profile dir ops and zip export/import — fully unit-testable. `gates/profile_node.py` is the companion node (outputs the id). The existing pool node gains an optional `profile` input and uses `profile or pool_id`. `gates/profiles_routes.py` is the aiohttp glue (uuid gen + zip streaming). Frontend (`web/pool_profile.js`) drives a dropdown + action buttons and propagates the selected id into connected pool nodes (project_key.js style); a small `grid_image_pool.js` tweak accepts the input + exposes a refresh hook.
**Tech Stack:** Python 3.12 (stdlib: json/shutil/zipfile), aiohttp, 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_profiles.py -v`
- `gates/profiles.py` MUST be stdlib-only (no comfy/torch); ids are passed in as params
(UUIDs are generated in the route layer) so tests are deterministic.
- Edits to `gates/node.py`, `web/grid_image_pool.js`, `__init__.py` are **additive**
re-Read first, keep existing Image Pool behavior, run full suite after.
- Base dir for all profile ops = `gates_compat.grid_pool_base()` (= `input/grid_pool`).
- Concurrency: stage only this feature's paths per commit. Commit style: Conventional
Commits + repo Co-Authored-By trailer.
---
### Task 1: `profiles.py` — registry read/write + find helpers
**Files:** Create `gates/profiles.py`; Test `tests/test_profiles.py`
**Step 1: Failing test**
```python
# tests/test_profiles.py
from gates import profiles as pr
def test_empty_registry():
assert pr.empty_registry() == {"profiles": []}
def test_read_missing_is_empty(tmp_path):
assert pr.read_registry(str(tmp_path)) == {"profiles": []}
def test_write_then_read(tmp_path):
reg = {"profiles": [{"id": "a", "name": "n", "created": 1}]}
pr.write_registry(str(tmp_path), reg)
assert (tmp_path / "profiles.json").exists()
assert pr.read_registry(str(tmp_path)) == reg
def test_read_corrupt_is_empty(tmp_path):
(tmp_path / "profiles.json").write_text("{ not json")
assert pr.read_registry(str(tmp_path)) == {"profiles": []}
def test_find_helpers():
reg = {"profiles": [{"id": "a", "name": "x"}, {"id": "b", "name": "y"}]}
assert pr.find_by_id(reg, "b")["name"] == "y"
assert pr.find_by_name(reg, "x")["id"] == "a"
assert pr.find_by_id(reg, "z") is None
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# gates/profiles.py
"""Named-profile registry + dir ops for the Image Pool. Stdlib only."""
import json
import os
import shutil
import zipfile
from pathlib import Path
REGISTRY_NAME = "profiles.json"
def registry_path(base):
return Path(base) / REGISTRY_NAME
def empty_registry():
return {"profiles": []}
def read_registry(base):
p = registry_path(base)
if not p.exists():
return empty_registry()
try:
with open(p, "r", encoding="utf-8") as f:
reg = json.load(f)
if not isinstance(reg, dict) or "profiles" not in reg:
raise ValueError("bad registry")
return reg
except (ValueError, json.JSONDecodeError):
return empty_registry()
def write_registry(base, reg):
Path(base).mkdir(parents=True, exist_ok=True)
final = registry_path(base)
tmp = final.with_name(REGISTRY_NAME + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(reg, f, indent=2)
os.replace(tmp, final)
return reg
def find_by_id(reg, pid):
return next((p for p in reg["profiles"] if p["id"] == pid), None)
def find_by_name(reg, name):
return next((p for p in reg["profiles"] if p["name"] == name), None)
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles registry read/write + find`
---
### Task 2: `profiles.py` — `create_profile`
**Files:** Modify `gates/profiles.py`, `tests/test_profiles.py`
**Step 1: Failing test**
```python
def test_create_profile(tmp_path):
e = pr.create_profile(str(tmp_path), "setA", "id1", ts=10)
assert e == {"id": "id1", "name": "setA", "created": 10}
assert (tmp_path / "id1").is_dir()
assert pr.find_by_name(pr.read_registry(str(tmp_path)), "setA")["id"] == "id1"
def test_create_duplicate_name_raises(tmp_path):
import pytest
pr.create_profile(str(tmp_path), "setA", "id1")
with pytest.raises(ValueError):
pr.create_profile(str(tmp_path), "setA", "id2")
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append)**
```python
def create_profile(base, name, pid, ts=0):
reg = read_registry(base)
if find_by_name(reg, name):
raise ValueError(f"profile name already exists: {name}")
(Path(base) / pid).mkdir(parents=True, exist_ok=True)
entry = {"id": pid, "name": name, "created": ts}
reg["profiles"].append(entry)
write_registry(base, reg)
return entry
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles create_profile`
---
### Task 3: `profiles.py` — `rename_profile`
**Step 1: Failing test**
```python
def test_rename_profile(tmp_path):
pr.create_profile(str(tmp_path), "old", "id1")
e = pr.rename_profile(str(tmp_path), "id1", "new")
assert e["name"] == "new"
assert pr.find_by_name(pr.read_registry(str(tmp_path)), "new")["id"] == "id1"
def test_rename_to_existing_name_raises(tmp_path):
import pytest
pr.create_profile(str(tmp_path), "a", "id1")
pr.create_profile(str(tmp_path), "b", "id2")
with pytest.raises(ValueError):
pr.rename_profile(str(tmp_path), "id2", "a")
```
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
```python
def rename_profile(base, pid, name):
reg = read_registry(base)
entry = find_by_id(reg, pid)
if not entry:
raise KeyError(pid)
other = find_by_name(reg, name)
if other and other["id"] != pid:
raise ValueError(f"profile name already exists: {name}")
entry["name"] = name
write_registry(base, reg)
return entry
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles rename_profile`
---
### Task 4: `profiles.py` — `delete_profile`
**Step 1: Failing test**
```python
def test_delete_profile_removes_dir_and_entry(tmp_path):
pr.create_profile(str(tmp_path), "a", "id1")
(tmp_path / "id1" / "img_0001.png").write_bytes(b"x")
pr.delete_profile(str(tmp_path), "id1")
assert not (tmp_path / "id1").exists()
assert pr.find_by_id(pr.read_registry(str(tmp_path)), "id1") is None
```
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
```python
def delete_profile(base, pid):
reg = read_registry(base)
reg["profiles"] = [p for p in reg["profiles"] if p["id"] != pid]
write_registry(base, reg)
d = Path(base) / pid
if d.exists():
shutil.rmtree(d)
return reg
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles delete_profile`
---
### Task 5: `profiles.py` — `duplicate_profile`
**Step 1: Failing test**
```python
def test_duplicate_copies_images(tmp_path):
pr.create_profile(str(tmp_path), "src", "id1")
(tmp_path / "id1" / "img_0001.png").write_bytes(b"abc")
e = pr.duplicate_profile(str(tmp_path), "id1", "copy", "id2", ts=5)
assert e == {"id": "id2", "name": "copy", "created": 5}
assert (tmp_path / "id2" / "img_0001.png").read_bytes() == b"abc"
def test_duplicate_duplicate_name_raises(tmp_path):
import pytest
pr.create_profile(str(tmp_path), "src", "id1")
with pytest.raises(ValueError):
pr.duplicate_profile(str(tmp_path), "id1", "src", "id2")
```
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
```python
def duplicate_profile(base, src_id, name, new_id, ts=0):
reg = read_registry(base)
if not find_by_id(reg, src_id):
raise KeyError(src_id)
if find_by_name(reg, name):
raise ValueError(f"profile name already exists: {name}")
src = Path(base) / src_id
dst = Path(base) / new_id
if src.exists():
shutil.copytree(src, dst)
else:
dst.mkdir(parents=True, exist_ok=True)
entry = {"id": new_id, "name": name, "created": ts}
reg["profiles"].append(entry)
write_registry(base, reg)
return entry
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles duplicate_profile`
---
### Task 6: `profiles.py` — `export_profile` + `import_profile`
**Step 1: Failing test**
```python
def test_export_import_roundtrip(tmp_path):
src_base = str(tmp_path / "a"); dst_base = str(tmp_path / "b")
pr.create_profile(src_base, "setA", "id1", ts=1)
from pathlib import Path
(Path(src_base) / "id1" / "img_0001.png").write_bytes(b"hello")
zpath = str(tmp_path / "setA.zip")
pr.export_profile(src_base, "id1", zpath)
assert (tmp_path / "setA.zip").exists()
# import into a different base, fresh id
e = pr.import_profile(dst_base, zpath, "id99", ts=2)
assert e["id"] == "id99"
assert e["name"] == "setA" # name carried in zip meta
assert (Path(dst_base) / "id99" / "img_0001.png").read_bytes() == b"hello"
def test_import_name_collision_suffixes(tmp_path):
base = str(tmp_path)
pr.create_profile(base, "setA", "id1")
from pathlib import Path
(Path(base) / "id1" / "f.png").write_bytes(b"x")
z = str(tmp_path / "e.zip"); pr.export_profile(base, "id1", z)
e = pr.import_profile(base, z, "id2")
assert e["name"] == "setA (2)"
```
**Step 2: Run → FAIL.** **Step 3: Implement (append)**
```python
def export_profile(base, pid, dest_zip):
src = Path(base) / pid
if not src.exists():
raise KeyError(pid)
entry = find_by_id(read_registry(base), pid)
name = entry["name"] if entry else pid
with zipfile.ZipFile(dest_zip, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("profile_meta.json", json.dumps({"name": name}))
for f in src.rglob("*"):
if f.is_file():
z.write(f, arcname=str(Path("pool") / f.relative_to(src)))
return dest_zip
def import_profile(base, src_zip, new_id, name=None, ts=0):
reg = read_registry(base)
meta_name = None
dst = Path(base) / new_id
dst.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(src_zip) as z:
names = z.namelist()
if "profile_meta.json" in names:
meta_name = json.loads(z.read("profile_meta.json")).get("name")
for n in names:
if n.startswith("pool/") and not n.endswith("/"):
target = dst / n[len("pool/"):]
target.parent.mkdir(parents=True, exist_ok=True)
with z.open(n) as srcf, open(target, "wb") as out:
shutil.copyfileobj(srcf, out)
final = name or meta_name or new_id
candidate, i = final, 2
while find_by_name(reg, candidate):
candidate = f"{final} ({i})"
i += 1
entry = {"id": new_id, "name": candidate, "created": ts}
reg["profiles"].append(entry)
write_registry(base, reg)
return entry
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: profiles export/import (portable zip)`
---
### Task 7: `profile_node.py` — the `PoolProfile` node
**Files:** Create `gates/profile_node.py`; Test `tests/test_profile_node.py`
**Step 1: Failing test**
```python
# tests/test_profile_node.py
from gates import profile_node as pn
def test_io():
assert pn.PoolProfile.RETURN_TYPES == ("POOL_PROFILE",)
assert pn.PoolProfile.RETURN_NAMES == ("profile",)
def test_run_returns_id_or_default():
assert pn.PoolProfile().run(profile="setA", profile_id="id1") == ("id1",)
assert pn.PoolProfile().run(profile="", profile_id="") == ("default",)
def test_is_changed_tracks_id():
assert pn.PoolProfile.IS_CHANGED(profile="x", profile_id="id1") == "id1"
```
**Step 2: Run → FAIL.** **Step 3: Implement**
```python
# gates/profile_node.py
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
class PoolProfile:
CATEGORY = "Datasete Gates"
FUNCTION = "run"
RETURN_TYPES = ("POOL_PROFILE",)
RETURN_NAMES = ("profile",)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"profile": ("STRING", {"default": ""}), # name; JS renders a dropdown
"profile_id": ("STRING", {"default": ""}), # hidden, JS-owned id
},
}
def run(self, profile, profile_id=""):
return (profile_id or "default",)
@classmethod
def IS_CHANGED(cls, profile, profile_id="", **kwargs):
return profile_id
NODE_CLASS_MAPPINGS = {"PoolProfile": PoolProfile}
NODE_DISPLAY_NAME_MAPPINGS = {"PoolProfile": "Pool Profile"}
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: PoolProfile companion node`
---
### Task 8: `node.py` — optional `profile` input on the pool (MERGE)
**Files:** Modify `gates/node.py`, `tests/test_node.py`
**Step 1: Failing test** (add)
```python
def test_profile_input_overrides_pool_id(tmp_path, monkeypatch):
base = str(tmp_path / "grid_pool")
monkeypatch.setattr(node, "_grid_pool_base", lambda: base)
import io
from PIL import Image
from gates import pool
buf = io.BytesIO(); Image.new("RGB", (4, 6), (255, 0, 0)).save(buf, "PNG")
pool.add_image(base, "prof1", buf.getvalue(), ts=1) # images under the PROFILE id
n = node.GridImagePool()
# pool_id is "default" (empty) but profile points at prof1
img, mask, idx, count, label = n.run(index=-1, pool_id="default", profile="prof1")
assert count == 1 and idx == 0
```
**Step 2: Run → FAIL.**
**Step 3: Implement** — re-Read `gates/node.py`, then:
- In `INPUT_TYPES`, add an optional block:
```python
"optional": {"profile": ("POOL_PROFILE",)},
```
- Compute the effective id wherever `pool_id` is used. Simplest: update `_resolve`, `run`,
`IS_CHANGED` signatures to accept `profile=None` and resolve `effective = profile or pool_id`
at the top, then use `effective` instead of `pool_id`:
```python
def run(self, index, pool_id="default", profile=None):
effective = profile or pool_id
base, m, idx = self._resolve(index, effective)
...
d = pool.pool_dir(base, effective)
...
```
```python
@classmethod
def IS_CHANGED(cls, index, pool_id="default", profile=None, **kwargs):
effective = profile or pool_id
base, m, idx = cls._resolve(index, effective)
...
return imaging.change_hash(effective, f"{idx}:{m.get('active')}", mtimes)
```
(`_resolve` already takes the id as its 2nd arg — pass `effective`.)
**Step 4: Run → PASS** (existing pool tests still pass).
**Step 5: Commit** `feat: Image Pool accepts optional POOL_PROFILE (profile or pool_id)`
---
### Task 9: `profiles_routes.py` — aiohttp glue + register (MERGE)
**Files:** Create `gates/profiles_routes.py`; Modify `__init__.py`
**Step 1: Implement `gates/profiles_routes.py`** (verified live, not unit-tested)
```python
# gates/profiles_routes.py
import os
import tempfile
import uuid
from aiohttp import web
from server import PromptServer
from . import profiles
from .gates_compat import grid_pool_base
routes = PromptServer.instance.routes
def _base():
return grid_pool_base()
@routes.get("/grid_pool/profiles/list")
async def _list(request):
return web.json_response(profiles.read_registry(_base()))
@routes.post("/grid_pool/profiles/create")
async def _create(request):
body = await request.json()
e = profiles.create_profile(_base(), body["name"], uuid.uuid4().hex)
return web.json_response(e)
@routes.post("/grid_pool/profiles/rename")
async def _rename(request):
body = await request.json()
return web.json_response(profiles.rename_profile(_base(), body["id"], body["name"]))
@routes.post("/grid_pool/profiles/delete")
async def _delete(request):
body = await request.json()
return web.json_response(profiles.delete_profile(_base(), body["id"]))
@routes.post("/grid_pool/profiles/duplicate")
async def _duplicate(request):
body = await request.json()
e = profiles.duplicate_profile(_base(), body["id"], body["name"], uuid.uuid4().hex)
return web.json_response(e)
@routes.get("/grid_pool/profiles/export")
async def _export(request):
pid = request.query["id"]
reg = profiles.read_registry(_base())
entry = profiles.find_by_id(reg, pid)
fname = (entry["name"] if entry else pid) + ".zip"
tmp = os.path.join(tempfile.gettempdir(), f"profile_{pid}.zip")
profiles.export_profile(_base(), pid, tmp)
return web.FileResponse(tmp, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@routes.post("/grid_pool/profiles/import")
async def _import(request):
reader = await request.multipart()
tmp = os.path.join(tempfile.gettempdir(), f"import_{uuid.uuid4().hex}.zip")
async for part in reader:
if part.name == "file":
with open(tmp, "wb") as f:
while True:
chunk = await part.read_chunk()
if not chunk:
break
f.write(chunk)
e = profiles.import_profile(_base(), tmp, uuid.uuid4().hex)
return web.json_response(e)
```
**Step 2: Re-Read `__init__.py`** and merge the companion node + import the routes:
```python
from .gates.profile_node import NODE_CLASS_MAPPINGS as _PROF_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _PROF_NAMES
from .gates import profiles_routes # noqa: F401 (registers /grid_pool/profiles/*)
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_PROF_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_PROF_NAMES}
```
**Step 3:** `$PY -c "import gates.profile_node; print(gates.profile_node.NODE_CLASS_MAPPINGS)"`.
**Step 4:** Full suite green: `$PY -m pytest tests/ -v`.
**Step 5: Commit** `feat: profiles routes + register PoolProfile`
---
### Task 10: `web/pool_profile.js` — dropdown, actions, propagation
**Files:** Create `web/pool_profile.js`
`app.registerExtension` for `PoolProfile`:
- Replace the `profile` STRING widget with a **combo** populated from `GET
/grid_pool/profiles/list`; keep a hidden `profile_id` widget (mint/sync like `pool_id`).
- Action buttons: **Create** (prompt name → POST create), **Rename**, **Delete** (confirm),
**Duplicate** (prompt name), **Export** (`window.open('/grid_pool/profiles/export?id=...')`),
**Import** (hidden file input → multipart POST → refresh). After each, re-list + reselect.
- On selection change: set `profile_id`, then **propagate** — for each link from this node's
`POOL_PROFILE` output, find the target pool node, set its hidden `pool_id` widget to the id,
and call `node._datasetePoolRefresh?.()`. Also propagate on `onConnectionsChange` when the
output gets connected. (Model on `ComfyUI-JSON-Manager/web/project_key.js`.)
**Manual verify:** dropdown lists profiles; create adds one; selecting updates a connected
pool's grid live.
**Commit** `feat: pool profile frontend — dropdown, actions, cross-node propagation`
---
### Task 11: `grid_image_pool.js` — accept `profile` input + refresh hook
**Files:** Modify `web/grid_image_pool.js`
- Expose `node._datasetePoolRefresh = () => refresh(node)` in the pool's `nodeCreated` so the
companion can trigger a grid reload.
- No other change required: propagation sets the pool's existing `pool_id` widget, and the
grid/routes already key off `getPoolId(node)`. (Optional: when the `profile` input is
disconnected, leave the last id in place.)
**Manual verify:** selecting in the companion repaints the pool grid with the profile's images.
**Commit** `feat: pool grid exposes refresh hook for profile sync`
---
### Task 12: Live smoke test in ComfyUI
Restart ComfyUI. Drop `Pool Profile` + `Image Pool (Grid)`, wire profile→profile. Verify:
- [ ] Both nodes appear under "Datasete Gates".
- [ ] Create profile "A" → folder + registry entry appear; dropdown shows "A".
- [ ] Add images to the pool → they land under the profile's id dir.
- [ ] Create "B", switch → pool grid switches live (empty); switch back to "A" → images return.
- [ ] In a **new** workflow, add both nodes, select "A" → the same images load.
- [ ] Rename / Delete / Duplicate behave; duplicate copies images.
- [ ] Export "A" downloads a zip; Import it → a new profile with the same images.
- [ ] A pool with **no** profile connected still works (per-node UUID, unchanged).
- [ ] Run the graph → IMAGE/MASK come from the selected profile's active slot.
**Commit** (if fixes) `fix: pool profiles live-test adjustments`
---
## Definition of done
- `$PY -m pytest tests/test_profiles.py tests/test_profile_node.py -v` green; full `tests/`
green (existing pool/gate/loader unaffected).
- Manual checklist passes: create/select with live grid switch, reuse across workflows,
rename/delete/duplicate, export/import round-trip, backward-compatible unconnected pools.
+67
View File
@@ -0,0 +1,67 @@
# Text Gate (Manual Pass) — Design
Date: 2026-06-21
Status: Approved (brainstorming complete, ready for implementation plan)
## 1. Purpose
A simple blocking gate for text: during a run it **pauses**, shows the incoming text in an
**editable** box, and waits for a **Pass** click; on pass it emits the (possibly edited)
text. An optional any-type **signal** input lets you force execution order, and a
**signal** passthrough output lets you chain gates in a fixed sequence. Fourth node in the
`ComfyUI-Datasete-Gates` suite; reuses the Image Gate's `gate_bus` blocking infra.
## 2. IO
| dir | name | type | notes |
|---|---|---|---|
| in | `text` | STRING (`forceInput`) | incoming text from upstream |
| in (optional) | `signal` | `*` (AnyType) | accepts anything; only used to sequence this node after its source |
| hidden | `unique_id` | UNIQUE_ID | keys the pause |
| out | `text` | STRING | the edited text passed by the user |
| out | `signal` | `*` (AnyType) | passthrough of the input signal (fires on pass) → chain ordering |
## 3. Behavior (the pause)
On execute:
1. `GateBus.arm(unique_id)`; push the incoming text to the UI
(`PromptServer.send_sync("datasete-textgate-show", {id, text})`).
2. Frontend shows an **editable textarea** prefilled with the text + a **Pass** button.
3. **Block** on `GateBus.wait_payload(unique_id, should_cancel=...)` until Pass.
4. **Pass** → frontend POSTs the edited text to `/datasete_text_gate/pass`; the node returns
`(edited_text, signal)`.
`IS_CHANGED` returns `nan` → pauses on every run.
**No Stop button**, but the wait loop honors ComfyUI's global Cancel via a `should_cancel`
callback (`comfy.model_management.processing_interrupted`) so a queue-cancel can't deadlock
the gate; on cancel it raises `InterruptProcessingException`.
## 4. Reuse / changes to existing files (all additive)
- `gates/gate_bus.py` — add a **payload channel**: `payloads` dict, `put_payload`,
`wait_payload(..., should_cancel=None)`; `arm()` also clears `payloads`. Existing
int-choice/mask API untouched (Image Gate keeps working).
- `gates/gate_server.py` — add `send_text()` + route `POST /datasete_text_gate/pass`.
- `gates/textgate.py` *(new)*`AnyType("*")` + `ANY`; the `TextGate` node (lazy comfy
imports so it unit-tests without ComfyUI).
- `web/text_gate.js` *(new)* — listen for `datasete-textgate-show`, render editable textarea
+ Pass, POST the edited text.
- root `__init__.py` — merge `TextGate` into the mappings (gate_server already imported).
## 5. Edge cases
- Signal not connected → `signal=None`; output `None` (downstream still ordered by the
dependency).
- `AnyType` output value `None` connects fine (the `__ne__`→False trick makes type checks
pass), matching the installed custom-node convention.
- Empty incoming text → empty textarea; Pass emits whatever's there (possibly `""`).
- Global queue-cancel while blocked → clean interrupt (see §3).
## 6. Testing
- pytest: `gate_bus` payload roundtrip + `arm` clears payloads + `wait_payload` cancel via
flag and via `should_cancel`; `AnyType` equals-everything; `TextGate` RETURN_TYPES/NAMES
and `IS_CHANGED==nan`.
- Manual (live): pause shows editable text, edit + Pass emits edited text; signal in forces
order; signal out chains to a second gate; global Cancel unblocks cleanly.
@@ -0,0 +1,310 @@
# Text Gate (Manual Pass) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a ComfyUI node `Text Gate (Manual Pass)` that pauses a run, shows the incoming text in an editable box, and on a Pass click emits the (edited) text — plus an optional any-type `signal` input and a `signal` passthrough output for ordering.
**Architecture:** Reuse the Image Gate's `gates/gate_bus.py` blocking infra, extended with a string **payload channel** (`put_payload`/`wait_payload`) plus a `should_cancel` hook so the Pass-only gate still honors ComfyUI's global Cancel. The node `gates/textgate.py` defines an `AnyType("*")` and keeps comfy imports lazy so it unit-tests without ComfyUI. `gates/gate_server.py` gains `send_text()` + a pass route; `web/text_gate.js` renders the editable textarea + Pass.
**Tech Stack:** Python 3.12, aiohttp; pytest 9; vanilla JS frontend. (No torch needed for this node.)
---
## Conventions (read once)
- **Test python:** `/media/p5/miniforge3/bin/python` (`PY=...`).
- **Run tests:** `cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_gate_bus.py tests/test_textgate.py -v`
- Edits to `gate_bus.py` / `gate_server.py` / `__init__.py` are **additive** — re-Read each
first, keep the Image Gate working, and run the full suite after.
- `gate_bus.py` stays stdlib-only. `textgate.py` imports comfy lazily inside `run()`.
- Concurrency: other sessions may share this tree; stage only this node's paths per commit.
- Commit style: Conventional Commits + repo Co-Authored-By trailer.
---
### Task 1: `gate_bus.py` — string payload channel
**Files:** Modify `gates/gate_bus.py`, `tests/test_gate_bus.py`
**Step 1: Failing test**
```python
# add to tests/test_gate_bus.py
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")
```
**Step 2: Run → FAIL.** `$PY -m pytest tests/test_gate_bus.py -v`
**Step 3: Implement** — add a class attr and methods to `GateBus`, and clear payloads in `arm`:
```python
payloads = {} # node_id(str) -> arbitrary payload (e.g., edited text)
```
In `arm`, add:
```python
cls.payloads.pop(str(node_id), None)
```
New methods:
```python
@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)
```
**Step 4: Run → PASS** (and existing gate_bus tests still pass).
**Step 5: Commit** `feat: gate_bus payload channel + should_cancel`
---
### Task 2: `gate_bus.py` — `should_cancel` triggers cancel
**Files:** Modify `tests/test_gate_bus.py`
**Step 1: Failing test**
```python
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)
```
**Step 2: Run → PASS immediately** (implemented in Task 1). If it fails, fix Task 1's loop.
**Step 3:** (no code) — this task just locks the behavior with a test.
**Step 4: Commit** `test: gate_bus wait_payload honors should_cancel`
---
### Task 3: `textgate.py` — `AnyType` wildcard
**Files:** Create `gates/textgate.py`; Test `tests/test_textgate.py`
**Step 1: Failing test**
```python
# 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)
```
**Step 2: Run → FAIL.**
**Step 3: Implement**
```python
# 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("*")
```
**Step 4: Run → PASS.** **Step 5: Commit** `feat: textgate AnyType wildcard`
---
### Task 4: `textgate.py` — `TextGate` node
**Files:** Modify `gates/textgate.py`, `tests/test_textgate.py`
**Step 0: Verify the global-cancel getter:**
`grep -n "def processing_interrupted\|def interrupt_current_processing\|class InterruptProcessingException" /media/p5/Comfyui/comfy/model_management.py`
Use the boolean getter that exists (expected `processing_interrupted`).
**Step 1: Failing test**
```python
import math
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)
```
**Step 2: Run → FAIL.**
**Step 3: Implement (append)**
```python
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) # confirm symbol (Step 0)
except gate_bus.GateCancelled:
raise mm.InterruptProcessingException()
return (edited, signal)
NODE_CLASS_MAPPINGS = {"TextGate": TextGate}
NODE_DISPLAY_NAME_MAPPINGS = {"TextGate": "Text Gate (Manual Pass)"}
```
**Step 4: Run → PASS.** (`run()` covered by the live test, not unit tests.)
**Step 5: Commit** `feat: TextGate node — pause, editable pass-through, signal passthrough`
---
### Task 5: `gate_server.py` — text route + preview, and register (MERGE)
**Files:** Modify `gates/gate_server.py`, `__init__.py`
**Step 1: Re-Read `gates/gate_server.py`**, then append (additive — don't touch the image-gate routes):
```python
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({})
```
**Step 2: Re-Read `__init__.py`** and merge `TextGate` into the mappings (gate_server is
already imported for the Image Gate, so the new route registers automatically):
```python
from .gates.textgate import NODE_CLASS_MAPPINGS as _TEXT_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _TEXT_NAMES
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_TEXT_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_TEXT_NAMES}
```
**Step 3:** `$PY -c "import gates.textgate; print(gates.textgate.NODE_CLASS_MAPPINGS)"` → shows TextGate.
**Step 4:** Full suite green: `$PY -m pytest tests/ -v`
**Step 5: Commit** `feat: text gate server route + register TextGate`
---
### Task 6: `web/text_gate.js` — editable pause UI
**Files:** Create `web/text_gate.js`
Implement `app.registerExtension` for `TextGate`:
- Listen for the `datasete-textgate-show` socket event (`api.addEventListener`); when it
fires for this node's id, render a DOM widget: an **editable `<textarea>`** prefilled with
the event's `text`, and a **Pass** button.
- **Pass** → POST `/datasete_text_gate/pass` form-encoded `{id, text: <textarea value>}`,
then hide the pause UI.
- Keep it minimal — no dynamic outputs (the two outputs are static).
**Manual note:** verify the textarea grows/scrolls for long captions.
**Commit** `feat: text gate frontend — editable textarea + pass`
---
### Task 7: Live smoke test in ComfyUI
Restart ComfyUI. Build: a text source (e.g., `Folder Image Loader.text` or a primitive) →
`Text Gate` → a text consumer (ShowText/SaveText). Optionally wire a `signal` from one node
and the `signal` output to another. Verify:
- [ ] "Text Gate (Manual Pass)" appears under "Datasete Gates".
- [ ] Queue → pauses; editable textarea shows the incoming text.
- [ ] Edit the text, click **Pass** → downstream receives the **edited** text.
- [ ] Pauses again on a second run (not cached).
- [ ] `signal` input forces this node to run after its source; `signal` output triggers a
downstream node after pass (chain order holds).
- [ ] Hitting ComfyUI's global **Cancel** while paused unblocks cleanly (no deadlock, no
scary traceback).
**Commit** (if fixes) `fix: text gate live-test adjustments`
---
## Definition of done
- `$PY -m pytest tests/test_gate_bus.py tests/test_textgate.py -v` green; full `tests/` green
(Image Gate unaffected).
- Manual checklist passes: editable pause, edited pass-through, signal ordering, clean cancel.
@@ -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.
+72
View File
@@ -0,0 +1,72 @@
"""BucketResize node: cover-crop an image (and optional mask) onto a Klein
training bucket. Pure compute (torch + PIL); no comfy imports in run()."""
import numpy as np
import torch
from PIL import Image
from . import buckets
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def _resize_crop_pil(pil, new_w, new_h, left, top, W, H):
pil = pil.resize((new_w, new_h), Image.LANCZOS)
return pil.crop((left, top, left + W, top + H))
def fit_image(image, W, H):
"""image [B,H,W,3] -> [B,H,W,3] at (W,H) using the first image's geometry."""
b, ih, iw = image.shape[0], image.shape[1], image.shape[2]
new_w, new_h, left, top, scale = buckets.cover_crop_params(iw, ih, W, H)
out = []
for i in range(b):
arr = (image[i].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
pil = _resize_crop_pil(Image.fromarray(arr), new_w, new_h, left, top, W, H)
out.append(torch.from_numpy(np.array(pil, dtype=np.float32) / 255.0))
return torch.stack(out, 0), scale
def fit_mask(mask, W, H):
b, ih, iw = mask.shape[0], mask.shape[1], mask.shape[2]
new_w, new_h, left, top, _ = buckets.cover_crop_params(iw, ih, W, H)
out = []
for i in range(b):
arr = (mask[i].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
pil = _resize_crop_pil(Image.fromarray(arr), new_w, new_h, left, top, W, H)
out.append(torch.from_numpy(np.array(pil, dtype=np.float32) / 255.0))
return torch.stack(out, 0)
class BucketResize:
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "mask", "width", "height", "label")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"resolution": ("INT", {"default": 1280, "min": 64, "max": 8192}),
"divisible": ("INT", {"default": 64, "min": 8, "max": 256}),
"max_upscale": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 8.0, "step": 0.1}),
},
"optional": {"mask": ("MASK",)},
}
def run(self, image, resolution=1280, divisible=64, max_upscale=1.5, mask=None):
ih, iw = int(image.shape[1]), int(image.shape[2])
W, H = buckets.pick_bucket(iw, ih, resolution, divisible)
out_img, scale = fit_image(image, W, H)
if scale > max_upscale:
print(f"[BucketResize] cover scale {scale:.2f}x exceeds max_upscale "
f"{max_upscale} for {iw}x{ih} -> {W}x{H}")
out_mask = fit_mask(mask, W, H) if mask is not None \
else torch.zeros((out_img.shape[0], H, W), dtype=torch.float32)
return (out_img, out_mask, W, H, f"{W}x{H}")
NODE_CLASS_MAPPINGS = {"BucketResize": BucketResize}
NODE_DISPLAY_NAME_MAPPINGS = {"BucketResize": "Bucket Resize (Klein 9B)"}
+31
View File
@@ -0,0 +1,31 @@
"""Pure bucket math for KLEIN_BUCKET_SIZES.md. Stdlib only."""
import math
def pick_bucket(iw, ih, resolution=1280, divisible=64):
"""Choose the on-grid bucket (W,H), area <= resolution^2, nearest to the
image aspect (log distance; tie-break larger area)."""
budget = resolution * resolution
target = iw / ih
best = None
w = divisible
w_max = budget // divisible
while w <= w_max:
h = (budget // w // divisible) * divisible # largest on-grid h within budget
if h >= divisible:
err = abs(math.log(w / h) - math.log(target))
cand = (err, -(w * h), w, h) # min err, then max area
if best is None or cand < best:
best = cand
w += divisible
return best[2], best[3]
def cover_crop_params(iw, ih, W, H):
"""Cover-scale + centered crop to land (iw,ih) exactly on (W,H)."""
scale = max(W / iw, H / ih)
new_w = max(W, round(iw * scale))
new_h = max(H, round(ih * scale))
left = (new_w - W) // 2
top = (new_h - H) // 2
return new_w, new_h, left, top, scale
+67
View File
@@ -0,0 +1,67 @@
# gates/gate.py
import io
import math
import numpy as np
import torch
from PIL import Image
from . import gate_bus
MAX_ROUTES = 10
def route_tuple(chosen, image, blocker, max_routes=MAX_ROUTES):
return tuple(image if i == chosen else blocker for i in range(max_routes))
def mask_from_stash(data, image):
b, h, w = image.shape[0], image.shape[1], image.shape[2]
if not data:
return torch.zeros((b, h, w), dtype=torch.float32)
m = Image.open(io.BytesIO(data)).convert("L")
arr = np.array(m, dtype=np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
class ImageGate:
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))
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"routes": ("INT", {"default": 2, "min": 1, "max": MAX_ROUTES}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan") # always pause; never cached
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, should_cancel=mm.processing_interrupted)
except gate_bus.GateCancelled:
raise mm.InterruptProcessingException()
mask = mask_from_stash(gate_bus.GateBus.pop_mask(unique_id), image)
chosen = max(0, min(chosen_1 - 1, routes - 1))
blocker = ExecutionBlocker(None)
return (mask,) + route_tuple(chosen, image, blocker, MAX_ROUTES)
NODE_CLASS_MAPPINGS = {"ImageGate": ImageGate}
NODE_DISPLAY_NAME_MAPPINGS = {"ImageGate": "Image Gate (Manual Router)"}
+59
View File
@@ -0,0 +1,59 @@
"""Blocking choice bus for the Image Gate node. Stdlib only — no comfy/torch."""
import time
class GateCancelled(Exception):
pass
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
def put(cls, node_id, message):
if message == "__cancel__":
cls.cancelled = True
else:
cls.messages[str(node_id)] = int(message)
@classmethod
def wait(cls, node_id, period=0.1, should_cancel=None):
sid = str(node_id)
while sid not in cls.messages:
if cls.cancelled or (should_cancel is not None and should_cancel()):
cls.cancelled = False
raise GateCancelled()
time.sleep(period)
return cls.messages.pop(sid)
@classmethod
def put_mask(cls, node_id, data):
cls.masks[str(node_id)] = data
@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)
+57
View File
@@ -0,0 +1,57 @@
# gates/gate_server.py
import base64
import io
import numpy as np
from aiohttp import web
from PIL import Image
from server import PromptServer
from .gate_bus import GateBus
routes = PromptServer.instance.routes
def send_preview(node_id, image, n_routes):
arr = (image[0].cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
buf = io.BytesIO()
Image.fromarray(arr).save(buf, "PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
PromptServer.instance.send_sync(
"datasete-gate-show",
{"id": str(node_id), "image": b64, "routes": int(n_routes)},
)
@routes.post("/datasete_gate/choice")
async def _choice(request):
post = await request.post()
GateBus.put(post.get("id"), post.get("message"))
return web.json_response({})
@routes.post("/datasete_gate/mask")
async def _mask(request):
reader = await request.multipart()
node_id, data = None, None
async for part in reader:
if part.name == "id":
node_id = await part.text()
elif part.name == "mask":
data = await part.read(decode=False)
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({})
+70
View File
@@ -0,0 +1,70 @@
# gates/loader.py
import hashlib
import os
import numpy as np
import torch
from PIL import Image, ImageOps
from . import scan
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def load_image_and_mask(path):
img = Image.open(path)
img = ImageOps.exif_transpose(img)
arr = np.array(img.convert("RGB"), dtype=np.float32) / 255.0
image = torch.from_numpy(arr).unsqueeze(0) # [1,H,W,3]
h, w = arr.shape[0], arr.shape[1]
if "A" in img.getbands():
a = np.array(img.getchannel("A"), dtype=np.float32) / 255.0
mask = (1.0 - torch.from_numpy(a)).unsqueeze(0) # [1,H,W]
else:
mask = torch.zeros((1, h, w), dtype=torch.float32)
return image, mask
class FolderImageLoader:
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "STRING", "MASK", "STRING", "INT")
RETURN_NAMES = ("image", "text", "mask", "filename", "index")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder": ("STRING", {"default": ""}),
"index": ("INT", {"default": 0, "min": 0,
"max": 0xffffffffffffffff,
"control_after_generate": True}),
"depth": ("INT", {"default": 0, "min": -1, "max": 64}),
}
}
def run(self, folder, index, depth=0):
files = scan.list_images(folder, depth)
idx = scan.resolve_index(len(files), index)
path = files[idx]
image, mask = load_image_and_mask(path)
return (image, scan.read_sidecar(path), mask, scan.stem(path), idx)
@classmethod
def IS_CHANGED(cls, folder, index, depth=0, **kwargs):
try:
files = scan.list_images(folder, depth)
idx = scan.resolve_index(len(files), index)
path = files[idx]
sc = scan.sidecar_path(path)
parts = [folder, str(depth), str(idx),
str(os.path.getmtime(path)),
str(os.path.getmtime(sc)) if os.path.isfile(sc) else "0"]
except Exception as e: # surface errors as a changed hash, not a crash here
parts = [folder, str(depth), str(index), f"err:{e}"]
return hashlib.sha256("|".join(parts).encode()).hexdigest()
NODE_CLASS_MAPPINGS = {"FolderImageLoader": FolderImageLoader}
NODE_DISPLAY_NAME_MAPPINGS = {"FolderImageLoader": "Folder Image Loader"}
+16 -9
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")
@@ -26,6 +26,11 @@ class GridImagePool:
"index": ("INT", {"default": -1, "min": -1, "max": 9999}), "index": ("INT", {"default": -1, "min": -1, "max": 9999}),
"pool_id": ("STRING", {"default": "default"}), "pool_id": ("STRING", {"default": "default"}),
}, },
# optional companion input: a Pool Profile node feeds the profile id
# here; when connected it overrides pool_id (see `effective` below).
"optional": {
"profile": ("POOL_PROFILE",),
},
} }
@staticmethod @staticmethod
@@ -35,13 +40,14 @@ class GridImagePool:
idx = pool.resolve_slot(m, index) idx = pool.resolve_slot(m, index)
return base, m, idx return base, m, idx
def run(self, index, pool_id="default"): def run(self, index, pool_id="default", profile=None):
base, m, idx = self._resolve(index, pool_id) effective = profile or pool_id
base, m, idx = self._resolve(index, effective)
if idx < 0: if idx < 0:
img, mask = imaging.empty_outputs() img, mask = imaging.empty_outputs()
return (img, mask, 0, 0, "") return (img, mask, 0, 0, "")
slot = m["slots"][idx] slot = m["slots"][idx]
d = pool.pool_dir(base, pool_id) d = pool.pool_dir(base, effective)
img = imaging.load_image_tensor(str(d / slot["image"])) img = imaging.load_image_tensor(str(d / slot["image"]))
h, w = int(img.shape[1]), int(img.shape[2]) h, w = int(img.shape[1]), int(img.shape[2])
mask_name = slot.get("mask") mask_name = slot.get("mask")
@@ -49,19 +55,20 @@ class GridImagePool:
return (img, mask, idx, len(m["slots"]), slot.get("label", "")) return (img, mask, idx, len(m["slots"]), slot.get("label", ""))
@classmethod @classmethod
def IS_CHANGED(cls, index, pool_id="default", **kwargs): def IS_CHANGED(cls, index, pool_id="default", profile=None, **kwargs):
base, m, idx = cls._resolve(index, pool_id) effective = profile or pool_id
base, m, idx = cls._resolve(index, effective)
if idx < 0: if idx < 0:
return imaging.change_hash(pool_id, -1, []) return imaging.change_hash(effective, -1, [])
slot = m["slots"][idx] slot = m["slots"][idx]
d = pool.pool_dir(base, pool_id) d = pool.pool_dir(base, effective)
mtimes = [] mtimes = []
for key in ("image", "mask"): for key in ("image", "mask"):
name = slot.get(key) name = slot.get(key)
p = d / name if name else None p = d / name if name else None
mtimes.append(os.path.getmtime(p) if p and p.exists() else 0.0) mtimes.append(os.path.getmtime(p) if p and p.exists() else 0.0)
# include active so manual selection changes invalidate cache # include active so manual selection changes invalidate cache
return imaging.change_hash(pool_id, f"{idx}:{m.get('active')}", mtimes) return imaging.change_hash(effective, f"{idx}:{m.get('active')}", mtimes)
NODE_CLASS_MAPPINGS = {"GridImagePool": GridImagePool} NODE_CLASS_MAPPINGS = {"GridImagePool": GridImagePool}
+30
View File
@@ -0,0 +1,30 @@
# gates/profile_node.py
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
class PoolProfile:
CATEGORY = "Dataset Gates"
FUNCTION = "run"
RETURN_TYPES = ("POOL_PROFILE",)
RETURN_NAMES = ("profile",)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"profile": ("STRING", {"default": ""}), # name; JS renders a dropdown
"profile_id": ("STRING", {"default": ""}), # hidden, JS-owned id
},
}
def run(self, profile, profile_id=""):
return (profile_id or "default",)
@classmethod
def IS_CHANGED(cls, profile, profile_id="", **kwargs):
return profile_id
NODE_CLASS_MAPPINGS = {"PoolProfile": PoolProfile}
NODE_DISPLAY_NAME_MAPPINGS = {"PoolProfile": "Pool Profile"}
+160
View File
@@ -0,0 +1,160 @@
"""Named-profile registry + dir ops for the Image Pool. Stdlib only."""
import json
import os
import shutil
import zipfile
from pathlib import Path
REGISTRY_NAME = "profiles.json"
def registry_path(base):
return Path(base) / REGISTRY_NAME
def empty_registry():
return {"profiles": []}
def read_registry(base):
p = registry_path(base)
if not p.exists():
return empty_registry()
try:
with open(p, "r", encoding="utf-8") as f:
reg = json.load(f)
if not isinstance(reg, dict) or "profiles" not in reg:
raise ValueError("bad registry")
return reg
except (ValueError, json.JSONDecodeError):
return empty_registry()
def write_registry(base, reg):
Path(base).mkdir(parents=True, exist_ok=True)
final = registry_path(base)
tmp = final.with_name(REGISTRY_NAME + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(reg, f, indent=2)
os.replace(tmp, final)
return reg
def find_by_id(reg, pid):
return next((p for p in reg["profiles"] if p["id"] == pid), None)
def find_by_name(reg, name):
return next((p for p in reg["profiles"] if p["name"] == name), None)
def create_profile(base, name, pid, ts=0):
reg = read_registry(base)
if find_by_name(reg, name):
raise ValueError(f"profile name already exists: {name}")
(Path(base) / pid).mkdir(parents=True, exist_ok=True)
entry = {"id": pid, "name": name, "created": ts}
reg["profiles"].append(entry)
write_registry(base, reg)
return entry
def rename_profile(base, pid, name):
reg = read_registry(base)
entry = find_by_id(reg, pid)
if not entry:
raise KeyError(pid)
other = find_by_name(reg, name)
if other and other["id"] != pid:
raise ValueError(f"profile name already exists: {name}")
entry["name"] = name
write_registry(base, reg)
return entry
def delete_profile(base, pid):
reg = read_registry(base)
reg["profiles"] = [p for p in reg["profiles"] if p["id"] != pid]
write_registry(base, reg)
d = Path(base) / pid
if d.exists():
shutil.rmtree(d)
return reg
def duplicate_profile(base, src_id, name, new_id, ts=0):
reg = read_registry(base)
if not find_by_id(reg, src_id):
raise KeyError(src_id)
if find_by_name(reg, name):
raise ValueError(f"profile name already exists: {name}")
src = Path(base) / src_id
dst = Path(base) / new_id
if src.exists():
shutil.copytree(src, dst)
else:
dst.mkdir(parents=True, exist_ok=True)
entry = {"id": new_id, "name": name, "created": ts}
reg["profiles"].append(entry)
write_registry(base, reg)
return entry
def export_profile(base, pid, dest_zip):
src = Path(base) / pid
if not src.exists():
raise KeyError(pid)
entry = find_by_id(read_registry(base), pid)
name = entry["name"] if entry else pid
with zipfile.ZipFile(dest_zip, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("profile_meta.json", json.dumps({"name": name}))
for f in src.rglob("*"):
if f.is_file():
z.write(f, arcname=str(Path("pool") / f.relative_to(src)))
return dest_zip
def seed_profile(base, from_id, profile_id):
"""Copy a pool dir's files (images/masks/manifest) into a profile dir.
Used to save an Image Pool's current contents into a freshly-selected empty
profile. Copies top-level files only (the pool layout is flat); returns the
number of files copied. No-op (0) if the source dir is missing.
"""
src = Path(base) / from_id
dst = Path(base) / profile_id
if not src.exists():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in src.iterdir():
if f.is_file():
shutil.copy2(f, dst / f.name)
n += 1
return n
def import_profile(base, src_zip, new_id, name=None, ts=0):
reg = read_registry(base)
meta_name = None
dst = Path(base) / new_id
dst.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(src_zip) as z:
names = z.namelist()
if "profile_meta.json" in names:
meta_name = json.loads(z.read("profile_meta.json")).get("name")
for n in names:
if n.startswith("pool/") and not n.endswith("/"):
target = dst / n[len("pool/"):]
target.parent.mkdir(parents=True, exist_ok=True)
with z.open(n) as srcf, open(target, "wb") as out:
shutil.copyfileobj(srcf, out)
final = name or meta_name or new_id
candidate, i = final, 2
while find_by_name(reg, candidate):
candidate = f"{final} ({i})"
i += 1
entry = {"id": new_id, "name": candidate, "created": ts}
reg["profiles"].append(entry)
write_registry(base, reg)
return entry
+81
View File
@@ -0,0 +1,81 @@
# gates/profiles_routes.py
import os
import tempfile
import uuid
from aiohttp import web
from server import PromptServer
from . import profiles
from .gates_compat import grid_pool_base
routes = PromptServer.instance.routes
def _base():
return grid_pool_base()
@routes.get("/grid_pool/profiles/list")
async def _list(request):
return web.json_response(profiles.read_registry(_base()))
@routes.post("/grid_pool/profiles/create")
async def _create(request):
body = await request.json()
e = profiles.create_profile(_base(), body["name"], uuid.uuid4().hex)
return web.json_response(e)
@routes.post("/grid_pool/profiles/rename")
async def _rename(request):
body = await request.json()
return web.json_response(profiles.rename_profile(_base(), body["id"], body["name"]))
@routes.post("/grid_pool/profiles/delete")
async def _delete(request):
body = await request.json()
return web.json_response(profiles.delete_profile(_base(), body["id"]))
@routes.post("/grid_pool/profiles/duplicate")
async def _duplicate(request):
body = await request.json()
e = profiles.duplicate_profile(_base(), body["id"], body["name"], uuid.uuid4().hex)
return web.json_response(e)
@routes.post("/grid_pool/profiles/seed")
async def _seed(request):
body = await request.json()
n = profiles.seed_profile(_base(), body["from"], body["id"])
return web.json_response({"copied": n})
@routes.get("/grid_pool/profiles/export")
async def _export(request):
pid = request.query["id"]
reg = profiles.read_registry(_base())
entry = profiles.find_by_id(reg, pid)
fname = (entry["name"] if entry else pid) + ".zip"
tmp = os.path.join(tempfile.gettempdir(), f"profile_{pid}.zip")
profiles.export_profile(_base(), pid, tmp)
return web.FileResponse(tmp, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@routes.post("/grid_pool/profiles/import")
async def _import(request):
reader = await request.multipart()
tmp = os.path.join(tempfile.gettempdir(), f"import_{uuid.uuid4().hex}.zip")
async for part in reader:
if part.name == "file":
with open(tmp, "wb") as f:
while True:
chunk = await part.read_chunk()
if not chunk:
break
f.write(chunk)
e = profiles.import_profile(_base(), tmp, uuid.uuid4().hex)
return web.json_response(e)
+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)",
}
+67
View File
@@ -0,0 +1,67 @@
# 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("*")
class TextGate:
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 {
"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, 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)
from . import gate_server
import comfy.model_management as mm
gate_bus.GateBus.arm(unique_id)
gate_server.send_text(unique_id, text or "")
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)"}
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-datasete-gates" name = "comfyui-datasete-gates"
version = "0.1.0" version = "0.1.0"
description = "Dataset Gates — Image Pool (Grid) node for ComfyUI" description = "Dataset Gates — image pool, folder loader, and manual routing/text gates for ComfyUI dataset curation"
requires-python = ">=3.10" requires-python = ">=3.10"
[tool.comfy] [tool.comfy]
+31
View File
@@ -0,0 +1,31 @@
import torch
from gates import bucket_node as bn
def test_square_to_1280():
out, m, w, h, label = bn.BucketResize().run(image=torch.rand((1, 1000, 1000, 3)))
assert (w, h) == (1280, 1280)
assert out.shape == (1, 1280, 1280, 3)
assert m.shape == (1, 1280, 1280) and float(m.max()) == 0.0 # no mask -> zeros
assert label == "1280x1280"
def test_landscape_bucket_shapes():
# tensor [B,H,W,3] with H=1000,W=2000 -> aspect 2.0 -> 1792x896
out, m, w, h, label = bn.BucketResize().run(image=torch.rand((1, 1000, 2000, 3)))
assert (w, h) == (1792, 896)
assert out.shape == (1, 896, 1792, 3)
assert label == "1792x896"
def test_mask_resized_and_aligned():
out, m, w, h, _ = bn.BucketResize().run(
image=torch.rand((1, 1000, 1000, 3)), mask=torch.ones((1, 1000, 1000)))
assert m.shape == (1, 1280, 1280) and float(m.min()) > 0.9
def test_outputs_are_on_grid():
out, m, w, h, _ = bn.BucketResize().run(
image=torch.rand((1, 777, 1333, 3)), resolution=1280, divisible=64)
assert w % 64 == 0 and h % 64 == 0
assert out.shape[1] == h and out.shape[2] == w
+47
View File
@@ -0,0 +1,47 @@
from gates import buckets
# (iw, ih) -> expected (W, H) from KLEIN_BUCKET_SIZES.md, budget 1280, ÷64
CASES = [
(1000, 1000, 1280, 1280), # square
(1000, 2000, 896, 1792), # a=0.50 portrait
(1000, 1730, 960, 1664), # a≈0.58
(1000, 1100, 1216, 1344), # a≈0.90 -> portrait-leaning
(2000, 1000, 1792, 896), # a=2.00 landscape
(1500, 1000, 1536, 1024), # a=1.50
]
def test_pick_bucket_matches_table():
for iw, ih, W, H in CASES:
assert buckets.pick_bucket(iw, ih, 1280, 64) == (W, H)
def test_buckets_are_on_grid_and_within_budget():
for iw, ih, *_ in CASES:
W, H = buckets.pick_bucket(iw, ih, 1280, 64)
assert W % 64 == 0 and H % 64 == 0
assert W * H <= 1280 * 1280
def test_square_is_exactly_1280():
assert buckets.pick_bucket(512, 512, 1280, 64) == (1280, 1280)
def test_cover_crop_exact_aspect_no_crop():
# a=2.0 image onto 1792x896 bucket -> scale 0.896, no crop
new_w, new_h, left, top, scale = buckets.cover_crop_params(2000, 1000, 1792, 896)
assert (new_w, new_h) == (1792, 896)
assert (left, top) == (0, 0)
assert round(scale, 3) == 0.896
def test_cover_crop_square_into_landscape_crops_height():
new_w, new_h, left, top, scale = buckets.cover_crop_params(1000, 1000, 1792, 896)
assert new_w == 1792 and new_h >= 896
assert left == 0 and top == (new_h - 896) // 2 # centered vertical crop
assert scale > 1.0 # upscaled to cover width
def test_cover_crop_upscale_square():
*_, scale = buckets.cover_crop_params(1000, 1000, 1280, 1280)
assert round(scale, 2) == 1.28
+37
View File
@@ -0,0 +1,37 @@
# tests/test_gate.py
import io
import math
import torch
from PIL import Image
from gates import gate
def test_route_tuple_places_image_at_chosen():
B = object()
t = gate.route_tuple(2, "IMG", B, max_routes=5)
assert t == (B, B, "IMG", B, B)
def test_route_tuple_length_is_max():
B = object()
assert len(gate.route_tuple(0, "IMG", B, max_routes=10)) == 10
def test_mask_from_stash_none_is_zeros():
img = torch.zeros((1, 6, 4, 3))
m = gate.mask_from_stash(None, img)
assert m.shape == (1, 6, 4) and float(m.max()) == 0.0
def test_mask_from_stash_decodes_png():
buf = io.BytesIO(); Image.new("L", (4, 6), 255).save(buf, "PNG")
img = torch.zeros((1, 6, 4, 3))
m = gate.mask_from_stash(buf.getvalue(), img)
assert m.shape == (1, 6, 4) and float(m.min()) > 0.99
def test_is_changed_always_nan():
v = gate.ImageGate.IS_CHANGED(image=None, routes=2, unique_id="1")
assert math.isnan(v)
def test_return_types_shape():
assert gate.ImageGate.RETURN_TYPES[0] == "MASK"
assert len(gate.ImageGate.RETURN_TYPES) == gate.MAX_ROUTES + 1
assert all(t == "IMAGE" for t in gate.ImageGate.RETURN_TYPES[1:])
+74
View File
@@ -0,0 +1,74 @@
# tests/test_gate_bus.py
import pytest
from gates import gate_bus as gb
def test_put_and_wait_returns_choice():
gb.GateBus.arm("7")
gb.GateBus.put("7", "3")
assert gb.GateBus.wait("7") == 3
def test_wait_consumes_message():
gb.GateBus.arm("7")
gb.GateBus.put("7", "2")
gb.GateBus.wait("7")
assert "7" not in gb.GateBus.messages
def test_cancel_raises_and_resets():
gb.GateBus.arm("7")
gb.GateBus.put("7", "__cancel__")
with pytest.raises(gb.GateCancelled):
gb.GateBus.wait("7")
assert gb.GateBus.cancelled is False # reset after raising
def test_arm_clears_stale_state():
gb.GateBus.put("1", "5")
gb.GateBus.cancelled = True
gb.GateBus.arm("1")
assert "1" not in gb.GateBus.messages
assert gb.GateBus.cancelled is False
def test_mask_stash_roundtrip():
gb.GateBus.put_mask("9", b"PNGDATA")
assert gb.GateBus.pop_mask("9") == b"PNGDATA"
assert gb.GateBus.pop_mask("9") is None # popped
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")
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)
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
+45
View File
@@ -0,0 +1,45 @@
# tests/test_loader.py
import io, os, torch
from PIL import Image
from gates import loader
def _save(path, color=(255, 0, 0), size=(4, 6), mode="RGB"): # size=(w,h)
os.makedirs(os.path.dirname(path), exist_ok=True)
Image.new(mode, size, color).save(path)
def test_run_loads_image_text_stem_index(tmp_path):
_save(str(tmp_path / "img1.png"), (255, 0, 0))
_save(str(tmp_path / "img2.png"), (0, 255, 0))
(tmp_path / "img2.txt").write_text("green frame\n", encoding="utf-8")
n = loader.FolderImageLoader()
image, text, mask, filename, index = n.run(folder=str(tmp_path), index=1, depth=0)
assert image.shape == (1, 6, 4, 3)
assert float(image[0, 0, 0, 1]) > 0.99 # green
assert text == "green frame"
assert filename == "img2"
assert index == 1
assert mask.shape == (1, 6, 4) and float(mask.max()) == 0.0 # no alpha -> zeros
def test_run_alpha_becomes_mask(tmp_path):
# RGBA image, fully opaque alpha=255 -> mask = 1-1 = 0
_save(str(tmp_path / "a.png"), (255, 255, 255, 255), mode="RGBA")
n = loader.FolderImageLoader()
_, _, mask, _, _ = n.run(folder=str(tmp_path), index=0, depth=0)
assert float(mask.max()) == 0.0
# transparent alpha=0 -> mask = 1-0 = 1
_save(str(tmp_path / "b.png"), (255, 255, 255, 0), mode="RGBA")
_, _, mask2, _, _ = n.run(folder=str(tmp_path), index=1, depth=0)
assert float(mask2.min()) > 0.99
def test_run_out_of_range_raises(tmp_path):
import pytest
_save(str(tmp_path / "only.png"))
n = loader.FolderImageLoader()
with pytest.raises(IndexError):
n.run(folder=str(tmp_path), index=9, depth=0)
def test_is_changed_differs_by_index_and_sidecar(tmp_path):
_save(str(tmp_path / "img1.png")); _save(str(tmp_path / "img2.png"))
h0 = loader.FolderImageLoader.IS_CHANGED(folder=str(tmp_path), index=0, depth=0)
h1 = loader.FolderImageLoader.IS_CHANGED(folder=str(tmp_path), index=1, depth=0)
assert h0 != h1
+14
View File
@@ -54,3 +54,17 @@ def test_is_changed_differs_after_active_change(tmp_path, monkeypatch):
pool.set_active(base, "p1", 1) pool.set_active(base, "p1", 1)
h2 = node.GridImagePool.IS_CHANGED(index=-1, pool_id="p1") h2 = node.GridImagePool.IS_CHANGED(index=-1, pool_id="p1")
assert h1 != h2 assert h1 != h2
def test_profile_input_overrides_pool_id(tmp_path, monkeypatch):
base = str(tmp_path / "grid_pool")
monkeypatch.setattr(node, "_grid_pool_base", lambda: base)
import io
from PIL import Image
from gates import pool
buf = io.BytesIO(); Image.new("RGB", (4, 6), (255, 0, 0)).save(buf, "PNG")
pool.add_image(base, "prof1", buf.getvalue(), ts=1) # images under the PROFILE id
n = node.GridImagePool()
# pool_id is "default" (empty) but profile points at prof1
img, mask, idx, count, label = n.run(index=-1, pool_id="default", profile="prof1")
assert count == 1 and idx == 0
+13
View File
@@ -0,0 +1,13 @@
# tests/test_profile_node.py
from gates import profile_node as pn
def test_io():
assert pn.PoolProfile.RETURN_TYPES == ("POOL_PROFILE",)
assert pn.PoolProfile.RETURN_NAMES == ("profile",)
def test_run_returns_id_or_default():
assert pn.PoolProfile().run(profile="setA", profile_id="id1") == ("id1",)
assert pn.PoolProfile().run(profile="", profile_id="") == ("default",)
def test_is_changed_tracks_id():
assert pn.PoolProfile.IS_CHANGED(profile="x", profile_id="id1") == "id1"
+108
View File
@@ -0,0 +1,108 @@
# tests/test_profiles.py
from gates import profiles as pr
def test_empty_registry():
assert pr.empty_registry() == {"profiles": []}
def test_read_missing_is_empty(tmp_path):
assert pr.read_registry(str(tmp_path)) == {"profiles": []}
def test_write_then_read(tmp_path):
reg = {"profiles": [{"id": "a", "name": "n", "created": 1}]}
pr.write_registry(str(tmp_path), reg)
assert (tmp_path / "profiles.json").exists()
assert pr.read_registry(str(tmp_path)) == reg
def test_read_corrupt_is_empty(tmp_path):
(tmp_path / "profiles.json").write_text("{ not json")
assert pr.read_registry(str(tmp_path)) == {"profiles": []}
def test_find_helpers():
reg = {"profiles": [{"id": "a", "name": "x"}, {"id": "b", "name": "y"}]}
assert pr.find_by_id(reg, "b")["name"] == "y"
assert pr.find_by_name(reg, "x")["id"] == "a"
assert pr.find_by_id(reg, "z") is None
def test_create_profile(tmp_path):
e = pr.create_profile(str(tmp_path), "setA", "id1", ts=10)
assert e == {"id": "id1", "name": "setA", "created": 10}
assert (tmp_path / "id1").is_dir()
assert pr.find_by_name(pr.read_registry(str(tmp_path)), "setA")["id"] == "id1"
def test_create_duplicate_name_raises(tmp_path):
import pytest
pr.create_profile(str(tmp_path), "setA", "id1")
with pytest.raises(ValueError):
pr.create_profile(str(tmp_path), "setA", "id2")
def test_rename_profile(tmp_path):
pr.create_profile(str(tmp_path), "old", "id1")
e = pr.rename_profile(str(tmp_path), "id1", "new")
assert e["name"] == "new"
assert pr.find_by_name(pr.read_registry(str(tmp_path)), "new")["id"] == "id1"
def test_rename_to_existing_name_raises(tmp_path):
import pytest
pr.create_profile(str(tmp_path), "a", "id1")
pr.create_profile(str(tmp_path), "b", "id2")
with pytest.raises(ValueError):
pr.rename_profile(str(tmp_path), "id2", "a")
def test_delete_profile_removes_dir_and_entry(tmp_path):
pr.create_profile(str(tmp_path), "a", "id1")
(tmp_path / "id1" / "img_0001.png").write_bytes(b"x")
pr.delete_profile(str(tmp_path), "id1")
assert not (tmp_path / "id1").exists()
assert pr.find_by_id(pr.read_registry(str(tmp_path)), "id1") is None
def test_duplicate_copies_images(tmp_path):
pr.create_profile(str(tmp_path), "src", "id1")
(tmp_path / "id1" / "img_0001.png").write_bytes(b"abc")
e = pr.duplicate_profile(str(tmp_path), "id1", "copy", "id2", ts=5)
assert e == {"id": "id2", "name": "copy", "created": 5}
assert (tmp_path / "id2" / "img_0001.png").read_bytes() == b"abc"
def test_duplicate_duplicate_name_raises(tmp_path):
import pytest
pr.create_profile(str(tmp_path), "src", "id1")
with pytest.raises(ValueError):
pr.duplicate_profile(str(tmp_path), "id1", "src", "id2")
def test_export_import_roundtrip(tmp_path):
src_base = str(tmp_path / "a"); dst_base = str(tmp_path / "b")
pr.create_profile(src_base, "setA", "id1", ts=1)
from pathlib import Path
(Path(src_base) / "id1" / "img_0001.png").write_bytes(b"hello")
zpath = str(tmp_path / "setA.zip")
pr.export_profile(src_base, "id1", zpath)
assert (tmp_path / "setA.zip").exists()
# import into a different base, fresh id
e = pr.import_profile(dst_base, zpath, "id99", ts=2)
assert e["id"] == "id99"
assert e["name"] == "setA" # name carried in zip meta
assert (Path(dst_base) / "id99" / "img_0001.png").read_bytes() == b"hello"
def test_import_name_collision_suffixes(tmp_path):
base = str(tmp_path)
pr.create_profile(base, "setA", "id1")
from pathlib import Path
(Path(base) / "id1" / "f.png").write_bytes(b"x")
z = str(tmp_path / "e.zip"); pr.export_profile(base, "id1", z)
e = pr.import_profile(base, z, "id2")
assert e["name"] == "setA (2)"
def test_seed_profile_copies_pool_into_empty(tmp_path):
from pathlib import Path
base = str(tmp_path)
pr.create_profile(base, "A", "id1") # empty profile dir
(Path(base) / "srcpool").mkdir() # a pool's own-UUID dir
(Path(base) / "srcpool" / "img_0001.png").write_bytes(b"img")
(Path(base) / "srcpool" / "manifest.json").write_text("{}")
n = pr.seed_profile(base, "srcpool", "id1")
assert n == 2 # image + manifest copied
assert (Path(base) / "id1" / "img_0001.png").read_bytes() == b"img"
def test_seed_profile_missing_source_is_noop(tmp_path):
base = str(tmp_path)
pr.create_profile(base, "A", "id1")
assert pr.seed_profile(base, "nope", "id1") == 0
+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"]
+46
View File
@@ -0,0 +1,46 @@
# 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)
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)
+4
View File
@@ -593,6 +593,10 @@ function setupGridNode(node) {
if (node._countEl) node._countEl.textContent = `${n} image${n === 1 ? "" : "s"}`; if (node._countEl) node._countEl.textContent = `${n} image${n === 1 ? "" : "s"}`;
}; };
// public hook for the Pool Profile companion: after it sets our pool_id widget
// to the selected profile id, it calls this to repaint the grid from that dir.
node._datasetePoolRefresh = () => node._gridRefresh();
// initial width (a sensible wide default) + content-driven height; the node // initial width (a sensible wide default) + content-driven height; the node
// stays freely resizable (no width floor) and the grid flex-wraps to fit. // stays freely resizable (no width floor) and the grid flex-wraps to fit.
node._lastCount = 0; node._lastCount = 0;
+596
View File
@@ -0,0 +1,596 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Image Gate (Manual Router) — pauses a running prompt, shows the image with N
// labeled route buttons + an Edit-mask + a Stop button, and routes the image down
// the clicked output (others ExecutionBlocker-ed server-side). The Python node
// blocks in run() on GateBus.wait(); this extension renders the preview that the
// server pushes via the "datasete-gate-show" socket event and POSTs the choice.
const NODE = "ImageGate";
const MAX_ROUTES = 10;
const R = "/datasete_gate";
const MIN_IMG_H = 140; // preview image area clamps (scales with node width)
const MAX_IMG_H = 600;
const BTN_ROW_H = 78; // buttons area (route buttons wrap + actions)
const MARGIN = 10; // ComfyUI DOM-widget inset, matches the pool node
// ---- routes widget + label store -------------------------------------------
function routesWidget(node) {
return node.widgets?.find((w) => w.name === "routes");
}
function getRouteCount(node) {
let n = parseInt(routesWidget(node)?.value ?? 2, 10);
if (isNaN(n)) n = 2;
return Math.max(1, Math.min(MAX_ROUTES, n));
}
// Labels live in node.properties (litegraph serializes properties for free, so
// they survive reload without a fake serializing widget — route_labels is not a
// backend input, so we must NOT push it into widgets_values).
function labelStore(node) {
if (!Array.isArray(node.properties.routeLabels)) node.properties.routeLabels = [];
return node.properties.routeLabels;
}
function labelFor(node, route) { // route is 1-based
const v = labelStore(node)[route - 1];
return (v != null && String(v).trim()) || String(route);
}
function setRouteLabel(node, route, text) {
labelStore(node)[route - 1] = text;
applyOutputLabels(node);
if (node._gateState && node._gateState !== "idle") render(node); // live-update
node.setDirtyCanvas?.(true, true);
}
// ---- dynamic route outputs --------------------------------------------------
// Slot 0 is the always-visible `mask` output; slots 1..N are route_1..route_N.
// We only ever add/remove from the TAIL so existing slot indices (and the
// backend's index→RETURN_TYPES mapping) stay stable and connections are kept.
function applyOutputLabels(node) {
for (let i = 1; i < node.outputs.length; i++) {
node.outputs[i].label = labelFor(node, i);
}
}
function applyRouteCount(node, n) {
if (!node.outputs || node.outputs.length === 0) return;
let cur = node.outputs.length - 1; // current route outputs
while (cur < n) { node.addOutput(`route_${cur + 1}`, "IMAGE"); cur++; }
while (cur > n) { node.removeOutput(node.outputs.length - 1); cur--; }
applyOutputLabels(node);
node.setDirtyCanvas?.(true, true);
}
// ---- server calls -----------------------------------------------------------
async function postChoice(node, message) {
const fd = new FormData();
fd.append("id", String(node.id));
fd.append("message", String(message));
await api.fetchApi(`${R}/choice`, { method: "POST", body: fd });
}
async function postMask(node, blob) {
const fd = new FormData();
fd.append("id", String(node.id));
fd.append("mask", blob, "mask.png");
await api.fetchApi(`${R}/mask`, { method: "POST", body: fd });
}
// ---- preview DOM widget + state machine -------------------------------------
// States: "idle" (collapsed, before the first run), "paused" (waiting for a
// route choice — route buttons shown), "resolved" (a route was picked — image +
// mask kept, a "Run from here" re-queue button shown). The node never blanks
// once a run has happened, so the previewed image and the sticky mask stay for
// context and the painted mask is reused on the next run until cleared.
function computeImgH(node) {
// image area scales with node WIDTH and the image's aspect ratio, so a wider
// node shows a bigger preview (getMinHeight is polled each layout frame).
const w = Math.max(120, (node.size?.[0] || 220) - 2 * MARGIN);
const h = Math.round(w * (node._imgAspect || 1));
return Math.max(MIN_IMG_H, Math.min(h, MAX_IMG_H));
}
function previewHeight(node) {
if (!node._gateState || node._gateState === "idle") return 0;
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 <img> 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;
// 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);
}
function hasMask(node) { return !!node._stickyMask; }
function maskControls(node) {
// Edit / Clear buttons + a small "mask retained" badge, shared by both states.
const els = [];
const edit = document.createElement("button");
edit.className = "dgate-edit";
edit.textContent = "🖌 Edit mask";
edit.onclick = () => openMaskEditor(node);
els.push(edit);
if (hasMask(node)) {
const clr = document.createElement("button");
clr.className = "dgate-clear";
clr.textContent = "✕ Clear mask";
clr.onclick = () => clearMask(node);
els.push(clr);
}
const badge = document.createElement("span");
badge.className = "dgate-status";
badge.textContent = hasMask(node) ? "🎭 mask retained" : "no mask";
badge.style.opacity = hasMask(node) ? "0.9" : "0.45";
els.push(badge);
return els;
}
function render(node) {
const { btns } = node._gate;
btns.innerHTML = "";
const routes = node._gateRoutes || getRouteCount(node);
if (node._gateState === "paused") {
for (let i = 1; i <= routes; i++) {
const b = document.createElement("button");
b.className = "dgate-route";
b.textContent = labelFor(node, i);
b.onclick = async () => {
await postChoice(node, i);
showResolved(node, labelFor(node, i));
};
btns.appendChild(b);
}
maskControls(node).forEach((el) => btns.appendChild(el));
const stop = document.createElement("button");
stop.className = "dgate-stop";
stop.textContent = "■ Stop";
stop.onclick = async () => {
await postChoice(node, "__cancel__");
showResolved(node, "stopped");
};
btns.appendChild(stop);
} else if (node._gateState === "resolved") {
const status = document.createElement("span");
status.className = "dgate-status";
status.textContent = `✓ routed to ${node._gateChoice ?? "?"}`;
btns.appendChild(status);
const run = document.createElement("button");
run.className = "dgate-run";
run.textContent = "▶ Run from here";
run.onclick = () => queueFromHere(node);
btns.appendChild(run);
maskControls(node).forEach((el) => btns.appendChild(el));
}
updateMaskOverlay(node);
}
function showPaused(node, b64, routes) {
node._gateState = "paused";
node._gateRoutes = Math.max(1, Math.min(MAX_ROUTES, parseInt(routes, 10) || getRouteCount(node)));
node._previewB64 = b64;
node._gate.img.src = `data:image/png;base64,${b64}`;
// sticky mask: re-stash the last painted mask for THIS run before the user
// picks a route. run() does arm()→clear, then send_preview→this event, then
// blocks in wait(), so this POST always lands before the choice is made.
if (node._stickyMask) {
postMask(node, b64ToBlob(node._stickyMask, "image/png")).catch(() => {});
}
render(node);
resizePreview(node);
}
function showResolved(node, choiceLabel) {
node._gateState = "resolved";
node._gateChoice = choiceLabel;
render(node);
resizePreview(node);
}
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("[dgate] queue failed", e2); }
}
}
async function clearMask(node) {
node._stickyMask = null;
node._stickyMaskOverlay = null;
// zero the current run's stash: an empty mask part -> server stores b"" ->
// mask_from_stash() treats it as falsy -> zeros.
try { await postMask(node, new Blob([], { type: "image/png" })); } catch (e) { /* ignore */ }
render(node);
}
// ---- mask overlay (show the painted region over the preview, semi-transparent)
// The sticky mask is grayscale (white = painted). Recolor it into an RGBA layer
// where alpha = paint intensity and RGB = a highlight color, so unpainted areas
// are fully transparent and only the painted region tints the image.
function maskToOverlay(b64) {
return new Promise((resolve, reject) => {
const im = new Image();
im.onload = () => {
const c = document.createElement("canvas");
c.width = im.naturalWidth || im.width;
c.height = im.naturalHeight || im.height;
const ctx = c.getContext("2d");
ctx.drawImage(im, 0, 0);
const d = ctx.getImageData(0, 0, c.width, c.height);
const px = d.data;
for (let i = 0; i < px.length; i += 4) {
const v = px[i]; // grayscale luminance (R=G=B)
px[i] = 255; px[i + 1] = 64; px[i + 2] = 64; // highlight = red
px[i + 3] = v; // alpha = paint intensity
}
ctx.putImageData(d, 0, 0);
resolve(c.toDataURL("image/png"));
};
im.onerror = reject;
im.src = `data:image/png;base64,${b64}`;
});
}
async function setStickyMask(node, b64) {
node._stickyMask = b64;
try {
node._stickyMaskOverlay = b64 ? await maskToOverlay(b64) : null;
} catch (e) {
node._stickyMaskOverlay = null;
}
updateMaskOverlay(node);
}
function updateMaskOverlay(node) {
const mi = node._gate?.maskImg;
if (!mi) return;
if (node._gateState && node._gateState !== "idle" && node._stickyMaskOverlay) {
mi.src = node._stickyMaskOverlay;
mi.style.display = "block";
} else {
mi.removeAttribute("src");
mi.style.display = "none";
}
}
// ---- mask editor (reuses ComfyUI MaskEditor, like the pool node) ------------
// The preview arrives as base64 (no server file), so upload it to input/ first,
// point the MaskEditor at it, then poll node.images for the saved clipspace ref.
function b64ToBlob(b64, type) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type });
}
function blobToImage(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
function blobToB64(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result).split(",")[1] || "");
fr.onerror = reject;
fr.readAsDataURL(blob);
});
}
function comfyAppClass() {
try { return app.constructor; } catch (e) { return null; }
}
// MaskEditor registers the painted image as this node's output; clear those
// stores so nothing repopulates node.imgs (we draw our own preview).
function clearNodeOutputs(node) {
try {
for (const map of [app.nodeOutputs, app.nodePreviewImages]) {
if (!map) continue;
for (const k of Object.keys(map)) {
if (k === String(node.id) || k.endsWith(`:${node.id}`)) delete map[k];
}
}
} catch (e) { /* best effort */ }
}
function cleanupMaskState(node) {
if (node._maskPoll) { clearInterval(node._maskPoll); node._maskPoll = null; }
node._maskActive = false;
try {
node.images = undefined;
node.previewMediaType = undefined;
} catch (e) { /* best effort */ }
clearNodeOutputs(node);
node.setDirtyCanvas?.(true, true);
}
async function uploadPreview(node) {
const blob = b64ToBlob(node._previewB64, "image/png");
const fd = new FormData();
fd.append("image", blob, `gate_${node.id}.png`);
fd.append("subfolder", "datasete_gate");
fd.append("type", "input");
fd.append("overwrite", "true");
const res = await api.fetchApi("/upload/image", { method: "POST", body: fd });
const j = await res.json();
return { filename: j.name, subfolder: j.subfolder || "datasete_gate", type: j.type || "input" };
}
async function captureMask(node, ref) {
try {
const sub = ref.subfolder ?? "clipspace";
const type = ref.type ?? "input";
const url = `/view?filename=${encodeURIComponent(ref.filename)}&subfolder=${encodeURIComponent(sub)}&type=${encodeURIComponent(type)}&r=${Date.now()}`;
const resp = await api.fetchApi(url);
const blob = await resp.blob();
const img = await blobToImage(blob);
const c = document.createElement("canvas");
c.width = img.naturalWidth || img.width;
c.height = img.naturalHeight || img.height;
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0);
const d = ctx.getImageData(0, 0, c.width, c.height);
const px = d.data;
// MaskEditor stores the mask in the ALPHA channel; painted areas come through
// as alpha 0, so invert (255 - a) into grayscale -> white = painted (MASK).
for (let i = 0; i < px.length; i += 4) {
const a = px[i + 3];
px[i] = px[i + 1] = px[i + 2] = 255 - a;
px[i + 3] = 255;
}
ctx.putImageData(d, 0, 0);
const maskBlob = await new Promise((res) => c.toBlob(res, "image/png"));
await postMask(node, maskBlob);
// remember it so it auto-applies on the next run until the user clears it,
// and build the colored overlay shown over the preview.
try { await setStickyMask(node, await blobToB64(maskBlob)); } catch (e) { /* ignore */ }
} catch (e) {
console.error("[dgate] mask capture failed", e);
} finally {
cleanupMaskState(node);
if (node._gateState && node._gateState !== "idle") render(node); // show badge
}
}
async function openMaskEditor(node) {
if (!node._previewB64) return;
cleanupMaskState(node);
let ref;
try {
ref = await uploadPreview(node);
} catch (e) {
console.error("[dgate] preview upload failed", e);
return;
}
node.images = [ref];
node.previewMediaType = "image";
node.imageIndex = 0;
node._maskActive = true;
const Comfy = comfyAppClass();
try { if (Comfy) Comfy.clipspace_return_node = node; } catch (e) { /* ignore */ }
// No save callback in frontend 1.45 — poll for the editor writing clipspace.
let waited = 0;
node._maskPoll = setInterval(() => {
waited += 300;
const r = node.images && node.images[0];
if (node._maskActive && r && r.subfolder === "clipspace") {
clearInterval(node._maskPoll); node._maskPoll = null;
captureMask(node, r);
} else if (waited > 10 * 60 * 1000) {
cleanupMaskState(node);
}
}, 300);
try { app.canvas?.selectNode?.(node); } catch (e) { /* ignore */ }
const cmd = app.extensionManager?.command;
if (cmd?.execute) {
cmd.execute("Comfy.MaskEditor.OpenMaskEditor");
} else if (Comfy?.open_maskeditor) {
Comfy.open_maskeditor();
} else {
console.error("[dgate] no MaskEditor entry point found");
cleanupMaskState(node);
}
}
// ---- styles + node setup ----------------------------------------------------
function injectStyles() {
if (document.getElementById("dgate-styles")) return;
const css = `
.dgate-wrap { display:flex; flex-direction:column; gap:6px; box-sizing:border-box;
height:100%; min-height:0; }
.dgate-imgbox { position:relative; flex:1 1 auto; min-height:0; width:100%;
background:rgba(0,0,0,0.25); border-radius:4px; overflow:hidden; }
.dgate-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
display:block; }
.dgate-mask { position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
opacity:0.5; pointer-events:none; }
.dgate-btns { display:flex; flex-wrap:wrap; gap:6px; align-items:center; flex:0 0 auto; }
.dgate-btns button { font-size:12px; padding:3px 10px; cursor:pointer; border-radius:3px;
border:1px solid #555; color:#fff; }
.dgate-route { background:rgba(40,90,140,0.9); }
.dgate-route:hover { background:rgba(60,120,180,0.95); }
.dgate-edit { background:rgba(40,40,40,0.9); }
.dgate-clear { background:rgba(90,60,30,0.9); }
.dgate-run { background:rgba(40,130,70,0.95); }
.dgate-stop { background:rgba(160,40,40,0.9); margin-left:auto; }
.dgate-status { font-size:11px; opacity:0.8; padding:0 4px; align-self:center; }
`;
const style = document.createElement("style");
style.id = "dgate-styles";
style.textContent = css;
document.head.appendChild(style);
}
function setupGateNode(node) {
injectStyles();
// Never let the MaskEditor's source image render as an output preview on us —
// we draw the preview ourselves in the DOM widget below.
try {
Object.defineProperty(node, "imgs", {
configurable: true,
get() { return undefined; },
set() { /* suppress */ },
});
} catch (e) { /* ignore */ }
const wrap = document.createElement("div");
wrap.className = "dgate-wrap";
// image + mask overlay share a container so both letterbox identically and
// stay pixel-aligned (object-fit:contain on same-size, same-aspect layers).
const imgbox = document.createElement("div");
imgbox.className = "dgate-imgbox";
const img = document.createElement("img");
img.className = "dgate-img";
// capture the image aspect so the preview area scales with the node width
img.onload = () => {
const w = img.naturalWidth || 1;
const h = img.naturalHeight || 1;
node._imgAspect = h / w;
resizePreview(node);
};
const maskImg = document.createElement("img");
maskImg.className = "dgate-mask";
maskImg.style.display = "none";
imgbox.appendChild(img);
imgbox.appendChild(maskImg);
const btns = document.createElement("div");
btns.className = "dgate-btns";
wrap.appendChild(imgbox);
wrap.appendChild(btns);
node._gate = { wrap, imgbox, img, maskImg, btns };
node._previewWidget = node.addDOMWidget("gate_preview", "div", wrap, {
serialize: false,
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);
if (rw) {
const prev = rw.callback;
rw.callback = function () {
const r = prev?.apply(this, arguments);
applyRouteCount(node, getRouteCount(node));
return r;
};
}
node._gateState = "idle";
resizePreview(node);
}
app.registerExtension({
name: "datasete.gates.imagegate",
// one global socket listener: route the server's pause event to the node
setup() {
api.addEventListener("datasete-gate-show", (e) => {
const d = e.detail || {};
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
if (!node || node.type !== NODE) return;
showPaused(node, d.image, d.routes);
});
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
setupGateNode(this);
return r;
};
// loaded workflows restore the routes widget + properties after create —
// re-sync output count/labels to match.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
if (this.outputs) {
applyRouteCount(this, getRouteCount(this));
}
return r;
};
// per-route "Rename…" entries (editable labels, persisted in properties)
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (canvas, options) {
const r = getExtraMenuOptions?.apply(this, arguments);
const node = this;
const routes = getRouteCount(node);
for (let i = 1; i <= routes; i++) {
options.push({
content: `Rename route ${i} (“${labelFor(node, i)}”)…`,
callback: () => {
const text = prompt(`Label for route ${i}:`, labelFor(node, i));
if (text != null) setRouteLabel(node, i, text);
},
});
}
return r;
};
},
});
+267
View File
@@ -0,0 +1,267 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Pool Profile — companion to the Image Pool. A dropdown of named profiles
// (registry under input/grid_pool/profiles.json) plus create/rename/delete/
// duplicate/export/import actions. The pool is switched ONLY when the user
// actively picks a profile in the dropdown (or creates/duplicates/imports one) —
// connecting the node never changes the pool. Selecting an *empty* profile while
// a pool with images is connected offers to seed it from those images, so the
// current pool is never silently lost. (Modeled on JSON-Manager/project_key.)
const NODE = "PoolProfile";
const POOL_NODE = "GridImagePool";
const R = "/grid_pool/profiles";
// ---- server calls -----------------------------------------------------------
async function listProfiles() {
const r = await api.fetchApi(`${R}/list`);
return (await r.json()).profiles || [];
}
async function listPoolSlots(poolId) {
try {
const r = await api.fetchApi(`/grid_pool/list?pool_id=${encodeURIComponent(poolId)}`);
return (await r.json()).slots || [];
} catch (e) { return []; }
}
async function postJson(path, body) {
const r = await api.fetchApi(`${R}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return await r.json();
}
// ---- widget helpers ---------------------------------------------------------
function hideWidget(w) {
if (!w) return;
if (w.origType === undefined) w.origType = w.type;
w.type = "hidden";
w.hidden = true;
w.computeSize = () => [0, -4];
}
function profileWidget(node) {
return node.widgets?.find((w) => w.name === "profile");
}
function idWidget(node) {
return node.widgets?.find((w) => w.name === "profile_id");
}
function currentEntry(node) {
const combo = profileWidget(node);
return (node._profiles || []).find((p) => p.name === combo?.value);
}
// Replace a STRING widget with a real combo, preserving its serialized value.
function replaceWithCombo(node, name, values, callback) {
const idx = node.widgets?.findIndex((w) => w.name === name);
if (idx === undefined || idx === -1) return null;
const old = node.widgets[idx];
const saved = old.value || "";
const vals = values.length ? values.slice() : [""];
if (saved && !vals.includes(saved)) vals.unshift(saved);
node.widgets.splice(idx, 1);
const combo = node.addWidget("combo", name, saved || vals[0], callback, { values: vals });
node.widgets.splice(node.widgets.length - 1, 1);
node.widgets.splice(idx, 0, combo);
return combo;
}
// ---- connected pools + switching --------------------------------------------
function connectedPools(node) {
const res = [];
const out = node.outputs?.[0];
if (!out?.links) return res;
for (const linkId of out.links) {
const link = node.graph?.links?.[linkId];
if (!link) continue;
const t = node.graph?.getNodeById?.(link.target_id);
if (t && t.type === POOL_NODE) res.push(t);
}
return res;
}
function setIdFromCombo(node) {
const entry = currentEntry(node);
const idw = idWidget(node);
if (idw) idw.value = entry?.id || "";
}
// Push the current profile id into every connected pool's pool_id widget (the
// grid keys off getPoolId) and repaint. Only ever called from user actions.
function switchPools(node) {
const id = idWidget(node)?.value || "default";
for (const pool of connectedPools(node)) {
const pw = pool.widgets?.find((w) => w.name === "pool_id");
if (pw) pw.value = id;
pool._datasetePoolRefresh?.();
pool.setDirtyCanvas?.(true, true);
}
node.setDirtyCanvas?.(true, true);
}
// If the selected profile is empty and a connected pool has images, offer to
// copy those images into the profile (so switching never loses the current pool).
async function maybeSeed(node, entry) {
const profSlots = await listPoolSlots(entry.id);
if (profSlots.length > 0) return; // profile already has images
for (const pool of connectedPools(node)) {
const curId = pool.widgets?.find((w) => w.name === "pool_id")?.value;
if (!curId || curId === entry.id) continue;
const curSlots = await listPoolSlots(curId);
if (curSlots.length === 0) continue;
if (confirm(`Profile "${entry.name}" is empty. Copy the ${curSlots.length} current pool image(s) into it?`)) {
try { await postJson("seed", { from: curId, id: entry.id }); }
catch (err) { alert("Seed failed: " + err); }
}
return; // seed from the first match only
}
}
// user-initiated: set id from the dropdown, optionally offer to seed, then switch
async function selectProfile(node) {
setIdFromCombo(node);
const entry = currentEntry(node);
if (entry) await maybeSeed(node, entry);
switchPools(node);
}
// programmatic: refresh the dropdown options + hidden id only — never switches
async function refreshList(node, selectName) {
const profs = await listProfiles();
node._profiles = profs;
const names = profs.map((p) => p.name);
const combo = profileWidget(node);
if (combo) {
combo.options = combo.options || {};
combo.options.values = names.length ? names : [""];
if (selectName !== undefined) combo.value = selectName;
else if (!names.includes(combo.value)) combo.value = names[0] || "";
}
setIdFromCombo(node);
node.setDirtyCanvas?.(true, true);
}
// ---- actions ----------------------------------------------------------------
async function actionCreate(node) {
const name = prompt("New profile name:");
if (!name) return;
try {
const e = await postJson("create", { name });
await refreshList(node, e.name);
await selectProfile(node); // new profile is empty → offer to seed current pool
} catch (err) { alert("Create failed: " + err); }
}
async function actionRename(node) {
const e = currentEntry(node);
if (!e) return alert("Select a profile first");
const name = prompt("Rename profile:", e.name);
if (!name || name === e.name) return;
try {
await postJson("rename", { id: e.id, name });
await refreshList(node, name); // same id, no pool switch needed
} catch (err) { alert("Rename failed: " + err); }
}
async function actionDuplicate(node) {
const e = currentEntry(node);
if (!e) return alert("Select a profile first");
const name = prompt("Duplicate as:", e.name + " copy");
if (!name) return;
try {
const ne = await postJson("duplicate", { id: e.id, name });
await refreshList(node, ne.name);
await selectProfile(node); // already has images → maybeSeed no-ops, just switch
} catch (err) { alert("Duplicate failed: " + err); }
}
async function actionDelete(node) {
const e = currentEntry(node);
if (!e) return alert("Select a profile first");
if (!confirm(`Delete profile "${e.name}"? This removes its images.`)) return;
try {
await postJson("delete", { id: e.id });
await refreshList(node); // update dropdown; leave the pool as-is
} catch (err) { alert("Delete failed: " + err); }
}
function actionExport(node) {
const e = currentEntry(node);
if (!e) return alert("Select a profile first");
window.open(`${R}/export?id=${encodeURIComponent(e.id)}`);
}
function actionImport(node) {
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip";
input.onchange = async () => {
if (!input.files?.length) return;
const fd = new FormData();
fd.append("file", input.files[0], input.files[0].name);
try {
const r = await api.fetchApi(`${R}/import`, { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
const e = await r.json();
await refreshList(node, e.name);
await selectProfile(node); // imported profile has images → just switch
} catch (err) { alert("Import failed: " + err); }
};
input.click();
}
// ---- node setup -------------------------------------------------------------
function setupProfileNode(node) {
hideWidget(idWidget(node));
// combo callback = active user selection → switch (and maybe seed)
replaceWithCombo(node, "profile", [], () => { selectProfile(node); });
node.addWidget("button", " Create", null, () => actionCreate(node));
node.addWidget("button", "✎ Rename", null, () => actionRename(node));
node.addWidget("button", "⧉ Duplicate", null, () => actionDuplicate(node));
node.addWidget("button", "🗑 Delete", null, () => actionDelete(node));
node.addWidget("button", "⬇ Export", null, () => actionExport(node));
node.addWidget("button", "⬆ Import", null, () => actionImport(node));
node.setSize(node.computeSize());
refreshList(node); // populate the dropdown; does NOT switch any pool
}
app.registerExtension({
name: "datasete.gates.poolprofile",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
setupProfileNode(this);
return r;
};
// on load the pool already has its saved pool_id, so just refresh the
// dropdown to show the saved name — no switching, no seeding.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
const node = this;
queueMicrotask(() => refreshList(node, profileWidget(node)?.value));
return r;
};
// NOTE: intentionally no onConnectionsChange handler — connecting a profile
// must never change the pool (the user switches via the dropdown).
},
});
+310
View File
@@ -0,0 +1,310 @@
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.
//
// 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.
const NODE = "TextGate";
const R = "/datasete_text_gate";
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
// ---- 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) {
const fd = new FormData();
fd.append("id", String(node.id));
fd.append("text", 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
// 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;
}
// 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 ----------------------------------------------------
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); }
.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");
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…";
// 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";
btns.appendChild(pass);
btns.appendChild(runHere);
btns.appendChild(status);
wrap.appendChild(area);
wrap.appendChild(btns);
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, {
serialize: false,
getMinHeight: () => widgetFloor(),
});
// 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;
};
// 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;
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 */ }
});
},
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;
};
// 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;
};
},
});