Files
gmorks 88443a387f refactor(wildcards): change seed system to line_index for consistent selection
Replace individual seed parameters with line_index system to provide more predictable wildcard selection. This aligns with TextFileLoader's behavior and allows both random selection (when line_index=-1) and specific line selection. The global_seed parameter is renamed to seed for clarity.
2025-07-08 15:54:58 -06:00

582 lines
22 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-Prompt"
@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-Prompt"
@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}.
Uses line_index system like TextFileLoader for consistent selection.
"""
CATEGORY = "Animagine-Prompt"
@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)"
}),
"seed": ("INT", {
"default": -1,
"min": -1,
"max": 2**32 - 1,
"display": "number",
"tooltip": "Seed for random selection (-1 for time-based seed)"
})
},
"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_line_index": ("INT", {
"default": -1,
"min": -1,
"display": "number",
"tooltip": "Line index for wildcard 1 (-1 for random)"
}),
# 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_line_index": ("INT", {
"default": -1,
"min": -1,
"display": "number",
"tooltip": "Line index for wildcard 2 (-1 for random)"
}),
# 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_line_index": ("INT", {
"default": -1,
"min": -1,
"display": "number",
"tooltip": "Line index for wildcard 3 (-1 for random)"
}),
# 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_line_index": ("INT", {
"default": -1,
"min": -1,
"display": "number",
"tooltip": "Line index for wildcard 4 (-1 for random)"
}),
# 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_line_index": ("INT", {
"default": -1,
"min": -1,
"display": "number",
"tooltip": "Line index for wildcard 5 (-1 for random)"
})
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("combined_text", "preview_text")
FUNCTION = "load_multiple_wildcards"
@classmethod
def IS_CHANGED(s, mode, template_text, separator, 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}_{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)
line_index = kwargs.get(f"wildcard_{i}_line_index", -1)
params_str += f"_{path}_{enabled}_{line_index}"
# 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, line_index, seed):
"""
Load a line from a wildcard file using the TextFileLoader logic.
Args:
file_path (str): Path to the wildcard file
line_index (int): Index of line to select (-1 for random)
seed (int): Seed for random selection
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"
# Select line
if line_index == -1:
# Set up random seed for consistent but different choices
current_time = time.time()
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}) for {os.path.basename(resolved_path)}"
selected_line = lines[line_index]
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, 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)
seed (int): Seed for random selection
**kwargs: Wildcard paths, enabled flags, and line indices
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, seed)
else:
return self._process_slots_mode(separator, 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, seed):
"""
Process template mode by finding {filename.txt} placeholders and replacing them.
Args:
template_text (str): Template with placeholders
seed (int): Seed for random selection
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 (offset by position)
if seed == -1:
wildcard_seed = -1
else:
wildcard_seed = seed + i
# Load wildcard line (always random selection in template mode)
selected_line = self._load_wildcard_line(filename, -1, wildcard_seed)
# 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, seed, **kwargs):
"""
Process slots mode by loading enabled wildcards and joining with separator.
Args:
separator (str): Separator between wildcards
seed (int): Seed for random selection
**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"
line_index_key = f"wildcard_{i}_line_index"
# Get values with defaults
file_path = kwargs.get(path_key, "")
enabled = kwargs.get(enabled_key, True)
line_index = kwargs.get(line_index_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 (offset by position)
if seed == -1:
wildcard_seed = -1
else:
wildcard_seed = seed + i
# Load wildcard line
selected_line = self._load_wildcard_line(file_path, line_index, wildcard_seed)
# 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"
}