diff --git a/README.md b/README.md index 40d551a..b49b0e5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ External LoRA Loader solves this by letting you browse to any path directly from - **Drive auto-detection** — Automatically detects all mounted drives on Windows, macOS, and Linux at startup - **Tree-style file browser** — Click Browse to open a modal with a full expandable drive/folder tree; navigate to any location without typing paths - **Extension filter** — Filter the browser to Safetensors only, all LoRA types, PyTorch files, or all files +- **LoRA metadata popup** — Single-click any `.safetensors` file to open a tabbed info panel showing base model, rank/alpha, training stats, trigger tags, and author notes without loading the file - **Draggable and resizable modal** — Drag the browser by its header; resize from the bottom-right corner - **Keyboard navigation** — Press Enter to confirm a selection, Escape to close - **System RAM caching** — LoRAs are loaded into memory on first use; subsequent runs skip disk I/O entirely @@ -81,12 +82,27 @@ Click the **Browse…** button on the node. A file browser modal opens, showing - **Checkpoint** — `.ckpt` only - **PyTorch** — `.pt` and `.pth` - **All files** — every non-hidden file -- Click a LoRA file to select it; the filename appears in the status bar +- Click a LoRA file to select it and open the **metadata popup** (see below) - Click **Select**, double-click the file, or press **Enter** to confirm - Press **Escape** or click **✕** to cancel The modal is draggable (grab the title bar) and resizable (drag the bottom-right corner). It remembers its position and size for the session. +#### Metadata popup + +Single-clicking any `.safetensors` file opens a floating info panel to the right of the browser. It reads the file's embedded header without loading the full model into memory and displays four tabs: + +| Tab | Contents | +|---|---| +| **Overview** | File size, last modified, base model version, LoRA rank (dim), alpha, effective scale (alpha ÷ dim) | +| **Training** | Step count, epochs, number of training images, resolution, UNet and text-encoder learning rates | +| **Tags** | Top trigger tags extracted from `ss_tag_frequency`, sorted by frequency and shown as chips | +| **About** | Title, author, license, and training notes / trigger-word hints left by the creator | + +Files without embedded metadata (non-safetensors formats, or safetensors files that omit `__metadata__`) still show filesystem info in the Overview tab; the remaining tabs display a "No embedded metadata" message. + +The popup closes when you click a folder, confirm a selection, or close the browser. + After confirming, the selected path is shown in the **selected_path** display on the node canvas. ### 3. Set strength diff --git a/api.py b/api.py index 9b88831..fc90c7a 100644 --- a/api.py +++ b/api.py @@ -1,18 +1,22 @@ """ api.py — aiohttp route handlers for the External LoRA Loader custom node. -Registers six routes on ComfyUI's aiohttp server: +Registers seven routes on ComfyUI's aiohttp server: POST /external_lora/list_files — list .safetensors/.ckpt files in a directory POST /external_lora/clear_cache — flush the in-memory LoRA tensor cache GET /external_lora/cache_stats — return current and max cache usage in MB GET /external_lora/loras_folder — return ComfyUI's configured loras folder GET /external_lora/drives — return detected drive/mount-point list POST /external_lora/browse — browse directories and files interactively + GET /external_lora/metadata — return file stats and embedded safetensors metadata """ import os import glob +import json as _json import platform +import struct +from datetime import datetime import psutil from aiohttp import web @@ -222,6 +226,59 @@ async def _browse_handler(request: web.Request) -> web.Response: return web.json_response({"dirs": dirs, "files": files, "error": None}) +async def _metadata_handler(request: web.Request) -> web.Response: + """GET /external_lora/metadata?drive=&path= + + Returns file system stats and, for .safetensors files, the embedded + __metadata__ dict parsed from the safetensors header. + + Response JSON: + { "file_size_mb": float, "modified": "YYYY-MM-DDTHH:MM:SS", + "metadata": dict | null, "error": str | null } + """ + drive = request.rel_url.query.get("drive", "") + path = request.rel_url.query.get("path", "") + + if not drive and not path: + return web.json_response({"file_size_mb": None, "modified": None, + "metadata": None, "error": "missing path"}) + + full_path = os.path.normpath(os.path.join(drive, path)) + + if not _is_within_drive(full_path, DRIVE_LIST): + return web.json_response({"file_size_mb": None, "modified": None, + "metadata": None, "error": "forbidden"}) + + if not os.path.isfile(full_path): + return web.json_response({"file_size_mb": None, "modified": None, + "metadata": None, "error": "not_found"}) + + stat = os.stat(full_path) + size_mb = round(stat.st_size / (1024 * 1024), 2) + modified = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%dT%H:%M:%S") + + metadata = None + if full_path.lower().endswith(".safetensors"): + try: + with open(full_path, "rb") as f: + raw = f.read(8) + if len(raw) == 8: + header_len = struct.unpack_from("64 MB) + if header_len <= 64 * 1024 * 1024: + header = _json.loads(f.read(header_len)) + metadata = header.get("__metadata__", {}) + except Exception: + pass + + return web.json_response({ + "file_size_mb": size_mb, + "modified": modified, + "metadata": metadata, + "error": None, + }) + + # --------------------------------------------------------------------------- # Route registration # --------------------------------------------------------------------------- @@ -237,3 +294,4 @@ def register_routes(routes) -> None: routes.get("/external_lora/loras_folder")(_loras_folder_handler) routes.get("/external_lora/drives")(_drives_handler) routes.post("/external_lora/browse")(_browse_handler) + routes.get("/external_lora/metadata")(_metadata_handler) diff --git a/web/ext.js b/web/ext.js index 43847e0..a4c4a24 100644 --- a/web/ext.js +++ b/web/ext.js @@ -139,6 +139,99 @@ function injectStyles() { transparent 80%); opacity: 0.6; } + .ell-meta-popup { + position: fixed; + background: var(--comfy-menu-bg, #353535); + border-radius: 8px; + box-shadow: 0 4px 32px rgba(0,0,0,0.6); + display: flex; flex-direction: column; + overflow: hidden; + z-index: 10000; + font-family: system-ui, sans-serif; + font-size: 13px; + color: var(--fg-color, #ddd); + min-width: 200px; + } + .ell-meta-popup-header { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #4e4e4e); + flex-shrink: 0; + } + .ell-meta-popup-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--input-text, #aaa); + font-weight: bold; + } + .ell-meta-popup-close { + background: none; border: none; color: var(--fg-color, #fff); + cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 4px; + } + .ell-meta-tabs { + display: flex; + border-bottom: 1px solid var(--border-color, #4e4e4e); + flex-shrink: 0; + } + .ell-meta-tab { + flex: 1; + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--input-text, #aaa); cursor: pointer; + padding: 6px 4px; font-size: 11px; + } + .ell-meta-tab:hover { color: var(--fg-color, #ddd); } + .ell-meta-tab-active { + color: #fff !important; + border-bottom-color: #0078d4 !important; + } + .ell-meta-body { + flex: 1; overflow-y: auto; padding: 8px; + background: var(--comfy-input-bg, #1a1a1a); + } + .ell-meta-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 10px; + align-items: baseline; + } + .ell-meta-key { + color: var(--input-text, #888); + font-size: 11px; + text-align: right; + white-space: nowrap; + } + .ell-meta-val { + color: var(--fg-color, #ddd); + font-size: 12px; + word-break: break-word; + } + .ell-meta-empty { + color: var(--input-text, #666); + font-size: 12px; + text-align: center; + padding: 24px 8px; + font-style: italic; + } + .ell-meta-loading { + color: var(--input-text, #888); + font-size: 12px; + text-align: center; + padding: 24px 8px; + } + .ell-meta-tag-list { + display: flex; flex-wrap: wrap; gap: 4px; + padding: 2px 0; + } + .ell-meta-tag { + background: rgba(255,255,255,0.1); + border-radius: 12px; + padding: 2px 8px; + font-size: 11px; + color: var(--fg-color, #ddd); + cursor: default; + white-space: nowrap; + } `; document.head.appendChild(style); } @@ -153,6 +246,11 @@ let _lorasFolderCache = undefined; // Last extension filter the user chose — persists across opens and nodes let _lastExtFilter = ".safetensors"; // default: Safetensors only +// Metadata popup state (shared across all node instances; only one popup shown at a time) +let metaPopupEl = null; +let _metaActiveTab = "overview"; +let _metaFetchSeq = 0; + async function _fetchLorasFolder() { if (_lorasFolderCache) return _lorasFolderCache; // only cache successes; failures retry next open try { @@ -174,6 +272,229 @@ function _extParam(filter) { return filter.split(","); // e.g. ".pt,.pth" } +// --------------------------------------------------------------------------- +// Metadata popup (module-level; shared across nodes) +// --------------------------------------------------------------------------- + +function _escHtml(str) { + return String(str) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function buildMetaPopup() { + if (metaPopupEl) return; + metaPopupEl = document.createElement("div"); + metaPopupEl.className = "ell-meta-popup"; + metaPopupEl.style.display = "none"; + + const popHeader = document.createElement("div"); + popHeader.className = "ell-meta-popup-header"; + const popTitle = document.createElement("span"); + popTitle.className = "ell-meta-popup-title"; + popTitle.textContent = "LoRA Info"; + const popClose = document.createElement("button"); + popClose.className = "ell-meta-popup-close"; + popClose.textContent = "\u2715"; + popClose.onclick = hideMetaPopup; + popHeader.appendChild(popTitle); + popHeader.appendChild(popClose); + + const tabBar = document.createElement("div"); + tabBar.className = "ell-meta-tabs"; + for (const [id, label] of [["overview","Overview"],["training","Training"],["tags","Tags"],["about","About"]]) { + const tab = document.createElement("button"); + tab.className = "ell-meta-tab" + (id === _metaActiveTab ? " ell-meta-tab-active" : ""); + tab.textContent = label; + tab.dataset.tab = id; + tab.onclick = () => { + _metaActiveTab = id; + metaPopupEl.querySelectorAll(".ell-meta-tab").forEach(t => + t.classList.toggle("ell-meta-tab-active", t.dataset.tab === id)); + metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => + p.style.display = p.dataset.panel === id ? "" : "none"); + }; + tabBar.appendChild(tab); + } + + const popBody = document.createElement("div"); + popBody.className = "ell-meta-body"; + for (const id of ["overview","training","tags","about"]) { + const panel = document.createElement("div"); + panel.className = "ell-meta-panel"; + panel.dataset.panel = id; + panel.style.display = id === _metaActiveTab ? "" : "none"; + popBody.appendChild(panel); + } + + metaPopupEl.appendChild(popHeader); + metaPopupEl.appendChild(tabBar); + metaPopupEl.appendChild(popBody); + document.body.appendChild(metaPopupEl); +} + +function positionMetaPopup(boxEl) { + if (!metaPopupEl || !boxEl) return; + const rect = boxEl.getBoundingClientRect(); + const popW = 280; + const gap = 8; + const vpW = window.innerWidth; + + let left; + if (rect.right + gap + popW <= vpW) { + left = rect.right + gap; + metaPopupEl.style.opacity = "1"; + } else { + left = Math.max(rect.right - popW - 4, rect.left); + metaPopupEl.style.opacity = "0.96"; + } + metaPopupEl.style.left = left + "px"; + metaPopupEl.style.top = rect.top + "px"; + metaPopupEl.style.width = popW + "px"; + metaPopupEl.style.height = rect.height + "px"; +} + +function _renderMetaPanels(result) { + if (!metaPopupEl) return; + const meta = (result && result.metadata) || {}; + const noMeta = !(result && result.metadata); + + function grid(rows) { + if (!rows.length) return '
No data
'; + return '
' + + rows.map(([k, v]) => + `${_escHtml(k)}` + + `${v ?? "\u2014"}` + ).join("") + "
"; + } + + const noEmbed = '
No embedded metadata
'; + + // --- Overview --- + const ov = []; + if (result && result.file_size_mb != null) ov.push(["Size", result.file_size_mb + " MB"]); + if (result && result.modified) ov.push(["Modified", result.modified.replace("T", " ")]); + if (meta.ss_base_model_version) ov.push(["Base model", _escHtml(meta.ss_base_model_version)]); + const dim = meta.ss_network_dim; + const alpha = meta.ss_network_alpha; + if (dim) ov.push(["Rank (dim)", _escHtml(dim)]); + if (alpha) ov.push(["Alpha", _escHtml(alpha)]); + if (dim && alpha) { + const d = parseFloat(dim), a = parseFloat(alpha); + if (d > 0) ov.push(["Eff. scale", (a / d).toFixed(3)]); + } + if (meta.ss_network_module) + ov.push(["Network", _escHtml(meta.ss_network_module.split(".").pop())]); + + // --- Training --- + const tr = []; + if (!noMeta) { + if (meta.ss_steps) tr.push(["Steps", _escHtml(meta.ss_steps)]); + if (meta.ss_epoch) tr.push(["Epochs", _escHtml(meta.ss_epoch)]); + if (meta.ss_num_train_images) tr.push(["Train images", _escHtml(meta.ss_num_train_images)]); + if (meta.ss_num_reg_images) tr.push(["Reg images", _escHtml(meta.ss_num_reg_images)]); + if (meta.ss_resolution) tr.push(["Resolution", _escHtml(meta.ss_resolution)]); + if (meta.ss_unet_lr) tr.push(["UNet LR", _escHtml(meta.ss_unet_lr)]); + if (meta.ss_text_encoder_lr) tr.push(["TE LR", _escHtml(meta.ss_text_encoder_lr)]); + else if (meta.ss_learning_rate) tr.push(["LR", _escHtml(meta.ss_learning_rate)]); + } + + // --- Tags --- + let tagHtml = noMeta ? noEmbed : ""; + if (!noMeta) { + let tagFreq = meta.ss_tag_frequency; + if (typeof tagFreq === "string") { + try { tagFreq = JSON.parse(tagFreq); } catch { tagFreq = null; } + } + if (tagFreq && typeof tagFreq === "object" && !Array.isArray(tagFreq)) { + const merged = {}; + for (const dir of Object.values(tagFreq)) { + if (dir && typeof dir === "object") { + for (const [tag, count] of Object.entries(dir)) { + merged[tag] = (merged[tag] || 0) + Number(count); + } + } + } + const sorted = Object.entries(merged).sort((a, b) => b[1] - a[1]).slice(0, 40); + tagHtml = sorted.length + ? '
' + + sorted.map(([tag, cnt]) => + `${_escHtml(tag)}` + ).join("") + "
" + : '
No tags
'; + } else { + tagHtml = '
No tag data
'; + } + } + + // --- About --- + const ab = []; + if (!noMeta) { + const title = meta["modelspec.title"] || meta.ss_output_name; + const author = meta["modelspec.author"]; + const desc = meta["modelspec.description"] || meta.ss_training_comment; + const license = meta["modelspec.license"]; + const tags = meta["modelspec.tags"]; + if (title) ab.push(["Title", _escHtml(title)]); + if (author) ab.push(["Author", _escHtml(author)]); + if (license) ab.push(["License", _escHtml(license)]); + if (tags) ab.push(["Tags", _escHtml(tags)]); + if (desc) ab.push(["Notes", + `${_escHtml(desc)}`]); + } + + const content = { + overview: grid(ov), + training: noMeta ? noEmbed : (tr.length ? grid(tr) : '
No training data
'), + tags: tagHtml, + about: noMeta ? noEmbed : (ab.length ? grid(ab) : '
No description
'), + }; + + metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => { + p.innerHTML = content[p.dataset.panel] || ""; + }); +} + +async function showMetaPopup(data, boxEl) { + buildMetaPopup(); + positionMetaPopup(boxEl); + metaPopupEl.style.display = "flex"; + + // Sync tab active state in case it changed since last open + metaPopupEl.querySelectorAll(".ell-meta-tab").forEach(t => + t.classList.toggle("ell-meta-tab-active", t.dataset.tab === _metaActiveTab)); + metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => + p.style.display = p.dataset.panel === _metaActiveTab ? "" : "none"); + + // Loading state in every panel + metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => { + p.innerHTML = '
Loading\u2026
'; + }); + + const seq = ++_metaFetchSeq; + const params = new URLSearchParams({ + drive: data.drive, + path: data.segments.join("/"), + }); + + let result = null; + try { + const resp = await fetch("/external_lora/metadata?" + params); + if (resp.ok) result = await resp.json(); + } catch {} + + if (seq !== _metaFetchSeq) return; // a newer file was clicked + if (!metaPopupEl || metaPopupEl.style.display === "none") return; + + _renderMetaPanels(result || {}); +} + +function hideMetaPopup() { + if (metaPopupEl) metaPopupEl.style.display = "none"; +} + +// --------------------------------------------------------------------------- + app.registerExtension({ name: "ExternalLoraLoader", @@ -289,6 +610,7 @@ app.registerExtension({ // 6. Modal DOM (built lazily on first open) let modalOverlay = null; + let modalBoxEl = null; let treeContainerEl = null; let statusEl = null; let selectBtn = null; @@ -412,10 +734,12 @@ app.registerExtension({ } if (statusEl) statusEl.textContent = data.name; renderTree(); + showMetaPopup(data, modalBoxEl); return; } // Folder / drive — lazy load on first expand, then toggle + hideMetaPopup(); if (!data._loaded) { if (statusEl) statusEl.textContent = "Loading\u2026"; const path = data.segments.join("/"); @@ -601,6 +925,7 @@ app.registerExtension({ const box = document.createElement("div"); box.className = "ell-modal-box"; + modalBoxEl = box; // Header const header = document.createElement("div"); @@ -766,6 +1091,7 @@ app.registerExtension({ function closeModal() { if (modalOverlay) modalOverlay.style.display = "none"; + hideMetaPopup(); // Remove Escape key handler to avoid memory leak if (onKeyDown) { document.removeEventListener("keydown", onKeyDown);