450 lines
15 KiB
Python
450 lines
15 KiB
Python
"""
|
|
Module for handling configuration, logging, and various utility functions for the ComfyUI-Sn0w-Scripts project.
|
|
|
|
Classes:
|
|
ConfigReader: Handles reading and managing configuration settings.
|
|
Logger: Logs messages with different severity levels and colors.
|
|
Utility: Provides static methods for common operations.
|
|
MessageHolder: Manages communication between the JavaScript frontend and Python backend.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import torch
|
|
|
|
from server import PromptServer
|
|
from aiohttp import web
|
|
|
|
import folder_paths
|
|
|
|
|
|
class ConfigReader:
|
|
"""
|
|
Handles reading and managing configuration settings for ComfyUI without using the API.
|
|
|
|
Methods:
|
|
get_setting(setting_id, default)
|
|
Retrieve a setting value from the configuration file.
|
|
|
|
is_comfy_portable()
|
|
Check if comfy is running in portable mode.
|
|
"""
|
|
|
|
DEFAULT_PATH = os.path.abspath(os.path.join(os.getcwd(), "user/default/comfy.settings.json"))
|
|
PORTABLE_PATH = os.path.abspath(os.path.join(os.getcwd(), "ComfyUI/user/default/comfy.settings.json"))
|
|
|
|
portable = None
|
|
|
|
@classmethod
|
|
def print_sn0w(cls, message, color="\033[0;35m"):
|
|
"""Print a message with a specific color prefix."""
|
|
print(f"{color}[sn0w] \033[0m{message}")
|
|
|
|
@classmethod
|
|
def is_comfy_portable(cls):
|
|
"""Check if the application is running in portable mode."""
|
|
if cls.portable is not None:
|
|
return cls.portable
|
|
|
|
# Check if default exists
|
|
if os.path.isfile(cls.DEFAULT_PATH):
|
|
cls.portable = False
|
|
ConfigReader.print_sn0w("Running standalone comfy.")
|
|
return False
|
|
|
|
# Check if portable exists
|
|
if os.path.isfile(cls.PORTABLE_PATH):
|
|
cls.portable = True
|
|
ConfigReader.print_sn0w("Running portable comfy.")
|
|
return True
|
|
|
|
# If neither exist
|
|
ConfigReader.print_sn0w("Could not find comfy settings.")
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_setting(setting_id, default=None):
|
|
"""Retrieve a setting value from the configuration file."""
|
|
# Determine the correct path based on the portable attribute
|
|
if ConfigReader.portable is None:
|
|
ConfigReader.print_sn0w(
|
|
f"Local configuration file not found at either {ConfigReader.PORTABLE_PATH} or {ConfigReader.DEFAULT_PATH}.",
|
|
"\033[0;33m",
|
|
)
|
|
return default
|
|
|
|
path = ConfigReader.PORTABLE_PATH if ConfigReader.portable else ConfigReader.DEFAULT_PATH
|
|
|
|
# Try to read the settings from the determined path
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as file:
|
|
settings = json.load(file)
|
|
return settings.get(setting_id, default)
|
|
except FileNotFoundError:
|
|
ConfigReader.print_sn0w(f"Local configuration file not found at {path}.", "\033[0;33m")
|
|
except json.JSONDecodeError:
|
|
ConfigReader.print_sn0w(f"Error decoding JSON from {path}.", "\033[0;31m")
|
|
|
|
return default
|
|
|
|
|
|
# Initialize portable check when the class is defined
|
|
ConfigReader.is_comfy_portable()
|
|
|
|
|
|
class Logger:
|
|
"""
|
|
Logger class for printing messages with different severity levels and colors.
|
|
|
|
Logger levels:
|
|
- EMERGENCY
|
|
- ALERT
|
|
- CRITICAL
|
|
- ERROR
|
|
- WARNING
|
|
- INFORMATIONAL
|
|
- DEBUG
|
|
|
|
Methods:
|
|
log(message, level)
|
|
Log a message with a specified severity level.
|
|
|
|
print_sigmas_differences(name, sigmas)
|
|
Prints sigmas to terminal.
|
|
"""
|
|
|
|
PURPLE_TEXT = "\033[0;35m"
|
|
RED_TEXT = "\033[0;31m"
|
|
YELLOW_TEXT = "\033[0;33m"
|
|
GREEN_TEXT = "\033[0;32m"
|
|
RESET_TEXT = "\033[0m"
|
|
PREFIX = "[sn0w] "
|
|
ALWAYS_LOG = ["EMERGENCY", "ALERT", "CRITICAL", "ERROR"]
|
|
|
|
enabled_levels = ConfigReader.get_setting("sn0w.LoggingLevel", ["INFORMATIONAL", "WARNING"])
|
|
|
|
@classmethod
|
|
def reload_config(cls):
|
|
"""Reload the logger configuration settings."""
|
|
cls.enabled_levels = ConfigReader.get_setting("sn0w.LoggingLevel", ["INFORMATIONAL", "WARNING"])
|
|
|
|
@classmethod
|
|
def _print_sn0w(cls, message, color):
|
|
"""Print a message with a specific color prefix."""
|
|
print(f"{color}{cls.PREFIX}{cls.RESET_TEXT}{message}")
|
|
|
|
def log(self, message, level="ERROR"):
|
|
"""Log a message with a specified severity level."""
|
|
# Determine the color based on the type of message
|
|
if level.upper() in self.ALWAYS_LOG:
|
|
color = self.RED_TEXT
|
|
elif level.upper() == "WARNING":
|
|
color = self.YELLOW_TEXT
|
|
else:
|
|
color = self.PURPLE_TEXT # Default color
|
|
|
|
# Check if the message's level is in the enabled log levels
|
|
if level.upper() in self.enabled_levels or level.upper() in self.ALWAYS_LOG:
|
|
self._print_sn0w(message, color)
|
|
|
|
def print_sigmas_differences(self, name, sigmas):
|
|
"""
|
|
Takes a tensor of sigmas and prints each sigma along with the difference to the next sigma
|
|
and the percentage difference to the next sigma.
|
|
|
|
Args:
|
|
sigmas (torch.Tensor): A 1D tensor of sigmas with a zero appended at the end.
|
|
"""
|
|
if "DEBUG" in self.enabled_levels:
|
|
self._print_sn0w(f"Scheduler: {name}", self.PURPLE_TEXT)
|
|
print("Index | Sigma Value | Difference to Next | % Difference")
|
|
print("-" * 65)
|
|
|
|
# Compute the differences
|
|
differences = sigmas[1:] - sigmas[:-1]
|
|
|
|
# Iterate over sigmas and differences
|
|
for i in range(len(sigmas) - 1):
|
|
if sigmas[i] != 0:
|
|
percent_diff = (differences[i] / sigmas[i]) * 100
|
|
else:
|
|
percent_diff = float("inf") # To handle division by zero in a meaningful way
|
|
print(f"{i:<5} | {sigmas[i]:<11.4f} | {differences[i]:<18.4f} | {percent_diff:<12.2f}")
|
|
|
|
# Print the last sigma value without a difference (since it's the appended zero)
|
|
print(f"{len(sigmas) - 1:<5} | {sigmas[-1]:<11.4f} | {'N/A':<18} | {'N/A':<12}")
|
|
|
|
|
|
class Utility:
|
|
"""
|
|
Utility class providing various static methods for common operations.
|
|
|
|
Methods:
|
|
levenshtein_distance(s1, s2):
|
|
Calculate the Levenshtein distance between two strings.
|
|
|
|
image_batch(**kwargs):
|
|
Concatenate and batch multiple image tensors.
|
|
|
|
get_model_type(model_patcher):
|
|
Retrieve the type of the model from the model patcher.
|
|
|
|
get_model_type_simple(model_patcher):
|
|
Retrieve a standardized model type from the model patcher.
|
|
|
|
put_favourite_on_top(setting, arr):
|
|
Prioritize favorite items in a list based on a setting.
|
|
|
|
create_setting_entry(setting_type, setting_value):
|
|
Create a setting entry based on the type and value.
|
|
|
|
get_node_output(data, node_id, output_id):
|
|
Get the output of a node from the workflow data.
|
|
"""
|
|
|
|
logger = Logger()
|
|
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
@staticmethod
|
|
def levenshtein_distance(s1, s2):
|
|
"""Calculate the Levenshtein distance between two strings."""
|
|
if len(s1) < len(s2):
|
|
return Utility.levenshtein_distance(s2, s1)
|
|
|
|
if len(s2) == 0:
|
|
return len(s1)
|
|
|
|
previous_row = range(len(s2) + 1)
|
|
for i, c1 in enumerate(s1):
|
|
current_row = [i + 1]
|
|
for j, c2 in enumerate(s2):
|
|
insertions = previous_row[j + 1] + 1
|
|
deletions = current_row[j] + 1
|
|
substitutions = previous_row[j] + (c1 != c2)
|
|
current_row.append(min(insertions, deletions, substitutions))
|
|
previous_row = current_row
|
|
|
|
return previous_row[-1]
|
|
|
|
@staticmethod
|
|
def _check_image_dimensions(tensors, names):
|
|
"""Check if the dimensions of the provided image tensors match."""
|
|
reference_dimensions = tensors[0].shape[1:] # Ignore batch dimension
|
|
mismatched_images = [names[i] for i, tensor in enumerate(tensors) if tensor.shape[1:] != reference_dimensions]
|
|
|
|
if mismatched_images:
|
|
raise ValueError(f"Input image dimensions do not match for images: {mismatched_images}")
|
|
|
|
@staticmethod
|
|
def image_batch(**kwargs):
|
|
"""Concatenate and batch multiple image tensors."""
|
|
batched_tensors = [tensor for key, tensor in kwargs.items() if tensor is not None]
|
|
image_names = [key for key, tensor in kwargs.items() if tensor is not None]
|
|
|
|
if not batched_tensors:
|
|
raise ValueError("At least one input image must be provided.")
|
|
|
|
Utility._check_image_dimensions(batched_tensors, image_names)
|
|
batched_tensors = torch.cat(batched_tensors, dim=0)
|
|
return batched_tensors
|
|
|
|
@staticmethod
|
|
def get_model_type(model_patcher):
|
|
"""Retrieve the type of the model from the model patcher."""
|
|
return model_patcher.model.__class__.__name__
|
|
|
|
@staticmethod
|
|
def get_model_type_simple(model_patcher):
|
|
"""Retrieve a standardized model type from the model patcher."""
|
|
model_type = Utility.get_model_type(model_patcher)
|
|
|
|
if model_type == "BaseModel":
|
|
return "SD15"
|
|
|
|
return model_type
|
|
|
|
@classmethod
|
|
def put_favourite_on_top(cls, setting, arr):
|
|
"""Prioritize favorite items in a list based on a setting."""
|
|
# Convert to a list if its a dictionary
|
|
if isinstance(arr, dict):
|
|
arr = list(arr.keys())
|
|
|
|
favourites = ConfigReader.get_setting(setting, None)
|
|
if favourites is None:
|
|
return arr
|
|
|
|
prioritized = []
|
|
arr_copy = arr[:]
|
|
|
|
# Iterate through the copied array
|
|
for item in arr_copy:
|
|
# Check for full match (case-insensitive) with any favorite
|
|
if any(favourite.lower() in item.lower() for favourite in favourites):
|
|
prioritized.append(item)
|
|
arr.remove(item)
|
|
|
|
# Append the remaining items to the prioritized list
|
|
prioritized.extend(arr)
|
|
return prioritized
|
|
|
|
@classmethod
|
|
def create_setting_entry(cls, setting_type, setting_value):
|
|
"""Create a setting entry based on the type and value."""
|
|
if setting_type == "INT":
|
|
return ("INT", {"default": setting_value[1], "min": setting_value[2], "max": setting_value[3]})
|
|
if setting_type == "FLOAT":
|
|
return (
|
|
"FLOAT",
|
|
{
|
|
"default": setting_value[1],
|
|
"min": setting_value[2],
|
|
"max": setting_value[3],
|
|
"step": setting_value[4],
|
|
},
|
|
)
|
|
if setting_type == "STRING":
|
|
return ("STRING", {"default": setting_value[1]})
|
|
if setting_type == "BOOLEAN":
|
|
return ("BOOLEAN", {"default": setting_value[1]})
|
|
raise ValueError(f"Unsupported setting type: {setting_type}")
|
|
|
|
@classmethod
|
|
def get_node_output(cls, data, node_id, output_id):
|
|
"""Get the output of a node from the workflow data."""
|
|
workflow = data.get("workflow", {})
|
|
nodes = workflow.get("nodes", [])
|
|
|
|
for node in nodes:
|
|
if int(node.get("id")) == int(node_id):
|
|
for output in node.get("outputs", []):
|
|
if int(output["slot_index"]) == int(output_id):
|
|
return output
|
|
return None
|
|
|
|
|
|
class AnyType(str):
|
|
def __ne__(self, __value: object) -> bool:
|
|
return False
|
|
|
|
|
|
class Cancelled(Exception):
|
|
pass
|
|
|
|
|
|
class MessageHolder:
|
|
"""
|
|
Communicate between the JavaScript frontend and Python backend.
|
|
"""
|
|
|
|
stash = {}
|
|
messages = {}
|
|
cancelled = False
|
|
routes = PromptServer.instance.routes
|
|
API_PREFIX = "/api/sn0w"
|
|
logger = Logger()
|
|
|
|
@classmethod
|
|
def addMessage(cls, id, message):
|
|
"""Add a message from the API."""
|
|
if message == "__cancel__":
|
|
cls.messages = {}
|
|
cls.cancelled = True
|
|
elif message == "__start__":
|
|
cls.messages = {}
|
|
cls.stash = {}
|
|
cls.cancelled = False
|
|
else:
|
|
cls.messages[str(id)] = message
|
|
|
|
@classmethod
|
|
def waitForMessage(cls, id, period=0.1, asList=False):
|
|
"""Wait for a message from the API."""
|
|
sid = str(id)
|
|
while (sid not in cls.messages) and ("-1" not in cls.messages):
|
|
if cls.cancelled:
|
|
cls.cancelled = False
|
|
raise Cancelled()
|
|
time.sleep(period)
|
|
if cls.cancelled:
|
|
cls.cancelled = False
|
|
raise Cancelled()
|
|
message = cls.messages.pop(str(id), None) or cls.messages.pop("-1")
|
|
try:
|
|
if asList:
|
|
return [int(x.strip()) for x in message.split(",")]
|
|
|
|
return int(message.strip())
|
|
except ValueError:
|
|
cls.logger.log(
|
|
f"failed to parse '{message}' as {'comma separated list of ints' if asList else 'int'}", "ERROR"
|
|
)
|
|
return [1] if asList else 1
|
|
|
|
|
|
API_PREFIX = "/sn0w"
|
|
|
|
|
|
@PromptServer.instance.routes.get(f"{API_PREFIX}/loras")
|
|
async def get_loras(request):
|
|
loras = folder_paths.get_filename_list("loras")
|
|
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras)))
|
|
|
|
|
|
@PromptServer.instance.routes.get(f"{API_PREFIX}/embeddings")
|
|
async def get_embeddings(request):
|
|
embeddings = folder_paths.get_filename_list("embeddings")
|
|
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], embeddings)))
|
|
|
|
|
|
@PromptServer.instance.routes.get(f"{API_PREFIX}/update_logging_level")
|
|
async def update_logging_level(request):
|
|
Logger.reload_config()
|
|
return web.json_response("Success")
|
|
|
|
|
|
@PromptServer.instance.routes.post(f"{API_PREFIX}/add_lora_loaders")
|
|
async def add_lora_loaders(request):
|
|
dir_path = os.path.dirname(os.path.realpath(__file__))
|
|
if os.path.basename(dir_path) == "src":
|
|
dir_path = os.path.dirname(dir_path)
|
|
json_path = os.path.join(dir_path, "web/settings/sn0w_settings.json")
|
|
# Ensure the directory exists
|
|
os.makedirs(os.path.dirname(json_path), exist_ok=True)
|
|
try:
|
|
# Parse the request body to get the new loaders
|
|
new_loaders = await request.json()
|
|
if not isinstance(new_loaders, list):
|
|
return web.json_response({"error": "Invalid input, expected a list."}, status=400)
|
|
|
|
# Read the existing JSON data from the file
|
|
if os.path.exists(json_path):
|
|
with open(json_path, "r") as file:
|
|
data = json.load(file)
|
|
else:
|
|
data = {}
|
|
|
|
# Initialize 'loraLoaders' if it doesn't exist in the JSON
|
|
if "loraLoaders" not in data:
|
|
data["loraLoaders"] = []
|
|
|
|
# Add only new loaders that are not already in the list
|
|
existing_loaders_set = set(tuple(loader) for loader in data["loraLoaders"])
|
|
new_unique_loaders = [loader for loader in new_loaders if tuple(loader) not in existing_loaders_set]
|
|
data["loraLoaders"].extend(new_unique_loaders)
|
|
|
|
if len(new_unique_loaders) == 0:
|
|
return web.json_response({"message": "No new lora loaders added."})
|
|
|
|
# Write the updated JSON data back to the file
|
|
with open(json_path, "w") as file:
|
|
json.dump(data, file, indent=4)
|
|
|
|
return web.json_response({"message": f"Lora loaders: {new_unique_loaders} added successfully."})
|
|
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"error": "Invalid JSON format."}, status=400)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|