4 Commits
Author SHA1 Message Date
AngelBottomless (sleepy) 0cb60bc8aa I think this is something env dependent 2026-01-21 07:40:43 +00:00
AngelBottomless (sleepy) 1da10d7a31 fix 2026-01-21 07:28:53 +00:00
AngelBottomless (sleepy) a39013912d enable auto install 2026-01-21 07:18:56 +00:00
AngelBottomless (sleepy) 835e382cd9 add two nodes 2026-01-21 07:03:24 +00:00
5 changed files with 247 additions and 72 deletions
+2 -2
View File
@@ -12,5 +12,5 @@ Run from the repo root:
## Notes
- Auto-install is opt-in via `COMFYUI_LOGICUTILS_AUTO_INSTALL=1`.
- To force-disable the install hook, set `COMFYUI_LOGICUTILS_SKIP_INSTALL=1`.
- Auto-install runs when loaded in ComfyUI. To disable it, set `COMFYUI_LOGICUTILS_SKIP_INSTALL=1`.
- To force CPU/GPU tagger dependency selection, set `COMFYUI_LOGICUTILS_IMGUTILS_VARIANT=cpu` or `COMFYUI_LOGICUTILS_IMGUTILS_VARIANT=gpu`.
+37 -20
View File
@@ -32,45 +32,62 @@ def process_wrap(cmd_str, cwd=None, handler=None):
return process.wait()
if "python_embeded" in sys.executable or "python_embedded" in sys.executable: #standalone python version
pip_install = [sys.executable, '-s', '-m', 'pip', 'install', "-U"]
pip_install = [sys.executable, '-s', '-m', 'pip', 'install', "-U", "--default-timeout=1000"]
else:
pip_install = [sys.executable, '-m', 'pip', 'install', "-U"]
pip_install = [sys.executable, '-m', 'pip', 'install', "-U", "--default-timeout=1000"]
def _imgutils_install_candidates() -> list[str]:
"""
Decide which dghs-imgutils package variant to install first.
- `dghs-imgutils[gpu]` includes optional GPU-related dependencies.
- If GPU detection fails, default to CPU variant first.
"""
variant = os.environ.get("COMFYUI_LOGICUTILS_IMGUTILS_VARIANT", "").strip().lower()
if variant in {"gpu", "cuda"}:
return ["dghs-imgutils[gpu]", "dghs-imgutils"]
if variant in {"cpu"}:
return ["dghs-imgutils", "dghs-imgutils[gpu]"]
# auto
try:
import torch
if getattr(torch, "cuda", None) is not None and torch.cuda.is_available():
return ["dghs-imgutils[gpu]", "dghs-imgutils"]
except Exception:
pass
return ["dghs-imgutils", "dghs-imgutils[gpu]"]
def initialization():
auto_install = os.environ.get("COMFYUI_LOGICUTILS_AUTO_INSTALL", "").strip().lower() in {
"1",
"true",
"yes",
}
if not auto_install:
return
try:
import piexif
except Exception:
print("piexif not found, installing...")
run_installation("piexif")
try:
import chardet
except Exception:
print("chardet not found, installing...")
run_installation("chardet")
try:
from imgutils.tagging import get_wd14_tags
except Exception:
# dghs-imgutils currently pins numpy<2, which typically won't have wheels for
# the latest Python releases right away (e.g. Python 3.13 in ComfyUI portable).
if sys.version_info >= (3, 13):
print(
"Skipping auto-install of dghs-imgutils on Python >= 3.13 "
"(tagger nodes will be disabled unless installed manually)."
)
else:
run_installation("dghs-imgutils[gpu]")
print("imgutils not found, installing...")
for candidate in _imgutils_install_candidates():
print(f"Trying to install {candidate}...")
run_installation(candidate)
try:
from imgutils.tagging import get_wd14_tags # noqa: F401
break
except Exception:
continue
try:
from Crypto.PublicKey import RSA
except Exception:
print("pycryptodome not found, installing...")
run_installation("pycryptodome")
def run_installation(pkg_name: str):
print(f"Installing {pkg_name}...")
try:
+121 -22
View File
@@ -1,8 +1,10 @@
import base64
import json
import math
import numpy as np
import torch
import base64
import json
import math
import random
from pathlib import Path
import numpy as np
import torch
try:
import piexif.helper
@@ -311,7 +313,7 @@ class SaveImageCustomNode:
@fundamental_node
class SaveTextCustomNode:
class SaveTextCustomNode:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
@@ -353,11 +355,40 @@ class SaveTextCustomNode:
f.write(text)
results.append({"filename": file, "subfolder": subfolder, "type": self.type})
return {"ui": {"texts": results}, "outputs": {"images": file.rstrip(".txt")}}
@fundamental_node
class DumpTextJsonlNode:
return {"ui": {"texts": results}, "outputs": {"images": file.rstrip(".txt")}}
@fundamental_node
class CommaRejoinNode:
FUNCTION = "comma_rejoin"
RETURN_TYPES = ("STRING",)
CATEGORY = "text"
custom_name = "Comma Rejoin"
@staticmethod
def comma_rejoin(text, split_separator=",", join_separator=", "):
# Split (by comma or given separator), strip items, then join using the
# join separator verbatim (e.g. ", ").
parts = str(text).split(split_separator) if split_separator else [str(text)]
stripped = [part.strip() for part in parts]
stripped = [part for part in stripped if part != ""]
return (join_separator.join(stripped),)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"default": ""}),
},
"optional": {
"split_separator": ("STRING", {"default": ","}),
"join_separator": ("STRING", {"default": ", "}),
},
}
@fundamental_node
class DumpTextJsonlNode:
"""
Appends text to a JSONL file (one JSON object per line).
Each line will have the structure: { "<keyname>": "<text_item>" }
@@ -1275,7 +1306,7 @@ class Base64DecodeNode:
@fundamental_node
class ImageFromURLNode:
class ImageFromURLNode:
FUNCTION = "url_download"
RETURN_TYPES = ("IMAGE",)
CATEGORY = "image"
@@ -1292,16 +1323,84 @@ class ImageFromURLNode:
return (image,)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING",),
}
}
@fundamental_node
class Base64EncodeNode:
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING",),
}
}
@fundamental_node
class RandomImageFromFolderNode:
FUNCTION = "random_image_from_folder"
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "path")
CATEGORY = "image"
custom_name = "Random Image From Folder"
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return float("nan")
@staticmethod
@PILHandlingHodes.output_wrapper
def random_image_from_folder(folder, extension, recursive, seed=0):
folder = str(folder).strip()
if not folder:
raise ValueError("folder must be a non-empty path")
folder_path = Path(folder).expanduser()
if not folder_path.is_dir():
raise ValueError(f"folder is not a directory: {folder_path}")
ext = str(extension).strip().lower().lstrip(".")
if ext == "all":
extensions = {".jpg", ".jpeg", ".png", ".webp"}
elif ext == "jpg":
extensions = {".jpg", ".jpeg"}
else:
extensions = {f".{ext}"}
candidates = []
if recursive:
for suffix in extensions:
candidates.extend(folder_path.rglob(f"*{suffix}"))
else:
for suffix in extensions:
candidates.extend(folder_path.glob(f"*{suffix}"))
candidates = [p for p in candidates if p.is_file()]
if not candidates:
raise FileNotFoundError(
f"No images found in '{folder_path}' with extension '{extension}'"
)
candidates = sorted(set(candidates), key=lambda p: str(p).lower())
rng = random.Random(seed)
chosen = candidates[rng.randrange(len(candidates))]
with Image.open(chosen) as img:
img = img.convert("RGB")
return (img, str(chosen))
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder": ("STRING", {"default": ""}),
"extension": (["all", "jpg", "png", "webp"], {"default": "all"}),
"recursive": ("BOOLEAN", {"default": True}),
},
"optional": {
"seed": ("INT", {"default": 0, "min": 0, "max": 2**63 - 1}),
},
}
@fundamental_node
class Base64EncodeNode:
FUNCTION = "base64_encode"
RETURN_TYPES = ("STRING",)
CATEGORY = "image"
+30 -28
View File
@@ -20,7 +20,9 @@ _SKIP_INSTALL = os.environ.get("COMFYUI_LOGICUTILS_SKIP_INSTALL", "").strip().lo
if _IN_COMFYUI and not _SKIP_INSTALL:
initialization()
else:
print("Skipping ComfyUI-LogicUtils installation.")
from .logic_gates import CLASS_MAPPINGS as LogicMapping, CLASS_NAMES as LogicNames
from .randomness import CLASS_MAPPINGS as RandomMapping, CLASS_NAMES as RandomNames
from .conversion import CLASS_MAPPINGS as ConversionMapping, CLASS_NAMES as ConversionNames
@@ -32,33 +34,33 @@ else:
IONames = {}
from .auxilary import CLASS_MAPPINGS as AuxilaryMapping, CLASS_NAMES as AuxilaryNames
from .external import CLASS_MAPPINGS as ExternalMapping, CLASS_NAMES as ExternalNames
NODE_CLASS_MAPPINGS = {
}
NODE_CLASS_MAPPINGS.update(IOMapping)
NODE_CLASS_MAPPINGS.update(LogicMapping)
NODE_CLASS_MAPPINGS.update(RandomMapping)
NODE_CLASS_MAPPINGS.update(ConversionMapping)
NODE_CLASS_MAPPINGS.update(MathMapping)
NODE_CLASS_MAPPINGS.update(ExternalMapping)
NODE_CLASS_MAPPINGS.update(AuxilaryMapping)
NODE_DISPLAY_NAME_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS.update(IONames)
NODE_DISPLAY_NAME_MAPPINGS.update(LogicNames)
NODE_DISPLAY_NAME_MAPPINGS.update(RandomNames)
NODE_DISPLAY_NAME_MAPPINGS.update(ConversionNames)
NODE_DISPLAY_NAME_MAPPINGS.update(MathNames)
NODE_DISPLAY_NAME_MAPPINGS.update(ExternalNames)
NODE_DISPLAY_NAME_MAPPINGS.update(AuxilaryNames)
NODE_CLASS_MAPPINGS = {
}
NODE_CLASS_MAPPINGS.update(IOMapping)
NODE_CLASS_MAPPINGS.update(LogicMapping)
NODE_CLASS_MAPPINGS.update(RandomMapping)
NODE_CLASS_MAPPINGS.update(ConversionMapping)
NODE_CLASS_MAPPINGS.update(MathMapping)
NODE_CLASS_MAPPINGS.update(ExternalMapping)
NODE_CLASS_MAPPINGS.update(AuxilaryMapping)
NODE_DISPLAY_NAME_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS.update(IONames)
NODE_DISPLAY_NAME_MAPPINGS.update(LogicNames)
NODE_DISPLAY_NAME_MAPPINGS.update(RandomNames)
NODE_DISPLAY_NAME_MAPPINGS.update(ConversionNames)
NODE_DISPLAY_NAME_MAPPINGS.update(MathNames)
NODE_DISPLAY_NAME_MAPPINGS.update(ExternalNames)
NODE_DISPLAY_NAME_MAPPINGS.update(AuxilaryNames)
try:
from .pystructure import CLASS_MAPPINGS as PyStructureMapping, CLASS_NAMES as PyStructureNames
NODE_CLASS_MAPPINGS.update(PyStructureMapping)
+57
View File
@@ -0,0 +1,57 @@
import tempfile
import unittest
from pathlib import Path
import torch
from PIL import Image
from import_utils import import_local
class TestIoNodesExtras(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.io_node = import_local("io_node")
def test_comma_rejoin_defaults(self):
Node = self.io_node.CLASS_MAPPINGS["CommaRejoinNode"]
self.assertEqual(Node.comma_rejoin("a,b , c"), ("a, b, c",))
self.assertEqual(Node.comma_rejoin(" a , b ,c "), ("a, b, c",))
def test_comma_rejoin_custom_separators(self):
Node = self.io_node.CLASS_MAPPINGS["CommaRejoinNode"]
self.assertEqual(
Node.comma_rejoin("a| b |c", split_separator="|", join_separator="| "),
("a| b| c",),
)
def test_random_image_from_folder(self):
Node = self.io_node.CLASS_MAPPINGS["RandomImageFromFolderNode"]
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
sub = root / "sub"
sub.mkdir()
img1 = root / "a.png"
img2 = root / "b.jpg"
img3 = sub / "c.png"
Image.new("RGB", (8, 6), (10, 20, 30)).save(img1)
Image.new("RGB", (8, 6), (40, 50, 60)).save(img2)
Image.new("RGB", (8, 6), (70, 80, 90)).save(img3)
# Non-recursive should only pick from root
tensor, path = Node.random_image_from_folder(
str(root), "all", False, seed=0
)
self.assertTrue(torch.is_tensor(tensor))
self.assertEqual(tuple(tensor.shape), (1, 6, 8, 3))
self.assertIn(Path(path), {img1, img2})
# Recursive should include subfolder images
tensor, path = Node.random_image_from_folder(str(root), "png", True, seed=0)
self.assertTrue(torch.is_tensor(tensor))
self.assertEqual(tuple(tensor.shape), (1, 6, 8, 3))
self.assertIn(Path(path), {img1, img3})