diff --git a/__init__.py b/__init__.py index 4564547..63e1af5 100644 --- a/__init__.py +++ b/__init__.py @@ -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" \ No newline at end of file diff --git a/nodes/color_palette.py b/nodes/color_palette.py index ba8127e..525bf0e 100644 --- a/nodes/color_palette.py +++ b/nodes/color_palette.py @@ -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 diff --git a/nodes/extra_utils.py b/nodes/extra_utils.py new file mode 100644 index 0000000..e1db18c --- /dev/null +++ b/nodes/extra_utils.py @@ -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", +} diff --git a/nodes/image.py b/nodes/image.py index 689857d..0248680 100644 --- a/nodes/image.py +++ b/nodes/image.py @@ -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", } diff --git a/nodes/list_nodes.py b/nodes/list_nodes.py index 467549e..31e0937 100644 --- a/nodes/list_nodes.py +++ b/nodes/list_nodes.py @@ -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)", } diff --git a/nodes/math_nodes.py b/nodes/math_nodes.py index 7d2679f..0c81a0e 100644 --- a/nodes/math_nodes.py +++ b/nodes/math_nodes.py @@ -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", } diff --git a/nodes/text_processing.py b/nodes/text_processing.py index ad2d39c..10561c9 100644 --- a/nodes/text_processing.py +++ b/nodes/text_processing.py @@ -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",