commit 4c68e124430fed48c764b8bf19a968a9cd5d1bfa Author: AtomAI-GR Date: Sat May 2 18:20:30 2026 +0300 Initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..396f280 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +
+ +# Atom_AI โ€” Custom ComfyUI Nodes + +**A clean, practical node pack for LoRA management and image enhancement.** + +![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-brightgreen) +![Python](https://img.shields.io/badge/Python-3.10+-blue) +![License](https://img.shields.io/badge/License-MIT-yellow) + +
+ +--- + +## Nodes + +### ๐Ÿ” LoRA Loader +The simplest way to load multiple LoRAs in one node. +Add or remove slots with **+** / **โˆ’** buttons. Each slot has a strength slider and an enable/disable toggle. + +### โš™๏ธ LoRA Loader Pro +Same as LoRA Loader but with separate **model strength** and **clip strength** per LoRA. +For advanced users who need fine control. + +### ๐Ÿ”— LoRA Stack +A chainable single LoRA slot. Connect multiple entries in sequence to build a stack of any size. + +### ๐Ÿ“ฆ LoRA Stack Apply +Takes the final stack and applies all LoRAs to model and clip in one pass. + +### ๐Ÿ” LoRA Stack Info +Displays the contents of a LoRA stack as text โ€” useful for debugging. + +### โœจ Detail Enhance +Post-processing node that sharpens and enhances detail in generated images. +Controls: **Sharpen**, **Detail**, **Contrast**, **Clarity** + +--- + +## Installation + +**Option A โ€” ComfyUI Manager** *(recommended)* +Search for `Atom_AI` in the Manager and install directly. + +**Option B โ€” Manual** +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/AtomAI-GR/Atom_AI +``` +Restart ComfyUI. + +--- + +## Usage + +### Basic LoRA workflow +``` +Load Checkpoint + โ”œโ”€โ”€ MODEL โ”€โ”€โ†’ LoRA Loader โ”€โ”€โ†’ model โ”€โ”€โ†’ KSampler + โ””โ”€โ”€ CLIP โ”€โ”€โ†’ LoRA Loader โ”€โ”€โ†’ clip โ”€โ”€โ†’ CLIP Text Encode +``` + +### With Detail Enhance +``` +VAE Decode โ”€โ”€โ†’ IMAGE โ”€โ”€โ†’ Detail Enhance โ”€โ”€โ†’ Preview Image +``` + +--- + +## Compatibility + +Works with all major model architectures: **SD 1.5 ยท SDXL ยท Flux ยท HiDream** + +--- + +## License + +MIT โ€” free to use, modify, and share. + +--- + +
+Made with โค๏ธ by Atom_AI +
diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b092ff9 --- /dev/null +++ b/__init__.py @@ -0,0 +1,15 @@ +from .nodes.detail_enhance import NODE_CLASS_MAPPINGS as ENHANCE_NODES +from .nodes.detail_enhance import NODE_DISPLAY_NAME_MAPPINGS as ENHANCE_NAMES +from .nodes.lora_stack import NODE_CLASS_MAPPINGS as LORA_NODES +from .nodes.lora_stack import NODE_DISPLAY_NAME_MAPPINGS as LORA_NAMES +from .nodes.lora_loader_pro import NODE_CLASS_MAPPINGS as DYN_NODES +from .nodes.lora_loader_pro import NODE_DISPLAY_NAME_MAPPINGS as DYN_NAMES +from .nodes.lora_loader import NODE_CLASS_MAPPINGS as SIMPLE_NODES +from .nodes.lora_loader import NODE_DISPLAY_NAME_MAPPINGS as SIMPLE_NAMES + +NODE_CLASS_MAPPINGS = {**ENHANCE_NODES, **LORA_NODES, **DYN_NODES, **SIMPLE_NODES} +NODE_DISPLAY_NAME_MAPPINGS = {**ENHANCE_NAMES, **LORA_NAMES, **DYN_NAMES, **SIMPLE_NAMES} + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..26da47d Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/realistic_touch_node.cpython-312.pyc b/__pycache__/realistic_touch_node.cpython-312.pyc new file mode 100644 index 0000000..df5ff30 Binary files /dev/null and b/__pycache__/realistic_touch_node.cpython-312.pyc differ diff --git a/nodes/__pycache__/detail_enhance.cpython-312.pyc b/nodes/__pycache__/detail_enhance.cpython-312.pyc new file mode 100644 index 0000000..f51be55 Binary files /dev/null and b/nodes/__pycache__/detail_enhance.cpython-312.pyc differ diff --git a/nodes/__pycache__/detail_enhance_node.cpython-312.pyc b/nodes/__pycache__/detail_enhance_node.cpython-312.pyc new file mode 100644 index 0000000..106f830 Binary files /dev/null and b/nodes/__pycache__/detail_enhance_node.cpython-312.pyc differ diff --git a/nodes/__pycache__/dynamic_lora_node.cpython-312.pyc b/nodes/__pycache__/dynamic_lora_node.cpython-312.pyc new file mode 100644 index 0000000..8a30d59 Binary files /dev/null and b/nodes/__pycache__/dynamic_lora_node.cpython-312.pyc differ diff --git a/nodes/__pycache__/lora_loader.cpython-312.pyc b/nodes/__pycache__/lora_loader.cpython-312.pyc new file mode 100644 index 0000000..2b9ade8 Binary files /dev/null and b/nodes/__pycache__/lora_loader.cpython-312.pyc differ diff --git a/nodes/__pycache__/lora_loader_pro.cpython-312.pyc b/nodes/__pycache__/lora_loader_pro.cpython-312.pyc new file mode 100644 index 0000000..cb92c81 Binary files /dev/null and b/nodes/__pycache__/lora_loader_pro.cpython-312.pyc differ diff --git a/nodes/__pycache__/lora_stack.cpython-312.pyc b/nodes/__pycache__/lora_stack.cpython-312.pyc new file mode 100644 index 0000000..cd628d0 Binary files /dev/null and b/nodes/__pycache__/lora_stack.cpython-312.pyc differ diff --git a/nodes/__pycache__/multi_lora_stack_node.cpython-312.pyc b/nodes/__pycache__/multi_lora_stack_node.cpython-312.pyc new file mode 100644 index 0000000..abdecb7 Binary files /dev/null and b/nodes/__pycache__/multi_lora_stack_node.cpython-312.pyc differ diff --git a/nodes/__pycache__/realistic_touch_node.cpython-312.pyc b/nodes/__pycache__/realistic_touch_node.cpython-312.pyc new file mode 100644 index 0000000..89ebe1b Binary files /dev/null and b/nodes/__pycache__/realistic_touch_node.cpython-312.pyc differ diff --git a/nodes/__pycache__/simple_lora_node.cpython-312.pyc b/nodes/__pycache__/simple_lora_node.cpython-312.pyc new file mode 100644 index 0000000..86d887b Binary files /dev/null and b/nodes/__pycache__/simple_lora_node.cpython-312.pyc differ diff --git a/nodes/detail_enhance.py b/nodes/detail_enhance.py new file mode 100644 index 0000000..61a3244 --- /dev/null +++ b/nodes/detail_enhance.py @@ -0,0 +1,78 @@ +import torch +import numpy as np +from PIL import Image, ImageFilter, ImageEnhance + +class DetailEnhanceNode: + CATEGORY = "Atom_AI/Image" + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("enhanced_image",) + FUNCTION = "enhance" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "sharpen_strength": ("FLOAT", { + "default": 1.5, "min": 0.0, "max": 5.0, + "step": 0.1, "display": "slider" + }), + "detail_radius": ("FLOAT", { + "default": 1.0, "min": 0.5, "max": 5.0, + "step": 0.5, "display": "slider" + }), + "detail_strength": ("FLOAT", { + "default": 0.4, "min": 0.0, "max": 2.0, + "step": 0.1, "display": "slider" + }), + "contrast": ("FLOAT", { + "default": 1.1, "min": 0.5, "max": 2.0, + "step": 0.05, "display": "slider" + }), + "clarity": ("FLOAT", { + "default": 0.3, "min": 0.0, "max": 1.0, + "step": 0.05, "display": "slider" + }), + } + } + + @staticmethod + def _to_pil(t): + arr = (t[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8) + return Image.fromarray(arr) + + @staticmethod + def _to_tensor(img): + arr = np.array(img).astype(np.float32) / 255.0 + return torch.from_numpy(arr).unsqueeze(0) + + def enhance(self, image, sharpen_strength, detail_radius, + detail_strength, contrast, clarity): + + pil = self._to_pil(image) + + if contrast != 1.0: + pil = ImageEnhance.Contrast(pil).enhance(contrast) + + if sharpen_strength != 1.0: + pil = ImageEnhance.Sharpness(pil).enhance(sharpen_strength) + + if detail_strength > 0: + blurred = pil.filter(ImageFilter.GaussianBlur(radius=detail_radius)) + img_arr = np.array(pil, dtype=np.float32) + blur_arr = np.array(blurred, dtype=np.float32) + detail = img_arr - blur_arr + result = img_arr + detail * detail_strength + pil = Image.fromarray(result.clip(0, 255).astype(np.uint8)) + + if clarity > 0: + arr = np.array(pil, dtype=np.float32) / 255.0 + mid = 0.5 + arr = arr + clarity * 0.15 * np.sin((arr - mid) * np.pi) * (1 - np.abs(arr - mid) * 2).clip(0) + pil = Image.fromarray((arr.clip(0, 1) * 255).astype(np.uint8)) + + return (self._to_tensor(pil),) + + +NODE_CLASS_MAPPINGS = {"DetailEnhanceNode": DetailEnhanceNode} +NODE_DISPLAY_NAME_MAPPINGS = {"DetailEnhanceNode": "Detail Enhance"} diff --git a/nodes/lora_loader.py b/nodes/lora_loader.py new file mode 100644 index 0000000..391fa9e --- /dev/null +++ b/nodes/lora_loader.py @@ -0,0 +1,74 @@ +import folder_paths +import comfy.utils +import comfy.sd +import json + +def get_lora_list(): + return ["None"] + folder_paths.get_filename_list("loras") + + +class AtomSimpleLoRANode: + """ + Simple LoRA Loader โ€” clean UI, no clip str., strength 0-2. + """ + + CATEGORY = "Atom_AI/LoRA" + RETURN_TYPES = ("MODEL", "CLIP", "STRING") + RETURN_NAMES = ("model", "clip", "stack_info") + FUNCTION = "apply_loras" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + "loras_json": ("STRING", { + "default": "[]", + "multiline": False, + }), + } + } + + def apply_loras(self, model, clip, loras_json): + try: + entries = json.loads(loras_json) + except Exception: + return (model, clip, "Error: invalid loras_json") + + info_lines = [] + loaded = 0 + + for entry in entries: + name = entry.get("name", "None") + s_mod = float(entry.get("strength_model", 1.0)) + enabled = bool(entry.get("enabled", True)) + + if not enabled or name == "None": + continue + + lora_path = folder_paths.get_full_path("loras", name) + if lora_path is None: + info_lines.append(f" โœ— {name} (not found)") + continue + + lora_data = comfy.utils.load_torch_file(lora_path, safe_load=True) + # clip strength fixed at 1.0 + model, clip = comfy.sd.load_lora_for_models( + model, clip, lora_data, s_mod, 1.0 + ) + info_lines.append(f" โœ“ {name} (strength={s_mod:.2f})") + loaded += 1 + + total = len(entries) + header = f"Loaded {loaded} of {total} LoRA(s):" + info = header + "\n" + "\n".join(info_lines) if info_lines else header + return (model, clip, info) + + +NODE_CLASS_MAPPINGS = {"AtomSimpleLoRANode": AtomSimpleLoRANode} +NODE_DISPLAY_NAME_MAPPINGS = {"AtomSimpleLoRANode": "LoRA Loader"} diff --git a/nodes/lora_loader_pro.py b/nodes/lora_loader_pro.py new file mode 100644 index 0000000..674b752 --- /dev/null +++ b/nodes/lora_loader_pro.py @@ -0,0 +1,81 @@ +import folder_paths +import comfy.utils +import comfy.sd + +def get_lora_list(): + return ["None"] + folder_paths.get_filename_list("loras") + + +class AtomDynamicLoRANode: + """ + Dynamic LoRA Loader โ€” slots managed entirely by JS. + Python receives lora_count + flat arrays of name/strength_model/strength_clip/enabled. + """ + + CATEGORY = "Atom_AI/LoRA" + RETURN_TYPES = ("MODEL", "CLIP", "STRING") + RETURN_NAMES = ("model", "clip", "stack_info") + FUNCTION = "apply_loras" + + # Tell ComfyUI this node has dynamic widgets โ€” skip validation + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + @classmethod + def INPUT_TYPES(cls): + loras = get_lora_list() + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + # JS writes all LoRA data as a JSON string โ€” one input only + "loras_json": ("STRING", { + "default": "[]", + "multiline": False, + }), + } + } + + def apply_loras(self, model, clip, loras_json): + import json + + try: + entries = json.loads(loras_json) + except Exception: + return (model, clip, "Error: invalid loras_json") + + info_lines = [] + loaded = 0 + + for entry in entries: + name = entry.get("name", "None") + s_mod = float(entry.get("strength_model", 1.0)) + s_clip = float(entry.get("strength_clip", 1.0)) + enabled = bool(entry.get("enabled", True)) + + if not enabled or name == "None": + continue + + lora_path = folder_paths.get_full_path("loras", name) + if lora_path is None: + info_lines.append(f" โœ— {name} (not found)") + continue + + lora_data = comfy.utils.load_torch_file(lora_path, safe_load=True) + model, clip = comfy.sd.load_lora_for_models( + model, clip, lora_data, s_mod, s_clip + ) + info_lines.append( + f" โœ“ {name}\n model={s_mod:.2f} clip={s_clip:.2f}" + ) + loaded += 1 + + total = len(entries) + header = f"Loaded {loaded} of {total} LoRA(s):" + info = header + "\n" + "\n".join(info_lines) if info_lines else header + return (model, clip, info) + + +NODE_CLASS_MAPPINGS = {"AtomDynamicLoRANode": AtomDynamicLoRANode} +NODE_DISPLAY_NAME_MAPPINGS = {"AtomDynamicLoRANode": "LoRA Loader Pro"} diff --git a/nodes/lora_stack.py b/nodes/lora_stack.py new file mode 100644 index 0000000..97a94a5 --- /dev/null +++ b/nodes/lora_stack.py @@ -0,0 +1,165 @@ +import os +import folder_paths + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Helper: get all available LoRA filenames +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +def get_lora_list(): + loras = folder_paths.get_filename_list("loras") + return ["None"] + loras + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# NODE 1 โ€” LoRA Stack Entry +# One slot: pick a LoRA + strength + toggle. +# Chains into a growing stack list. +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class AtomLoRAStackEntry: + """ + A single chainable LoRA slot. + Connect multiple entries in sequence to build a stack of any size. + """ + + CATEGORY = "Atom_AI/LoRA" + RETURN_TYPES = ("LORA_STACK",) + RETURN_NAMES = ("lora_stack",) + FUNCTION = "add_lora" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "lora_name": (get_lora_list(),), + "strength_model": ("FLOAT", { + "default": 1.0, "min": -10.0, "max": 10.0, + "step": 0.01, "display": "slider" + }), + "strength_clip": ("FLOAT", { + "default": 1.0, "min": -10.0, "max": 10.0, + "step": 0.01, "display": "slider" + }), + "enabled": ("BOOLEAN", {"default": True}), + }, + "optional": { + "lora_stack": ("LORA_STACK",), # chain input from previous entry + } + } + + def add_lora(self, lora_name, strength_model, strength_clip, enabled, + lora_stack=None): + # Start from existing stack or fresh list + stack = list(lora_stack) if lora_stack else [] + + if enabled and lora_name != "None": + stack.append({ + "lora_name": lora_name, + "strength_model": strength_model, + "strength_clip": strength_clip, + }) + + return (stack,) + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# NODE 2 โ€” LoRA Stack Loader +# Takes the final stack list, applies every +# LoRA in sequence to model + clip. +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class AtomLoRAStackLoader: + """ + Applies a LORA_STACK (built from AtomLoRAStackEntry nodes) to + a model and clip in one pass. + """ + + CATEGORY = "Atom_AI/LoRA" + RETURN_TYPES = ("MODEL", "CLIP", "STRING") + RETURN_NAMES = ("model", "clip", "stack_info") + FUNCTION = "load_stack" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + "lora_stack": ("LORA_STACK",), + } + } + + def load_stack(self, model, clip, lora_stack): + import comfy.utils + + if not lora_stack: + return (model, clip, "No LoRAs loaded.") + + info_lines = [f"Loaded {len(lora_stack)} LoRA(s):"] + + for entry in lora_stack: + name = entry["lora_name"] + s_mod = entry["strength_model"] + s_clip = entry["strength_clip"] + + lora_path = folder_paths.get_full_path("loras", name) + lora_data = comfy.utils.load_torch_file(lora_path, safe_load=True) + + model, clip = comfy.sd.load_lora_for_models( + model, clip, lora_data, s_mod, s_clip + ) + + info_lines.append(f" โœ“ {name} (model={s_mod:.2f}, clip={s_clip:.2f})") + + return (model, clip, "\n".join(info_lines)) + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# NODE 3 โ€” LoRA Stack Inspector (bonus) +# Shows what's in the stack without applying. +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class AtomLoRAStackInspector: + """ + Displays the contents of a LORA_STACK as a human-readable string. + Useful for debugging complex chains. + """ + + CATEGORY = "Atom_AI/LoRA" + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("stack_info",) + FUNCTION = "inspect" + OUTPUT_NODE = True # allows display in UI + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "lora_stack": ("LORA_STACK",), + } + } + + def inspect(self, lora_stack): + if not lora_stack: + return ("Stack is empty.",) + + lines = [f"Stack contains {len(lora_stack)} LoRA(s):\n"] + for i, entry in enumerate(lora_stack, 1): + lines.append( + f"{i}. {entry['lora_name']}\n" + f" model strength : {entry['strength_model']:.3f}\n" + f" clip strength : {entry['strength_clip']:.3f}" + ) + return ("\n".join(lines),) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ComfyUI registration +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +NODE_CLASS_MAPPINGS = { + "AtomLoRAStackEntry": AtomLoRAStackEntry, + "AtomLoRAStackLoader": AtomLoRAStackLoader, + "AtomLoRAStackInspector": AtomLoRAStackInspector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AtomLoRAStackEntry": "LoRA Stack", + "AtomLoRAStackLoader": "LoRA Stack Apply", + "AtomLoRAStackInspector": "LoRA Stack Info", +} diff --git a/web/js/dynamic_lora_node.js b/web/js/dynamic_lora_node.js new file mode 100644 index 0000000..2da3a5b --- /dev/null +++ b/web/js/dynamic_lora_node.js @@ -0,0 +1,138 @@ +import { app } from "/scripts/app.js"; + +const DEFAULT_SLOTS = 1; + +function serializeSlots(node) { + const entries = (node._loraSlots ?? []).map(s => ({ + name: s.nameW.value, + strength_model: s.smW.value, + strength_clip: s.scW.value, + enabled: s.enW.value, + })); + const jw = node.widgets?.find(w => w.name === "loras_json"); + if (jw) jw.value = JSON.stringify(entries); +} + +function addSlot(node, def = {}) { + const loras = node._loraNames ?? ["None"]; + + const nameW = node.addWidget("combo", "lora", def.name ?? "None", + () => serializeSlots(node), { values: loras }); + + const smW = node.addWidget("number", "model str.", def.strength_model ?? 1.0, + () => serializeSlots(node), { min: -10, max: 10, step: 0.1, precision: 2 }); + + const scW = node.addWidget("number", "clip str.", def.strength_clip ?? 1.0, + () => serializeSlots(node), { min: -10, max: 10, step: 0.1, precision: 2 }); + + const enW = node.addWidget("toggle", "enabled", def.enabled ?? true, + () => serializeSlots(node)); + + node._loraSlots = node._loraSlots ?? []; + node._loraSlots.push({ nameW, smW, scW, enW }); + + serializeSlots(node); + node.setSize([node.size[0], node.computeSize()[1]]); + node.graph?.setDirtyCanvas(true, true); +} + +function removeSlot(node) { + if (!node._loraSlots?.length) return; + const s = node._loraSlots.pop(); + for (const w of [s.nameW, s.smW, s.scW, s.enW]) { + const i = node.widgets.indexOf(w); + if (i !== -1) node.widgets.splice(i, 1); + } + serializeSlots(node); + node.setSize([node.size[0], node.computeSize()[1]]); + node.graph?.setDirtyCanvas(true, true); +} + +function init(node) { + if (node._atomDynInit) return; + node._atomDynInit = true; + node._loraSlots = []; + + // Hide loras_json + const jw = node.widgets?.find(w => w.name === "loras_json"); + if (jw) { + jw.hidden = true; + jw.computeSize = () => [0, -4]; + } + + node.addWidget("button", "+ Add LoRA", null, () => addSlot(node)); + node.addWidget("button", "โˆ’ Remove LoRA", null, () => removeSlot(node)); + + // Restore from saved JSON or add defaults + let restored = false; + if (jw?.value && jw.value !== "[]") { + try { + const entries = JSON.parse(jw.value); + if (entries.length > 0) { + entries.forEach(e => addSlot(node, e)); + restored = true; + } + } catch {} + } + if (!restored) { + for (let i = 0; i < DEFAULT_SLOTS; i++) addSlot(node); + } +} + +// Fetch lora list directly from ComfyUI's /object_info endpoint +async function fetchLoraNames() { + try { + const r = await fetch("/object_info"); + const all = await r.json(); + + // Try every known lora loader node name + const candidates = [ + "LoraLoader", + "LoRALoader", + "Load LoRA", + "LoraLoaderModelOnly", + ]; + + for (const name of candidates) { + const def = all[name]; + if (!def) continue; + // Search all required inputs for a list that looks like lora filenames + for (const [, val] of Object.entries(def.input?.required ?? {})) { + if (Array.isArray(val[0]) && val[0].some(v => + typeof v === "string" && (v.endsWith(".safetensors") || v.endsWith(".pt") || v.endsWith(".ckpt")) + )) { + return ["None", ...val[0]]; + } + } + } + + // Last resort: scan ALL node defs for a safetensors list + for (const def of Object.values(all)) { + for (const [, val] of Object.entries(def.input?.required ?? {})) { + if (Array.isArray(val[0]) && val[0].length > 0 && + val[0].some(v => typeof v === "string" && v.includes("lora"))) { + return ["None", ...val[0]]; + } + } + } + } catch (e) { + console.warn("[Atom_AI] Could not fetch lora list:", e); + } + return ["None"]; +} + +app.registerExtension({ + name: "Atom_AI.DynamicLoRANode", + + async setup() { + // Fetch and cache lora list at startup + app._atomLoraNames = await fetchLoraNames(); + console.log("[Atom_AI] LoRA list loaded:", app._atomLoraNames.length, "entries"); + }, + + nodeCreated(node) { + if (node.comfyClass !== "AtomDynamicLoRANode") return; + node._loraNames = app._atomLoraNames ?? ["None"]; + init(node); + }, +}); diff --git a/web/js/simple_lora_node.js b/web/js/simple_lora_node.js new file mode 100644 index 0000000..45d0b3c --- /dev/null +++ b/web/js/simple_lora_node.js @@ -0,0 +1,84 @@ +import { app } from "/scripts/app.js"; + +const DEFAULT_SLOTS = 2; + +function serializeSlots(node) { + const entries = (node._loraSlots ?? []).map(s => ({ + name: s.nameW.value, + strength_model: s.smW.value, + enabled: s.enW.value, + })); + const jw = node.widgets?.find(w => w.name === "loras_json"); + if (jw) jw.value = JSON.stringify(entries); +} + +function addSlot(node, def = {}) { + const loras = node._loraNames ?? ["None"]; + + const nameW = node.addWidget("combo", "lora", def.name ?? "None", + () => serializeSlots(node), { values: loras }); + + const smW = node.addWidget("number", "strength", def.strength_model ?? 1.0, + () => serializeSlots(node), { min: 0.0, max: 2.0, step: 0.1, precision: 2 }); + + const enW = node.addWidget("toggle", "enabled", def.enabled ?? true, + () => serializeSlots(node)); + + node._loraSlots = node._loraSlots ?? []; + node._loraSlots.push({ nameW, smW, enW }); + + serializeSlots(node); + node.setSize([node.size[0], node.computeSize()[1]]); + node.graph?.setDirtyCanvas(true, true); +} + +function removeSlot(node) { + if (!node._loraSlots?.length) return; + const s = node._loraSlots.pop(); + for (const w of [s.nameW, s.smW, s.enW]) { + const i = node.widgets.indexOf(w); + if (i !== -1) node.widgets.splice(i, 1); + } + serializeSlots(node); + node.setSize([node.size[0], node.computeSize()[1]]); + node.graph?.setDirtyCanvas(true, true); +} + +function init(node) { + if (node._atomSimpleInit) return; + node._atomSimpleInit = true; + node._loraSlots = []; + + const jw = node.widgets?.find(w => w.name === "loras_json"); + if (jw) { + jw.hidden = true; + jw.computeSize = () => [0, -4]; + } + + node.addWidget("button", "+ Add LoRA", null, () => addSlot(node)); + node.addWidget("button", "โˆ’ Remove LoRA", null, () => removeSlot(node)); + + let restored = false; + if (jw?.value && jw.value !== "[]") { + try { + const entries = JSON.parse(jw.value); + if (entries.length > 0) { + entries.forEach(e => addSlot(node, e)); + restored = true; + } + } catch {} + } + if (!restored) { + for (let i = 0; i < DEFAULT_SLOTS; i++) addSlot(node); + } +} + +app.registerExtension({ + name: "Atom_AI.SimpleLoRANode", + + nodeCreated(node) { + if (node.comfyClass !== "AtomSimpleLoRANode") return; + node._loraNames = app._atomLoraNames ?? ["None"]; + init(node); + }, +});