SPECTRE v4.1.0: Full collection update including VRAM management and Qwen optimization

This commit is contained in:
AWWalker
2026-03-12 02:04:42 -04:00
parent d5c2299b1b
commit e233d0625b
57 changed files with 4527 additions and 13 deletions
+50 -9
View File
@@ -4,22 +4,63 @@ import os
import sys
from .pysssss import init, get_ext_dir
# High-Compatibility ANSI Colors
MAGENTA_BRIGHT = "\033[95m"
BLUE_BRIGHT = "\033[94m"
CYAN_BRIGHT = "\033[96m"
GREEN = "\033[1;32m"
CYAN = "\033[1;36m"
MAGENTA = "\033[1;35m"
RESET = "\033[0m"
# 5-Line Gradient ASCII Header: EmAySee (Block Style)
# Each line is wrapped individually to force the color shift
banner = f"""
{MAGENTA_BRIGHT} ███████╗███╗ ███╗ █████╗ ██╗ ██╗ ██████╗███████╗███████╗{RESET}
{MAGENTA_BRIGHT} ██╔════╝████╗ ████║██╔══██╗╚██╗ ██╔╝██╔════╝██╔════╝██╔════╝{RESET}
{BLUE_BRIGHT} █████╗ ██╔████╔██║███████║ ╚████╔╝ ╚█████╗ █████╗ █████╗ {RESET}
{CYAN_BRIGHT} ██╔══╝ ██║╚██╔╝██║██╔══██║ ╚██╔╝ ╚═══██╗██╔══╝ ██╔══╝ {RESET}
{CYAN_BRIGHT} ███████╗██║ ╚═╝ ██║██║ ██║ ██║ ██████╔╝███████╗███████╗{RESET}
{CYAN_BRIGHT} ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝{RESET}"""
print(banner)
print(f"{MAGENTA}----------------------------------------------------------{RESET}")
print(f"{GREEN}Initializing SPECTRE v5.0 | EmAySee Custom Nodes Active{RESET}")
print(f"{MAGENTA}----------------------------------------------------------{RESET}")
print(f"{CYAN}Starting Diagnostic System Check...{RESET}")
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
if init():
py = get_ext_dir("py")
files = glob.glob(os.path.join(py, "*.py"), recursive=False)
for file in files:
name = os.path.splitext(file)[0]
spec = importlib.util.spec_from_file_location(name, file)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None:
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
module_name = os.path.splitext(os.path.basename(file))[0]
if module_name == "__init__":
continue
try:
spec = importlib.util.spec_from_file_location(module_name, file)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None:
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
# Success Checkmark
print(f" {GREEN}[✓]{RESET} {module_name}")
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
except Exception as e:
# Failure Cross
print(f" \033[1;31m[✗]\033[0m {module_name} : Error -> {str(e)}")
print(f"{MAGENTA}----------------------------------------------------------{RESET}")
print(f"{GREEN}SPECTRE Online: {len(NODE_CLASS_MAPPINGS)} Nodes Mapped.{RESET}\n")
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Regular → Executable
BIN
View File
Binary file not shown.
+204
View File
@@ -0,0 +1,204 @@
import requests
import json
import re
class EmAySee_AdvancedOobaboogaConnector:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_url": ("STRING", {"default": "http://10.0.0.71:5000/v1/chat/completions"}),
"api_key": ("STRING", {"default": "supersecretkey"}),
"prompt": ("STRING", {"multiline": True, "default": "Enter prompt here"}),
"system_prompt": ("STRING", {"multiline": True, "default": "You are a helpful assistant."}),
"seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff, "control_after_generate": True}),
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}),
"top_p": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"min_p": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"top_k": ("INT", {"default": 0, "min": 0, "max": 200}),
"repetition_penalty": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 2.0, "step": 0.01}),
"reasoning_effort": (["low", "medium", "high"], {"default": "medium"}),
"auto_continue": ("BOOLEAN", {"default": False}),
"max_continues": ("INT", {"default": 3, "min": 1, "max": 10}),
"stop_on_error": ("BOOLEAN", {"default": True}),
},
"optional": {
"best_of": ("INT", {"default": 1, "min": 1, "max": 10}),
"echo": ("BOOLEAN", {"default": False}),
"frequency_penalty": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.01}),
"presence_penalty": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.01}),
"n": ("INT", {"default": 1, "min": 1, "max": 10}),
"suffix": ("STRING", {"default": ""}),
"user": ("STRING", {"default": ""}),
"preset": ("STRING", {"default": ""}),
"dynatemp_low": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}),
"dynatemp_high": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}),
"dynatemp_exponent": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"smoothing_factor": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"smoothing_curve": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}),
"typical_p": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"xtc_threshold": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
"xtc_probability": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"epsilon_cutoff": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"eta_cutoff": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"tfs": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"top_a": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"top_n_sigma": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"dry_multiplier": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.01}),
"dry_allowed_length": ("INT", {"default": 2, "min": 0, "max": 20}),
"dry_base": ("FLOAT", {"default": 1.75, "min": 1.0, "max": 5.0, "step": 0.01}),
"encoder_repetition_penalty": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 2.0, "step": 0.01}),
"no_repeat_ngram_size": ("INT", {"default": 0, "min": 0, "max": 20}),
"repetition_penalty_range": ("INT", {"default": 1024, "min": 0, "max": 8192}),
"penalty_alpha": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"guidance_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 20.0, "step": 0.1}),
"mirostat_mode": ("INT", {"default": 0, "min": 0, "max": 2}),
"mirostat_tau": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 20.0, "step": 0.1}),
"mirostat_eta": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
"prompt_lookup_num_tokens": ("INT", {"default": 0, "min": 0, "max": 20}),
"max_tokens_second": ("INT", {"default": 0, "min": 0, "max": 32768}),
"do_sample": ("BOOLEAN", {"default": True}),
"dynamic_temperature": ("BOOLEAN", {"default": False}),
"temperature_last": ("BOOLEAN", {"default": False}),
"auto_max_new_tokens": ("BOOLEAN", {"default": False}),
"ban_eos_token": ("BOOLEAN", {"default": False}),
"add_bos_token": ("BOOLEAN", {"default": True}),
"enable_thinking": ("BOOLEAN", {"default": True}),
"skip_special_tokens": ("BOOLEAN", {"default": True}),
"static_cache": ("BOOLEAN", {"default": False}),
"truncation_length": ("INT", {"default": 0, "min": 0, "max": 131072}),
"custom_token_bans": ("STRING", {"default": ""}),
"negative_prompt": ("STRING", {"default": ""}),
"dry_sequence_breakers": ("STRING", {"default": '"\\n", ":", "\\"", "*"'}),
"grammar_string": ("STRING", {"multiline": True, "default": ""}),
"sampler_priority": ("STRING", {"multiline": True, "default": ""}),
"stop_sequences": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("answer", "thinking", "full_raw")
FUNCTION = "execute_request"
CATEGORY = "EmAySee/LLM"
def execute_request(self, api_url, api_key, prompt, system_prompt, seed, max_tokens,
temperature, top_p, min_p, top_k, repetition_penalty,
reasoning_effort, auto_continue, max_continues, stop_on_error, **kwargs):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
schema_defaults = {
"best_of": 1, "echo": False, "frequency_penalty": 0.0, "presence_penalty": 0.0,
"n": 1, "suffix": "", "user": "", "preset": "", "dynatemp_low": 1.0,
"dynatemp_high": 1.0, "dynatemp_exponent": 1.0, "smoothing_factor": 0.0,
"smoothing_curve": 1.0, "typical_p": 1.0, "xtc_threshold": 0.1,
"xtc_probability": 0.0, "epsilon_cutoff": 0.0, "eta_cutoff": 0.0,
"tfs": 1.0, "top_a": 0.0, "top_n_sigma": 0.0, "dry_multiplier": 0.0,
"dry_allowed_length": 2, "dry_base": 1.75, "encoder_repetition_penalty": 1.0,
"no_repeat_ngram_size": 0, "repetition_penalty_range": 1024, "penalty_alpha": 0.0,
"guidance_scale": 1.0, "mirostat_mode": 0, "mirostat_tau": 5.0, "mirostat_eta": 0.1,
"prompt_lookup_num_tokens": 0, "max_tokens_second": 0, "do_sample": True,
"dynamic_temperature": False, "temperature_last": False, "auto_max_new_tokens": False,
"ban_eos_token": False, "add_bos_token": True, "enable_thinking": True,
"skip_special_tokens": True, "static_cache": False, "truncation_length": 0,
"custom_token_bans": "", "negative_prompt": "", "grammar_string": "",
"dry_sequence_breakers": '"\\n", ":", "\\"", "*"', "sampler_priority": "",
"stop_sequences": ""
}
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
full_content = ""
current_continue = 0
last_json_response = {}
while current_continue <= max_continues:
payload = {
"messages": messages,
"seed": seed,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"min_p": min_p,
"top_k": top_k,
"repetition_penalty": repetition_penalty,
"reasoning_effort": reasoning_effort,
"stream": False
}
for key, default_val in schema_defaults.items():
current_val = kwargs.get(key, default_val)
if current_val != default_val:
if key == "sampler_priority" and current_val.strip():
payload[key] = [s.strip() for s in current_val.split(",") if s.strip()]
elif key == "stop_sequences" and current_val.strip():
payload["stop"] = [s.strip() for s in current_val.split(",") if s.strip()]
else:
payload[key] = current_val
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=600)
if response.status_code != 200:
if stop_on_error:
raise Exception(f"Backend Error {response.status_code}: {response.text}")
return (f"Error {response.status_code}", "", response.text)
result_json = response.json()
last_json_response = result_json
if "choices" in result_json and len(result_json["choices"]) > 0:
choice = result_json["choices"][0]
content = choice["message"]["content"]
finish_reason = choice.get("finish_reason", "")
full_content += content
if not auto_continue or (finish_reason != "length" and finish_reason != "maxlen"):
break
messages.append({"role": "assistant", "content": content})
current_continue += 1
else:
if stop_on_error:
raise Exception("No content in response")
return ("No content in response", "", str(result_json))
except Exception as e:
if stop_on_error:
raise e
return (f"Connection Error: {str(e)}", "", "ERROR")
think_pattern = r'<(?:think|thought)>(.*?)</(?:think|thought)>'
think_match = re.search(think_pattern, full_content, flags=re.DOTALL | re.IGNORECASE)
if think_match:
thinking = think_match.group(1).strip()
answer = re.sub(think_pattern, '', full_content, flags=re.DOTALL | re.IGNORECASE).strip()
else:
if "<think>" in full_content.lower():
start_idx = full_content.lower().find("<think>") + 7
thinking = full_content[start_idx:].strip()
answer = "[REASONING TRUNCATED]"
else:
thinking = ""
answer = full_content.strip()
return (answer, thinking, json.dumps(last_json_response, indent=2))
NODE_CLASS_MAPPINGS = {
"EmAySee_AdvancedOobaboogaConnector": EmAySee_AdvancedOobaboogaConnector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_AdvancedOobaboogaConnector": "EmAySee Advanced Oobabooga Connector"
}
+44
View File
@@ -0,0 +1,44 @@
class EmAySee_BodyAttributes_Float:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
labels = [
"01 Breast Size", "02 Breast Sag", "03 Areola Size", "04 Puffies",
"05 Belly Fat", "06 Waist Size", "07 Butt Size", "08 Thigh Size",
"09 Thigh Gap", "10 Body Type", "11 Pregnant", "12 Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05"
]
inputs = {"required": {}}
for label in labels:
inputs["required"][label] = ("FLOAT", {"default": 0.0, "min": -3.0, "max": 6.0, "step": 0.1})
return inputs
RETURN_TYPES = ("FLOAT",) * 17
RETURN_NAMES = (
"01 Breast Size", "02 Breast Sag", "03 Areola Size", "04 Puffies",
"05 Belly Fat", "06 Waist Size", "07 Butt Size", "08 Thigh Size",
"09 Thigh Gap", "10 Body Type", "11 Pregnant", "12 Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05"
)
FUNCTION = "get_values"
CATEGORY = "EmAySee/Morph"
def get_values(self, **kwargs):
labels = [
"01 Breast Size", "02 Breast Sag", "03 Areola Size", "04 Puffies",
"05 Belly Fat", "06 Waist Size", "07 Butt Size", "08 Thigh Size",
"09 Thigh Gap", "10 Body Type", "11 Pregnant", "12 Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05"
]
results = [kwargs.get(label, 0.0) for label in labels]
return tuple(results)
NODE_CLASS_MAPPINGS = {
"EmAySee_BodyAttributes_Float": EmAySee_BodyAttributes_Float
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_BodyAttributes_Float": "EmAySee Body Attributes Float"
}
+44
View File
@@ -0,0 +1,44 @@
class EmAySee_BodyAttributes_TextOut:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
labels = [
"01 Breast Size", "02 Breast Sag", "03 Areola Size", "04 Puffies",
"05 Belly Fat", "06 Waist Size", "07 Butt Size", "08 Thigh Size",
"09 Thigh Gap", "10 Body Type", "11 Pregnant", "12 Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05"
]
inputs = {"required": {}}
for label in labels:
inputs["required"][label] = ("STRING", {"default": "", "multiline": True})
return inputs
RETURN_TYPES = ("STRING",) * 17
RETURN_NAMES = (
"01 Breast Size", "02 Breast Sag", "03 Areola Size", "04 Puffies",
"05 Belly Fat", "06 Waist Size", "07 Butt Size", "08 Thigh Size",
"09 Thigh Gap", "10 Body Type", "11 Pregnant", "12 Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05"
)
FUNCTION = "get_text"
CATEGORY = "EmAySee/Text"
def get_text(self, **kwargs):
labels = [
"01 Breast Size", "02 Breast Sag", "03 Areola Size", "04 Puffies",
"05 Belly Fat", "06 Waist Size", "07 Butt Size", "08 Thigh Size",
"09 Thigh Gap", "10 Body Type", "11 Pregnant", "12 Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05"
]
results = [kwargs.get(label, "") for label in labels]
return tuple(results)
NODE_CLASS_MAPPINGS = {
"EmAySee_BodyAttributes_TextOut": EmAySee_BodyAttributes_TextOut
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_BodyAttributes_TextOut": "EmAySee Body Attributes Text Out"
}
+38
View File
@@ -0,0 +1,38 @@
import torch
import gc
import comfy.model_management
class EmAySee_CacheCleaner:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"anything": ("*", {}),
},
}
RETURN_TYPES = ("*",)
RETURN_NAMES = ("output",)
FUNCTION = "clean_cache"
CATEGORY = "EmAySee/Utils"
def clean_cache(self, anything):
comfy.model_management.unload_all_models()
comfy.model_management.soft_empty_cache()
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
return (anything,)
NODE_CLASS_MAPPINGS = {
"EmAySee_CacheCleaner": EmAySee_CacheCleaner
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_CacheCleaner": "EmAySee Cache Cleaner"
}
+64
View File
@@ -0,0 +1,64 @@
import torch
import torch.nn.functional as F
class EmAySee_ConditionalResize:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"trigger_threshold": ("INT", {"default": 1024, "min": 0, "max": 16384, "step": 8}),
"target_resolution": ("INT", {"default": 1024, "min": 0, "max": 16384, "step": 8}),
"method": (["nearest-exact", "bilinear", "area", "bicubic", "lanczos"],),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "resize_if_needed"
CATEGORY = "EmAySee/Image"
def resize_if_needed(self, image, trigger_threshold, target_resolution, method):
_, h, w, _ = image.shape
longest_side = max(h, w)
if longest_side >= trigger_threshold:
return (image,)
scale = target_resolution / longest_side
new_h = int(h * scale)
new_w = int(w * scale)
samples = image.movedim(-1, 1)
if method == "lanczos":
s = samples.clone()
# Lanczos kernel implementation usually requires custom handling or specific libraries in standard Torch.
# Using bicubic as a fallback for standard torch interpolate if lanczos isn't explicitly defined in context,
# but standard ComfyUI `common_upscale` handles this.
# To keep this dependency-free and simple torch:
# We will use bicubic for lanczos slot here or implement standard resize.
# Actually, simpler to map standard torch modes.
# Let's use bicubic for 'lanczos' to avoid external dependencies,
# or simple bicubic with antialias=True if supported.
mode = "bicubic"
else:
mode = method
if method == "nearest-exact":
mode = "nearest"
s = F.interpolate(samples, size=(new_h, new_w), mode=mode, align_corners=False if mode not in ["nearest", "area"] else None)
s = s.movedim(1, -1)
return (s,)
NODE_CLASS_MAPPINGS = {
"EmAySee_ConditionalResize": EmAySee_ConditionalResize
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_ConditionalResize": "EmAySee Conditional Resize"
}
+74
View File
@@ -0,0 +1,74 @@
import os
import re
class EmAySee_ContextManager:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"default": "prompt.txt"}),
"history_count": ("INT", {"default": 2, "min": 1, "max": 10, "step": 1}),
"mode": (["Read_Context", "Append_and_Prune"],),
"force_reset": ("BOOLEAN", {"default": False}),
},
"optional": {
"new_generation": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "manage_context"
CATEGORY = "EmAySee/Automation"
def manage_context(self, file_path, history_count, mode, force_reset, new_generation=""):
# Force absolute path to avoid "Ghost" file issues in ComfyUI temp folders
abs_path = os.path.abspath(file_path)
if force_reset and os.path.exists(abs_path):
os.remove(abs_path)
# Helper to read blocks
def get_blocks(path):
if not os.path.exists(path) or os.path.getsize(path) == 0:
return []
with open(path, 'r', encoding='utf-8') as f:
data = f.read()
found = re.findall(r"(PROMPT:.*?STATE:.*?)(?=PROMPT:|\Z)", data, re.DOTALL)
return [b.strip() for b in found if b.strip()]
blocks = get_blocks(abs_path)
if mode == "Read_Context":
# SEEDING: If file is empty, use the input wire as the current state
if not blocks:
if new_generation and new_generation.strip():
seed = new_generation.strip()
# Write immediately so the Append node sees it later in the same cycle
with open(abs_path, 'w', encoding='utf-8') as f:
f.write(seed + "\n\n")
f.flush()
os.fsync(f.fileno())
return (seed,)
return ("",)
recent_blocks = blocks[-history_count:]
return ("\n\n".join(recent_blocks),)
if mode == "Append_and_Prune":
if new_generation and new_generation.strip():
# Avoid doubling up if Read_Context already seeded it
if not blocks or blocks[-1] != new_generation.strip():
blocks.append(new_generation.strip())
pruned = blocks[-history_count:]
# ATOMIC WRITE
with open(abs_path, 'w', encoding='utf-8') as f:
f.write("\n\n".join(pruned))
f.flush()
os.fsync(f.fileno()) # Forces Windows/Linux to write to disk NOW
return (new_generation if new_generation else "",)
NODE_CLASS_MAPPINGS = {"EmAySee_ContextManager": EmAySee_ContextManager}
NODE_DISPLAY_NAME_MAPPINGS = {"EmAySee_ContextManager": "EmAySee Context Manager"}
+95
View File
@@ -0,0 +1,95 @@
import os
import re
def get_comfy_root():
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
def atomic_write(path, blocks):
directory = os.path.dirname(path)
if directory and not os.path.exists(directory):
os.makedirs(directory, mode=0o777, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write("\n\n".join(blocks))
def get_blocks(path):
if not os.path.exists(path) or os.path.getsize(path) == 0:
return []
with open(path, 'r', encoding='utf-8') as f:
data = f.read()
# V5 RE-DESIGNED REGEX: Looks for PROMPT and the LAST occurrence of STATE in a chunk
found = re.findall(r"(PROMPT:.*?STATE:.*?)(?=PROMPT:|\Z)", data, re.IGNORECASE | re.DOTALL)
return [b.strip() for b in found if b.strip()]
class EmAySee_ContextReader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_name": ("STRING", {"default": "user/default/emaysee_prompts/prompt_v2.txt"}),
"history_count": ("INT", {"default": 2, "min": 1, "max": 10}),
"force_reset_file": ("BOOLEAN", {"default": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {"seed_text": ("STRING", {"forceInput": True})}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("history_text", "status")
FUNCTION = "read_context"
CATEGORY = "EmAySee_Automation/V5"
def read_context(self, file_name, history_count, force_reset_file, seed, seed_text=""):
abs_path = os.path.join(get_comfy_root(), file_name)
if force_reset_file and os.path.exists(abs_path):
os.remove(abs_path)
blocks = get_blocks(abs_path)
if not blocks:
if seed_text:
atomic_write(abs_path, [seed_text.strip()])
return (seed_text.strip(), "SEEDED")
return ("", "EMPTY")
return ("\n\n".join(blocks[-history_count:]), f"READ {len(blocks[-history_count:])}")
class EmAySee_ContextWriter:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_name": ("STRING", {"default": "user/default/emaysee_prompts/prompt_v2.txt"}),
"history_count": ("INT", {"default": 2, "min": 1, "max": 10}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {"new_generation": ("STRING", {"forceInput": True})}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "write_context"
CATEGORY = "EmAySee_Automation/V5"
OUTPUT_NODE = True
def write_context(self, file_name, history_count, seed, new_generation=""):
if not new_generation or not new_generation.strip(): return ("",)
text = new_generation.strip()
# --- V5 REPAIR LOGIC ---
# 1. Ensure PROMPT header
if not re.search(r"^PROMPT:", text, re.IGNORECASE):
text = "PROMPT: " + text
# 2. Check for STATE header (case insensitive)
if not re.search(r"STATE:", text, re.IGNORECASE):
print("--- [V5 WRITER] Repairing Missing STATE ---")
text = text + "\n\nSTATE: [Repaired Placeholder]"
abs_path = os.path.join(get_comfy_root(), file_name)
blocks = get_blocks(abs_path)
# Deduplication check
if not (blocks and blocks[-1][:100] == text[:100]):
blocks.append(text)
# Keep only the requested history depth
atomic_write(abs_path, blocks[-history_count:])
return (text,)
NODE_CLASS_MAPPINGS = {"EmAySee_ContextReader": EmAySee_ContextReader, "EmAySee_ContextWriter": EmAySee_ContextWriter}
NODE_DISPLAY_NAME_MAPPINGS = {"EmAySee_ContextReader": "EmAySee Context Reader V5", "EmAySee_ContextWriter": "EmAySee Context Writer V5"}
+84
View File
@@ -0,0 +1,84 @@
import os
import re
class EmAySee_ContextManager_V2:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"default": "prompt_v2.txt"}),
"history_count": ("INT", {"default": 2, "min": 1, "max": 10, "step": 1}),
"mode": (["Read_Context", "Append_and_Prune"],),
"force_reset": ("BOOLEAN", {"default": False}),
},
"optional": {
"new_generation": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "manage_context"
CATEGORY = "EmAySee_Automation"
def manage_context(self, file_path, history_count, mode, force_reset, new_generation=""):
# ABSOLUTE PATH LOGGING
abs_path = os.path.abspath(file_path)
print(f"--- [EmAySee V2] DEBUG: Working Directory is {os.getcwd()} ---")
print(f"--- [EmAySee V2] DEBUG: Looking for file at {abs_path} ---")
if force_reset:
if os.path.exists(abs_path):
os.remove(abs_path)
print(f"--- [EmAySee V2] RESET: DELETED {abs_path} ---")
def get_blocks(path):
if not os.path.exists(path):
print(f"--- [EmAySee V2] DEBUG: File does NOT exist on disk. ---")
return []
if os.path.getsize(path) == 0:
print(f"--- [EmAySee V2] DEBUG: File exists but is EMPTY (0 bytes). ---")
return []
with open(path, 'r', encoding='utf-8') as f:
data = f.read()
# This regex is strict. If your file doesn't have "PROMPT:" it will return nothing.
found = re.findall(r"(PROMPT:.*?STATE:.*?)(?=PROMPT:|\Z)", data, re.DOTALL)
print(f"--- [EmAySee V2] DEBUG: Found {len(found)} valid PROMPT/STATE blocks. ---")
return [b.strip() for b in found if b.strip()]
blocks = get_blocks(abs_path)
if mode == "Read_Context":
if not blocks:
if new_generation and new_generation.strip():
seed = new_generation.strip()
print(f"--- [EmAySee V2] SEEDING: Writing input to {abs_path} ---")
with open(abs_path, 'w', encoding='utf-8') as f:
f.write(seed + "\n\n")
f.flush()
os.fsync(f.fileno())
return (seed,)
else:
print(f"--- [EmAySee V2] CRITICAL: No file and no seed input! ---")
return ("",)
recent = blocks[-history_count:]
print(f"--- [EmAySee V2] SUCCESS: Passing {len(recent)} blocks to LLM. ---")
return ("\n\n".join(recent),)
if mode == "Append_and_Prune":
if new_generation and new_generation.strip():
if not blocks or blocks[-1] != new_generation.strip():
blocks.append(new_generation.strip())
pruned = blocks[-history_count:]
with open(abs_path, 'w', encoding='utf-8') as f:
f.write("\n\n".join(pruned))
f.flush()
os.fsync(f.fileno())
return (new_generation if new_generation else "",)
NODE_CLASS_MAPPINGS = {"EmAySee_ContextManager_V2": EmAySee_ContextManager_V2}
NODE_DISPLAY_NAME_MAPPINGS = {"EmAySee_ContextManager_V2": "EmAySee Context Manager V2"}
+47
View File
@@ -0,0 +1,47 @@
import os
class EmAySee_ContextWriter_V5:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"default": "/full/path/to/prompt_v2.txt"}),
"text_to_append": ("STRING", {"forceInput": True}),
"max_history": ("INT", {"default": 4, "min": 1, "max": 20}),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "write_context"
CATEGORY = "EmAySee_Automation/Context"
def write_context(self, file_path, text_to_append, max_history):
# 1. Truncation Guard
if "[REASONING TRUNCATED]" in text_to_append:
raise ValueError(f"CRITICAL ERROR: LLM Output was truncated. Workflow halted to prevent history corruption.")
# 2. Path Validation
if not os.path.exists(os.path.dirname(file_path)):
os.makedirs(os.path.dirname(file_path), mode=0o777, exist_ok=True)
# 3. Read existing to manage history length
history = []
if os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
history = [block for block in content.split("---") if "STATE:" in block]
# 4. Append New Entry
history.append(text_to_append)
# 5. Keep only the last N entries
history = history[-max_history:]
# 6. Rewrite file
with open(file_path, "w", encoding="utf-8") as f:
f.write("---".join(history))
return (text_to_append,)
NODE_CLASS_MAPPINGS = {"EmAySee_ContextWriter_V5": EmAySee_ContextWriter_V5}
NODE_DISPLAY_NAME_MAPPINGS = {"EmAySee_ContextWriter_V5": "EmAySee Context Writer V5"}
+148
View File
@@ -0,0 +1,148 @@
import requests
import json
import re
import time
class EmAySee_DeepReasoningConnector:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": "Enter prompt here"}),
"system_prompt": ("STRING", {"multiline": True, "default": "You are a helpful assistant."}),
"api_url": ("STRING", {"default": "http://10.0.0.71:5000/v1/chat/completions"}),
"api_key": ("STRING", {"default": "supersecretkey"}),
"model_name": ("STRING", {"default": "default"}),
"max_tokens_per_call": ("INT", {"default": 4096, "min": 1, "max": 8192, "step": 64}),
"truncation_length": ("INT", {"default": 32768, "min": 0, "max": 131072, "step": 512}),
"temperature": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.05}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}),
"min_p": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"top_k": ("INT", {"default": 20, "min": 0, "max": 200}),
"repetition_penalty": ("FLOAT", {"default": 1.15, "min": 1.0, "max": 2.0, "step": 0.01}),
"repetition_penalty_range": ("INT", {"default": 2048, "min": 0, "max": 8192, "step": 64}),
"reasoning_effort": (["low", "medium", "high"], {"default": "low"}),
"xtc_threshold": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
"xtc_probability": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"dry_multiplier": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.1}),
"dry_base": ("FLOAT", {"default": 1.75, "min": 1.0, "max": 5.0, "step": 0.05}),
"dry_allowed_length": ("INT", {"default": 1, "min": 0, "max": 20}),
"auto_continue": ("BOOLEAN", {"default": True}),
"max_continues": ("INT", {"default": 3, "min": 1, "max": 10}),
"stop_on_error": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("answer", "thinking", "full_raw")
FUNCTION = "execute_request"
CATEGORY = "EmAySee/LLM"
def execute_request(self, prompt, system_prompt, api_url, api_key, model_name,
max_tokens_per_call, truncation_length, temperature, top_p, min_p, top_k,
repetition_penalty, repetition_penalty_range, reasoning_effort,
xtc_threshold, xtc_probability, dry_multiplier, dry_base,
dry_allowed_length, auto_continue, max_continues, stop_on_error):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
full_content = ""
current_continue = 0
while current_continue <= max_continues:
payload = {
"model": model_name,
"messages": messages,
"max_tokens": max_tokens_per_call,
"truncation_length": truncation_length,
"temperature": temperature,
"top_p": top_p,
"min_p": min_p,
"top_k": top_k,
"repetition_penalty": repetition_penalty,
"repetition_penalty_range": repetition_penalty_range,
"xtc_threshold": xtc_threshold,
"xtc_probability": xtc_probability,
"dry_multiplier": dry_multiplier,
"dry_base": dry_base,
"dry_allowed_length": dry_allowed_length,
"enable_thinking": True,
"reasoning_effort": reasoning_effort,
"do_sample": True,
"add_bos_token": False,
"skip_special_tokens": True,
"stream": False
}
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=600)
if response.status_code != 200:
error_detail = response.text
print(f"!!! EmAySee Deep Reasoning Error [{response.status_code}] !!!")
print(f"Backend Response: {error_detail}")
if stop_on_error:
raise Exception(f"Backend Error {response.status_code}: {error_detail}")
return (f"Error {response.status_code}", "", error_detail)
result_json = response.json()
if "choices" in result_json and len(result_json["choices"]) > 0:
choice = result_json["choices"][0]
content = choice["message"]["content"]
finish_reason = choice.get("finish_reason", "")
full_content += content
usage = result_json.get("usage", {})
print(f"[EmAySee LLM] Chunk {current_continue + 1} | Context: {usage.get('total_tokens', '??')}/{truncation_length} | Finish: {finish_reason}")
if not auto_continue or (finish_reason != "length" and finish_reason != "maxlen"):
break
messages.append({"role": "assistant", "content": content})
current_continue += 1
else:
if stop_on_error: raise Exception("No content in response")
return ("No content", "", str(result_json))
except Exception as e:
if stop_on_error: raise e
return (f"Error: {str(e)}", "", "ERROR")
# Parsing results
think_pattern = r'<(?:think|thought)>(.*?)</(?:think|thought)>'
think_match = re.search(think_pattern, full_content, flags=re.DOTALL | re.IGNORECASE)
if think_match:
thinking = think_match.group(1).strip()
answer = re.sub(think_pattern, '', full_content, flags=re.DOTALL | re.IGNORECASE).strip()
else:
if "<think>" in full_content.lower():
start_idx = full_content.lower().find("<think>") + 7
thinking = full_content[start_idx:].strip()
answer = "[REASONING DID NOT FINISH]"
else:
thinking = ""
answer = full_content.strip()
return (answer, thinking, full_content)
NODE_CLASS_MAPPINGS = {
"EmAySee_DeepReasoningConnector": EmAySee_DeepReasoningConnector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_DeepReasoningConnector": "EmAySee Deep Reasoning Connector"
}
+149
View File
@@ -0,0 +1,149 @@
import requests
import json
import re
import time
class EmAySee_DeepReasoningConnectorWithSeed:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": "Enter prompt here"}),
"system_prompt": ("STRING", {"multiline": True, "default": "You are a helpful assistant."}),
"api_url": ("STRING", {"default": "http://10.0.0.71:5000/v1/chat/completions"}),
"api_key": ("STRING", {"default": "supersecretkey"}),
"model_name": ("STRING", {"default": "default"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
"max_tokens_per_call": ("INT", {"default": 4096, "min": 1, "max": 8192, "step": 64}),
"truncation_length": ("INT", {"default": 32768, "min": 0, "max": 131072, "step": 512}),
"temperature": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.05}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}),
"min_p": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"top_k": ("INT", {"default": 20, "min": 0, "max": 200}),
"repetition_penalty": ("FLOAT", {"default": 1.15, "min": 1.0, "max": 2.0, "step": 0.01}),
"repetition_penalty_range": ("INT", {"default": 2048, "min": 0, "max": 8192, "step": 64}),
"reasoning_effort": (["low", "medium", "high"], {"default": "low"}),
"xtc_threshold": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
"xtc_probability": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"dry_multiplier": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.1}),
"dry_base": ("FLOAT", {"default": 1.75, "min": 1.0, "max": 5.0, "step": 0.05}),
"dry_allowed_length": ("INT", {"default": 1, "min": 0, "max": 20}),
"auto_continue": ("BOOLEAN", {"default": True}),
"max_continues": ("INT", {"default": 3, "min": 1, "max": 10}),
"stop_on_error": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("answer", "thinking", "full_raw")
FUNCTION = "execute_request"
CATEGORY = "EmAySee/LLM"
def execute_request(self, prompt, system_prompt, api_url, api_key, model_name, seed,
max_tokens_per_call, truncation_length, temperature, top_p, min_p, top_k,
repetition_penalty, repetition_penalty_range, reasoning_effort,
xtc_threshold, xtc_probability, dry_multiplier, dry_base,
dry_allowed_length, auto_continue, max_continues, stop_on_error):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
full_content = ""
current_continue = 0
while current_continue <= max_continues:
payload = {
"model": model_name,
"messages": messages,
"seed": seed,
"max_tokens": max_tokens_per_call,
"truncation_length": truncation_length,
"temperature": temperature,
"top_p": top_p,
"min_p": min_p,
"top_k": top_k,
"repetition_penalty": repetition_penalty,
"repetition_penalty_range": repetition_penalty_range,
"xtc_threshold": xtc_threshold,
"xtc_probability": xtc_probability,
"dry_multiplier": dry_multiplier,
"dry_base": dry_base,
"dry_allowed_length": dry_allowed_length,
"enable_thinking": True,
"reasoning_effort": reasoning_effort,
"do_sample": True,
"add_bos_token": False,
"skip_special_tokens": True,
"stream": False
}
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=600)
if response.status_code != 200:
error_detail = response.text
print(f"!!! EmAySee Deep Reasoning Error [{response.status_code}] !!!")
print(f"Backend Response: {error_detail}")
if stop_on_error:
raise Exception(f"Backend Error {response.status_code}: {error_detail}")
return (f"Error {response.status_code}", "", error_detail)
result_json = response.json()
if "choices" in result_json and len(result_json["choices"]) > 0:
choice = result_json["choices"][0]
content = choice["message"]["content"]
finish_reason = choice.get("finish_reason", "")
full_content += content
usage = result_json.get("usage", {})
print(f"[EmAySee LLM] Chunk {current_continue + 1} | Context: {usage.get('total_tokens', '??')}/{truncation_length} | Finish: {finish_reason}")
if not auto_continue or (finish_reason != "length" and finish_reason != "maxlen"):
break
messages.append({"role": "assistant", "content": content})
current_continue += 1
else:
if stop_on_error: raise Exception("No content in response")
return ("No content", "", str(result_json))
except Exception as e:
if stop_on_error: raise e
return (f"Error: {str(e)}", "", "ERROR")
think_pattern = r'<(?:think|thought)>(.*?)</(?:think|thought)>'
think_match = re.search(think_pattern, full_content, flags=re.DOTALL | re.IGNORECASE)
if think_match:
thinking = think_match.group(1).strip()
answer = re.sub(think_pattern, '', full_content, flags=re.DOTALL | re.IGNORECASE).strip()
else:
if "<think>" in full_content.lower():
start_idx = full_content.lower().find("<think>") + 7
thinking = full_content[start_idx:].strip()
answer = "[REASONING DID NOT FINISH]"
else:
thinking = ""
answer = full_content.strip()
return (answer, thinking, full_content)
NODE_CLASS_MAPPINGS = {
"EmAySee_DeepReasoningConnectorWithSeed": EmAySee_DeepReasoningConnectorWithSeed
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_DeepReasoningConnectorWithSeed": "EmAySee Deep Reasoning Connector"
}
+32
View File
@@ -0,0 +1,32 @@
class EmAySee_DimensionSwapper:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"width": ("INT", {"default": 512, "min": 1, "max": 4096, "step": 8}),
"height": ("INT", {"default": 512, "min": 1, "max": 4096, "step": 8}),
"swap_dimensions": (["No Swap", "Swap"],)
}
}
RETURN_TYPES = ("INT", "INT",)
RETURN_NAMES = ("WIDTH", "HEIGHT",)
FUNCTION = "swap_dims"
CATEGORY = "EmAySee/Dimensions"
def swap_dims(self, width, height, swap_dimensions):
if swap_dimensions == "Swap":
return (height, width,)
else:
return (width, height,)
NODE_CLASS_MAPPINGS = {
"EmAySee_DimensionSwapper": EmAySee_DimensionSwapper
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_DimensionSwapper": "EmAySee Dimension Swapper"
}
+76
View File
@@ -0,0 +1,76 @@
import random
import re
class EmAySee_DynamicListProcessor:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": "{a|b|c{s1|s2}|d}"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("processed_text",)
FUNCTION = "process"
CATEGORY = "EmAySee/Text"
def process(self, text, seed):
rng = random.Random(seed)
def parse_text(input_str):
result = []
i = 0
while i < len(input_str):
if input_str[i] == '{':
start = i
depth = 1
i += 1
while i < len(input_str) and depth > 0:
if input_str[i] == '{':
depth += 1
elif input_str[i] == '}':
depth -= 1
i += 1
if depth == 0:
group_content = input_str[start + 1:i - 1]
options = []
current_option = []
inner_depth = 0
for char in group_content:
if char == '|' and inner_depth == 0:
options.append("".join(current_option))
current_option = []
else:
if char == '{':
inner_depth += 1
elif char == '}':
inner_depth -= 1
current_option.append(char)
options.append("".join(current_option))
choice = rng.choice(options)
result.append(parse_text(choice))
else:
result.append(input_str[start])
i = start + 1
else:
result.append(input_str[i])
i += 1
return "".join(result)
return (parse_text(text),)
NODE_CLASS_MAPPINGS = {
"EmAySee_DynamicListProcessor": EmAySee_DynamicListProcessor
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_DynamicListProcessor": "EmAySee Dynamic List Processor"
}
+40
View File
@@ -0,0 +1,40 @@
import math
class EmAySee_DynamicRangeSlider:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"min_val": ("FLOAT", {"default": -1.0, "step": 0.1}),
"max_val": ("FLOAT", {"default": 2.0, "step": 0.1}),
"step_val": ("FLOAT", {"default": 0.25, "min": 0.001, "step": 0.001}),
"value_percent": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("value",)
FUNCTION = "get_value"
CATEGORY = "EmAySee/Utils"
def get_value(self, min_val, max_val, step_val, value_percent):
if min_val > max_val:
min_val, max_val = max_val, min_val
raw_value = min_val + (value_percent * (max_val - min_val))
quantized_value = raw_value
if step_val > 0:
quantized_value = round(raw_value / step_val) * step_val
final_value = max(min_val, min(max_val, quantized_value))
return (final_value,)
NODE_CLASS_MAPPINGS = {
"EmAySee_DynamicRangeSlider": EmAySee_DynamicRangeSlider
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_DynamicRangeSlider": "EmAySee Dynamic Range Slider"
}
+53
View File
@@ -0,0 +1,53 @@
import folder_paths
import os
import random
from PIL import Image
import numpy as np
class EphemeralPreview:
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
@classmethod
def INPUT_TYPES(s):
return {"required": {"images": ("IMAGE",)}}
RETURN_TYPES = ()
FUNCTION = "preview_no_cache"
OUTPUT_NODE = True
CATEGORY = "EmAySee_Tools" # Added per your preference
def preview_no_cache(self, images):
results = []
# We use a fixed filename to encourage the browser to reuse the memory slot
# slightly changing the query param (subfolder) to force a refresh but try to keep it lean.
for i, image in enumerate(images):
i_np = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i_np, 0, 255).astype(np.uint8))
# Save to a fixed filename in temp to avoid disk bloat
filename = f"ephemeral_monitor_{i}.png"
file = os.path.join(self.output_dir, filename)
img.save(file, optimize=True, compress_level=4)
# We append a random int to the subfolder only to trigger the UI update,
# but the file on disk remains constant.
results.append({
"filename": filename,
"subfolder": "",
"type": self.type
})
# This return structure mimics a PreviewImage node
return {"ui": {"images": results}}
# Register the node
NODE_CLASS_MAPPINGS = {
"EmAySee_EphemeralPreview": EphemeralPreview
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_EphemeralPreview": "Ephemeral Preview (Low RAM)"
}
+29
View File
@@ -0,0 +1,29 @@
class EmAySee_FloatToText:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("FLOAT", {"default": 0.0, "min": -3.0, "max": 3.0, "step": 0.05}),
"prefix": ("STRING", {"default": "", "multiline": False}),
"suffix": ("STRING", {"default": "", "multiline": False}),
}
}
RETURN_TYPES = ("STRING", "FLOAT")
RETURN_NAMES = ("text", "float")
FUNCTION = "convert"
CATEGORY = "EmAySee/Text"
def convert(self, value, prefix, suffix):
return (f"({prefix}:{value}{suffix})", value)
NODE_CLASS_MAPPINGS = {
"EmAySee_FloatToText": EmAySee_FloatToText
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_FloatToText": "EmAySee Float To Text"
}
+138
View File
@@ -0,0 +1,138 @@
import base64
from io import BytesIO
from PIL import Image
import torch
import gc
import numpy as np
try:
from llama_cpp import Llama
from llama_cpp.llama_chat_format import Llava15ChatHandler
except ImportError:
Llama = None
Llava15ChatHandler = None
class EmAySee_GGUFVisionLocal:
_MODEL = None
_CHAT_HANDLER = None
_CURRENT_MODEL_PATH = None
_CURRENT_MMPROJ_PATH = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"gguf_model_path": ("STRING", {"default": "/ai/models-2/models/LLM/qwen3-vl-4b.gguf"}),
"mmproj_path": ("STRING", {"default": "/ai/models-2/models/LLM/mmproj-qwen3-vl-4b.gguf"}),
"system_prompt": ("STRING", {"multiline": True, "default": "Analyze this image and generate a detailed image generation prompt. Output only the prompt."}),
"user_prompt": ("STRING", {"multiline": True, "default": ""}),
"max_tokens": ("INT", {"default": 2048, "min": 16, "max": 8192}),
"temperature": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 2.0, "step": 0.05}),
"n_gpu_layers": ("INT", {"default": -1, "min": -1, "max": 128}),
"unload_after": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "thinking")
FUNCTION = "generate"
CATEGORY = "EmAySee"
def _tensor_to_base64(self, image_tensor):
img0 = image_tensor[0].detach().cpu().float().clamp(0, 1)
arr = (img0.numpy() * 255.0).round().astype(np.uint8)
pil_img = Image.fromarray(arr, mode="RGB")
buffered = BytesIO()
pil_img.save(buffered, format="JPEG")
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{img_str}"
def load_model(self, model_path, mmproj_path, n_gpu_layers):
if EmAySee_GGUFVisionLocal._MODEL is not None and \
EmAySee_GGUFVisionLocal._CURRENT_MODEL_PATH == model_path and \
EmAySee_GGUFVisionLocal._CURRENT_MMPROJ_PATH == mmproj_path:
return EmAySee_GGUFVisionLocal._MODEL
if Llama is None:
raise RuntimeError("llama_cpp is not installed. Please compile llama-cpp-python with CUDA support.")
chat_handler = Llava15ChatHandler(clip_model_path=mmproj_path)
llm = Llama(
model_path=model_path,
chat_handler=chat_handler,
n_ctx=4096,
n_gpu_layers=n_gpu_layers,
verbose=False
)
EmAySee_GGUFVisionLocal._CHAT_HANDLER = chat_handler
EmAySee_GGUFVisionLocal._MODEL = llm
EmAySee_GGUFVisionLocal._CURRENT_MODEL_PATH = model_path
EmAySee_GGUFVisionLocal._CURRENT_MMPROJ_PATH = mmproj_path
return EmAySee_GGUFVisionLocal._MODEL
def unload_model(self):
if EmAySee_GGUFVisionLocal._MODEL is not None:
del EmAySee_GGUFVisionLocal._MODEL
del EmAySee_GGUFVisionLocal._CHAT_HANDLER
EmAySee_GGUFVisionLocal._MODEL = None
EmAySee_GGUFVisionLocal._CHAT_HANDLER = None
EmAySee_GGUFVisionLocal._CURRENT_MODEL_PATH = None
EmAySee_GGUFVisionLocal._CURRENT_MMPROJ_PATH = None
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def generate(self, image, gguf_model_path, mmproj_path, system_prompt, user_prompt, max_tokens, temperature, n_gpu_layers, unload_after):
b64_image = self._tensor_to_base64(image)
llm = self.load_model(gguf_model_path, mmproj_path, n_gpu_layers)
if system_prompt.strip() == "":
text_instruction = "Convert the image into a rich image-generation prompt."
else:
text_instruction = system_prompt.strip()
if user_prompt and user_prompt.strip():
text_instruction = f"{text_instruction}\nAdditional requirements:\n{user_prompt.strip()}"
response = llm.create_chat_completion(
messages = [
{"role": "system", "content": system_prompt.strip()},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": b64_image}},
{"type": "text", "text": text_instruction}
]
}
],
max_tokens=max_tokens,
temperature=temperature
)
output_text = response["choices"][0]["message"]["content"]
thinking = ""
prompt = output_text
think_end_tag = "</think>"
if think_end_tag in output_text:
parts = output_text.split(think_end_tag, 1)
thinking = parts[0].replace("<think>", "").strip()
prompt = parts[1].strip()
if unload_after:
self.unload_model()
return (prompt, thinking)
NODE_CLASS_MAPPINGS = {
"EmAySee_GGUFVisionLocal": EmAySee_GGUFVisionLocal
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_GGUFVisionLocal": "EmAySee_GGUF Vision Local Generator"
}
+82
View File
@@ -0,0 +1,82 @@
import torch
import numpy as np
from PIL import Image
import io
from google import genai
from google.genai import types
class EmAySee_GeminiImageGen:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("STRING", {"multiline": False}),
"prompt": ("STRING", {"multiline": True}),
"model": (["gemini-2.0-flash-exp", "gemini-2.5-flash-image", "imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"],),
"aspect_ratio": (["1:1", "16:9", "9:16", "4:3", "3:4"],),
"number_of_images": ("INT", {"default": 1, "min": 1, "max": 4}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "generate_image"
CATEGORY = "EmAySee"
def generate_image(self, api_key, prompt, model, aspect_ratio, number_of_images):
client = genai.Client(api_key=api_key)
try:
if number_of_images > 4:
number_of_images = 4
response = client.models.generate_images(
model=model,
prompt=prompt,
config=types.GenerateImagesConfig(
number_of_images=number_of_images,
aspect_ratio=aspect_ratio,
safety_filter_level="BLOCK_ONLY_HIGH",
person_generation="ALLOW_ADULT",
)
)
output_images = []
if response.generated_images:
for img_entry in response.generated_images:
image_bytes = img_entry.image.image_bytes
image = Image.open(io.BytesIO(image_bytes))
if image.mode != 'RGB':
image = image.convert('RGB')
image_np = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_np)
output_images.append(image_tensor)
if not output_images:
print("No images returned by Gemini.")
return (torch.zeros((1, 512, 512, 3), dtype=torch.float32),)
if len(output_images) > 1:
final_tensor = torch.stack(output_images)
else:
final_tensor = output_images[0].unsqueeze(0)
return (final_tensor,)
except Exception as e:
print(f"Gemini API Error: {e}")
empty = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
return (empty,)
NODE_CLASS_MAPPINGS = {
"EmAySee_GeminiImageGen": EmAySee_GeminiImageGen
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_GeminiImageGen": "EmAySee Gemini Image Gen"
}
+77
View File
@@ -0,0 +1,77 @@
import os
class EmAySee_GlobalStringReader:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"path": ("STRING", {"default": "O:/custom_nodes/storage"}),
"file_name": ("STRING", {"default": "iteration_state"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "read_string"
CATEGORY = "EmAySee/Logic"
def read_string(self, path, file_name):
full_path = os.path.join(path, f"{file_name}.txt")
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
if not os.path.exists(full_path):
return ("",)
with open(full_path, "r", encoding="utf-8") as f:
content = f.read()
return (content,)
class EmAySee_GlobalStringUpdater:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_text": ("STRING", {"forceInput": True}),
"path": ("STRING", {"default": "O:/custom_nodes/storage"}),
"file_name": ("STRING", {"default": "iteration_state"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("passthrough",)
FUNCTION = "update_string"
CATEGORY = "EmAySee/Logic"
def update_string(self, input_text, path, file_name):
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
state_path = os.path.join(path, f"{file_name}.txt")
story_path = os.path.join(path, f"{file_name}-story.txt")
with open(state_path, "w", encoding="utf-8") as f:
f.write(input_text)
with open(story_path, "a", encoding="utf-8") as f:
f.write(input_text + "\n\n")
return (input_text,)
NODE_CLASS_MAPPINGS = {
"EmAySee_GlobalStringReader": EmAySee_GlobalStringReader,
"EmAySee_GlobalStringUpdater": EmAySee_GlobalStringUpdater
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_GlobalStringReader": "EmAySee Global String Reader",
"EmAySee_GlobalStringUpdater": "EmAySee Global String Updater"
}
+39
View File
@@ -0,0 +1,39 @@
import os
class EmAySee_ImageLogger:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"log_directory": ("STRING", {"default": "user/default/emaysee_logs"}),
"log_filename": ("STRING", {"default": "production_log.txt"}),
"history_text": ("STRING", {"forceInput": True}),
"current_prompt": ("STRING", {"forceInput": True}),
"generated_name": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("log_entry",)
FUNCTION = "log_data"
CATEGORY = "EmAySee_Automation/Logging"
OUTPUT_NODE = True
def log_data(self, log_directory, log_filename, history_text, current_prompt, generated_name):
from nodes import get_comfy_root
abs_dir = os.path.join(get_comfy_root(), log_directory)
if not os.path.exists(abs_dir):
os.makedirs(abs_dir, mode=0o777, exist_ok=True)
file_path = os.path.join(abs_dir, log_filename)
entry = f"--- NEW ENTRY ---\nFILE: {generated_name}\nPROMPT: {current_prompt}\nSTATE HISTORY:\n{history_text}\n\n"
with open(file_path, "a", encoding="utf-8") as f:
f.write(entry)
return (entry,)
NODE_CLASS_MAPPINGS = {"EmAySee_ImageLogger": EmAySee_ImageLogger}
NODE_DISPLAY_NAME_MAPPINGS = {"EmAySee_ImageLogger": "EmAySee Image Logger V1"}
+68
View File
@@ -0,0 +1,68 @@
import folder_paths
import numpy as np
from PIL import Image
import os
import time
class EmAySee_ImagePreviewPassthrough:
def __init__(self):
# Get the temp directory from ComfyUI
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "preview_and_passthrough"
CATEGORY = "EmAySee/Utils"
def preview_and_passthrough(self, image):
# 1. --- Prepare images for preview ---
# This logic is adapted from ComfyUI's PreviewImage node
# Convert tensor to PIL images
images = []
for i in range(image.shape[0]):
img_tensor = image[i]
img = (img_tensor * 255.0).cpu().numpy().astype(np.uint8)
images.append(Image.fromarray(img))
# Save images to temp folder and get filenames
preview_images = []
for img in images:
# Generate a unique filename based on current time
filename = f"emaysee_preview_{time.time_ns()}.png"
filepath = os.path.join(self.output_dir, filename)
# Save the image
img.save(filepath, format='PNG', compress_level=4) # Use low compression for speed
# Add to preview list for the UI
preview_images.append({
"filename": filename,
"subfolder": "", # We are using the temp folder directly
"type": self.type
})
# 2. --- Return results ---
# This dictionary structure tells ComfyUI what to display in the UI
# and what to pass to the output port.
return {
"ui": { "images": preview_images }, # This triggers the inline preview
"result": (image,) # This is the passthrough output
}
NODE_CLASS_MAPPINGS = {
"EmAySee_ImagePreviewPassthrough": EmAySee_ImagePreviewPassthrough
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_ImagePreviewPassthrough": "EmAySee Image Preview Passthrough"
}
+37
View File
@@ -0,0 +1,37 @@
import re
class EmAySee_LLMOutputCleaner:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("clean_text",)
FUNCTION = "clean_text"
CATEGORY = "EmAySee/Utils"
def clean_text(self, text):
# 1. Remove <think>...</think> blocks (DeepSeek/Reasoning models)
# flags=re.DOTALL ensures the dot matches newlines inside the think block
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
# 2. Remove common "Here is the thinking process:" preambles if they exist
# (Optional, but helps with some chatty models)
# cleaned = re.sub(r'(?i)here[\'s\s]+the\s+thinking.*?:', '', cleaned)
# 3. Clean up extra whitespace left behind
cleaned = cleaned.strip()
return (cleaned,)
NODE_CLASS_MAPPINGS = {
"EmAySee_LLMOutputCleaner": EmAySee_LLMOutputCleaner
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_LLMOutputCleaner": "EmAySee LLM Output Cleaner"
}
+44
View File
@@ -0,0 +1,44 @@
import os
import torch
import numpy as np
from PIL import Image, ImageOps
import folder_paths
class EmAySee_LoadImagePlusName:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
return {"required": {"image": (sorted(files), {"image_upload": True})}}
CATEGORY = "EmAySee"
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("image", "mask", "filename_stem")
FUNCTION = "load_image"
def load_image(self, image):
image_path = folder_paths.get_annotated_filepath(image)
i = Image.open(image_path)
i = ImageOps.exif_transpose(i)
image_out = i.convert("RGB")
image_out = np.array(image_out).astype(np.float32) / 255.0
image_out = torch.from_numpy(image_out)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1.0 - mask
mask = torch.from_numpy(mask)
else:
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
filename_stem = os.path.splitext(os.path.basename(image))[0]
return (image_out, mask.unsqueeze(0), filename_stem)
NODE_CLASS_MAPPINGS = {
"EmAySee_LoadImagePlusName": EmAySee_LoadImagePlusName
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_LoadImagePlusName": "EmAySee Load Image + Name"
}
+45
View File
@@ -0,0 +1,45 @@
import folder_paths
import comfy.sd
import torch
class EmAySee_LoraLoaderFromInput:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
# This input will connect to the output of the EmAySee Lora Name Selector
"lora_filename": ("LORA",),
"strength_model": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
}
}
RETURN_TYPES = ("MODEL", "CLIP",)
CATEGORY = "EmAySee/Loaders"
FUNCTION = "EmAySee_load_lora"
def EmAySee_load_lora(self, model, clip, lora_filename, strength_model, strength_clip):
# 1. Find the full path of the Lora file using the filename from input
lora_path = folder_paths.get_full_path("loras", lora_filename)
# 2. Check if the file exists before proceeding
if lora_path is None:
# We print an error but return the original model/clip to prevent the workflow from crashing
print(f"[EmAySee_LoraLoaderFromInput] Lora file not found: {lora_filename}. Skipping Lora application.")
return (model, clip)
# 3. Load and apply the Lora using ComfyUI's standard utility function
model_lora, clip_lora = comfy.sd.load_lora(model, clip, lora_path, strength_model, strength_clip)
return (model_lora, clip_lora)
NODE_CLASS_MAPPINGS = {
"EmAySee_LoraLoaderFromInput": EmAySee_LoraLoaderFromInput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_LoraLoaderFromInput": "EmAySee Lora Loader From Input"
}
+146
View File
@@ -0,0 +1,146 @@
import folder_paths
import comfy.sd
import comfy.utils
class EmAySee_LoraNameSelector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"lora_name": (folder_paths.get_filename_list("loras"), ),
}
}
RETURN_TYPES = ("LORA_OBJECT",)
RETURN_NAMES = ("lora_object",)
FUNCTION = "load_lora_data"
CATEGORY = "EmAySee/Loaders"
def load_lora_data(self, lora_name):
lora_path = folder_paths.get_full_path("loras", lora_name)
lora = None
if lora_path is None:
print(f"Lora not found: {lora_name}")
return (None,)
else:
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
return (lora,)
class EmAySee_LoraProcessor:
@classmethod
def INPUT_TYPES(s):
inputs = {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"lora_selector": ("INT", {"default": 1, "min": 1, "max": 9}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
},
"optional": {}
}
for i in range(1, 10):
inputs["optional"][f"lora_object_{i}"] = ("LORA_OBJECT",)
return inputs
RETURN_TYPES = ("MODEL", "CLIP")
FUNCTION = "apply_selected_lora"
CATEGORY = "EmAySee/Loaders"
def apply_selected_lora(self, model, clip, lora_selector, strength_model, strength_clip, **kwargs):
selected_key = f"lora_object_{lora_selector}"
lora_object = kwargs.get(selected_key, None)
if strength_model == 0 and strength_clip == 0:
return (model, clip)
if lora_object is None:
return (model, clip)
model_lora, clip_lora = comfy.sd.load_lora_for_models(model, clip, lora_object, strength_model, strength_clip)
return (model_lora, clip_lora)
class EmAySee_NineChoiceSelector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"selector": ("INT", {"default": 1, "min": 1, "max": 9}),
"label_1": ("STRING", {"multiline": False, "default": "Option 1"}),
"label_2": ("STRING", {"multiline": False, "default": "Option 2"}),
"label_3": ("STRING", {"multiline": False, "default": "Option 3"}),
"label_4": ("STRING", {"multiline": False, "default": "Option 4"}),
"label_5": ("STRING", {"multiline": False, "default": "Option 5"}),
"label_6": ("STRING", {"multiline": False, "default": "Option 6"}),
"label_7": ("STRING", {"multiline": False, "default": "Option 7"}),
"label_8": ("STRING", {"multiline": False, "default": "Option 8"}),
"label_9": ("STRING", {"multiline": False, "default": "Option 9"}),
}
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("selected_int", "selected_label")
FUNCTION = "get_choice"
CATEGORY = "EmAySee/Utils"
def get_choice(self, selector, label_1, label_2, label_3, label_4, label_5, label_6, label_7, label_8, label_9):
labels = [label_1, label_2, label_3, label_4, label_5, label_6, label_7, label_8, label_9]
idx = max(1, min(9, selector)) - 1
return (selector, labels[idx])
class EmAySee_LoraStacker20:
@classmethod
def INPUT_TYPES(s):
inputs = {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
},
"optional": {}
}
# Create inputs for 20 Loras with individual strength controls
for i in range(1, 21):
inputs["optional"][f"lora_{i}"] = ("LORA_OBJECT",)
inputs["required"][f"strength_model_{i}"] = ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01})
inputs["required"][f"strength_clip_{i}"] = ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01})
return inputs
RETURN_TYPES = ("MODEL", "CLIP")
FUNCTION = "apply_stack"
CATEGORY = "EmAySee/Loaders"
def apply_stack(self, model, clip, **kwargs):
current_model = model
current_clip = clip
# Iterate through all 20 possible slots
for i in range(1, 21):
lora_obj = kwargs.get(f"lora_{i}", None)
str_model = kwargs.get(f"strength_model_{i}", 1.0)
str_clip = kwargs.get(f"strength_clip_{i}", 1.0)
# Only apply if a Lora Object is connected to the slot
if lora_obj is not None:
if str_model == 0 and str_clip == 0:
continue
current_model, current_clip = comfy.sd.load_lora_for_models(current_model, current_clip, lora_obj, str_model, str_clip)
return (current_model, current_clip)
NODE_CLASS_MAPPINGS = {
"EmAySee_LoraNameSelector": EmAySee_LoraNameSelector,
"EmAySee_LoraProcessor": EmAySee_LoraProcessor,
"EmAySee_NineChoiceSelector": EmAySee_NineChoiceSelector,
"EmAySee_LoraStacker20": EmAySee_LoraStacker20
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_LoraNameSelector": "EmAySee Lora Selector (Object)",
"EmAySee_LoraProcessor": "EmAySee Lora Processor (9-Input Switch)",
"EmAySee_NineChoiceSelector": "EmAySee Nine Choice Selector",
"EmAySee_LoraStacker20": "EmAySee Lora Stacker (20 Sequential)"
}
+38
View File
@@ -0,0 +1,38 @@
import torch
class EmAySee_LoraSelector:
"""
A universal Lora control node that provides file selection and strength controls
as internal widgets (no inputs) and outputs the values for downstream loaders.
"""
@classmethod
def INPUT_TYPES(s):
# Define the controls as part of the INPUT_TYPES but specify that they should
# be rendered as internal widgets (input_is_widget=True)
return {
"required": {
# This input is used solely for the internal widget rendering.
"lora_name": ("LORA", {"default": "NONE", "input_is_widget": True, "widget_name": "lora_name_select"}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, "input_is_widget": True, "widget_name": "strength_model_select"}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, "input_is_widget": True, "widget_name": "strength_clip_select"}),
},
"optional": {},
}
# The output now includes the filename and the two strength values as separate outputs
RETURN_TYPES = ("LORA", "FLOAT", "FLOAT",)
RETURN_NAMES = ("lora_filename", "strength_model", "strength_clip",)
CATEGORY = "EmAySee/Loaders"
FUNCTION = "EmAySee_select_lora"
def EmAySee_select_lora(self, lora_name, strength_model, strength_clip):
# The node receives values from its internal widgets and passes them out.
return (lora_name, strength_model, strength_clip,)
NODE_CLASS_MAPPINGS = {
"EmAySee_LoraSelector": EmAySee_LoraSelector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_LoraSelector": "EmAySee Lora Name Selector"
}
+52
View File
@@ -0,0 +1,52 @@
import torch
import torch.nn.functional as F
class EmAySee_MaskUnion:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
inputs = {
"required": {},
"optional": {}
}
for i in range(1, 15):
inputs["optional"][f"mask_{i}"] = ("MASK",)
return inputs
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
FUNCTION = "combine"
CATEGORY = "EmAySee/Mask"
def combine(self, **kwargs):
masks = [v for k, v in kwargs.items() if v is not None]
if not masks:
return (torch.zeros((1, 64, 64), dtype=torch.float32),)
target_h = max(m.shape[-2] for m in masks)
target_w = max(m.shape[-1] for m in masks)
res = torch.zeros((1, target_h, target_w), dtype=torch.float32)
for m in masks:
if m.dim() == 2:
m = m.unsqueeze(0)
if m.shape[-2] != target_h or m.shape[-1] != target_w:
m_temp = m.unsqueeze(0) if m.dim() == 3 else m
m = F.interpolate(m_temp, size=(target_h, target_w), mode="bilinear").squeeze(0)
res = torch.max(res, m)
return (res,)
NODE_CLASS_MAPPINGS = {
"EmAySee_MaskUnion": EmAySee_MaskUnion
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_MaskUnion": "EmAySee Mask Union"
}
+54
View File
@@ -0,0 +1,54 @@
import torch
import torch.nn.functional as F
class EmAySee_MegapixelImageScaler:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"target_megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 64.0, "step": 0.1}),
"multiple_of": ("INT", {"default": 8, "min": 1, "max": 64}),
"method": (["nearest-exact", "bilinear", "area", "bicubic", "lanczos"], {"default": "bicubic"}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "FLOAT")
RETURN_NAMES = ("IMAGE", "width", "height", "actual_mp")
FUNCTION = "resize_to_mp"
CATEGORY = "EmAySee/Image"
def resize_to_mp(self, image, target_megapixels, multiple_of, method):
_, old_h, old_w, _ = image.shape
aspect_ratio = old_w / old_h
target_pixels = target_megapixels * 1_000_000
new_h = (target_pixels / aspect_ratio)**0.5
new_w = new_h * aspect_ratio
final_w = int(round(new_w / multiple_of) * multiple_of)
final_h = int(round(new_h / multiple_of) * multiple_of)
img = image.permute(0, 3, 1, 2)
if method == "lanczos":
rescaled_image = F.interpolate(img, size=(final_h, final_w), mode="bicubic", align_corners=False)
else:
rescaled_image = F.interpolate(img, size=(final_h, final_w), mode=method, align_corners=False if method != "nearest-exact" else None)
rescaled_image = rescaled_image.permute(0, 2, 3, 1)
actual_mp = (final_w * final_h) / 1_000_000
return (rescaled_image, final_w, final_h, actual_mp)
NODE_CLASS_MAPPINGS = {
"EmAySee_MegapixelImageScaler": EmAySee_MegapixelImageScaler
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_MegapixelImageScaler": "EmAySee Megapixel Image Resizer"
}
+89
View File
@@ -0,0 +1,89 @@
import comfy.model_management
import json
class EmAySee_ModelVramTracker:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"anything": ("*", {}),
},
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("model_list", "debug_dump", "count")
FUNCTION = "get_models"
CATEGORY = "EmAySee/Utils"
def get_models(self, anything):
loaded_models_info = []
detailed_dump = []
loaded_models = comfy.model_management.current_loaded_models[:]
for idx, m in enumerate(loaded_models):
m_info = {
"index": idx,
"patcher_class": m.__class__.__name__,
"attributes": {},
"model_wrapper": "None",
"inner_model": "None",
"config": "None",
}
for attr in dir(m):
if not attr.startswith("__"):
try:
val = getattr(m, attr)
if isinstance(val, (str, int, float, bool)):
m_info["attributes"][attr] = val
except:
pass
model_obj = getattr(m, "model", None)
if model_obj:
m_info["model_wrapper"] = model_obj.__class__.__name__
inner_model = getattr(model_obj, "model", None)
if inner_model:
m_info["inner_model"] = inner_model.__class__.__name__
if hasattr(model_obj, "model_config"):
m_info["config"] = model_obj.model_config.__class__.__name__
try:
conf_vars = vars(model_obj.model_config)
m_info["config_details"] = {str(k): str(v) for k, v in conf_vars.items() if not k.startswith("_")}
except:
m_info["config_details"] = "Error dumping vars"
for attr in ["model_name", "name", "architecture", "type", "model_id", "sam_model", "predictor", "ckpt_name"]:
if hasattr(model_obj, attr):
val = getattr(model_obj, attr)
m_info[f"found_{attr}"] = str(val.__class__.__name__ if hasattr(val, "__class__") and not isinstance(val, str) else val)
current_name = "Unknown"
if m_info.get("found_model_name"): current_name = m_info["found_model_name"]
elif m_info.get("found_sam_model"): current_name = f"SAM:{m_info['found_sam_model']}"
elif m_info["config"] != "None": current_name = m_info["config"]
elif m_info["inner_model"] != "None": current_name = m_info["inner_model"]
else: current_name = m_info["model_wrapper"]
loaded_models_info.append(current_name)
detailed_dump.append(json.dumps(m_info, indent=2))
if not loaded_models_info:
return ("No models in VRAM", "VRAM Empty", 0)
final_list = "\n".join(loaded_models_info)
final_dump = "\n\n========================================\n\n".join(detailed_dump)
return (final_list, final_dump, len(loaded_models_info))
NODE_CLASS_MAPPINGS = {
"EmAySee_ModelVramTracker": EmAySee_ModelVramTracker
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_ModelVramTracker": "EmAySee Model VRAM Tracker"
}
+72
View File
@@ -0,0 +1,72 @@
class EmAySee_MorphPipeIn:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
labels = [
"Breast Size", "Breast Sag", "Areola Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "PHP",
"Extra 01", "Extra 02", "Extra 03", "Extra 04", "Extra 05",
"Extra 06", "Extra 07", "Extra 08"
]
inputs = {"required": {}}
for label in labels:
inputs["required"][label] = ("FLOAT", {"default": 0.0, "min": -2.0, "max": 3.0, "step": 0.01})
return inputs
RETURN_TYPES = ("MORPH_PIPE",)
RETURN_NAMES = ("morph_pipe",)
FUNCTION = "bundle"
CATEGORY = "EmAySee/Morph"
def bundle(self, **kwargs):
return (kwargs,)
class EmAySee_MorphPipeOut:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"morph_pipe": ("MORPH_PIPE",),
}
}
RETURN_TYPES = ("FLOAT",) * 20
RETURN_NAMES = (
"Breast Size", "Breast Sag", "Areola Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "PHP",
"Extra 01", "Extra 02", "Extra 03", "Extra 04", "Extra 05",
"Extra 06", "Extra 07", "Extra 08"
)
FUNCTION = "expand"
CATEGORY = "EmAySee/Morph"
def expand(self, morph_pipe):
labels = [
"Breast Size", "Breast Sag", "Areola Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "PHP",
"Extra 01", "Extra 02", "Extra 03", "Extra 04", "Extra 05",
"Extra 06", "Extra 07", "Extra 08"
]
results = [morph_pipe.get(label, 0.0) for label in labels]
return tuple(results)
NODE_CLASS_MAPPINGS = {
"EmAySee_MorphPipeIn": EmAySee_MorphPipeIn,
"EmAySee_MorphPipeOut": EmAySee_MorphPipeOut
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_MorphPipeIn": "EmAySee Morph Pipe In",
"EmAySee_MorphPipeOut": "EmAySee Morph Pipe Out"
}
+65
View File
@@ -0,0 +1,65 @@
import torch
class EmAySee_MultiMaskToBBOX:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"mask_1": ("MASK",),
},
"optional": {
"mask_2": ("MASK",),
"mask_3": ("MASK",),
"mask_4": ("MASK",),
"mask_5": ("MASK",),
"mask_6": ("MASK",),
"mask_7": ("MASK",),
"mask_8": ("MASK",),
"mask_9": ("MASK",),
"mask_10": ("MASK",),
}
}
RETURN_TYPES = ("BBOX", "INT", "INT", "INT", "INT")
RETURN_NAMES = ("bbox", "x", "y", "width", "height")
FUNCTION = "extract_bbox"
CATEGORY = "EmAySee/Masking"
def extract_bbox(self, mask_1, **kwargs):
masks = [mask_1]
for i in range(2, 11):
m = kwargs.get(f"mask_{i}")
if m is not None:
masks.append(m)
all_nonzero = []
for mask in masks:
if mask.dim() == 2:
mask = mask.unsqueeze(0)
nonzero = torch.nonzero(mask)
if nonzero.size(0) > 0:
all_nonzero.append(nonzero)
if not all_nonzero:
return ([0, 0, 0, 0], 0, 0, 0, 0)
combined_nonzero = torch.cat(all_nonzero, dim=0)
y_min = torch.min(combined_nonzero[:, 1]).item()
y_max = torch.max(combined_nonzero[:, 1]).item()
x_min = torch.min(combined_nonzero[:, 2]).item()
x_max = torch.max(combined_nonzero[:, 2]).item()
width = (x_max - x_min) + 1
height = (y_max - y_min) + 1
return ([x_min, y_min, width, height], int(x_min), int(y_min), int(width), int(height))
NODE_CLASS_MAPPINGS = {
"EmAySee_MultiMaskToBBOX": EmAySee_MultiMaskToBBOX
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_MultiMaskToBBOX": "EmAySee_Multi Mask to BBOX"
}
+50
View File
@@ -0,0 +1,50 @@
class EmAySee_PromptStateParser:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text_input": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "state")
FUNCTION = "parse"
CATEGORY = "EmAySee/Text"
def parse(self, text_input):
prompt_marker = "PROMPT:"
state_marker = "STATE:"
p_idx = text_input.find(prompt_marker)
s_idx = text_input.find(state_marker)
prompt_out = ""
state_out = ""
if p_idx != -1:
start = p_idx + len(prompt_marker)
if s_idx != -1 and s_idx > p_idx:
prompt_out = text_input[start:s_idx].strip()
else:
prompt_out = text_input[start:].strip()
if s_idx != -1:
start = s_idx + len(state_marker)
if p_idx != -1 and p_idx > s_idx:
state_out = text_input[start:p_idx].strip()
else:
state_out = text_input[start:].strip()
return (prompt_out, state_out)
NODE_CLASS_MAPPINGS = {
"EmAySee_PromptStateParser": EmAySee_PromptStateParser
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_PromptStateParser": "EmAySee Prompt State Parser"
}
+147
View File
@@ -0,0 +1,147 @@
import gc
import torch
import numpy as np
import psutil
from PIL import Image
try:
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration, Qwen2_5_VLForConditionalGeneration, AutoModelForImageTextToText
except ImportError:
AutoProcessor = None
Qwen3VLForConditionalGeneration = None
Qwen2_5_VLForConditionalGeneration = None
AutoModelForImageTextToText = None
class EmAySee_QwenVLLocal:
_MODEL = None
_PROCESSOR = None
_CURRENT_PATH = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model_path": ("STRING", {"default": "/ai/datasets/ComfyUI/models/LLM/DavidAU/gemma-3-4b-it-vl-Heretic-SuperBrain7x-Uncensored"}),
"system_prompt": ("STRING", {"multiline": True, "default": "Analyze this image and generate a detailed image generation prompt. Output only the prompt."}),
"user_prompt": ("STRING", {"multiline": True, "default": ""}),
"max_tokens": ("INT", {"default": 2048, "min": 16, "max": 8192}),
"temperature": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 2.0, "step": 0.05}),
"max_memory_percent": ("FLOAT", {"default": 0.95, "min": 0.10, "max": 1.00, "step": 0.01}),
"unload_after": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "thinking")
FUNCTION = "generate"
CATEGORY = "EmAySee"
def _tensor_to_pil(self, image_tensor):
img0 = image_tensor[0].detach().cpu().float().clamp(0, 1)
arr = (img0.numpy() * 255.0).round().astype(np.uint8)
return Image.fromarray(arr, mode="RGB")
def load_model(self, model_path, max_memory_percent):
if EmAySee_QwenVLLocal._MODEL is not None and EmAySee_QwenVLLocal._CURRENT_PATH == model_path:
return EmAySee_QwenVLLocal._MODEL, EmAySee_QwenVLLocal._PROCESSOR
if AutoProcessor is None:
raise RuntimeError("transformers library is missing or out of date.")
EmAySee_QwenVLLocal._PROCESSOR = AutoProcessor.from_pretrained(model_path)
path_lower = model_path.lower()
if "gemma" in path_lower:
model_class = AutoModelForImageTextToText
elif "qwen2.5" in path_lower or "qwen2-5" in path_lower or "qwen2_5" in path_lower:
model_class = Qwen2_5_VLForConditionalGeneration
elif "qwen" in path_lower:
model_class = Qwen3VLForConditionalGeneration
else:
model_class = AutoModelForImageTextToText
if model_class is None:
raise RuntimeError("Required model architecture not found in transformers. Please update transformers.")
max_mem = {}
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
max_mem[i] = int(torch.cuda.get_device_properties(i).total_memory * max_memory_percent)
max_mem["cpu"] = int(psutil.virtual_memory().total * max_memory_percent)
EmAySee_QwenVLLocal._MODEL = model_class.from_pretrained(
model_path,
torch_dtype=torch.bfloat16,
device_map="auto",
max_memory=max_mem
)
EmAySee_QwenVLLocal._MODEL.eval()
EmAySee_QwenVLLocal._CURRENT_PATH = model_path
return EmAySee_QwenVLLocal._MODEL, EmAySee_QwenVLLocal._PROCESSOR
def unload_model(self):
EmAySee_QwenVLLocal._MODEL = None
EmAySee_QwenVLLocal._PROCESSOR = None
EmAySee_QwenVLLocal._CURRENT_PATH = None
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
def generate(self, image, model_path, system_prompt, user_prompt, max_tokens, temperature, max_memory_percent, unload_after):
pil_image = self._tensor_to_pil(image)
model, processor = self.load_model(model_path, max_memory_percent)
messages = [
{"role": "system", "content": [{"type": "text", "text": system_prompt.strip()}]},
{"role": "user", "content": [
{"type": "image", "image": pil_image},
{"type": "text", "text": user_prompt.strip()}
]}
]
inputs = processor.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt"
)
inputs = inputs.to(model.device)
gen_kwargs = {"max_new_tokens": int(max_tokens)}
if temperature > 0:
gen_kwargs.update({"do_sample": True, "temperature": float(temperature), "top_p": 0.9})
else:
gen_kwargs.update({"do_sample": False})
with torch.inference_mode():
output_ids = model.generate(**inputs, **gen_kwargs)
trimmed_ids = [out_id[len(in_id):] for in_id, out_id in zip(inputs.input_ids, output_ids)]
output_text = processor.batch_decode(trimmed_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False)[0]
thinking = ""
prompt = output_text
think_end_tag = "</think>"
if think_end_tag in output_text:
parts = output_text.split(think_end_tag, 1)
thinking = parts[0].replace("<think>", "").strip()
prompt = parts[1].strip()
if unload_after:
self.unload_model()
return (prompt, thinking)
NODE_CLASS_MAPPINGS = {
"EmAySee_QwenVLLocal": EmAySee_QwenVLLocal
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_QwenVLLocal": "EmAySee_Vision LLM Local Generator"
}
+65
View File
@@ -0,0 +1,65 @@
import torch
import torch.nn.functional as F
class EmAySee_QwenPixelAligner:
@classmethod
def INPUT_TYPES(s):
# Generate the list of resolutions from 784 to 2240 in steps of 112
resolutions = [str(x) for x in range(784, 2241, 112)]
return {
"required": {
"image": ("IMAGE",),
"longest_edge": (resolutions, {"default": "1232"}),
"alignment": (["112", "56", "224"], {"default": "112"}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING")
RETURN_NAMES = ("IMAGE", "width", "height", "resolution_text")
FUNCTION = "align_and_resize"
CATEGORY = "EmAySee/Image"
def align_and_resize(self, image, longest_edge, alignment):
align_val = int(alignment)
target_edge = int(longest_edge)
B, H, W, C = image.shape
# Calculate aspect ratio and new dimensions
current_longest = max(H, W)
# Scale factor to match the longest edge target
scale_factor = target_edge / current_longest
target_h = H * scale_factor
target_w = W * scale_factor
# Snap dimensions to the nearest alignment multiple
new_height = int(round(target_h / align_val) * align_val)
new_width = int(round(target_w / align_val) * align_val)
# Ensure dimensions are at least one alignment block
new_height = max(align_val, new_height)
new_width = max(align_val, new_width)
# Permute image to [B, C, H, W] for interpolation
img_permuted = image.permute(0, 3, 1, 2)
# Resize using bicubic interpolation
resized_img = F.interpolate(img_permuted, size=(new_height, new_width), mode="bicubic", align_corners=False)
# Permute back to [B, H, W, C]
final_image = resized_img.permute(0, 2, 3, 1)
# Create resolution string
res_text = f"{new_width}x{new_height}"
return (final_image, new_width, new_height, res_text)
NODE_CLASS_MAPPINGS = {
"EmAySee_QwenPixelAligner": EmAySee_QwenPixelAligner
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_QwenPixelAligner": "EmAySee Qwen Pixel Aligner"
}
+475
View File
@@ -0,0 +1,475 @@
import base64
import json
import re
import os
from io import BytesIO
from typing import Dict, Tuple
import gc
import numpy as np
import torch
from PIL import Image
import requests
try:
from transformers import AutoProcessor
from transformers import Qwen2_5_VLForConditionalGeneration
from transformers import Qwen3VLForConditionalGeneration
except Exception:
AutoProcessor = None
Qwen2_5_VLForConditionalGeneration = None
Qwen3VLForConditionalGeneration = None
def _tensor_image_to_pil(image_tensor: torch.Tensor) -> Image.Image:
if not isinstance(image_tensor, torch.Tensor):
raise TypeError("IMAGE input must be a torch.Tensor")
if image_tensor.ndim != 4 or image_tensor.shape[-1] != 3:
raise ValueError(f"Expected IMAGE with shape [B,H,W,3], got {tuple(image_tensor.shape)}")
img0 = image_tensor[0].detach().cpu().float().clamp(0, 1)
arr = (img0.numpy() * 255.0).round().astype(np.uint8)
return Image.fromarray(arr, mode="RGB")
def _clean_single_line(text: str) -> str:
text = text.strip()
text = text.replace("\r", " ").replace("\n", " ")
text = re.sub(r"\s+", " ", text).strip()
if len(text) >= 2 and ((text[0] == '"' and text[-1] == '"') or (text[0] == "'" and text[-1] == "'")):
text = text[1:-1].strip()
return text
def _available_devices() -> Tuple[str, ...]:
devices = []
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
name = torch.cuda.get_device_name(i)
devices.append(f"cuda:{i} | {name}")
devices.append("cpu")
return tuple(devices)
def _parse_device_choice(device_choice: str) -> str:
return device_choice.split("|", 1)[0].strip()
def _cuda_cleanup(device_index: int | None):
gc.collect()
if torch.cuda.is_available():
if device_index is None:
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
else:
with torch.cuda.device(device_index):
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
def _resolve_model_family(model_id: str) -> str:
model_lower = model_id.lower()
if "qwen2.5" in model_lower:
return "qwen2_5_vl"
elif "qwen3" in model_lower:
return "qwen3_vl"
else:
raise ValueError(f"Unknown model ID: {model_id}")
class EmAySee_QwenPromptFromImage:
_MODEL_CACHE: Dict[Tuple[str, str, str, str], object] = {}
_PROC_CACHE: Dict[str, object] = {}
@classmethod
def INPUT_TYPES(cls):
default_system = (
"You convert the given image into a rich, image-generation prompt.\n"
"Return ONLY the final prompt as a single line, no quotes, no extra text.\n"
"Include: subject, environment, style, lighting, camera/lens, composition, key details.\n"
"Avoid meta-commentary."
)
model_list = (
"Qwen/Qwen2.5-VL-3B-Instruct",
"Qwen/Qwen2.5-VL-7B-Instruct",
"Qwen/Qwen3-VL-2B-Instruct",
"Qwen/Qwen3-VL-2B-Thinking",
"Qwen/Qwen3-VL-8B-Instruct",
"Qwen/Qwen3-VL-8B-Thinking",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-VL-235B-A22B-Thinking",
)
return {
"required": {
"image": ("IMAGE",),
"backend": (("local", "openai_compatible"),),
"qwen_model": (model_list,),
"local_model_path": ("STRING", {"default": ""}),
"device": (_available_devices(),),
"quantization": ((
"int8_bnb",
"int4_bnb",
"bf16",
"fp16",
"fp32_cpu",
),),
"flash_attention": (("off", "on"),),
"system_prompt": ("STRING", {"multiline": True, "default": default_system}),
"user_prompt": ("STRING", {"multiline": True, "default": ""}),
"max_new_tokens": ("INT", {"default": 2048, "min": 16, "max": 8192, "step": 1}),
"temperature": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 2.0, "step": 0.05}),
"safe_quants": ([True, False], {"default": True}),
"unload_model": ([True, False], {"default": True}),
},
"optional": {
"openai_base_url": ("STRING", {"default": "http://127.0.0.1:11434"}),
"openai_api_key": ("STRING", {"default": ""}),
"openai_model_override": ("STRING", {"default": ""}),
"openrouter_providers": ("STRING", {"default": ""}),
"verbose_output": ([True, False], {"default": True}),
}
}
RETURN_TYPES = ("STRING","STRING")
RETURN_NAMES = ("prompt_text", "thinking_text")
FUNCTION = "run"
CATEGORY = "d3cker/Prompt-Generator"
def _get_processor(self, model_id: str, load_path: str):
if AutoProcessor is None:
raise RuntimeError("transformers is missing or too old.")
if self._PROC_CACHE.get(load_path) is None:
self._PROC_CACHE[load_path] = AutoProcessor.from_pretrained(load_path)
return self._PROC_CACHE[load_path]
def _load_local_model(self, model_id: str, load_path: str, device_choice: str, quantization: str, flash_attention: str, safe_quants: bool):
model_family = _resolve_model_family(model_id)
if Qwen2_5_VLForConditionalGeneration is None or Qwen3VLForConditionalGeneration is None:
raise RuntimeError("transformers is missing or too old.")
device = _parse_device_choice(device_choice)
if safe_quants:
if device == "cpu" and quantization != "fp32_cpu":
quantization = "fp32_cpu"
elif device.lower().startswith("cuda"):
if model_family == "qwen2_5_vl" and (quantization == "fp16" or quantization == "fp32_cpu"):
quantization = "bf16"
elif model_family == "qwen3_vl" and quantization == "fp32_cpu":
quantization = "bf16"
if quantization == "fp32_cpu":
dtype = torch.float32
elif quantization == "bf16":
dtype = torch.bfloat16
elif quantization == "fp16":
dtype = torch.float16
else:
dtype = torch.bfloat16 if (device.startswith("cuda") and torch.cuda.is_available()) else torch.float32
dtype_key = str(dtype).replace("torch.", "")
cache_key = (load_path, quantization, device, dtype_key)
kwargs = {
"torch_dtype": dtype,
"device_map": "auto" if device.startswith("cuda") else {"": device},
}
cached = self._MODEL_CACHE.get(cache_key)
if cached is not None:
return cached
if quantization in ("int8_bnb", "int4_bnb"):
if not device.startswith("cuda"):
raise RuntimeError("bitsandbytes requires a CUDA device.")
try:
from transformers import BitsAndBytesConfig
import bitsandbytes
except Exception as e:
raise RuntimeError("BitsAndBytes missing.") from e
if quantization == "int8_bnb":
qconf = BitsAndBytesConfig(load_in_8bit=True)
else:
qconf = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=dtype,
)
kwargs["quantization_config"] = qconf
if flash_attention == "on":
kwargs["attn_implementation"] = "flash_attention_2"
if model_family == "qwen2_5_vl":
self._MODEL_CACHE[cache_key] = Qwen2_5_VLForConditionalGeneration.from_pretrained(load_path, **kwargs)
elif model_family == "qwen3_vl":
self._MODEL_CACHE[cache_key] = Qwen3VLForConditionalGeneration.from_pretrained(load_path, **kwargs)
self._MODEL_CACHE[cache_key].eval()
return self._MODEL_CACHE[cache_key]
def _generate_local(
self,
image_pil: Image.Image,
model_id: str,
load_path: str,
device_choice: str,
quantization: str,
flash_attention: str,
system_prompt: str,
user_prompt: str,
max_new_tokens: int,
temperature: float,
safe_quants: bool,
unload_model: bool,
) -> str:
model = self._load_local_model(model_id, load_path, device_choice, quantization, flash_attention, safe_quants)
processor = self._get_processor(model_id, load_path)
if system_prompt.strip() == "":
text_instruction = "Convert the image into a rich image-generation prompt."
else:
text_instruction = system_prompt.strip()
if user_prompt and user_prompt.strip():
text_instruction = f"{text_instruction}\nAdditional requirements:\n{user_prompt.strip()}"
messages = [
{
"role": "system",
"content": [{"type": "text", "text": system_prompt.strip()}],
},
{
"role": "user",
"content": [
{"type": "image", "image": image_pil},
{"type": "text", "text": text_instruction},
],
},
]
inputs = processor.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
)
inputs = inputs.to(model.device)
gen_kwargs = {
"max_new_tokens": int(max_new_tokens),
}
if temperature and temperature > 0:
gen_kwargs.update({"do_sample": True, "temperature": float(temperature), "top_p": 0.9})
else:
gen_kwargs.update({"do_sample": False})
with torch.inference_mode():
out_ids = model.generate(**inputs, **gen_kwargs)
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, out_ids)]
out_text = processor.batch_decode(
trimmed,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
sep = "</think>"
idx = out_text.find(sep)
if idx != -1:
pstart = idx + len(sep)
prompt = out_text[pstart:]
reasoning = out_text[:idx]
else:
reasoning = ""
prompt = out_text
if unload_model:
for key in self._MODEL_CACHE:
self._MODEL_CACHE[key] = None
for key in self._PROC_CACHE:
self._PROC_CACHE[key] = None
model = None
processor = None
inputs = None
_cuda_cleanup(None)
return _clean_single_line(prompt), reasoning
def _generate_openai_compatible(
self,
image_pil: Image.Image,
model_id: str,
base_url: str,
api_key: str,
model_override: str,
system_prompt: str,
user_prompt: str,
max_new_tokens: int,
temperature: float,
providers: str = "",
verbose: bool = True,
) -> str:
buf = BytesIO()
image_pil.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
data_url = f"data:image/png;base64,{b64}"
if system_prompt.strip() == "":
text_instruction = "Convert the image into a rich image-generation prompt."
else:
text_instruction = system_prompt.strip()
if user_prompt and user_prompt.strip():
text_instruction = f"{text_instruction}\nAdditional requirements:\n{user_prompt.strip()}"
url = base_url.rstrip("/") + "/v1/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key and api_key.strip():
headers["Authorization"] = f"Bearer {api_key.strip()}"
payload = {
"model": (model_override.strip() if model_override and model_override.strip() else model_id),
"messages": [
{"role": "system", "content": system_prompt.strip()},
{
"role": "user",
"content": [
{"type": "text", "text": text_instruction},
{"type": "image_url", "image_url": {"url": data_url}},
],
},
],
"temperature": float(temperature),
"max_tokens": int(max_new_tokens),
}
if providers and providers.strip():
provider_list = [p.strip() for p in providers.split(",") if p.strip()]
if provider_list:
payload["provider"] = {
"order": provider_list,
"allow_fallbacks": False
}
resp = requests.post(url, headers=headers, data=json.dumps(payload), timeout=600)
if resp.status_code != 200:
raise RuntimeError(f"OpenAI-compatible endpoint error {resp.status_code}: {resp.text}")
data = resp.json()
if verbose:
provider = data.get("provider", "unknown")
model = data.get("model", "unknown")
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", 0)
cost = usage.get("cost", 0)
cached_tokens = usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)
lines = [
f"Provider: {provider}",
f"Model: {model}",
f"Tokens: {prompt_tokens} prompt + {completion_tokens} completion = {total_tokens} total",
]
if cached_tokens > 0:
lines.append(f"Cached: {cached_tokens} tokens")
lines.append(f"Cost: ${cost:.6f}")
width = max(len(line) for line in lines) + 2
title = " OpenAI API Response "
top_border = f"┌{title}{'─' * (width - len(title))}┐"
bottom_border = f"└{'─' * width}┘"
print(f"\n{top_border}")
for line in lines:
print(f"│ {line.ljust(width - 1)}│")
print(f"{bottom_border}\n")
reasoning = (
data.get("choices", [{}])[0]
.get("message", {})
.get("reasoning")
)
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content")
)
if reasoning == None:
reasoning = ""
return _clean_single_line(content), reasoning
def run(
self,
image,
backend,
qwen_model,
local_model_path,
device,
quantization,
flash_attention,
system_prompt,
user_prompt,
max_new_tokens,
temperature,
safe_quants,
unload_model,
openai_base_url="http://127.0.0.1:11434",
openai_api_key="",
openai_model_override="",
openrouter_providers="",
verbose_output=True,
):
image_pil = _tensor_image_to_pil(image)
load_path = local_model_path.strip() if local_model_path.strip() else qwen_model
if backend == "openai_compatible":
if not openai_api_key or not openai_api_key.strip():
if "OPENROUTER_API_KEY" in os.environ:
openai_api_key = os.environ.get("OPENROUTER_API_KEY", "")
if openai_base_url in ("http://127.0.0.1:11434", "") or not openai_base_url.strip():
openai_base_url = "https://openrouter.ai/api"
elif "OPENAI_API_KEY" in os.environ:
openai_api_key = os.environ.get("OPENAI_API_KEY", "")
prompt, reasoning = self._generate_openai_compatible(
image_pil=image_pil,
model_id=qwen_model,
base_url=openai_base_url,
api_key=openai_api_key,
model_override=openai_model_override,
system_prompt=system_prompt,
user_prompt=user_prompt,
max_new_tokens=max_new_tokens,
temperature=temperature,
providers=openrouter_providers,
verbose=verbose_output,
)
else:
prompt, reasoning = self._generate_local(
image_pil=image_pil,
model_id=qwen_model,
load_path=load_path,
device_choice=device,
quantization=quantization,
flash_attention=flash_attention,
system_prompt=system_prompt,
user_prompt=user_prompt,
max_new_tokens=max_new_tokens,
temperature=temperature,
safe_quants=safe_quants,
unload_model=unload_model
)
return (prompt,reasoning,)
NODE_CLASS_MAPPINGS = {
"EmAySee_QwenPromptFromImage": EmAySee_QwenPromptFromImage,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_QwenPromptFromImage": "EmAySee_Qwen / OpenAI Prompt From Image",
}
+70
View File
@@ -0,0 +1,70 @@
import torch
import torch.nn.functional as F
class EmAySee_QwenResolutionOptimizer:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"scale_multiplier": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 4.0, "step": 0.1}),
"method": (["crop", "stretch"], {"default": "crop"}),
"upscale_method": (["bicubic", "nearest-exact", "bilinear", "area"], {"default": "bicubic"}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING")
RETURN_NAMES = ("IMAGE", "width", "height", "aspect_ratio_name")
FUNCTION = "optimize"
CATEGORY = "EmAySee_Nodes/Image/Alignment"
def optimize(self, image, scale_multiplier, method, upscale_method):
B, H, W, C = image.shape
input_aspect = W / H
targets = [
(1.0, 1008, 1008, "1:1 Square"),
(1.25, 1120, 896, "4:3 Landscape"),
(1.375, 1232, 896, "3:2 Classic"),
(1.714, 1344, 784, "16:9 Widescreen"),
(0.8, 896, 1120, "3:4 Portrait"),
(0.727, 896, 1232, "2:3 Classic Portrait"),
(0.583, 784, 1344, "9:16 Vertical")
]
best_match = min(targets, key=lambda x: abs(x[0] - input_aspect))
base_w, base_h, ratio_name = best_match[1], best_match[2], best_match[3]
target_w = int(round((base_w * scale_multiplier) / 112) * 112)
target_h = int(round((base_h * scale_multiplier) / 112) * 112)
if target_w == 0: target_w = 112
if target_h == 0: target_h = 112
samples = image.movedim(-1, 1)
if method == "crop":
target_aspect = base_w / base_h
if input_aspect > target_aspect:
new_w = int(H * target_aspect)
offset = (W - new_w) // 2
samples = samples[:, :, :, offset:offset + new_w]
else:
new_h = int(W / target_aspect)
offset = (H - new_h) // 2
samples = samples[:, :, offset:offset + new_h, :]
samples = F.interpolate(samples, size=(target_h, target_w), mode=upscale_method)
samples = samples.movedim(1, -1)
final_info = f"{ratio_name} @ {scale_multiplier}x ({target_w}x{target_h})"
return (samples, target_w, target_h, final_info)
NODE_CLASS_MAPPINGS = {
"EmAySee_QwenResolutionOptimizer": EmAySee_QwenResolutionOptimizer
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_QwenResolutionOptimizer": "EmAySee_ Qwen Resolution Optimizer"
}
+46
View File
@@ -0,0 +1,46 @@
import random
class EmAySee_RandomStringSelector:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
inputs = {
"required": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {}
}
for i in range(1, 21):
inputs["optional"][f"string_{i}"] = ("STRING",)
return inputs
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_string",)
FUNCTION = "select_random_string"
CATEGORY = "EmAySee/Utils"
def select_random_string(self, seed, **kwargs):
valid_strings = []
for i in range(1, 21):
s = kwargs.get(f"string_{i}", None)
if s is not None:
valid_strings.append(s)
selected_string = ""
if valid_strings:
random.seed(seed)
selected_string = random.choice(valid_strings)
return (selected_string,)
NODE_CLASS_MAPPINGS = {
"EmAySee_RandomStringSelector": EmAySee_RandomStringSelector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_RandomStringSelector": "EmAySee Random String Selector (20)"
}
+46
View File
@@ -0,0 +1,46 @@
class EmAySee_SegsUnion:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
inputs = {
"required": {},
"optional": {
"fallback_segs": ("SEGS",),
}
}
for i in range(1, 11):
inputs["optional"][f"segs_{i}"] = ("SEGS",)
return inputs
RETURN_TYPES = ("SEGS",)
RETURN_NAMES = ("segs",)
FUNCTION = "combine"
CATEGORY = "EmAySee/Segs"
def combine(self, fallback_segs=None, **kwargs):
combined_items = []
base_shape = None
for i in range(1, 11):
seg_input = kwargs.get(f"segs_{i}")
if seg_input is not None:
if base_shape is None:
base_shape = seg_input[0]
combined_items.extend(seg_input[1])
if not combined_items:
if fallback_segs is not None:
return (fallback_segs,)
return ((base_shape if base_shape else (64, 64), []),)
return ((base_shape, combined_items),)
NODE_CLASS_MAPPINGS = {
"EmAySee_SegsUnion": EmAySee_SegsUnion
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SegsUnion": "EmAySee Segs Union"
}
+199
View File
@@ -0,0 +1,199 @@
import torch
import gc
import comfy.model_management
import warnings
warnings.filterwarnings("ignore", category=FutureWarning, module="torch.distributed")
warnings.filterwarnings("ignore", category=UserWarning, module="torch.nn.modules.module")
class EmAySee_SelectiveModelUnloader:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"model_name_filter": ("STRING", {"default": "ModelConfig"}),
"deep_gc_scan": ("BOOLEAN", {"default": False}),
"exclude_checkpoints": ("BOOLEAN", {"default": True}),
"unload_mode": (["move_to_cpu", "delete_references"], {"default": "move_to_cpu"}),
"purge_cache": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "STRING")
RETURN_NAMES = ("image", "unloaded_count", "debug_info")
FUNCTION = "unload_specific"
CATEGORY = "EmAySee/Utils"
def unload_specific(self, image, model_name_filter, deep_gc_scan, exclude_checkpoints, unload_mode, purge_cache):
unloaded_count = 0
debug_logs = []
filters = [f.strip().lower() for f in model_name_filter.split(",")]
CHECKPOINT_TYPES = [
"SD15", "SD21", "SDXL", "SDXLRefiner", "SVD_img2vid",
"Flux", "SD3", "Stable_Cascade", "SD_ASR", "PlaygroundV2", "Hyvid", "Wan", "HunyuanVideo"
]
remaining_models = []
loaded_models = comfy.model_management.current_loaded_models[:]
for m in loaded_models:
model_obj = m.model
inner_model = getattr(model_obj, "model", None)
meta_name = "Unknown"
is_checkpoint = False
if hasattr(model_obj, "model_config"):
meta_name = model_obj.model_config.__class__.__name__
if any(cp_type in meta_name for cp_type in CHECKPOINT_TYPES):
is_checkpoint = True
elif inner_model:
meta_name = inner_model.__class__.__name__
else:
meta_name = model_obj.__class__.__name__
should_unload = (model_name_filter == "*" or
any(f in meta_name.lower() for f in filters))
if should_unload:
if exclude_checkpoints and is_checkpoint:
debug_logs.append(f"Skipping Protected Checkpoint: {meta_name}")
remaining_models.append(m)
else:
debug_logs.append(f"Standard Unload: {meta_name}")
m.model_unload()
unloaded_count += 1
else:
remaining_models.append(m)
comfy.model_management.current_loaded_models = remaining_models
if deep_gc_scan:
for obj in list(gc.get_objects()):
try:
if isinstance(obj, torch.nn.Module):
class_name = obj.__class__.__name__
is_gpu = False
try:
test_tensor = next(obj.parameters(), next(obj.buffers(), None))
if test_tensor is not None and "cuda" in str(test_tensor.device):
is_gpu = True
except (StopIteration, ReferenceError):
pass
if is_gpu:
is_cp_hidden = any(cp_type in class_name for cp_type in CHECKPOINT_TYPES)
if model_name_filter == "*" or any(f in class_name.lower() for f in filters):
if exclude_checkpoints and is_cp_hidden:
continue
obj.to("cpu")
unloaded_count += 1
except:
continue
if purge_cache:
gc.collect()
comfy.model_management.soft_empty_cache()
# Replicating the "Free model and node cache" button backend logic:
if not exclude_checkpoints:
comfy.model_management.unload_all_models()
if hasattr(comfy.model_management, "loaded_models"):
comfy.model_management.loaded_models.clear()
# Clear the execution cache (Node Cache)
if hasattr(comfy.model_management, "node_cache"):
comfy.model_management.node_cache.clear()
# Brute force memory cleanup
try:
comfy.model_management.cleanup_models(keep_free_mem=0)
except:
pass
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
elif unloaded_count > 0:
gc.collect()
comfy.model_management.soft_empty_cache()
final_debug = "\n".join(debug_logs) if debug_logs else "No matches found."
return (image, unloaded_count, final_debug)
class EmAySee_SelectiveModelReloader:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"model_name_filter": ("STRING", {"default": "SamImage"}),
"force_bfloat16": ("BOOLEAN", {"default": False}),
"purge_cache": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "STRING")
RETURN_NAMES = ("image", "reloaded_count", "debug_info")
FUNCTION = "reload_specific"
CATEGORY = "EmAySee/Utils"
def reload_specific(self, image, model_name_filter, force_bfloat16, purge_cache):
reloaded_count = 0
debug_logs = []
device = comfy.model_management.get_torch_device()
filters = [f.strip().lower() for f in model_name_filter.split(",")]
for obj in list(gc.get_objects()):
try:
if isinstance(obj, torch.nn.Module):
class_name = obj.__class__.__name__
if any(f in class_name.lower() for f in filters):
is_cpu = False
try:
test_tensor = next(obj.parameters(), next(obj.buffers(), None))
if test_tensor is not None and "cpu" in str(test_tensor.device):
is_cpu = True
except (StopIteration, ReferenceError):
pass
if is_cpu:
debug_logs.append(f"Moving back to GPU: {class_name}")
if force_bfloat16:
obj.to(device=device, dtype=torch.bfloat16)
else:
obj.to(device)
reloaded_count += 1
except:
continue
if purge_cache:
gc.collect()
comfy.model_management.soft_empty_cache()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
elif reloaded_count > 0:
comfy.model_management.soft_empty_cache()
final_debug = "\n".join(debug_logs) if debug_logs else "No models needed reloading."
return (image, reloaded_count, final_debug)
NODE_CLASS_MAPPINGS = {
"EmAySee_SelectiveModelUnloader": EmAySee_SelectiveModelUnloader,
"EmAySee_SelectiveModelReloader": EmAySee_SelectiveModelReloader
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SelectiveModelUnloader": "EmAySee Selective Model Unloader",
"EmAySee_SelectiveModelReloader": "EmAySee Selective Model Reloader"
}
+171
View File
@@ -0,0 +1,171 @@
import torch
import gc
import comfy.model_management
class EmAySee_SelectiveModelUnloader:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"anything": ("*", {}),
"model_name_filter": ("STRING", {"default": "ModelConfig"}),
"deep_gc_scan": ("BOOLEAN", {"default": False}),
"exclude_checkpoints": ("BOOLEAN", {"default": True}),
"unload_mode": (["move_to_cpu", "delete_references"], {"default": "move_to_cpu"}),
},
}
RETURN_TYPES = ("*", "INT", "STRING")
RETURN_NAMES = ("output", "unloaded_count", "debug_info")
FUNCTION = "unload_specific"
CATEGORY = "EmAySee/Utils"
def unload_specific(self, anything, model_name_filter, deep_gc_scan, exclude_checkpoints, unload_mode):
unloaded_count = 0
debug_logs = []
CHECKPOINT_TYPES = [
"SD15", "SD21", "SDXL", "SDXLRefiner", "SVD_img2vid",
"Flux", "SD3", "Stable_Cascade", "SD_ASR", "PlaygroundV2"
]
remaining_models = []
loaded_models = comfy.model_management.current_loaded_models[:]
for m in loaded_models:
model_obj = m.model
inner_model = getattr(model_obj, "model", None)
meta_name = "Unknown"
is_checkpoint = False
if hasattr(model_obj, "model_config"):
meta_name = model_obj.model_config.__class__.__name__
if any(cp_type in meta_name for cp_type in CHECKPOINT_TYPES):
is_checkpoint = True
elif inner_model:
meta_name = inner_model.__class__.__name__
else:
meta_name = model_obj.__class__.__name__
should_unload = (model_name_filter == "*" or
model_name_filter.lower() in meta_name.lower())
if should_unload:
if exclude_checkpoints and is_checkpoint:
debug_logs.append(f"Skipping Protected Checkpoint: {meta_name}")
remaining_models.append(m)
else:
debug_logs.append(f"Standard Unload: {meta_name}")
m.model_unload()
unloaded_count += 1
else:
remaining_models.append(m)
comfy.model_management.current_loaded_models = remaining_models
if deep_gc_scan:
debug_logs.append(f"--- Deep GC Scan (Mode: {unload_mode}) ---")
for obj in gc.get_objects():
try:
if isinstance(obj, torch.nn.Module):
class_name = obj.__class__.__name__
is_gpu = False
try:
param = next(obj.parameters(), None)
if param is not None and param.device.type == 'cuda':
is_gpu = True
except:
pass
if is_gpu:
is_cp_hidden = any(cp_type in class_name for cp_type in CHECKPOINT_TYPES)
if model_name_filter == "*" or model_name_filter.lower() in class_name.lower():
if exclude_checkpoints and is_cp_hidden:
debug_logs.append(f"Skipping Protected Hidden Checkpoint: {class_name}")
continue
if unload_mode == "move_to_cpu":
debug_logs.append(f"Evicting to CPU: {class_name}")
obj.to("cpu")
else:
debug_logs.append(f"Nuking reference: {class_name}")
obj.to("cpu")
unloaded_count += 1
except:
continue
if unloaded_count > 0:
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
final_debug = "\n".join(debug_logs) if debug_logs else "No matches found."
return (anything, unloaded_count, final_debug)
class EmAySee_SelectiveModelReloader:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"anything": ("*", {}),
"model_name_filter": ("STRING", {"default": "SamImage"}),
},
}
RETURN_TYPES = ("*", "INT", "STRING")
RETURN_NAMES = ("output", "reloaded_count", "debug_info")
FUNCTION = "reload_specific"
CATEGORY = "EmAySee/Utils"
def reload_specific(self, anything, model_name_filter):
reloaded_count = 0
debug_logs = []
for obj in gc.get_objects():
try:
if isinstance(obj, torch.nn.Module):
class_name = obj.__class__.__name__
if model_name_filter.lower() in class_name.lower():
is_cpu = False
try:
param = next(obj.parameters(), None)
if param is not None and param.device.type == 'cpu':
is_cpu = True
except:
pass
if is_cpu:
debug_logs.append(f"Moving back to GPU: {class_name}")
obj.to(comfy.model_management.get_torch_device())
reloaded_count += 1
except:
continue
if reloaded_count > 0:
if torch.cuda.is_available():
torch.cuda.empty_cache()
final_debug = "\n".join(debug_logs) if debug_logs else "No models needed reloading."
return (anything, reloaded_count, final_debug)
NODE_CLASS_MAPPINGS = {
"EmAySee_SelectiveModelUnloader": EmAySee_SelectiveModelUnloader,
"EmAySee_SelectiveModelReloader": EmAySee_SelectiveModelReloader
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SelectiveModelUnloader": "EmAySee Selective Model Unloader",
"EmAySee_SelectiveModelReloader": "EmAySee Selective Model Reloader"
}
+91
View File
@@ -0,0 +1,91 @@
import base64
from io import BytesIO
from PIL import Image
import torch
import numpy as np
import requests
import json
class EmAySee_SpectreAPIConnector:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"spectre_url": ("STRING", {"default": "http://10.0.0.71:11434/v1/chat/completions"}),
"model_name": ("STRING", {"default": "qwen3-vl"}),
"system_prompt": ("STRING", {"multiline": True, "default": "Analyze this image and generate a detailed image generation prompt. Output only the prompt."}),
"user_prompt": ("STRING", {"multiline": True, "default": ""}),
"max_tokens": ("INT", {"default": 2048, "min": 16, "max": 8192}),
"temperature": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 2.0, "step": 0.05}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "thinking")
FUNCTION = "generate"
CATEGORY = "EmAySee"
def _tensor_to_base64(self, image_tensor):
img0 = image_tensor[0].detach().cpu().float().clamp(0, 1)
arr = (img0.numpy() * 255.0).round().astype(np.uint8)
pil_img = Image.fromarray(arr, mode="RGB")
buffered = BytesIO()
pil_img.save(buffered, format="JPEG")
return base64.b64encode(buffered.getvalue()).decode("utf-8")
def generate(self, image, spectre_url, model_name, system_prompt, user_prompt, max_tokens, temperature):
b64_image = self._tensor_to_base64(image)
data_url = f"data:image/jpeg;base64,{b64_image}"
text_instruction = system_prompt.strip()
if user_prompt and user_prompt.strip():
text_instruction = f"{text_instruction}\nAdditional requirements:\n{user_prompt.strip()}"
payload = {
"model": model_name,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": text_instruction},
{"type": "image_url", "image_url": {"url": data_url}}
]
}
],
"max_tokens": max_tokens,
"temperature": temperature
}
headers = {"Content-Type": "application/json"}
try:
response = requests.post(spectre_url, headers=headers, data=json.dumps(payload), timeout=600)
response.raise_for_status()
response_data = response.json()
output_text = response_data["choices"][0]["message"]["content"]
thinking = ""
prompt = output_text
think_end_tag = "</think>"
if think_end_tag in output_text:
parts = output_text.split(think_end_tag, 1)
thinking = parts[0].replace("<think>", "").strip()
prompt = parts[1].strip()
return (prompt, thinking)
except requests.exceptions.RequestException as e:
return (f"API Connection Error: {str(e)}", "")
except KeyError as e:
return (f"Unexpected API Response format: {str(e)}", "")
NODE_CLASS_MAPPINGS = {
"EmAySee_SpectreAPIConnector": EmAySee_SpectreAPIConnector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SpectreAPIConnector": "EmAySee_Spectre API Connector"
}
+32
View File
@@ -0,0 +1,32 @@
class EmAySee_StringDetector:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"detected_value": ("INT", {"default": 1, "min": -1000000, "max": 1000000}),
},
"optional": {
"text_input": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("int",)
FUNCTION = "detect"
CATEGORY = "EmAySee/Logic"
def detect(self, detected_value, text_input=None):
if text_input is not None and text_input != "":
return (detected_value,)
return (0,)
NODE_CLASS_MAPPINGS = {
"EmAySee_StringDetector": EmAySee_StringDetector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"f": "EmAySee String Detector"
}
+86
View File
@@ -0,0 +1,86 @@
import requests
import json
import re
class EmAySee_SubmitToOobaboogaAPIWithKeyThinker:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"api_url": ("STRING", {"default": "http://10.0.0.71:5000/v1/chat/completions"}),
"api_key": ("STRING", {"default": "supersecretkey"}),
"max_tokens": ("INT", {"default": 500, "min": 1, "max": 8192}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 2.0, "step": 0.05}),
"top_p": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"system_prompt": ("STRING", {"default": "You are a helpful assistant.", "multiline": True}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("clean_text", "thinking_content")
FUNCTION = "submit_request"
CATEGORY = "EmAySee/API"
def submit_request(self, text, api_url, api_key, max_tokens, temperature, top_p, seed, system_prompt="You are a helpful assistant."):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
# Construct the payload for OpenAI-compatible API
data = {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text}
],
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"seed": seed,
"mode": "instruct", # Helpful for some Ooba configs
"stream": False
}
try:
response = requests.post(api_url, headers=headers, json=data, timeout=120)
response.raise_for_status()
result_json = response.json()
# Extract content from OpenAI format
if "choices" in result_json and len(result_json["choices"]) > 0:
raw_content = result_json["choices"][0]["message"]["content"]
else:
raw_content = ""
print(f"EmAySee_SubmitToOobaboogaAPIWithKeyThinker: No content in response: {result_json}")
# --- Logic to separate <think> content from clean text ---
# 1. Extract thinking content
think_match = re.search(r'<think>(.*?)</think>', raw_content, flags=re.DOTALL)
thinking_content = think_match.group(1).strip() if think_match else ""
# 2. Remove thinking tags to get clean text
clean_text = re.sub(r'<think>.*?</think>', '', raw_content, flags=re.DOTALL).strip()
return (clean_text, thinking_content)
except Exception as e:
print(f"EmAySee_SubmitToOobaboogaAPIWithKeyThinker Error: {e}")
return (f"Error: {str(e)}", "")
NODE_CLASS_MAPPINGS = {
"EmAySee_SubmitToOobaboogaAPIWithKeyThinker": EmAySee_SubmitToOobaboogaAPIWithKeyThinker
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SubmitToOobaboogaAPIWithKeyThinker": "EmAySee Submit To Oobabooga API With Key With THinkParse"
}
+106
View File
@@ -0,0 +1,106 @@
import requests
import json
import time
class EmAySee_SubmitToOobaboogaAPIWithKey_test: # Class name now prefixed with EmAySee_
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"apikey_input": ("STRING", {"default": "Enter your api key here"}),
"text_input": ("STRING", {"default": "Enter your prompt for text-generation-webui here"}),
"api_endpoint": ("STRING", ["http://10.0.0.71:5000/v1/completions","http://10.0.0.76:5000/v1/completions"],{"default": "http://10.0.0.71:5000/v1/completions"}), # Default oobabooga COMPletions API endpoint - Non-streaming
"temperature": ("FLOAT", {"default": 0.7, "min": 0.01, "max": 2.0, "step": 0.01}), # Common oobabooga default
"top_p": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01}), # Common oobabooga default
"top_k": ("INT", {"default": 0, "min": 0, "max": 200}), # 0 means disabled in oobabooga
"min_p": ("INT", {"default": 0, "min": 0, "max": 200}), # 0 means disabled in oobabooga
"typical_p": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), # Common oobabooga default
"repetition_penalty": ("FLOAT", {"default": 1.1, "min": 0.0, "max": 2.0, "step": 0.01}), # Common oobabooga default
"max_new_tokens": (
["25", "50", "75", "100", "125", "150", "175", "200", "250", "300", "350", "400", "450", "500", "550", "600", "650", "700", "750", "800"], # Dropdown options
{"default": "200"} # Default, adjust as needed
),
"seed": ("INT", {"default": -1, "label": "Seed (-1 for random)"}), # Added Seed - common in completions API
"delay_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1, "label": "Delay (seconds)"}),
"show_api_response": ("BOOLEAN", {"default": False, "label": "Show API Response in Console"}),
},
"optional": {
# You can add 'stopping_strings' or other completion-specific parameters here later if needed
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("api_response",)
OUTPUT_NODE = True
FUNCTION = "submitToOobabooga"
def submitToOobabooga(self, apikey_input, api_endpoint, text_input, temperature, top_p, top_k, typical_p, repetition_penalty, max_new_tokens, seed, delay_seconds, show_api_response, **kwargs): # Removed history_, added seed
"""
Submits a request to the text-generation-webui (oobabooga) /v1/completions API for non-streaming text generation.
Waits for the full response before returning.
"""
payload = {
"prompt": text_input,
"max_tokens": int(max_new_tokens), # Use 'max_tokens' for /v1/completions
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": top_k,
"typical_p": typical_p,
"repetition_penalty": repetition_penalty,
"seed": seed, # Added seed to payload
"stream": False # **Ensuring non-streaming: explicitly set stream to False**
# Add other oobabooga /v1/completions parameters here as needed, consult API docs
}
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {apikey_input}' # <-- ADD THIS LINE
}
try:
time.sleep(delay_seconds)
response = requests.post(api_endpoint, json=payload, headers=headers)
response.raise_for_status()
try:
api_response = response.json()
except json.JSONDecodeError:
error_message = f"Oobabooga API returned non-JSON. Status: {response.status_code}. Text: {response.text}"
print(error_message)
return (error_message,)
if show_api_response:
print("Oobabooga API Response Content:")
print(api_response)
# Response parsing for /v1/completions endpoint - VERIFY AGAINST DOCS!
if isinstance(api_response, dict) and 'choices' in api_response and api_response['choices']:
api_response_text = api_response['choices'][0]['text'] # Expecting text directly under 'choices'-> 0 -> 'text' for /v1/completions
elif isinstance(api_response, dict) and 'text' in api_response: # Fallback if API just returns text directly
api_response_text = api_response['text']
elif isinstance(api_response, str): # Fallback if API returns plain text string
api_response_text = api_response
else:
error_message = f"Unexpected Oobabooga API response format for /v1/completions. JSON: {api_response}"
print(error_message)
return (error_message,)
trimmed_text = api_response_text.strip()
return (trimmed_text,)
except requests.exceptions.RequestException as e:
error_message = f"Oobabooga API Request Error: {e}. Status: {getattr(e.response, 'status_code', 'N/A')}. Content: {getattr(e.response, 'text', 'N/A')}"
print(error_message)
return (error_message,)
NODE_CLASS_MAPPINGS = {
"EmAySee_SubmitToOobaboogaAPIWithKey_test": EmAySee_SubmitToOobaboogaAPIWithKey_test # Class mapping with alias
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SubmitToOobaboogaAPIWithKey_test": "EmAySee Submit to Oobabooga API Node With API Key_test" # Display name with alias
}
+2 -2
View File
@@ -19,8 +19,8 @@ class EmAySee_SubmitToOobaboogaAPIWithKey: # Class name now prefixed with EmAyS
"typical_p": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), # Common oobabooga default
"repetition_penalty": ("FLOAT", {"default": 1.1, "min": 0.0, "max": 2.0, "step": 0.01}), # Common oobabooga default
"max_new_tokens": (
["25", "50", "75", "100", "125", "150", "175", "200", "250", "300", "350", "400", "450", "500", "550", "600", "650", "700", "750", "800"], # Dropdown options
{"default": "200"} # Default, adjust as needed
["25", "50", "75", "100", "125", "150", "175", "200", "250", "300", "350", "400", "450", "500", "550", "600", "650", "700", "750", "800", "1024", "2048", "4096"], # Dropdown options
{"default": "300"} # Default, adjust as needed
),
"seed": ("INT", {"default": -1, "label": "Seed (-1 for random)"}), # Added Seed - common in completions API
"delay_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1, "label": "Delay (seconds)"}),
+117
View File
@@ -0,0 +1,117 @@
import requests
import json
import re
class EmAySee_SubmitToOobaboogaAPIWithKey_WithThinkParse:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"api_url": ("STRING", {"default": "http://10.0.0.71:5000/v1/chat/completions"}),
"api_key": ("STRING", {"default": "supersecretkey"}),
"model_name": ("STRING", {"default": "default"}),
"max_tokens": ("INT", {"default": 500, "min": 1, "max": 8192}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 2.0, "step": 0.05}),
"top_p": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01}),
"top_k": ("INT", {"default": 20, "min": 0, "max": 200}),
"min_p": ("FLOAT", {"default": 0.05, "min": 0.0, "max": 1.0, "step": 0.01}),
"repetition_penalty": ("FLOAT", {"default": 1.15, "min": 1.0, "max": 2.0, "step": 0.01}),
"frequency_penalty": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.05}),
"presence_penalty": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.05}),
"dry_multiplier": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"dry_base": ("FLOAT", {"default": 1.75, "min": 1.0, "max": 5.0, "step": 0.05}),
"dry_allowed_length": ("INT", {"default": 2, "min": 0, "max": 20}),
"enable_thinking": ("BOOLEAN", {"default": True}),
# New Parameter: reasoning_effort
"reasoning_effort": (["low", "medium", "high"], {"default": "medium"}),
"guidance_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1}),
"seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff}),
},
"optional": {
"system_prompt": ("STRING", {"default": "You are a helpful assistant.", "multiline": True}),
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
"stop": ("STRING", {"default": "", "multiline": False, "placeholder": "comma, separated, stop, strings"}),
"instruction_template": ("STRING", {"default": "", "placeholder": "e.g. Qwen, Alpaca, ChatML"}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("clean_text", "thinking_content")
FUNCTION = "submit_request"
CATEGORY = "EmAySee/API"
def submit_request(self, text, api_url, api_key, model_name, max_tokens, temperature, top_p, top_k, min_p, repetition_penalty, frequency_penalty, presence_penalty, dry_multiplier, dry_base, dry_allowed_length, enable_thinking, reasoning_effort, guidance_scale, seed, system_prompt="You are a helpful assistant.", negative_prompt="", stop="", instruction_template=""):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
stop_list = [s.strip() for s in stop.split(",")] if stop and stop.strip() else []
data = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text}
],
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"repetition_penalty": repetition_penalty,
"frequency_penalty": frequency_penalty,
"presence_penalty": presence_penalty,
"dry_multiplier": dry_multiplier,
"dry_base": dry_base,
"dry_allowed_length": dry_allowed_length,
"enable_thinking": enable_thinking,
"reasoning_effort": reasoning_effort, # Added here
"guidance_scale": guidance_scale,
"negative_prompt": negative_prompt,
"instruction_template": instruction_template,
"seed": seed,
"mode": "instruct",
"stream": False
}
if stop_list:
data["stop"] = stop_list
try:
response = requests.post(api_url, headers=headers, json=data, timeout=120)
response.raise_for_status()
result_json = response.json()
if "choices" in result_json and len(result_json["choices"]) > 0:
raw_content = result_json["choices"][0]["message"]["content"]
else:
raw_content = ""
print(f"EmAySee_SubmitToOobaboogaAPIWithKey: No content in response: {result_json}")
# 1. Extract thinking content
think_match = re.search(r'<think>(.*?)</think>', raw_content, flags=re.DOTALL)
thinking_content = think_match.group(1).strip() if think_match else ""
# 2. Remove thinking tags to get clean text
clean_text = re.sub(r'<think>.*?</think>', '', raw_content, flags=re.DOTALL).strip()
return (clean_text, thinking_content)
except Exception as e:
print(f"EmAySee_SubmitToOobaboogaAPIWithKey Error: {e}")
return (f"Error: {str(e)}", "")
NODE_CLASS_MAPPINGS = {
"EmAySee_SubmitToOobaboogaAPIWithKey_WithThinkParse": EmAySee_SubmitToOobaboogaAPIWithKey_WithThinkParse
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_SubmitToOobaboogaAPIWithKey_WithThinkParse": "EmAySee Submit To Oobabooga API With Key With Think Parse"
}
+40
View File
@@ -0,0 +1,40 @@
import re
class EmAySee_TagPruner:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_tags": ("STRING", {"multiline": True, "default": "tag1, tag2, bad_tag, tag3, 1girl"}),
"tags_to_remove": ("STRING", {"multiline": True, "default": "bad_tag, 1girl, solo"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("cleaned_tags",)
FUNCTION = "prune"
CATEGORY = "EmAySee"
def prune(self, input_tags, tags_to_remove):
remove_tags_list = re.split(r'[,\n]+', tags_to_remove)
remove_set = set(tag.strip().lower() for tag in remove_tags_list if tag.strip())
input_tags_list = re.split(r'[,\n]+', input_tags)
cleaned_list = []
for tag in input_tags_list:
stripped_tag = tag.strip()
if stripped_tag and stripped_tag.lower() not in remove_set:
cleaned_list.append(stripped_tag)
final_string = ", ".join(cleaned_list)
return (final_string,)
NODE_CLASS_MAPPINGS = {
"EmAySee_TagPruner": EmAySee_TagPruner
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_TagPruner": "EmAySee Tag Pruner"
}
+35
View File
@@ -0,0 +1,35 @@
class EmAySee_TextCombiner:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
inputs = {
"required": {},
"optional": {}
}
for i in range(1, 51):
inputs["optional"][f"text_{i}"] = ("STRING", {"forceInput": True})
return inputs
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "combine"
CATEGORY = "EmAySee/Text"
def combine(self, **kwargs):
text_list = []
for i in range(1, 51):
key = f"text_{i}"
if key in kwargs and kwargs[key] is not None and kwargs[key] != "":
text_list.append(str(kwargs[key]))
return ("\n".join(text_list),)
NODE_CLASS_MAPPINGS = {
"EmAySee_TextCombiner": EmAySee_TextCombiner
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_TextCombiner": "EmAySee Text Combiner"
}
+47
View File
@@ -0,0 +1,47 @@
import re
class EmAySee_TwentyFloatToText:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
labels = [
"Breasts Size", "Breasts Sag", "Areolas Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Gap", "Body Type", "Pregnant", "Peeking Pubic Hairs"
]
inputs = {"required": {}}
for label in labels:
inputs["required"][label] = ("FLOAT", {"default": 0.0, "min": -3.0, "max": 5.0, "step": 0.1})
return inputs
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("combined_text",)
FUNCTION = "convert"
CATEGORY = "EmAySee/Text"
def convert(self, **kwargs):
labels = [
"Breasts Size", "Breasts Sag", "Areolas Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Gap", "Body Type", "Pregnant", "Peeking Pubic Hairs"
]
combined_list = []
for label in labels:
value = kwargs.get(label, 0.0)
if value != 0.0:
combined_list.append(f"{label}: {value:.2f}")
return ("\n".join(combined_list),)
NODE_CLASS_MAPPINGS = {
"EmAySee_TwentyFloatToText": EmAySee_TwentyFloatToText
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_TwentyFloatToText": "EmAySee Twenty Float To Text"
}
+60
View File
@@ -0,0 +1,60 @@
import re
class EmAySee_TwentyFloatToTextAndFloat:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
labels = [
"Breasts Size", "Breasts Sag", "Areloas Size", "Puffie",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05",
"Option 06", "Option 07", "Option 08"
]
inputs = {"required": {}}
for label in labels:
inputs["required"][label] = ("FLOAT", {"default": 0.0, "min": -2.0, "max": 3.0, "step": 0.01})
return inputs
RETURN_TYPES = ("STRING",) + ("FLOAT",) * 20
RETURN_NAMES = ("combined_text",) + (
"Breasts Size", "Breasts Sag", "Areloas Size", "Puffie",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05",
"Option 06", "Option 07", "Option 08"
)
FUNCTION = "convert"
CATEGORY = "EmAySee/Text"
def convert(self, **kwargs):
labels = [
"Breasts Size", "Breasts Sag", "Areloas Size", "Puffie",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "Pubic Hair Peek",
"Option 01", "Option 02", "Option 03", "Option 04", "Option 05",
"Option 06", "Option 07", "Option 08"
]
combined_list = []
float_outputs = []
for label in labels:
value = kwargs.get(label, 0.0)
float_outputs.append(value)
if value != 0.0:
combined_list.append(f"{label}: {value:.2f}")
return ("\n".join(combined_list),) + tuple(float_outputs)
NODE_CLASS_MAPPINGS = {
"EmAySee_TwentyFloatToTextAndFloat": EmAySee_TwentyFloatToTextAndFloat
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_TwentyFloatToTextAndFloat": "EmAySee Twenty Float To Text And Float"
}
+71
View File
@@ -0,0 +1,71 @@
import torch
class EmAySee_TwentyFloatToTextV2:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
labels = [
"Breast Size", "Breast Sag", "Areola Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "PHP",
"Extra 01", "Extra 02", "Extra 03", "Extra 04", "Extra 05",
"Extra 06", "Extra 07", "Extra 08"
]
inputs = {"required": {}}
for label in labels:
inputs["required"][label] = ("FLOAT", {"default": 0.0, "min": -2.0, "max": 3.0, "step": 0.01})
return inputs
RETURN_TYPES = ("STRING",) + ("STRING",) * 20 + ("FLOAT",) * 20
RETURN_NAMES = ("combined_text",) + (
"T_Breast Size", "T_Breast Sag", "T_Areola Size", "T_Puffies",
"T_Belly Fat", "T_Waist Size", "T_Butt Size", "T_Thigh Size",
"T_Thigh Spacing", "T_Body Type", "T_Pregnant", "T_PHP",
"T_Extra 01", "T_Extra 02", "T_Extra 03", "T_Extra 04", "T_Extra 05",
"T_Extra 06", "T_Extra 07", "T_Extra 08",
"F_Breast Size", "F_Breast Sag", "F_Areola Size", "F_Puffies",
"F_Belly Fat", "F_Waist Size", "F_Butt Size", "F_Thigh Size",
"F_Thigh Spacing", "F_Body Type", "F_Pregnant", "F_PHP",
"F_Extra 01", "F_Extra 02", "F_Extra 03", "F_Extra 04", "F_Extra 05",
"F_Extra 06", "F_Extra 07", "F_Extra 08"
)
FUNCTION = "convert"
CATEGORY = "EmAySee/Text"
def convert(self, **kwargs):
labels = [
"Breast Size", "Breast Sag", "Areola Size", "Puffies",
"Belly Fat", "Waist Size", "Butt Size", "Thigh Size",
"Thigh Spacing", "Body Type", "Pregnant", "PHP",
"Extra 01", "Extra 02", "Extra 03", "Extra 04", "Extra 05",
"Extra 06", "Extra 07", "Extra 08"
]
combined_list = []
individual_texts = []
float_outputs = []
for label in labels:
value = kwargs.get(label, 0.0)
float_outputs.append(value)
if value != 0.0:
formatted = f"{label}: {value:.2f}"
combined_list.append(formatted)
individual_texts.append(formatted)
else:
individual_texts.append("")
return ("\n".join(combined_list),) + tuple(individual_texts) + tuple(float_outputs)
NODE_CLASS_MAPPINGS = {
"EmAySee_TwentyFloatToTextV2": EmAySee_TwentyFloatToTextV2
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_TwentyFloatToTextV2": "EmAySee Twenty Float To Text V2"
}
+65
View File
@@ -0,0 +1,65 @@
import comfy.utils
import comfy.sd
import folder_paths
lora_names = ["None"] + folder_paths.get_filename_list("loras")
class EmAySee_LoraNameSelector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"lora_name": (lora_names,),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("lora_name",)
FUNCTION = "get_name"
CATEGORY = "EmAySee/Loaders"
def get_name(self, lora_name):
return (lora_name,)
class EmAySee_LoraApplier:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"lora_name": ("STRING", {"default": "None", "multiline": False}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
}
}
RETURN_TYPES = ("MODEL", "CLIP")
FUNCTION = "apply_lora"
CATEGORY = "EmAySee/Loaders"
def apply_lora(self, model, clip, lora_name, strength_model, strength_clip):
if strength_model == 0 and strength_clip == 0 or lora_name is None or lora_name == "None":
return (model, clip)
lora_path = folder_paths.get_full_path("loras", lora_name)
if not lora_path:
print(f"EmAySee LoRA Applier: Warning, LoRA not found: {lora_name}")
return (model, clip)
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
model_lora, clip_lora = comfy.sd.load_lora_for_models(model, clip, lora, strength_model, strength_clip)
return (model_lora, clip_lora)
NODE_CLASS_MAPPINGS = {
"EmAySee_LoraNameSelector": EmAySee_LoraNameSelector,
"EmAySee_LoraApplier": EmAySee_LoraApplier,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmAySee_LoraNameSelector": "EmAySee LoRA Name Selector",
"EmAySee_LoraApplier": "EmAySee LoRA Applier",
}
+67
View File
@@ -0,0 +1,67 @@
import os
import json
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
class EmAySee_VisualMachine_SaveImageAndText_V2:
def __init__(self):
self.output_dir = "output"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "SPECTRE"}),
"history_text": ("STRING", {"forceInput": True}),
"current_prompt": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "EmAySee_Automation/Image"
def save_images(self, images, filename_prefix, history_text, current_prompt):
from nodes import get_comfy_root
full_output_folder = os.path.join(get_comfy_root(), self.output_dir)
if not os.path.exists(full_output_folder):
os.makedirs(full_output_folder, mode=0o777, exist_ok=True)
results = list()
for image in images:
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
# 1. Handle Naming
# Uses the prefix as the base name; extension replacement happens here
file_base = f"{filename_prefix}"
image_file = f"{file_base}.png"
log_file = f"{file_base}.txt"
image_path = os.path.join(full_output_folder, image_file)
log_path = os.path.join(full_output_folder, log_file)
# 2. Save the Image
img.save(image_path, pnginfo=None, compress_level=4)
# 3. Save the Companion Text File
entry = (
f"--- SPECTRE V2 METADATA ---\n"
f"FILENAME: {image_file}\n"
f"METADATA_FILE: {log_file}\n"
f"PROMPT: {current_prompt}\n"
f"STATE HISTORY:\n{history_text}\n"
f"----------------------------"
)
with open(log_path, "w", encoding="utf-8") as f:
f.write(entry)
return {"ui": {"images": [{"filename": image_file, "subfolder": "", "type": "output"}]}}
NODE_CLASS_MAPPINGS = {"EmAySee_VisualMachine_SaveImageAndText_V2": EmAySee_VisualMachine_SaveImageAndText_V2}
NODE_DISPLAY_NAME_MAPPINGS = {"EmAySee_VisualMachine_SaveImageAndText_V2": "EmAySee VisualMachine Save Image And Text V2"}
+120
View File
@@ -0,0 +1,120 @@
import node_helpers
import comfy.utils
import math
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
class TextEncodeQwenImageEdit_V2(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TextEncodeQwenImageEdit_V2",
category="advanced/conditioning",
inputs=[
io.Clip.Input("clip"),
io.String.Input("prompt", multiline=True, dynamic_prompts=True),
io.Vae.Input("vae", optional=True),
io.Image.Input("image", optional=True),
],
outputs=[
io.Conditioning.Output(),
],
)
@classmethod
def execute(cls, clip, prompt, vae=None, image=None) -> io.NodeOutput:
ref_latent = None
if image is None:
images = []
else:
samples = image.movedim(-1, 1)
total = int(1024 * 1024)
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
s = comfy.utils.common_upscale(samples, width, height, "area", "disabled")
image = s.movedim(1, -1)
images = [image[:, :, :, :3]]
if vae is not None:
ref_latent = vae.encode(image[:, :, :, :3])
tokens = clip.tokenize(prompt, images=images)
conditioning = clip.encode_from_tokens_scheduled(tokens)
if ref_latent is not None:
conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": [ref_latent]}, append=True)
return io.NodeOutput(conditioning)
class TextEncodeQwenImageEdit_V2Plus(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TextEncodeQwenImageEdit_V2Plus",
category="advanced/conditioning",
inputs=[
io.Clip.Input("clip"),
io.String.Input("prompt", multiline=True, dynamic_prompts=True),
io.Vae.Input("vae", optional=True),
io.Image.Input("image1", optional=True),
io.Image.Input("image2", optional=True),
io.Image.Input("image3", optional=True),
io.Image.Input("image4", optional=True),
io.Image.Input("image5", optional=True),
io.Image.Input("image6", optional=True),
io.Image.Input("image7", optional=True),
io.Latent.Input("target_latent", optional=True),
],
outputs=[
io.Conditioning.Output(),
],
)
@classmethod
def execute(cls, clip, prompt, vae=None, image1=None, image2=None, image3=None, image4=None, target_latent=None) -> io.NodeOutput:
ref_latents = []
images = [image1, image2, image3, image4]
images_vl = []
llama_template = "<|im_start|>system\nDescribe key details of the input image (including any objects, characters, poses, facial features, clothing, setting, textures and style), then explain how the user's text instruction should alter, modify or recreate the image. Generate a new image that meets the user's requirements, which can vary from a small change to a completely new image using inputs as a guide.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
image_prompt = ""
for i, image in enumerate(images):
if image is not None:
samples = image.movedim(-1, 1)
total = int(384 * 384)
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
s = comfy.utils.common_upscale(samples, width, height, "lanczos", "center")
images_vl.append(s.movedim(1, -1))
if vae is not None:
if target_latent is not None:
twidth = target_latent["samples"].shape[-1] * 8
theight = target_latent["samples"].shape[-2] * 8
s = comfy.utils.common_upscale(samples, twidth, theight, "lanczos", "center")
else:
s = samples
ref_latents.append(vae.encode(s.movedim(1, -1)[:, :, :, :3]))
image_prompt += "Picture {}: <|vision_start|><|image_pad|><|vision_end|>".format(i + 1)
tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template)
conditioning = clip.encode_from_tokens_scheduled(tokens)
if len(ref_latents) > 0:
conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True)
return io.NodeOutput(conditioning)
class QwenExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [
TextEncodeQwenImageEdit_V2,
TextEncodeQwenImageEdit_V2Plus,
]
async def comfy_entrypoint() -> QwenExtension:
return QwenExtension()
+2 -2
View File
@@ -1,8 +1,8 @@
[project]
name = "ComfyUI_EmAySee_CustomNodes"
description = "**FIXED DL** Collection of nodes i've made to better my life in comfy. There is an LLM Ooobagabooga interface, text file saver, multiple randominzers for integers and string selections, and many other nodes I created. CheckboxFloatNode, DateTimeStringNode, DynamicStringSelectorNode, HostPinger, IntegerStringSelectorNode, IntegerStringSelectorNodeDynamic, multiplier_node, ProbabilityStringSelectorNode, RandomIntegerFromListNode, RandomIntegerFromTogglesNode_PremadeLabels, RandomIntFromList, RandomIntFromList2, RandomStringSelectorNode, RandomStringSelectorNode2, RandomStringSelectorNodeFourChoice, RandomStringSelectorNodeThreeChoice, RemoveDuplicateCSV, RepaintKSampler, SaveImage, SaveTextToFile, StringPoseSelectorNode, StringTupleInputNode, SubmitToOobaboogaAPI, ToggleIntNode, VarTextReplacer, VeryUniqueStringSelectorNode"
description = "A specialized suite of over 60 custom nodes for ComfyUI. Includes advanced image processing (Qwen Pixel Aligner/Optimizer), VRAM management tools, masking logic, math expression evaluators, and high-performance LLM connectors with Auto-Continue. Amongst other fun, useful, and useless nodes TBH."
version = "3.2.0"
version = "4.1.0"
license = {file = "LICENSE"}
[project.urls]