From f0e1891c0dbe5fdadaa36004e1c3cc13cbe431f1 Mon Sep 17 00:00:00 2001 From: Firetheft <97147055+Firetheft@users.noreply.github.com> Date: Sat, 30 Aug 2025 16:54:37 +0800 Subject: [PATCH] 1.0.3 --- Local_Lora_Gallery.py | 147 +++++++++++++------ README.md | 15 ++ js/Local_Lora_Gallery.js | 297 +++++++++++++++++++++++++-------------- pyproject.toml | 2 +- 4 files changed, 311 insertions(+), 150 deletions(-) diff --git a/Local_Lora_Gallery.py b/Local_Lora_Gallery.py index 77b164b..39c034d 100644 --- a/Local_Lora_Gallery.py +++ b/Local_Lora_Gallery.py @@ -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)" } \ No newline at end of file +NODE_CLASS_MAPPINGS = { + "LocalLoraGallery": LocalLoraGallery, + "LocalLoraGalleryModelOnly": LocalLoraGalleryModelOnly +} +NODE_DISPLAY_NAME_MAPPINGS = { + "LocalLoraGallery": "Local Lora Gallery", + "LocalLoraGalleryModelOnly": "Local Lora Gallery (Model Only)" +} \ No newline at end of file diff --git a/README.md b/README.md index d3b8230..1c95fa1 100644 --- a/README.md +++ b/README.md @@ -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模型的使用者。 diff --git a/js/Local_Lora_Gallery.js b/js/Local_Lora_Gallery.js index a6b5b35..24f3df0 100644 --- a/js/Local_Lora_Gallery.js +++ b/js/Local_Lora_Gallery.js @@ -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 = {
${lora.name}
+