Files
MDMAchine-ComfyUI_MD_Nodes/text/MD_TextInput.py
T

544 lines
24 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 v1.9.0 ░▒▓█
# █▓▒░ ░▒▓█
# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
# ╠═ © 2026 MDMAchine
# ╠═ License: GNU General Public License v3.0 (GPL v3)
# ║
# ║ This program is free software: you can redistribute it and/or modify
# ║ it under the terms of the GNU General Public License as published by
# ║ the Free Software Foundation, either version 3 of the License, or
# ║ (at your option) any later version.
# ║
# ║ This program is distributed in the hope that it will be useful,
# ║ but WITHOUT ANY WARRANTY; without even the implied warranty of
# ║ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# ║ GNU General Public License for more details.
# ║
# ║ You should have received a copy of the GNU General Public License
# ║ along with this program. If not, see <https://www.gnu.org/licenses/>.
# ╠════════════════════════════════════════════════════════════════════════════
# ║ ░▒▓ ORIGIN & DEV:
# ║ • Cast into the void by: MDMAchine
# ║ • Enhanced by: Gemini, Claude
# ║
# ║ ░▒▓ DESCRIPTION:
# ║ A versatile text input node with seed-controlled wildcard support, text
# ║ transformations (case, whitespace), and a companion Text File Loader node.
# ║ NOTE: As a string utility node, this runs entirely in the public wrapper domain.
# ║
# ║ ░▒▓ FEATURES:
# ║ ✓ Large multiline text input.
# ║ ✓ Seed-controlled wildcards: {option1|option2} or __option1|option2__.
# ║ ✓ Nested wildcard support with robust error handling.
# ║ ✓ Text transformations: lowercase, uppercase, whitespace control.
# ║ ✓ Multiple seed modes: fixed, random, increment (auto-increment for batch).
# ║ ✓ Companion 'Text File Loader' node for external file import.
# ║
# ║ ░▒▓ CHANGELOG:
# ║ - v1.9.0 (Enterprise Standards - Feb 2026):
# ║ • ADDED: PerformanceProfiler class (v1.5.3 standard).
# ║ • ADDED: debug_mode parameter.
# ║ • REFACTOR: Tooltips strictly updated to 5-part v1.5.4 standard.
# ║ - v1.8.0 (Seed Precision Fix - Nov 2025):
# ║ • CRITICAL FIX: Capped seed range to JavaScript's MAX_SAFE_INTEGER.
# ╚════════════════════════════════════════════════════════════════════════════
# =================================================================================
# == Standard Library Imports ==
# =================================================================================
VERSION = "v1.9.0" # UPS v1.5.8
import logging
import os
import random
import re
import secrets
import traceback
import time
# =================================================================================
# == Seed Constants (JavaScript Safe Range) ==
# =================================================================================
SEED_MIN = 0
SEED_MAX = 9007199254740991 # JS-safe range for full reproducibility
# =================================================================================
# == Helper Classes (Enterprise Standards) ==
# =================================================================================
class PerformanceProfiler:
"""Standard performance profiler for MD_Nodes."""
def __init__(self, enabled=True):
self.enabled = enabled
self.timings = {}
self.start_times = {}
def start(self, operation_name):
if not self.enabled: return
self.start_times[operation_name] = time.perf_counter()
def stop(self, operation_name):
if not self.enabled: return
if operation_name in self.start_times:
elapsed = time.perf_counter() - self.start_times[operation_name]
if operation_name not in self.timings:
self.timings[operation_name] = []
self.timings[operation_name].append(elapsed)
del self.start_times[operation_name]
def get_total_time(self):
if not self.enabled or not self.timings: return 0.0
return sum(sum(times) for times in self.timings.values())
def print_report(self):
if not self.enabled or not self.timings: return
logging.info("\n⏱️ PERFORMANCE (Text Parse):")
total = self.get_total_time()
logging.info(f" • Total Time: {total:.4f}s")
for op_name, times in sorted(self.timings.items()):
avg = sum(times) / len(times)
if len(times) == 1:
logging.info(f" • {op_name}: {avg:.4f}s")
else:
logging.info(f" • {op_name}: {avg:.4f}s avg ({len(times)}x)")
# =================================================================================
# == 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.
"""
_PATTERN_CURLY = re.compile(r'\{([^{}]+?)\}')
_PATTERN_UNDERSCORE = re.compile(r'__([^_]+?)__')
@staticmethod
def _validate_seed(seed_value):
try:
int_value = int(seed_value)
except (ValueError, TypeError):
logging.warning(f"[AdvancedTextNode] Invalid seed value: {seed_value}. Using {SEED_MIN}.")
return SEED_MIN
if int_value > SEED_MAX:
logging.warning(f"[AdvancedTextNode] Seed {int_value} exceeds JS-safe range. Clamping to {SEED_MAX}.")
return SEED_MAX
elif int_value < SEED_MIN:
logging.warning(f"[AdvancedTextNode] Seed {int_value} below minimum. Clamping to {SEED_MIN}.")
return SEED_MIN
return int_value
@staticmethod
def _generate_random_seed():
return secrets.randbelow(SEED_MAX + 1)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "",
"dynamicPrompts": False,
"tooltip": (
"TEXT INPUT\n"
"• Purpose: Main text input area for prompts, YAML, JSON, etc.\n"
"• Usage: Type raw text or use wildcards like {option1|option2}.\n"
"• Note: Multi-line compatible."
)
}),
},
"optional": {
"seed": ("INT", {
"default": 0,
"min": SEED_MIN,
"max": SEED_MAX,
"step": 1,
"tooltip": (
"RANDOM SEED\n"
"• Purpose: Controls randomization for wildcard selection.\n"
"• Range: 0 to 9,007,199,254,740,991 (JS Safe Max).\n"
"• Note: Ignored if 'seed_mode' is set to 'random'.\n"
"\n⭐ Recommended: Fixed value for reproducible generation."
)
}),
"seed_mode": (["fixed", "random", "increment"], {
"default": "fixed",
"tooltip": (
"SEED MODE\n"
"• Purpose: Defines how the seed updates per execution.\n"
"• Options:\n"
" - fixed: Uses the exact seed value provided.\n"
" - random: Generates a new seed every run.\n"
" - increment: Auto-adds 1 to the seed (great for batches).\n"
"\n⭐ Recommended: 'fixed' for testing, 'increment' for batching."
)
}),
"seed_list": ("STRING", {
"default": "",
"multiline": True,
"tooltip": (
"SEED LIST (WILDCARD)\n"
"• Purpose: Randomly pick ONE seed from a wildcard string.\n"
"• Example: {1234|5678|9012} or __42|777|1337__\n"
"• Output: Outputs chosen value to the 'selected_seed' INT.\n"
"\n⭐ Recommended: Leave empty unless injecting specific seeds."
)
}),
"seed_offset": ("INT", {
"default": 0,
"min": SEED_MIN,
"max": SEED_MAX,
"step": 1,
"tooltip": (
"SEED OFFSET\n"
"• Purpose: Adds an integer offset to the seed before processing seed_list.\n"
"• Effect: Helps reduce repetition in batch workflows.\n"
"\n⭐ Recommended: 0."
)
}),
"wildcard_mode": ("BOOLEAN", {
"default": False,
"label_on": "enabled",
"label_off": "disabled",
"tooltip": (
"WILDCARD MODE\n"
"• Purpose: Enable processing of {opt1|opt2} or __opt1|opt2__ patterns.\n"
"• Effect: If disabled, text passes through exactly as written.\n"
"\n⭐ Recommended: True if writing dynamic prompts."
)
}),
"strip_whitespace": ("BOOLEAN", {
"default": False,
"label_on": "enabled",
"label_off": "disabled",
"tooltip": (
"STRIP WHITESPACE\n"
"• Purpose: Removes leading/trailing whitespace from the ENTIRE block.\n"
"\n⭐ Recommended: False (unless cleaning bad JSON/YAML)."
)
}),
"lowercase": ("BOOLEAN", {
"default": False,
"label_on": "enabled",
"label_off": "disabled",
"tooltip": (
"FORCE LOWERCASE\n"
"• Purpose: Converts entire output text to lowercase.\n"
"• Note: Overrides 'Force Uppercase' if both are true.\n"
"\n⭐ Recommended: False."
)
}),
"uppercase": ("BOOLEAN", {
"default": False,
"label_on": "enabled",
"label_off": "disabled",
"tooltip": (
"FORCE UPPERCASE\n"
"• Purpose: Converts entire output text to UPPERCASE.\n"
"\n⭐ Recommended: False."
)
}),
"remove_extra_spaces": ("BOOLEAN", {
"default": True,
"label_on": "enabled",
"label_off": "disabled",
"tooltip": (
"REMOVE EXTRA SPACES\n"
"• Purpose: Collapses multiple spaces into single spaces.\n"
"\n⭐ Recommended: True (keeps prompts clean)."
)
}),
"wildcard_syntax": (["curly_braces", "double_underscore"], {
"default": "curly_braces",
"tooltip": (
"WILDCARD SYNTAX\n"
"• Purpose: Choose the pattern style for wildcard parsing.\n"
"• Options: {curly_braces} or __double_underscore__.\n"
"\n⭐ Recommended: curly_braces."
)
}),
"debug_mode": (["0 - Silent", "1 - Info"], {
"default": "0 - Silent",
"tooltip": "LOGGING VERBOSITY\n• Controls console output and parser profiling."
}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT", "INT", "INT", "INT")
RETURN_NAMES = ("processed_text", "original_text", "seed_used", "selected_seed", "text_length", "wildcard_count")
FUNCTION = "process_text"
CATEGORY = "MD_Nodes/Text"
@classmethod
def IS_CHANGED(cls, text, seed=0, seed_mode="fixed", seed_list="", seed_offset=0, wildcard_mode=False, strip_whitespace=False,
lowercase=False, uppercase=False, remove_extra_spaces=True,
wildcard_syntax="curly_braces", debug_mode="0 - Silent"):
if seed_mode in ["random", "increment"]:
return secrets.token_hex(16)
return (text, seed, seed_mode, seed_list, seed_offset, wildcard_mode, strip_whitespace, lowercase, uppercase,
remove_extra_spaces, wildcard_syntax)
def _process_wildcards_recursive(self, text, pattern, rng):
iteration = 0
wildcard_count = 0
max_iterations = 100
while iteration < max_iterations:
match = pattern.search(text)
if not match:
break
options_str = match.group(1)
options = [opt.strip() for opt in options_str.split('|') if opt.strip()]
if not options:
logging.warning(f"[AdvancedTextNode] Empty wildcard detected: {match.group(0)}. Replacing with empty string.")
options = [""]
chosen_option = rng.choice(options)
text = text[:match.start()] + chosen_option + text[match.end():]
iteration += 1
wildcard_count += 1
if iteration == max_iterations:
logging.warning("[AdvancedTextNode] Max wildcard processing iterations reached. Possible runaway recursion?")
return text, wildcard_count
def process_wildcards_curly(self, text, seed):
rng = random.Random(seed)
return self._process_wildcards_recursive(text, self._PATTERN_CURLY, rng)
def process_wildcards_underscore(self, text, seed):
rng = random.Random(seed)
return self._process_wildcards_recursive(text, self._PATTERN_UNDERSCORE, rng)
def process_text(self, text, seed=0, seed_mode="fixed", seed_list="", seed_offset=0, wildcard_mode=False, strip_whitespace=False,
lowercase=False, uppercase=False, remove_extra_spaces=True,
wildcard_syntax="curly_braces", debug_mode="0 - Silent"):
debug_level = int(debug_mode.split(" ")[0])
profiler = PerformanceProfiler(enabled=(debug_level >= 1))
profiler.start("total_processing")
original_text = text
processed_text = text
seed_used = seed
selected_seed = 0
wildcard_count = 0
try:
# Handle seed_mode
if seed_mode == "random":
seed_used = self._generate_random_seed()
if debug_level >= 1: print(f"[AdvancedTextNode] 🎲 Random mode: Generated new seed: {seed_used}")
elif seed_mode == "increment":
seed_used = self._validate_seed((seed + 1) % (SEED_MAX + 1))
if debug_level >= 1: print(f"[AdvancedTextNode] ➕ Increment mode: Using seed: {seed_used}")
else:
seed_used = self._validate_seed(seed)
# Process seed_list if provided
if seed_list and seed_list.strip():
profiler.start("parse_seed_list")
selection_seed = self._validate_seed((seed_used + seed_offset) % (SEED_MAX + 1))
seed_list_wc_count = 0
if wildcard_syntax == "curly_braces":
selected_seed_str, seed_list_wc_count = self.process_wildcards_curly(seed_list, selection_seed)
elif wildcard_syntax == "double_underscore":
selected_seed_str, seed_list_wc_count = self.process_wildcards_underscore(seed_list, selection_seed)
else:
selected_seed_str = seed_list
wildcard_count += seed_list_wc_count
try:
selected_seed_raw = int(selected_seed_str.strip())
selected_seed = self._validate_seed(selected_seed_raw)
if selected_seed != selected_seed_raw and debug_level >= 1:
logging.warning(f"[AdvancedTextNode] ⚠️ Seed from list ({selected_seed_raw}) clamped to JS-safe: {selected_seed}")
except ValueError:
logging.warning(f"[AdvancedTextNode] Could not convert '{selected_seed_str}' to integer. Using {SEED_MIN}.")
selected_seed = SEED_MIN
profiler.stop("parse_seed_list")
else:
selected_seed = seed_used
if wildcard_mode:
profiler.start("parse_wildcards")
text_wc_count = 0
if wildcard_syntax == "curly_braces":
processed_text, text_wc_count = self.process_wildcards_curly(processed_text, seed_used)
elif wildcard_syntax == "double_underscore":
processed_text, text_wc_count = self.process_wildcards_underscore(processed_text, seed_used)
wildcard_count += text_wc_count
profiler.stop("parse_wildcards")
profiler.start("string_formatting")
if strip_whitespace:
processed_text = processed_text.strip()
if remove_extra_spaces:
processed_text = re.sub(r' +', ' ', processed_text)
processed_text = '\n'.join(line.strip() for line in processed_text.split('\n'))
if lowercase:
processed_text = processed_text.lower()
elif uppercase:
processed_text = processed_text.upper()
profiler.stop("string_formatting")
text_length = len(processed_text)
profiler.stop("total_processing")
if debug_level >= 1:
logging.info("\n" + "=" * 60)
logging.info("📊 [AdvancedTextNode] ANALYTICS REPORT")
logging.info("=" * 60)
logging.info("📝 TEXT STATS:")
logging.info(f" • Text Length: {text_length} chars")
logging.info(f" • Wildcards: {wildcard_count} processed")
profiler.print_report()
logging.info("=" * 60)
return (processed_text, original_text, seed_used, selected_seed, text_length, wildcard_count)
except Exception as e:
logging.error(f"[AdvancedTextNode] Error processing text: {e}")
logging.debug(traceback.format_exc())
return (original_text, original_text, seed_used, 0, len(original_text), 0)
# =================================================================================
# == Core Node Class: TextFileLoader ==
# =================================================================================
class TextFileLoader:
"""Companion node to load text content from external files."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {
"default": "",
"multiline": False,
"tooltip": (
"FILE PATH\n"
"• Purpose: Full path to the text file (.txt, .yaml, .json, etc.).\n"
"• Note: Supports relative paths from ComfyUI root or absolute paths.\n"
"• Example: `C:/data/config.yaml`"
)
}),
},
"optional": {
"encoding": (["utf-8", "ascii", "latin-1"], {
"default": "utf-8",
"tooltip": (
"FILE ENCODING\n"
"• Purpose: Character encoding of the file.\n"
"• Tip: If you see garbled text, try a different encoding.\n"
"\n⭐ Recommended: utf-8"
)
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "load_file"
CATEGORY = "MD_Nodes/Text"
@classmethod
def IS_CHANGED(cls, file_path, encoding="utf-8"):
try:
norm_path = os.path.normpath(file_path)
if not os.path.exists(norm_path):
m_time = -1
else:
m_time = os.path.getmtime(norm_path)
except Exception as e:
logging.warning(f"[TextFileLoader] IS_CHANGED check failed for path '{file_path}': {e}")
m_time = -2
return (norm_path, encoding, m_time)
def load_file(self, file_path, encoding="utf-8"):
logging.info(f"[TextFileLoader] 📂 Attempting to load file: {file_path}")
try:
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,)
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,)
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,)
with open(file_path, 'r', encoding=encoding) as f:
text = f.read()
logging.info(f"[TextFileLoader] ✅ Successfully loaded file: {file_path}")
return (text,)
except Exception as e:
error_msg = f"[TextFileLoader] Error loading file '{file_path}': {e}"
logging.error(error_msg)
logging.debug(traceback.format_exc())
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",
}
# ==============================================================================
# == Unit Tests (smoke — runs without ComfyUI)
# ==============================================================================
if __name__ == "__main__":
print("\n🧪 Smoke tests: MD_TextInput")
print(" VERSION :", VERSION)
_pass = _fail = 0
def _check(label, expr):
global _pass, _fail
if expr:
print(f" ✅ {label}")
_pass += 1
else:
print(f" ❌ {label}")
_fail += 1
_check("VERSION defined", VERSION == "v1.9.0")
_check("NODE_CLASS_MAPPINGS defined",
isinstance(NODE_CLASS_MAPPINGS, dict) and len(NODE_CLASS_MAPPINGS) > 0)
_check(" class AdvancedTextNode in map", "AdvancedTextNode" in NODE_CLASS_MAPPINGS)
_check(" class TextFileLoader in map", "TextFileLoader" in NODE_CLASS_MAPPINGS)
print(f"\n {_pass} passed, {_fail} failed")
if _fail == 0:
print(" 🎉 All good.")