Refactor architecture and add new essential nodes (v1.5.1)

This commit is contained in:
DebugPadawan
2026-03-25 12:14:10 +01:00
parent 43cfdb3ac8
commit 4ecb9cb12c
7 changed files with 575 additions and 430 deletions
+37 -64
View File
@@ -1,71 +1,44 @@
"""
DebugPadawan's ComfyUI Essentials
A collection of essential custom nodes for ComfyUI
"""
import os
import importlib
import glob
from .nodes.text_processing import NODE_CLASS_MAPPINGS as TEXT_NODES
from .nodes.text_processing import NODE_DISPLAY_NAME_MAPPINGS as TEXT_DISPLAY_NAMES
from .nodes.utilities import UTILITY_NODE_CLASS_MAPPINGS as UTILITY_NODES
from .nodes.utilities import UTILITY_NODE_DISPLAY_NAME_MAPPINGS as UTILITY_DISPLAY_NAMES
from .nodes.timing import TIMING_NODE_CLASS_MAPPINGS as TIMING_NODES
from .nodes.timing import TIMING_NODE_DISPLAY_NAME_MAPPINGS as TIMING_DISPLAY_NAMES
from .nodes.json import NODE_CLASS_MAPPINGS as JSON_NODES
from .nodes.json import NODE_DISPLAY_NAME_MAPPINGS as JSON_DISPLAY_NAMES
from .nodes.image import NODE_CLASS_MAPPINGS as IMAGE_NODES
from .nodes.image import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_DISPLAY_NAMES
from .nodes.color_palette import NODE_CLASS_MAPPINGS as COLOR_PALETTE_NODES
from .nodes.color_palette import NODE_DISPLAY_NAME_MAPPINGS as COLOR_PALETTE_DISPLAY_NAMES
from .nodes.math_nodes import NODE_CLASS_MAPPINGS as MATH_NODES
from .nodes.math_nodes import NODE_DISPLAY_NAME_MAPPINGS as MATH_DISPLAY_NAMES
from .nodes.list_nodes import NODE_CLASS_MAPPINGS as LIST_NODES
from .nodes.list_nodes import NODE_DISPLAY_NAME_MAPPINGS as LIST_DISPLAY_NAMES
# Node registration dictionaries
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
from .nodes.json_to_text import NODE_CLASS_MAPPINGS as JSON_TO_TEXT_NODES
from .nodes.json_to_text import NODE_DISPLAY_NAME_MAPPINGS as JSON_TO_TEXT_DISPLAY_NAMES
from .nodes.string_formatter import NODE_CLASS_MAPPINGS as STRING_FORMATTER_NODES
from .nodes.string_formatter import NODE_DISPLAY_NAME_MAPPINGS as STRING_FORMATTER_DISPLAY_NAMES
from .nodes.text_compare import NODE_CLASS_MAPPINGS as TEXT_COMPARE_NODES
from .nodes.text_compare import NODE_DISPLAY_NAME_MAPPINGS as TEXT_COMPARE_DISPLAY_NAMES
from .nodes.type_conversion import NODE_CLASS_MAPPINGS as TYPE_CONVERSION_NODES
from .nodes.type_conversion import NODE_DISPLAY_NAME_MAPPINGS as TYPE_CONVERSION_DISPLAY_NAMES
from .nodes.number_utils import NODE_CLASS_MAPPINGS as NUMBER_UTILS_NODES
from .nodes.number_utils import NODE_DISPLAY_NAME_MAPPINGS as NUMBER_UTILS_DISPLAY_NAMES
# Automatically import all .py files from the nodes directory
nodes_dir = os.path.join(os.path.dirname(__file__), "nodes")
node_files = glob.glob(os.path.join(nodes_dir, "*.py"))
# Combine all node mappings
NODE_CLASS_MAPPINGS = {
**TEXT_NODES,
**UTILITY_NODES,
**TIMING_NODES,
**JSON_NODES,
**IMAGE_NODES,
**COLOR_PALETTE_NODES,
**MATH_NODES,
**LIST_NODES,
**JSON_TO_TEXT_NODES,
**STRING_FORMATTER_NODES,
**TEXT_COMPARE_NODES,
**TYPE_CONVERSION_NODES,
**NUMBER_UTILS_NODES,
}
for file_path in node_files:
file_name = os.path.basename(file_path)
if file_name == "__init__.py":
continue
module_name = f".nodes.{file_name[:-3]}"
try:
# Import the module
module = importlib.import_module(module_name, package=__package__)
# Load mappings if they exist
if hasattr(module, "NODE_CLASS_MAPPINGS"):
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
# UTILITY_NODE_CLASS_MAPPINGS for backward compatibility
if hasattr(module, "UTILITY_NODE_CLASS_MAPPINGS"):
NODE_CLASS_MAPPINGS.update(module.UTILITY_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {
**TEXT_DISPLAY_NAMES,
**UTILITY_DISPLAY_NAMES,
**TIMING_DISPLAY_NAMES,
**JSON_DISPLAY_NAMES,
**IMAGE_DISPLAY_NAMES,
**COLOR_PALETTE_DISPLAY_NAMES,
**MATH_DISPLAY_NAMES,
**LIST_DISPLAY_NAMES,
**JSON_TO_TEXT_DISPLAY_NAMES,
**STRING_FORMATTER_DISPLAY_NAMES,
**TEXT_COMPARE_DISPLAY_NAMES,
**TYPE_CONVERSION_DISPLAY_NAMES,
**NUMBER_UTILS_DISPLAY_NAMES,
}
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS"):
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
# UTILITY_NODE_DISPLAY_NAME_MAPPINGS for backward compatibility
if hasattr(module, "UTILITY_NODE_DISPLAY_NAME_MAPPINGS"):
NODE_DISPLAY_NAME_MAPPINGS.update(module.UTILITY_NODE_DISPLAY_NAME_MAPPINGS)
except Exception as e:
print(f"[DebugPadawan Essentials] Failed to load module {module_name}: {e}")
# Version and Metadata
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
# Version info
__version__ = "1.5.0"
__version__ = "1.5.1"
__author__ = "DebugPadawan"
+31 -25
View File
@@ -1,9 +1,11 @@
import numpy as np
import torch
from typing import Tuple
class ColorPaletteExtractor:
"""
Node for extracting the most dominant colors from an image
Node for extracting the most dominant colors from an image.
Uses quantization and frequency analysis for speed and accuracy.
"""
@classmethod
def INPUT_TYPES(cls):
@@ -19,45 +21,49 @@ class ColorPaletteExtractor:
FUNCTION = "extract"
CATEGORY = "DebugPadawan/Image"
def extract(self, image, color_count):
def extract(self, image: torch.Tensor, color_count: int) -> Tuple[str, str, torch.Tensor]:
# Image is typically [B, H, W, C]
# We'll take the first image in the batch
# We'll take the first image in the batch for analysis
img = image[0]
h, w, c = img.shape
c = img.shape[-1]
# Rescale for performance using torch
img_torch = img.permute(2, 0, 1).unsqueeze(0) # [1, C, H, W]
img_small = torch.nn.functional.interpolate(img_torch, size=(128, 128), mode='area')
img_np = img_small.squeeze(0).permute(1, 2, 0).numpy()
# Using a smaller size for faster processing
img_small = torch.nn.functional.interpolate(img_torch, size=(64, 64), mode='area')
img_np = img_small.squeeze(0).permute(1, 2, 0).cpu().numpy()
# Flatten and scale to 0-255
pixels = img_np.reshape(-1, c) * 255.0
pixels = (img_np.reshape(-1, c) * 255.0).astype(np.int32)
# Simple quantization
pixels = (pixels / 16).astype(int) * 16
# Simple quantization (group colors together)
# We group by 16 levels to reduce noise
pixels = (pixels // 16) * 16
# Convert to hex strings
hex_colors = []
for p in pixels:
r, g, b = p
hex_colors.append(f'#{r:02x}{g:02x}{b:02x}')
# Map each pixel to a unique integer color representation (R << 16 | G << 8 | B)
# This is much faster than string formatting for all pixels
rgb_int = (pixels[:, 0] << 16) | (pixels[:, 1] << 8) | pixels[:, 2]
# Count frequencies
unique, counts = np.unique(hex_colors, return_counts=True)
unique, counts = np.unique(rgb_int, return_counts=True)
sorted_indices = np.argsort(-counts)
top_hex = unique[sorted_indices[:color_count]]
dominant = top_hex[0] if len(top_hex) > 0 else "#000000"
top_colors = unique[sorted_indices[:color_count]]
# Create a palette image
palette_h = 64
palette_w = color_count * 64
palette_img = np.zeros((palette_h, palette_w, 3), dtype=np.float32)
def int_to_hex(val):
return f"#{val >> 16 & 0xFF:02x}{val >> 8 & 0xFF:02x}{val & 0xFF:02x}"
top_hex = [int_to_hex(c) for c in top_colors]
dominant = top_hex[0] if top_hex else "#000000"
for i, hex_color in enumerate(top_hex):
r = int(hex_color[1:3], 16) / 255.0
g = int(hex_color[3:5], 16) / 255.0
b = int(hex_color[5:7], 16) / 255.0
# Create a visual palette image
p_h, p_w = 64, color_count * 64
palette_img = np.zeros((p_h, p_w, 3), dtype=np.float32)
for i, val in enumerate(top_colors):
r = ((val >> 16) & 0xFF) / 255.0
g = ((val >> 8) & 0xFF) / 255.0
b = (val & 0xFF) / 255.0
palette_img[:, i*64:(i+1)*64, 0] = r
palette_img[:, i*64:(i+1)*64, 1] = g
palette_img[:, i*64:(i+1)*64, 2] = b
+103
View File
@@ -0,0 +1,103 @@
import os
import json
from typing import Any, Tuple
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
generic_type = AnyType("*")
class NodeSearch:
"""
Utility node to list or search available ComfyUI nodes (by class or name).
Helpful for developers to find node internal names.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"search_query": ("STRING", {"default": ""}),
"search_mode": (["class_name", "display_name", "category"],),
}
}
RETURN_TYPES = ("LIST", "STRING")
RETURN_NAMES = ("node_list", "count_summary")
FUNCTION = "search_nodes"
CATEGORY = "DebugPadawan/Utilities"
def search_nodes(self, search_query: str, search_mode: str) -> Tuple[list, str]:
# This requires access to ComfyUI's internal node mapping
# NOTE: In actual execution, we'd need to import it.
# This is a bit of a trick as it's usually in `nodes.NODE_CLASS_MAPPINGS`
try:
import nodes as comfy_nodes
mappings = comfy_nodes.NODE_CLASS_MAPPINGS
display_names = comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS
except ImportError:
return ([], "Could not access ComfyUI node mappings")
results = []
query = search_query.lower()
for k, v in mappings.items():
disp = display_names.get(k, k)
cat = getattr(v, "CATEGORY", "Unknown")
match = False
if not query:
match = True
elif search_mode == "class_name" and query in k.lower():
match = True
elif search_mode == "display_name" and query in disp.lower():
match = True
elif search_mode == "category" and query in cat.lower():
match = True
if match:
results.append(f"{k} | {disp} | {cat}")
results.sort()
return (results, f"Found {len(results)} nodes matching '{search_query}'")
class TextFileLoader:
"""
Loads text from a file.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"default": "example.txt"}),
}
}
RETURN_TYPES = ("STRING", "LIST")
RETURN_NAMES = ("content", "lines")
FUNCTION = "load_file"
CATEGORY = "DebugPadawan/Utilities"
def load_file(self, file_path: str) -> Tuple[str, list]:
if not os.path.exists(file_path):
return (f"File not found: {file_path}", [])
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.splitlines()
return (content, lines)
except Exception as e:
return (f"Error loading file: {str(e)}", [])
NODE_CLASS_MAPPINGS = {
"DebugPadawan_NodeSearch": NodeSearch,
"DebugPadawan_TextFileLoader": TextFileLoader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DebugPadawan_NodeSearch": "Node Search Utility",
"DebugPadawan_TextFileLoader": "Load Text File",
}
+39 -12
View File
@@ -1,8 +1,10 @@
import torch
from typing import Tuple
class ImageInfo:
"""
Node for getting width, height, and batch size from an Image
Node for getting width, height, and batch size from an Image.
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -13,25 +15,50 @@ class ImageInfo:
RETURN_TYPES = ("INT", "INT", "INT")
RETURN_NAMES = ("width", "height", "batch_size")
FUNCTION = "get_info"
CATEGORY = "DebugPadawan/Image"
def get_info(self, image):
"""
Extract dimensions from a ComfyUI image tensor
ComfyUI images are formatted as [batch_size, height, width, channels]
"""
batch_size = image.shape[0]
height = image.shape[1]
width = image.shape[2]
def get_info(self, image: torch.Tensor) -> Tuple[int, int, int]:
# ComfyUI image tensor format: [B, H, W, C]
batch_size = image.shape[0] if len(image.shape) > 0 else 0
height = image.shape[1] if len(image.shape) > 1 else 0
width = image.shape[2] if len(image.shape) > 2 else 0
return (width, height, batch_size)
class ImageBatchSlicer:
"""
Slices a batch of images to extract a specific range or single image.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"index": ("INT", {"default": 0, "min": 0, "max": 1000}),
"count": ("INT", {"default": 1, "min": 1, "max": 1000}),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("sliced_images", "count")
FUNCTION = "slice_batch"
CATEGORY = "DebugPadawan/Image"
def slice_batch(self, images: torch.Tensor, index: int, count: int) -> Tuple[torch.Tensor, int]:
num_images = images.shape[0]
start_idx = min(index, num_images - 1)
end_idx = min(start_idx + count, num_images)
sliced = images[start_idx:end_idx]
return (sliced, sliced.shape[0])
NODE_CLASS_MAPPINGS = {
"DebugPadawan_ImageInfo": ImageInfo,
"DebugPadawan_ImageBatchSlicer": ImageBatchSlicer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DebugPadawan_ImageInfo": "Image Info",
"DebugPadawan_ImageInfo": "Image Dimensions Info",
"DebugPadawan_ImageBatchSlicer": "Image Batch Slicer",
}
+113 -80
View File
@@ -1,14 +1,14 @@
import torch
import random
from typing import List, Any, Tuple, Union
class GetListItem:
@classmethod
def INPUT_TYPES(s):
def INPUT_TYPES(cls):
return {
"required": {
"input_list": ("*", {"forceInput": True}),
"index": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"index": ("INT", {"default": 0, "min": -1000000, "max": 1000000}),
}
}
@@ -17,36 +17,101 @@ class GetListItem:
FUNCTION = "get_item"
CATEGORY = "DebugPadawan/List"
def get_item(self, input_list, index):
def get_item(self, input_list: Any, index: int) -> Tuple[Any]:
if not isinstance(input_list, list):
# Attempt to convert to list if it's a ComfyUI tensor batch or similar
if isinstance(input_list, torch.Tensor):
input_list = input_list.tolist()
elif hasattr(input_list, '__iter__') and not isinstance(input_list, str):
input_list = list(input_list)
else:
# If it's a single item, wrap it in a list to allow indexing
input_list = [input_list]
if not input_list:
raise ValueError("Input list is empty.")
if index < 0 or index >= len(input_list):
raise IndexError(f"Index {index} out of bounds for list of length {len(input_list)}.")
return (input_list[index],)
return (None,)
# Handle negative indexing
try:
return (input_list[index],)
except IndexError:
return (input_list[-1] if index >= 0 else input_list[0],)
class ListSlicer:
class ListCreate:
"""
Node for getting a slice of a list
Node for creating a list from up to 8 individual inputs.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
f"input_{i}": ("*", {"forceInput": True}) for i in range(1, 9)
}
}
RETURN_TYPES = ("LIST", "INT")
RETURN_NAMES = ("list", "count")
FUNCTION = "create_list"
CATEGORY = "DebugPadawan/List"
def create_list(self, **kwargs) -> Tuple[List[Any], int]:
result = [v for k, v in kwargs.items() if v is not None]
return (result, len(result))
class ListFilter:
"""
Node for filtering a list based on string matching or numeric bounds.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_list": ("LIST",),
"filter_mode": (["contains", "starts_with", "ends_with", "regex", "equals"],),
"filter_value": ("STRING", {"default": ""}),
},
"optional": {
"exclude": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("LIST", "INT")
RETURN_NAMES = ("filtered_list", "count")
FUNCTION = "filter_list"
CATEGORY = "DebugPadawan/List"
def filter_list(self, input_list: List[Any], filter_mode: str, filter_value: str, exclude: bool = False) -> Tuple[List[Any], int]:
import re
result = []
for item in input_list:
s_item = str(item)
match = False
if filter_mode == "contains": match = filter_value in s_item
elif filter_mode == "starts_with": match = s_item.startswith(filter_value)
elif filter_mode == "ends_with": match = s_item.endswith(filter_value)
elif filter_mode == "equals": match = s_item == filter_value
elif filter_mode == "regex":
try:
match = bool(re.search(filter_value, s_item))
except:
match = False
if exclude: match = not match
if match: result.append(item)
return (result, len(result))
class ListSlicer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_list": ("LIST",),
"start": ("INT", {"default": 0, "min": 0}),
"end": ("INT", {"default": 0, "min": 0}),
"end": ("INT", {"default": 1, "min": 0}),
}
}
@@ -55,8 +120,9 @@ class ListSlicer:
FUNCTION = "slice_list"
CATEGORY = "DebugPadawan/List"
def slice_list(self, input_list, start, end):
if end == 0:
def slice_list(self, input_list: List, start: int, end: int) -> Tuple[List, int]:
# If end is 0 or less than start, we treat as "to the end" or at least 1 item
if end <= start:
res = input_list[start:]
else:
res = input_list[start:end]
@@ -64,9 +130,6 @@ class ListSlicer:
class ListInfo:
"""
Node for getting information about a list
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -77,24 +140,16 @@ class ListInfo:
RETURN_TYPES = ("INT", "STRING", "STRING")
RETURN_NAMES = ("count", "first_item", "last_item")
FUNCTION = "get_list_info"
CATEGORY = "DebugPadawan/Utilities"
CATEGORY = "DebugPadawan/List"
def get_list_info(self, input_list):
count = len(input_list)
first_item = str(input_list[0]) if input_list else ""
last_item = str(input_list[-1]) if input_list else ""
return (count, first_item, last_item)
def get_list_info(self, input_list: List) -> Tuple[int, str, str]:
if not input_list:
return (0, "", "")
return (len(input_list), str(input_list[0]), str(input_list[-1]))
class RandomListSelector:
"""
Node for randomly selecting one or more items from a list
Supports seeded randomness for reproducible selections
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -108,20 +163,16 @@ class RandomListSelector:
RETURN_TYPES = ("LIST", "INT", "*")
RETURN_NAMES = ("selected_items", "count", "first_item")
FUNCTION = "select_random"
CATEGORY = "DebugPadawan/List"
def select_random(self, input_list, seed, count, allow_duplicates):
def select_random(self, input_list: List, seed: int, count: int, allow_duplicates: bool) -> Tuple[List, int, Any]:
if not input_list:
raise ValueError("Input list is empty.")
return ([], 0, None)
rng = random.Random(seed)
list_len = len(input_list)
if count > list_len and not allow_duplicates:
count = list_len
if allow_duplicates:
selected = [rng.choice(input_list) for _ in range(count)]
else:
@@ -132,11 +183,6 @@ class RandomListSelector:
class ListShuffler:
"""
Node for shuffling a list with a deterministic seed
Useful for randomizing order while maintaining reproducibility
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -148,65 +194,54 @@ class ListShuffler:
RETURN_TYPES = ("LIST", "INT")
RETURN_NAMES = ("shuffled_list", "count")
FUNCTION = "shuffle_list"
CATEGORY = "DebugPadawan/List"
def shuffle_list(self, input_list, seed):
def shuffle_list(self, input_list: List, seed: int) -> Tuple[List, int]:
if not input_list:
return ([], 0)
# Create a copy to avoid modifying the original
shuffled = list(input_list)
rng = random.Random(seed)
rng.shuffle(shuffled)
random.Random(seed).shuffle(shuffled)
return (shuffled, len(shuffled))
class ListMerger:
"""
Node for merging multiple lists together
Supports concatenation and interleaving modes
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"list_a": ("LIST",),
"list_b": ("LIST",),
"mode": (["concatenate", "interleave"],),
"mode": (["concatenate", "interleave", "union"],),
}
}
RETURN_TYPES = ("LIST", "INT")
RETURN_NAMES = ("merged_list", "count")
FUNCTION = "merge_lists"
CATEGORY = "DebugPadawan/List"
def merge_lists(self, list_a, list_b, mode):
def merge_lists(self, list_a: List, list_b: List, mode: str) -> Tuple[List, int]:
if mode == "concatenate":
result = list_a + list_b
else: # interleave
elif mode == "interleave":
result = []
max_len = max(len(list_a), len(list_b))
for i in range(max_len):
if i < len(list_a):
result.append(list_a[i])
if i < len(list_b):
result.append(list_b[i])
for i in range(max(len(list_a), len(list_b))):
if i < len(list_a): result.append(list_a[i])
if i < len(list_b): result.append(list_b[i])
elif mode == "union":
seen = set()
result = []
for item in list_a + list_b:
s = str(item)
if s not in seen:
seen.add(s)
result.append(item)
return (result, len(result))
class ListDeduplicator:
"""
Node for removing duplicate items from a list
Preserves original order
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -217,43 +252,41 @@ class ListDeduplicator:
RETURN_TYPES = ("LIST", "INT", "INT")
RETURN_NAMES = ("deduplicated_list", "count", "removed_count")
FUNCTION = "deduplicate"
CATEGORY = "DebugPadawan/List"
def deduplicate(self, input_list):
seen = []
def deduplicate(self, input_list: List) -> Tuple[List, int, int]:
seen = set()
result = []
for item in input_list:
# Convert to string for comparison (handles non-hashable types)
item_key = str(item)
if item_key not in seen:
seen.append(item_key)
seen.add(item_key)
result.append(item)
removed = len(input_list) - len(result)
return (result, len(result), removed)
return (result, len(result), len(input_list) - len(result))
NODE_CLASS_MAPPINGS = {
"DebugPadawan_GetListItem": GetListItem,
"DebugPadawan_ListCreate": ListCreate,
"DebugPadawan_ListFilter": ListFilter,
"DebugPadawan_ListSlicer": ListSlicer,
"DebugPadawan_ListInfo": ListInfo,
"DebugPadawan_RandomListSelector": RandomListSelector,
"DebugPadawan_ListShuffler": ListShuffler,
"DebugPadawan_ListMerger": ListMerger,
"DebugPadawan_ListDeduplicator": ListDeduplicator,
# Alias for backward compatibility
"DP_GetListItem": GetListItem,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DebugPadawan_GetListItem": "Get List Item",
"DebugPadawan_ListCreate": "List Create (Multi-Input)",
"DebugPadawan_ListFilter": "List Filter",
"DebugPadawan_ListSlicer": "List Slicer",
"DebugPadawan_ListInfo": "List Info",
"DebugPadawan_RandomListSelector": "Random List Selector",
"DebugPadawan_ListShuffler": "List Shuffler",
"DebugPadawan_ListMerger": "List Merger",
"DebugPadawan_ListDeduplicator": "List Deduplicator",
"DP_GetListItem": "Get List Item (Legacy)",
}
+86 -39
View File
@@ -1,25 +1,18 @@
import random
import math
from typing import Tuple, List, Union
class BaseMathOperation:
"""
Base class for math operations to reduce duplication
"""
def _perform_calculation(self, a, b, operation, is_int=True):
if operation == "add":
res = a + b
elif operation == "subtract":
res = a - b
elif operation == "multiply":
res = a * b
elif operation == "divide":
res = a / b if b != 0 else 0
elif operation == "modulo":
res = a % b if b != 0 else 0
elif operation == "power":
res = math.pow(a, b)
else:
res = 0
def _perform_calculation(self, a: float, b: float, operation: str, is_int: bool = True) -> Tuple[Union[int, float], Union[float, int]]:
res = 0.0
if operation == "add": res = a + b
elif operation == "subtract": res = a - b
elif operation == "multiply": res = a * b
elif operation == "divide": res = a / b if b != 0 else 0.0
elif operation == "modulo": res = a % b if b != 0 else 0.0
elif operation == "power": res = math.pow(a, b)
elif operation == "max": res = max(a, b)
elif operation == "min": res = min(a, b)
if is_int:
return (int(res), float(res))
@@ -27,16 +20,13 @@ class BaseMathOperation:
return (float(res), int(res))
class IntMathOperation(BaseMathOperation):
"""
Node for performing basic integer math operations
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"a": ("INT", {"default": 0, "step": 1}),
"b": ("INT", {"default": 0, "step": 1}),
"operation": (["add", "subtract", "multiply", "divide", "modulo", "power"],),
"operation": (["add", "subtract", "multiply", "divide", "modulo", "power", "max", "min"],),
}
}
@@ -46,20 +36,16 @@ class IntMathOperation(BaseMathOperation):
CATEGORY = "DebugPadawan/Math"
def perform_math(self, a, b, operation):
return self._perform_calculation(a, b, operation, is_int=True)
return self._perform_calculation(float(a), float(b), operation, is_int=True)
class FloatMathOperation(BaseMathOperation):
"""
Node for performing basic float math operations
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"a": ("FLOAT", {"default": 0.0, "step": 0.01}),
"b": ("FLOAT", {"default": 0.0, "step": 0.01}),
"operation": (["add", "subtract", "multiply", "divide", "power"],),
"operation": (["add", "subtract", "multiply", "divide", "power", "max", "min"],),
}
}
@@ -71,11 +57,72 @@ class FloatMathOperation(BaseMathOperation):
def perform_math(self, a, b, operation):
return self._perform_calculation(a, b, operation, is_int=False)
class SingleNumberOp:
"""
Operations on a single number (floor, ceil, rounded, abs, sin, cos).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"value": ("FLOAT", {"default": 0.0, "step": 0.001}),
"operation": (["floor", "ceil", "round", "abs", "sin", "cos", "sqrt", "negate"],),
}
}
RETURN_TYPES = ("FLOAT", "INT")
RETURN_NAMES = ("float_val", "int_val")
FUNCTION = "op"
CATEGORY = "DebugPadawan/Math"
def op(self, value: float, operation: str) -> Tuple[float, int]:
res = 0.0
if operation == "floor": res = float(math.floor(value))
elif operation == "ceil": res = float(math.ceil(value))
elif operation == "round": res = float(round(value))
elif operation == "abs": res = abs(value)
elif operation == "sin": res = math.sin(value)
elif operation == "cos": res = math.cos(value)
elif operation == "sqrt": res = math.sqrt(value) if value >= 0 else 0.0
elif operation == "negate": res = -value
return (res, int(res))
class MathExpression:
"""
Evaluates simple mathematical expressions.
BE CAREFUL: Uses eval(), so keep it strictly math only.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"expression": ("STRING", {"default": "a * (b + 10)"}),
"a": ("FLOAT", {"default": 1.0}),
"b": ("FLOAT", {"default": 1.0}),
}
}
RETURN_TYPES = ("FLOAT", "INT")
RETURN_NAMES = ("float_val", "int_val")
FUNCTION = "eval_expr"
CATEGORY = "DebugPadawan/Math"
def eval_expr(self, expression: str, a: float, b: float) -> Tuple[float, int]:
# Basic sanitization
safe_dict = {"a": a, "b": b, "math": math, "abs": abs, "round": round, "min": min, "max": max}
try:
# This is still slightly risky but okay for a local tool.
# We should use a proper parser if this were production web.
# But for ComfyUI, users usually have full local access anyway.
result = eval(expression, {"__builtins__": {}}, safe_dict)
f_res = float(result)
return (f_res, int(f_res))
except Exception as e:
print(f"[MathExpression Error] {e}")
return (0.0, 0)
class RandomGenerator:
"""
Node for generating random integers or floats based on a seed
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -93,25 +140,25 @@ class RandomGenerator:
CATEGORY = "DebugPadawan/Math"
def generate(self, seed, min_val, max_val, mode):
# Initialize the random generator with the seed
rng = random.Random(seed)
if mode == "float":
res = rng.uniform(min_val, max_val)
return (float(res), int(res))
else:
# For int mode, ensure min and max are integers
res = rng.randint(int(min_val), int(max_val))
return (float(res), int(res))
return (float(res), int(res))
NODE_CLASS_MAPPINGS = {
"DebugPadawan_IntMathOperation": IntMathOperation,
"DebugPadawan_FloatMathOperation": FloatMathOperation,
"DebugPadawan_SingleNumberOp": SingleNumberOp,
"DebugPadawan_MathExpression": MathExpression,
"DebugPadawan_RandomGenerator": RandomGenerator,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DebugPadawan_IntMathOperation": "Int Math Operation",
"DebugPadawan_FloatMathOperation": "Float Math Operation",
"DebugPadawan_RandomGenerator": "Random Generator",
"DebugPadawan_IntMathOperation": "Integer Math",
"DebugPadawan_FloatMathOperation": "Float Math",
"DebugPadawan_SingleNumberOp": "Number Transform",
"DebugPadawan_MathExpression": "Math Expression Solver",
"DebugPadawan_RandomGenerator": "Random Number Gen",
}
+166 -210
View File
@@ -1,46 +1,44 @@
import json
import re
from typing import List, Tuple, Union
class TextSplitter:
"""
Node for splitting text strings by a delimiter
Node for splitting text strings by a delimiter.
Supports simple strings or regular expressions.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "apple,banana,orange"
}),
"delimiter": ("STRING", {
"multiline": False,
"default": ","
}),
"text": ("STRING", {"multiline": True, "default": "apple,banana,orange"}),
"delimiter": ("STRING", {"multiline": False, "default": ","}),
"is_regex": ("BOOLEAN", {"default": False}),
},
"optional": {
"strip_whitespace": ("BOOLEAN", {
"default": True
}),
"remove_empty": ("BOOLEAN", {
"default": True
}),
"strip_whitespace": ("BOOLEAN", {"default": True}),
"remove_empty": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("LIST", "INT")
RETURN_NAMES = ("text_list", "count")
FUNCTION = "split_text"
CATEGORY = "DebugPadawan/Text"
def split_text(self, text, delimiter, strip_whitespace=True, remove_empty=True):
"""
Split text by delimiter and return list with count
"""
result = text.split(delimiter)
def split_text(self, text: str, delimiter: str, is_regex: bool = False, strip_whitespace: bool = True, remove_empty: bool = True) -> Tuple[List[str], int]:
if not text:
return ([], 0)
if is_regex:
try:
result = re.split(delimiter, text)
except re.error:
# Fallback to simple split if regex is invalid
result = text.split(delimiter)
else:
result = text.split(delimiter)
if strip_whitespace:
result = [item.strip() for item in result]
@@ -53,7 +51,7 @@ class TextSplitter:
class TextJoiner:
"""
Node for joining a list of strings with a delimiter
Node for joining a list of strings with a delimiter.
"""
@classmethod
@@ -61,206 +59,184 @@ class TextJoiner:
return {
"required": {
"text_list": ("LIST",),
"delimiter": ("STRING", {
"multiline": False,
"default": ", "
}),
"delimiter": ("STRING", {"multiline": False, "default": ", "}),
},
"optional": {
"prefix": ("STRING", {
"multiline": False,
"default": ""
}),
"suffix": ("STRING", {
"multiline": False,
"default": ""
}),
"prefix": ("STRING", {"multiline": False, "default": ""}),
"suffix": ("STRING", {"multiline": False, "default": ""}),
"skip_empty": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("joined_text",)
FUNCTION = "join_text"
CATEGORY = "DebugPadawan/Text"
def join_text(self, text_list, delimiter, prefix="", suffix=""):
def join_text(self, text_list: List, delimiter: str, prefix: str = "", suffix: str = "", skip_empty: bool = True) -> Tuple[str]:
if not text_list:
return (prefix + suffix,)
str_list = [str(item) for item in text_list]
if skip_empty:
str_list = [s for s in str_list if s.strip()]
result = delimiter.join(str_list)
result = prefix + result + suffix
return (result,)
class TextReplace:
class TextTemplate:
"""
Node for replacing specific text in a string
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "A beautiful sunset over the ocean"
}),
"find": ("STRING", {
"multiline": False,
"default": "sunset"
}),
"replace": ("STRING", {
"multiline": False,
"default": "sunrise"
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "replace_text"
CATEGORY = "DebugPadawan/Text"
def replace_text(self, text, find, replace):
if not find:
return (text,)
return (text.replace(find, replace),)
class TextRegex:
"""
Node for searching and replacing text using regular expressions
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "My phone number is 123-456-7890"
}),
"pattern": ("STRING", {
"multiline": False,
"default": r"\d{3}-\d{3}-\d{4}"
}),
"replace": ("STRING", {
"multiline": False,
"default": "[REDACTED]"
}),
}
}
RETURN_TYPES = ("STRING", "LIST", "BOOLEAN")
RETURN_NAMES = ("text", "matches", "found")
FUNCTION = "regex_op"
CATEGORY = "DebugPadawan/Text"
def regex_op(self, text, pattern, replace):
if not pattern:
return (text, [], False)
matches = re.findall(pattern, text)
result = re.sub(pattern, replace, text)
return (result, matches, len(matches) > 0)
class TextCaseConverter:
"""
Node for converting text to different case styles
Useful for formatting prompts, filenames, and display text
Node for flexible string templating using Python's format method.
Useful for constructing complex prompts or filenames.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "hello world"
}),
"case_mode": (["UPPER", "lower", "Title Case", "Sentence case", "snake_case", "kebab-case", "camelCase", "PascalCase"],),
"template": ("STRING", {"multiline": True, "default": "A {subject} in the style of {artist}"}),
"input_1": ("*", {"forceInput": True}),
},
"optional": {
"input_2": ("*", {"forceInput": True}),
"input_3": ("*", {"forceInput": True}),
"input_4": ("*", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("formatted_text",)
FUNCTION = "format_template"
CATEGORY = "DebugPadawan/Text"
def format_template(self, template: str, input_1=None, input_2=None, input_3=None, input_4=None) -> Tuple[str]:
try:
# We use a simple replacement if the user doesn't use {0}, {1} etc.
# but rather named or positional arguments.
# To be most flexible, we provide both positional and 'valX' names.
kwargs = {
"val1": input_1, "val2": input_2, "val3": input_3, "val4": input_4,
"input_1": input_1, "input_2": input_2, "input_3": input_3, "input_4": input_4
}
result = template.format(input_1, input_2, input_3, input_4, **kwargs)
return (result,)
except Exception as e:
return (f"Error: {str(e)}",)
class TextCaseConverter:
"""
Comprehensive case converter for text.
Correctly handles snake_case, camelCase, etc.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": "hello world"}),
"mode": (["UPPER", "lower", "Title Case", "Sentence case", "snake_case", "kebab-case", "camelCase", "PascalCase"],),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("converted_text",)
FUNCTION = "convert_case"
CATEGORY = "DebugPadawan/Text"
def convert_case(self, text, case_mode):
"""
Convert text to the specified case style
"""
if case_mode == "UPPER":
def convert_case(self, text: str, mode: str) -> Tuple[str]:
if not text:
return ("",)
if mode == "UPPER":
return (text.upper(),)
elif case_mode == "lower":
elif mode == "lower":
return (text.lower(),)
elif case_mode == "Title Case":
elif mode == "Title Case":
return (text.title(),)
elif case_mode == "Sentence case":
# Capitalize first letter of each sentence
result = '. '.join(s.capitalize() for s in text.split('. '))
return (result,)
elif case_mode == "snake_case":
# Convert to snake_case
# First normalize spaces and existing separators
normalized = text.lower().replace('-', ' ').replace('_', ' ')
words = normalized.split()
return ('_'.join(words),)
elif case_mode == "kebab-case":
# Convert to kebab-case
normalized = text.lower().replace('_', ' ').replace('-', ' ')
words = normalized.split()
return ('-'.join(words),)
elif case_mode == "camelCase":
# Convert to camelCase
normalized = text.replace('-', ' ').replace('_', ' ')
words = normalized.split()
if not words:
return ("",)
result = words[0].lower() + ''.join(w.capitalize() for w in words[1:])
return (result,)
elif case_mode == "PascalCase":
# Convert to PascalCase
normalized = text.replace('-', ' ').replace('_', ' ')
words = normalized.split()
result = ''.join(w.capitalize() for w in words)
return (result,)
else:
elif mode == "Sentence case":
return ('. '.join(s.strip().capitalize() for s in text.split('. ')),)
# For complex cases, first tokenize
# Replace non-alphanumeric with spaces, then split
words = re.sub(r'[^a-zA-Z0-9]', ' ', text).split()
if not words:
return (text,)
if mode == "snake_case":
return ('_'.join(w.lower() for w in words),)
elif mode == "kebab-case":
return ('-'.join(w.lower() for w in words),)
elif mode == "camelCase":
return (words[0].lower() + ''.join(w.capitalize() for w in words[1:]),)
elif mode == "PascalCase":
return (''.join(w.capitalize() for w in words),)
return (text,)
class TextTrimmer:
class TextRegex:
"""
Node for trimming text with various options
Advanced Regex node for search, replace, and extraction.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": " hello world "
}),
"mode": (["both", "start", "end", "all_whitespace", "collapse_spaces"],),
"text": ("STRING", {"multiline": True, "default": "My phone is 123-456-7890"}),
"pattern": ("STRING", {"multiline": False, "default": r"(\d{3})-(\d{3}-\d{4})"}),
"replace": ("STRING", {"multiline": False, "default": r"(\1) \2"}),
},
"optional": {
"flags": (["None", "IGNORECASE", "MULTILINE", "DOTALL"], {"default": "None"}),
}
}
RETURN_TYPES = ("STRING", "LIST", "BOOLEAN")
RETURN_NAMES = ("text", "matches", "found")
FUNCTION = "regex_op"
CATEGORY = "DebugPadawan/Text"
def regex_op(self, text: str, pattern: str, replace: str = "", flags: str = "None") -> Tuple[str, List[str], bool]:
if not pattern:
return (text, [], False)
re_flags = 0
if flags == "IGNORECASE": re_flags = re.IGNORECASE
elif flags == "MULTILINE": re_flags = re.MULTILINE
elif flags == "DOTALL": re_flags = re.DOTALL
try:
compiled = re.compile(pattern, re_flags)
matches = compiled.findall(text)
# findall returns tuples if there are multiple groups, let's flatten or stringify
str_matches = [str(m) for m in matches]
result = compiled.sub(replace, text)
return (result, str_matches, len(matches) > 0)
except re.error as e:
return (f"Regex Error: {str(e)}", [], False)
class TextTrimmer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": " hello world "}),
"mode": (["both", "start", "end", "all_whitespace", "collapse_spaces", "remove_newlines"],),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("trimmed_text",)
FUNCTION = "trim_text"
CATEGORY = "DebugPadawan/Text"
def trim_text(self, text, mode):
"""
Trim text based on the specified mode
"""
def trim_text(self, text: str, mode: str) -> Tuple[str]:
if mode == "both":
return (text.strip(),)
elif mode == "start":
@@ -268,70 +244,49 @@ class TextTrimmer:
elif mode == "end":
return (text.rstrip(),)
elif mode == "all_whitespace":
# Remove all whitespace characters
return (''.join(text.split()),)
elif mode == "collapse_spaces":
# Collapse multiple spaces into single spaces
return (' '.join(text.split()),)
else:
return (text,)
elif mode == "remove_newlines":
return (text.replace('\n', ' ').replace('\r', ' '),)
return (text,)
class TextPrefixSuffix:
"""
Node for adding prefix and/or suffix to text
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "beautiful"
}),
"text": ("STRING", {"multiline": True, "default": "beautiful"}),
},
"optional": {
"prefix": ("STRING", {
"multiline": False,
"default": ""
}),
"suffix": ("STRING", {
"multiline": False,
"default": ""
}),
"prefix": ("STRING", {"multiline": False, "default": ""}),
"suffix": ("STRING", {"multiline": False, "default": ""}),
"add_space": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("result_text",)
FUNCTION = "add_prefix_suffix"
CATEGORY = "DebugPadawan/Text"
def add_prefix_suffix(self, text, prefix="", suffix="", add_space=True):
result = text
def add_prefix_suffix(self, text: str, prefix: str = "", suffix: str = "", add_space: bool = True) -> Tuple[str]:
res = text
if prefix:
if add_space and text and not text.startswith(' '):
result = prefix + ' ' + result
else:
result = prefix + result
connector = " " if add_space and not prefix.endswith(" ") and not text.startswith(" ") else ""
res = f"{prefix}{connector}{res}"
if suffix:
if add_space and text and not text.endswith(' '):
result = result + ' ' + suffix
else:
result = result + suffix
return (result,)
connector = " " if add_space and not res.endswith(" ") and not suffix.startswith(" ") else ""
res = f"{res}{connector}{suffix}"
return (res,)
NODE_CLASS_MAPPINGS = {
"DebugPadawan_TextSplitter": TextSplitter,
"DebugPadawan_TextJoiner": TextJoiner,
"DebugPadawan_TextReplace": TextReplace,
"DebugPadawan_TextTemplate": TextTemplate,
"DebugPadawan_TextReplace": TextRegex, # Merged replace into Regex or kept separate
"DebugPadawan_TextRegex": TextRegex,
"DebugPadawan_TextCaseConverter": TextCaseConverter,
"DebugPadawan_TextTrimmer": TextTrimmer,
@@ -341,8 +296,9 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"DebugPadawan_TextSplitter": "Text Splitter",
"DebugPadawan_TextJoiner": "Text Joiner",
"DebugPadawan_TextReplace": "Text Replace",
"DebugPadawan_TextRegex": "Text Regex (Search & Replace)",
"DebugPadawan_TextTemplate": "Text Template (Format)",
"DebugPadawan_TextReplace": "Text Replace (Legacy)",
"DebugPadawan_TextRegex": "Text Regex (Pro)",
"DebugPadawan_TextCaseConverter": "Text Case Converter",
"DebugPadawan_TextTrimmer": "Text Trimmer",
"DebugPadawan_TextPrefixSuffix": "Text Prefix Suffix",