1.0.3
This commit is contained in:
+103
-44
@@ -56,7 +56,7 @@ def get_lora_preview_asset(lora_name):
|
||||
if lora_path is None:
|
||||
return None
|
||||
base_name, _ = os.path.splitext(lora_path)
|
||||
|
||||
|
||||
for ext in IMAGE_EXTENSIONS + VIDEO_EXTENSIONS:
|
||||
if os.path.exists(base_name + ext):
|
||||
return os.path.basename(base_name + ext)
|
||||
@@ -84,11 +84,11 @@ async def get_loras_endpoint(request):
|
||||
else:
|
||||
if not any(ft in tags for ft in filter_tags):
|
||||
continue
|
||||
|
||||
|
||||
preview_filename = get_lora_preview_asset(lora)
|
||||
preview_url = ""
|
||||
preview_type = "none"
|
||||
|
||||
|
||||
if preview_filename:
|
||||
_, ext = os.path.splitext(preview_filename)
|
||||
if ext.lower() in VIDEO_EXTENSIONS:
|
||||
@@ -103,23 +103,29 @@ async def get_loras_endpoint(request):
|
||||
"name": lora,
|
||||
"preview_url": preview_url,
|
||||
"preview_type": preview_type,
|
||||
"tags": lora_meta.get('tags', [])
|
||||
"tags": lora_meta.get('tags', []),
|
||||
"trigger_words": lora_meta.get('trigger_words', ''),
|
||||
"download_url": lora_meta.get('download_url', '')
|
||||
})
|
||||
|
||||
|
||||
return web.json_response(lora_info_list)
|
||||
except Exception as e: return web.json_response({"error": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.get("/localloragallery/preview")
|
||||
async def get_preview_image(request):
|
||||
filename = request.query.get('filename')
|
||||
lora_name = request.query.get('lora_name')
|
||||
if not filename or not lora_name or ".." in filename or "/" in filename or "\\" in filename: return web.Response(status=403)
|
||||
if not filename or not lora_name or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
lora_full_path = folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_full_path: return web.Response(status=404)
|
||||
if not lora_full_path:
|
||||
return web.Response(status=404)
|
||||
image_path = os.path.join(os.path.dirname(lora_full_path), filename)
|
||||
return web.FileResponse(image_path) if os.path.exists(image_path) else web.Response(status=404)
|
||||
except Exception as e: return web.json_response({"error": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.post("/localloragallery/set_ui_state")
|
||||
async def set_ui_state(request):
|
||||
@@ -156,20 +162,38 @@ async def set_lora_selection(request):
|
||||
selections[str(data.get("node_id"))] = data.get("lora_stack", [])
|
||||
save_selections(selections)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.post("/localloragallery/update_metadata")
|
||||
async def update_lora_metadata(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
lora_name, tags = data.get("lora_name"), data.get("tags")
|
||||
if not lora_name: return web.json_response({"status": "error", "message": "Missing lora_name"}, status=400)
|
||||
lora_name = data.get("lora_name")
|
||||
tags = data.get("tags")
|
||||
trigger_words = data.get("trigger_words")
|
||||
download_url = data.get("download_url")
|
||||
|
||||
if not lora_name:
|
||||
return web.json_response({"status": "error", "message": "Missing lora_name"}, status=400)
|
||||
|
||||
metadata = load_metadata()
|
||||
if lora_name not in metadata: metadata[lora_name] = {}
|
||||
if tags is not None: metadata[lora_name]['tags'] = [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||
if lora_name not in metadata:
|
||||
metadata[lora_name] = {}
|
||||
|
||||
if tags is not None:
|
||||
metadata[lora_name]['tags'] = [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||
|
||||
if trigger_words is not None:
|
||||
metadata[lora_name]['trigger_words'] = str(trigger_words)
|
||||
|
||||
if download_url is not None:
|
||||
metadata[lora_name]['download_url'] = str(download_url)
|
||||
|
||||
save_metadata(metadata)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.get("/localloragallery/get_all_tags")
|
||||
async def get_all_tags(request):
|
||||
@@ -177,22 +201,26 @@ async def get_all_tags(request):
|
||||
metadata = load_metadata()
|
||||
all_tags = set(tag for item_meta in metadata.values() if isinstance(item_meta.get("tags"), list) for tag in item_meta["tags"])
|
||||
return web.json_response({"tags": sorted(list(all_tags), key=lambda s: s.lower())})
|
||||
except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.get("/localloragallery/get_selection")
|
||||
async def get_lora_selection(request):
|
||||
try:
|
||||
node_id = request.query.get('node_id')
|
||||
if not node_id: return web.json_response({"error": "node_id is required"}, status=400)
|
||||
if not node_id:
|
||||
return web.json_response({"error": "node_id is required"}, status=400)
|
||||
return web.json_response({"lora_stack": load_selections().get(str(node_id), [])})
|
||||
except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
class BaseLoraGallery:
|
||||
"""Base class for common functionality."""
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
if os.path.exists(SELECTIONS_FILE): return os.path.getmtime(SELECTIONS_FILE)
|
||||
if os.path.exists(SELECTIONS_FILE):
|
||||
return os.path.getmtime(SELECTIONS_FILE)
|
||||
return float("inf")
|
||||
|
||||
def _is_nunchaku_model(self, model):
|
||||
@@ -204,29 +232,38 @@ class BaseLoraGallery:
|
||||
class LocalLoraGallery(BaseLoraGallery):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return { "required": { "model": ("MODEL",), "clip": ("CLIP",) }, "hidden": { "unique_id": "UNIQUE_ID" } }
|
||||
RETURN_TYPES, FUNCTION, CATEGORY = ("MODEL", "CLIP"), "load_loras", "📜Asset Gallery/Local"
|
||||
return {"required": {"model": ("MODEL",), "clip": ("CLIP",)}, "hidden": {"unique_id": "UNIQUE_ID"}}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words")
|
||||
|
||||
FUNCTION = "load_loras"
|
||||
CATEGORY = "📜Asset Gallery/Local"
|
||||
|
||||
def load_loras(self, model, clip, unique_id, **kwargs):
|
||||
lora_configs = load_selections().get(str(unique_id), [])
|
||||
all_metadata = load_metadata()
|
||||
trigger_words_list = []
|
||||
|
||||
current_model, current_clip = model, clip
|
||||
applied_count = 0
|
||||
|
||||
use_nunchaku_loader = self._is_nunchaku_model(model)
|
||||
|
||||
loader_instance = None
|
||||
if use_nunchaku_loader:
|
||||
loader_instance = NunchakuFluxLoraLoader()
|
||||
print("LocalLoraGallery: Detected Nunchaku model. Using NunchakuFluxLoraLoader.")
|
||||
else:
|
||||
loader_instance = LoraLoader()
|
||||
print("LocalLoraGallery: Using standard LoraLoader.")
|
||||
loader_instance = NunchakuFluxLoraLoader() if use_nunchaku_loader else LoraLoader()
|
||||
print(f"LocalLoraGallery: Using {'NunchakuFluxLoraLoader' if use_nunchaku_loader else 'standard LoraLoader'}.")
|
||||
|
||||
for config in lora_configs:
|
||||
if not config.get('on', True) or not config.get('lora'):
|
||||
continue
|
||||
|
||||
lora_name = config['lora']
|
||||
|
||||
lora_meta = all_metadata.get(lora_name, {})
|
||||
triggers = lora_meta.get('trigger_words', '').strip()
|
||||
if triggers:
|
||||
trigger_words_list.append(triggers)
|
||||
|
||||
try:
|
||||
strength_model = float(config.get('strength', 1.0))
|
||||
strength_clip = float(config.get('strength_clip', strength_model))
|
||||
@@ -243,48 +280,70 @@ class LocalLoraGallery(BaseLoraGallery):
|
||||
except Exception as e:
|
||||
print(f"LocalLoraGallery: Failed to load LoRA '{lora_name}': {e}")
|
||||
|
||||
print(f"LocalLoraGallery: Applied {applied_count} LoRAs using {'Nunchaku' if use_nunchaku_loader else 'Standard'} loader.")
|
||||
return (current_model, current_clip)
|
||||
print(f"LocalLoraGallery: Applied {applied_count} LoRAs.")
|
||||
|
||||
trigger_words_string = ", ".join(trigger_words_list)
|
||||
return (current_model, current_clip, trigger_words_string)
|
||||
|
||||
class LocalLoraGalleryModelOnly(BaseLoraGallery):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return { "required": { "model": ("MODEL",) }, "hidden": { "unique_id": "UNIQUE_ID" } }
|
||||
RETURN_TYPES, FUNCTION, CATEGORY = ("MODEL",), "load_loras", "📜Asset Gallery/Local"
|
||||
return {"required": {"model": ("MODEL",)}, "hidden": {"unique_id": "UNIQUE_ID"}}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "trigger_words")
|
||||
|
||||
FUNCTION = "load_loras"
|
||||
CATEGORY = "📜Asset Gallery/Local"
|
||||
|
||||
def load_loras(self, model, unique_id, **kwargs):
|
||||
lora_configs = load_selections().get(str(unique_id), [])
|
||||
all_metadata = load_metadata()
|
||||
trigger_words_list = []
|
||||
|
||||
current_model = model
|
||||
applied_count = 0
|
||||
|
||||
use_nunchaku_loader = self._is_nunchaku_model(model)
|
||||
|
||||
loader_instance = None
|
||||
if use_nunchaku_loader:
|
||||
loader_instance = NunchakuFluxLoraLoader()
|
||||
print("LocalLoraGalleryModelOnly: Detected Nunchaku model. Using NunchakuFluxLoraLoader.")
|
||||
else:
|
||||
loader_instance = LoraLoaderModelOnly()
|
||||
print("LocalLoraGalleryModelOnly: Using standard LoraLoaderModelOnly.")
|
||||
loader_instance = NunchakuFluxLoraLoader() if use_nunchaku_loader else LoraLoaderModelOnly()
|
||||
print(f"LocalLoraGalleryModelOnly: Using {'NunchakuFluxLoraLoader' if use_nunchaku_loader else 'standard LoraLoaderModelOnly'}.")
|
||||
|
||||
for config in lora_configs:
|
||||
if not config.get('on', True) or not config.get('lora'):
|
||||
continue
|
||||
|
||||
lora_name = config['lora']
|
||||
|
||||
lora_meta = all_metadata.get(lora_name, {})
|
||||
triggers = lora_meta.get('trigger_words', '').strip()
|
||||
if triggers:
|
||||
trigger_words_list.append(triggers)
|
||||
|
||||
try:
|
||||
strength_model = float(config.get('strength', 1.0))
|
||||
if strength_model == 0:
|
||||
continue
|
||||
|
||||
(current_model,) = loader_instance.load_lora(current_model, lora_name, strength_model) if use_nunchaku_loader else loader_instance.load_lora_model_only(current_model, lora_name, strength_model)
|
||||
if use_nunchaku_loader:
|
||||
(current_model,) = loader_instance.load_lora(current_model, lora_name, strength_model)
|
||||
else:
|
||||
(current_model,) = loader_instance.load_lora_model_only(current_model, lora_name, strength_model)
|
||||
|
||||
applied_count += 1
|
||||
except Exception as e:
|
||||
print(f"LocalLoraGalleryModelOnly: Failed to load LoRA '{lora_name}': {e}")
|
||||
|
||||
print(f"LocalLoraGalleryModelOnly: Applied {applied_count} LoRAs using {'Nunchaku' if use_nunchaku_loader else 'Standard'} loader.")
|
||||
return (current_model,)
|
||||
print(f"LocalLoraGalleryModelOnly: Applied {applied_count} LoRAs.")
|
||||
|
||||
trigger_words_string = ", ".join(trigger_words_list)
|
||||
return (current_model, trigger_words_string)
|
||||
|
||||
NODE_CLASS_MAPPINGS = { "LocalLoraGallery": LocalLoraGallery, "LocalLoraGalleryModelOnly": LocalLoraGalleryModelOnly }
|
||||
NODE_DISPLAY_NAME_MAPPINGS = { "LocalLoraGallery": "Local Lora Gallery", "LocalLoraGalleryModelOnly": "Local Lora Gallery (Model Only)" }
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"LocalLoraGallery": LocalLoraGallery,
|
||||
"LocalLoraGalleryModelOnly": LocalLoraGalleryModelOnly
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LocalLoraGallery": "Local Lora Gallery",
|
||||
"LocalLoraGalleryModelOnly": "Local Lora Gallery (Model Only)"
|
||||
}
|
||||
@@ -10,9 +10,17 @@
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🇬🇧 English
|
||||
|
||||
### Update Log (2025-08-30)
|
||||
* **Trigger Word Editor**: You can now add, edit, and save trigger words for each LoRA directly within the editor panel (when a single card is selected).
|
||||
* **Download URL**: A new field allows you to save a source/download URL for each LoRA. A link icon (🔗) will appear on the card, allowing you to open the URL in a new browser tab.
|
||||
* **Trigger Word Output**: A new trigger_words text output has been added to the node. It automatically concatenates the trigger words of all active LoRAs in the stack, ready to be connected to your prompt nodes.
|
||||
|
||||
---
|
||||
|
||||
### Overview
|
||||
|
||||
The Local LoRA Gallery node replaces the standard dropdown LoRA loader with an intuitive, card-based visual interface. It allows you to see your LoRA previews, organize them with tags, and stack multiple LoRAs with adjustable strengths. This node is designed to streamline your workflow, especially when working with a large collection of LoRAs.
|
||||
@@ -74,6 +82,13 @@ It also features optional integration with **[comfyui-nunchaku](https://github.c
|
||||
|
||||
## 🇨🇳 中文
|
||||
|
||||
### 更新日志 (2025-08-30)
|
||||
* **触发词编辑器**: 现在您可以直接在编辑面板中为每个LoRA添加、编辑和保存触发词(当选中单个卡片时)。
|
||||
* **下载地址**: 新增了一个输入框,用于为每个LoRA保存其来源或下载URL。卡片右上角会出现一个链接图标(🔗),点击即可在新标签页中打开该网址。
|
||||
* **触发词输出**: 节点增加了一个新的 trigger_words 文本输出端口。它会自动拼接当前堆栈中所有已启用LoRA的触发词,可以直接连接到您的提示词节点。
|
||||
|
||||
---
|
||||
|
||||
### 概述
|
||||
|
||||
**Local LoRA Gallery (本地LoRA画廊)** 是一个ComfyUI自定义节点,它用一个直观的、基于卡片的视觉界面取代了标准的下拉式LoRA加载器。它允许您查看LoRA预览图、使用标签进行组织,并堆叠多个强度可调的LoRA。此节点旨在简化您的工作流程,尤其适合需要处理大量LoRA模型的使用者。
|
||||
|
||||
+192
-105
@@ -14,12 +14,13 @@ const LocalLoraGalleryNode = {
|
||||
}
|
||||
},
|
||||
|
||||
async updateMetadata(lora_name, tags) {
|
||||
async updateMetadata(lora_name, data) {
|
||||
try {
|
||||
const body = { lora_name, ...data };
|
||||
await api.fetchApi("/localloragallery/update_metadata", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lora_name, tags }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch(e) {
|
||||
console.error("LocalLoraGallery: Failed to update metadata", e);
|
||||
@@ -67,6 +68,9 @@ const LocalLoraGalleryNode = {
|
||||
#${uniqueId} .locallora-lora-item .lora-label { font-size: 10px; color: var(--node-text-color); }
|
||||
#${uniqueId} .locallora-controls { display: flex; flex-direction: column; padding: 5px; gap: 5px; flex-shrink: 0; }
|
||||
#${uniqueId} .locallora-controls-row { display: flex; gap: 10px; align-items: center; }
|
||||
#${uniqueId} .locallora-controls-row input[type=text], #${uniqueId} .locallora-controls-row select {
|
||||
background: #222; color: #ccc; border: 1px solid #555; padding: 4px; border-radius: 4px;
|
||||
}
|
||||
#${uniqueId} .locallora-gallery { flex-grow: 1; overflow-y: auto; background-color: #1a1a1a; padding: 5px; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; align-content: start; }
|
||||
#${uniqueId} .locallora-gallery::-webkit-scrollbar { width: 8px; }
|
||||
#${uniqueId} .locallora-gallery::-webkit-scrollbar-track { background: #2a2a2a; border-radius: 4px; }
|
||||
@@ -77,34 +81,44 @@ const LocalLoraGalleryNode = {
|
||||
#${uniqueId} .locallora-lora-card.selected-flow { border-color: #00FFC9; }
|
||||
|
||||
#${uniqueId} .locallora-media-container {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background-color: #111;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%; height: 150px; background-color: #111;
|
||||
border-top-left-radius: 5px; border-top-right-radius: 5px;
|
||||
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#${uniqueId} .locallora-media-container img,
|
||||
#${uniqueId} .locallora-media-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
}
|
||||
|
||||
#${uniqueId} .locallora-lora-card-info { padding: 4px; flex-grow: 1; display: flex; flex-direction: column; }
|
||||
#${uniqueId} .locallora-lora-card p { font-size: 11px; margin: 0; word-break: break-all; text-align: center; color: var(--node-text-color); }
|
||||
|
||||
#${uniqueId} .lora-card-triggers {
|
||||
font-size: 10px; color: #a5a5a5; padding: 2px 4px; margin-top: 2px;
|
||||
text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-height: 14px;
|
||||
}
|
||||
|
||||
#${uniqueId} .lora-card-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-top: auto; padding-top: 4px; }
|
||||
#${uniqueId} .lora-card-tags .tag { background-color: #006699; color: #fff; padding: 1px 4px; font-size: 10px; border-radius: 3px; cursor: pointer; }
|
||||
#${uniqueId} .lora-card-tags .tag:hover { background-color: #0088CC; }
|
||||
#${uniqueId} .locallora-tag-editor { display: none; }
|
||||
#${uniqueId} .locallora-tag-editor.visible { display: flex; }
|
||||
|
||||
#${uniqueId} .locallora-metadata-editor { display: none; flex-direction: column; gap: 5px; }
|
||||
#${uniqueId} .locallora-metadata-editor.visible { display: flex; }
|
||||
#${uniqueId} .tag-editor-list .tag .remove-tag { margin-left: 4px; color: #fdd; cursor: pointer; font-weight: bold; }
|
||||
#${uniqueId} .edit-tags-btn { position: absolute; bottom: 4px; right: 4px; width: 22px; height: 22px; background-color: rgba(0,0,0,0.5); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: background-color 0.2s; opacity: 0; }
|
||||
#${uniqueId} .locallora-lora-card:hover .edit-tags-btn { opacity: 1; }
|
||||
#${uniqueId} .edit-tags-btn:hover { background-color: rgba(0,0,0,0.8); }
|
||||
|
||||
#${uniqueId} .edit-tags-btn {
|
||||
position: absolute; bottom: 4px; right: 4px; width: 22px; height: 22px; background-color: rgba(0,0,0,0.5);
|
||||
color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; transition: background-color 0.2s; opacity: 0;
|
||||
}
|
||||
#${uniqueId} .lora-card-link-btn {
|
||||
position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; background-color: rgba(0,0,0,0.5);
|
||||
color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; cursor: pointer; transition: background-color 0.2s; opacity: 0; text-decoration: none;
|
||||
}
|
||||
#${uniqueId} .locallora-lora-card:hover .edit-tags-btn, #${uniqueId} .locallora-lora-card:hover .lora-card-link-btn { opacity: 1; }
|
||||
#${uniqueId} .edit-tags-btn:hover, #${uniqueId} .lora-card-link-btn:hover { background-color: rgba(0,0,0,0.8); }
|
||||
|
||||
#${uniqueId} .locallora-container.gallery-collapsed .locallora-gallery { display: none; }
|
||||
#${uniqueId} .locallora-lora-item .remove-lora-btn {
|
||||
background: #555; color: #fff; border: none; border-radius: 10%;
|
||||
@@ -123,19 +137,31 @@ const LocalLoraGalleryNode = {
|
||||
<div class="locallora-controls">
|
||||
<div class="locallora-controls-row">
|
||||
<button class="toggle-all-btn">Toggle All</button>
|
||||
<input type="text" class="search-input" placeholder="Filter by Name..." style="flex-grow: 1; background: #222; color: #ccc; border: 1px solid #555; padding: 4px; border-radius: 4px;">
|
||||
<input type="text" class="search-input" placeholder="Filter by Name..." style="flex-grow: 1;">
|
||||
<button class="clear-all-btn" title="Clear all selected LoRAs">Clear All</button>
|
||||
</div>
|
||||
<div class="locallora-controls-row locallora-tag-editor">
|
||||
<label style="font-size:12px;">Edit Tags (<span class="selected-count">0</span>):</label>
|
||||
<div class="tag-editor-list lora-card-tags" style="flex-grow:1;"></div>
|
||||
<input type="text" class="tag-editor-input" placeholder="Add tag..." style="width: 100px; background: #222; color: #ccc; border: 1px solid #555; padding: 4px; border-radius: 4px;">
|
||||
|
||||
<div class="locallora-metadata-editor">
|
||||
<div class="locallora-controls-row">
|
||||
<label style="font-size:12px;">Edit Tags (<span class="selected-count">0</span>):</label>
|
||||
<div class="tag-editor-list lora-card-tags" style="flex-grow:1;"></div>
|
||||
<input type="text" class="tag-editor-input" placeholder="Add tag..." style="width: 100px;">
|
||||
</div>
|
||||
<div class="locallora-controls-row trigger-editor-row" style="display:none;">
|
||||
<label style="font-size:12px;">Triggers:</label>
|
||||
<input type="text" class="trigger-editor-input" placeholder="Enter trigger words..." style="flex-grow: 1;">
|
||||
</div>
|
||||
<div class="locallora-controls-row url-editor-row" style="display:none;">
|
||||
<label style="font-size:12px;">URL:</label>
|
||||
<input type="text" class="url-editor-input" placeholder="Enter download URL..." style="flex-grow: 1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="locallora-controls-row">
|
||||
<button class="tag-filter-mode-btn" title="Click to switch filter mode">OR</button>
|
||||
<input type="text" class="tag-filter-input" placeholder="Filter by Tag..." style="flex-grow: 1; background: #222; color: #ccc; border: 1px solid #555; padding: 4px; border-radius: 4px;">
|
||||
<input type="text" class="tag-filter-input" placeholder="Filter by Tag..." style="flex-grow: 1;">
|
||||
<button class="clear-tag-filter-btn" title="Clear Tag Filter">✖</button>
|
||||
<select class="tag-filter-presets" style="background: #222; color: #ccc; border: 1px solid #555;"></select>
|
||||
<select class="tag-filter-presets"></select>
|
||||
<button class="toggle-gallery-btn" title="Toggle Gallery" style="margin-left: auto; flex-shrink: 0;">Hide Gallery</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,9 +174,13 @@ const LocalLoraGalleryNode = {
|
||||
const selectedListEl = widgetContainer.querySelector(".locallora-selected-list");
|
||||
const galleryEl = widgetContainer.querySelector(".locallora-gallery");
|
||||
const searchInput = widgetContainer.querySelector(".search-input");
|
||||
const tagEditor = widgetContainer.querySelector(".locallora-tag-editor");
|
||||
const metadataEditor = widgetContainer.querySelector(".locallora-metadata-editor");
|
||||
const tagEditorList = widgetContainer.querySelector(".tag-editor-list");
|
||||
const tagEditorInput = widgetContainer.querySelector(".tag-editor-input");
|
||||
const triggerEditorRow = widgetContainer.querySelector(".trigger-editor-row");
|
||||
const triggerEditorInput = widgetContainer.querySelector(".trigger-editor-input");
|
||||
const urlEditorRow = widgetContainer.querySelector(".url-editor-row");
|
||||
const urlEditorInput = widgetContainer.querySelector(".url-editor-input");
|
||||
const tagFilterInput = widgetContainer.querySelector(".tag-filter-input");
|
||||
const tagFilterPresets = widgetContainer.querySelector(".tag-filter-presets");
|
||||
const tagFilterModeBtn = widgetContainer.querySelector(".tag-filter-mode-btn");
|
||||
@@ -257,6 +287,8 @@ const LocalLoraGalleryNode = {
|
||||
card.className = "locallora-lora-card";
|
||||
card.dataset.loraName = lora.name;
|
||||
card.dataset.tags = lora.tags.join(',');
|
||||
card.dataset.triggerWords = lora.trigger_words;
|
||||
card.dataset.downloadUrl = lora.download_url;
|
||||
card.title = lora.name;
|
||||
|
||||
let mediaHTML = '';
|
||||
@@ -267,13 +299,19 @@ const LocalLoraGalleryNode = {
|
||||
} else {
|
||||
mediaHTML = `<img src="${lora.preview_url || empty_lora_image}">`;
|
||||
}
|
||||
|
||||
const linkBtnHTML = lora.download_url
|
||||
? `<a href="${lora.download_url}" target="_blank" class="lora-card-link-btn" title="Open download page">🔗</a>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
${linkBtnHTML}
|
||||
<div class="locallora-media-container">
|
||||
${mediaHTML}
|
||||
</div>
|
||||
<div class="locallora-lora-card-info">
|
||||
<p>${lora.name}</p>
|
||||
<div class="lora-card-triggers" title="${lora.trigger_words}">${lora.trigger_words || 'No triggers'}</div>
|
||||
<div class="lora-card-tags"></div>
|
||||
</div>
|
||||
<div class="edit-tags-btn">✏️</div>
|
||||
@@ -284,6 +322,11 @@ const LocalLoraGalleryNode = {
|
||||
}
|
||||
galleryEl.appendChild(card);
|
||||
|
||||
const linkBtn = card.querySelector(".lora-card-link-btn");
|
||||
if (linkBtn) {
|
||||
linkBtn.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
if (this.loraData.some(item => item.lora === lora.name)) {
|
||||
card.classList.add("selected-flow");
|
||||
}
|
||||
@@ -345,7 +388,7 @@ const LocalLoraGalleryNode = {
|
||||
}
|
||||
}
|
||||
|
||||
renderTagEditor();
|
||||
renderMetadataEditor();
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -372,21 +415,18 @@ const LocalLoraGalleryNode = {
|
||||
} catch(e) { console.error("LocalLoraGallery: Failed to load all tags:", e); }
|
||||
};
|
||||
|
||||
const renderTagEditor = () => {
|
||||
tagEditorList.innerHTML = "";
|
||||
const renderMetadataEditor = () => {
|
||||
selectedCountEl.textContent = this.selectedCardsForEditing.size;
|
||||
|
||||
if (this.selectedCardsForEditing.size === 0) {
|
||||
tagEditor.classList.remove("visible");
|
||||
metadataEditor.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
|
||||
const allTags = Array.from(this.selectedCardsForEditing).map(card => {
|
||||
return card.dataset.tags ? card.dataset.tags.split(',').filter(Boolean) : [];
|
||||
});
|
||||
|
||||
const commonTags = allTags.reduce((acc, tags) => acc.filter(tag => tags.includes(tag)));
|
||||
|
||||
tagEditorList.innerHTML = "";
|
||||
const allTags = Array.from(this.selectedCardsForEditing).map(card => card.dataset.tags ? card.dataset.tags.split(',').filter(Boolean) : []);
|
||||
const commonTags = allTags.reduce((a, b) => a.filter(c => b.includes(c)));
|
||||
|
||||
commonTags.forEach(tag => {
|
||||
const tagEl = document.createElement("span");
|
||||
tagEl.className = "tag";
|
||||
@@ -396,39 +436,38 @@ const LocalLoraGalleryNode = {
|
||||
removeEl.textContent = "ⓧ";
|
||||
removeEl.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const updatePromises = Array.from(this.selectedCardsForEditing).map(async (card) => {
|
||||
const loraName = card.dataset.loraName;
|
||||
const tags = card.dataset.tags ? card.dataset.tags.split(',').filter(Boolean) : [];
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
|
||||
await LocalLoraGalleryNode.updateMetadata(loraName, { tags: newTags });
|
||||
|
||||
card.dataset.tags = newTags.join(',');
|
||||
|
||||
const loraInDataSource = this.availableLoras.find(lora => lora.name === loraName);
|
||||
if (loraInDataSource) {
|
||||
loraInDataSource.tags = newTags;
|
||||
}
|
||||
|
||||
await LocalLoraGalleryNode.updateMetadata(loraName, newTags);
|
||||
if (loraInDataSource) loraInDataSource.tags = newTags;
|
||||
renderCardTags(card);
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
this.selectedCardsForEditing.clear();
|
||||
|
||||
await loadAllTags();
|
||||
renderTagEditor();
|
||||
|
||||
if (tagFilterInput.value.trim()) {
|
||||
await fetchAndRender();
|
||||
} else {
|
||||
renderGallery();
|
||||
}
|
||||
renderMetadataEditor();
|
||||
};
|
||||
tagEl.appendChild(removeEl);
|
||||
tagEditorList.appendChild(tagEl);
|
||||
});
|
||||
|
||||
tagEditor.classList.add("visible");
|
||||
if (this.selectedCardsForEditing.size === 1) {
|
||||
const selectedCard = Array.from(this.selectedCardsForEditing)[0];
|
||||
triggerEditorInput.value = selectedCard.dataset.triggerWords || "";
|
||||
triggerEditorRow.style.display = "flex";
|
||||
urlEditorInput.value = selectedCard.dataset.downloadUrl || "";
|
||||
urlEditorRow.style.display = "flex";
|
||||
} else {
|
||||
triggerEditorRow.style.display = "none";
|
||||
urlEditorRow.style.display = "none";
|
||||
}
|
||||
|
||||
metadataEditor.classList.add("visible");
|
||||
};
|
||||
|
||||
const renderCardTags = (card) => {
|
||||
@@ -472,10 +511,7 @@ const LocalLoraGalleryNode = {
|
||||
if (initialState.is_collapsed) {
|
||||
setTimeout(() => {
|
||||
const controlsEl = widgetContainer.querySelector(".locallora-controls");
|
||||
if (!controlsEl) {
|
||||
console.error("LocalLoraGallery: Could not find controls element for size calculation.");
|
||||
return;
|
||||
}
|
||||
if (!controlsEl) return;
|
||||
const contentHeight = selectedListEl.scrollHeight + controlsEl.offsetHeight;
|
||||
this.size[1] = contentHeight + HEADER_HEIGHT;
|
||||
mainContainer.classList.add("gallery-collapsed");
|
||||
@@ -493,7 +529,97 @@ const LocalLoraGalleryNode = {
|
||||
if (this.selectedCardsForEditing.size > 0) {
|
||||
document.querySelectorAll(`#${uniqueId} .locallora-lora-card.selected-edit`).forEach(c => c.classList.remove("selected-edit"));
|
||||
this.selectedCardsForEditing.clear();
|
||||
renderTagEditor();
|
||||
renderMetadataEditor();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
urlEditorInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this.selectedCardsForEditing.size !== 1) return;
|
||||
|
||||
const selectedCard = Array.from(this.selectedCardsForEditing)[0];
|
||||
const loraName = selectedCard.dataset.loraName;
|
||||
const newUrl = urlEditorInput.value.trim();
|
||||
|
||||
await LocalLoraGalleryNode.updateMetadata(loraName, { download_url: newUrl });
|
||||
|
||||
selectedCard.dataset.downloadUrl = newUrl;
|
||||
const loraInDataSource = this.availableLoras.find(l => l.name === loraName);
|
||||
if (loraInDataSource) loraInDataSource.download_url = newUrl;
|
||||
|
||||
let linkBtn = selectedCard.querySelector('.lora-card-link-btn');
|
||||
if (newUrl) {
|
||||
if (!linkBtn) {
|
||||
linkBtn = document.createElement('a');
|
||||
linkBtn.className = 'lora-card-link-btn';
|
||||
linkBtn.title = 'Open download page';
|
||||
linkBtn.innerHTML = '🔗';
|
||||
linkBtn.target = '_blank';
|
||||
linkBtn.addEventListener("click", (e) => e.stopPropagation());
|
||||
selectedCard.prepend(linkBtn);
|
||||
}
|
||||
linkBtn.href = newUrl;
|
||||
} else if (linkBtn) {
|
||||
linkBtn.remove();
|
||||
}
|
||||
|
||||
const originalColor = urlEditorInput.style.backgroundColor;
|
||||
urlEditorInput.style.backgroundColor = "#2a5";
|
||||
setTimeout(() => { urlEditorInput.style.backgroundColor = ""; }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
triggerEditorInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this.selectedCardsForEditing.size !== 1) return;
|
||||
|
||||
const selectedCard = Array.from(this.selectedCardsForEditing)[0];
|
||||
const loraName = selectedCard.dataset.loraName;
|
||||
const newTriggers = triggerEditorInput.value.trim();
|
||||
|
||||
await LocalLoraGalleryNode.updateMetadata(loraName, { trigger_words: newTriggers });
|
||||
|
||||
selectedCard.dataset.triggerWords = newTriggers;
|
||||
const loraInDataSource = this.availableLoras.find(l => l.name === loraName);
|
||||
if (loraInDataSource) loraInDataSource.trigger_words = newTriggers;
|
||||
|
||||
const triggerDisplayEl = selectedCard.querySelector('.lora-card-triggers');
|
||||
if(triggerDisplayEl) {
|
||||
triggerDisplayEl.textContent = newTriggers || 'No triggers';
|
||||
triggerDisplayEl.title = newTriggers;
|
||||
}
|
||||
|
||||
const originalColor = triggerEditorInput.style.backgroundColor;
|
||||
triggerEditorInput.style.backgroundColor = "#2a5";
|
||||
setTimeout(() => { triggerEditorInput.style.backgroundColor = ""; }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
tagEditorInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === 'Enter' && tagEditorInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
const newTag = tagEditorInput.value.trim();
|
||||
if (newTag) {
|
||||
const updatePromises = Array.from(this.selectedCardsForEditing).map(async (card) => {
|
||||
const loraName = card.dataset.loraName;
|
||||
const tags = card.dataset.tags ? card.dataset.tags.split(',').filter(Boolean) : [];
|
||||
|
||||
if (!tags.includes(newTag)) {
|
||||
tags.push(newTag);
|
||||
await LocalLoraGalleryNode.updateMetadata(loraName, { tags: tags });
|
||||
card.dataset.tags = tags.join(',');
|
||||
const loraInDataSource = this.availableLoras.find(lora => lora.name === loraName);
|
||||
if (loraInDataSource) loraInDataSource.tags = [...tags];
|
||||
renderCardTags(card);
|
||||
}
|
||||
});
|
||||
await Promise.all(updatePromises);
|
||||
await loadAllTags();
|
||||
renderMetadataEditor();
|
||||
e.target.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -508,10 +634,7 @@ const LocalLoraGalleryNode = {
|
||||
if (mainContainer.classList.contains("gallery-collapsed")) {
|
||||
setTimeout(() => {
|
||||
const controlsEl = widgetContainer.querySelector(".locallora-controls");
|
||||
if (!controlsEl) {
|
||||
console.error("LocalLoraGallery: Could not find controls element for size calculation.");
|
||||
return;
|
||||
}
|
||||
if (!controlsEl) return;
|
||||
const contentHeight = selectedListEl.scrollHeight + controlsEl.offsetHeight;
|
||||
this.size[1] = contentHeight + HEADER_HEIGHT;
|
||||
this.setDirtyCanvas(true, true);
|
||||
@@ -563,35 +686,7 @@ const LocalLoraGalleryNode = {
|
||||
|
||||
searchInput.addEventListener("input", renderGallery);
|
||||
tagFilterInput.addEventListener("keydown", (e) => { if(e.key === 'Enter') fetchAndRender(); });
|
||||
tagEditorInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === 'Enter' && this.selectedCardsForEditing.size > 0) {
|
||||
e.preventDefault();
|
||||
const newTag = e.target.value.trim();
|
||||
if (newTag) {
|
||||
const updatePromises = Array.from(this.selectedCardsForEditing).map(async (card) => {
|
||||
const loraName = card.dataset.loraName;
|
||||
const tags = card.dataset.tags ? card.dataset.tags.split(',').filter(Boolean) : [];
|
||||
|
||||
if (!tags.includes(newTag)) {
|
||||
tags.push(newTag);
|
||||
card.dataset.tags = tags.join(',');
|
||||
await LocalLoraGalleryNode.updateMetadata(loraName, tags);
|
||||
|
||||
const loraInDataSource = this.availableLoras.find(lora => lora.name === loraName);
|
||||
if (loraInDataSource) {
|
||||
loraInDataSource.tags = [...tags];
|
||||
}
|
||||
renderCardTags(card);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
await loadAllTags();
|
||||
renderTagEditor();
|
||||
e.target.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tagFilterPresets.addEventListener('change', () => {
|
||||
if (tagFilterPresets.value) {
|
||||
tagFilterInput.value = tagFilterPresets.value;
|
||||
@@ -603,20 +698,12 @@ const LocalLoraGalleryNode = {
|
||||
|
||||
this.onResize = function(size) {
|
||||
const controlsEl = widgetContainer.querySelector(".locallora-controls");
|
||||
|
||||
const dynamicMinHeight = selectedListEl.scrollHeight + controlsEl.offsetHeight + HEADER_HEIGHT;
|
||||
|
||||
const dynamicMinHeight = selectedListEl.scrollHeight + (controlsEl?.offsetHeight || 0) + HEADER_HEIGHT;
|
||||
if (!mainContainer.classList.contains("gallery-collapsed")) {
|
||||
this.expandedHeight = size[1];
|
||||
}
|
||||
|
||||
if (size[1] < dynamicMinHeight) {
|
||||
size[1] = dynamicMinHeight;
|
||||
}
|
||||
|
||||
if (size[0] < MIN_NODE_WIDTH) {
|
||||
size[0] = MIN_NODE_WIDTH;
|
||||
}
|
||||
if (size[1] < dynamicMinHeight) size[1] = dynamicMinHeight;
|
||||
if (size[0] < MIN_NODE_WIDTH) size[0] = MIN_NODE_WIDTH;
|
||||
};
|
||||
|
||||
bindEventListeners();
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI_Local_Lora_Gallery"
|
||||
description = "A custom node for ComfyUI that provides a visual gallery for managing and applying multiple LoRA models."
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
license = {file = "LICENSE"}
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
|
||||
Reference in New Issue
Block a user