Initial state
State at time of repository creation.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.env
|
||||
venv/
|
||||
*.log
|
||||
@@ -0,0 +1,166 @@
|
||||
# Neurad Visual LoRA Loader
|
||||
|
||||
**A powerful, user-friendly ComfyUI extension for managing, organizing, and loading LoRAs with metadata intelligence.**
|
||||
|
||||
Neurad Visual LoRA Loader replaces the standard text-based LoRA selection with a rich, visual interface. It features intelligent caching, tab-based organization, drag-and-drop workflow, and seamless integration with CivitAI to fetch metadata and trigger words automatically.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### 🎨 Visual Catalog & Grid
|
||||
* **Rich Previews:** Browse your LoRAs in a responsive grid with cover images.
|
||||
* **Smart Caching:** Images are cached locally and in-memory for instant loading, even with large libraries.
|
||||
* **Dynamic Filtering:** Filter by name, file path, folder, or state (Active/Inactive, Has Metadata/No Metadata).
|
||||
* **NSFW Auto-Filter:** Intelligent content filtering that hides NSFW-tagged files and folders unless explicitly requested.
|
||||
|
||||
### 🗂️ Tab-Based Organization
|
||||
* **Custom Tabs:** Create unlimited custom tabs to organize LoRAs by project, style, or character.
|
||||
* **Drag & Drop:** Easily move LoRAs between tabs or reorder them within a tab using intuitive drag-and-drop.
|
||||
* **Live Counters:** Each tab displays real-time counts of active vs. inactive LoRAs.
|
||||
|
||||
### 🧠 Metadata Intelligence
|
||||
* **CivitAI Integration:** One-click fetching of metadata (Trigger Words, Recommended Strength, Images) directly from CivitAI.
|
||||
* **Local Editing:** Override metadata manually. Edit names, set custom cover images, define trigger words, and set strength ranges.
|
||||
* **Dual Persistence:** Data is saved in both browser LocalStorage (for speed) and server-side disk storage (for backup and persistence across sessions).
|
||||
|
||||
### ⚡ Workflow Integration
|
||||
* **Visual Strength Control:** Adjust LoRA strength directly from the card.
|
||||
* **Active List Preview:** The node background displays a live list of currently active LoRAs and their strengths.
|
||||
* **Hidden Data Widget:** Seamlessly passes JSON data to the ComfyUI backend without cluttering the node interface.
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
*(Add your screenshots here in the `images/` folder and reference them below)*
|
||||
|
||||
| Visual Grid & Tabs | Metadata Editor |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
| *Browse and organize your library visually.* | *Edit trigger words, strengths, and cover images.* |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Installation
|
||||
|
||||
### Prerequisites
|
||||
* **ComfyUI** installed and running.
|
||||
* **Python 3.10+**
|
||||
|
||||
### Method 1: ComfyUI Manager (Recommended)
|
||||
1. Open **ComfyUI Manager**.
|
||||
2. Click **"Install Custom Nodes"**.
|
||||
3. Search for `Visual LoRA Loader`.
|
||||
4. Click **Install** and restart ComfyUI.
|
||||
|
||||
### Method 2: Manual Installation
|
||||
1. Navigate to your ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
```
|
||||
2. Clone this repository:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Visual-LoRA-Loader.git
|
||||
```
|
||||
3. **Restart ComfyUI** completely.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Usage Guide
|
||||
|
||||
### 1. Opening the Library
|
||||
* Add the **Visual LoRA Loader** node to your workflow.
|
||||
* Click the **"Open Library"** button on the node.
|
||||
* A large floating modal will appear with your full LoRA catalog.
|
||||
|
||||
### 2. Managing LoRAs
|
||||
* **Activate/Deactivate:** Click anywhere on a card to toggle it On/Off.
|
||||
* **Adjust Strength:** Use the `◄` / `►` buttons or click the number to type a specific strength value.
|
||||
* **Get Info:** If a card shows a "📥 Get Info" placeholder, click it to fetch metadata from CivitAI.
|
||||
* **Edit Details:** Click the **ℹ️** icon on a card to view details. Inside the modal, click **✏️ Edit** to manually change names, trigger words, cover images, or recommended strength.
|
||||
* **Image Details:** Inside the modal, click an image to zoom in. Images displaying the **📝** icon include available generation metadata.
|
||||
* **Trigger Words:** Inside the modal, you can select multiple trigger words simultaneously; they are automatically copied to the clipboard as a comma-separated list.
|
||||
|
||||
### 3. Organizing with Tabs
|
||||
* **Create Tab:** Click the **+** button in the tab bar.
|
||||
* **Rename:** Double-click a tab name.
|
||||
* **Move LoRAs:** Drag a card from the "Library" tab (or any other tab) and drop it onto a custom tab to copy it there.
|
||||
* **Reorder:** Drag cards within a tab to reorder them. A blue line indicates where the card will be dropped.
|
||||
|
||||
### 4. Filtering Results
|
||||
* **Search Field:** Enter any string to include or exclude it. Words are tested individually against LoRA names, filenames, and paths.
|
||||
* **Filter Buttons:** Select **On/Off** and/or **Has Meta/No Meta** to further refine your search.
|
||||
* **Consistent Results:** Displayed LoRAs will always satisfy all specified criteria simultaneously.
|
||||
* **Automatic NSFW Filtering:** "nsfw" is a pre-activated negative keyword. It automatically hides any LoRA with "nsfw" in its name, filename, or path. To display these LoRAs, explicitly include "nsfw" in the positive search field.
|
||||
|
||||
### 5. Using in Workflow
|
||||
* Once you have selected your LoRAs in the visual interface, close the modal.
|
||||
* The node will automatically update its internal hidden widget with the selected LoRAs and strengths.
|
||||
* Connect the node to your **Checkpoint Loader** or **LoRA Loader** chain as required by your specific implementation logic.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Library vs. Node Context
|
||||
|
||||
Understanding the separation between **Global Data** and **Node Configuration** is key to mastering this extension:
|
||||
|
||||
| Feature | Scope | Behavior |
|
||||
| :--- | :--- | :--- |
|
||||
| **LoRA Metadata** | **Global (User)** | Names, trigger words, cover images, and strengths fetched from CivitAI or edited manually are saved **globally**. If you edit a LoRA's name in one workflow, it updates everywhere. |
|
||||
| **Tabs & Organization** | **Per-Node** | Custom tabs, the order of LoRAs within tabs, and which LoRAs are currently **Active/On** are saved **specific to each node instance**. |
|
||||
| **Workflow Portability** | **Portable** | When you copy a **Visual LoRA Loader** node from one workflow to another, it brings its own unique tab structure and active selection with it, while still accessing your single, global library of metadata. |
|
||||
|
||||
> **Pro Tip:** Create a "Master Library" node with your favorite general-purpose tabs for daily use. Then, create separate nodes for specific projects (e.g., "Portrait Workflow," "Anime Workflow") with their own tailored tab setups. They all share the same underlying metadata intelligence but maintain independent organizations.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Technical Details
|
||||
|
||||
### Data Persistence
|
||||
The extension uses a robust dual-layer storage system:
|
||||
1. **Browser LocalStorage:** Used for immediate read/write access during your session.
|
||||
2. **Server Disk Backup:** A debounced backup system writes your changes to the server's disk. If you clear your browser cache, the extension will automatically offer to restore your data from the server backup upon the next launch.
|
||||
|
||||
### Caching System
|
||||
* **Image Cache:** Keeps up to 800 images in memory to prevent flickering and reduce network requests.
|
||||
* **Metadata Cache:** Stores fetched CivitAI data locally to avoid hitting API rate limits.
|
||||
|
||||
### Clearing Cache
|
||||
A hidden **"⚠️ Clear Cache"** button is available in the top-left of the modal.
|
||||
* **How to access:** Hover over the button for **1 second**. It will turn red and become clickable.
|
||||
* **Action:** This wipes both local browser storage and server-side backup files. Use with caution.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Whether it's fixing a bug, improving the UI, or adding new features:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`).
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`).
|
||||
5. Open a Pull Request.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
* Built for the **ComfyUI** community.
|
||||
* Metadata powered by **CivitAI**.
|
||||
* Inspired by the need for better asset management in AI art workflows.
|
||||
|
||||
---
|
||||
|
||||
**Enjoy organizing your LoRAs!** 🎨🚀
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
from .visual_lora_loader import (
|
||||
NODE_CLASS_MAPPINGS as _VISUAL_NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as _VISUAL_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS.update(_VISUAL_NODE_CLASS_MAPPINGS)
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(_VISUAL_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 912 KiB |
@@ -0,0 +1,397 @@
|
||||
import folder_paths
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from nodes import LoraLoader
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import logging
|
||||
import threading
|
||||
|
||||
CIVITAI_API_BASE = "https://civitai.com/api/v1"
|
||||
# On stocke le cache dans le dossier utilisateur de ComfyUI (comme rgthree)
|
||||
# Ex: ComfyUI/user/nomad/neurad_cache/
|
||||
CACHE_DIR = os.path.join(folder_paths.user_directory, "neurad_cache")
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
os.makedirs(CACHE_DIR)
|
||||
|
||||
class VisualLoraLoaderNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"lora_data": ("STRING", {"default": "[]", "multiline": False, "hidden": True}),
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "active_loras_info")
|
||||
FUNCTION = "load_lora"
|
||||
CATEGORY = "loaders"
|
||||
|
||||
def load_lora(self, model, clip, lora_data):
|
||||
try: active_loras = json.loads(lora_data)
|
||||
except: return (model, clip, "Error")
|
||||
current_model, current_clip = model, clip
|
||||
lora_loader_instance = LoraLoader()
|
||||
applied_info = []
|
||||
for lora in active_loras:
|
||||
lora_name = lora.get("name")
|
||||
strength_model = float(lora.get("strength_model", 1.0))
|
||||
strength_clip = float(lora.get("strength_clip", strength_model))
|
||||
subfolder = lora.get("subfolder", "")
|
||||
full_name = f"{subfolder.replace('\\', '/')}/{lora_name.replace('\\', '/')}".replace("//", "/").strip("/")
|
||||
path = folder_paths.get_full_path("loras", full_name) or folder_paths.get_full_path("loras", lora_name)
|
||||
if path:
|
||||
try:
|
||||
current_model, current_clip = lora_loader_instance.load_lora(current_model, current_clip, full_name, strength_model, strength_clip)
|
||||
applied_info.append(f"{full_name} ({strength_model})")
|
||||
except Exception as e: logging.error(f"[Neurad] Load error: {e}")
|
||||
return (current_model, current_clip, ", ".join(applied_info))
|
||||
|
||||
@PromptServer.instance.routes.post("/neurad/reset-cache")
|
||||
async def reset_cache(request):
|
||||
"""
|
||||
PURGE TOTALE : Supprime le fichier de backup ET tous les fichiers de cache par hash.
|
||||
"""
|
||||
try:
|
||||
# 1. Supprimer le backup LocalStorage
|
||||
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
|
||||
if os.path.exists(backup_path):
|
||||
os.remove(backup_path)
|
||||
logging.info("[Neurad] Backup file deleted.")
|
||||
|
||||
# 2. Supprimer TOUS les fichiers de cache .json (sauf le backup qu'on vient de traiter)
|
||||
# On liste tous les fichiers dans le dossier de cache
|
||||
for filename in os.listdir(CACHE_DIR):
|
||||
if filename.endswith(".json") and filename != "localStorage_backup.json":
|
||||
file_path = os.path.join(CACHE_DIR, filename)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
logging.warning(f"[Neurad] Could not delete cache file {filename}: {e}")
|
||||
|
||||
# 3. CRÉER LE DRAPEAU DE PURGE (Fichier vide)
|
||||
flag_path = os.path.join(CACHE_DIR, "purge_flag.lock")
|
||||
with open(flag_path, 'w') as f:
|
||||
f.write("purged") # Ou laisser vide
|
||||
|
||||
logging.info("[Neurad] Full cache purge completed.")
|
||||
return web.json_response({"success": True, "message": "Cache purged"})
|
||||
except Exception as e:
|
||||
logging.error(f"[Neurad] Critical purge error: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
def get_sha256_hash(file_path):
|
||||
"""Calcule le hash SHA256 du fichier (comme rgthree)."""
|
||||
if not os.path.exists(file_path): return None
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
def get_cache_file_path(file_hash):
|
||||
return os.path.join(CACHE_DIR, f"{file_hash}.json")
|
||||
|
||||
def load_from_cache(file_hash):
|
||||
cache_path = get_cache_file_path(file_hash)
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# Vérifier si le cache est trop vieux (optionnel, ici 24h)
|
||||
# if datetime.now().timestamp() - data.get('timestamp', 0) < 86400:
|
||||
return data.get('response')
|
||||
except: pass
|
||||
return None
|
||||
|
||||
def has_cache_for_file(file_path):
|
||||
"""Vérifie simplement si un fichier de cache existe pour ce fichier .safetensors."""
|
||||
file_hash = get_sha256_hash(file_path)
|
||||
if not file_hash:
|
||||
return False
|
||||
cache_path = get_cache_file_path(file_hash)
|
||||
return os.path.exists(cache_path)
|
||||
|
||||
def save_to_cache(file_hash, data):
|
||||
cache_path = get_cache_file_path(file_hash)
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({'timestamp': datetime.now().timestamp(), 'response': data}, f, indent=2)
|
||||
except Exception as e:
|
||||
logging.warning(f"[Neurad] Cache write error (non-critical): {e}")
|
||||
|
||||
def fetch_civitai_by_hash(file_hash):
|
||||
"""Appelle l'API Civitai par HASH (la méthode magique de rgthree)."""
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{file_hash}"
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# Vérifier que c'est bien un modèle (parfois le hash ne correspond à rien)
|
||||
if data and 'model' in data:
|
||||
return data
|
||||
elif response.status_code == 404:
|
||||
logging.info(f"[Neurad] No model found for hash {file_hash}")
|
||||
else:
|
||||
logging.warning(f"[Neurad] API Error {response.status_code} for hash {file_hash}")
|
||||
except Exception as e:
|
||||
logging.warning(f"[Neurad] Request failed: {e}")
|
||||
return None
|
||||
|
||||
def parse_civitai_response(data):
|
||||
"""Formate la réponse Civitai pour notre frontend."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# 1. Extraction ROBUSTE de l'ID du MODÈLE (pas la version)
|
||||
model_id = None
|
||||
|
||||
# Priorité 1: Le champ 'modelId' explicite (souvent à la racine pour cette API)
|
||||
if 'modelId' in data:
|
||||
model_id = data.get('modelId')
|
||||
|
||||
# Priorité 2: L'objet 'model' imbriqué
|
||||
if not model_id and 'model' in data and isinstance(data['model'], dict):
|
||||
model_id = data['model'].get('id')
|
||||
|
||||
# Priorité 3: Fallback sur 'id' si on pense que c'est le modèle (rare pour by-hash)
|
||||
# Mais attention, si on n'a que l'ID de version, on doit le gérer différemment pour l'URL
|
||||
version_id = data.get('id')
|
||||
|
||||
if not model_id and version_id:
|
||||
# Si on n'a trouvé que l'ID de version, on essaie de voir si 'model' existe quand même
|
||||
# Sinon, on devra construire l'URL avec l'ID de version en paramètre
|
||||
pass
|
||||
|
||||
# 2. Construction de l'URL CORRECTE
|
||||
civitai_url = "https://civitai.com/models"
|
||||
|
||||
if model_id:
|
||||
civitai_url += f"/{model_id}"
|
||||
# Si on a aussi un ID de version différent, on l'ajoute en paramètre pour tomber sur la bonne version
|
||||
if version_id and version_id != model_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
elif version_id:
|
||||
# Cas rare : on n'a que l'ID de version, on tente l'URL directe (ça redirige souvent)
|
||||
# Mais c'est mieux d'avoir le modelId. Si on est ici, c'est qu'on n'a pas trouvé le modelId.
|
||||
# On laisse l'URL de base ou on tente avec l'ID de version (moins fiable)
|
||||
civitai_url += f"/{version_id}" # Risqué, mais mieux que None
|
||||
|
||||
# 3. Nom
|
||||
model_name = ""
|
||||
if 'model' in data and isinstance(data['model'], dict):
|
||||
model_name = data['model'].get('name', '')
|
||||
|
||||
version_name = data.get('name', '')
|
||||
full_name = model_name
|
||||
if version_name and model_name and version_name != model_name:
|
||||
full_name += f" - {version_name}"
|
||||
elif not model_name:
|
||||
full_name = version_name
|
||||
|
||||
# 4. Images
|
||||
images = []
|
||||
# 4. Images (Toutes les images, sans limite)
|
||||
images = []
|
||||
if 'images' in data:
|
||||
for img in data['images']:
|
||||
meta_data = img.get('meta', {})
|
||||
if isinstance(meta_data, str):
|
||||
try: meta_data = json.loads(meta_data)
|
||||
except: meta_data = {}
|
||||
|
||||
images.append({
|
||||
"url": img.get('url'),
|
||||
"nsfwLevel": img.get('nsfwLevel', 0),
|
||||
"width": img.get('width'),
|
||||
"height": img.get('height'),
|
||||
"positive": meta_data.get('prompt', '') if meta_data else ""
|
||||
})
|
||||
|
||||
return {
|
||||
"source": "civitai_hash",
|
||||
"model_id": model_id if model_id else version_id,
|
||||
"name": full_name,
|
||||
"trainedWords": (data.get('trainedWords', []) or []) + (data.get('triggerWords', []) or []),
|
||||
"images": images,
|
||||
"strengthMin": None,
|
||||
"strengthMax": None,
|
||||
"civitaiUrl": civitai_url
|
||||
}
|
||||
|
||||
@PromptServer.instance.routes.get("/neurad/get-loras")
|
||||
async def get_loras(request):
|
||||
"""
|
||||
LISTE ULTRA-RAPIDE.
|
||||
Ne calcule AUCUN hash, ne lit AUCUN cache.
|
||||
Renvoie has_meta: False pour tout le monde.
|
||||
C'est le JS (LocalStorage) qui gère l'affichage des icônes déjà connues.
|
||||
"""
|
||||
lora_folders = folder_paths.get_folder_paths("loras")
|
||||
all_loras = []
|
||||
|
||||
for root_folder in lora_folders:
|
||||
if not os.path.exists(root_folder): continue
|
||||
for root, dirs, files in os.walk(root_folder):
|
||||
for filename in files:
|
||||
if filename.endswith(".safetensors"):
|
||||
try:
|
||||
rel_path = os.path.relpath(os.path.join(root, filename), root_folder)
|
||||
except:
|
||||
rel_path = filename
|
||||
|
||||
all_loras.append({
|
||||
"name": filename,
|
||||
"display_name": os.path.splitext(filename)[0],
|
||||
"relative_path": rel_path,
|
||||
"subfolder": os.path.dirname(rel_path),
|
||||
"has_meta": False, # Toujours faux ici pour la vitesse
|
||||
"meta": {} # Vide
|
||||
})
|
||||
|
||||
all_loras.sort(key=lambda x: (x["subfolder"], x["display_name"].lower()))
|
||||
return web.json_response(all_loras)
|
||||
|
||||
@PromptServer.instance.routes.post("/neurad/fetch-single-info")
|
||||
async def fetch_single_info(request):
|
||||
"""
|
||||
MÉTHODE RGTHREE : Calcule le HASH -> Appelle API By-Hash -> Cache dans user/
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
rel_path = data.get("path")
|
||||
if not rel_path: return web.json_response({"error": "No path"}, status=400)
|
||||
|
||||
full_path = None
|
||||
for root in folder_paths.get_folder_paths("loras"):
|
||||
p = os.path.join(root, rel_path)
|
||||
if os.path.exists(p): full_path = p; break
|
||||
|
||||
if not full_path: return web.json_response({"error": "File not found"}, status=404)
|
||||
|
||||
# 1. Calcul du Hash (Peut prendre 1-2s pour un gros fichier)
|
||||
file_hash = get_sha256_hash(full_path)
|
||||
if not file_hash:
|
||||
return web.json_response({"error": "Could not calculate hash"}, status=500)
|
||||
|
||||
# 2. Vérifier le cache local
|
||||
cached_data = load_from_cache(file_hash)
|
||||
if cached_data:
|
||||
parsed = parse_civitai_response(cached_data)
|
||||
if parsed: return web.json_response({"success": True, "meta": parsed})
|
||||
|
||||
# 3. Fetch API par Hash
|
||||
raw_data = fetch_civitai_by_hash(file_hash)
|
||||
if not raw_data:
|
||||
return web.json_response({"success": False, "error": "No model found on Civitai for this file hash"}, status=404)
|
||||
|
||||
# 4. Parser et Sauvegarder
|
||||
parsed = parse_civitai_response(raw_data)
|
||||
if parsed:
|
||||
save_to_cache(file_hash, raw_data)
|
||||
return web.json_response({"success": True, "meta": parsed})
|
||||
|
||||
return web.json_response({"success": False, "error": "Data parsing failed"}, status=500)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[Neurad] Critical Error: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/neurad/save-localstorage-backup")
|
||||
async def save_backup(request):
|
||||
"""Reçoit les données du JS et les sauvegarde sur le disque dur."""
|
||||
try:
|
||||
data = await request.json()
|
||||
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
|
||||
with open(backup_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logging.info("[Neurad] LocalStorage backup saved to disk.")
|
||||
return web.json_response({"success": True})
|
||||
except Exception as e:
|
||||
logging.error(f"[Neurad] Backup save error: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/neurad/update-lora-info")
|
||||
async def update_lora_info(request):
|
||||
"""
|
||||
Reçoit les données modifiées par l'utilisateur et met à jour le fichier de cache JSON correspondant.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
lora_path = data.get("path") # Le chemin relatif unique (ex: "subfolder/model.safetensors")
|
||||
updated_meta = data.get("meta") # Les nouvelles données (nom, triggers, etc.)
|
||||
|
||||
if not lora_path or not updated_meta:
|
||||
return web.json_response({"error": "Missing path or meta data"}, status=400)
|
||||
|
||||
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
|
||||
backup_data = {}
|
||||
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
with open(backup_path, 'r', encoding='utf-8') as f:
|
||||
backup_data = json.load(f)
|
||||
except:
|
||||
backup_data = {}
|
||||
|
||||
# Mise à jour des données pour cette LoRA
|
||||
backup_data[lora_path] = updated_meta
|
||||
|
||||
# Ajout d'un flag pour indiquer que c'est édité manuellement (optionnel mais utile)
|
||||
backup_data[lora_path]['edited_by_user'] = True
|
||||
|
||||
# Sauvegarde sur le disque
|
||||
with open(backup_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(backup_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logging.info(f"[Neurad] Updated manual data for: {lora_path}")
|
||||
return web.json_response({"success": True})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[Neurad] Update error: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/neurad/load-localstorage-backup")
|
||||
async def load_backup(request):
|
||||
"""Renvoie les données sauvegardées sur le disque pour restaurer le JS."""
|
||||
try:
|
||||
flag_path = os.path.join(CACHE_DIR, "purge_flag.lock")
|
||||
|
||||
# SI LE DRAPEAU EXISTE : On bloque la restauration et on nettoie le drapeau
|
||||
if os.path.exists(flag_path):
|
||||
try:
|
||||
os.remove(flag_path)
|
||||
logging.info("[Neurad] Purge flag detected. Blocking restoration to ensure clean state.")
|
||||
except:
|
||||
pass
|
||||
# On renvoie des données vides, peu importe si le backup existe
|
||||
return web.json_response({"success": False, "data": {}, "blocked_by_purge": True})
|
||||
|
||||
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
|
||||
if os.path.exists(backup_path):
|
||||
# Vérification de sécurité : si le fichier est vide ou corrompu, on ne restaure rien
|
||||
try:
|
||||
with open(backup_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# On ne restaure que si c'est un dictionnaire non vide
|
||||
if isinstance(data, dict) and len(data) > 0:
|
||||
return web.json_response({"success": True, "data": data})
|
||||
else:
|
||||
# Fichier vide ou invalide, on considère qu'il n'y a rien à restaurer
|
||||
return web.json_response({"success": False, "data": {}})
|
||||
except json.JSONDecodeError:
|
||||
logging.warning("[Neurad] Backup file corrupted, ignoring.")
|
||||
return web.json_response({"success": False, "data": {}})
|
||||
else:
|
||||
return web.json_response({"success": False, "data": {}})
|
||||
except Exception as e:
|
||||
logging.error(f"[Neurad] Backup load error: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"NeuradVisualLora": VisualLoraLoaderNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"NeuradVisualLora": "Visual LoRA Loader - Neurad"}
|
||||
@@ -0,0 +1,66 @@
|
||||
// ============================================================================
|
||||
// CONFIGURATION & CONSTANTS
|
||||
// ============================================================================
|
||||
export const CONFIG = {
|
||||
// Dimensions
|
||||
CARD_WIDTH: 140,
|
||||
CARD_HEIGHT: 230,
|
||||
CARD_HEIGHT_PX: "230px",
|
||||
IMG_HEIGHT: 160,
|
||||
TAB_HEIGHT: 28,
|
||||
|
||||
// Behavior
|
||||
STRENGTH_STEP: 0.05,
|
||||
CACHE_SAVE_DELAY_MS: 1000,
|
||||
IMAGE_CACHE_MAX_ITEMS: 800,
|
||||
|
||||
// Colors (ComfyUI Native Palette)
|
||||
COLORS: {
|
||||
// --- Backgrounds ---
|
||||
bg_dark: '#222222',
|
||||
bg_panel: '#353535',
|
||||
bg_header: '#333333',
|
||||
bg_toggle: '#3b3b3b',
|
||||
bg_toggle_faded: '#4c4c4c',
|
||||
bg_card_inactive: '#444444',
|
||||
bg_card_active: '#5577bb',
|
||||
bg_card_hover: '#666666',
|
||||
bg_card_active_hover: '#7799dd',
|
||||
bg_button: '#666666',
|
||||
bg_button_hover: '#a1a1a1',
|
||||
bg_tab_active: '#353535',
|
||||
bg_tab_hover: '#2b2b2b',
|
||||
bg_close_hover: '#bb6666',
|
||||
|
||||
// --- Borders & Separators ---
|
||||
border_default: '#666666',
|
||||
border_active: '#5577bb',
|
||||
border_moving_marker: '#2266ee',
|
||||
border_error: '#ff6b6b',
|
||||
border_negative: '#885555',
|
||||
separator_header: '#2c2c2c',
|
||||
border_bright_blue: '#4488ff',
|
||||
|
||||
// --- Text ---
|
||||
text_main: '#dddddd',
|
||||
text_node_title: '#ffffff',
|
||||
text_field_title: '#999999',
|
||||
text_faded: '#6c6c6c',
|
||||
text_negative: '#bb6666',
|
||||
text_links: '#aaaaff',
|
||||
text_error: '#ffaaaa',
|
||||
|
||||
// --- Accents & Status ---
|
||||
accent_blue_faded: '#8899bb', //button hover
|
||||
accent_blue_select: '#5577bb', //button
|
||||
dot_off: '#888888',
|
||||
dot_off_dark: '#333333',
|
||||
|
||||
// --- Scrollbars ---
|
||||
scroll_track: '#222222',
|
||||
scroll_thumb: '#666666',
|
||||
scroll_hover: '#999999'
|
||||
}
|
||||
};
|
||||
|
||||
//${CONFIG.COLORS.}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user