Files
mudknight-comfyui-mudknight…/presets.py
T

676 lines
21 KiB
Python

#!/usr/bin/env python3
import json
import os
import re
import random
# Default configuration templates
DEFAULT_CHARACTERS = {
"example_character": {
"character": "1girl, blonde hair, blue eyes",
"top": "white shirt, red tie",
"bottom": "black skirt, white socks",
"neg": ""
}
}
DEFAULT_MODELS = {
"Pony": {
"quality": {
"positive": "score_9, score_8_up, score_7_up",
"negative": "score_6, score_5, score_4"
},
"embeddings": {
"positive": "",
"negative": "negativeXL_D"
}
},
"Illustrious": {
"quality": {
"positive": "masterpiece, best quality, very aesthetic",
"negative": "worst quality, low quality, displeasing"
},
"embeddings": {
"positive": "",
"negative": ""
}
},
"waiIllustriousSDXL_v160.safetensors": {
"quality": {
"positive": "",
"negative": "",
},
"embeddings": {
"positive": "",
"negative": "",
}
}
}
DEFAULT_STYLES = {
"anime": {
"positive": "anime style, cel shaded, vibrant colors",
"negative": "realistic, photorealistic"
},
"realistic": {
"positive": "photorealistic, highly detailed, 8k uhd",
"negative": "anime, cartoon, illustration"
}
}
DEFAULT_WILDCARDS = {
"example": "option1 | option2 | option3"
}
# Shared utility functions
def strip_jsonc_comments(text):
"""
Remove single-line and multi-line comments from JSONC text.
Preserves comment-like content within strings.
"""
# Remove multi-line comments /* ... */
text = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL)
# Remove single-line comments // ...
text = re.sub(r'//.*?$', '', text, flags=re.MULTILINE)
return text
def ensure_config_exists(file_path, default_data):
"""
Ensure a config file exists, creating it with default data if needed.
Args:
file_path: Path to the config file
default_data: Default data to write if file doesn't exist
Returns:
True if file was created, False if it already existed
"""
# Create config directory if it doesn't exist
config_dir = os.path.dirname(file_path)
if not os.path.exists(config_dir):
os.makedirs(config_dir)
print(f"Created config directory: {config_dir}")
# Create config file if it doesn't exist
if not os.path.exists(file_path):
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(default_data, f, indent=2, ensure_ascii=False)
print(f"Created default config file: {file_path}")
return True
except Exception as e:
print(f"Error creating config file {file_path}: {e}")
return False
return False
def load_jsonc_file(file_path, default_data=None):
"""
Load and parse a JSONC file, creating it with defaults if needed.
Args:
file_path: Path to the JSONC file
default_data: Default data to use if file doesn't exist
Returns:
Parsed JSON data or default data on error
"""
# Ensure file exists with defaults
if default_data is not None:
ensure_config_exists(file_path, default_data)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
jsonc_content = strip_jsonc_comments(content)
return json.loads(jsonc_content)
except FileNotFoundError:
print(f"File not found at {file_path}")
return default_data if default_data is not None else {}
except json.JSONDecodeError as e:
print(f"Error parsing JSONC: {e}")
return default_data if default_data is not None else {}
except Exception as e:
print(f"Error loading file: {e}")
return default_data if default_data is not None else {}
def load_cached_data(
file_path, cache_dict, last_modified_key, default_data=None):
"""
Load data from a JSONC file with caching based on modification time.
Args:
file_path: Path to the JSONC file
cache_dict: Dictionary to store cached data
(should have 'data' and 'mtime' keys)
last_modified_key: Key to track last modification time in cache_dict
default_data: Default data to use if file doesn't exist
Returns:
Cached or newly loaded data
"""
try:
# Ensure file exists before checking mtime
if default_data is not None:
ensure_config_exists(file_path, default_data)
# Check if file has been modified
current_mtime = os.path.getmtime(file_path)
if current_mtime != cache_dict.get(last_modified_key, 0):
cache_dict[last_modified_key] = current_mtime
cache_dict['data'] = load_jsonc_file(file_path, default_data)
return cache_dict.get(
'data', default_data if default_data is not None else {})
except Exception as e:
print(f"Error checking file modification time: {e}")
return cache_dict.get(
'data', default_data if default_data is not None else {})
class CharacterPresetNode:
"""
A ComfyUI node for selecting pre-defined characters with optional default
outfits. Character data is loaded from an external JSONC file.
"""
# Class variable to cache loaded characters
_cache = {}
# Path to the JSONC file (relative to this script)
JSON_PATH = os.path.join(
os.path.dirname(__file__), "config", "characters.jsonc")
@classmethod
def INPUT_TYPES(cls):
# Load characters to get latest data
characters = load_cached_data(
cls.JSON_PATH, cls._cache, 'mtime', DEFAULT_CHARACTERS)
character_list = ["none"] + sorted(list(characters.keys()))
return {
"required": {
"character": (character_list, {"default": "none"}),
"use_default_outfit": ("BOOLEAN", {"default": True}),
"use_bottom": ("BOOLEAN", {"default": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("positive", "negative")
FUNCTION = "select_character"
CATEGORY = "conditioning"
@classmethod
def IS_CHANGED(
cls, character, use_default_outfit, use_bottom, unique_id=None):
"""
Return the file modification time to invalidate cache
when file changes.
"""
try:
mtime = os.path.getmtime(cls.JSON_PATH)
return mtime
except:
return float("nan")
def select_character(
self, character, use_default_outfit, use_bottom, unique_id=None):
"""
Combines character tags with outfit tags based on selection.
Args:
character: Selected character name or "none"
use_default_outfit: Whether to include the outfit top tags
use_bottom: Whether to include the outfit bottom tags (only
applies if use_default_outfit is True)
unique_id: Hidden parameter for cache busting
Returns:
A tuple containing the positive tags and negative tags
"""
if character == "none":
return ("", "")
# Reload character data to ensure we have the latest values
characters = load_cached_data(
self.JSON_PATH, self.__class__._cache,
'mtime', DEFAULT_CHARACTERS)
if character not in characters:
return ("", "")
char_data = characters[character]
# Get character tags (required field)
character_tags = char_data.get("character", "")
# Get outfit parts (optional fields)
top = char_data.get("top", "") if use_default_outfit else ""
bottom = char_data.get("bottom", "") if (
use_default_outfit and use_bottom) else ""
# Get negative tags (optional field)
negative_tags = char_data.get("neg", "")
# Build positive tags
positive_tags = ", ".join(filter(None, [character_tags, top, bottom]))
return (positive_tags, negative_tags)
class TagReplacementNode:
"""
A ComfyUI node that replaces tags from a multiline string input based on
a JSONC mapping file. Matched tags are output to character outputs, while
unmatched tags go to the prompt output.
"""
# Class variable to cache loaded mappings
_cache = {}
# Path to the JSONC file (relative to this script)
JSON_PATH = os.path.join(
os.path.dirname(__file__), "config", "characters.jsonc")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_tags": ("STRING", {
"multiline": True,
"default": ""
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("prompt", "character_pos", "character_neg", "match")
FUNCTION = "process_tags"
CATEGORY = "conditioning"
@classmethod
def IS_CHANGED(cls, input_tags, unique_id=None):
"""
Return the file modification time to invalidate cache
when file changes.
"""
try:
mtime = os.path.getmtime(cls.JSON_PATH)
return mtime
except:
return float("nan")
def process_tags(self, input_tags, unique_id=None):
"""
Processes input tags by splitting at commas and replacing any that
match keys in the JSONC mapping file.
Args:
input_tags: Multiline string containing comma-separated tags
unique_id: Hidden parameter for cache busting
Returns:
A tuple containing (prompt, character_pos, character_neg)
"""
if not input_tags.strip():
return ("", "", "", "")
# Reload mapping data to ensure we have the latest values
mappings = load_cached_data(
self.JSON_PATH, self.__class__._cache, 'mtime', {})
# Split tags at commas and strip whitespace
tags = [tag.strip() for tag in input_tags.split(',') if tag.strip()]
# Lists to hold categorized tags
prompt_tags = []
character_pos_parts = []
character_neg_parts = []
# Store matching character tag
match = None
# Track which outfit parts to include
include_top = False
include_bottom = False
# First pass: identify outfit flags
for tag in tags:
if tag == "top":
include_top = True
elif tag == "bottom":
include_bottom = True
# Second pass: process all tags
for tag in tags:
if tag in mappings:
# Tag matched a key in the JSONC
char_data = mappings[tag]
match = tag
# Extract character data (matching CharacterPresetNode format)
if isinstance(char_data, dict):
# Get character tags (positive)
character_tags = char_data.get("character", "")
if character_tags:
character_pos_parts.append(character_tags)
# Get negative tags
neg_tags = char_data.get("neg", "")
if neg_tags:
character_neg_parts.append(neg_tags)
# Include outfit tags if requested
if include_top:
top = char_data.get("top", "")
if top:
character_pos_parts.append(top)
if include_bottom:
bottom = char_data.get("bottom", "")
if bottom:
character_pos_parts.append(bottom)
elif isinstance(char_data, str):
# Simple string mapping goes to positive
character_pos_parts.append(char_data)
elif tag == "top" or tag == "bottom":
# These are outfit flags, don't add to prompt
continue
else:
# Tag didn't match, goes to prompt
prompt_tags.append(tag)
# Join tags with commas
prompt_output = ", ".join(prompt_tags)
character_pos_output = ", ".join(character_pos_parts)
character_neg_output = ", ".join(character_neg_parts)
return (
prompt_output, character_pos_output,
character_neg_output, match)
class ModelPresetNode:
"""
A ComfyUI node for selecting model-specific quality tags and embeddings.
Configuration is loaded from an external JSONC file.
"""
# Class variable to cache loaded config
_cache = {}
# Path to the JSONC file (relative to this script)
JSON_PATH = os.path.join(
os.path.dirname(__file__), "config", "models.jsonc")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ckpt_name": ("STRING", {"default": ""}),
"quality_tags": ("BOOLEAN", {"default": True}),
"embeddings": ("BOOLEAN", {"default": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("positive", "negative")
FUNCTION = "generate_prompts"
CATEGORY = "conditioning"
@classmethod
def IS_CHANGED(cls, ckpt_name, quality_tags, embeddings, unique_id=None):
"""
Return the file modification time to invalidate cache
when file changes.
"""
try:
mtime = os.path.getmtime(cls.JSON_PATH)
return mtime
except:
return float("nan")
def generate_prompts(
self, ckpt_name, quality_tags, embeddings, unique_id=None):
"""
Generate prompt tags based on model configuration.
Args:
ckpt_name: Checkpoint path (format: "model/checkpoint.safetensors")
quality_tags: Whether to include quality tags
embeddings: Whether to include embedding tags
unique_id: Hidden parameter for cache busting
Returns:
A tuple containing the positive tags and negative tags
"""
if not ckpt_name:
return ("", "")
# Parse model type from checkpoint path
model = ckpt_name.split('/')[-1]
family = ckpt_name.split('/')[0]
# Load config
config = load_cached_data(
self.JSON_PATH, self.__class__._cache, 'mtime', DEFAULT_MODELS)
# Use family if the full model name isn't used.
if model in config:
model_config = config[model]
elif family in config:
model_config = config[family]
else:
return ("", "")
# Build positive prompt
positive_parts = []
if quality_tags and model_config.get("quality", {}).get("positive"):
positive_parts.append(model_config["quality"]["positive"])
if embeddings and model_config.get("embeddings", {}).get("positive"):
positive_parts.append(model_config["embeddings"]["positive"])
# Build negative prompt
negative_parts = []
if quality_tags and model_config.get("quality", {}).get("negative"):
negative_parts.append(model_config["quality"]["negative"])
if embeddings and model_config.get("embeddings", {}).get("negative"):
negative_parts.append(model_config["embeddings"]["negative"])
positive_output = ", ".join(positive_parts)
negative_output = ", ".join(negative_parts)
return (positive_output, negative_output)
class StylePresetNode:
"""
A ComfyUI node for selecting style presets.
Style definitions are loaded from an external JSONC file.
"""
# Class variable to cache loaded styles
_cache = {}
# Path to the JSONC file (relative to this script)
JSON_PATH = os.path.join(
os.path.dirname(__file__), "config", "styles.jsonc")
@classmethod
def INPUT_TYPES(cls):
# Load styles to get latest data
styles = load_cached_data(
cls.JSON_PATH, cls._cache, 'mtime', DEFAULT_STYLES)
style_list = ["none"] + sorted(list(styles.keys()))
return {
"required": {
"style": (style_list, {"default": "none"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("positive", "negative")
FUNCTION = "generate_style"
CATEGORY = "conditioning"
@classmethod
def IS_CHANGED(cls, style, unique_id=None):
"""
Return the file modification time to invalidate cache
when file changes.
"""
try:
mtime = os.path.getmtime(cls.JSON_PATH)
return mtime
except:
return float("nan")
def generate_style(self, style, unique_id=None):
"""
Generate style tags based on selection.
Args:
style: Selected style name or "none"
unique_id: Hidden parameter for cache busting
Returns:
A tuple containing the positive tags and negative tags
"""
# If none selected, return empty strings
if style == "none":
return ("", "")
# Load styles
styles = load_cached_data(
self.JSON_PATH, self.__class__._cache, 'mtime', DEFAULT_STYLES)
if style not in styles:
return ("", "")
style_config = styles[style]
positive_output = style_config.get("positive", "")
negative_output = style_config.get("negative", "")
return (positive_output, negative_output)
class WildcardNode:
"""
A ComfyUI node for replacing wildcard keys with randomly selected values.
Wildcard definitions are loaded from an external JSONC file.
"""
# Class variable to cache loaded wildcards
_cache = {}
# Path to the JSONC file (relative to this script)
JSON_PATH = os.path.join(
os.path.dirname(__file__), "config", "wildcards.jsonc")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"opt_string": ("STRING", {"default": "", "forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("output",)
FUNCTION = "replace_wildcards"
CATEGORY = "conditioning"
@classmethod
def IS_CHANGED(cls, text, opt_string="", unique_id=None):
"""
Return a random value to force re-execution on each run
if text is not empty.
"""
if text:
return random.random()
try:
mtime = os.path.getmtime(cls.JSON_PATH)
return mtime
except:
return float("nan")
def replace_wildcards(self, text, opt_string="", unique_id=None):
"""
Replace wildcard keys in text with randomly selected values.
Args:
text: Input text containing wildcard keys
opt_string: Optional string to concatenate with output
unique_id: Hidden parameter for cache busting
Returns:
A tuple containing the processed text
"""
if not text:
return (opt_string,) if opt_string else ("",)
# Load wildcards
wildcards = load_cached_data(
self.JSON_PATH, self.__class__._cache,
'mtime', DEFAULT_WILDCARDS)
# Replace each wildcard key with a randomly selected value
result = text
for key, values_string in wildcards.items():
if key in result:
# Split values by pipe and strip whitespace
values = [v.strip() for v in values_string.split('|')]
# Choose a random value
replacement = random.choice(values)
# Replace all instances of the key
result = result.replace(key, replacement)
# Concatenate with optional string if provided
if opt_string:
result = f"{result}, {opt_string}"
return (result,)
# Node registration
NODE_CLASS_MAPPINGS = {
"ModelPresetNode": ModelPresetNode,
"StylePresetNode": StylePresetNode,
"CharacterPresetNode": CharacterPresetNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ModelPresetNode": "Model Preset",
"StylePresetNode": "Style Preset",
"CharacterPresetNode": "Character Preset"
}