Files
xLegende-ComfyUI-Prompt-For…/categorized_prompt_formatter.py
T
2025-04-12 20:19:15 +02:00

328 lines
14 KiB
Python

# /ComfyUI-Prompt-Formatter/categorized_prompt_formatter.py
import yaml
import re
import os
import sys
from collections import defaultdict
from pathlib import Path
# --- Dependency Check ---
try:
import yaml
except ImportError:
print("PyYAML is not installed. Please run install.py or install it manually.", file=sys.stderr)
# --- Constants ---
NODE_NAME = "Categorized Prompt Formatter"
NODE_REPO = "ComfyUI-Prompt-Formatter"
# --- Helper Functions ---
def get_node_directory():
"""Gets the directory path of the current node."""
# This assumes the script is directly inside the node's folder in custom_nodes
return Path(__file__).parent
def find_yaml_file(filename):
"""Finds the YAML file, checking relative to node, inputs, and absolute."""
if not filename:
return None
# 1. Check if it's an absolute path
if Path(filename).is_absolute():
if Path(filename).is_file():
return Path(filename)
else:
print(f"Warning: Absolute path specified but not found: {filename}")
return None
# 2. Check relative to the custom node's directory
node_dir = get_node_directory()
relative_to_node = node_dir / filename
if relative_to_node.is_file():
return relative_to_node
# 3. Check relative to ComfyUI's input directory (common practice)
# Need to find the base ComfyUI directory first.
# This is a bit heuristic, go up until we find 'ComfyUI'.
# This might fail in unusual setups.
try:
current_dir = Path.cwd() # Usually ComfyUI base dir when run normally
if (current_dir / 'input').exists():
input_dir_path = current_dir / 'input' / filename
if input_dir_path.is_file():
return input_dir_path
# Fallback search upwards from node dir if cwd isn't reliable
search_dir = node_dir
for _ in range(5):
if (search_dir / 'input').exists():
input_dir_path = search_dir / 'input' / filename
if input_dir_path.is_file():
return input_dir_path
if (search_dir / 'ComfyUI').exists() or (search_dir / 'main.py').exists(): # Found potential base
if (search_dir / 'input').exists():
input_dir_path = search_dir / 'input' / filename
if input_dir_path.is_file():
return input_dir_path
break
parent = search_dir.parent
if parent == search_dir: break
search_dir = parent
except Exception as e:
print(f"Warning: Error searching for ComfyUI input directory: {e}")
print(f"Warning: YAML file '{filename}' not found as absolute, relative to node, or in input dir.")
return None
def parse_tag(tag, handle_weights=True):
"""
Parses a tag to extract the base tag and preserve the original form.
Handles common weighting and emphasis syntax like (tag:1.2), [tag], {tag}.
Returns (original_tag, base_tag).
"""
original_tag = tag.strip()
base_tag = original_tag
if handle_weights:
# More robust regex to handle variations and potential nesting (simple cases)
# Pattern for (tag:weight) or (tag)
match = re.match(r"^\s*\(\s*(.*?)\s*(?::\s*[\d.]+\s*)?\)\s*$", original_tag)
if match:
base_tag = match.group(1).strip()
# Handle nested cases like ((tag)) - recursively call? Or just strip outer? Let's strip outer.
nested_match = re.match(r"^\s*\(\s*(.*?)\s*\)\s*$", base_tag)
if nested_match:
base_tag = nested_match.group(1).strip()
return original_tag, base_tag
# Pattern for [tag]
match = re.match(r"^\s*\[\s*(.*?)\s*\]\s*$", original_tag)
if match:
base_tag = match.group(1).strip()
# Handle nested [[tag]]
nested_match = re.match(r"^\s*\[\s*(.*?)\s*\]\s*$", base_tag)
if nested_match:
base_tag = nested_match.group(1).strip()
return original_tag, base_tag
# Pattern for {tag}
match = re.match(r"^\s*\{\s*(.*?)\s*\}\s*$", original_tag)
if match:
base_tag = match.group(1).strip()
# Handle nested {{tag}}
nested_match = re.match(r"^\s*\{\s*(.*?)\s*\}\s*$", base_tag)
if nested_match:
base_tag = nested_match.group(1).strip()
return original_tag, base_tag
# If no specific weight/emphasis pattern matched, return the stripped original
return original_tag, base_tag.strip()
def clean_output_string(text, delimiter=", "):
"""Cleans up the final output string."""
if not text:
return ""
# Remove leading/trailing whitespace and delimiters
text = text.strip().strip(delimiter.strip()).strip()
# Replace multiple delimiters (and surrounding whitespace) with a single one
delimiter_pattern = r'\s*' + re.escape(delimiter.strip()) + r'\s*'
text = re.sub(f'({delimiter_pattern})+', delimiter, text)
return text
# --- The Node Class ---
class CategorizedPromptFormatter:
"""
A ComfyUI node to categorize tags from an input prompt based on a YAML file
and format them into a new prompt string using a template.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"category_definition_file": ("STRING", {"default": "prompt_categories.yaml"}),
"output_template": ("STRING", {"multiline": True, "default": "<|quality|>, <|character_num|>, <|person_details|>, <|clothing|>, <|setting|>"}),
},
"optional": {
"input_delimiter": ("STRING", {"default": ","}),
"output_delimiter": ("STRING", {"default": ", "}),
"strip_whitespace": ("BOOLEAN", {"default": True}),
"case_sensitive_matching": ("BOOLEAN", {"default": False}),
"handle_weights": ("BOOLEAN", {"default": True}),
"match_underscores_spaces": ("BOOLEAN", {"default": True}),
"disable_duplicates": ("BOOLEAN", {"default": False}),
"unmatched_tag_handling": (["discard", "append_end", "output_separately"], {"default": "discard"}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("formatted_prompt", "rejected_prompt")
FUNCTION = "format_prompt"
CATEGORY = "text/filtering"
def format_prompt(self, prompt, category_definition_file, output_template,
input_delimiter=",", output_delimiter=", ", strip_whitespace=True,
case_sensitive_matching=False, match_underscores_spaces=True, handle_weights=True,
disable_duplicates=False, unmatched_tag_handling="discard"):
# --- 1. Load Category Definitions ---
tag_to_categories_map = defaultdict(list)
yaml_path = find_yaml_file(category_definition_file)
if yaml_path and yaml_path.exists():
try:
with open(yaml_path, 'r', encoding='utf-8') as f:
yaml_data = yaml.safe_load(f)
if yaml_data:
for category, tags in yaml_data.items():
if isinstance(tags, list):
for tag in tags:
processed_tag = str(tag).strip()
if not case_sensitive_matching:
processed_tag = processed_tag.lower()
if processed_tag:
tag_to_categories_map[processed_tag].append(str(category).strip())
except FileNotFoundError:
print(f"Error: YAML file not found at resolved path: {yaml_path}")
return ("", prompt)
except yaml.YAMLError as e:
print(f"Error parsing YAML file {yaml_path}: {e}")
return ("", prompt)
except Exception as e:
print(f"Error loading YAML file {yaml_path}: {e}")
return ("", prompt)
else:
print(f"Warning: Category definition file '{category_definition_file}' not found. Proceeding without categorization.")
# --- 2. Parse Input Prompt & Categorize ---
categorized_tags = defaultdict(list)
all_input_tags_original = set()
processed_input_tags = []
if prompt:
raw_tags = prompt.split(input_delimiter)
for raw_tag in raw_tags:
tag_original = raw_tag.strip() if strip_whitespace else raw_tag
if not tag_original: continue
all_input_tags_original.add(tag_original)
original_parsed, base_parsed = parse_tag(tag_original, handle_weights)
# Prepare the tag used for dictionary lookups (case normalization)
lookup_key_base = base_parsed if case_sensitive_matching else base_parsed.lower()
processed_input_tags.append((original_parsed, lookup_key_base))
matched_original_tags = set()
for original_tag, lookup_key_base in processed_input_tags:
found_categories = None
if match_underscores_spaces:
# Generate variants for lookup
variants_to_check = {
lookup_key_base,
lookup_key_base.replace('_', ' '),
lookup_key_base.replace(' ', '_')
}
# Check each variant against the map
for variant in variants_to_check:
categories = tag_to_categories_map.get(variant)
if categories:
found_categories = categories
break #
else:
# Direct lookup if matching is disabled
found_categories = tag_to_categories_map.get(lookup_key_base)
# If categories were found
if found_categories:
for category in found_categories:
categorized_tags[category].append(original_tag)
matched_original_tags.add(original_tag)
# --- 3. Process Template ---
formatted_output_string = "" # Will be built piece by piece
placeholders = re.findall(r"<\|(.*?)\|>|", output_template)
placeholders = [p for p in placeholders if p]
# Track tags added to the output string to prevent duplicates if requested
already_added_tags = set()
# Track tags considered "used" by the template for rejection logic
used_in_template_tags = set()
result_parts = []
last_end = 0
for match in re.finditer(r"<\|(.*?)\|>|", output_template):
placeholder_content = match.group(1)
start, end = match.span()
# Append the literal text segment before the placeholder
result_parts.append(output_template[last_end:start])
if placeholder_content:
category_name = placeholder_content.strip()
tags_for_category = categorized_tags.get(category_name, [])
if tags_for_category:
# Mark all tags associated with this placeholder as 'used' by the template
# This happens *before* deduplication, as the template *did* reference them.
used_in_template_tags.update(tags_for_category)
tags_to_join = []
if disable_duplicates:
# Filter out tags already added
for tag in tags_for_category:
if tag not in already_added_tags:
tags_to_join.append(tag)
already_added_tags.add(tag) # Mark as added
else:
# Include all tags if duplicates are allowed
tags_to_join = tags_for_category
if tags_to_join:
joined_tags = output_delimiter.join(tags_to_join)
result_parts.append(joined_tags)
last_end = end
# Append any remaining literal text after the last placeholder
result_parts.append(output_template[last_end:])
formatted_output_string = "".join(result_parts)
# --- 4. Handle Unmatched Tags ---
rejected_tags_list = []
if unmatched_tag_handling != "discard":
# Find tags that were in the input but NOT marked as 'used' by the template placeholders
rejected_tags_list = list(all_input_tags_original - used_in_template_tags)
rejected_output_string = ""
if rejected_tags_list:
if unmatched_tag_handling == "append_end":
# Ensure there's a delimiter if the main string isn't empty and doesn't end with one
if formatted_output_string and not formatted_output_string.endswith(output_delimiter):
formatted_output_string += output_delimiter
elif not formatted_output_string: # If main string is empty, don't start with delimiter
pass
else: # Main string ends with delimiter, add space if needed
if not formatted_output_string.endswith(" "):
formatted_output_string += " "
formatted_output_string += output_delimiter.join(rejected_tags_list)
elif unmatched_tag_handling == "output_separately":
rejected_output_string = output_delimiter.join(rejected_tags_list)
# --- 5. Cleanup and Return ---
final_formatted = clean_output_string(formatted_output_string, output_delimiter)
final_rejected = clean_output_string(rejected_output_string, output_delimiter)
return (final_formatted, final_rejected)