Merge branch 'main' into time_nodes

This commit is contained in:
StableLlama
2025-07-16 22:30:04 +02:00
8 changed files with 149 additions and 44 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "basic_data_handling"
version = "0.4.5"
version = "0.4.7"
description = """Basic Python functions for manipulating data that every programmer is used to, lightweight with no additional dependencies.
Supported data types:
@@ -308,6 +308,9 @@ class ExecutionOrder(ComfyNodeABC):
the execution order of nodes in the workflow. You only need to chain this node
with the other execution order nodes in the desired order and add any
output of the nodes you want to force execution order on.
This node also passes through any input connected to "any node output" as
its second output.
"""
@classmethod
def INPUT_TYPES(cls):
@@ -318,13 +321,16 @@ class ExecutionOrder(ComfyNodeABC):
}
}
RETURN_TYPES = ("E/O",)
RETURN_TYPES = ("E/O", IO.ANY)
RETURN_NAMES = ("E/O", "passthrough")
FUNCTION = "execution_order"
CATEGORY = "Basic/flow control"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "execute"
def execute(self, **kwargs) -> tuple[Any]:
return (None,)
def execute(self, **kwargs: list[Any]) -> tuple[None, Any]:
any_node_output = kwargs.get('any node output', [])
return (None, any_node_output)
NODE_CLASS_MAPPINGS = {
@@ -747,6 +747,10 @@ class DataListPopRandom(ComfyNodeABC):
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True, False)
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Not equal to anything -> trigger recalculation
def pop_random_element(self, **kwargs: list[Any]) -> tuple[list[Any], Any]:
from random import randrange
input_list = kwargs.get('list', []).copy()
+4
View File
@@ -736,6 +736,10 @@ class DictPopRandom(ComfyNodeABC):
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "pop_random"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Not equal to anything -> trigger recalculation
def pop_random(self, input_dict: dict) -> tuple[dict, str, Any, bool]:
import random
result = input_dict.copy()
+4
View File
@@ -610,6 +610,10 @@ class ListPopRandom(ComfyNodeABC):
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "pop_random_element"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Not equal to anything -> trigger recalculation
def pop_random_element(self, list: list[Any]) -> tuple[list[Any], Any]:
import random
result = list.copy()
+116 -31
View File
@@ -28,13 +28,15 @@ def load_image_helper(path: str):
pass
if not os.path.exists(path):
raise FileNotFoundError(f"Basic data handling: Image file not found: {path}")
return None
# Open and process the image
img = Image.open(path)
img = ImageOps.exif_transpose(img)
return img
try:
img = Image.open(path)
img = ImageOps.exif_transpose(img)
return img
except Exception:
return None
def extract_mask_from_alpha(img):
@@ -675,19 +677,33 @@ class PathLoadStringFile(ComfyNodeABC):
},
}
RETURN_TYPES = (IO.STRING,)
RETURN_NAMES = ("text",)
RETURN_TYPES = (IO.STRING, IO.BOOLEAN)
RETURN_NAMES = ("text", "exists")
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "load_text"
def load_text(self, path: str):
if not os.path.exists(path):
raise FileNotFoundError(f"Basic data handling: String file not found: {path}")
@classmethod
def IS_CHANGED(cls, path):
try:
if os.path.exists(path):
return os.path.getmtime(path)
except Exception:
pass
return float("NaN") # Return NaN if file doesn't exist or can't access modification time
with open(path, "r", encoding="utf-8") as f:
text = f.read()
return (text,)
def load_text(self, path: str):
exists = os.path.exists(path)
if not exists:
return ("", False)
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
return (text, True)
except Exception:
return ("", False)
class PathLoadImageRGB(ComfyNodeABC):
@@ -705,18 +721,32 @@ class PathLoadImageRGB(ComfyNodeABC):
},
}
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
RETURN_TYPES = (IO.IMAGE, IO.BOOLEAN)
RETURN_NAMES = ("image", "exists")
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "load_image_rgb"
@classmethod
def IS_CHANGED(cls, path):
try:
if os.path.exists(path):
return os.path.getmtime(path)
except Exception:
pass
return float("NaN") # Return NaN if file doesn't exist or can't access modification time
def load_image_rgb(self, path: str):
import numpy as np
import torch
img = load_image_helper(path)
if img is None:
# Create an empty 1x1 image
empty_tensor = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
return (empty_tensor, False)
# Convert to RGB (removing alpha if present)
img_rgb = img.convert("RGB")
@@ -724,7 +754,7 @@ class PathLoadImageRGB(ComfyNodeABC):
image_tensor = np.array(img_rgb).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_tensor)[None,]
return (image_tensor,)
return (image_tensor, True)
class PathLoadImageRGBA(ComfyNodeABC):
@@ -743,18 +773,33 @@ class PathLoadImageRGBA(ComfyNodeABC):
},
}
RETURN_TYPES = (IO.IMAGE, IO.MASK)
RETURN_NAMES = ("image", "mask")
RETURN_TYPES = (IO.IMAGE, IO.MASK, IO.BOOLEAN)
RETURN_NAMES = ("image", "mask", "exists")
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "load_image_rgba"
@classmethod
def IS_CHANGED(cls, path):
try:
if os.path.exists(path):
return os.path.getmtime(path)
except Exception:
pass
return float("NaN") # Return NaN if file doesn't exist or can't access modification time
def load_image_rgba(self, path: str):
import numpy as np
import torch
img = load_image_helper(path)
if img is None:
# Create empty 1x1 image and mask
empty_image = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32)
return (empty_image, empty_mask, False)
# Convert to RGB for the image
img_rgb = img.convert("RGB")
@@ -765,7 +810,7 @@ class PathLoadImageRGBA(ComfyNodeABC):
# Extract alpha channel as mask
mask_tensor = extract_mask_from_alpha(img)
return (image_tensor, mask_tensor)
return (image_tensor, mask_tensor, True)
class PathLoadMaskFromAlpha(ComfyNodeABC):
@@ -784,16 +829,33 @@ class PathLoadMaskFromAlpha(ComfyNodeABC):
},
}
RETURN_TYPES = (IO.MASK,)
RETURN_NAMES = ("mask",)
RETURN_TYPES = (IO.MASK, IO.BOOLEAN)
RETURN_NAMES = ("mask", "exists")
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "load_mask_from_alpha"
@classmethod
def IS_CHANGED(cls, path):
try:
if os.path.exists(path):
return os.path.getmtime(path)
except Exception:
pass
return float("NaN") # Return NaN if file doesn't exist or can't access modification time
def load_mask_from_alpha(self, path: str):
import torch
img = load_image_helper(path)
if img is None:
# Return empty 1x1 mask
empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32)
return (empty_mask, False)
mask_tensor = extract_mask_from_alpha(img)
return (mask_tensor,)
return (mask_tensor, True)
class PathLoadMaskFromGreyscale(ComfyNodeABC):
@@ -815,21 +877,38 @@ class PathLoadMaskFromGreyscale(ComfyNodeABC):
},
}
RETURN_TYPES = (IO.MASK,)
RETURN_NAMES = ("mask",)
RETURN_TYPES = (IO.MASK, IO.BOOLEAN)
RETURN_NAMES = ("mask", "exists")
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "load_mask_from_greyscale"
@classmethod
def IS_CHANGED(cls, path):
try:
if os.path.exists(path):
return os.path.getmtime(path)
except Exception:
pass
return float("NaN") # Return NaN if file doesn't exist or can't access modification time
def load_mask_from_greyscale(self, path: str, invert: bool = False):
import torch
img = load_image_helper(path)
if img is None:
# Return empty 1x1 mask
empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32)
return (empty_mask, False)
mask_tensor = extract_mask_from_greyscale(img)
# Optionally invert the mask (1.0 - mask)
if invert:
mask_tensor = 1.0 - mask_tensor
return (mask_tensor,)
return (mask_tensor, True)
class PathSaveStringFile(ComfyNodeABC):
@@ -852,8 +931,8 @@ class PathSaveStringFile(ComfyNodeABC):
}
}
RETURN_TYPES = (IO.BOOLEAN)
RETURN_NAMES = ("success")
RETURN_TYPES = (IO.BOOLEAN,)
RETURN_NAMES = ("success",)
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "save_text"
@@ -861,6 +940,7 @@ class PathSaveStringFile(ComfyNodeABC):
def save_text(self, text: str, path: str, create_dirs: bool = True, encoding: str = "utf-8"):
if not path:
print("Basic data handling: Save failed - no path specified")
return (False,)
try:
@@ -872,6 +952,7 @@ class PathSaveStringFile(ComfyNodeABC):
with open(path, "w", encoding=encoding) as f:
f.write(text)
print(f"Basic data handling: Successfully saved text to {path}")
return (True,)
except Exception as e:
print(f"Basic data handling: Error saving text file: {e}")
@@ -899,8 +980,8 @@ class PathSaveImageRGB(ComfyNodeABC):
}
}
RETURN_TYPES = (IO.BOOLEAN)
RETURN_NAMES = ("success")
RETURN_TYPES = (IO.BOOLEAN,)
RETURN_NAMES = ("success",)
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "save_image"
@@ -908,6 +989,7 @@ class PathSaveImageRGB(ComfyNodeABC):
def save_image(self, images, path: str, format: str = "png", quality: int = 95, create_dirs: bool = True):
if not path:
print("Basic data handling: Save failed - no path specified")
return (False,)
# If the path doesn't have an extension or it doesn't match the format, add it
@@ -957,6 +1039,7 @@ class PathSaveImageRGB(ComfyNodeABC):
else:
pil_img.save(path, format=format.upper())
print(f"Basic data handling: Successfully saved image to {path}")
return (True,)
except Exception as e:
print(f"Basic data handling: Error saving image: {e}")
@@ -987,8 +1070,8 @@ class PathSaveImageRGBA(ComfyNodeABC):
}
}
RETURN_TYPES = (IO.BOOLEAN)
RETURN_NAMES = ("success")
RETURN_TYPES = (IO.BOOLEAN,)
RETURN_NAMES = ("success",)
CATEGORY = "Basic/Path"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "save_image_with_mask"
@@ -998,6 +1081,7 @@ class PathSaveImageRGBA(ComfyNodeABC):
quality: int = 95, invert_mask: bool = False,
create_dirs: bool = True):
if not path:
print("Basic data handling: Save failed - no path specified")
return (False,)
# Check format compatibility - needs to support alpha channel
@@ -1065,6 +1149,7 @@ class PathSaveImageRGBA(ComfyNodeABC):
else:
pil_img_rgba.save(path, format=format.upper())
print(f"Basic data handling: Successfully saved image with mask to {path}")
return (True,)
except Exception as e:
print(f"Basic data handling: Error saving image with mask: {e}")
+4
View File
@@ -520,6 +520,10 @@ class SetPopRandom(ComfyNodeABC):
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "pop_random_element"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Not equal to anything -> trigger recalculation
def pop_random_element(self, set: set[Any]) -> tuple[set[Any], Any]:
import random
result = set.copy()
+7 -9
View File
@@ -41,7 +41,7 @@ def test_path_load_save_string_file(tmp_path):
# Test loading the string back
load_node = PathLoadStringFile()
loaded_string = load_node.load_text(file_path)
assert loaded_string == (test_string,)
assert loaded_string == (test_string, True)
# Test creating directories when saving
nested_path = str(tmp_path / "nested" / "dir" / "test.txt")
@@ -52,12 +52,10 @@ def test_path_load_save_string_file(tmp_path):
utf8_text = "UTF-8 text with special chars: 你好, ñ, é, ö"
utf8_path = str(tmp_path / "utf8_test.txt")
assert save_node.save_text(utf8_text, utf8_path, encoding="utf-8") == (True,)
assert load_node.load_text(utf8_path) == (utf8_text,)
assert load_node.load_text(utf8_path) == (utf8_text, True)
# Test error handling
with pytest.raises(FileNotFoundError):
load_node.load_text(str(tmp_path / "nonexistent.txt"))
assert load_node.load_text(str(tmp_path / "nonexistent.txt")) == ("", False)
def test_path_abspath():
@@ -304,7 +302,7 @@ def test_path_load_mask_nodes(tmp_path, monkeypatch):
# Verify the mask shape
assert isinstance(alpha_mask, tuple)
assert len(alpha_mask) == 1
assert len(alpha_mask) == 2
assert isinstance(alpha_mask[0], torch.Tensor)
assert alpha_mask[0].shape == (1, img_size[1], img_size[0])
@@ -314,7 +312,7 @@ def test_path_load_mask_nodes(tmp_path, monkeypatch):
# Verify the mask shape
assert isinstance(gray_mask, tuple)
assert len(gray_mask) == 1
assert len(gray_mask) == 2
assert isinstance(gray_mask[0], torch.Tensor)
assert gray_mask[0].shape == (1, img_size[1], img_size[0])
@@ -352,7 +350,7 @@ def test_path_load_save_image_rgba(tmp_path, monkeypatch):
# Test loading an image with alpha
load_node = PathLoadImageRGBA()
loaded_img, loaded_mask = load_node.load_image_rgba(img_path)
loaded_img, loaded_mask, success = load_node.load_image_rgba(img_path)
# Verify that the returned objects are tensors with the right shapes
assert isinstance(loaded_img, torch.Tensor)
@@ -410,7 +408,7 @@ def test_path_load_save_image_rgb(tmp_path, monkeypatch):
# Verify that the returned object is a tensor with the right shape
assert isinstance(loaded_img, tuple)
assert len(loaded_img) == 1
assert len(loaded_img) == 2
assert isinstance(loaded_img[0], torch.Tensor)
assert loaded_img[0].shape == (1, img_size[1], img_size[0], 3) # (batch, height, width, channels)