Initial release
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<div align="center">
|
||||
|
||||
# Atom_AI — Custom ComfyUI Nodes
|
||||
|
||||
**A clean, practical node pack for LoRA management and image enhancement.**
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Made with ❤️ by <a href="https://github.com/AtomAI-GR">Atom_AI</a>
|
||||
</div>
|
||||
+15
@@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user