492 lines
23 KiB
Python
492 lines
23 KiB
Python
# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||
# ████ 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",
|
||
} |