Added wildcards, text splitter, text extractor nodes
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -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
@@ -4,4 +4,5 @@ transformers
|
||||
torch
|
||||
tiktoken
|
||||
colorama
|
||||
python-dotenv>=1.0.0
|
||||
python-dotenv>=1.0.0
|
||||
requests
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user