Files
2024-08-05 00:43:31 +02:00

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)