Initial commit

This commit is contained in:
Eugene
2026-07-17 00:31:36 +02:00
commit d38647157e
10 changed files with 508 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Ignore Python bytecode and cache directories
__pycache__/
*.py[cod]
+1
View File
@@ -0,0 +1 @@
TODO
+31
View File
@@ -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"]
+32
View File
@@ -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,)
+48
View File
@@ -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,)
+79
View File
@@ -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,)
+77
View File
@@ -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,)
+113
View File
@@ -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(),)
+98
View File
@@ -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,)
+26
View File
@@ -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"]
}
]
});