from __future__ import annotations import os import importlib import importlib.util import torch import folder_paths import comfy import comfy.sd # Added import for CLIP type access import json import sys import re # Added for regex operations import random # Added for random choice import time # Added for IS_CHANGED import platform import inspect from inspect import cleandoc from typing import Any, Dict from server import PromptServer from comfy_execution.graph_utils import is_link # Custom Nodes for ComfyUI # Note: All nodes in this file should use the "Smart" prefix in their names # Category: SmartHelperNodes class AlwaysEqualProxy(str): def __eq__(self, _): return True def __ne__(self, _): return False any_type = AlwaysEqualProxy("*") # Helper function at module level def format_lora_string(lora_name, strength, blocks_type, existing_string=""): # Convert "-" to "none" for blocks_type blocks = "none" if blocks_type == "-" else blocks_type new_lora_string = f"{lora_name}, str:{strength}, blocks:({blocks})" if existing_string: return f"{existing_string}\n{new_lora_string}" return new_lora_string # Helper function to format objects with binary data handling def format_object(obj, indent=0, max_depth=5, path=""): if max_depth <= 0: return " " * indent + "[max depth reached]" # Handle None if obj is None: return " " * indent + "None" # Get type and handle binary data obj_type = type(obj) # Check for binary data (bytes, bytearray, memoryview) if isinstance(obj, (bytes, bytearray, memoryview)): size_bytes = sys.getsizeof(obj) size_mb = size_bytes / (1024 * 1024) if size_mb >= 1: return f"{' ' * indent}[...binary data ({size_mb:.2f} MB)...]" else: size_kb = size_bytes / 1024 return f"{' ' * indent}[...binary data ({size_kb:.2f} KB)...]" # Handle strings if isinstance(obj, str): if len(obj) > 1000: # Truncate long strings return f"{' ' * indent}\"{obj[:1000]}...\"" return f"{' ' * indent}\"{obj}\"" # Handle basic types if isinstance(obj, (int, float, bool)): return f"{' ' * indent}{obj}" # Handle dictionaries if isinstance(obj, dict): if not obj: return f"{' ' * indent}{{}}" result = [f"{' ' * indent}{{"] for k, v in obj.items(): key_str = repr(k) if isinstance(k, str) else str(k) next_path = f"{path}.{key_str}" if path else key_str # Format value with increased indentation value_str = format_object(v, indent + 2, max_depth - 1, next_path) # Remove the indentation from value_str as we'll add it manually if value_str.startswith(" " * (indent + 2)): value_str = value_str[(indent + 2):] result.append(f"{' ' * (indent + 2)}{key_str}: {value_str}") result.append(f"{' ' * indent}}}") return "\n".join(result) # Handle lists, tuples, sets if isinstance(obj, (list, tuple, set)): if not obj: return f"{' ' * indent}[]" if isinstance(obj, list) else \ f"{' ' * indent}()" if isinstance(obj, tuple) else \ f"{' ' * indent}set()" brackets = "[]" if isinstance(obj, list) else "()" if isinstance(obj, tuple) else "set([])" result = [f"{' ' * indent}{brackets[0]}"] for i, item in enumerate(obj): next_path = f"{path}[{i}]" if path else f"[{i}]" item_str = format_object(item, indent + 2, max_depth - 1, next_path) # Remove the indentation from item_str as we'll add it manually if item_str.startswith(" " * (indent + 2)): item_str = item_str[(indent + 2):] result.append(f"{' ' * (indent + 2)}{item_str},") result.append(f"{' ' * indent}{brackets[1]}") return "\n".join(result) # For other objects, try to get dict representation or use str() try: if hasattr(obj, '__dict__') and obj.__dict__: result = [f"{' ' * indent}{obj.__class__.__name__} {{"] for k, v in obj.__dict__.items(): # Skip private attributes if k.startswith('_'): continue next_path = f"{path}.{k}" if path else k # Format value with increased indentation value_str = format_object(v, indent + 2, max_depth - 1, next_path) # Remove the indentation from value_str as we'll add it manually if value_str.startswith(" " * (indent + 2)): value_str = value_str[(indent + 2):] result.append(f"{' ' * (indent + 2)}{k}: {value_str}") result.append(f"{' ' * indent}}}") return "\n".join(result) except: pass # Fallback to simple string representation return f"{' ' * indent}{str(obj)}" class SmartHVLoraSelect: @classmethod def INPUT_TYPES(s): return { "required": { "lora": (folder_paths.get_filename_list("loras"), {"tooltip": "LORA models are expected to be in ComfyUI/models/loras with .safetensors extension"}), "strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, "tooltip": "LORA strength, set to 0.0 to unmerge the LORA"}), "blocks_type": (["none", "all", "single_blocks", "double_blocks"], {"default": "double_blocks"}), }, "optional": { "prev_lora": ("HYVIDLORA", {"default": None, "tooltip": "For loading multiple LoRAs"}), "lora_string": ("STRING", {"forceInput": True, "default": ""}), } } RETURN_TYPES = ("HYVIDLORA", "STRING") RETURN_NAMES = ("lora", "lora_string") FUNCTION = "getlorapath" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Select a LoRA model and output both the LORA data and a formatted string containing the LoRA name, strength, and blocks" def getlorapath(self, lora, strength, blocks_type, prev_lora=None, fuse_lora=False, lora_string=""): loras_list = [] lora_name = lora.split(".")[0] final_lora_string = format_lora_string(lora_name, strength, blocks_type, lora_string) lora_data = { "path": folder_paths.get_full_path("loras", lora), "strength": strength, "name": lora_name, "fuse_lora": fuse_lora, "blocks": None if blocks_type == "none" else blocks_type } if prev_lora is not None: loras_list.extend(prev_lora) loras_list.append(lora_data) return (loras_list, final_lora_string) class SmartHVLoraStack: max_lora_num = 50 # Maximum number of LoRA slots @classmethod def INPUT_TYPES(cls): # Get the list of available LoRA filenames once available_loras = folder_paths.get_filename_list("loras") # Build the input dictionary inputs = { "required": { "lora_count": ("INT", {"default": 1, "min": 0, "max": cls.max_lora_num, "step": 1}), "blocks_type": (["none", "all", "single_blocks", "double_blocks"], {"default": "double_blocks"}), }, "optional": { "prev_lora": ("HYVIDLORA", {"default": None, "tooltip": "For loading multiple LoRAs"}), "lora_string": ("STRING", {"forceInput": True, "default": ""}), }, "hidden": { "unique_id": "UNIQUE_ID", } } # Define input fields for each possible LoRA slot for i in range(1, cls.max_lora_num + 1): inputs["required"][f"lora_{i}_enabled"] = ("BOOLEAN", {"default": True}) inputs["required"][f"lora_{i}_name"] = (["None"] + available_loras, {"default": "None"}) inputs["required"][f"lora_{i}_strength"] = ( "FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01} ) return inputs RETURN_TYPES = ("HYVIDLORA", "STRING") RETURN_NAMES = ("lora", "lora_string") FUNCTION = "stack_loras" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Stack multiple LoRAs together with individual strength controls and enable/disable switches" def stack_loras(self, lora_count, blocks_type, lora_string="", prev_lora=None, unique_id=None, **kwargs): loras_list = [] final_lora_string = lora_string if lora_string else "" # Ensure empty string if None # If there was a previous LoRA stack provided, start with that. if prev_lora is not None: loras_list.extend(prev_lora) # Process only the first lora_count entries. for i in range(1, lora_count + 1): curr_enabled = kwargs.get(f"lora_{i}_enabled", False) curr_lora = kwargs.get(f"lora_{i}_name") curr_strength = kwargs.get(f"lora_{i}_strength", 1.0) # Only process this LoRA if it is enabled and a valid name is selected. if curr_enabled and curr_lora and curr_lora != "None": # Optionally, adjust the lora_string (using your existing helper function) curr_lora_name = curr_lora.split(".")[0] final_lora_string = format_lora_string(curr_lora_name, curr_strength, blocks_type, final_lora_string) loras_list.append({ "path": folder_paths.get_full_path("loras", curr_lora), "strength": curr_strength, "name": curr_lora_name, "fuse_lora": False, "blocks": None if blocks_type == "none" else blocks_type }) return (loras_list, final_lora_string) class SmartFormatString: max_param_num = 5 # Maximum number of parameters @classmethod def INPUT_TYPES(cls): inputs = { "required": { "string_format": ("STRING", { "default": "Value is: %1", "multiline": True, "tooltip": "Format string with:\n%1-%5 for parameters\n%H for hours (00-23)\n%M for minutes (00-59)\n%S for seconds (00-59)\n%y for year (00-99)\n%m for month (01-12)\n%d for day (01-31)" }), }, "optional": {}, "hidden": { "unique_id": "UNIQUE_ID", } } # Define input fields for each possible parameter slot for i in range(1, cls.max_param_num + 1): inputs["optional"][f"param_{i}"] = (any_type, ) return inputs RETURN_TYPES = ("STRING",) RETURN_NAMES = ("formatted_string",) FUNCTION = "format_string" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Format a string by replacing %1, %2, etc. with provided parameter values" def format_string(self, string_format, unique_id=None, **kwargs): from datetime import datetime result = string_format now = datetime.now() # Replace time-based placeholders time_placeholders = { '%H': f"{now.hour:02d}", '%M': f"{now.minute:02d}", '%S': f"{now.second:02d}", '%y': f"{now.year % 100:02d}", '%m': f"{now.month:02d}", '%d': f"{now.day:02d}" } for placeholder, value in time_placeholders.items(): result = result.replace(placeholder, value) # Process numbered parameters in reverse order to handle double-digit numbers correctly for i in range(self.max_param_num, 0, -1): param_value = kwargs.get(f"param_{i}", "") str_value = str(param_value) if param_value is not None else "" result = result.replace(f"%{i}", str_value) return (result,) @classmethod def IS_CHANGED(cls, string_format, unique_id=None, **kwargs): # Return a value that's always different to force re-evaluation when time-based placeholders are used import time if any(x in string_format for x in ['%H', '%M', '%S', '%y', '%m', '%d']): return time.time() return 0 class SmartFormatString10(SmartFormatString): max_param_num = 10 # Override with 10 parameters SMART_BUS_TYPE = "SMART_BUS" SMART_BUS_SLOT_LABELS = ( "any 1", "any 2", "any 3", "any 4", "any 5", "any 6", "any 7", "any 8", "any 9", "any 10", ) def _normalize_smart_bus(bus, size=10): values = [None] * size labels = list(SMART_BUS_SLOT_LABELS[:size]) if isinstance(bus, dict): source_values = bus.get("values", []) source_labels = bus.get("labels", []) elif isinstance(bus, (list, tuple)): source_values = bus source_labels = [] else: source_values = [] source_labels = [] for index, value in enumerate(source_values[:size]): values[index] = value for index, label in enumerate(source_labels[:size]): if isinstance(label, str) and label.strip(): labels[index] = label.strip() return { "values": values, "labels": labels, } def _get_smart_bus_node(unique_id=None, extra_pnginfo=None): if not unique_id or not extra_pnginfo: return None try: info = extra_pnginfo[0] if isinstance(extra_pnginfo, (list, tuple)) else extra_pnginfo workflow = info.get("workflow") node_value = unique_id[0] if isinstance(unique_id, (list, tuple)) else unique_id node_id = str(node_value) except (IndexError, AttributeError, TypeError): return None if not workflow: return None return next((node for node in workflow.get("nodes", []) if str(node.get("id")) == node_id), None) def _get_workflow_node_by_id(node_id=None, extra_pnginfo=None): if node_id is None or not extra_pnginfo: return None try: info = extra_pnginfo[0] if isinstance(extra_pnginfo, (list, tuple)) else extra_pnginfo workflow = info.get("workflow") except (IndexError, AttributeError, TypeError): return None if not workflow: return None node_id = str(node_id) return next((node for node in workflow.get("nodes", []) if str(node.get("id")) == node_id), None) def _get_smart_bus_labels(unique_id=None, extra_pnginfo=None, size=10): labels = list(SMART_BUS_SLOT_LABELS[:size]) node = _get_smart_bus_node(unique_id, extra_pnginfo) if not node: return labels properties = node.get("properties", {}) for index, default_label in enumerate(labels): value = properties.get(default_label) if isinstance(value, str) and value.strip(): labels[index] = value.strip() return labels def _smart_bus_param_is_linked(prompt, unique_id, param_key): """True if param_* has a wire in the prompt; False if absent/null (keep bus slot).""" if prompt is None or unique_id is None: return None node_value = unique_id[0] if isinstance(unique_id, (list, tuple)) else unique_id node_id = str(node_value) node = prompt.get(node_id) if not isinstance(node, dict): return None raw = (node.get("inputs") or {}).get(param_key) if raw is None: return False return is_link(raw) class SmartBusIn: max_param_num = 10 @classmethod def INPUT_TYPES(cls): inputs = { "required": {}, "optional": { "bus": (SMART_BUS_TYPE,), }, "hidden": { "unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO", "prompt": "PROMPT", }, } for i in range(1, cls.max_param_num + 1): inputs["optional"][f"param_{i}"] = (any_type,) return inputs RETURN_TYPES = (SMART_BUS_TYPE,) RETURN_NAMES = ("bus",) FUNCTION = "build_bus" CATEGORY = "SmartHelperNodes/Bus" DESCRIPTION = "Merge into a Smart Bus: only wired param slots replace values; unwired slots keep the incoming bus." def build_bus(self, bus=None, unique_id=None, extra_pnginfo=None, prompt=None, **kwargs): bus_data = _normalize_smart_bus(bus, self.max_param_num) values = bus_data["values"] for i in range(1, self.max_param_num + 1): key = f"param_{i}" linked = _smart_bus_param_is_linked(prompt, unique_id, key) if linked is True: if key in kwargs: values[i - 1] = kwargs[key] elif linked is False: pass else: if key in kwargs: values[i - 1] = kwargs[key] labels = _get_smart_bus_labels(unique_id, extra_pnginfo, self.max_param_num) return ({ "values": values, "labels": labels, },) class SmartBusOut: max_param_num = 10 @classmethod def INPUT_TYPES(cls): return { "required": { "bus": (SMART_BUS_TYPE,), } } RETURN_TYPES = (SMART_BUS_TYPE,) + (any_type,) * max_param_num RETURN_NAMES = ("bus",) + SMART_BUS_SLOT_LABELS FUNCTION = "expand_bus" CATEGORY = "SmartHelperNodes/Bus" DESCRIPTION = "Expand a Smart Bus back into its 10 stored passthrough values." def expand_bus(self, bus): bus_data = _normalize_smart_bus(bus, self.max_param_num) values = bus_data["values"] return (bus_data, *values) class SmartBusIn5(SmartBusIn): max_param_num = 5 DESCRIPTION = "Merge into a Smart Bus: only wired param slots replace values; unwired slots keep the incoming bus." class SmartBusOut5(SmartBusOut): max_param_num = 5 RETURN_TYPES = (SMART_BUS_TYPE,) + (any_type,) * max_param_num RETURN_NAMES = ("bus",) + SMART_BUS_SLOT_LABELS[:max_param_num] DESCRIPTION = "Expand a Smart Bus back into its 5 stored passthrough values." class SmartSaveText: """ A node that saves text to a file, creating directories as needed and appending to existing files. """ @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"multiline": True}), "filename": ("STRING", {"default": "output.txt"}), }, } RETURN_TYPES = ("STRING",) FUNCTION = "save_text" CATEGORY = "SmartHelperNodes" OUTPUT_NODE = True def save_text(self, text, filename): # Convert relative path to absolute path within ComfyUI's output directory output_dir = folder_paths.get_output_directory() full_path = os.path.join(output_dir, filename) # Create directories if they don't exist directory = os.path.dirname(full_path) if directory: os.makedirs(directory, exist_ok=True) # Check if we need a leading newline (file exists, non-empty, doesn't end with newline) needs_newline = False if os.path.exists(full_path) and os.path.getsize(full_path) > 0: with open(full_path, "rb") as f: f.seek(-1, 2) # Seek to last byte from end needs_newline = f.read(1) != b'\n' # Append to file with open(full_path, "a", encoding="utf-8") as f: if needs_newline: f.write('\n') f.write(text) return (text,) class SmartSaveAnimatedGIF: """ A node that saves a series of images as an animated GIF file with specified FPS. """ @classmethod def INPUT_TYPES(cls): return { "required": { "images": ("IMAGE", {"tooltip": "Series of images to save as animated GIF"}), "filename": ("STRING", {"default": "animation.gif", "tooltip": "Output filename for the GIF"}), "fps": ("FLOAT", {"default": 10.0, "min": 0.1, "max": 60.0, "step": 0.1, "tooltip": "Frames per second for the animation"}), "loop": ("INT", {"default": 0, "min": 0, "max": 65535, "tooltip": "Number of loops (0 = infinite loop)"}), "optimize": ("BOOLEAN", {"default": True, "tooltip": "Optimize the GIF file size"}), "quality": ("INT", {"default": 95, "min": 1, "max": 100, "tooltip": "Quality of the GIF (higher = better quality, larger file)"}), "preserve_transparency": ("BOOLEAN", {"default": True, "tooltip": "Preserve alpha channel as GIF transparency (binary)"}), "alpha_threshold": ("FLOAT", {"default": 0.01, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Alpha values below this are transparent (0 = only fully transparent, 1 = all transparent)"}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("file_path",) FUNCTION = "save_animated_gif" CATEGORY = "SmartHelperNodes" OUTPUT_NODE = True DESCRIPTION = "Save a series of images as an animated GIF with customizable FPS, loop count, quality settings, and transparency preservation" def save_animated_gif(self, images, filename, fps, loop, optimize, quality, preserve_transparency, alpha_threshold): try: from PIL import Image import numpy as np import torch except ImportError as e: raise RuntimeError(f"Required libraries not available: {e}. Please install Pillow.") # Ensure filename has .gif extension if not filename.lower().endswith('.gif'): if filename.endswith('.'): filename = filename + 'gif' else: filename = filename + '.gif' # Convert relative path to absolute path within ComfyUI's output directory output_dir = folder_paths.get_output_directory() full_path = os.path.join(output_dir, filename) # Create directories if they don't exist directory = os.path.dirname(full_path) if directory: os.makedirs(directory, exist_ok=True) # Convert images to PIL format pil_frames = [] has_transparent_pixel = False used_colors = set() alpha_thresh = alpha_threshold * 255 if isinstance(images, torch.Tensor) and images.dim() == 4: # Handle batch of images batch_size = images.shape[0] else: # If not a batch tensor, treat as list batch_size = len(images) for i in range(batch_size): if isinstance(images, torch.Tensor): img = images[i] else: img = images[i] if isinstance(img, torch.Tensor): img = img.detach().cpu() if img.dim() == 3: if img.shape[0] in [1, 3, 4]: # CHW img = img.permute(1, 2, 0) if img.max() <= 1.0: img = img * 255 img = img.round().clamp(0, 255).byte().numpy() mode = 'RGBA' if img.shape[-1] == 4 else 'RGB' if img.shape[-1] == 3 else 'L' if img.shape[-1] == 1 else None if mode is None: raise ValueError(f"Unsupported tensor channel count: {img.shape[-1]}") if mode == 'L': img = np.repeat(img[:, :, np.newaxis], 3, axis=-1) mode = 'RGB' pil_img = Image.fromarray(img, mode) elif isinstance(img, np.ndarray): if img.max() <= 1.0: img = img * 255 img = np.round(img).clip(0, 255).astype(np.uint8) mode = 'RGBA' if img.shape[-1] == 4 else 'RGB' if img.shape[-1] == 3 else 'L' if img.shape[-1] == 1 else None if mode is None: raise ValueError(f"Unsupported array channel count: {img.shape[-1]}") if mode == 'L': img = np.repeat(img[:, :, np.newaxis], 3, axis=-1) mode = 'RGB' pil_img = Image.fromarray(img, mode) elif isinstance(img, Image.Image): pil_img = img else: raise ValueError(f"Unsupported image type: {type(img)}") # Convert to RGBA for consistency if transparency might be present if pil_img.mode not in ['RGB', 'RGBA']: pil_img = pil_img.convert('RGB') if preserve_transparency and pil_img.mode == 'RGBA': data = np.array(pil_img) alpha = data[:, :, 3] if np.any(alpha < alpha_thresh): has_transparent_pixel = True opaque_mask = alpha >= alpha_thresh opaque_rgb = data[opaque_mask, :3] for color in opaque_rgb: used_colors.add(tuple(map(int, color))) pil_frames.append(pil_img) if not pil_frames: raise ValueError("No valid images provided") if not preserve_transparency or not has_transparent_pixel: # Composite to RGB with white background for i in range(len(pil_frames)): if pil_frames[i].mode == 'RGBA': background = Image.new('RGB', pil_frames[i].size, (255, 255, 255)) background.paste(pil_frames[i], mask=pil_frames[i].getchannel('A')) pil_frames[i] = background pil_images = pil_frames else: # Find global key_color not used in any opaque pixel key_color = None attempts = 0 while attempts < 10000 and key_color is None: r = random.randint(0, 255) g = random.randint(0, 255) b = random.randint(0, 255) if (r, g, b) not in used_colors: key_color = (r, g, b) attempts += 1 if key_color is None: key_color = (255, 0, 255) # Replace occurrences of key_color in opaque areas for i in range(len(pil_frames)): if pil_frames[i].mode == 'RGBA': data = np.array(pil_frames[i]) alpha = data[:, :, 3] match = (data[:, :, 0] == 255) & (data[:, :, 1] == 0) & (data[:, :, 2] == 255) & (alpha >= alpha_thresh) data[match, 0] = 254 pil_frames[i] = Image.fromarray(data, 'RGBA') # Create RGB frames with key_color in transparent areas rgb_frames = [] for frame in pil_frames: if frame.mode == 'RGBA': data = np.array(frame) transparent_mask = data[:, :, 3] < alpha_thresh rgb_data = data[:, :, :3] rgb_data[transparent_mask] = key_color frame_rgb = Image.fromarray(rgb_data, 'RGB') else: frame_rgb = frame.convert('RGB') rgb_frames.append(frame_rgb) # Create large image for global palette widths, heights = zip(*(f.size for f in rgb_frames)) max_width = max(widths) total_height = sum(heights) + 32 # Extra for key block large_img = Image.new('RGB', (max_width, total_height)) y = 0 for f in rgb_frames: large_img.paste(f, (0, y)) y += f.height key_block = Image.new('RGB', (32, 32), key_color) large_img.paste(key_block, (0, y)) # Quantize large image large_quant = large_img.quantize(colors=256, method=Image.Quantize.MAXCOVERAGE) global_palette = large_quant.getpalette()[:256*3] # Find trans_index trans_index = -1 for i in range(256): if (global_palette[i*3] == key_color[0] and global_palette[i*3 + 1] == key_color[1] and global_palette[i*3 + 2] == key_color[2]): trans_index = i break if trans_index == -1: # Fallback to per-frame palettes pil_images = [] for frame_rgb in rgb_frames: frame_p = frame_rgb.quantize(colors=256) palette = frame_p.getpalette()[:256*3] for j in range(256): if (palette[j*3] == key_color[0] and palette[j*3 + 1] == key_color[1] and palette[j*3 + 2] == key_color[2]): frame_p.info['transparency'] = j frame_p.info['disposal'] = 2 break pil_images.append(frame_p) if pil_images: pil_images[0].info['background'] = pil_images[0].info.get('transparency', 0) else: # Use global palette palette_img = Image.new('P', (1, 1)) palette_img.putpalette(global_palette) pil_images = [] for frame_rgb in rgb_frames: frame_p = frame_rgb.quantize(palette=palette_img) frame_p.info['transparency'] = trans_index frame_p.info['disposal'] = 2 pil_images.append(frame_p) if pil_images: pil_images[0].info['background'] = trans_index # Calculate duration per frame in milliseconds duration = int(1000 / fps) # Save as animated GIF pil_images[0].save( full_path, save_all=True, append_images=pil_images[1:], duration=duration, loop=loop, optimize=optimize, quality=quality ) return (full_path,) class SmartRemoveComments: """ A node that removes all comments starting with // anywhere in the line """ @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"multiline": True}), }, } RETURN_TYPES = ("STRING",) FUNCTION = "remove_comments" CATEGORY = "SmartHelperNodes" def remove_comments(self, text): # Split text into lines, remove comment portions, and rejoin lines = text.splitlines() processed_lines = [] for line in lines: # Find the position of // if it exists comment_pos = line.find('//') if comment_pos != -1: # Keep only the part before the comment line = line[:comment_pos].rstrip() # Only add non-empty lines after comment removal if line.strip(): processed_lines.append(line) return ("\n".join(processed_lines),) class SmartLoadLoRA: def __init__(self): self.loaded_lora = None @classmethod def INPUT_TYPES(s): return { "required": { "model": ("MODEL", {"tooltip": "The diffusion model the LoRA will be applied to."}), "clip": ("CLIP", {"tooltip": "The CLIP model the LoRA will be applied to."}), "lora_name": (folder_paths.get_filename_list("loras"), {"tooltip": "The name of the LoRA."}), "strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "tooltip": "How strongly to modify the diffusion model. This value can be negative."}), "strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "tooltip": "How strongly to modify the CLIP model. This value can be negative."}), }, "optional": { "model2": ("MODEL", {"tooltip": "Optional second diffusion model; the same LoRA is applied to both models (UNet only on this input)."}), "lora_string": ("STRING", {"forceInput": True, "default": ""}) } } RETURN_TYPES = ("MODEL", "CLIP", "STRING", "MODEL") RETURN_NAMES = ("model", "clip", "lora_string", "model2") FUNCTION = "load_lora" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Load a LoRA model (just like the core LoRA loader), but also outputs the model name and strength to a formatted string. Optional second model applies the same UNet LoRA." def load_lora(self, model, clip, lora_name, strength_model, strength_clip, model2=None, lora_string=""): if strength_model == 0 and strength_clip == 0: m2_out = model2 if model2 is not None else model return (model, clip, lora_string, m2_out) lora_path = folder_paths.get_full_path("loras", lora_name) lora = None if self.loaded_lora is not None: if self.loaded_lora[0] == lora_path: lora = self.loaded_lora[1] else: self.loaded_lora = None if lora is None: lora = comfy.utils.load_torch_file(lora_path, safe_load=True) self.loaded_lora = (lora_path, lora) model_lora, clip_lora = comfy.sd.load_lora_for_models(model, clip, lora, strength_model, strength_clip) if model2 is not None: model2_lora, _ = comfy.sd.load_lora_for_models(model2, None, lora, strength_model, 0) else: model2_lora = model_lora # Create formatted string output lora_name_without_ext = lora_name.split(".")[0] new_lora_string = f"{lora_name_without_ext}, model_str:{strength_model}, clip_str:{strength_clip}" if lora_string: final_lora_string = f"{lora_string}\n{new_lora_string}" else: final_lora_string = new_lora_string return (model_lora, clip_lora, final_lora_string, model2_lora) class SmartLoadDoubleLoRA: def __init__(self): self.loaded_lora_1 = None self.loaded_lora_2 = None @classmethod def INPUT_TYPES(s): lora_list = folder_paths.get_filename_list("loras") return { "required": { "model1": ("MODEL", {"tooltip": "First diffusion model."}), "model2": ("MODEL", {"tooltip": "Second diffusion model."}), "clip": ("CLIP", {"tooltip": "The CLIP model both LoRAs will be applied to."}), "lora_name_1": (lora_list, {"tooltip": "LoRA applied to model1 and CLIP."}), "strength_model_1": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), "strength_clip_1": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), "lora_name_2": (lora_list, {"tooltip": "LoRA applied to model2 and CLIP."}), "strength_model_2": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), "strength_clip_2": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), }, "optional": { "lora_string": ("STRING", {"forceInput": True, "default": ""}) } } RETURN_TYPES = ("MODEL", "MODEL", "CLIP", "STRING") RETURN_NAMES = ("model1", "model2", "clip", "lora_string") FUNCTION = "load_double_lora" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Apply two different LoRAs: lora1 to model1, lora2 to model2, and both to CLIP sequentially." def _get_lora(self, cache_attr, lora_name): lora_path = folder_paths.get_full_path("loras", lora_name) cached = getattr(self, cache_attr) if cached is not None and cached[0] == lora_path: return cached[1] lora = comfy.utils.load_torch_file(lora_path, safe_load=True) setattr(self, cache_attr, (lora_path, lora)) return lora def load_double_lora(self, model1, model2, clip, lora_name_1, strength_model_1, strength_clip_1, lora_name_2, strength_model_2, strength_clip_2, lora_string=""): # LoRA 1 → model1 + clip if strength_model_1 == 0 and strength_clip_1 == 0: m1_out, clip_out = model1, clip else: lora1 = self._get_lora("loaded_lora_1", lora_name_1) m1_out, clip_out = comfy.sd.load_lora_for_models(model1, clip, lora1, strength_model_1, strength_clip_1) # LoRA 2 → model2 + clip (already patched by lora1) if strength_model_2 == 0 and strength_clip_2 == 0: m2_out = model2 else: lora2 = self._get_lora("loaded_lora_2", lora_name_2) m2_out, clip_out = comfy.sd.load_lora_for_models(model2, clip_out, lora2, strength_model_2, strength_clip_2) # Build lora_string parts = [] if lora_string: parts.append(lora_string) n1 = lora_name_1.split(".")[0] parts.append(f"{n1}, model_str:{strength_model_1}, clip_str:{strength_clip_1}") n2 = lora_name_2.split(".")[0] parts.append(f"{n2}, model_str:{strength_model_2}, clip_str:{strength_clip_2}") return (m1_out, m2_out, clip_out, "\n".join(parts)) class SmartPrompt: """ A node that provides a multiline text input, removes comments, supports random selection syntax { A | B | C }, and optionally encodes it using a CLIP model. """ @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"multiline": True, "default": "", "tooltip": "Enter your prompt text. Lines with // comments will be cleaned up. Use { A | B | C } for random selection."}), }, "optional": { "clip": ("CLIP", {"tooltip": "Optional CLIP model for encoding the text."}), "seed": ("INT", {"forceInput": True, "tooltip": "Optional seed for deterministic random selections. Same seed always produces the same result."}) } } RETURN_TYPES = ("STRING", "CONDITIONING") RETURN_NAMES = ("text", "conditioning") FUNCTION = "process_prompt" CATEGORY = "SmartHelperNodes" @staticmethod def parse_random_syntax(text, rng=None): """Parses the { A | B | C } syntax and selects a random element. If rng is provided, uses it for deterministic results; otherwise uses global random.""" chooser = rng if rng is not None else random def replace_match(match): # Extract content inside braces and split by '|' options = [opt.strip() for opt in match.group(1).split('|')] # Return a randomly chosen option return chooser.choice(options) # Regex to find { content } where content contains at least one | # Allows empty options, e.g. {cat|} means "cat or nothing" pattern = r'\{([^}]*?\|[^}]*?)\}' # Repeatedly apply substitution until no more matches are found # This handles multiple occurrences, e.g., "{a|b} and {c|d}" processed_text = text # Limit iterations to prevent potential infinite loops with malformed input, though unlikely with this pattern iterations = 0 max_iterations = 100 # Safety limit while re.search(pattern, processed_text) and iterations < max_iterations: # Replace one occurrence at a time processed_text = re.sub(pattern, replace_match, processed_text, count=1) iterations += 1 if iterations >= max_iterations: print(f"Warning: Max iterations reached during random syntax processing in SmartPrompt. Input text: {text}") return processed_text def process_prompt(self, text, clip=None, seed=None): # 1. Remove comments lines = text.splitlines() processed_lines = [] for line in lines: comment_pos = line.find('//') if comment_pos != -1: line = line[:comment_pos].rstrip() # Keep lines that are not empty after comment removal if line.strip(): processed_lines.append(line) comment_cleaned_text = "\n".join(processed_lines) # 2. Process random syntax { A | B | C } # Use a seeded local RNG if seed is provided for deterministic results rng = random.Random(seed) if seed is not None else None processed_text = self.parse_random_syntax(comment_cleaned_text, rng=rng) # 3. Encode if CLIP is provided conditioning = None if clip is not None: tokens = clip.tokenize(processed_text) # Use encode_from_tokens_scheduled if available, otherwise fall back to encode if hasattr(clip, 'encode_from_tokens_scheduled'): conditioning = clip.encode_from_tokens_scheduled(tokens) elif hasattr(clip, 'encode'): # Standard CLIPTextEncode uses encode conditioning, _ = clip.encode_token_weights(tokens) # Assuming encode returns pooled_output as second value else: print(f"Warning: Provided CLIP object for SmartPrompt node is missing a recognized encoding method (encode or encode_from_tokens_scheduled).") # Return empty conditioning or handle appropriately # For now, returning None which might cause downstream issues if conditioning is expected. # A more robust solution might involve returning a default "empty" conditioning object if possible. return (processed_text, conditioning) @classmethod def IS_CHANGED(cls, text, clip=None, seed=None, **kwargs): # Check if the random syntax { A | B } is present in the input text # but ignore patterns that have a comment // before the opening { on the same line lines = text.splitlines() pattern = r'\{([^}]*?\|[^}]*?)\}' has_random_syntax = False for line in lines: # Skip lines that have // before any { on that line comment_pos = line.find('//') brace_pos = line.find('{') # If there's a { and either no // or the // comes after the { if brace_pos != -1 and (comment_pos == -1 or comment_pos > brace_pos): if re.search(pattern, line): has_random_syntax = True break if has_random_syntax: # When seed is provided, the output is deterministic for the same text+seed if seed is not None: return hash((text, seed)) # No seed — force re-evaluation every time return time.time() # Hash the input text to detect changes. Add clip object's potential changes if needed. # Note: Hashing the clip object itself might be complex/unreliable. # Relying on text changes is standard for prompt nodes. return hash(text) class SmartModelOrLoraToString: @classmethod def INPUT_TYPES(s): return { "optional": { "model": ("WANVIDEOMODEL", {"default": None}), "lora": ("WANVIDLORA", {"default": None}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("output_string",) FUNCTION = "process" CATEGORY = "WanVideoWrapper" DESCRIPTION = "Converts a model and/or LORA object to a formatted string" def process(self, model=None, lora=None): output_lines = [] # Process model if provided if model is not None: try: # Access the model name from the pipeline dictionary in the nested structure if hasattr(model, 'model') and hasattr(model.model, 'pipeline') and isinstance(model.model.pipeline, dict) and 'model_name' in model.model.pipeline: model_name = model.model.pipeline['model_name'] # Remove .safetensors extension if present if model_name.endswith('.safetensors'): model_name = model_name[:-12] # Remove the last 12 characters ('.safetensors') output_lines.append(f"MODEL: {model_name}") else: output_lines.append("MODEL: unknown") except Exception as e: output_lines.append(f"MODEL: unknown ({str(e)})") # Process lora if provided if lora is not None: for lora_item in lora: name = lora_item["name"] strength = lora_item["strength"] output_lines.append(f"LORA: {name} : {strength:.2f}") return ("\n".join(output_lines),) class SmartShowAnything: """ A node that displays any type of input as text and returns it unchanged. Supports recursive object unfolding and proper handling of binary data. """ @classmethod def INPUT_TYPES(s): return {"required": {}, "optional": {"anything": (any_type, {})}, "hidden": {"unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"} } RETURN_TYPES = (any_type,) RETURN_NAMES = ('output',) INPUT_IS_LIST = True OUTPUT_NODE = True FUNCTION = "log_input" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Displays any type of content as text in the UI and passes it through unchanged. Also supports recursive object unfolding." def log_input(self, unique_id=None, extra_pnginfo=None, **kwargs): values = [] if "anything" in kwargs: for val in kwargs['anything']: try: if type(val) is str: values.append(val) elif type(val) is list: values = val else: # Use the new format_object function for recursive object formatting formatted_val = format_object(val, max_depth=5) values.append(formatted_val) except Exception as e: values.append(f"Error formatting object: {str(e)}") pass if not extra_pnginfo: print("Error: extra_pnginfo is empty") elif (not isinstance(extra_pnginfo[0], dict) or "workflow" not in extra_pnginfo[0]): print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key") else: workflow = extra_pnginfo[0]["workflow"] node = next((x for x in workflow["nodes"] if str(x["id"]) == unique_id[0]), None) if node: node["widgets_values"] = [values] if isinstance(values, list) and len(values) == 1: return {"ui": {"text": values}, "result": (values[0],), } else: return {"ui": {"text": values}, "result": (values,), } class SmartSetInt: """ A node that sets the output to a specific integer value. When bypassed, it passes through the input value instead. """ @classmethod def INPUT_TYPES(cls): return { "required": { "input": ("INT", {"default": 0, "tooltip": "Input value (used when node is bypassed)"}), "value": ("INT", {"default": 0, "tooltip": "Output value (used when node is active)"}), }, } RETURN_TYPES = ("INT",) RETURN_NAMES = ("output",) FUNCTION = "set_value" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Sets the output to a specific integer value. When bypassed, passes through the input value." def set_value(self, input, value): return (value,) @classmethod def check_lazy_status(cls, input, value): # This indicates which inputs are needed # When bypassed, ComfyUI will pass through the input return ["value"] class SmartSetFloat: """ A node that sets the output to a specific float value. When bypassed, it passes through the input value instead. """ @classmethod def INPUT_TYPES(cls): return { "required": { "input": ("FLOAT", {"default": 0.0, "tooltip": "Input value (used when node is bypassed)"}), "value": ("FLOAT", {"default": 0.0, "tooltip": "Output value (used when node is active)"}), }, } RETURN_TYPES = ("FLOAT",) RETURN_NAMES = ("output",) FUNCTION = "set_value" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Sets the output to a specific float value. When bypassed, passes through the input value." def set_value(self, input, value): return (value,) @classmethod def check_lazy_status(cls, input, value): # This indicates which inputs are needed # When bypassed, ComfyUI will pass through the input return ["value"] class SmartModelInfo: """ A node that displays information about a MODEL object and passes it through. """ @classmethod def INPUT_TYPES(cls): return { "required": { "model": ("MODEL", {"tooltip": "Model to inspect"}), }, } RETURN_TYPES = ("MODEL", "STRING") RETURN_NAMES = ("model", "info") FUNCTION = "get_model_info" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Displays information about a MODEL object including config details" OUTPUT_NODE = True def get_model_info(self, model): info_lines = [] # Try to get model config info try: if hasattr(model, 'model') and hasattr(model.model, 'model_config'): config = model.model.model_config info_lines.append("=== Model Config ===") # Get model type/class name if hasattr(config, '__class__'): info_lines.append(f"Type: {config.__class__.__name__}") # Get unet config if available if hasattr(config, 'unet_config'): unet_config = config.unet_config if isinstance(unet_config, dict): info_lines.append("\n=== UNet Config ===") for key, value in unet_config.items(): if isinstance(value, (int, float, str, bool)): info_lines.append(f"{key}: {value}") # Check for latent format if hasattr(config, 'latent_format'): latent_format = config.latent_format if hasattr(latent_format, '__class__'): info_lines.append(f"\nLatent Format: {latent_format.__class__.__name__}") except Exception as e: info_lines.append(f"Error reading model config: {str(e)}") # Check model_options try: if hasattr(model, 'model_options'): options = model.model_options if options and isinstance(options, dict): info_lines.append("\n=== Model Options ===") for key, value in options.items(): if key != 'transformer_options' and isinstance(value, (int, float, str, bool)): info_lines.append(f"{key}: {value}") except Exception as e: info_lines.append(f"Error reading model options: {str(e)}") # Check attachments (where custom data might be stored) try: if hasattr(model, 'attachments'): attachments = model.attachments if attachments and isinstance(attachments, dict): info_lines.append("\n=== Attachments ===") for key, value in attachments.items(): info_lines.append(f"{key}: {value}") except Exception as e: info_lines.append(f"Error reading attachments: {str(e)}") if not info_lines: info_lines.append("No model information available") info_text = "\n".join(info_lines) return (model, info_text) class SmartHWMonitor: """ A node that monitors hardware metrics including CPU/GPU temperature and RAM usage. """ @classmethod def INPUT_TYPES(cls): return { "required": { "refresh_rate": ("INT", {"default": 1, "min": 1, "max": 60, "step": 1, "tooltip": "Refresh rate in seconds for hardware monitoring"}), "include_cpu_temp": ("BOOLEAN", {"default": True, "tooltip": "Include CPU temperature in output"}), "include_gpu_temp": ("BOOLEAN", {"default": True, "tooltip": "Include GPU temperature in output"}), "include_ram": ("BOOLEAN", {"default": True, "tooltip": "Include RAM usage in output"}), "include_cpu_usage": ("BOOLEAN", {"default": True, "tooltip": "Include CPU usage percentage in output"}), "include_vram": ("BOOLEAN", {"default": True, "tooltip": "Include VRAM usage in output"}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("hw_info",) FUNCTION = "get_hardware_info" CATEGORY = "SmartHelperNodes" OUTPUT_NODE = True DESCRIPTION = "Monitor hardware metrics including CPU/GPU temperature and RAM usage" def get_hardware_info(self, refresh_rate, include_cpu_temp, include_gpu_temp, include_ram, include_cpu_usage, include_vram): hw_info_lines = [] hw_info_lines.append(f"=== Hardware Monitor ({time.strftime('%H:%M:%S')}) ===") # CPU Usage if include_cpu_usage: try: import psutil cpu_percent = psutil.cpu_percent(interval=0.1) hw_info_lines.append(f"CPU Usage: {cpu_percent:.1f}%") except ImportError: hw_info_lines.append("CPU Usage: psutil not available") except Exception as e: hw_info_lines.append(f"CPU Usage: Error - {str(e)}") # CPU Temperature if include_cpu_temp: try: import psutil cpu_temp = None # Method 1: Try psutil sensors (Linux/some Windows) if hasattr(psutil, 'sensors_temperatures'): temps = psutil.sensors_temperatures() # Try different sensor names for CPU temperature for sensor_name in ['coretemp', 'cpu_thermal', 'k10temp', 'acpi']: if sensor_name in temps and temps[sensor_name]: cpu_temp = temps[sensor_name][0].current break # Method 2: Try Windows WMI Win32_TemperatureProbe if cpu_temp is None and platform.system() == "Windows": try: import subprocess result = subprocess.run([ 'wmic', 'path', 'Win32_TemperatureProbe', 'get', 'CurrentReading', '/format:value' ], capture_output=True, text=True, timeout=10) if result.returncode == 0: for line in result.stdout.split('\n'): if 'CurrentReading=' in line: temp_value = line.split('=')[1].strip() if temp_value.isdigit(): # Win32_TemperatureProbe returns tenths of Kelvin temp_celsius = (int(temp_value) / 10.0) - 273.15 # Only accept reasonable temperature values if 0 <= temp_celsius <= 120: cpu_temp = temp_celsius break except Exception: pass # Method 3: Try Windows WMI MSAcpi_ThermalZoneTemperature if cpu_temp is None and platform.system() == "Windows": try: import subprocess result = subprocess.run([ 'wmic', '/namespace:\\\\root\\wmi', 'path', 'MSAcpi_ThermalZoneTemperature', 'get', 'CurrentTemperature', '/format:value' ], capture_output=True, text=True, timeout=10) if result.returncode == 0: for line in result.stdout.split('\n'): if 'CurrentTemperature=' in line: temp_value = line.split('=')[1].strip() if temp_value.isdigit(): # Convert from tenths of Kelvin to Celsius temp_celsius = (int(temp_value) / 10.0) - 273.15 # Only accept reasonable temperature values if 0 <= temp_celsius <= 120: cpu_temp = temp_celsius break except Exception: pass # Method 4: Try Windows Performance Counters if cpu_temp is None and platform.system() == "Windows": try: import subprocess result = subprocess.run([ 'typeperf', '\\Thermal Zone Information(*)\\Temperature', '-sc', '1' ], capture_output=True, text=True, timeout=10) if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: if 'Temperature' in line and ',' in line: parts = line.split(',') if len(parts) >= 2: temp_str = parts[-1].strip().replace('"', '') try: temp_value = float(temp_str) temp_celsius = temp_value - 273.15 # Convert Kelvin to Celsius # Only accept reasonable temperature values if 0 <= temp_celsius <= 120: cpu_temp = temp_celsius break except ValueError: continue except Exception: pass # Method 5: Try reading thermal zones on Linux if cpu_temp is None and platform.system() == "Linux": try: with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: temp_millicelsius = int(f.read().strip()) cpu_temp = temp_millicelsius / 1000.0 except (FileNotFoundError, ValueError, PermissionError): pass # Method 6: Try alternative thermal zones on Linux if cpu_temp is None and platform.system() == "Linux": try: import glob thermal_zones = glob.glob('/sys/class/thermal/thermal_zone*/temp') for zone_file in thermal_zones: try: with open(zone_file, 'r') as f: temp_millicelsius = int(f.read().strip()) potential_temp = temp_millicelsius / 1000.0 # Filter reasonable CPU temperatures (0-120°C) if 0 <= potential_temp <= 120: cpu_temp = potential_temp break except (ValueError, PermissionError): continue except Exception: pass # Method 7: Try Windows PowerShell WMI query if cpu_temp is None and platform.system() == "Windows": try: import subprocess result = subprocess.run([ 'powershell', '-Command', 'Get-WmiObject -Namespace "root\\wmi" -Class MSAcpi_ThermalZoneTemperature | Select-Object CurrentTemperature' ], capture_output=True, text=True, timeout=15) if result.returncode == 0: for line in result.stdout.split('\n'): line = line.strip() if line.isdigit(): temp_celsius = (int(line) / 10.0) - 273.15 # Only accept reasonable temperature values if 0 <= temp_celsius <= 120: cpu_temp = temp_celsius break except Exception: pass # Only show temperature if we found a real sensor reading if cpu_temp is not None: hw_info_lines.append(f"CPU Temperature: {cpu_temp:.1f}°C") else: # Provide helpful instructions based on platform if platform.system() == "Windows": hw_info_lines.append("CPU Temperature: Not available") hw_info_lines.append("To enable CPU temperature monitoring:") hw_info_lines.append("• Install OpenHardwareMonitor (ohm.openhardwaremonitor.org)") hw_info_lines.append("• OR install HWiNFO64 with shared memory enabled") hw_info_lines.append("• OR run ComfyUI as Administrator") hw_info_lines.append("• OR install motherboard-specific monitoring software") elif platform.system() == "Linux": hw_info_lines.append("CPU Temperature: Not available") hw_info_lines.append("To enable CPU temperature monitoring:") hw_info_lines.append("• Install lm-sensors: sudo apt install lm-sensors") hw_info_lines.append("• Run: sudo sensors-detect") hw_info_lines.append("• Load sensor modules: sudo modprobe coretemp") hw_info_lines.append("• Install psutil: pip install psutil") elif platform.system() == "Darwin": hw_info_lines.append("CPU Temperature: Not available") hw_info_lines.append("To enable CPU temperature monitoring:") hw_info_lines.append("• Install TG Pro or Macs Fan Control") hw_info_lines.append("• OR install iStat Menus") hw_info_lines.append("• macOS restricts direct sensor access") else: hw_info_lines.append("CPU Temperature: Not available on this platform") # If no real temperature found, don't show anything (no estimates) except ImportError: # Don't show error message, just skip temperature pass except Exception as e: # Don't show error message, just skip temperature pass # GPU Temperature if include_gpu_temp: gpu_temp_found = False # Try NVIDIA GPU first try: import subprocess result = subprocess.run(['nvidia-smi', '--query-gpu=temperature.gpu', '--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=5) if result.returncode == 0: temps = result.stdout.strip().split('\n') for i, temp in enumerate(temps): if temp.strip(): hw_info_lines.append(f"GPU {i} Temperature: {temp.strip()}°C") gpu_temp_found = True except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError): pass except Exception as e: pass # Try AMD GPU if NVIDIA not found if not gpu_temp_found: try: import psutil if hasattr(psutil, 'sensors_temperatures'): temps = psutil.sensors_temperatures() for sensor_name in ['amdgpu', 'radeon']: if sensor_name in temps and temps[sensor_name]: for sensor in temps[sensor_name]: if 'edge' in sensor.label.lower() or 'junction' in sensor.label.lower(): hw_info_lines.append(f"GPU Temperature: {sensor.current:.1f}°C") gpu_temp_found = True break if gpu_temp_found: break except Exception: pass if not gpu_temp_found: hw_info_lines.append("GPU Temperature: No compatible GPU found") # RAM Usage if include_ram: try: import psutil memory = psutil.virtual_memory() ram_used_gb = memory.used / (1024**3) ram_total_gb = memory.total / (1024**3) ram_percent = memory.percent hw_info_lines.append(f"RAM Usage: {ram_used_gb:.1f}GB / {ram_total_gb:.1f}GB ({ram_percent:.1f}%)") except ImportError: hw_info_lines.append("RAM Usage: psutil not available") except Exception as e: hw_info_lines.append(f"RAM Usage: Error - {str(e)}") # System info hw_info_lines.append(f"System: {platform.system()} {platform.release()}") # VRAM Usage if include_vram: vram_found = False # Try NVIDIA GPU VRAM try: import subprocess result = subprocess.run(['nvidia-smi', '--query-gpu=memory.used,memory.total', '--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=5) if result.returncode == 0: lines = result.stdout.strip().split('\n') for i, line in enumerate(lines): if line.strip(): parts = line.strip().split(', ') if len(parts) == 2: used_mb = int(parts[0]) total_mb = int(parts[1]) used_gb = used_mb / 1024 total_gb = total_mb / 1024 percent = (used_mb / total_mb) * 100 hw_info_lines.append(f"GPU {i} VRAM: {used_gb:.1f}GB / {total_gb:.1f}GB ({percent:.1f}%)") vram_found = True except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError): pass except Exception: pass # Try AMD GPU VRAM (if available) if not vram_found: try: # Try reading from /sys filesystem on Linux for AMD if platform.system() == "Linux": import glob amd_cards = glob.glob('/sys/class/drm/card*/device/mem_info_vram_*') for card_path in amd_cards: try: with open(card_path, 'r') as f: vram_info = f.read().strip() # Parse AMD VRAM info if format is known hw_info_lines.append(f"AMD VRAM: {vram_info}") vram_found = True break except: continue except Exception: pass if not vram_found: hw_info_lines.append("VRAM: No compatible GPU found") return ("\n".join(hw_info_lines),) @classmethod def IS_CHANGED(cls, refresh_rate, include_cpu_temp, include_gpu_temp, include_ram, include_cpu_usage, include_vram): # Force refresh based on refresh_rate import time return int(time.time() / refresh_rate) _SMARTHELPER_GGUF_PKG = "SmartHelperGGUFPkg" def _get_unet_gguf_filenames(): try: return folder_paths.get_filename_list("unet_gguf") except KeyError: return [] def _ensure_comfyui_gguf_package(): if _SMARTHELPER_GGUF_PKG in sys.modules: return root = None for custom_root in folder_paths.get_folder_paths("custom_nodes"): for name in ("ComfyUI-GGUF", "comfyui-gguf"): candidate = os.path.join(custom_root, name) if os.path.isfile(os.path.join(candidate, "__init__.py")): root = candidate break if root: break if not root: raise RuntimeError( "ComfyUI-GGUF is required for GGUF mode. Install from https://github.com/city96/ComfyUI-GGUF" ) init_py = os.path.join(root, "__init__.py") spec = importlib.util.spec_from_file_location( _SMARTHELPER_GGUF_PKG, init_py, submodule_search_locations=[root], ) mod = importlib.util.module_from_spec(spec) sys.modules[_SMARTHELPER_GGUF_PKG] = mod spec.loader.exec_module(mod) def _load_gguf_model(unet_name: str): _ensure_comfyui_gguf_package() nodes_mod = importlib.import_module(f"{_SMARTHELPER_GGUF_PKG}.nodes") return nodes_mod.UnetLoaderGGUF().load_unet(unet_name)[0] def _load_safetensors_diffusion_model(unet_name: str, weight_dtype: str): model_options = {} if weight_dtype == "fp8_e4m3fn": model_options["dtype"] = torch.float8_e4m3fn elif weight_dtype == "fp8_e4m3fn_fast": model_options["dtype"] = torch.float8_e4m3fn model_options["fp8_optimizations"] = True elif weight_dtype == "fp8_e5m2": model_options["dtype"] = torch.float8_e5m2 unet_path = folder_paths.get_full_path_or_raise("diffusion_models", unet_name) return comfy.sd.load_diffusion_model(unet_path, model_options=model_options) class SmartModelLoader: @classmethod def INPUT_TYPES(s): return { "required": { "gguf": ("BOOLEAN", {"default": False}), "unet_name": (folder_paths.get_filename_list("diffusion_models"),), "weight_dtype": (["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],), "gguf_name": (_get_unet_gguf_filenames(),), } } RETURN_TYPES = ("MODEL", "STRING") RETURN_NAMES = ("model", "model_name") FUNCTION = "load" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Load a diffusion model from .safetensors (Load Diffusion Model) or .gguf (Unet Loader GGUF)." def load(self, gguf, unet_name, weight_dtype, gguf_name): if gguf: model = _load_gguf_model(gguf_name) return (model, gguf_name) model = _load_safetensors_diffusion_model(unet_name, weight_dtype) return (model, unet_name) class SmartDualModelLoader: @classmethod def INPUT_TYPES(s): return { "required": { "gguf": ("BOOLEAN", {"default": False}), "unet_name_1": (folder_paths.get_filename_list("diffusion_models"),), "unet_name_2": (folder_paths.get_filename_list("diffusion_models"),), "weight_dtype": (["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],), "gguf_name_1": (_get_unet_gguf_filenames(),), "gguf_name_2": (_get_unet_gguf_filenames(),), } } RETURN_TYPES = ("MODEL", "MODEL", "STRING") RETURN_NAMES = ("model_1", "model_2", "model_names") FUNCTION = "load" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Load two diffusion models (safetensors or GGUF) with a combined name summary." def load(self, gguf, unet_name_1, unet_name_2, weight_dtype, gguf_name_1, gguf_name_2): if gguf: m1 = _load_gguf_model(gguf_name_1) m2 = _load_gguf_model(gguf_name_2) n1, n2 = gguf_name_1, gguf_name_2 else: m1 = _load_safetensors_diffusion_model(unet_name_1, weight_dtype) m2 = _load_safetensors_diffusion_model(unet_name_2, weight_dtype) n1, n2 = unet_name_1, unet_name_2 text = f"Model #1: {n1}\nModel #2: {n2}" return (m1, m2, text) class SmartGetParametersAsString: @classmethod def INPUT_TYPES(cls): return { "required": { "mode": (["full_params", "node_status"], {"default": "full_params"}), }, "optional": { "source": (any_type, {"tooltip": "Connect to any node output to read that node's parameters"}), "params_str": ("STRING", {"forceInput": True, "default": ""}), }, "hidden": { "unique_id": "UNIQUE_ID", "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("params_str",) FUNCTION = "get_parameters" CATEGORY = "SmartHelperNodes" OUTPUT_NODE = True DESCRIPTION = "Connect to any node's output to read all its widget parameter names and values as a multi-line string." def get_parameters(self, mode, unique_id=None, prompt=None, extra_pnginfo=None, source=None, params_str=""): def _append_value(new_value): if params_str: return (f"{params_str}\n-------------------------------------\n{new_value}",) return (new_value,) if prompt is None or unique_id is None: return _append_value("") node_id = str(unique_id[0] if isinstance(unique_id, (list, tuple)) else unique_id) my_node = prompt.get(node_id) if not my_node or not isinstance(my_node, dict): return _append_value("") source_raw = (my_node.get("inputs") or {}).get("source") if source_raw is None or not is_link(source_raw): return _append_value("No source node connected.") source_node_id = str(source_raw[0]) source_node = prompt.get(source_node_id) if not source_node or not isinstance(source_node, dict): return _append_value(f"Could not find source node {source_node_id}.") node_title = source_node.get("class_type", "Unknown") node_status = "ON" workflow_node = _get_workflow_node_by_id(source_node_id, extra_pnginfo) if workflow_node: node_title = workflow_node.get("title") or node_title node_mode = workflow_node.get("mode", 0) if node_mode == 2: node_status = "muted" elif node_mode == 4: node_status = "bypassed" if mode == "node_status": return _append_value(f"{node_title}: {node_status}") if node_status != "ON": return _append_value(f"NODE: {node_title}, {node_status}") source_inputs = source_node.get("inputs", {}) lines = [f"NODE: {node_title}"] for param_name, param_value in source_inputs.items(): if is_link(param_value): lines.append(f"{param_name}: ") else: lines.append(f"{param_name}: {param_value}") return _append_value("\n".join(lines)) class SmartBypassSwitcher: @classmethod def INPUT_TYPES(cls): dynamic_inputs = { "input1": (any_type, {"lazy": True, "tooltip": "Value to pass through when control 1 is active."}), "control1": (any_type, {"lazy": True, "tooltip": "Connect any node output here. If that source node is active, input 1 is selected."}), } stack = inspect.stack() if len(stack) > 1 and stack[1].function == "get_input_info": class AllContainer: def __contains__(self, item): return True def __getitem__(self, key): if str(key).startswith("control"): return any_type, {"lazy": True} return any_type, {"lazy": True} dynamic_inputs = AllContainer() return { "required": {}, "optional": dynamic_inputs, "hidden": { "unique_id": "UNIQUE_ID", "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", }, } RETURN_TYPES = (any_type, "INT") RETURN_NAMES = ("value", "selected_index") FUNCTION = "switch" CATEGORY = "SmartHelperNodes" DESCRIPTION = "Selects the first input whose matching control source node is connected and not muted or bypassed." @staticmethod def _normalize_unique_id(unique_id): if unique_id is None: return None if isinstance(unique_id, (list, tuple)): if not unique_id: return None return str(unique_id[0]) return str(unique_id) @classmethod def _get_candidate_indices(cls, node_inputs): indices = set() for key in (node_inputs or {}): if key.startswith("input") or key.startswith("control"): suffix = key[5:] if key.startswith("input") else key[7:] if suffix.isdigit(): indices.add(int(suffix)) return sorted(indices) @classmethod def _get_selected_index(cls, prompt, unique_id, extra_pnginfo): if prompt is None: return 0 node_id = cls._normalize_unique_id(unique_id) if node_id is None: return 0 switch_node = prompt.get(node_id) if not isinstance(switch_node, dict): return 0 node_inputs = switch_node.get("inputs") or {} for index in cls._get_candidate_indices(node_inputs): control_key = f"control{index}" control_raw = node_inputs.get(control_key) if control_raw is None or not is_link(control_raw): continue control_node_id = str(control_raw[0]) workflow_node = _get_workflow_node_by_id(control_node_id, extra_pnginfo) if workflow_node: node_mode = workflow_node.get("mode", 0) if node_mode in (2, 4): continue input_key = f"input{index}" input_raw = node_inputs.get(input_key) if input_raw is None or not is_link(input_raw): continue return index return 0 def check_lazy_status(self, *args, **kwargs): selected_index = self._get_selected_index( kwargs.get("prompt"), kwargs.get("unique_id"), kwargs.get("extra_pnginfo"), ) if selected_index <= 0: return [] return [f"input{selected_index}"] def switch(self, unique_id=None, prompt=None, extra_pnginfo=None, **kwargs): selected_index = self._get_selected_index(prompt, unique_id, extra_pnginfo) if selected_index <= 0: return (None, 0) selected_key = f"input{selected_index}" return (kwargs.get(selected_key), selected_index) NODE_CLASS_MAPPINGS = { "SmartHVLoraSelect": SmartHVLoraSelect, "SmartHVLoraStack": SmartHVLoraStack, "SmartFormatString": SmartFormatString, "SmartFormatString10": SmartFormatString10, "SmartSaveText": SmartSaveText, "SmartSaveAnimatedGIF": SmartSaveAnimatedGIF, "SmartRemoveComments": SmartRemoveComments, "SmartLoadLoRA": SmartLoadLoRA, "SmartLoadDoubleLoRA": SmartLoadDoubleLoRA, "SmartPrompt": SmartPrompt, "SmartModelOrLoraToString": SmartModelOrLoraToString, "SmartShowAnything": SmartShowAnything, "SmartHWMonitor": SmartHWMonitor, "SmartSetInt": SmartSetInt, "SmartSetFloat": SmartSetFloat, "SmartBusIn": SmartBusIn, "SmartBusOut": SmartBusOut, "SmartBusIn5": SmartBusIn5, "SmartBusOut5": SmartBusOut5, "SmartModelInfo": SmartModelInfo, "SmartModelLoader": SmartModelLoader, "SmartDualModelLoader": SmartDualModelLoader, "SmartGetParametersAsString": SmartGetParametersAsString, "SmartBypassSwitcher": SmartBypassSwitcher, } NODE_DISPLAY_NAME_MAPPINGS = { "SmartHVLoraSelect": "Smart HunyuanVideo Lora Select", "SmartHVLoraStack": "Smart HunyuanVideo Lora Stack", "SmartFormatString": "Smart Format String", "SmartFormatString10": "Smart Format String (10 params)", "SmartSaveText": "Smart Save Text File", "SmartSaveAnimatedGIF": "Smart Save Animated GIF", "SmartRemoveComments": "Smart Remove Comments", "SmartLoadLoRA": "Smart Load LoRA", "SmartLoadDoubleLoRA": "Smart Load Double LoRA", "SmartPrompt": "Smart Prompt", "SmartModelOrLoraToString": "Smart Model or Lora to String", "SmartShowAnything": "Smart Show Anything", "SmartHWMonitor": "Smart Hardware Monitor", "SmartSetInt": "Smart Set Int", "SmartSetFloat": "Smart Set Float", "SmartBusIn": "Smart Bus In", "SmartBusOut": "Smart Bus Out", "SmartBusIn5": "Smart Bus In 5", "SmartBusOut5": "Smart Bus Out 5", "SmartModelInfo": "Smart Model Info", "SmartModelLoader": "Smart Model Loader", "SmartDualModelLoader": "Smart Dual Model Loader", "SmartGetParametersAsString": "Smart Get Parameters As String", "SmartBypassSwitcher": "Bypass Switcher", }