diff --git a/Local_Lora_Gallery.py b/Local_Lora_Gallery.py index 741b4d8..6264f5f 100644 --- a/Local_Lora_Gallery.py +++ b/Local_Lora_Gallery.py @@ -5,6 +5,10 @@ import server from aiohttp import web from nodes import LoraLoader, LoraLoaderModelOnly import urllib.parse +import hashlib +import aiohttp +import asyncio +from urllib.parse import urlparse NunchakuFluxLoraLoader = None is_nunchaku_available = False @@ -24,6 +28,16 @@ PRESETS_FILE = os.path.join(NODE_DIR, "lora_gallery_presets.json") VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi'] IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif'] +def calculate_sha256(filepath): + """Calculates the SHA256 hash of a file efficiently.""" + if not os.path.exists(filepath): + return None + hash_sha256 = hashlib.sha256() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha256.update(chunk) + return hash_sha256.hexdigest() + def load_json_file(file_path, default_data={}): if not os.path.exists(file_path): return default_data @@ -51,17 +65,153 @@ save_ui_state = lambda data: save_json_file(data, UI_STATE_FILE) load_presets = lambda: load_json_file(PRESETS_FILE) save_presets = lambda data: save_json_file(data, PRESETS_FILE) -def get_lora_preview_asset(lora_name): - """Finds a preview asset (image or video) for a given LoRA.""" +def get_lora_preview_asset_info(lora_name): + """Finds a preview asset (image or video) for a given LoRA and returns its info.""" lora_path = folder_paths.get_full_path("loras", lora_name) if lora_path is None: - return None + return None, "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) - return None + preview_path = base_name + ext + if os.path.exists(preview_path): + preview_filename = os.path.basename(preview_path) + encoded_lora_name = urllib.parse.quote_plus(lora_name) + encoded_filename = urllib.parse.quote_plus(preview_filename) + url = f"/localloragallery/preview?filename={encoded_filename}&lora_name={encoded_lora_name}" + + preview_type = "none" + if ext.lower() in VIDEO_EXTENSIONS: + preview_type = "video" + elif ext.lower() in IMAGE_EXTENSIONS: + preview_type = "image" + + return url, preview_type + + return None, "none" + +@server.PromptServer.instance.routes.post("/localloragallery/sync_civitai") +async def sync_civitai_metadata(request): + try: + data = await request.json() + lora_name = data.get("lora_name") + if not lora_name: + return web.json_response({"status": "error", "message": "Missing lora_name"}, status=400) + + lora_full_path = folder_paths.get_full_path("loras", lora_name) + if not lora_full_path: + return web.json_response({"status": "error", "message": "LoRA file not found"}, status=404) + + metadata = load_metadata() + lora_meta = metadata.get(lora_name, {}) + + model_hash = lora_meta.get('hash') + if not model_hash: + print(f"Local Lora Gallery: Calculating hash for {lora_name}...") + model_hash = calculate_sha256(lora_full_path) + if model_hash: + lora_meta['hash'] = model_hash + metadata[lora_name] = lora_meta + save_metadata(metadata) + else: + return web.json_response({"status": "error", "message": "Failed to calculate hash"}, status=500) + + civitai_version_url = f"https://civitai.com/api/v1/model-versions/by-hash/{model_hash}" + async with aiohttp.ClientSession() as session: + async with session.get(civitai_version_url) as response: + if response.status != 200: + return web.json_response({"status": "error", "message": f"Civitai API (version) returned {response.status}. Model not found or API error."}, status=response.status) + + civitai_version_data = await response.json() + model_id = civitai_version_data.get('modelId') + if not model_id: + return web.json_response({"status": "error", "message": "Could not find modelId in Civitai API response."}, status=500) + + # civitai_model_url = f"https://civitai.com/api/v1/models/{model_id}" + # async with session.get(civitai_model_url) as response: + # if response.status != 200: + # print(f"Local Lora Gallery: Warning - Could not fetch model details for tags. Status: {response.status}") + # civitai_model_data = {} + # else: + # civitai_model_data = await response.json() + + images = civitai_version_data.get('images', []) + if not images: + print("Local Lora Gallery: No preview images found on Civitai, but will save other metadata.") + else: + preview_media = next((img for img in images if img.get('type') == 'image'), images[0]) + preview_url = preview_media.get('url') + is_video = preview_media.get('type') == 'video' + + try: + if is_video: + if '/original=true/' in preview_url: + temp_url = preview_url.replace('/original=true/', '/transcode=true,width=450,optimized=true/') + final_url = os.path.splitext(temp_url)[0] + '.webm' + else: + url_obj = urlparse(preview_url) + path_parts = url_obj.path.split('/') + filename = path_parts.pop() + filename_base = os.path.splitext(filename)[0] + new_path = f"{'/'.join(path_parts)}/transcode=true,width=450,optimized=true/{filename_base}.webm" + final_url = url_obj._replace(path=new_path).geturl() + file_ext = '.webm' + else: + if '/original=true/' in preview_url: + final_url = preview_url.replace('/original=true/', '/width=450/') + else: + final_url = preview_url.replace('/width=\d+/', '/width=450/') if '/width=' in preview_url else preview_url.replace(urlparse(preview_url).path, f"/width=450{urlparse(preview_url).path}") + + path = urlparse(final_url).path + file_ext = os.path.splitext(path)[1] + if not file_ext or file_ext.lower() not in IMAGE_EXTENSIONS: + file_ext = '.jpg' + except Exception as e: + print(f"Local Lora Gallery: Failed to parse or modify URL '{preview_url}'. Error: {e}") + final_url = preview_url + file_ext = '.jpg' if not is_video else '.mp4' + + lora_dir = os.path.dirname(lora_full_path) + lora_basename = os.path.splitext(os.path.basename(lora_full_path))[0] + save_path = os.path.join(lora_dir, lora_basename + file_ext) + + async with session.get(final_url) as download_response: + if download_response.status != 200: + print(f"Local Lora Gallery: Warning - Failed to download preview from {final_url}. Proceeding without preview.") + else: + with open(save_path, 'wb') as f: + while True: + chunk = await download_response.content.read(8192) + if not chunk: break + f.write(chunk) + print(f"Local Lora Gallery: Successfully downloaded preview to '{save_path}'") + + trained_words = civitai_version_data.get('trainedWords', []) + if trained_words: + lora_meta['trigger_words'] = ", ".join(trained_words) + + lora_meta['download_url'] = f"https://civitai.com/models/{model_id}" + + # tags = set(lora_meta.get('tags', [])) + # if 'tags' in civitai_model_data: + # for tag in civitai_model_data['tags']: + # tags.add(tag) + # lora_meta['tags'] = sorted(list(tags)) + + metadata[lora_name] = lora_meta + save_metadata(metadata) + + new_local_url, new_preview_type = get_lora_preview_asset_info(lora_name) + + return web.json_response({ + "status": "ok", + "metadata": { "preview_url": new_local_url, "preview_type": new_preview_type, **lora_meta } + }) + + except Exception as e: + import traceback + print(f"Error in sync_civitai_metadata: {traceback.format_exc()}") + return web.json_response({"status": "error", "message": str(e)}, status=500) @server.PromptServer.instance.routes.get("/localloragallery/get_presets") async def get_presets(request): @@ -164,23 +314,11 @@ async def get_loras_endpoint(request): lora_info_list = [] for lora in paginated_loras: lora_meta = metadata.get(lora, {}) - 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: - preview_type = "video" - elif ext.lower() in IMAGE_EXTENSIONS: - preview_type = "image" - - encoded_lora_name = urllib.parse.quote(lora) - preview_url = f"/localloragallery/preview?filename={preview_filename}&lora_name={encoded_lora_name}" + preview_url, preview_type = get_lora_preview_asset_info(lora) lora_info_list.append({ "name": lora, - "preview_url": preview_url, + "preview_url": preview_url or "", "preview_type": preview_type, "tags": lora_meta.get('tags', []), "trigger_words": lora_meta.get('trigger_words', ''), @@ -204,14 +342,24 @@ async def get_loras_endpoint(request): 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) + try: - lora_full_path = folder_paths.get_full_path("loras", lora_name) + lora_name_decoded = urllib.parse.unquote_plus(lora_name) + filename_decoded = urllib.parse.unquote_plus(filename) + + lora_full_path = folder_paths.get_full_path("loras", lora_name_decoded) 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) + return web.Response(status=404, text=f"Lora '{lora_name_decoded}' not found.") + + image_path = os.path.join(os.path.dirname(lora_full_path), filename_decoded) + if os.path.exists(image_path): + return web.FileResponse(image_path) + else: + return web.Response(status=404, text=f"Preview '{filename_decoded}' not found.") + except Exception as e: return web.json_response({"error": str(e)}, status=500) @@ -318,7 +466,7 @@ class LocalLoraGallery(BaseLoraGallery): RETURN_NAMES = ("MODEL", "CLIP", "trigger_words") FUNCTION = "load_loras" - CATEGORY = "📜Asset Gallery/Local" + CATEGORY = "📜Asset Gallery/Loras" def load_loras(self, model, clip, unique_id, selection_data="[]", **kwargs): try: @@ -384,7 +532,7 @@ class LocalLoraGalleryModelOnly(BaseLoraGallery): RETURN_NAMES = ("MODEL", "trigger_words") FUNCTION = "load_loras" - CATEGORY = "📜Asset Gallery/Local" + CATEGORY = "📜Asset Gallery/Loras" def load_loras(self, model, unique_id, selection_data="[]", **kwargs): try: diff --git a/README.md b/README.md index f24ca4e..625f60b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ --- ## 🇬🇧 English +### Update Log (2025-10-02) +* **Civitai Metadata Sync & Preview Downloader**: + * Added a "Sync with Civitai" (☁️) button to each LoRA card. This feature calculates the model's hash to fetch metadata like trigger words and homepage URLs from Civitai. + * When syncing, the plugin automatically downloads a small, web-optimized preview (image or video, 450px wide) and saves it locally alongside your LoRA file. This ensures fast, offline access to previews after the initial sync. + ### Changelog (2025-09-12) * **Preset Management**: You can now save your favorite LoRA stacks as presets and load them with a single click. * **Folder Filtering**: A new dropdown menu allows you to filter LoRAs by their subfolder, making it easier to manage large collections. @@ -92,6 +97,11 @@ It also features optional integration with **[comfyui-nunchaku](https://github.c ----- ## 🇨🇳 中文 +### 更新日志 (2025-10-02) +* **Civitai 元数据同步与预览图下载**: + * 在每个 LoRA 卡片上增加了一个“与Civitai同步”(☁️) 按钮。此功能会计算模型哈希值,以从 Civitai 获取触发词和主页URL等元数据。 + * 同步时,插件会自动下载一个经网络优化的预览图(450px宽的图片或视频),并将其保存在您的 LoRA 文件旁边。这确保了在初次同步后,您可以在本地快速、离线地访问预览图。 + ### 更新日志 (2025-09-12) * **预设管理**: 现在您可以将常用的 LoRA 堆栈保存为预设,并一键加载。 * **文件夹筛选**: 新增了文件夹筛选下拉菜单,可以按子文件夹显示 LoRA,便于管理庞大的模型库。 diff --git a/js/Local_Lora_Gallery.js b/js/Local_Lora_Gallery.js index a0d1f6e..2166729 100644 --- a/js/Local_Lora_Gallery.js +++ b/js/Local_Lora_Gallery.js @@ -113,131 +113,91 @@ const LocalLoraGalleryNode = { const uniqueId = `locallora-gallery-${this.id}`; widgetContainer.innerHTML = `
@@ -283,7 +243,7 @@ const LocalLoraGalleryNode = {
- @@ -329,13 +289,8 @@ const LocalLoraGalleryNode = { }; const updatePresetButtonText = (presetName = null) => { - if (presetName) { - loadPresetBtn.textContent = `Preset: ${presetName} ▼`; - loadPresetBtn.title = `Current Preset: ${presetName}`; - } else { - loadPresetBtn.textContent = "Load Preset ▼"; - loadPresetBtn.title = "Load a saved preset"; - } + loadPresetBtn.textContent = presetName ? `Preset: ${presetName} ▼` : "Load Preset ▼"; + loadPresetBtn.title = presetName ? `Current Preset: ${presetName}` : "Load a saved preset"; }; galleryEl.addEventListener('scroll', () => { @@ -351,11 +306,8 @@ const LocalLoraGalleryNode = { const selectionJson = JSON.stringify(serializableData); this.setProperty("selection_data", selectionJson); - const widget = this.widgets.find(w => w.name === "selection_data"); - if (widget) { - widget.value = selectionJson; - } + if (widget) widget.value = selectionJson; LocalLoraGalleryNode.setUiState(this.id, this.properties.lora_gallery_unique_id, { is_collapsed: mainContainer.classList.contains("gallery-collapsed"), @@ -415,7 +367,6 @@ const LocalLoraGalleryNode = { removeBtn.className = "remove-lora-btn"; removeBtn.textContent = "✖"; removeBtn.title = "Remove LoRA"; - removeBtn.addEventListener("click", () => { this.loraData.splice(index, 1); renderSelectedList(); @@ -438,34 +389,14 @@ const LocalLoraGalleryNode = { e.currentTarget.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); - - el.addEventListener('dragend', (e) => { - e.currentTarget.classList.remove('dragging'); - document.querySelectorAll(`#${uniqueId} .locallora-lora-item.drag-over`).forEach(item => item.classList.remove('drag-over')); - }); - - el.addEventListener('dragover', (e) => { - e.preventDefault(); - const targetItem = e.currentTarget; - if(targetItem && targetItem.dataset.index !== draggedIndex) { - targetItem.classList.add('drag-over'); - } - }); - - el.addEventListener('dragleave', (e) => { - e.currentTarget.classList.remove('drag-over'); - }); - + el.addEventListener('dragend', (e) => e.currentTarget.classList.remove('dragging')); + el.addEventListener('dragover', (e) => e.preventDefault()); el.addEventListener('drop', (e) => { e.preventDefault(); - const targetItem = e.currentTarget; - targetItem.classList.remove('drag-over'); - const targetIndex = parseInt(targetItem.dataset.index); - + const targetIndex = parseInt(e.currentTarget.dataset.index); if (draggedIndex !== targetIndex) { const [movedItem] = this.loraData.splice(draggedIndex, 1); this.loraData.splice(targetIndex, 0, movedItem); - updateSelection(); renderSelectedList(); } @@ -474,159 +405,213 @@ const LocalLoraGalleryNode = { selectedListEl.appendChild(el); }); }; + + const syncWithCivitai = async (loraName, card) => { + const syncBtn = card.querySelector('.sync-civitai-btn'); + syncBtn.textContent = '🔄'; + syncBtn.classList.add('loading'); + + try { + const response = await api.fetchApi("/localloragallery/sync_civitai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lora_name: loraName }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result.status === 'ok' && result.metadata) { + const { preview_url, preview_type, trigger_words, download_url, tags } = result.metadata; + + const loraInDataSource = this.availableLoras.find(l => l.name === loraName); + if (loraInDataSource) { + loraInDataSource.preview_url = preview_url || ''; + loraInDataSource.preview_type = preview_type || 'none'; + loraInDataSource.trigger_words = trigger_words || ''; + loraInDataSource.download_url = download_url || ''; + loraInDataSource.tags = tags || []; + } + + const mediaContainer = card.querySelector('.locallora-media-container'); + if (preview_type === 'video' && preview_url) { + mediaContainer.innerHTML = ``; + const video = mediaContainer.querySelector('video'); + card.addEventListener('mouseenter', () => video.play().catch(e => {})); + card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; }); + } else if (preview_type === 'image' && preview_url) { + mediaContainer.innerHTML = ``; + } else { + const empty_lora_image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + mediaContainer.innerHTML = ``; + } + + const triggerEl = card.querySelector('.lora-card-triggers'); + if(triggerEl) { + triggerEl.textContent = trigger_words || 'No triggers'; + triggerEl.title = trigger_words || ''; + } + card.dataset.triggerWords = trigger_words || ''; + card.dataset.downloadUrl = download_url || ''; + card.dataset.tags = (tags || []).join(','); + + const oldLinkBtn = card.querySelector('.lora-card-link-btn'); + if(oldLinkBtn) oldLinkBtn.remove(); + if(download_url){ + const linkBtn = document.createElement('a'); + linkBtn.href = download_url; + linkBtn.target = '_blank'; + linkBtn.className = 'card-btn lora-card-link-btn'; + linkBtn.title = 'Open download page'; + linkBtn.innerHTML = '🔗'; + linkBtn.addEventListener('click', e => e.stopPropagation()); + card.prepend(linkBtn); + } + + renderCardTags(card); + await loadAllTags(); + } else { + throw new Error(result.message || 'Sync failed'); + } + + } catch (error) { + console.error("LocalLoraGallery: Failed to sync with Civitai:", error); + syncBtn.textContent = '❌'; + setTimeout(() => syncBtn.textContent = '☁️', 2000); + } finally { + syncBtn.classList.remove('loading'); + if(syncBtn.textContent !== '❌') syncBtn.textContent = '☁️'; + } + }; const renderGallery = (append = false) => { - if (!append) { - galleryEl.innerHTML = ""; - } - + if (!append) galleryEl.innerHTML = ""; const nameFilter = searchInput.value.toLowerCase(); - const lorasToRender = this.availableLoras.filter(lora => lora.name.toLowerCase().includes(nameFilter)); - - const existingCardNames = new Set( - Array.from(galleryEl.querySelectorAll('.locallora-lora-card')).map(c => c.dataset.loraName) - ); + const existingCardNames = new Set(Array.from(galleryEl.querySelectorAll('.locallora-lora-card')).map(c => c.dataset.loraName)); lorasToRender.forEach(lora => { - if (append && existingCardNames.has(lora.name)) { - return; + if (append && existingCardNames.has(lora.name)) return; + + const card = document.createElement("div"); + 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 = ''; + const empty_lora_image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + const previewUrl = lora.preview_url; + + if (lora.preview_type === 'video' && previewUrl) { + mediaHTML = ``; + } else { + mediaHTML = ``; } - const card = document.createElement("div"); - 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; + + const linkBtnHTML = lora.download_url ? `🔗` : ''; - let mediaHTML = ''; - const empty_lora_image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + card.innerHTML = ` +
☁️
+ ${linkBtnHTML} +
${mediaHTML}
+
+

${lora.name}

+
${lora.trigger_words || 'No triggers'}
+
+
+
✏️
+ `; - if (lora.preview_type === 'video' && lora.preview_url) { - mediaHTML = ``; + if (lora.preview_type !== 'video') { + card.querySelector("img").onerror = (e) => { e.target.src = empty_lora_image; }; + } + galleryEl.appendChild(card); + + card.querySelector(".lora-card-link-btn")?.addEventListener("click", e => e.stopPropagation()); + card.querySelector(".sync-civitai-btn").addEventListener("click", e => { + e.stopPropagation(); + syncWithCivitai(lora.name, card); + }); + + if (this.loraData.some(item => item.lora === lora.name)) card.classList.add("selected-flow"); + if (this.selectedCardsForEditing.has(card)) card.classList.add("selected-edit"); + + renderCardTags(card); + + if (lora.preview_type === 'video') { + const video = card.querySelector('video'); + if (video) { + card.addEventListener('mouseenter', () => video.play().catch(e => {})); + card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; }); + } + } + + card.addEventListener("click", () => { + const loraName = card.dataset.loraName; + const existingIndex = this.loraData.findIndex(item => item.lora === loraName); + if (existingIndex > -1) { + this.loraData.splice(existingIndex, 1); } else { - mediaHTML = ``; + this.loraData.push({ on: true, lora: loraName, strength: 1.0, strength_clip: 1.0 }); } + renderSelectedList(); + updateSelection(); + updatePresetButtonText(null); - const linkBtnHTML = lora.download_url - ? `🔗` - : ''; + card.classList.toggle("selected-flow"); + }); - card.innerHTML = ` - ${linkBtnHTML} -
- ${mediaHTML} -
-
-

${lora.name}

-
${lora.trigger_words || 'No triggers'}
-
-
-
✏️
- `; - - if (lora.preview_type !== 'video') { - card.querySelector("img").onerror = (e) => { e.target.src = empty_lora_image; }; - } - 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"); - } - if (this.selectedCardsForEditing.has(card)) { - card.classList.add("selected-edit"); - } - - renderCardTags(card); - - if (lora.preview_type === 'video') { - const video = card.querySelector('video'); - if (video) { - card.addEventListener('mouseenter', () => { - video.play().catch(e => { /* Ignore errors from autoplay restrictions */ }); - }); - card.addEventListener('mouseleave', () => { - video.pause(); - video.currentTime = 0; - }); - } - } - - card.addEventListener("click", () => { - const loraName = card.dataset.loraName; - const existingIndex = this.loraData.findIndex(item => item.lora === loraName); - if (existingIndex > -1) { - this.loraData.splice(existingIndex, 1); + const editBtn = card.querySelector(".edit-tags-btn"); + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + + if (e.ctrlKey) { + if (this.selectedCardsForEditing.has(card)) { + this.selectedCardsForEditing.delete(card); + card.classList.remove("selected-edit"); } else { - this.loraData.push({ on: true, lora: loraName, strength: 1.0, strength_clip: 1.0 }); + this.selectedCardsForEditing.add(card); + card.classList.add("selected-edit"); } - fetchAndRender(false); - renderSelectedList(); - updateSelection(); - updatePresetButtonText(null); - }); - - const editBtn = card.querySelector(".edit-tags-btn"); - editBtn.addEventListener("click", (e) => { - e.stopPropagation(); - - if (e.ctrlKey) { - if (this.selectedCardsForEditing.has(card)) { - this.selectedCardsForEditing.delete(card); - card.classList.remove("selected-edit"); - } else { - this.selectedCardsForEditing.add(card); - card.classList.add("selected-edit"); - } + } else { + if (this.selectedCardsForEditing.has(card) && this.selectedCardsForEditing.size === 1) { + this.selectedCardsForEditing.clear(); + card.classList.remove("selected-edit"); } else { - if (this.selectedCardsForEditing.has(card) && this.selectedCardsForEditing.size === 1) { - this.selectedCardsForEditing.clear(); - card.classList.remove("selected-edit"); - } else { - document.querySelectorAll(`#${uniqueId} .locallora-lora-card.selected-edit`).forEach(c => c.classList.remove("selected-edit")); - this.selectedCardsForEditing.clear(); - - this.selectedCardsForEditing.add(card); - card.classList.add("selected-edit"); - } + document.querySelectorAll(`#${uniqueId} .locallora-lora-card.selected-edit`).forEach(c => c.classList.remove("selected-edit")); + this.selectedCardsForEditing.clear(); + + this.selectedCardsForEditing.add(card); + card.classList.add("selected-edit"); } - - renderMetadataEditor(); - }); + } + + renderMetadataEditor(); + }); }); }; const fetchAndRender = async (append = false) => { if (this.isLoading) return; - const pageToFetch = append ? this.currentPage + 1 : 1; if (append && pageToFetch > this.totalPages) return; - - const tagFilter = tagFilterInput.value; - const filterMode = tagFilterModeBtn.textContent; - const folderFilter = folderFilterSelect.value; - const selectedLoraNames = this.loraData.map(item => item.lora); - - const { loras, folders } = await LocalLoraGalleryNode.getLoras.call(this, tagFilter, filterMode, folderFilter, pageToFetch, selectedLoraNames); + const { loras, folders } = await LocalLoraGalleryNode.getLoras.call(this, tagFilterInput.value, tagFilterModeBtn.textContent, folderFilterSelect.value, pageToFetch, this.loraData.map(item => item.lora)); if (append) { const existingNames = new Set(this.availableLoras.map(l => l.name)); - const newLoras = (loras || []).filter(l => !existingNames.has(l.name)); - this.availableLoras = this.availableLoras.concat(newLoras); + this.availableLoras.push(...(loras || []).filter(l => !existingNames.has(l.name))); } else { this.availableLoras = loras || []; - if (!foldersRendered && folders && folders.length > 0) { - renderFolders(folders); - } + if (!foldersRendered && folders && folders.length > 0) renderFolders(folders); galleryEl.scrollTop = 0; } - renderGallery(append); }; @@ -648,8 +633,7 @@ const LocalLoraGalleryNode = { checkbox.type = 'checkbox'; checkbox.value = tag; checkbox.addEventListener('change', handleTagSelectionChange); - label.appendChild(checkbox); - label.appendChild(document.createTextNode(` ${tag}`)); + label.append(checkbox, ` ${tag}`); multiSelectTagDropdown.appendChild(label); }); } @@ -835,9 +819,7 @@ const LocalLoraGalleryNode = { const selectionJson = JSON.stringify(this.loraData); this.setProperty("selection_data", selectionJson); const widget = this.widgets.find(w => w.name === "selection_data"); - if (widget) { - widget.value = selectionJson; - } + if (widget) widget.value = selectionJson; tagFilterInput.value = initialState.filter_tag; if (initialState.filter_mode === "AND") { @@ -1070,7 +1052,7 @@ const LocalLoraGalleryNode = { updateSelection(); }); - searchInput.addEventListener("input", renderGallery); + searchInput.addEventListener("input", () => renderGallery(false)); tagFilterInput.addEventListener("keydown", (e) => { if(e.key === 'Enter') saveStateAndFetch(); }); const arrow = multiSelectTagContainer.querySelector('.locallora-multiselect-arrow'); @@ -1085,8 +1067,8 @@ const LocalLoraGalleryNode = { multiSelectTagDropdown.style.display = 'none'; arrow.classList.remove('open'); } - if (!loadPresetBtn.contains(e.target)) { - presetDropdown.style.display = 'none'; + if (presetDropdown && !loadPresetBtn.contains(e.target) && !presetDropdown.contains(e.target)) { + presetDropdown.style.display = 'none'; } }); }; diff --git a/pyproject.toml b/pyproject.toml index 6239bbe..68c5e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.6" +version = "1.1.0" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems)