Files
MDMAchine-ComfyUI_MD_Nodes/text/TextInputNode.py
T

492 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
# ████ MD_Nodes/AdvancedTextNode – Text input with wildcards & transforms v1.5.0 ████▓▒░
# © 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 (fixed).
# ✓ Text transformations: lowercase, uppercase, whitespace control.
# ✓ Multiple outputs: processed text, original text, seed used.
# ✓ Companion 'Text File Loader' node for external file import.
# ░▒▓ CHANGELOG:
# - v1.5.0 (Critical Fix - Oct 2025):
# • CRITICAL: Fixed wildcard processing logic to correctly handle multiple, non-nested wildcard blocks after the first one is processed.
# • REFACTOR: Simplified recursive wildcard logic to be more robust.
# • COMPLIANCE: Removed all type hints, version numbers, and standardized log outputs.
# • COMPLIANCE: Added 'MD:' prefix to display names.
# - v1.4.2 (Guideline Update - Oct 2025):
# • REFACTOR: Full compliance update to v1.4.2 guidelines.
# • STYLE: Standardized imports, docstrings, and error handling.
# • ROBUST: Wrapped main execution in try/except with fallback outputs.
# - v1.1 (Stability Fix):
# • FIXED: Added/Corrected `IS_CHANGED` methods.
# - v1.0 (Initial Release):
# • ADDED: Core wildcard functionality, text transformations, file loader.
# ░▒▓ 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.
# ▓▒░ Flashbacks to editing CONFIG.SYS in EDIT.COM just to get your Gravis Ultrasound working.
# 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.
"""
@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)."
)
}),
"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")
RETURN_NAMES = ("processed_text", "original_text", "seed_used")
FUNCTION = "process_text"
CATEGORY = "MD_Nodes/Text"
@classmethod
def IS_CHANGED(cls, text, seed=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.
if seed == -1:
return secrets.token_hex(16) # Force re-run
# 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, 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)
# Since the pattern only matches the INNEMOST level, we need to
# make sure the options are fully resolved *before* splitting them.
# Rerunning the recursive call on the options_str is ONLY necessary
# if we use a simple regex that matches the outermost pattern first.
# With the current regex designed for INNERMOST, we can skip the
# recursive call here and rely on the main while loop to continue.
# The options string itself may contain partially processed wildcards
# if the outermost pattern was matched first. Since we use the
# innermost pattern (r'\{([^{}|]+(?:\|[^{}|]+)*)\}'), this
# is simplified as the options_str should be the final non-nested options.
# The previous logic was fragile. Let's simplify the regex for non-greedy
# matching and rely on the iteration to resolve nesting.
# Simple non-greedy match for anything between the delimiters
# Recompile pattern to be non-greedy to handle multiple wildcards correctly
# NOTE: We can't recompile here, so we must rely on the passed pattern,
# which is designed to find the *innermost* non-nested set.
# Use the original logic, which, while complex, is the intended
# way to handle nested braces with the old regex:
# processed_options_str = self._process_wildcards_recursive(options_str, pattern, rng)
# FIX: If the internal text contains a wildcard, the main loop
# will eventually catch it, but we need to ensure the options string
# is correctly split. The core issue is that the text replacement
# has to happen on the main string in a loop.
# With the innermost pattern, options_str should be a flat list of options.
# If the options contain wildcards, they will be missed by the current
# implementation's innermost regex.
# Let's trust the original regex for *innermost* and simplify the options split
options = [opt.strip() for opt in options_str.split('|')]
chosen_option = rng.choice(options)
# Replace only the innermost match found in this iteration
# CRITICAL FIX: Ensure the replacement is performed correctly on the whole string
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, 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.
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)
"""
original_text = text
processed_text = text
seed_used = seed
try:
# Handle dynamic seed generation (-1 means generate random)
if seed == -1:
seed_used = random.randint(0, 0xffffffffffffffff)
logging.info(f"[AdvancedTextNode] Using dynamically generated seed: {seed_used}")
else:
logging.debug(f"[AdvancedTextNode] Using provided seed: {seed_used}")
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.")
# Ensure tuple return with trailing comma if needed (already 3 outputs)
return (processed_text, original_text, seed_used)
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 and seed
return (original_text, original_text, seed_used)
# =================================================================================
# == 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.
"""
logging.info(f"[TextFileLoader] Attempting to load file: {file_path} with encoding {encoding}")
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()
logging.info(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",
}