Files
gmorks-ComfyUI-Animagine-Pr…/wildcards_node.py
T
gmorks 71615236b6 feat(wildcards): add MultiWildcardLoader node for advanced wildcard management
Introduce new node supporting template mode with {filename.txt} syntax and slots mode with individual seed control. Includes real-time preview and error handling. Update version to 1.5.0 and add example wildcard files for expressions, poses, clothing, and scenarios.

Update example workflows to demonstrate new functionality and improve README with node documentation.
2025-07-07 21:02:06 -06:00

579 lines
21 KiB
Python

import os
import random
import time
from pathlib import Path
from dynamicprompts.generators import RandomPromptGenerator
# Cache to store text lines per file
text_file_cache = {}
# Dictionary to track last execution time for each node instance
last_execution_time = {}
class MultilineTextInput:
"""
A node that provides a multiline text input and outputs the entered text as a string.
Now supports dynamicprompts syntax for variants and wildcards.
"""
CATEGORY = "animagine/text"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter your text here..."
}),
"use_dynamic_prompts": ("BOOLEAN", {
"default": True,
"label": "Process Dynamic Prompts"
}),
"seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for dynamic prompts generation (-1 for random)"
})
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "output_text"
def output_text(self, text, use_dynamic_prompts, seed):
"""
Processes and returns the text, using dynamicprompts if enabled.
Args:
text (str): Input text from the multiline input
use_dynamic_prompts (bool): Whether to process with dynamicprompts
seed (int): Seed for random generation, -1 for random seed
Returns:
tuple: Contains the processed text as a string
"""
if not use_dynamic_prompts:
return (text,)
try:
# Use current time as unique identifier for this execution
node_instance_id = id(self)
current_time = time.time()
last_execution_time[node_instance_id] = current_time
# Set up seed
if seed == -1:
actual_seed = random.randint(0, 2**32 - 1)
else:
actual_seed = seed
# Set random seed for reproducible results
random.seed(actual_seed)
# Process with dynamicprompts
generator = RandomPromptGenerator()
processed_text = generator.generate(text, num_images=1)[0]
# Reset random seed to avoid affecting other randomness
random.seed(None)
return (processed_text,)
except Exception as e:
return (f"Dynamic Prompts Error: {str(e)}\nOriginal text: {text}",)
class TextFileLoader:
"""
A node that loads a text file and returns a specific line or a random line.
Ensures random choices are refreshed on each workflow execution.
"""
CATEGORY = "animagine/text"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"file_path": ("STRING", {
"default": "",
"placeholder": "Path to text file"
}),
"line_index": ("INT", {
"default": -1,
"min": -1,
"display": "number",
"tooltip": "Index of line to select (-1 for random)"
}),
"seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for random selection (-1 for time-based seed)"
})
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_line",)
FUNCTION = "load_text_line"
def load_text_line(self, file_path, line_index, seed):
"""
Loads a text file and returns a specific line or a random line.
Ensures new random choices on each workflow execution.
Args:
file_path (str): Path to the text file
line_index (int): Index of the line to select, or -1 for random selection
seed (int): Seed for random selection, -1 for time-based seed
Returns:
tuple: Contains the selected line as a string
"""
try:
# Use current time as unique identifier for this execution
node_instance_id = id(self)
current_time = time.time()
last_execution_time[node_instance_id] = current_time
# Normalize path
file_path = os.path.normpath(file_path.strip())
# Check if file exists
if not os.path.isfile(file_path):
return (f"Error: File not found at {file_path}",)
# Get file modification time
file_mod_time = os.path.getmtime(file_path)
# Use cache if available and file hasn't been modified
cache_key = file_path
if cache_key in text_file_cache and text_file_cache[cache_key]["mod_time"] == file_mod_time:
lines = text_file_cache[cache_key]["lines"]
else:
# Read file and store in cache
with open(file_path, 'r', encoding='utf-8') as f:
lines = [line.rstrip() for line in f if line.strip()]
text_file_cache[cache_key] = {
"lines": lines,
"mod_time": file_mod_time
}
# Check if file is empty
if not lines:
return ("Error: File is empty",)
# Select line
if line_index == -1:
# Set up random seed for consistent but different choices
if seed == -1:
# Time-based seed ensures different choice on each run
random_seed = int(current_time * 1000) % (2**32)
else:
# User-provided seed for reproducible randomness
random_seed = seed
# Set the seed for this selection
random.seed(random_seed)
# Random selection
selected_line = random.choice(lines)
# Reset random seed to avoid affecting other randomness
random.seed(None)
else:
# Check if index is valid
if line_index < 0 or line_index >= len(lines):
return (f"Error: Line index {line_index} out of range (0-{len(lines)-1})",)
selected_line = lines[line_index]
return (selected_line,)
except Exception as e:
return (f"Error loading file: {str(e)}",)
class MultiWildcardLoader:
"""
A node that loads multiple wildcard files and concatenates their selected lines.
Supports template mode with placeholder syntax like {filename.txt}.
Each wildcard has its own independent seed for varied selections.
"""
CATEGORY = "animagine/text"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"mode": (["slots", "template"], {
"default": "slots",
"tooltip": "Slots: Use individual wildcard slots. Template: Use template with {filename.txt} placeholders"
}),
"template_text": ("STRING", {
"multiline": True,
"default": "{expressions.txt} {poses.txt} wearing {clothing.txt} in a {scenarios.txt}",
"placeholder": "Template with {filename.txt} placeholders"
}),
"separator": ("STRING", {
"default": ", ",
"placeholder": "Separator between wildcards (slots mode only)"
}),
"global_seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Global seed offset (-1 for random)"
})
},
"optional": {
# Wildcard slot 1
"wildcard_1_path": ("STRING", {
"default": "",
"placeholder": "Path to first wildcard file"
}),
"wildcard_1_enabled": ("BOOLEAN", {
"default": True,
"label": "Enable Wildcard 1"
}),
"wildcard_1_seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for wildcard 1 (-1 for auto)"
}),
# Wildcard slot 2
"wildcard_2_path": ("STRING", {
"default": "",
"placeholder": "Path to second wildcard file"
}),
"wildcard_2_enabled": ("BOOLEAN", {
"default": True,
"label": "Enable Wildcard 2"
}),
"wildcard_2_seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for wildcard 2 (-1 for auto)"
}),
# Wildcard slot 3
"wildcard_3_path": ("STRING", {
"default": "",
"placeholder": "Path to third wildcard file"
}),
"wildcard_3_enabled": ("BOOLEAN", {
"default": True,
"label": "Enable Wildcard 3"
}),
"wildcard_3_seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for wildcard 3 (-1 for auto)"
}),
# Wildcard slot 4
"wildcard_4_path": ("STRING", {
"default": "",
"placeholder": "Path to fourth wildcard file"
}),
"wildcard_4_enabled": ("BOOLEAN", {
"default": True,
"label": "Enable Wildcard 4"
}),
"wildcard_4_seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for wildcard 4 (-1 for auto)"
}),
# Wildcard slot 5
"wildcard_5_path": ("STRING", {
"default": "",
"placeholder": "Path to fifth wildcard file"
}),
"wildcard_5_enabled": ("BOOLEAN", {
"default": True,
"label": "Enable Wildcard 5"
}),
"wildcard_5_seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for wildcard 5 (-1 for auto)"
})
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("combined_text", "preview_text")
FUNCTION = "load_multiple_wildcards"
@classmethod
def IS_CHANGED(s, mode, template_text, separator, global_seed, **kwargs):
"""
This method tells ComfyUI when the node output should be recalculated.
It helps with real-time preview by making the output update when parameters change.
"""
# Create a hash of all input parameters to detect changes
import hashlib
# Combine all parameters into a string
params_str = f"{mode}_{template_text}_{separator}_{global_seed}"
# Add wildcard parameters
for i in range(1, 6):
path = kwargs.get(f"wildcard_{i}_path", "")
enabled = kwargs.get(f"wildcard_{i}_enabled", True)
seed = kwargs.get(f"wildcard_{i}_seed", -1)
params_str += f"_{path}_{enabled}_{seed}"
# Return hash of parameters - when this changes, ComfyUI will recalculate
return hashlib.md5(params_str.encode()).hexdigest()
def _resolve_file_path(self, file_path):
"""
Resolve file path, trying relative to project directory first, then absolute.
Args:
file_path (str): The file path to resolve
Returns:
str: Resolved absolute path or original path if not found
"""
if not file_path or not file_path.strip():
return None
file_path = file_path.strip()
# If already absolute and exists, return as-is
if os.path.isabs(file_path) and os.path.isfile(file_path):
return file_path
# Try relative to current script directory
script_dir = os.path.dirname(os.path.abspath(__file__))
relative_path = os.path.join(script_dir, file_path)
if os.path.isfile(relative_path):
return relative_path
# Try relative to example_files directory
example_files_path = os.path.join(script_dir, "example_files", file_path)
if os.path.isfile(example_files_path):
return example_files_path
# Return original path (will cause error in load_text_line if not found)
return file_path
def _load_wildcard_line(self, file_path, seed_offset):
"""
Load a random line from a wildcard file using the TextFileLoader logic.
Args:
file_path (str): Path to the wildcard file
seed_offset (int): Seed offset for this wildcard
Returns:
str: Selected line or error message
"""
try:
resolved_path = self._resolve_file_path(file_path)
if not resolved_path:
return f"Error: Empty file path"
# Normalize path
resolved_path = os.path.normpath(resolved_path)
# Check if file exists
if not os.path.isfile(resolved_path):
return f"Error: File not found at {resolved_path}"
# Get file modification time
file_mod_time = os.path.getmtime(resolved_path)
# Use cache if available and file hasn't been modified
cache_key = resolved_path
if cache_key in text_file_cache and text_file_cache[cache_key]["mod_time"] == file_mod_time:
lines = text_file_cache[cache_key]["lines"]
else:
# Read file and store in cache
with open(resolved_path, 'r', encoding='utf-8') as f:
lines = [line.rstrip() for line in f if line.strip()]
text_file_cache[cache_key] = {
"lines": lines,
"mod_time": file_mod_time
}
# Check if file is empty
if not lines:
return f"Error: File {os.path.basename(resolved_path)} is empty"
# Generate seed for this wildcard
current_time = time.time()
if seed_offset == -1:
wildcard_seed = int(current_time * 1000) % (2**32)
else:
wildcard_seed = (seed_offset + int(current_time)) % (2**32)
# Set the seed for this selection
random.seed(wildcard_seed)
# Random selection
selected_line = random.choice(lines)
# Reset random seed to avoid affecting other randomness
random.seed(None)
return selected_line
except Exception as e:
return f"Error loading {os.path.basename(file_path) if file_path else 'file'}: {str(e)}"
def load_multiple_wildcards(self, mode, template_text, separator, global_seed, **kwargs):
"""
Load multiple wildcard files and combine their content.
Args:
mode (str): "slots" or "template" mode
template_text (str): Template text with {filename.txt} placeholders
separator (str): Separator between wildcards (slots mode only)
global_seed (int): Global seed offset
**kwargs: Wildcard paths, enabled flags, and individual seeds
Returns:
tuple: Contains the combined text and preview text
"""
try:
# Use current time as unique identifier for this execution
node_instance_id = id(self)
current_time = time.time()
last_execution_time[node_instance_id] = current_time
if mode == "template":
return self._process_template_mode(template_text, global_seed)
else:
return self._process_slots_mode(separator, global_seed, **kwargs)
except Exception as e:
error_msg = f"Multi-Wildcard Error: {str(e)}"
return (error_msg, error_msg)
def _process_template_mode(self, template_text, global_seed):
"""
Process template mode by finding {filename.txt} placeholders and replacing them.
Args:
template_text (str): Template with placeholders
global_seed (int): Global seed offset
Returns:
tuple: Processed template text and preview text
"""
import re
# Find all {filename.txt} patterns
placeholder_pattern = r'\{([^}]+)\}'
placeholders = re.findall(placeholder_pattern, template_text)
if not placeholders:
return (template_text, template_text)
result_text = template_text
# Process each placeholder
for i, filename in enumerate(placeholders):
# Calculate seed for this wildcard
if global_seed == -1:
seed_offset = -1
else:
seed_offset = global_seed + i
# Load wildcard line
selected_line = self._load_wildcard_line(filename, seed_offset)
# Replace the first occurrence of this placeholder
placeholder = '{' + filename + '}'
result_text = result_text.replace(placeholder, selected_line, 1)
return (result_text, result_text)
def _process_slots_mode(self, separator, global_seed, **kwargs):
"""
Process slots mode by loading enabled wildcards and joining with separator.
Args:
separator (str): Separator between wildcards
global_seed (int): Global seed offset
**kwargs: Wildcard configuration
Returns:
tuple: Combined wildcard text and preview text
"""
selected_lines = []
# Process each wildcard slot
for i in range(1, 6): # 5 slots
path_key = f"wildcard_{i}_path"
enabled_key = f"wildcard_{i}_enabled"
seed_key = f"wildcard_{i}_seed"
# Get values with defaults
file_path = kwargs.get(path_key, "")
enabled = kwargs.get(enabled_key, True)
individual_seed = kwargs.get(seed_key, -1)
# Skip if not enabled or no path
if not enabled or not file_path or not file_path.strip():
continue
# Calculate seed for this wildcard
if individual_seed != -1:
seed_offset = individual_seed
elif global_seed != -1:
seed_offset = global_seed + i
else:
seed_offset = -1
# Load wildcard line
selected_line = self._load_wildcard_line(file_path, seed_offset)
# Only add if not an error message
if not selected_line.startswith("Error:"):
selected_lines.append(selected_line)
else:
# Include error in output for debugging
selected_lines.append(selected_line)
# Combine with separator
if not selected_lines:
no_wildcards_msg = "No wildcards loaded"
return (no_wildcards_msg, no_wildcards_msg)
combined_text = separator.join(selected_lines)
return (combined_text, combined_text)
# Node class mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {
"MultilineTextInput": MultilineTextInput,
"TextFileLoader": TextFileLoader,
"MultiWildcardLoader": MultiWildcardLoader
}
# Display names for better UI presentation
NODE_DISPLAY_NAME_MAPPINGS = {
"MultilineTextInput": "Multiline Text Input",
"TextFileLoader": "Wildcards Text File Loader",
"MultiWildcardLoader": "Multi-Wildcard Loader"
}