commit 2c1777ae4e0313f453cdb8a617039229cf149160 Author: Ethanfel Date: Thu Jun 4 00:25:41 2026 +0200 Initial commit: in-process object_info cache + refresh UI ComfyUI-Tenaciousload speeds up ComfyUI page loads for large model/LoRA collections by caching the slow /api/object_info response in memory and on disk (survives restarts) and serving it gzipped in ~milliseconds, instead of rebuilding it (and freezing the event loop) on every load. Adds a "Refresh Models / LoRAs" menu button, a graph node, and a POST /tenaciousload/refresh endpoint to rebuild the cache after adding or removing models. No external dependencies; no nginx/docker required. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e77665 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +cache/ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fbd5cb7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ethanfel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a79b294 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# ComfyUI-Tenaciousload + +Self-contained fix for slow / black-screen ComfyUI loading when you have a huge +model/LoRA collection (especially on a network mount). **Just install the pack +and restart ComfyUI — no nginx, no docker, no extra port.** + +## The problem +ComfyUI's `/api/object_info` enumerates every node's inputs. With thousands of +LoRAs (worse on a network mount) it becomes tens of MB and takes **minutes to +build on every page load** — and the build **freezes ComfyUI's whole event +loop**, so you get a long black screen, worst over a remote network. + +## How this pack fixes it +On load it injects an aiohttp **middleware** into ComfyUI that intercepts +`/object_info` and `/api/object_info` and: + +- **caches the built response in memory _and_ on disk** (`./cache/`), so it is + built once instead of on every load — and the disk copy makes **restarts + instant** (no rebuild); +- **serves it gzipped in ~milliseconds** (≈85% smaller, independent of any CLI + flag); +- serves from cache **without running the build**, so the event-loop freeze is + gone for normal loads. + +The only time a build runs is the first load after install, or when you +explicitly refresh (below). + +## Refreshing after you add / remove models or LoRAs +The cache holds the old model lists until you refresh, so new files won't appear +until you do one of: + +- **Menu:** `Extensions ▸ 🔄 Refresh Models / LoRAs` (also in the command palette). +- **Graph node:** `🔄 Refresh Models/LoRAs (Tenaciousload)` (for automated workflows). +- **HTTP:** `POST /tenaciousload/refresh`, then `GET /object_info?nocache=1`. + +A refresh re-walks your model folders (slow over a network mount, ~minutes) — the +button shows a "refreshing…" toast meanwhile. Normal loads stay instant. + +## Requirements +**None to install.** Only ComfyUI itself (tested on 0.23.0) and Python ≥ 3.8. +Everything used is Python stdlib or already bundled with ComfyUI (`aiohttp`, +`folder_paths`, `server`). The web button needs no npm packages. + +## Install +Clone (or copy) this repo into your ComfyUI `custom_nodes/` folder and restart +ComfyUI: + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/ethanfel/ComfyUI-Tenaciousload.git +# then restart ComfyUI +``` +Nothing to `pip install`. ComfyUI-Manager can also install it from the registry. + +## Verify it's working +After restart, load the page once (first time builds + caches), then: +```bash +curl -s -H 'Accept-Encoding: gzip' -o /dev/null \ + -w '%{time_total}s | %{size_download} bytes | %header{x-tenaciousload-cache} | %header{content-encoding}\n' \ + http://127.0.0.1:8188/api/object_info # use your ComfyUI port +# expect after the first load: ~0.00Xs | ~10 MB | HIT | gzip +``` +ComfyUI's startup log should show `Tenaciousload: object_info cache middleware installed`. + +## Notes +- The disk cache lives in `./cache/` (git-ignored). Delete it, or use the refresh + button, to force a rebuild. +- An nginx reverse proxy can cache `object_info` at the HTTP layer too, but this + pack does it in-process so no extra service, container, or port is needed. + +## License +MIT — see [LICENSE](LICENSE). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cbf7a88 --- /dev/null +++ b/__init__.py @@ -0,0 +1,203 @@ +""" +ComfyUI-Tenaciousload +===================== +Self-contained fix for slow / black-screen ComfyUI loading when you have a huge +model/LoRA collection (especially on a network mount). + +The cause: /api/object_info is tens of MB and takes minutes to build on EVERY +page load (it re-walks the model folders), and the build blocks ComfyUI's event +loop the whole time. This pack installs an in-process caching layer that: + + * caches the built object_info in memory AND on disk (so it survives restarts), + * serves it gzipped in ~milliseconds instead of rebuilding every load, + * exposes a one-click "Refresh Models / LoRAs" button + graph node to rebuild + the cache after you add/remove models. + +No nginx, no docker, no extra port. Just install the pack and restart ComfyUI. +""" + +import os +import gzip +import json +import logging +import threading + +from aiohttp import web + +import folder_paths +from server import PromptServer + +log = logging.getLogger("Tenaciousload") + +WEB_DIRECTORY = "./web" + +# --- cache storage -------------------------------------------------------------- +_CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache") +_RAW_PATH = os.path.join(_CACHE_DIR, "object_info.json") +_GZ_PATH = os.path.join(_CACHE_DIR, "object_info.json.gz") +_OBJECT_INFO_PATHS = ("/object_info", "/api/object_info") +_GZIP_LEVEL = 5 + +_lock = threading.Lock() +_mem = {"raw": None, "gz": None} # bytes / bytes +_disk_loaded = False + + +def _load_from_disk(): + """Populate the in-memory cache from disk once (cheap, makes restarts instant).""" + global _disk_loaded + _disk_loaded = True + try: + if os.path.exists(_GZ_PATH): + with open(_GZ_PATH, "rb") as f: + _mem["gz"] = f.read() + if os.path.exists(_RAW_PATH): + with open(_RAW_PATH, "rb") as f: + _mem["raw"] = f.read() + if _mem["gz"] is not None and _mem["raw"] is None: + _mem["raw"] = gzip.decompress(_mem["gz"]) + if _mem["raw"] is not None: + log.info("Tenaciousload: loaded object_info cache from disk (%d bytes raw)", len(_mem["raw"])) + except Exception as e: # pragma: no cover + log.warning("Tenaciousload: failed to load disk cache: %s", e) + + +def _store(raw_bytes): + """Cache a freshly built object_info body (memory + disk).""" + with _lock: + gz = gzip.compress(raw_bytes, _GZIP_LEVEL) + _mem["raw"] = raw_bytes + _mem["gz"] = gz + try: + os.makedirs(_CACHE_DIR, exist_ok=True) + with open(_RAW_PATH, "wb") as f: + f.write(raw_bytes) + with open(_GZ_PATH, "wb") as f: + f.write(gz) + except Exception as e: # pragma: no cover + log.warning("Tenaciousload: failed to persist disk cache: %s", e) + log.info("Tenaciousload: cached object_info (%d bytes raw / %d gz)", len(raw_bytes), len(gz)) + + +def invalidate_object_info_cache(): + """Drop the cached object_info so the next request rebuilds it.""" + with _lock: + _mem["raw"] = None + _mem["gz"] = None + for p in (_RAW_PATH, _GZ_PATH): + try: + os.remove(p) + except FileNotFoundError: + pass + except Exception as e: # pragma: no cover + log.warning("Tenaciousload: could not delete %s: %s", p, e) + + +def clear_comfy_model_cache(): + """Force ComfyUI to re-scan every model/LoRA folder on the next build.""" + try: + folder_paths.filename_list_cache.clear() + except Exception as e: # pragma: no cover + log.warning("Tenaciousload: could not clear filename_list_cache: %s", e) + try: + folder_paths.cache_helper.clear() + except Exception as e: # pragma: no cover + log.warning("Tenaciousload: could not clear cache_helper: %s", e) + + +def _serve_cached(request): + raw = _mem["raw"] + gz = _mem["gz"] + accepts_gzip = "gzip" in request.headers.get("Accept-Encoding", "") + if accepts_gzip and gz is not None: + return web.Response( + body=gz, status=200, content_type="application/json", + headers={"Content-Encoding": "gzip", "X-Tenaciousload-Cache": "HIT", + "Cache-Control": "no-store"}, + ) + if raw is None and gz is not None: + raw = gzip.decompress(gz) + return web.Response( + body=raw, status=200, content_type="application/json", + headers={"X-Tenaciousload-Cache": "HIT", "Cache-Control": "no-store"}, + ) + + +@web.middleware +async def _object_info_cache_mw(request, handler): + # Only touch the full object_info list (not /object_info/{node_class}). + if request.method != "GET" or request.path not in _OBJECT_INFO_PATHS: + return await handler(request) + + global _disk_loaded + if not _disk_loaded: + _load_from_disk() + + nocache = "nocache" in request.query + if not nocache and _mem["raw"] is not None: + return _serve_cached(request) + + # MISS (or forced refresh): build via the real handler, then cache the body. + resp = await handler(request) + try: + body = getattr(resp, "body", None) + if resp.status == 200 and isinstance(body, (bytes, bytearray)) and len(body) > 0: + _store(bytes(body)) + return _serve_cached(request) + except Exception as e: # pragma: no cover + log.warning("Tenaciousload: caching skipped: %s", e) + return resp + + +def _install_middleware(): + try: + app = PromptServer.instance.app + app.middlewares.insert(0, _object_info_cache_mw) + log.info("Tenaciousload: object_info cache middleware installed") + except Exception as e: + log.error("Tenaciousload: could not install cache middleware (loads will be slow): %s", e) + + +_install_middleware() + + +# --- refresh API ---------------------------------------------------------------- +@PromptServer.instance.routes.post("/tenaciousload/refresh") +async def _refresh(request): + clear_comfy_model_cache() + invalidate_object_info_cache() + log.info("Tenaciousload: caches cleared via refresh endpoint") + return web.json_response({"status": "ok"}) + + +# --- optional graph node (for workflow automation) ------------------------------ +class _AnyType(str): + def __ne__(self, other): + return False + + +ANY = _AnyType("*") + + +class TenaciousloadRefresh: + @classmethod + def INPUT_TYPES(cls): + return {"optional": {"trigger": (ANY, {})}} + + RETURN_TYPES = (ANY,) + RETURN_NAMES = ("trigger",) + FUNCTION = "refresh" + CATEGORY = "Tenaciousload" + OUTPUT_NODE = True + DESCRIPTION = "Clear ComfyUI's model/LoRA cache and invalidate the object_info cache." + + def refresh(self, trigger=None): + clear_comfy_model_cache() + invalidate_object_info_cache() + return (trigger,) + + +NODE_CLASS_MAPPINGS = {"TenaciousloadRefresh": TenaciousloadRefresh} +NODE_DISPLAY_NAME_MAPPINGS = {"TenaciousloadRefresh": "🔄 Refresh Models/LoRAs (Tenaciousload)"} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..46e2aaf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "comfyui-tenaciousload" +description = "Fast ComfyUI loading behind an nginx cache, with an in-UI button to refresh model/LoRA lists." +version = "1.0.0" +license = { text = "MIT" } +dependencies = [] + +[project.urls] +Repository = "https://github.com/ethanfel/ComfyUI-Tenaciousload" + +[tool.comfy] +PublisherId = "ethanfel" +DisplayName = "Tenaciousload" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a92144f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# ComfyUI-Tenaciousload has NO external dependencies. +# Everything it uses is either the Python standard library +# (os, gzip, json, logging, threading) or already provided by ComfyUI: +# - aiohttp (ComfyUI's web server, aiohttp>=3.11.8) +# - folder_paths, server (ComfyUI internal modules) +# +# Requirements to run: +# - ComfyUI (any recent version; tested on 0.23.0) +# - Python >= 3.8 diff --git a/web/tenaciousload.js b/web/tenaciousload.js new file mode 100644 index 0000000..4c98970 --- /dev/null +++ b/web/tenaciousload.js @@ -0,0 +1,55 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +let busy = false; + +function notify(severity, detail, life = 6000) { + const toast = app.extensionManager?.toast; + if (toast?.add) { + toast.add({ severity, summary: "Tenaciousload", detail, life }); + } else if (severity === "error") { + alert("Tenaciousload: " + detail); + } + console[severity === "error" ? "error" : "log"]("[Tenaciousload]", detail); +} + +async function doRefresh() { + if (busy) { + notify("warn", "A refresh is already running…"); + return; + } + busy = true; + notify("info", "Refreshing models/LoRAs — scanning over the network, this can take a few minutes…", 10000); + try { + // 1) clear ComfyUI's folder cache + drop the object_info cache. + await api.fetchApi("/tenaciousload/refresh", { method: "POST" }); + // 2) force a fresh object_info build and re-cache it (this is the slow step). + await api.fetchApi("/object_info?nocache=1").then((r) => r.arrayBuffer()); + // 3) update open node dropdowns live if the frontend supports it. + try { await app.refreshComboInNodes?.(); } catch (e) { /* non-fatal */ } + notify("success", "Done — new models are now available (reload the page if a dropdown still looks stale).", 8000); + } catch (e) { + notify("error", "Refresh failed: " + (e?.message || e), 10000); + } finally { + busy = false; + } +} + +app.registerExtension({ + name: "Tenaciousload.Refresh", + commands: [ + { + id: "Tenaciousload.refresh", + label: "🔄 Refresh Models / LoRAs", + icon: "pi pi-refresh", + function: doRefresh, + }, + ], + // Adds the command under the top "Extensions" menu (and the command palette). + menuCommands: [ + { + path: ["Extensions"], + commands: ["Tenaciousload.refresh"], + }, + ], +});