diff --git a/README.md b/README.md index a74291e..d37444d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ ComfyUI's native caching often breaks with external API nodes (dynamic timestamp - **Device-Portable Caching:** All tensors are saved to CPU and loaded with `map_location="cpu"`, so cache files work regardless of GPU configuration. - **Atomic Writes:** Cache files are written to a temp file first, then atomically replaced — preventing corruption from interrupted writes. - **Concurrent-Safe:** File locking on every cache read/write operation. -- **Sidecar Metadata (v1.3.0):** Every saved entry gets a `{hash_key}.json` sidecar with human-readable `label`, `created_at`, `last_accessed_at`, and payload summary. Image outputs also get a `{hash_key}.thumb.png` 256px preview. Sidecar data is decoupled from the hash, so editing a `label` never invalidates cache. This is what the upcoming Hash Vault Browser indexes. +- **Sidecar Metadata (v1.3.0):** Every saved entry gets a `{hash_key}.json` sidecar with human-readable `label`, `created_at`, `last_accessed_at`, and payload summary. Image outputs also get a `{hash_key}.thumb.png` 256px preview. Sidecar data is decoupled from the hash, so editing a `label` never invalidates cache. This is what the Hash Vault Browser indexes. +- **Hash Vault Browser (v1.4.0):** A modal gallery over the entire vault. Open from `Extensions → AKURATE → Hash Vault Browser` in the ComfyUI menu. Shows every cached entry as a card with thumbnail, label, relative age, and `.pt` size. Live substring filter on label + hash. Click a card to copy its hash to clipboard. Sorted by last-accessed desc. No load-into-workflow action in v0.1 — that's v1.5+. ### Sidecar Label Input diff --git a/__init__.py b/__init__.py index eb1fd92..2d8990b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,8 @@ from .api_optimizer_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from . import hash_vault_browser # registers the /akurate/hash_vault/list route -__all__ =['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] +WEB_DIRECTORY = "./web" -print("\033[34m[ComfyUI-API-Optimizer] \033[92mLoaded Cost Tracker & Deterministic Hash Vault\033[0m") \ No newline at end of file +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] + +print("\033[34m[ComfyUI-API-Optimizer] \033[92mLoaded Cost Tracker, Hash Vault, and Browser\033[0m") \ No newline at end of file diff --git a/hash_vault_browser.py b/hash_vault_browser.py new file mode 100644 index 0000000..1ec508a --- /dev/null +++ b/hash_vault_browser.py @@ -0,0 +1,77 @@ +"""Hash Vault Browser backend route. + +Registers GET /akurate/hash_vault/list which walks output/hash_vault/, reads +every {hash}.json sidecar, and returns a JSON array sorted by last_accessed_at +desc. Thumbnails are served by ComfyUI's built-in /view route using +type=output&subfolder=hash_vault&filename={hash}.thumb.png — no extra route +needed on our side. + +The .pt files themselves are never read here; they are opaque + expensive to +load, and the sidecar already carries everything the browser UI needs. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +try: + import folder_paths +except ImportError: + folder_paths = None + +try: + from server import PromptServer + from aiohttp import web +except ImportError: + PromptServer = None + web = None + + +def _vault_dir() -> str | None: + if folder_paths is None: + return None + return os.path.join(folder_paths.get_output_directory(), "hash_vault") + + +def _list_entries() -> list[dict[str, Any]]: + """Walk hash_vault/, read sidecars, return enriched entries sorted by last_accessed_at desc.""" + vault = _vault_dir() + if not vault or not os.path.isdir(vault): + return [] + + entries: list[dict[str, Any]] = [] + for name in os.listdir(vault): + if not name.endswith(".json"): + continue + sidecar_path = os.path.join(vault, name) + hash_key = name[:-5] # strip .json + + try: + with open(sidecar_path, "r", encoding="utf-8") as f: + sidecar = json.load(f) + except (OSError, json.JSONDecodeError): + continue + + pt_path = os.path.join(vault, f"{hash_key}.pt") + pt_size = os.path.getsize(pt_path) if os.path.exists(pt_path) else 0 + + sidecar["pt_size"] = pt_size + sidecar["pt_exists"] = pt_size > 0 + entries.append(sidecar) + + entries.sort(key=lambda e: e.get("last_accessed_at", ""), reverse=True) + return entries + + +if PromptServer is not None and web is not None: + routes = PromptServer.instance.routes + + @routes.get("/akurate/hash_vault/list") + async def hash_vault_list(request): + try: + data = _list_entries() + return web.json_response({"entries": data, "count": len(data)}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) diff --git a/pyproject.toml b/pyproject.toml index 19be3cf..d1f4388 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-api-optimizer" description = "API cost tracking, deterministic hash caching, and lazy execution bypass for cloud API workflows" -version = "1.3.0" +version = "1.4.0" license = { file = "LICENSE" } [project.urls] diff --git a/web/hash_vault_browser.js b/web/hash_vault_browser.js new file mode 100644 index 0000000..8c670df --- /dev/null +++ b/web/hash_vault_browser.js @@ -0,0 +1,269 @@ +import { app } from "../../scripts/app.js"; + +const LIST_URL = "/akurate/hash_vault/list"; +const THUMB_URL = (hash, name) => + `/view?filename=${encodeURIComponent(name)}&type=output&subfolder=hash_vault&rand=${hash.slice(0, 8)}`; + +const STYLE = ` +.akurate-hv-backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,0.82); + z-index: 10000; display: flex; align-items: center; justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +.akurate-hv-modal { + background: #1a1a1a; color: #eee; border: 1px solid #333; border-radius: 8px; + width: 92vw; height: 90vh; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); +} +.akurate-hv-header { + padding: 14px 20px; border-bottom: 1px solid #333; + display: flex; align-items: center; gap: 16px; +} +.akurate-hv-header h2 { margin: 0; font-size: 16px; font-weight: 500; } +.akurate-hv-count { color: #888; font-size: 13px; } +.akurate-hv-search { + flex: 1; background: #0f0f0f; color: #eee; border: 1px solid #333; + border-radius: 4px; padding: 6px 10px; font-size: 13px; + font-family: inherit; outline: none; +} +.akurate-hv-search:focus { border-color: #4a90e2; } +.akurate-hv-close { + background: transparent; color: #aaa; border: 1px solid #333; + border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 14px; +} +.akurate-hv-close:hover { background: #333; color: #fff; } +.akurate-hv-body { + flex: 1; overflow-y: auto; padding: 16px 20px; +} +.akurate-hv-grid { + display: grid; gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} +.akurate-hv-card { + background: #111; border: 1px solid #2a2a2a; border-radius: 6px; + overflow: hidden; cursor: pointer; transition: border-color 0.12s, transform 0.12s; + display: flex; flex-direction: column; +} +.akurate-hv-card:hover { border-color: #4a90e2; transform: translateY(-1px); } +.akurate-hv-thumb { + width: 100%; aspect-ratio: 1 / 1; background: #0a0a0a; + display: flex; align-items: center; justify-content: center; + color: #555; font-size: 36px; +} +.akurate-hv-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.akurate-hv-meta { + padding: 8px 10px; font-size: 12px; line-height: 1.35; + display: flex; flex-direction: column; gap: 2px; min-height: 54px; +} +.akurate-hv-label { color: #eee; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.akurate-hv-label.empty { color: #666; font-style: italic; font-weight: 400; } +.akurate-hv-sub { color: #888; font-size: 11px; } +.akurate-hv-empty { + text-align: center; color: #666; padding: 60px 20px; font-size: 14px; +} +.akurate-hv-toast { + position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); + background: #2a2a2a; color: #eee; border: 1px solid #4a90e2; + padding: 10px 18px; border-radius: 6px; font-size: 13px; + z-index: 10001; opacity: 0; transition: opacity 0.2s; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +.akurate-hv-toast.show { opacity: 1; } +`; + +function injectStyle() { + if (document.getElementById("akurate-hv-style")) return; + const el = document.createElement("style"); + el.id = "akurate-hv-style"; + el.textContent = STYLE; + document.head.appendChild(el); +} + +function formatBytes(n) { + if (!n) return "—"; + if (n < 1024) return `${n} B`; + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`; + return `${(n / 1024 ** 3).toFixed(2)} GB`; +} + +function relativeAge(isoString) { + if (!isoString) return "unknown"; + const then = new Date(isoString).getTime(); + const now = Date.now(); + const secs = Math.max(0, (now - then) / 1000); + if (secs < 60) return "just now"; + if (secs < 3600) return `${Math.round(secs / 60)}m ago`; + if (secs < 86400) return `${Math.round(secs / 3600)}h ago`; + if (secs < 86400 * 30) return `${Math.round(secs / 86400)}d ago`; + if (secs < 86400 * 365) return `${Math.round(secs / (86400 * 30))}mo ago`; + return `${Math.round(secs / (86400 * 365))}y ago`; +} + +function showToast(text) { + const toast = document.createElement("div"); + toast.className = "akurate-hv-toast"; + toast.textContent = text; + document.body.appendChild(toast); + requestAnimationFrame(() => toast.classList.add("show")); + setTimeout(() => { + toast.classList.remove("show"); + setTimeout(() => toast.remove(), 250); + }, 1600); +} + +function buildCard(entry) { + const card = document.createElement("div"); + card.className = "akurate-hv-card"; + + const thumb = document.createElement("div"); + thumb.className = "akurate-hv-thumb"; + if (entry.thumbnail) { + const img = document.createElement("img"); + img.src = THUMB_URL(entry.hash_key, entry.thumbnail); + img.loading = "lazy"; + img.onerror = () => { + thumb.innerHTML = ""; + thumb.textContent = "⋄"; + }; + thumb.appendChild(img); + } else { + const kind = entry.payload?.kind || "?"; + thumb.textContent = kind === "dict" ? "{…}" : kind === "tensor" ? "⊞" : "⋄"; + } + card.appendChild(thumb); + + const meta = document.createElement("div"); + meta.className = "akurate-hv-meta"; + + const label = document.createElement("div"); + label.className = "akurate-hv-label"; + if (entry.label) { + label.textContent = entry.label; + } else { + label.classList.add("empty"); + label.textContent = entry.hash_key.slice(0, 12); + } + + const sub = document.createElement("div"); + sub.className = "akurate-hv-sub"; + sub.textContent = `${relativeAge(entry.last_accessed_at)} · ${formatBytes(entry.pt_size)}`; + + meta.appendChild(label); + meta.appendChild(sub); + card.appendChild(meta); + + card.addEventListener("click", () => { + navigator.clipboard.writeText(entry.hash_key).then( + () => showToast(`Hash copied: ${entry.hash_key.slice(0, 12)}…`), + () => showToast("Copy failed — selection blocked"), + ); + }); + + return card; +} + +function buildModal(entries) { + const backdrop = document.createElement("div"); + backdrop.className = "akurate-hv-backdrop"; + + const modal = document.createElement("div"); + modal.className = "akurate-hv-modal"; + + const header = document.createElement("div"); + header.className = "akurate-hv-header"; + const title = document.createElement("h2"); + title.textContent = "Hash Vault Browser"; + const count = document.createElement("span"); + count.className = "akurate-hv-count"; + const search = document.createElement("input"); + search.className = "akurate-hv-search"; + search.placeholder = "Filter by label…"; + search.type = "text"; + const close = document.createElement("button"); + close.className = "akurate-hv-close"; + close.textContent = "Close"; + header.append(title, count, search, close); + + const body = document.createElement("div"); + body.className = "akurate-hv-body"; + const grid = document.createElement("div"); + grid.className = "akurate-hv-grid"; + body.appendChild(grid); + + modal.append(header, body); + backdrop.appendChild(modal); + + function render(filter) { + const needle = (filter || "").trim().toLowerCase(); + grid.innerHTML = ""; + let shown = 0; + for (const entry of entries) { + if (needle) { + const hay = (entry.label || "").toLowerCase() + " " + entry.hash_key.toLowerCase(); + if (!hay.includes(needle)) continue; + } + grid.appendChild(buildCard(entry)); + shown++; + } + if (shown === 0) { + const empty = document.createElement("div"); + empty.className = "akurate-hv-empty"; + empty.textContent = entries.length === 0 + ? "Vault is empty — save something via Hash Vault (Save API Result) first." + : "No entries match the filter."; + grid.appendChild(empty); + } + count.textContent = needle ? `${shown} of ${entries.length}` : `${entries.length} entries`; + } + + function dismiss() { + backdrop.remove(); + document.removeEventListener("keydown", onKey); + } + function onKey(e) { + if (e.key === "Escape") dismiss(); + } + + close.addEventListener("click", dismiss); + backdrop.addEventListener("click", (e) => { if (e.target === backdrop) dismiss(); }); + document.addEventListener("keydown", onKey); + search.addEventListener("input", () => render(search.value)); + + document.body.appendChild(backdrop); + search.focus(); + render(""); +} + +async function openBrowser() { + injectStyle(); + try { + const resp = await fetch(LIST_URL); + if (!resp.ok) { + showToast(`Vault list failed: HTTP ${resp.status}`); + return; + } + const data = await resp.json(); + buildModal(data.entries || []); + } catch (e) { + showToast(`Vault list failed: ${e.message}`); + } +} + +app.registerExtension({ + name: "akurate.HashVaultBrowser", + commands: [ + { + id: "akurate.HashVaultBrowser.open", + label: "AKURATE: Hash Vault Browser", + icon: "pi pi-database", + function: openBrowser, + }, + ], + menuCommands: [ + { + path: ["Extensions"], + commands: ["akurate.HashVaultBrowser.open"], + }, + ], +});