Compare commits
16 Commits
5419366bde
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 690278b592 | |||
| 3ee14819b7 | |||
| d6d2c98a58 | |||
| 36dd5c91ee | |||
| 954b9ec2e6 | |||
| 1881aa727f | |||
| 78b1b85a11 | |||
| b50718f7fb | |||
| d9134b4e9b | |||
| 3fb63e44a3 | |||
| 00c8c6a790 | |||
| 726cd7bf17 | |||
| 5b92e9b338 | |||
| b2f5850b46 | |||
| 31a7112052 | |||
| 66e664247c |
+6
-2
@@ -20,14 +20,18 @@ if __package__:
|
|||||||
NODE_DISPLAY_NAME_MAPPINGS as _PROF_NAMES
|
NODE_DISPLAY_NAME_MAPPINGS as _PROF_NAMES
|
||||||
from .gates.bucket_node import NODE_CLASS_MAPPINGS as _BUCKET_NODES, \
|
from .gates.bucket_node import NODE_CLASS_MAPPINGS as _BUCKET_NODES, \
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as _BUCKET_NAMES
|
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 gate_server # noqa: F401 (registers /datasete_gate/* + text routes)
|
||||||
from .gates import profiles_routes # noqa: F401 (registers /grid_pool/profiles/*)
|
from .gates import profiles_routes # noqa: F401 (registers /grid_pool/profiles/*)
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {**_POOL_NODES, **_LOADER_NODES, **_GATE_NODES,
|
NODE_CLASS_MAPPINGS = {**_POOL_NODES, **_LOADER_NODES, **_GATE_NODES,
|
||||||
**_TEXT_NODES, **_PROF_NODES, **_BUCKET_NODES}
|
**_TEXT_NODES, **_PROF_NODES, **_BUCKET_NODES,
|
||||||
|
**_SC_NODES}
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES,
|
NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES,
|
||||||
**_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES}
|
**_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES,
|
||||||
|
**_SC_NAMES}
|
||||||
else: # pragma: no cover - exercised only under pytest collection
|
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,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).
|
||||||
@@ -39,7 +39,7 @@ def fit_mask(mask, W, H):
|
|||||||
|
|
||||||
|
|
||||||
class BucketResize:
|
class BucketResize:
|
||||||
CATEGORY = "Datasete Gates"
|
CATEGORY = "Dataset Gates"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
|
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING")
|
||||||
RETURN_NAMES = ("image", "mask", "width", "height", "label")
|
RETURN_NAMES = ("image", "mask", "width", "height", "label")
|
||||||
|
|||||||
+4
-3
@@ -25,7 +25,7 @@ def mask_from_stash(data, image):
|
|||||||
|
|
||||||
|
|
||||||
class ImageGate:
|
class ImageGate:
|
||||||
CATEGORY = "Datasete Gates"
|
CATEGORY = "Dataset Gates"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
RETURN_TYPES = ("MASK",) + ("IMAGE",) * MAX_ROUTES
|
RETURN_TYPES = ("MASK",) + ("IMAGE",) * MAX_ROUTES
|
||||||
RETURN_NAMES = ("mask",) + tuple(f"route_{i + 1}" for i in range(MAX_ROUTES))
|
RETURN_NAMES = ("mask",) + tuple(f"route_{i + 1}" for i in range(MAX_ROUTES))
|
||||||
@@ -47,13 +47,14 @@ class ImageGate:
|
|||||||
def run(self, image, routes, unique_id):
|
def run(self, image, routes, unique_id):
|
||||||
from comfy_execution.graph_utils import ExecutionBlocker
|
from comfy_execution.graph_utils import ExecutionBlocker
|
||||||
from . import gate_server
|
from . import gate_server
|
||||||
|
import comfy.model_management as mm
|
||||||
|
|
||||||
gate_bus.GateBus.arm(unique_id)
|
gate_bus.GateBus.arm(unique_id)
|
||||||
gate_server.send_preview(unique_id, image, routes)
|
gate_server.send_preview(unique_id, image, routes)
|
||||||
try:
|
try:
|
||||||
chosen_1 = gate_bus.GateBus.wait(unique_id)
|
chosen_1 = gate_bus.GateBus.wait(
|
||||||
|
unique_id, should_cancel=mm.processing_interrupted)
|
||||||
except gate_bus.GateCancelled:
|
except gate_bus.GateCancelled:
|
||||||
import comfy.model_management as mm
|
|
||||||
raise mm.InterruptProcessingException()
|
raise mm.InterruptProcessingException()
|
||||||
|
|
||||||
mask = mask_from_stash(gate_bus.GateBus.pop_mask(unique_id), image)
|
mask = mask_from_stash(gate_bus.GateBus.pop_mask(unique_id), image)
|
||||||
|
|||||||
+2
-2
@@ -27,10 +27,10 @@ class GateBus:
|
|||||||
cls.messages[str(node_id)] = int(message)
|
cls.messages[str(node_id)] = int(message)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def wait(cls, node_id, period=0.1):
|
def wait(cls, node_id, period=0.1, should_cancel=None):
|
||||||
sid = str(node_id)
|
sid = str(node_id)
|
||||||
while sid not in cls.messages:
|
while sid not in cls.messages:
|
||||||
if cls.cancelled:
|
if cls.cancelled or (should_cancel is not None and should_cancel()):
|
||||||
cls.cancelled = False
|
cls.cancelled = False
|
||||||
raise GateCancelled()
|
raise GateCancelled()
|
||||||
time.sleep(period)
|
time.sleep(period)
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ def load_image_and_mask(path):
|
|||||||
|
|
||||||
|
|
||||||
class FolderImageLoader:
|
class FolderImageLoader:
|
||||||
CATEGORY = "Datasete Gates"
|
CATEGORY = "Dataset Gates"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
RETURN_TYPES = ("IMAGE", "STRING", "MASK", "STRING", "INT")
|
RETURN_TYPES = ("IMAGE", "STRING", "MASK", "STRING", "INT")
|
||||||
RETURN_NAMES = ("image", "text", "mask", "filename", "index")
|
RETURN_NAMES = ("image", "text", "mask", "filename", "index")
|
||||||
|
|||||||
+1
-1
@@ -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")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
|
|||||||
|
|
||||||
|
|
||||||
class PoolProfile:
|
class PoolProfile:
|
||||||
CATEGORY = "Datasete Gates"
|
CATEGORY = "Dataset Gates"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
RETURN_TYPES = ("POOL_PROFILE",)
|
RETURN_TYPES = ("POOL_PROFILE",)
|
||||||
RETURN_NAMES = ("profile",)
|
RETURN_NAMES = ("profile",)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)",
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ ANY = AnyType("*")
|
|||||||
|
|
||||||
|
|
||||||
class TextGate:
|
class TextGate:
|
||||||
CATEGORY = "Datasete Gates"
|
CATEGORY = "Dataset Gates"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
RETURN_TYPES = ("STRING", ANY)
|
RETURN_TYPES = ("STRING", ANY)
|
||||||
RETURN_NAMES = ("text", "signal")
|
RETURN_NAMES = ("text", "signal")
|
||||||
|
|||||||
@@ -65,3 +65,10 @@ def test_wait_payload_should_cancel_raises():
|
|||||||
gb.GateBus.arm("p")
|
gb.GateBus.arm("p")
|
||||||
with pytest.raises(gb.GateCancelled):
|
with pytest.raises(gb.GateCancelled):
|
||||||
gb.GateBus.wait_payload("p", should_cancel=lambda: True)
|
gb.GateBus.wait_payload("p", should_cancel=lambda: True)
|
||||||
|
|
||||||
|
def test_wait_should_cancel_raises():
|
||||||
|
# image gate: ComfyUI Interrupt (should_cancel) must abort the wait too
|
||||||
|
gb.GateBus.arm("7")
|
||||||
|
with pytest.raises(gb.GateCancelled):
|
||||||
|
gb.GateBus.wait("7", should_cancel=lambda: True)
|
||||||
|
assert gb.GateBus.cancelled is False
|
||||||
|
|||||||
@@ -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) == []
|
||||||
@@ -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"]
|
||||||
+35
-12
@@ -32,7 +32,9 @@ const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other nodes
|
|||||||
// `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend
|
// `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend
|
||||||
// widgets. When protected, the node acts as a plain text node: it outputs
|
// widgets. When protected, the node acts as a plain text node: it outputs
|
||||||
// stored_text and ignores upstream (no pause). The DOM textarea is the visible
|
// stored_text and ignores upstream (no pause). The DOM textarea is the visible
|
||||||
// editor and mirrors its value into stored_text so it persists and reaches run().
|
// editor and mirrors its value into stored_text on EVERY change (typing, upstream
|
||||||
|
// arrival, Pass) — so the editor content survives refresh / workflow reload in
|
||||||
|
// BOTH modes (stored_text also reaches run() when protected).
|
||||||
|
|
||||||
function widgetByName(node, name) {
|
function widgetByName(node, name) {
|
||||||
return node.widgets?.find((w) => w.name === name);
|
return node.widgets?.find((w) => w.name === name);
|
||||||
@@ -48,21 +50,24 @@ function syncStored(node) {
|
|||||||
if (w) w.value = node._tg?.area?.value ?? "";
|
if (w) w.value = node._tg?.area?.value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// collapse the auto-created stored_text widget out of the layout (pool_id trick)
|
// fully hide the auto-created stored_text widget (same as the pool node's
|
||||||
|
// pool_id): getVisibleWidgets() filters on `hidden`, so it's dropped from both
|
||||||
|
// draw and layout — computeSize alone (or type="hidden") does NOT hide it.
|
||||||
|
// Serialization still iterates all widgets, so stored_text is saved/sent.
|
||||||
function hideStoredWidget(node) {
|
function hideStoredWidget(node) {
|
||||||
const w = widgetByName(node, "stored_text");
|
const w = widgetByName(node, "stored_text");
|
||||||
if (w) w.computeSize = () => [0, -4];
|
if (!w) return;
|
||||||
|
w.hidden = true;
|
||||||
|
w.computeSize = () => [0, -4];
|
||||||
}
|
}
|
||||||
|
|
||||||
// reflect the persisted protected/stored_text state into the editor + UI
|
// reflect the persisted stored_text + mode into the editor + UI. The editor text
|
||||||
|
// is restored in BOTH modes so it survives a refresh / workflow reload; the mode
|
||||||
|
// only selects the UI state (protected vs idle waiting-for-a-run).
|
||||||
function applyPersistedMode(node) {
|
function applyPersistedMode(node) {
|
||||||
if (!node._tg) return;
|
if (!node._tg) return;
|
||||||
if (isProtected(node)) {
|
node._tg.area.value = widgetByName(node, "stored_text")?.value ?? "";
|
||||||
node._tg.area.value = widgetByName(node, "stored_text")?.value ?? "";
|
setState(node, isProtected(node) ? "protected" : "idle");
|
||||||
setState(node, "protected");
|
|
||||||
} else {
|
|
||||||
setState(node, "idle");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- server call ------------------------------------------------------------
|
// ---- server call ------------------------------------------------------------
|
||||||
@@ -165,8 +170,17 @@ function setupTextGateNode(node) {
|
|||||||
const area = document.createElement("textarea");
|
const area = document.createElement("textarea");
|
||||||
area.className = "tgate-area";
|
area.className = "tgate-area";
|
||||||
area.placeholder = "waiting for a run…";
|
area.placeholder = "waiting for a run…";
|
||||||
// don't let typing/space toggle node selection or graph shortcuts
|
// Stop keys from reaching litegraph (so typing/space can't toggle node
|
||||||
area.onkeydown = (e) => e.stopPropagation();
|
// selection or fire canvas shortcuts) — EXCEPT ComfyUI's prompt-weighting
|
||||||
|
// shortcut (Ctrl/Cmd+↑/↓). That handler is a global `window` keydown listener
|
||||||
|
// that wraps the selection in (token:weight); a blanket stopPropagation here
|
||||||
|
// kept it from ever bubbling up, so weighting didn't work in this editor.
|
||||||
|
// Its execCommand edit fires our oninput, so the weighted text still syncs.
|
||||||
|
area.onkeydown = (e) => {
|
||||||
|
const isWeight = (e.ctrlKey || e.metaKey) &&
|
||||||
|
(e.key === "ArrowUp" || e.key === "ArrowDown");
|
||||||
|
if (!isWeight) e.stopPropagation();
|
||||||
|
};
|
||||||
// keep the hidden stored_text widget mirrored so edits persist + reach run()
|
// keep the hidden stored_text widget mirrored so edits persist + reach run()
|
||||||
area.oninput = () => syncStored(node);
|
area.oninput = () => syncStored(node);
|
||||||
|
|
||||||
@@ -177,6 +191,7 @@ function setupTextGateNode(node) {
|
|||||||
pass.className = "tgate-pass";
|
pass.className = "tgate-pass";
|
||||||
pass.textContent = "▶ Pass";
|
pass.textContent = "▶ Pass";
|
||||||
pass.onclick = async () => {
|
pass.onclick = async () => {
|
||||||
|
syncStored(node); // persist the passed text so a reload keeps it
|
||||||
await postPass(node, area.value);
|
await postPass(node, area.value);
|
||||||
setState(node, "passed");
|
setState(node, "passed");
|
||||||
};
|
};
|
||||||
@@ -240,11 +255,18 @@ function setupTextGateNode(node) {
|
|||||||
syncWidgetWidth(node);
|
syncWidgetWidth(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build marker — lets you confirm the browser loaded THIS build (not a cached
|
||||||
|
// old copy). If the editor comes back empty after reload but you don't see this
|
||||||
|
// line in the devtools console, your tab is running stale JS: hard-refresh
|
||||||
|
// (Ctrl/Cmd+Shift+R).
|
||||||
|
const BUILD = "2026-07-03 persist+weight";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "datasete.gates.textgate",
|
name: "datasete.gates.textgate",
|
||||||
|
|
||||||
// one global socket listener: route the server's pause event to the node
|
// one global socket listener: route the server's pause event to the node
|
||||||
setup() {
|
setup() {
|
||||||
|
console.info(`[datasete.textgate] loaded build ${BUILD}`);
|
||||||
api.addEventListener("datasete-textgate-show", (e) => {
|
api.addEventListener("datasete-textgate-show", (e) => {
|
||||||
const d = e.detail || {};
|
const d = e.detail || {};
|
||||||
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
|
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
|
||||||
@@ -260,6 +282,7 @@ app.registerExtension({
|
|||||||
} else {
|
} else {
|
||||||
node._tg.area.value = d.text || "";
|
node._tg.area.value = d.text || "";
|
||||||
}
|
}
|
||||||
|
syncStored(node); // persist the shown text so a refresh/reload keeps it
|
||||||
setState(node, "paused");
|
setState(node, "paused");
|
||||||
try { node._tg.area.focus(); } catch (err) { /* ignore */ }
|
try { node._tg.area.focus(); } catch (err) { /* ignore */ }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user