# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ # ████ MD_Nodes/AdvancedTextNode – Text input with wildcards & transforms ████▓▒░ # © 2025 MDMAchine # ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ # ░▒▓ ORIGIN & DEV: # • Cast into the void by: MDMAchine # • Enhanced by: Gemini, Claude # • License: Apache 2.0 — Sharing is caring # ░▒▓ DESCRIPTION: # A versatile text input node with seed-controlled wildcard support, text # transformations (case, whitespace), and a companion Text File Loader node. # Ideal for dynamic prompts, large text blocks (YAML/JSON), and external data. # ░▒▓ FEATURES: # ✓ Large multiline text input. # ✓ Seed-controlled wildcards: {option1|option2} or __option1|option2__. # ✓ Nested wildcard support. # ✓ Wildcard seed selection: Use {seed1|seed2|seed3} to randomly pick a seed value. # ✓ Text transformations: lowercase, uppercase, whitespace control. # ✓ Multiple outputs: processed text, original text, seed used, selected seed INT. # ✓ Companion 'Text File Loader' node for external file import. # ░▒▓ CHANGELOG: # - v1.6.1 (Randomness Fix - Nov 2025): # • FIXED: Replaced manual modulo math with `rng.choice()` to prevent seed clustering. # • IMPROVED: Better distribution of values when using small seed lists. # - v1.6.0 (Seed List Feature - Nov 2025): # • ADDED: New 'seed_list' input parameter for wildcard seed selection. # • ADDED: New 'selected_seed' INT output - randomly selects seed from wildcard list. # • ADDED: New 'seed_offset' parameter to reduce repetition in batch workflows. # ░▒▓ CONFIGURATION: # → Primary Use: Dynamic prompt generation using {wildcards|options} with a fixed seed. # → Secondary Use: A simple text box for holding complex YAML or JSON configs. # → Edge Use: Loading entire workflow templates from external .txt files via the loader. # ░▒▓ WARNING: # This node may trigger: # ▓▒░ Obsessively nesting wildcards {like a {russian {doll|matryoshka}|madman}|until you hit a stack overflow}. # ▓▒░ Spending 4 hours 'seed surfing' just to find the one combo that doesn't say 'green'. # ▓▒░ A sudden, uncontrollable urge to create a .NFO file for your workflow. # Consult your nearest demoscene vet if hallucinations persist. # ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ # ================================================================================= # == Standard Library Imports == # ================================================================================= import logging import os import random import re import secrets import traceback # ================================================================================= # == Third-Party Imports == # ================================================================================= # (No third-party imports needed) # ================================================================================= # == ComfyUI Core Modules == # ================================================================================= # (No core ComfyUI imports needed directly in this file) # ================================================================================= # == Local Project Imports == # ================================================================================= # (No local project imports in this file) # ================================================================================= # == Helper Classes & Dependencies == # ================================================================================= # (No helper classes needed) # ================================================================================= # == Core Node Class: AdvancedTextNode == # ================================================================================= class AdvancedTextNode: """ A versatile text input node with seed-controlled wildcard processing, text transformations, and multiple output options including text length. """ @classmethod def INPUT_TYPES(cls): """Define all input parameters with standardized tooltips.""" return { "required": { "text": ("STRING", { "multiline": True, "default": "", "dynamicPrompts": False, # Keep dynamicPrompts as ComfyUI uses it "tooltip": ( "TEXT INPUT\n" "- Main text input area.\n" "- Enter prompts, YAML, JSON, or any text content.\n" "- Use wildcards like {option1|option2} or __option1|option2__ for random variations if enabled." ) }), }, "optional": { "seed": ("INT", { "default": 0, "min": -1, # Allow -1 for dynamic seed "max": 0xffffffffffffffff, "step": 1, "tooltip": ( "SEED\n" "- Controls randomization for wildcard selection.\n" "- Same seed = same wildcard choices every time.\n" "- Change seed for different variations.\n" "- Set to -1 to generate a new random seed on each run (forces re-execution)." ) }), "seed_list": ("STRING", { "default": "", "multiline": True, "tooltip": ( "SEED LIST (WILDCARD)\n" "- Provide a list of seeds using wildcard syntax.\n" "- Example: {1234|5678|9012} or __42|777|1337__\n" "- The node will randomly select ONE seed from the list.\n" "- Selected seed is output as 'selected_seed' INT.\n" "- Use the main 'seed' input to control which seed is chosen.\n" "- Leave empty to use only the main 'seed' parameter." ) }), "seed_offset": ("INT", { "default": 0, "min": 0, "max": 0xffffffffffffffff, "step": 1, "tooltip": ( "SEED OFFSET\n" "- Adds an offset to the seed before selecting from seed_list.\n" "- This helps reduce repetition in batch workflows.\n" "- Example: seed=42, offset=0 → uses 42 for selection\n" "- Example: seed=42, offset=1 → uses 43 for selection\n" "- In batch mode, increment this to get different variations.\n" "- Has no effect if seed_list is empty." ) }), "wildcard_mode": ("BOOLEAN", { "default": False, "label_on": "enabled", "label_off": "disabled", "tooltip": ( "WILDCARD MODE\n" "- Enable processing of {option1|option2} or __option1|option2__ patterns.\n" "- ON: Randomly selects one option based on the seed.\n" "- OFF: Text passes through unchanged." ) }), "strip_whitespace": ("BOOLEAN", { "default": False, "label_on": "enabled", "label_off": "disabled", "tooltip": ( "STRIP WHITESPACE\n" "- Removes leading and trailing whitespace from the *entire* text block.\n" "- Useful for cleaning up pasted content." ) }), "lowercase": ("BOOLEAN", { "default": False, "label_on": "enabled", "label_off": "disabled", "tooltip": ( "FORCE LOWERCASE\n" "- Converts the entire output text to lowercase.\n" "- Note: This overrides 'Force Uppercase' if both are enabled." ) }), "uppercase": ("BOOLEAN", { "default": False, "label_on": "enabled", "label_off": "disabled", "tooltip": ( "FORCE UPPERCASE\n" "- Converts the entire output text to UPPERCASE.\n" "- Note: 'Force Lowercase' takes priority if both are enabled." ) }), "remove_extra_spaces": ("BOOLEAN", { "default": True, "label_on": "enabled", "label_off": "disabled", "tooltip": ( "REMOVE EXTRA SPACES\n" "- Collapses multiple spaces into single spaces.\n" "- Trims leading/trailing spaces from each line.\n" "- Helps normalize formatting." ) }), "wildcard_syntax": (["curly_braces", "double_underscore"], { "default": "curly_braces", "tooltip": ( "WILDCARD SYNTAX\n" "- Choose the pattern style for wildcards:\n" "- 'curly_braces': {option1|option2}\n" "- 'double_underscore': __option1|option2__" ) }), } } RETURN_TYPES = ("STRING", "STRING", "INT", "INT") RETURN_NAMES = ("processed_text", "original_text", "seed_used", "selected_seed") FUNCTION = "process_text" CATEGORY = "MD_Nodes/Text" @classmethod def IS_CHANGED(cls, text, seed=0, seed_list="", seed_offset=0, wildcard_mode=False, strip_whitespace=False, lowercase=False, uppercase=False, remove_extra_spaces=True, wildcard_syntax="curly_braces"): """ Determine if the node should re-run based on input changes. If seed is -1, treat it as dynamic (always re-run). """ # Note: ComfyUI typically handles dynamic behavior via negative seeds or similar patterns internally. # This implementation explicitly forces re-run for seed = -1 by returning a unique random hex string. # This is a valid ComfyUI pattern for forcing execution. if seed == -1: return secrets.token_hex(16) # Otherwise, changes in any processing parameter should trigger re-run # Returning a tuple/list of all relevant inputs is standard practice # ComfyUI hashes this to detect changes. return (text, seed, seed_list, seed_offset, wildcard_mode, strip_whitespace, lowercase, uppercase, remove_extra_spaces, wildcard_syntax) def _process_wildcards_recursive(self, text, pattern, rng): """ Recursively processes nested wildcards. Helper function. Args: text: The text containing wildcards. pattern: The compiled regex pattern for the syntax. rng: The seeded random number generator. Returns: str: Text with wildcards processed. """ iteration = 0 max_iterations = 100 # Safety break for potential infinite loops # CRITICAL FIX: The loop must continue until no more *innermost* matches are found. # By repeatedly searching and replacing only the innermost pattern, we ensure # that nested wildcards are resolved from inside out. while iteration < max_iterations: match = pattern.search(text) if not match: break # No more wildcards of this type found options_str = match.group(1) options = [opt.strip() for opt in options_str.split('|')] # Validate options to prevent crash on empty brackets {} if not options: options = [""] # Use standard rng.choice() for statistically better distribution. chosen_option = rng.choice(options) # Replace only the innermost match found in this iteration text = text[:match.start()] + chosen_option + text[match.end():] iteration += 1 if iteration == max_iterations: logging.warning("[AdvancedTextNode] Max wildcard processing iterations reached. Possible runaway recursion?") return text def process_wildcards_curly(self, text, seed): """ Process wildcards in {option1|option2|option3} format, supporting nesting. """ rng = random.Random(seed) # Non-greedy pattern to find the *innermost* wildcards first # This pattern matches any { } that contains only text and | symbols, but no nested { or } pattern = re.compile(r'\{([^{}]+?)\}') return self._process_wildcards_recursive(text, pattern, rng) def process_wildcards_underscore(self, text, seed): """ Process wildcards in __option1|option2|option3__ format, supporting nesting. """ rng = random.Random(seed) # Non-greedy pattern to find the *innermost* wildcards first pattern = re.compile(r'__([^_]+?)__') return self._process_wildcards_recursive(text, pattern, rng) def process_text(self, text, seed=0, seed_list="", seed_offset=0, wildcard_mode=False, strip_whitespace=False, lowercase=False, uppercase=False, remove_extra_spaces=True, wildcard_syntax="curly_braces"): """ Main execution function. Processes text based on input parameters. Args: text: Main text input string. seed: Seed for random selection. seed_list: String containing wildcard list of seeds to choose from. seed_offset: Offset added to seed for seed_list selection. wildcard_mode: Boolean to enable/disable wildcard processing. strip_whitespace: Boolean to strip overall whitespace. lowercase: Boolean to force lowercase. uppercase: Boolean to force uppercase. remove_extra_spaces: Boolean to collapse spaces. wildcard_syntax: String defining the wildcard pattern type. Returns: tuple: (processed_text, original_text, seed_used, selected_seed) """ original_text = text processed_text = text seed_used = seed selected_seed = 0 # Default output seed try: # Handle dynamic seed generation (-1 means generate random) if seed == -1: seed_used = random.randint(0, 0xffffffffffffffff) print(f"[AdvancedTextNode] 🎲 Using dynamically generated seed: {seed_used}") else: logging.debug(f"[AdvancedTextNode] Using provided seed: {seed_used}") # Process seed_list if provided if seed_list and seed_list.strip(): # Apply offset to create variation in seed selection selection_seed = (seed_used + seed_offset) & 0xffffffffffffffff # Keep within bounds logging.debug(f"[AdvancedTextNode] Processing seed_list with syntax '{wildcard_syntax}' and selection_seed {selection_seed}") # Process the seed_list string using wildcard processing if wildcard_syntax == "curly_braces": selected_seed_str = self.process_wildcards_curly(seed_list, selection_seed) elif wildcard_syntax == "double_underscore": selected_seed_str = self.process_wildcards_underscore(seed_list, selection_seed) else: selected_seed_str = seed_list # Convert the selected seed string to integer try: selected_seed = int(selected_seed_str.strip()) print(f"[AdvancedTextNode] 🌱 Selected seed from list: {selected_seed}") except ValueError: logging.warning(f"[AdvancedTextNode] Could not convert '{selected_seed_str}' to integer. Using 0.") selected_seed = 0 else: # If no seed_list provided, output the seed_used as selected_seed selected_seed = seed_used logging.debug(f"[AdvancedTextNode] No seed_list provided, using seed_used as selected_seed: {selected_seed}") if wildcard_mode: logging.debug(f"[AdvancedTextNode] Processing wildcards with syntax '{wildcard_syntax}' and seed {seed_used}") if wildcard_syntax == "curly_braces": processed_text = self.process_wildcards_curly(processed_text, seed_used) elif wildcard_syntax == "double_underscore": processed_text = self.process_wildcards_underscore(processed_text, seed_used) logging.debug("[AdvancedTextNode] Wildcard processing complete.") if strip_whitespace: processed_text = processed_text.strip() logging.debug("[AdvancedTextNode] Stripped leading/trailing whitespace.") if remove_extra_spaces: processed_text = re.sub(r' +', ' ', processed_text) # Collapse multiple spaces processed_text = '\n'.join(line.strip() for line in processed_text.split('\n')) # Trim lines logging.debug("[AdvancedTextNode] Removed extra spaces and trimmed lines.") # Lowercase takes precedence if lowercase: processed_text = processed_text.lower() logging.debug("[AdvancedTextNode] Converted text to lowercase.") elif uppercase: # Only apply if lowercase is false processed_text = processed_text.upper() logging.debug("[AdvancedTextNode] Converted text to uppercase.") # Return all outputs return (processed_text, original_text, seed_used, selected_seed) except Exception as e: logging.error(f"[AdvancedTextNode] Error processing text: {e}") logging.debug(traceback.format_exc()) print(f"[AdvancedTextNode] ⚠️ Error: {e}. Returning original text unchanged.") # Graceful failure: Return original text, seed, and 0 for selected_seed return (original_text, original_text, seed_used, 0) # ================================================================================= # == Core Node Class: TextFileLoader == # ================================================================================= class TextFileLoader: """ Companion node to load text content from external files. """ @classmethod def INPUT_TYPES(cls): """Define input parameters for the file loader.""" return { "required": { "file_path": ("STRING", { "default": "", "multiline": False, "tooltip": ( "FILE PATH\n" "- Full path to the text file (.txt, .yaml, .json, etc.).\n" "- Supports relative paths from ComfyUI root or absolute paths.\n" "- Example (relative): `input/my_prompts.txt`\n" "- Example (absolute): `C:/data/config.yaml`" ) }), }, "optional": { "encoding": (["utf-8", "ascii", "latin-1"], { "default": "utf-8", "tooltip": ( "FILE ENCODING\n" "- Character encoding of the file.\n" "- 'utf-8': Recommended universal standard.\n" "- 'ascii': Basic English characters only.\n" "- 'latin-1': Western European characters.\n" "- If you see garbled text, try a different encoding." ) }), } } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("text",) FUNCTION = "load_file" CATEGORY = "MD_Nodes/Text" @classmethod def IS_CHANGED(cls, file_path, encoding="utf-8"): """ Check if the file content has changed based on modification time. Forces re-run if the file is modified or path changes. """ try: # Normalize path for consistent hashing norm_path = os.path.normpath(file_path) if not os.path.exists(norm_path): m_time = -1 # Indicate file not found else: m_time = os.path.getmtime(norm_path) except Exception as e: # Handle potential errors like invalid paths during check logging.warning(f"[TextFileLoader] IS_CHANGED check failed for path '{file_path}': {e}") m_time = -2 # Indicate an error occurred during check # Return tuple that ComfyUI hashes return (norm_path, encoding, m_time) def load_file(self, file_path, encoding="utf-8"): """ Load text content from the specified file. Args: file_path: The path to the text file (str). encoding: The file encoding to use (str). Returns: tuple: (text_content,) or (error_message,) on failure. """ print(f"[TextFileLoader] 📂 Attempting to load file: {file_path}") try: # Basic path validation if not file_path or not isinstance(file_path, str): error_msg = "[TextFileLoader] Error: Invalid file path provided." logging.error(error_msg) return (error_msg,) # Must return a tuple if not os.path.exists(file_path): error_msg = f"[TextFileLoader] Error: File not found at path: {file_path}" logging.error(error_msg) return (error_msg,) # Must return a tuple if not os.path.isfile(file_path): error_msg = f"[TextFileLoader] Error: Path exists but is not a file: {file_path}" logging.error(error_msg) return (error_msg,) # Must return a tuple with open(file_path, 'r', encoding=encoding) as f: text = f.read() print(f"[TextFileLoader] ✅ Successfully loaded file: {file_path}") return (text,) # Must return a tuple except Exception as e: error_msg = f"[TextFileLoader] Error loading file '{file_path}': {e}" logging.error(error_msg) logging.debug(traceback.format_exc()) print(f"[TextFileLoader] ⚠️ Error: {e}. Check console/logs.") # Graceful failure: Return error message in tuple return (error_msg,) # ================================================================================= # == Node Registration == # ================================================================================= NODE_CLASS_MAPPINGS = { "AdvancedTextNode": AdvancedTextNode, "TextFileLoader": TextFileLoader, } NODE_DISPLAY_NAME_MAPPINGS = { "AdvancedTextNode": "MD: Advanced Text Input", "TextFileLoader": "MD: Text File Loader", }