Added wildcards, text splitter, text extractor nodes

This commit is contained in:
MNeMoNiCuZ
2025-06-07 14:08:00 +02:00
parent 5a51d79013
commit d09f512184
13 changed files with 1021 additions and 94 deletions
+158
View File
@@ -0,0 +1,158 @@
# Wildcard Processor Node
The Wildcard Processor is a powerful and flexible custom node for ComfyUI designed to add dynamic content to your prompts. It allows you to pull random elements from lists, use weighted choices, select multiple items, define variables, and nest these features for complex and varied outputs.
This node is a complete rewrite of a previous version, designed for stability, performance, and a rich feature set.
## Features
- **File-based Wildcards**: Use `__filename__` to insert a random line from any `.txt` file in your `wildcards` folder.
- **Glob Wildcards**: Use patterns like `__animals/*__` to randomly select a line from any file within the `animals` subdirectory.
- **Inline Wildcards**: Use `{option1|option2|option3}` for simple, on-the-fly choices.
- **Weighted Choices**: Give certain options a higher chance of being picked with `{2::option1|option2}`.
- **Multiple Selections**:
- **Fixed**: Choose a specific number of items: `{3$$item1|item2|item3|item4}`.
- **Ranged**: Choose a random number of items within a range: `{1-3$$item1|item2|item3|item4}`.
- **Custom Separator**: An input field on the node lets you define the string used to join multiple selected items from a single wildcard (e.g., a space, a comma, or nothing).
- **Variables**: Define and reuse a dynamic value within your prompt: `${my_var=!{a|b|c}} ... ${my_var}`.
- **Nesting**: Combine any of the above features, like `A {__colors__} {car|truck} with a {__animal__|__colors__} decal.`
- **Comments & Whitespace**: Add comments (`#`) and line breaks inside `{}` blocks to keep your prompts readable.
- **Intelligent File Matching**: When looking for `__wildcard__` files, the processor uses a smart matching system to find the best possible file, prioritizing exact matches and handling subdirectories gracefully.
- **Console Logging**: A toggleable option (off by default) to print detailed processing steps to the console for easy debugging and verification.
- **Tag Extraction**: A powerful feature to pull specific parts out of your prompt for separate use, while removing them from the main text.
## How to Use
1. **Add the Node**: Add the `Wildcard Processor` node to your workflow.
2. **Connect Inputs**:
- `wildcard_string`: This is where you write your prompt using the wildcard syntax.
- `seed`: Controls the randomization. Use the `control_after_generate` widget to set it to `fixed`, `randomize`, etc.
- `multiple_separator`: (Default: space) The character(s) to put between items when you select more than one from a single wildcard (e.g., using `2$$`).
- `recache_wildcards`: Enable this to force a reload of all wildcard files from disk.
- `console_log`: (Default: False) Check this to see detailed output in your console.
- `tag_extraction_tags`: Define character pairs to extract content (e.g., `[],**`).
3. **Connect Outputs**:
- `processed_text`: The final, cleaned text to be used as your prompt.
- `seed`: The integer seed value that was used for this run.
- `extracted_tags_string`: A single string containing all the processed content from the extracted tags, joined by `|`.
- `extracted_tags_list`: A list of strings, where each item is a piece of processed content from an extracted tag.
- `raw_tags_string`: A single string containing the re-assembled tags (delimiters included) after their internal wildcards have been processed.
- `raw_tags_list`: A list of strings, where each item is a re-assembled tag.
---
## Feature Details & Examples
### File Wildcards (`__...__`)
This feature lets you manage large lists of options in separate text files. Place any `.txt` file inside your `ComfyUI/custom_nodes/ComfyUI-mnemic-nodes/wildcards/` directory. The node will automatically find them, even in subfolders.
- **Example**: If you have `wildcards/animals.txt`, you can use `__animals__` in your prompt.
- **Input**: `A cute little __animals__.`
- **Possible Output**: `A cute little cat.`
### Glob Wildcards (`__*__`)
This feature allows you to use glob patterns (like `*`, `?`, `**`) to match multiple wildcard files at once. The node will collect all lines from all matching files and pick one at random.
- **Example**: You have files `wildcards/monsters/goblins.txt` and `wildcards/monsters/orcs.txt`.
- **Input**: `A dangerous __monsters/*__ appears.`
- **Explanation**: The pattern `monsters/*` matches both `goblins.txt` and `orcs.txt`. The processor will pool all lines from both files together before picking one.
- **Possible Output**: `A dangerous goblin scout appears.`
### Inline Wildcards (`{...|...}`)
For simple choices, you can define them directly in the prompt.
- **Example Input**: `A {red|green|blue} car.`
- **Possible Output**: `A green car.`
- **Empty Options**: You can have empty options. `{a|b||d}` gives a 25% chance of returning an empty string.
### Weighted Choices (`N::...`)
To make an option more likely, prefix it with a number and `::`. The number represents its "weight". An option without a weight has a default weight of 1.
- **Example Input**: `A {5::majestic|tiny} {10::panda|ant|crawler}.`
- **Explanation**: `majestic` is 5 times more likely to be chosen than `tiny`, and `panda` is 10 times more likely than `ant` or `crawler`.
- **Possible Output**: `A majestic panda.` (most of the time)
### Fixed Multiple Selections (`N$$...`)
Choose a fixed number of unique items from a list. The selected items will be joined together by the string you provide in the `multiple_separator` input.
- **Example with an inline wildcard**:
- **Prompt Input**: `My favorite colors are {2$$red|green|blue|yellow}.`
- **`multiple_separator` Input**: `, `
- **Possible Output**: `My favorite colors are blue, green.`
### Ranged Multiple Selections (`N-M$$...`)
Choose a random number of unique items from a list within a specified range.
- **Example with a file wildcard**:
- Assume you have a file `wildcards/clothing.txt` containing `hat, shirt, pants, socks`.
- **Prompt Input**: `The character is wearing {1-2$$__clothing__}.`
- **`multiple_separator` Input**: ` and `
- **Possible Output**: `The character is wearing shirt and socks.`
### Variables (`${...=!...}`)
Define a variable to reuse a randomly selected value multiple times in the same prompt. This ensures consistency. The value is determined once and then substituted wherever the variable is used.
- **Example**:
- **Prompt Input**: `The ${animal=!__animals__} is a happy ${animal}. It loves to chase a {red|blue} ball.`
- **Explanation**: The `__animals__` wildcard is evaluated once and stored in the `animal` variable. That same value is then used everywhere `${animal}` appears.
- **Possible Output**: `The cat is a happy cat. It loves to chase a red ball.`
### Nesting
You can combine all of these features for incredibly dynamic and complex prompts. The processor evaluates them from the inside out.
- **Example**: `a {{red|black}__animal__|__color__ __animal__}`
- **Explanation**: This gives a 50% chance of a `red` or `black` animal (e.g., `a red cat`), and a 50% chance of a random color and a random animal (e.g., `a blue dog`).
### Comments and Whitespace
To improve the readability of complex prompts, you can add comments and format your wildcards over multiple lines.
- **Example**:
```
A diamond ring set on a {
{rose|yellow|white} gold # you can also add comments
| platinum # which will be ignored by the parser
} band
```
- **Possible Output**: `A diamond ring set on a rose gold band`
### Tag Extraction
This feature allows you to define special tags in your prompt that will be extracted and processed separately. The tags and their content are removed from the main prompt, allowing you to isolate parts of your prompt for other uses, such as feeding them into other nodes.
- **Syntax**: You define the tag delimiters in the `tag_extraction_tags` input field. For example, `[],**` would tell the node to look for content inside `[square brackets]` and `*asterisks*`.
- **Wildcard Compatibility**: Wildcards work inside the extracted tags! The content is processed with the same seed as the main prompt.
- **Invalid Characters**: The characters `( ) { } |` cannot be used as tag delimiters.
- **Example Workflow**:
1. **`wildcard_string` input**:
```
A beautiful landscape, [photo by __artist__], style <{realistic|painterly}>
```
2. **`tag_extraction_tags` input**:
```
[],<>
```
3. **Processing Steps**:
- The node finds `[photo by __artist__]` and `<{realistic|painterly}>`.
- It removes them from the main string, leaving: `A beautiful landscape, , style `
- It processes the content of the first tag: `photo by __artist__` might become `photo by Ansel Adams`.
- It processes the content of the second tag: `{realistic|painterly}` will become either `realistic` or `painterly`.
4. **Outputs**:
- **`processed_text`**: `A beautiful landscape, , style `
- **`extracted_tags_string`**: `photo by Ansel Adams|realistic` (assuming `__artist__` resolved to `Ansel Adams` and `{...}` to `realistic`)
- **`extracted_tags_list`**: `['photo by Ansel Adams', 'realistic']`
- **`raw_tags_string`**: `[photo by Ansel Adams]<{realistic}>` (Note: wildcards inside the tags are resolved)
- **`raw_tags_list`**: `['[photo by Ansel Adams]', '<{realistic}>']`
This allows you to, for example, route the artist's name or a chosen style to another part of your workflow, while ensuring it doesn't appear in the final prompt sent to the sampler.
> **Pro Tip:** The `processed_text` output may contain remnants of the delimiters (e.g., `, , style`). For cleaner output, you can chain the `processed_text` into a `String Cleaning` node to remove extra spaces or symbols. To split the `extracted_tags_string` by its `|` delimiter, use the **`String Text Splitter`** node.
+11 -2
View File
@@ -1,3 +1,6 @@
import os
import folder_paths
from .nodes.download_image_from_url import DownloadImageFromURL
from .nodes.save_text_file import SaveTextFile
from .nodes.get_file_path import GetFilePath
@@ -10,9 +13,11 @@ from .nodes.string_cleaning import StringCleaning
from .nodes.generate_negative_prompt import GenerateNegativePrompt
from .nodes.lora_tag_loader import LoraTagLoader
from .nodes.resolution_selector import ResolutionSelector
from .nodes.wildcard_processor import WildcardProcessor
from .nodes.string_text_splitter import StringTextSplitter
from .nodes.string_text_extractor import StringTextExtractor
NODE_CLASS_MAPPINGS = {
NODE_CLASS_MAPPINGS = {
"📁 Get File Path": GetFilePath,
"💾 Save Text File With Path": SaveTextFile,
"🖼️ Download Image from URL": DownloadImageFromURL,
@@ -24,7 +29,11 @@ NODE_CLASS_MAPPINGS = {
"🧹 String Cleaning": StringCleaning,
"🏷️ LoRA Loader Prompt Tags": LoraTagLoader,
"📐 Resolution Image Size Selector": ResolutionSelector,
"📝 Wildcard Processor": WildcardProcessor,
"⛔ Generate Negative Prompt": GenerateNegativePrompt,
"✂️ String Text Splitter": StringTextSplitter,
"✂️ String Text Extractor": StringTextExtractor,
}
print("\033[34mMNeMiC Nodes: \033[92mLoaded\033[0m")
+6
View File
@@ -9,6 +9,9 @@ from .tiktoken_tokenizer import TiktokenTokenizer
from .string_cleaning import StringCleaning
from .lora_tag_loader import LoraTagLoader
from .resolution_selector import ResolutionSelector
from .wildcard_processor import WildcardProcessor
from .string_text_splitter import StringTextSplitter
from .string_text_extractor import StringTextExtractor
#from .groq_api_alm_translate import GroqAPIALMTranslate
__all__ = [
@@ -24,4 +27,7 @@ __all__ = [
"GenerateNegativePrompt",
"LoraTagLoader",
"ResolutionSelector",
"WildcardProcessor",
"StringTextSplitter",
"StringTextExtractor",
]
+2 -88
View File
@@ -1,6 +1,7 @@
from pathlib import Path
import folder_paths
import re
from ..utils.file_utils import find_best_match
# Import ComfyUI files
import comfy.sd
@@ -40,93 +41,6 @@ class LoraTagLoader:
CATEGORY = "⚡ MNeMiC Nodes" # Category for organizing the node in a UI or library
DESCRIPTION = "Loads LoRA tags from the provided input string (usually the prompt) and applies them to the model without needing one or multiple LoRA Loader nodes"
def score_filename_match(self, name, filename):
"""Score how well a filename matches the requested name."""
base_name = Path(filename).name
base_name_no_ext = Path(filename).stem # filename without extension
name_no_ext = Path(name).stem # search term without extension
# Calculate path depth score
path_depth = len(Path(filename).parts) - 1
path_penalty = path_depth * 0.0001
# If searching for a numbered variant, also match the base name
base_search_name = re.sub(r'\d+$', '', name_no_ext).rstrip('-')
# Exact match gets highest priority (100)
if base_name_no_ext == name_no_ext:
return (100 - path_penalty, f"exact match (depth: {path_depth})")
# Base version match when searching for numbered variant (90)
elif base_name_no_ext == base_search_name:
return (90 - path_penalty, f"base version match (depth: {path_depth})")
# Check if it's a numbered variant of the exact name (80)
if base_name.startswith(name + "-"):
try:
num = int(re.findall(r'-(\d+)', base_name)[0])
return (80 + (num * 0.001) - path_penalty, f"numbered variant ({num}, depth: {path_depth})")
except (IndexError, ValueError):
pass
# Check if we're looking for a specific number
number_search = re.search(r'(\d+)$', name)
if number_search:
base_without_number = name[:-len(number_search.group(1))]
target_number = int(number_search.group(1))
if base_name.startswith(base_without_number):
try:
file_number = int(re.findall(r'-(\d+)', base_name)[0])
number_diff = abs(target_number - file_number)
if number_diff == 0:
return (95 - path_penalty, f"exact number match ({target_number}, depth: {path_depth})")
return (85 - (number_diff * 0.1) - path_penalty, f"number near match ({file_number}, depth: {path_depth})")
except (IndexError, ValueError):
pass
# Simple startswith match (lowest priority)
if base_name.startswith(name) or filename.startswith(name):
return (50 - path_penalty, f"prefix match (depth: {path_depth})")
return (0, "no match")
def find_best_lora_match(self, name, lora_files):
"""Find the best matching LoRA file based on scoring."""
matches = []
print(f"\nFinding matches for '{name}':")
# Track filenames to detect duplicates
seen_filenames = {}
for lora_file in lora_files:
score, reason = self.score_filename_match(name, lora_file)
if score > 0:
base_name = Path(lora_file).name
if base_name in seen_filenames:
# Convert to showing full paths for this filename
seen_filenames[base_name] = True
else:
seen_filenames[base_name] = False
matches.append((score, lora_file, reason))
# Sort by score (highest first)
matches.sort(reverse=True)
# Print all matches with their scores
if matches:
print("\nCandidate files (sorted by relevance):")
for score, file, reason in matches:
base_name = Path(file).name
if seen_filenames[base_name]:
# Show full path for duplicates
display_name = file
else:
display_name = base_name
print(f" {display_name:<50} : {score:>5.1f} ({reason})")
print(f"\nSelected: {matches[0][1]}")
else:
print(" No matching files found")
return matches[0][1] if matches else None
def load_lora(self, MODEL, CLIP, STRING):
print(f"\nLoraTagLoader processing text: {STRING}")
@@ -163,7 +77,7 @@ class LoraTagLoader:
continue
# Use our new matching system
lora_name = self.find_best_lora_match(name, lora_files)
lora_name = find_best_match(name, lora_files, log=True)
if lora_name is None:
print(f"No matching LoRA found for tag: {(type, name, wModel, wClip)}")
+1
View File
@@ -5,6 +5,7 @@ class StringCleaning:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("cleaned_string",)
OUTPUT_IS_LIST = (False,)
OUTPUT_TOOLTIPS = ("The string after all the selected cleaning operations have been applied.",)
FUNCTION = "clean_string"
CATEGORY = "⚡ MNeMiC Nodes"
DESCRIPTION = "Cleans up the input text based on various stripping options."
+66
View File
@@ -0,0 +1,66 @@
import re
class StringTextExtractor:
"""
A node to extract the first occurrence of text between specified delimiters.
"""
OUTPUT_NODE = True
FUNCTION = "extract_text"
CATEGORY = "⚡ MNeMiC Nodes"
DESCRIPTION = "Extracts the first occurrence of text between a pair of characters (delimiters)."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_string": ("STRING", {
"multiline": True,
"tooltip": "The input text to search within.",
"placeholder": "Some text [with captured content] and more text."
}),
"delimiters": ("STRING", {
"multiline": False,
"tooltip": "The pair of characters to use as delimiters.\nExample: [], **, <>",
"placeholder": "e.g., []"
}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING",)
RETURN_NAMES = ("extracted_text", "remainder_text", "extracted_list",)
OUTPUT_TOOLTIPS = (
"The content found inside the first instance of the specified delimiters.",
"The rest of the text after the extracted content and its delimiters have been removed.",
"A list of all items found between the delimiters.",
)
def extract_text(self, input_string, delimiters):
if not delimiters or len(delimiters) < 2:
# If delimiters are invalid, return original text as remainder
return ("", input_string, [])
start_delim = re.escape(delimiters[0])
end_delim = re.escape(delimiters[-1])
# Find all non-overlapping matches for the list output
all_captures = re.findall(f"{start_delim}(.*?){end_delim}", input_string, re.DOTALL)
# Non-greedy search for the content between the first pair of delimiters
match = re.search(f"{start_delim}(.*?){end_delim}", input_string, re.DOTALL)
if match:
extracted_text = match.group(1)
# The remainder is the part before the match plus the part after the match
remainder_text = input_string[:match.start()] + input_string[match.end():]
return (extracted_text, remainder_text, all_captures)
else:
# No match found
return ("", input_string, [])
NODE_CLASS_MAPPINGS = {
"StringTextExtractor": StringTextExtractor,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"StringTextExtractor": "String Text Extractor",
}
+56
View File
@@ -0,0 +1,56 @@
import re
class StringTextSplitter:
"""
A node to split a string by the first occurrence of a delimiter.
"""
OUTPUT_NODE = True
FUNCTION = "split_text"
CATEGORY = "⚡ MNeMiC Nodes"
DESCRIPTION = "Splits a string by the first occurrence of a delimiter.\n\nExample:\n- input_string: part1|part2|part3\n- delimiter: |\n- first_chunk: part1\n- remainder: part2|part3"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_string": ("STRING", {
"multiline": True,
"tooltip": "The input text to split.",
"placeholder": "Text to be split..."
}),
"delimiter": ("STRING", {
"multiline": False,
"tooltip": "The character to split the text on. The node will split at the first time this character appears.",
"placeholder": "e.g., |"
}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING",)
RETURN_NAMES = ("first_chunk", "remainder", "chunk_list",)
OUTPUT_TOOLTIPS = (
"The part of the string before the first delimiter.",
"The remainder string after the first delimiter.",
"A list of all items split by the delimiter.",
)
def split_text(self, input_string, delimiter):
if not delimiter:
return (input_string, "", [input_string])
parts = input_string.split(delimiter, 1)
all_parts = input_string.split(delimiter)
if len(parts) == 1:
# Delimiter not found
return (parts[0], "", all_parts)
else:
return (parts[0], parts[1], all_parts)
NODE_CLASS_MAPPINGS = {
"StringTextSplitter": StringTextSplitter,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"StringTextSplitter": "String Text Splitter",
}
+554
View File
@@ -0,0 +1,554 @@
import re
import random
import os
from pathlib import Path
import folder_paths
import json
from ..utils.file_utils import find_best_match
from colorama import Fore, Style, init as colorama_init
class WildcardProcessor:
"""
A custom node for ComfyUI that processes text containing wildcards, with support for
file-based wildcards, inline choices, weights, multiple selections, variables, and nesting.
This is a complete rewrite of the original node to fix issues and add features.
"""
OUTPUT_NODE = True
FUNCTION = "process_wildcards"
CATEGORY = "⚡ MNeMiC Nodes"
DESCRIPTION = ("A versatile text processor that replaces wildcards with dynamic content from files or inline lists.\n\n"
"Features:\n"
"File Wildcards:\nUse __filename__ to insert a random line from filename.txt in one of the supported wildcard directories.\n\n"
"Inline Choices:\nUse {a|b|c} to randomly choose between a, b, or c.\nExample Input: A photo of a {red|green|blue} car.\nExample Output: A photo of a green car.\n\n"
"Weighted Choices:\nUse {5::black|green|red} to make black 5 times more likely to be chosen than green or red.\n\n"
"Select Multiple Wildcards:\nUse {2$$a|b|c|d} to output a specific number of items from the result.\nExample Input: My favorite colors are {3$$red|green|blue|yellow|purple}.\nExample Output: My favorite colors are blue, yellow, purple.\n\n"
"Ranged Select Multiple:\nUse {1-3$$red|green|blue|yellow|purple} to select a random number of 1-3 items within a range.\n\n"
"Variables:\nDefine a variable to reuse a value. Can be defined directly, or using a wildcard\nExample Input: ${animal=!__animals__} The ${animal} is friends with the other ${animal}.\nExample Output: The cat is friends with the other cat.\n\n"
"Smart wildcard matching:\nThe node will try to find the best match for a wildcard, even if the name is not an exact match. It will search for files in the wildcards directories and use the best match based on a scoring system. Exact matches have priority, and more root level files have priority after that.\n\n"
"Multiple Wildcard Paths:\nWildcards can be placed in different directories. It's recommended to only use one.\n"
"ComfyUI/wildcards\n"
"ComfyUI/custom_nodes/ComfyUI-mnemic-nodes/wildcards\n"
"or in a user-defined path in:\n"
"ComfyUI/custom_nodes/ComfyUI-mnemic-nodes/nodes/wildcards/wildcards_paths_user.json."
)
def __init__(self):
colorama_init()
# Caches to store wildcard file content and located file paths
self.wildcard_cache = {}
self.create_user_wildcard_paths_file() # Ensure the user paths file exists
self.wildcard_files = self._find_wildcard_files()
# Variables for the current processing run
self.variables = {}
# Console logging setting for the current run
self.consolewildcard_log = False
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"wildcard_string": ("STRING", {
"multiline": True,
"dynamicPrompts": False,
"tooltip": (
"The text prompt to process. Supports multiple features:\n\n"
"File Wildcards:\nUse __filename__ to insert a random line from filename.txt in one of the supported wildcard directories.\n\n"
"Inline Choices:\nUse {a|b|c} to randomly choose between a, b, or c.\nExample Input: A photo of a {red|green|blue} car.\nExample Output: A photo of a green car.\n\n"
"Weighted Choices:\nUse {5::black|green|red} to make black 5 times more likely to be chosen than green or red.\n\n"
"Select Multiple Wildcards:\nUse {2$$a|b|c|d} to output a specific number of items from the result.\nExample Input: My favorite colors are {3$$red|green|blue|yellow|purple}.\nExample Output: My favorite colors are blue, yellow, purple.\n\n"
"Ranged Select Multiple:\nUse {1-3$$red|green|blue|yellow|purple} to select a random number of 1-3 items within a range.\n\n"
"Variables:\nDefine a variable to reuse a value. Can be defined directly, or using a wildcard\nExample Input: ${animal=!__animals__} The ${animal} is friends with the other ${animal}.\nExample Output: The cat is friends with the other cat."
),
"placeholder": "A photo of a __sample_colors__ {dog|cat|monkey}."
}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "tooltip": "The seed for the random number generator. Using the same seed with the same prompt will produce the same output."}),
"multiple_separator": ("STRING", {"default": " ", "multiline": False, "tooltip": "The separator used when selecting multiple items from a single wildcard.\n\nExample:\n- Prompt: {2$$red|green|blue}\n- Separator: \", \"\n- Output example: \"red, green\""}),
"recache_wildcards": ("BOOLEAN", {"default": False, "tooltip": "Force a reload of all wildcard files from disk. Can be disabled again after you have ran it once."}),
"consolewildcard_log": ("BOOLEAN", {"default": False, "tooltip": "Enable or disable detailed logging of the wildcard processing steps in the console."}),
"tag_extraction_tags": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Define pairs of characters to extract tags from the prompt. Example: [],**,<<>>. The extracted content is processed for wildcards and removed from the main prompt. \n\nReserved characters: ( ) { } |.",
"placeholder": "Example: [],**,<>"
}),
}
}
RETURN_TYPES = ("STRING", "INT", "STRING", "STRING", "STRING", "STRING",)
RETURN_NAMES = ("processed_text", "seed", "extracted_tags_string", "extracted_tags_list", "raw_tags_string", "raw_tags_list",)
OUTPUT_TOOLTIPS = (
"The final text after all wildcards and tags have been processed.",
"The seed value used for this generation.",
"A single string containing all extracted and processed tag content, joined by '|'.",
"A list of strings, where each item is one piece of extracted and processed tag content.",
"A single string containing all raw, unprocessed tags, including their delimiters, concatenated together.",
"A list of strings, where each item is one raw, unprocessed tag, including its delimiters."
)
def wildcard_log(self, message, level=0):
"""Logs a message to the console if logging is enabled, with color and indentation."""
if self.consolewildcard_log:
indent = " " * level
print(f"{indent}{message}{Style.RESET_ALL}")
def create_user_wildcard_paths_file(self):
"""Creates an empty user wildcard paths file if it doesn't exist."""
user_settings_dir = Path(__file__).parent / "wildcards"
user_settings_dir.mkdir(parents=True, exist_ok=True)
user_paths_file = user_settings_dir / "wildcards_paths_user.json"
if not user_paths_file.exists():
with open(user_paths_file, 'w', encoding='utf-8') as f:
json.dump([], f, indent=4)
print(f"{Fore.CYAN}WildcardProcessor: Created user wildcard paths file: {user_paths_file}{Style.RESET_ALL}")
def get_user_wildcard_paths(self):
"""
Loads user-defined wildcard paths from wildcards_paths_user.json.
This parser is intentionally lenient to handle paths with single backslashes
that would otherwise be invalid in strict JSON.
"""
user_paths_file = Path(__file__).parent / "wildcards" / "wildcards_paths_user.json"
if not user_paths_file.exists():
return []
try:
with open(user_paths_file, 'r', encoding='utf-8') as f:
content = f.read()
if not content.strip():
return []
# Use regex to find all strings within quotes. This is robust against
# JSON errors caused by unescaped backslashes.
paths = re.findall(r'"([^"]*)"', content)
# The Path() object constructor will handle normalizing the slashes.
return [p for p in paths if p.strip()]
except Exception as e:
print(f"{Fore.YELLOW}Warning: Could not load user wildcard paths from {user_paths_file}. Error: {e}{Style.RESET_ALL}")
return []
def get_all_wildcard_paths(self):
"""
Gets all wildcard paths, including this node's, user-defined, and ComfyUI root paths.
"""
# Use a set to avoid duplicate paths.
wildcard_paths = set()
# 1. Add this node's own 'wildcards' directory.
node_wildcards_path = Path(__file__).parent.parent / "wildcards"
if node_wildcards_path.is_dir():
wildcard_paths.add(str(node_wildcards_path))
# 2. Add user-defined paths from the JSON file.
user_paths = self.get_user_wildcard_paths()
if user_paths:
wildcard_paths.update(user_paths)
# 3. Add ComfyUI's default wildcard paths and root wildcard folder.
try:
# Safely get ComfyUI's default wildcard paths, if they exist.
if "wildcards" in folder_paths.folder_names_and_paths:
default_paths = folder_paths.get_folder_paths("wildcards")
wildcard_paths.update(default_paths)
# Also add the main ComfyUI wildcards folder.
comfyui_root_path = Path(folder_paths.get_folder_paths("custom_nodes")[0]).parent
root_wildcards_path = comfyui_root_path / "wildcards"
if root_wildcards_path.is_dir():
wildcard_paths.add(str(root_wildcards_path))
except (ImportError, IndexError):
print("[WildcardProcessor] `folder_paths` not available. Relying on local and user paths.")
return list(wildcard_paths)
def _find_wildcard_files(self, log=False):
"""Scans for all .txt files in the wildcards directories, with optional logging."""
wildcard_paths = self.get_all_wildcard_paths()
all_files = []
if log:
print(f"{Fore.YELLOW}\nRe-caching wildcards: Reloading user paths and re-scanning all wildcard files...{Style.RESET_ALL}")
for p_str in wildcard_paths:
path = Path(p_str)
if path.is_dir():
found_files = list(path.rglob('*.txt'))
if log:
print(f" - {path} [{len(found_files)}]")
all_files.extend([str(f) for f in found_files])
if log:
print() # Add a newline for clear separation
return all_files
def get_wildcard_options(self, wildcard_name):
"""
Retrieves the content of a wildcard file using the prioritization schema.
Caches results for efficiency.
"""
# If logging is on, we always want to show the search process.
if self.consolewildcard_log:
if self.first_wildcard_processed:
# Add a separator between wildcard processing logs
print(f"\n{Fore.CYAN}{'-----' * 21}{Style.RESET_ALL}\n") # Match length of the start/end separator
# Pass wildcard_paths to find_best_match for accurate logging
find_best_match(wildcard_name, self.wildcard_files, log=True, wildcard_paths=self.get_all_wildcard_paths())
self.first_wildcard_processed = True
# Now, get the content, using cache if possible.
if wildcard_name in self.wildcard_cache:
return self.wildcard_cache[wildcard_name]
# Cache miss, so find the file (silently) and read it.
best_match = find_best_match(wildcard_name, self.wildcard_files, log=False, wildcard_paths=self.get_all_wildcard_paths())
if not best_match:
self.wildcard_cache[wildcard_name] = None # Cache the failure
return None
try:
with open(best_match, 'r', encoding='utf-8') as f:
lines = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except UnicodeDecodeError:
self.wildcard_log(f"{Fore.YELLOW}Warning: Could not decode {os.path.basename(best_match)} as UTF-8. Trying with 'latin-1' encoding.", level=1)
with open(best_match, 'r', encoding='latin-1') as f:
lines = [line.strip() for line in f if line.strip() and not line.startswith('#')]
self.wildcard_cache[wildcard_name] = lines
return lines
def _evaluate_file_wildcard(self, match):
"""
Replaces a __wildcard__ with a random line from corresponding file(s).
Supports glob patterns for filename matching.
"""
wildcard_name = match.group(1)
# A simple check to see if the wildcard name contains any glob characters.
is_glob = any(c in wildcard_name for c in '*?[]')
# If it's not a glob pattern, use the existing efficient method.
if not is_glob:
options = self.get_wildcard_options(wildcard_name)
if not options:
return match.group(0)
chosen_option = self._process_text(random.choice(options))
self.wildcard_log(f"{Style.DIM}Evaluated {match.group(0)} -> {Style.NORMAL}{Fore.MAGENTA}{chosen_option}", level=1)
return chosen_option
# If it is a glob pattern, perform a file search.
self.wildcard_log(f"Detected glob pattern: {wildcard_name}", level=1)
all_lines = []
wildcard_paths = self.get_all_wildcard_paths()
matching_files = set()
for p_str in wildcard_paths:
p = Path(p_str)
# The glob pattern is applied relative to each wildcard directory.
# `rglob` is used for recursive matching, which is what `**` does.
for found_path in p.rglob(wildcard_name):
if found_path.is_file():
matching_files.add(found_path)
if not matching_files:
self.wildcard_log(f"{Fore.YELLOW}Warning: Glob pattern '{wildcard_name}' did not match any files.", level=1)
return match.group(0)
# Sort files for deterministic behavior in tests.
sorted_files = sorted(list(matching_files))
self.wildcard_log(f"Glob pattern '{wildcard_name}' matched {len(sorted_files)} files: {[os.path.basename(str(f)) for f in sorted_files]}", level=1)
for file_path in sorted_files:
# Use the cached method to read files to avoid re-reading.
# We need to find the "wildcard name" that corresponds to the file path.
# This is a bit tricky, but we can derive it.
try:
# Find which base path this file belongs to
base_path = next(p for p in wildcard_paths if Path(p) in file_path.parents)
relative_path = file_path.relative_to(base_path)
file_wildcard_name = str(relative_path).replace('\\', '/').replace('.txt', '')
lines = self.get_wildcard_options(file_wildcard_name)
if lines:
all_lines.extend(lines)
except StopIteration:
self.wildcard_log(f"{Fore.YELLOW}Warning: Could not determine base path for {file_path}. Skipping.", level=1)
if not all_lines:
self.wildcard_log(f"{Fore.YELLOW}Warning: Glob pattern '{wildcard_name}' matched files, but they are empty or contain only comments.", level=1)
return match.group(0)
chosen_option = self._process_text(random.choice(all_lines))
self.wildcard_log(f"{Style.DIM}Evaluated glob {match.group(0)} -> {Style.NORMAL}{Fore.MAGENTA}{chosen_option}", level=1)
return chosen_option
def evaluate_curly_braces(self, match):
"""
Evaluates an inline wildcard expression, e.g., {a|b|c}.
Handles weights, multiple selections, and nesting.
"""
expression = match.group(1)
# 1. Clean comments and whitespace
expression = re.sub(r'#.*', '', expression)
expression = re.sub(r'\s*\n\s*', '', expression).strip()
# 2. Parse multi-selection syntax (e.g., {2$$...} or {1-3$$...})
count = 1
min_count, max_count = 1, 1
is_range = False
count_match = re.match(r'(\d+)-(\d+)\$\$(.*)', expression)
if count_match:
min_count, max_count, expression = int(count_match.group(1)), int(count_match.group(2)), count_match.group(3)
is_range = True
else:
count_match = re.match(r'(\d+)\$\$(.*)', expression)
if count_match:
count, expression = int(count_match.group(1)), count_match.group(2)
min_count = max_count = count
if is_range:
count = random.randint(min_count, max_count)
# 3. Split into options and parse weights (e.g., {2::a|b})
options_str = expression.split('|')
choices = []
weights = []
for option in options_str:
opt_strip = option.strip()
weight_match = re.match(r'(\d+(?:\.\d+)?)::(.*)', opt_strip)
if weight_match:
weight = float(weight_match.group(1))
opt = weight_match.group(2).strip()
weights.append(weight)
choices.append(opt)
else:
weights.append(1.0)
choices.append(opt_strip)
if not choices:
return ""
# 4. Select `count` options
selected_options = []
if count > 0:
# If the number of requested items is greater than the number of available options,
# cap the selection to the number of unique items to avoid duplicates.
if count > len(choices):
self.wildcard_log(f"Warning: Requested {count} items, but only {len(choices)} unique options available. Returning all unique options.")
count = len(choices)
# We need unique items.
# If weights are uniform, `random.sample` is efficient.
if all(w == 1.0 for w in weights):
selected_options = random.sample(choices, k=count)
else:
# For weighted unique sampling, we pick one by one.
temp_choices = list(choices)
temp_weights = list(weights)
for _ in range(count):
if not temp_choices: break
chosen = random.choices(temp_choices, weights=temp_weights, k=1)[0]
selected_options.append(chosen)
idx = temp_choices.index(chosen)
temp_choices.pop(idx)
temp_weights.pop(idx)
# 5. Join and return using the provided separator.
result = self.separator.join(selected_options)
self.wildcard_log(f"{Style.DIM}Evaluated {{{match.group(1)}}} -> {Style.NORMAL}{Fore.CYAN}{result}", level=1)
return result
def _process_text(self, text):
"""
Iteratively processes a string, resolving wildcards from the inside out.
This function handles both __file__ wildcards and {inline|wildcards}.
"""
# Loop until the string no longer changes, ensuring all nested wildcards are processed.
while True:
original_text = text
# First, substitute any defined variables.
for var_name, var_value in self.variables.items():
text = text.replace(f"${{{var_name}}}", var_value)
# Process innermost curly braces expressions
text = re.sub(r'{([^{}]*?)}', self.evaluate_curly_braces, text)
# Process file-based wildcards
# Process file-based wildcards (including glob patterns)
text = re.sub(r'__([a-zA-Z0-9_./\\*?\[\]-]+?)__', self._evaluate_file_wildcard, text)
if text == original_text:
break
return text
def extract_and_process_tags(self, text, tag_delimiters_str):
"""
Extracts content from specified tags, processes wildcards within them,
and removes them from the original text.
"""
if not tag_delimiters_str:
return text, [], []
raw_tags_found = []
# Parse the tag delimiters
# e.g., "[],**,<<>>" -> [('[', ']'), ('*', '*'), ('<', '>')]
delimiter_pairs = []
raw_pairs = tag_delimiters_str.split(',')
for pair in raw_pairs:
pair = pair.strip()
if len(pair) >= 2:
start_tag = pair[0]
end_tag = pair[-1]
# Validation
if any(char in '()}{)|' for char in (start_tag, end_tag)):
self.wildcard_log(f"{Fore.YELLOW}Warning: Invalid characters in tag pair '{pair}'. Skipping.", level=1)
continue
delimiter_pairs.append((start_tag, end_tag))
if not delimiter_pairs:
return text, [], []
# Build a regex to find all tags
# e.g., (\[.*?\])|(\*.*?\*)|(\<.*?\>)
# We now capture the full tag, including delimiters
regex_parts = []
for start, end in delimiter_pairs:
# Escape special regex characters
esc_start = re.escape(start)
esc_end = re.escape(end)
# Capture the full tag including delimiters
regex_parts.append(f"({esc_start}.*?{esc_end})")
full_regex = "|".join(regex_parts)
# We need to find all matches and then process them.
matches = list(re.finditer(full_regex, text, re.DOTALL))
# We iterate backwards to not mess up indices of unprocessed parts of the string
for match in reversed(matches):
# The first non-None group is our full matched tag.
full_tag = next((g for g in match.groups() if g is not None), None)
if full_tag is not None:
# Prepend to our list to maintain original order
# We store the original tag with its delimiters
raw_tags_found.insert(0, full_tag)
# Remove the matched tag from the text
text = text[:match.start()] + text[match.end():]
# Now, process the extracted contents for wildcards
processed_tags = []
processed_raw_tags = [] # This will hold the raw tags with resolved wildcards
if raw_tags_found:
self.wildcard_log(f"Extracted {len(raw_tags_found)} raw tags for processing: {raw_tags_found}")
for i, raw_tag in enumerate(raw_tags_found):
# To process, we need to strip the outer delimiters first
# This is a bit naive but works for single-character delimiters
start_delim = raw_tag[0]
end_delim = raw_tag[-1]
content_to_process = raw_tag[1:-1]
# Process the content inside the tag
processed_content = self._process_text(content_to_process)
processed_tags.append(processed_content)
# Re-assemble the "raw" tag with the processed content
processed_raw_tag = f"{start_delim}{processed_content}{end_delim}"
processed_raw_tags.append(processed_raw_tag)
self.wildcard_log(f"Processed tag #{i+1}: '{raw_tag}' -> '{processed_content}'", level=1)
return text, processed_tags, processed_raw_tags
def process_wildcards(self, **kwargs):
"""
Main function to process an input string with wildcards.
All inputs are received via kwargs to handle spaces in names.
"""
# Extract parameters from kwargs
wildcard_string = kwargs.get("wildcard_string", "")
seed = kwargs.get("seed", 0)
self.separator = kwargs.get("multiple_separator", " ")
self.consolewildcard_log = kwargs.get("consolewildcard_log", False)
recache = kwargs.get("recache_wildcards", False)
tag_extraction_tags = kwargs.get("tag_extraction_tags", "")
random.seed(seed)
self.variables = {} # Reset variables for each run
self.first_wildcard_processed = False # Reset for each run
if recache:
# Re-scan all wildcard directories and clear the cache.
# The logging is now handled inside _find_wildcard_files.
self.wildcard_files = self._find_wildcard_files(log=self.consolewildcard_log)
self.wildcard_cache.clear()
if self.consolewildcard_log:
print(f"{Fore.GREEN}{'-----' * 8}📝 Wildcard Processor Start{'-----' * 8}{Style.RESET_ALL}")
# Use repr() to make newlines and other special characters visible
print(f"{Fore.YELLOW}Input:{Style.RESET_ALL} {repr(wildcard_string)}")
print(f"{Fore.YELLOW}Seed:{Style.RESET_ALL} {seed}")
if tag_extraction_tags:
print(f"{Fore.YELLOW}Tag Delimiters:{Style.RESET_ALL} {tag_extraction_tags}")
text = wildcard_string
# 1. Find and evaluate variable definitions: ${var=!{...}}
variable_pattern = r"\${(.*?)=!(.*?)}"
definitions = re.findall(variable_pattern, text)
# Create a temporary, clean version of the text with definitions removed
text_no_defs = re.sub(variable_pattern, "", text)
for var_name, var_value_expr in definitions:
var_name = var_name.strip()
# The value expression itself can contain wildcards. To evaluate it in isolation,
# we instantiate a temporary processor.
temp_processor = WildcardProcessor()
# Use a new random seed for variable evaluation to not interfere with main seed
var_seed = random.randint(0, 0xffffffffffffffff)
# We pass console log as false to prevent recursive logging clutter.
evaluated_value = temp_processor.process_wildcards(**{"wildcard_string": var_value_expr, "seed": var_seed, "consolewildcard_log": False})[0]
self.variables[var_name] = evaluated_value
self.wildcard_log(f"Defined variable ${{{var_name}}} = {evaluated_value}")
# 2. Extract and process tags
text_after_extraction, processed_tags, raw_tags = self.extract_and_process_tags(text_no_defs, tag_extraction_tags)
# 3. Process the main text (which has definitions and tags removed)
processed_text = self._process_text(text_after_extraction)
# 4. Prepare outputs
extracted_tags_string = "|".join(processed_tags)
extracted_tags_list = processed_tags
# New raw outputs
raw_tags_string = "".join(raw_tags) # Concatenated without any separator
raw_tags_list = raw_tags
if self.consolewildcard_log:
if raw_tags:
print(f"{Fore.YELLOW}Extracted Tags (Raw):{Style.RESET_ALL} {raw_tags}")
print(f"{Fore.YELLOW}Extracted Tags (Processed):{Style.RESET_ALL} {processed_tags}")
print(f"{Fore.YELLOW}Processed Text:{Style.RESET_ALL} {repr(processed_text)}")
print(f"{Fore.GREEN}{'-----' * 8}📝 Wildcard Processor End{'-----' * 8}{Style.RESET_ALL}")
return (processed_text, seed, extracted_tags_string, extracted_tags_list, raw_tags_string, raw_tags_list)
NODE_CLASS_MAPPINGS = {
"WildcardProcessor": WildcardProcessor,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WildcardProcessor": "Wildcard Processor",
}
@@ -0,0 +1,6 @@
[
"C:\\AI\\SharedFiles\\Wildcards",
"C:/AI/SharedFiles/Wildcards2",
"C:\AI\SharedFiles\Wildcards3",
"relative/path/to/wildcards"
]
+3 -3
View File
@@ -1,9 +1,9 @@
[project]
name = "comfyui-mnemic-nodes"
description = "Added the Resolution Image Size Selector node"
version = "1.3.0"
description = "Added the Wildcard Processor, String Text Extractor and String Text Splitter nodes"
version = "1.3.3"
license = { file = "LICENSE" }
dependencies = ["configparser", "groq", "transformers", "torch", "tiktoken"]
dependencies = ["configparser", "groq", "transformers", "torch", "tiktoken", "requests", "colorama"]
[project.urls]
Repository = "https://github.com/MNeMoNiCuZ/ComfyUI-mnemic-nodes"
+2 -1
View File
@@ -4,4 +4,5 @@ transformers
torch
tiktoken
colorama
python-dotenv>=1.0.0
python-dotenv>=1.0.0
requests
+128
View File
@@ -0,0 +1,128 @@
from pathlib import Path
import os
import re
def score_filename_match(name, filename, base_path=None):
"""Score how well a filename matches the requested name."""
p_filename = Path(filename)
base_name = p_filename.name
base_name_no_ext = p_filename.stem
name_no_ext = Path(name).stem
# Calculate path depth score relative to the base_path if provided
path_depth = 0
if base_path:
try:
# Calculate depth relative to the wildcard folder
path_depth = len(p_filename.relative_to(base_path).parts)
except ValueError:
# This can happen if the file is not in the base_path, fallback to absolute depth
path_depth = len(p_filename.parts) - 1
else:
path_depth = len(p_filename.parts) - 1
path_penalty = path_depth * 0.0001
# If searching for a numbered variant, also match the base name
base_search_name = re.sub(r'\d+$', '', name_no_ext).rstrip('-')
# Perfect path match (highest priority)
if filename == name:
return (200, "perfect path match")
# Exact match gets high priority
if base_name_no_ext == name_no_ext:
return (100 - path_penalty, f"exact match (depth: {path_depth})")
# Base version match when searching for numbered variant
if base_name_no_ext == base_search_name:
return (90 - path_penalty, f"base version match (depth: {path_depth})")
# Check if it's a numbered variant of the exact name
if base_name.startswith(name + "-"):
try:
num = int(re.findall(r'-(\d+)', base_name)[0])
return (80 + (num * 0.001) - path_penalty, f"numbered variant ({num}, depth: {path_depth})")
except (IndexError, ValueError):
pass
# Check if we're looking for a specific number
number_search = re.search(r'(\d+)$', name)
if number_search:
base_without_number = name[:-len(number_search.group(1))]
target_number = int(number_search.group(1))
if base_name.startswith(base_without_number):
try:
file_number = int(re.findall(r'-(\d+)', base_name)[0])
number_diff = abs(target_number - file_number)
if number_diff == 0:
return (95 - path_penalty, f"exact number match ({target_number}, depth: {path_depth})")
return (85 - (number_diff * 0.1) - path_penalty, f"number near match ({file_number}, depth: {path_depth})")
except (IndexError, ValueError):
pass
# Simple startswith match
if base_name_no_ext.startswith(name):
return (50 - path_penalty, f"prefix match (depth: {path_depth})")
# Contains match (lower priority)
if name in base_name_no_ext:
return (40 - path_penalty, f"contains match (depth: {path_depth})")
return (0, "no match")
def find_best_match(search_term, file_list, log=False, wildcard_paths=None):
"""Find the best matching file from a list based on scoring."""
matches = []
if log:
print(f"\nFinding matches for '{search_term}':")
seen_filenames = {}
# Convert wildcard_paths to Path objects for comparison
path_wildcard_paths = [Path(p) for p in wildcard_paths] if wildcard_paths else []
for file_path in file_list:
p_file_path = Path(file_path)
base_path = None
if path_wildcard_paths:
# Find which wildcard_path this file belongs to.
possible_bases = [p for p in path_wildcard_paths if p in p_file_path.parents]
if possible_bases:
# Get the longest path, which is the most specific base path.
base_path = max(possible_bases, key=lambda p: len(p.as_posix()))
score, reason = score_filename_match(search_term, file_path, base_path=base_path)
if score > 0:
base_name = Path(file_path).name
if base_name in seen_filenames:
seen_filenames[base_name] = True
else:
seen_filenames[base_name] = False
matches.append((score, file_path, reason, base_path))
matches.sort(key=lambda x: x[0], reverse=True)
if log and matches:
print("\nCandidate files (sorted by relevance):")
for score, file, reason, base_path in matches:
base_name = Path(file).name
try:
display_name = str(Path(file).relative_to(base_path)) if base_path else base_name
except (ValueError, TypeError):
display_name = base_name
print(f" {display_name:<60} : {score:>5.1f} ({reason})")
_score, selected_path_str, _reason, selected_base_path = matches[0]
selected_path = Path(selected_path_str)
try:
selected_display = str(selected_path.relative_to(selected_base_path)) if selected_base_path else selected_path.name
except (ValueError, TypeError):
selected_display = selected_path.name
print(f"\nSelected: {selected_display}")
elif log:
print(" No matching files found")
return matches[0][1] if matches else None
+28
View File
@@ -0,0 +1,28 @@
red
blue
yellow
green
orange
purple
black
white
gray
brown
pink
beige
turquoise
navy blue
teal
lavender
maroon
olive
gold
silver
bronze
copper
magenta
indigo
cyan
auburn
burgundy
azure