From 66e664247c45e5d0b0cb314433e4a5b26f4882e1 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 1 Jul 2026 13:58:29 +0200 Subject: [PATCH 1/3] docs: save image + chainable sidecars design Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-06-29-sidecar-save-design.md | 60 ++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/plans/2026-06-29-sidecar-save-design.md diff --git a/docs/plans/2026-06-29-sidecar-save-design.md b/docs/plans/2026-06-29-sidecar-save-design.md new file mode 100644 index 0000000..94f615b --- /dev/null +++ b/docs/plans/2026-06-29-sidecar-save-design.md @@ -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). From 31a71120527b59b0ddfe687f2b4d3b06763f96cc Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 1 Jul 2026 14:00:38 +0200 Subject: [PATCH 2/3] feat: sidecar planning logic (filename resolution, allowlist, dedup) Co-Authored-By: Claude Opus 4.8 --- gates/sidecar.py | 54 ++++++++++++++++++++++++++++++++++ tests/test_sidecar.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 gates/sidecar.py create mode 100644 tests/test_sidecar.py diff --git a/gates/sidecar.py b/gates/sidecar.py new file mode 100644 index 0000000..cef9b76 --- /dev/null +++ b/gates/sidecar.py @@ -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 + ` + 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 '{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 diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py new file mode 100644 index 0000000..8a8cd89 --- /dev/null +++ b/tests/test_sidecar.py @@ -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) == [] From b2f5850b466e2f52f0be5bf9bf5fcc50390a1da2 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 1 Jul 2026 14:00:38 +0200 Subject: [PATCH 3/3] 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 --- __init__.py | 8 ++- gates/sidecar_node.py | 126 +++++++++++++++++++++++++++++++++++++ tests/test_sidecar_node.py | 34 ++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 gates/sidecar_node.py create mode 100644 tests/test_sidecar_node.py diff --git a/__init__.py b/__init__.py index 3e230e9..8cda9e3 100644 --- a/__init__.py +++ b/__init__.py @@ -20,14 +20,18 @@ if __package__: NODE_DISPLAY_NAME_MAPPINGS as _PROF_NAMES from .gates.bucket_node import NODE_CLASS_MAPPINGS as _BUCKET_NODES, \ NODE_DISPLAY_NAME_MAPPINGS as _BUCKET_NAMES + from .gates.sidecar_node import NODE_CLASS_MAPPINGS as _SC_NODES, \ + NODE_DISPLAY_NAME_MAPPINGS as _SC_NAMES from .gates import routes # noqa: F401 (registers aiohttp routes on import) from .gates import gate_server # noqa: F401 (registers /datasete_gate/* + text routes) from .gates import profiles_routes # noqa: F401 (registers /grid_pool/profiles/*) NODE_CLASS_MAPPINGS = {**_POOL_NODES, **_LOADER_NODES, **_GATE_NODES, - **_TEXT_NODES, **_PROF_NODES, **_BUCKET_NODES} + **_TEXT_NODES, **_PROF_NODES, **_BUCKET_NODES, + **_SC_NODES} NODE_DISPLAY_NAME_MAPPINGS = {**_POOL_NAMES, **_LOADER_NAMES, **_GATE_NAMES, - **_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES} + **_TEXT_NAMES, **_PROF_NAMES, **_BUCKET_NAMES, + **_SC_NAMES} else: # pragma: no cover - exercised only under pytest collection NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/gates/sidecar_node.py b/gates/sidecar_node.py new file mode 100644 index 0000000..61577c4 --- /dev/null +++ b/gates/sidecar_node.py @@ -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 = "Datasete 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 = "Datasete 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)", +} diff --git a/tests/test_sidecar_node.py b/tests/test_sidecar_node.py new file mode 100644 index 0000000..af623f8 --- /dev/null +++ b/tests/test_sidecar_node.py @@ -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"]