From d38647157e7ec99101baab10cc33a2a23ceddca0 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 17 Jul 2026 00:31:36 +0200 Subject: [PATCH] Initial commit --- .gitignore | 3 ++ README.md | 1 + __init__.py | 31 ++++++++++++ console_log.py | 32 ++++++++++++ execution_delay.py | 48 ++++++++++++++++++ file_loader.py | 79 ++++++++++++++++++++++++++++++ randomizer.py | 77 +++++++++++++++++++++++++++++ text_concatenator.py | 113 +++++++++++++++++++++++++++++++++++++++++++ text_tag_replacer.py | 98 +++++++++++++++++++++++++++++++++++++ web/reload_ui.js | 26 ++++++++++ 10 files changed, 508 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 console_log.py create mode 100644 execution_delay.py create mode 100644 file_loader.py create mode 100644 randomizer.py create mode 100644 text_concatenator.py create mode 100644 text_tag_replacer.py create mode 100644 web/reload_ui.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeaf3f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Ignore Python bytecode and cache directories +__pycache__/ +*.py[cod] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..30404ce --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9fa6c91 --- /dev/null +++ b/__init__.py @@ -0,0 +1,31 @@ +WEB_DIRECTORY = "./web" + +# Import all independent node files safely +from .console_log import ConsoleLogNode +from .execution_delay import ExecutionDelayNode +from .file_loader import WildcardFileLoaderNode +from .randomizer import MultilineRandomChanceNode +from .text_concatenator import TextConcatenator4Node, TextConcatenator8Node +from .text_tag_replacer import TextRandomReplacerNode + +NODE_CLASS_MAPPINGS = { + "MultilineRandomChanceNode": MultilineRandomChanceNode, + "ConsoleLogNode": ConsoleLogNode, + "TextRandomReplacerNode": TextRandomReplacerNode, + "WildcardFileLoaderNode": WildcardFileLoaderNode, + "ExecutionDelayNode": ExecutionDelayNode, + "TextConcatenator4Node": TextConcatenator4Node, + "TextConcatenator8Node": TextConcatenator8Node, # New mapping +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "MultilineRandomChanceNode": "🎲 Weighted Multiline Randomizer", + "ConsoleLogNode": "🖥️ Log to Console", + "TextRandomReplacerNode": "🏷️ Text Random Replacer", + "WildcardFileLoaderNode": "📂 Wildcard File Loader", + "ExecutionDelayNode": "⏳ Execution Delay", + "TextConcatenator4Node": "🧩 Text Concatenator (4-Way)", + "TextConcatenator8Node": "🧱 Text Concatenator (8-Way)", # New visual display label +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/console_log.py b/console_log.py new file mode 100644 index 0000000..890e3a4 --- /dev/null +++ b/console_log.py @@ -0,0 +1,32 @@ +class ConsoleLogNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"forceInput": True}), + "prefix_label": ("STRING", {"default": "LOG TEXT"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + FUNCTION = "log_text_to_terminal" + CATEGORY = "TextRandomizer/Debug" + + def log_text_to_terminal(self, text, prefix_label): + cyan_start, yellow_start, color_reset = "\033[96m", "\033[93m", "\033[0m" + + # Check if the incoming data is a batch list or a single string + if isinstance(text, list): + print( + f"\n{cyan_start}[ℹ️ {prefix_label} - BATCH COUNT: {len(text)}]{color_reset}" + ) + for idx, item in enumerate(text): + print(f'{cyan_start} [{idx + 1}]:{yellow_start} "{item}"{color_reset}') + print("") + else: + # Fallback for standard single string inputs + print(f"\n{cyan_start}[ℹ️ {prefix_label}]{color_reset}") + print(f'{yellow_start}"{text}"{color_reset}\n') + + return (text,) diff --git a/execution_delay.py b/execution_delay.py new file mode 100644 index 0000000..93856d5 --- /dev/null +++ b/execution_delay.py @@ -0,0 +1,48 @@ +import time + + +class ExecutionDelayNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + # The data flowing through the chain (Any type placeholder '*') + "any_input": ("*", {"forceInput": True}), + # The number of seconds to sleep/throttle + "seconds_to_wait": ( + "INT", + {"default": 5, "min": 0, "max": 3600, "step": 1}, + ), + # Visual label toggle to trace wait status in your terminal window + "print_to_console": (["enable", "disable"], {"default": "enable"}), + } + } + + # By declaring '*' as the output type, ComfyUI dynamically copies + # the exact data type that entered the node (String, Latent, Image, etc.) + RETURN_TYPES = ("*",) + RETURN_NAMES = ("output_data",) + FUNCTION = "trigger_throttle_delay" + CATEGORY = "TextRandomizer/Utility" + + def trigger_throttle_delay(self, any_input, seconds_to_wait, print_to_console): + if seconds_to_wait > 0: + if print_to_console == "enable": + cyan_start, yellow_start, color_reset = ( + "\033[96m", + "\033[93m", + "\033[0m", + ) + print( + f"{cyan_start}[⏳ Throttle]{color_reset} Sleeping for {yellow_start}{seconds_to_wait}{color_reset} seconds..." + ) + + # Freeze execution thread entirely + time.sleep(seconds_to_wait) + + if print_to_console == "enable": + print( + f"{cyan_start}[⏳ Throttle]{color_reset} Resume signal sent down pipeline." + ) + + return (any_input,) diff --git a/file_loader.py b/file_loader.py new file mode 100644 index 0000000..e722289 --- /dev/null +++ b/file_loader.py @@ -0,0 +1,79 @@ +import os +import random + +from .randomizer import MultilineRandomChanceNode + + +class WildcardFileLoaderNode: + @classmethod + def get_wildcard_files(cls): + """Scans the wildcards directory recursively to find all text files, preserving folder structures.""" + base_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "wildcards") + ) + os.makedirs(base_dir, exist_ok=True) + + file_list = [] + for root, _, files in os.walk(base_dir): + for file in files: + if file.endswith(".txt"): + full_path = os.path.join(root, file) + rel_path = os.path.relpath(full_path, base_dir) + file_list.append(rel_path.replace("\\", "/")) + + if not file_list: + return ["No .txt files found in ComfyUI/wildcards/"] + + return sorted(file_list) + + @classmethod + def INPUT_TYPES(cls): + file_list = cls.get_wildcard_files() + return { + "required": { + "wildcard_file": (file_list,), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("selected_text",) + FUNCTION = "load_and_randomize_file" + CATEGORY = "TextRandomizer/Text" + + def load_and_randomize_file(self, wildcard_file, seed): + base_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "wildcards") + ) + file_path = os.path.join(base_dir, wildcard_file) + + if not os.path.exists(file_path) or wildcard_file.startswith("No .txt files"): + return ("",) + + try: + with open(file_path, "r", encoding="utf-8") as f: + raw_lines = f.readlines() + except Exception: + return ("",) + + # Filter out comments and blank lines + clean_text_block = "\n".join( + [ + line.strip() + for line in raw_lines + if line.strip() and not line.startswith("#") + ] + ) + + if not clean_text_block: + return ("",) + + # 1. Fetch the raw tuple output generated by the randomizer module code + randomizer = MultilineRandomChanceNode() + final_string_tuple = randomizer.process_random_text(clean_text_block, seed) + + # 2. Extract index 0 to strip away the internal tuple container structure + pure_extracted_string = final_string_tuple[0] + + # 3. Return the pure string packaged cleanly inside ComfyUI's mandatory outer node tuple wrapper + return (pure_extracted_string,) diff --git a/randomizer.py b/randomizer.py new file mode 100644 index 0000000..05d2115 --- /dev/null +++ b/randomizer.py @@ -0,0 +1,77 @@ +import random +import re + + +class MultilineRandomChanceNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ( + "STRING", + { + "multiline": True, + "default": "A professional photo of a {female::5|male} mage, casting a {fire::5|ice::2|lightning} spell, cinematic lighting.", + }, + ), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), + }, + "optional": { + "regex_pattern": ("STRING", {"default": ",\\s*,"}), + "regex_replacement": ("STRING", {"default": ","}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("selected_text",) + FUNCTION = "process_random_text" + CATEGORY = "TextRandomizer/Text" + + def _parse_weights(self, options_list, separator="::"): + choices, weights = [], [] + for opt in options_list: + if separator in opt: + parts = opt.rsplit(separator, 1) + choice_text = parts[0].strip() + try: + weight = float(parts[1].strip()) + except ValueError: + weight = 1.0 + else: + choice_text = opt.strip() + weight = 1.0 + choices.append(choice_text) + weights.append(weight) + return choices, weights + + def _replace_brackets(self, match): + content = match.group(1) + raw_options = content.split("|") + choices, weights = self._parse_weights(raw_options, separator="::") + if choices: + return random.choices(choices, weights=weights, k=1)[0] + return "" + + def process_random_text(self, text, seed, regex_pattern="", regex_replacement=""): + random.seed(seed) + lines = [line.strip() for line in text.split("\n") if line.strip()] + if not lines: + return ("",) + + base_choices, base_weights = self._parse_weights(lines, separator="::") + selected_line = random.choices(base_choices, weights=base_weights, k=1)[0] + + bracket_pattern = re.compile(r"\{([^{}]+)\}") + loop_guard = 0 + while bracket_pattern.search(selected_line) and loop_guard < 10: + selected_line = bracket_pattern.sub(self._replace_brackets, selected_line) + loop_guard += 1 + + if regex_pattern: + try: + compiled_regex = re.compile(regex_pattern) + selected_line = compiled_regex.sub(regex_replacement, selected_line) + except re.error: + pass + + return (selected_line,) diff --git a/text_concatenator.py b/text_concatenator.py new file mode 100644 index 0000000..455a51f --- /dev/null +++ b/text_concatenator.py @@ -0,0 +1,113 @@ +import re + + +class TextConcatenator4Node: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "separator": ("STRING", {"default": "; "}), + }, + "optional": { + "input_1": ("STRING", {"multiline": True, "default": ""}), + "input_2": ("STRING", {"multiline": True, "default": ""}), + "input_3": ("STRING", {"multiline": True, "default": ""}), + "input_4": ("STRING", {"multiline": True, "default": ""}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("merged_text",) + FUNCTION = "combine_strings" + CATEGORY = "TextRandomizer/Text" + + def combine_strings( + self, separator, input_1="", input_2="", input_3="", input_4="" + ): + raw_fragments = [input_1, input_2, input_3, input_4] + active_fragments = [] + + for frag in raw_fragments: + if frag is not None: + clean_frag = str(frag).strip() + if clean_frag: + active_fragments.append(clean_frag) + + if not active_fragments: + return ("",) + + combined_output = separator.join(active_fragments) + combined_output = re.sub(r"\s+", " ", combined_output) + combined_output = re.sub(r",\s*,", ", ", combined_output) + combined_output = combined_output.strip().rstrip(",") + + return (combined_output.strip(),) + + +class TextConcatenator8Node: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "separator": ("STRING", {"default": "; "}), + }, + "optional": { + # 8 dedicated, predictable slots accepting typing OR wire inputs + "input_1": ("STRING", {"multiline": True, "default": ""}), + "input_2": ("STRING", {"multiline": True, "default": ""}), + "input_3": ("STRING", {"multiline": True, "default": ""}), + "input_4": ("STRING", {"multiline": True, "default": ""}), + "input_5": ("STRING", {"multiline": True, "default": ""}), + "input_6": ("STRING", {"multiline": True, "default": ""}), + "input_7": ("STRING", {"multiline": True, "default": ""}), + "input_8": ("STRING", {"multiline": True, "default": ""}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("merged_text",) + FUNCTION = "combine_strings" + CATEGORY = "TextRandomizer/Text" + + def combine_strings( + self, + separator, + input_1="", + input_2="", + input_3="", + input_4="", + input_5="", + input_6="", + input_7="", + input_8="", + ): + # 1. Gather all 8 potential input strings + raw_fragments = [ + input_1, + input_2, + input_3, + input_4, + input_5, + input_6, + input_7, + input_8, + ] + active_fragments = [] + + # 2. Iterate and drop empty text values or skipped wires safely + for frag in raw_fragments: + if frag is not None: + clean_frag = str(frag).strip() + if clean_frag: + active_fragments.append(clean_frag) + + if not active_fragments: + return ("",) + + # 3. Assemble and apply standard punctuation/spacing cleanup passes + combined_output = separator.join(active_fragments) + combined_output = re.sub(r"\s+", " ", combined_output) + combined_output = re.sub(r",\s*,", ", ", combined_output) + combined_output = combined_output.strip().rstrip(",") + + return (combined_output.strip(),) diff --git a/text_tag_replacer.py b/text_tag_replacer.py new file mode 100644 index 0000000..4ac9d8c --- /dev/null +++ b/text_tag_replacer.py @@ -0,0 +1,98 @@ +import re + +from .randomizer import MultilineRandomChanceNode + + +class TextRandomReplacerNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ( + "STRING", + { + "multiline": True, + "default": "" + # "default": "A {professional photo|2d sketch} of a #CHARACTER in a #LOCATION, cinematic lighting.", + }, + ), + #"target_tag": ("STRING", {"default": "#CHARACTER"}), + "target_tag": ("STRING", {"default": "#TAG"}), + "replacement_options": ( + "STRING", + { + "multiline": True, + "default": "" + # "default": "ancient elven {wizard|mage}\ncyberpunk {hacker|netrunner} :: 2", + }, + ), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("completed_prompt",) + FUNCTION = "replace_tag_with_random" + CATEGORY = "TextRandomizer/Text" + + def _process_single_string( + self, single_text, target_tag, chosen_replacement, randomizer + ): + """Helper to swap the tag and expand any curly brackets in a single prompt string.""" + if target_tag and target_tag in single_text: + text_with_tag_swapped = single_text.replace(target_tag, chosen_replacement) + else: + text_with_tag_swapped = single_text + + bracket_pattern = re.compile(r"\{([^{}]+)\}") + final_prompt = text_with_tag_swapped + + loop_guard = 0 + while bracket_pattern.search(final_prompt) and loop_guard < 10: + final_prompt = bracket_pattern.sub( + randomizer._replace_brackets, final_prompt + ) + loop_guard += 1 + + return final_prompt + + def replace_tag_with_random(self, text, target_tag, replacement_options, seed): + randomizer = MultilineRandomChanceNode() + + # 1. Standardise the replacement options into a flat list of pure strings + if isinstance(replacement_options, list): + options_list = replacement_options + else: + # FIX: We unpack index [0] to extract the pure string from the randomizer tuple + chosen_tuple = randomizer.process_random_text(replacement_options, seed) + options_list = [chosen_tuple[0]] + + # 2. Standardise the input template text into a list + if isinstance(text, list): + text_list = text + else: + text_list = [text] + + final_batch_output = [] + + # 3. Zip or loop through the text templates and your batch of options + max_batch = max(len(text_list), len(options_list)) + + for i in range(max_batch): + single_text_item = text_list[i % len(text_list)] + chosen_replacement = options_list[i % len(options_list)] + + processed_item = self._process_single_string( + single_text_item, target_tag, chosen_replacement, randomizer + ) + final_batch_output.append(processed_item) + + # 4. Clean up the output format for ComfyUI pipeline compliance + if ( + len(final_batch_output) == 1 + and not isinstance(text, list) + and not isinstance(replacement_options, list) + ): + return (final_batch_output[0],) + else: + return (final_batch_output,) diff --git a/web/reload_ui.js b/web/reload_ui.js new file mode 100644 index 0000000..de69de4 --- /dev/null +++ b/web/reload_ui.js @@ -0,0 +1,26 @@ +import { app } from "/scripts/app.js"; + +app.registerExtension({ + name: "CustomUtils.ReloadUIButton", + + // 1. Define the actual browser execution task + commands: [ + { + id: "reloadComfyUICommand", + label: "🔄 Reload ComfyUI", + function: () => { + // Instantly soft-reboots the user interface without losing local storage cache + window.location.reload(); + } + } + ], + + // 2. Inject the command into ComfyUI's main top utility bar menu layout + menuCommands: [ + { + // Places it under 'Menu -> Extensions -> Reload UI' or inside the Topbar depending on your active UI version + path: ["Extensions"], + commands: ["reloadComfyUICommand"] + } + ] +});