Skip to content

Commit 04e31f5

Browse files
v1.4.0: Hash Vault Browser modal gallery
New feature: modal gallery UI over the vault, accessible from the top menu bar under Extensions → AKURATE: Hash Vault Browser. Reads all sidecar metadata + thumbnails shipped in v1.3.0 and presents them as a filterable grid. No graph node required. Backend: new GET /akurate/hash_vault/list route walks output/hash_vault/, reads every {hash}.json sidecar, returns JSON sorted by last_accessed_at desc with pt_size enrichment. Thumbnails reuse ComfyUI's built-in /view route (type=output&subfolder=hash_vault) — zero new thumbnail endpoint. Frontend: single web/hash_vault_browser.js (vanilla JS, no framework). Modal with CSS grid, live substring filter on label+hash, click-to-copy hash with toast, lazy-loaded thumbnails, relative-age display, ESC and backdrop-click dismiss. __init__.py now declares WEB_DIRECTORY and imports the route module. Deferred to v1.5+: load-into-workflow action, delete, bulk ops, sort controls, pagination. Current vault (57 entries, 2.33 GB .pt, 3.7 MB thumbs) loads instantly so none of those are urgent.
1 parent 84e0747 commit 04e31f5

5 files changed

Lines changed: 354 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ ComfyUI's native caching often breaks with external API nodes (dynamic timestamp
3232
- **Device-Portable Caching:** All tensors are saved to CPU and loaded with `map_location="cpu"`, so cache files work regardless of GPU configuration.
3333
- **Atomic Writes:** Cache files are written to a temp file first, then atomically replaced — preventing corruption from interrupted writes.
3434
- **Concurrent-Safe:** File locking on every cache read/write operation.
35-
- **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.
35+
- **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.
36+
- **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+.
3637

3738
### Sidecar Label Input
3839

__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from .api_optimizer_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
2+
from . import hash_vault_browser # registers the /akurate/hash_vault/list route
23

3-
__all__ =['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
4+
WEB_DIRECTORY = "./web"
45

5-
print("\033[34m[ComfyUI-API-Optimizer] \033[92mLoaded Cost Tracker & Deterministic Hash Vault\033[0m")
6+
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
7+
8+
print("\033[34m[ComfyUI-API-Optimizer] \033[92mLoaded Cost Tracker, Hash Vault, and Browser\033[0m")

hash_vault_browser.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Hash Vault Browser backend route.
2+
3+
Registers GET /akurate/hash_vault/list which walks output/hash_vault/, reads
4+
every {hash}.json sidecar, and returns a JSON array sorted by last_accessed_at
5+
desc. Thumbnails are served by ComfyUI's built-in /view route using
6+
type=output&subfolder=hash_vault&filename={hash}.thumb.png — no extra route
7+
needed on our side.
8+
9+
The .pt files themselves are never read here; they are opaque + expensive to
10+
load, and the sidecar already carries everything the browser UI needs.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import os
17+
from typing import Any
18+
19+
try:
20+
import folder_paths
21+
except ImportError:
22+
folder_paths = None
23+
24+
try:
25+
from server import PromptServer
26+
from aiohttp import web
27+
except ImportError:
28+
PromptServer = None
29+
web = None
30+
31+
32+
def _vault_dir() -> str | None:
33+
if folder_paths is None:
34+
return None
35+
return os.path.join(folder_paths.get_output_directory(), "hash_vault")
36+
37+
38+
def _list_entries() -> list[dict[str, Any]]:
39+
"""Walk hash_vault/, read sidecars, return enriched entries sorted by last_accessed_at desc."""
40+
vault = _vault_dir()
41+
if not vault or not os.path.isdir(vault):
42+
return []
43+
44+
entries: list[dict[str, Any]] = []
45+
for name in os.listdir(vault):
46+
if not name.endswith(".json"):
47+
continue
48+
sidecar_path = os.path.join(vault, name)
49+
hash_key = name[:-5] # strip .json
50+
51+
try:
52+
with open(sidecar_path, "r", encoding="utf-8") as f:
53+
sidecar = json.load(f)
54+
except (OSError, json.JSONDecodeError):
55+
continue
56+
57+
pt_path = os.path.join(vault, f"{hash_key}.pt")
58+
pt_size = os.path.getsize(pt_path) if os.path.exists(pt_path) else 0
59+
60+
sidecar["pt_size"] = pt_size
61+
sidecar["pt_exists"] = pt_size > 0
62+
entries.append(sidecar)
63+
64+
entries.sort(key=lambda e: e.get("last_accessed_at", ""), reverse=True)
65+
return entries
66+
67+
68+
if PromptServer is not None and web is not None:
69+
routes = PromptServer.instance.routes
70+
71+
@routes.get("/akurate/hash_vault/list")
72+
async def hash_vault_list(request):
73+
try:
74+
data = _list_entries()
75+
return web.json_response({"entries": data, "count": len(data)})
76+
except Exception as e:
77+
return web.json_response({"error": str(e)}, status=500)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "comfyui-api-optimizer"
33
description = "API cost tracking, deterministic hash caching, and lazy execution bypass for cloud API workflows"
4-
version = "1.3.0"
4+
version = "1.4.0"
55
license = { file = "LICENSE" }
66

77
[project.urls]

web/hash_vault_browser.js

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { app } from "../../scripts/app.js";
2+
3+
const LIST_URL = "/akurate/hash_vault/list";
4+
const THUMB_URL = (hash, name) =>
5+
`/view?filename=${encodeURIComponent(name)}&type=output&subfolder=hash_vault&rand=${hash.slice(0, 8)}`;
6+
7+
const STYLE = `
8+
.akurate-hv-backdrop {
9+
position: fixed; inset: 0; background: rgba(0,0,0,0.82);
10+
z-index: 10000; display: flex; align-items: center; justify-content: center;
11+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
12+
}
13+
.akurate-hv-modal {
14+
background: #1a1a1a; color: #eee; border: 1px solid #333; border-radius: 8px;
15+
width: 92vw; height: 90vh; display: flex; flex-direction: column;
16+
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
17+
}
18+
.akurate-hv-header {
19+
padding: 14px 20px; border-bottom: 1px solid #333;
20+
display: flex; align-items: center; gap: 16px;
21+
}
22+
.akurate-hv-header h2 { margin: 0; font-size: 16px; font-weight: 500; }
23+
.akurate-hv-count { color: #888; font-size: 13px; }
24+
.akurate-hv-search {
25+
flex: 1; background: #0f0f0f; color: #eee; border: 1px solid #333;
26+
border-radius: 4px; padding: 6px 10px; font-size: 13px;
27+
font-family: inherit; outline: none;
28+
}
29+
.akurate-hv-search:focus { border-color: #4a90e2; }
30+
.akurate-hv-close {
31+
background: transparent; color: #aaa; border: 1px solid #333;
32+
border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 14px;
33+
}
34+
.akurate-hv-close:hover { background: #333; color: #fff; }
35+
.akurate-hv-body {
36+
flex: 1; overflow-y: auto; padding: 16px 20px;
37+
}
38+
.akurate-hv-grid {
39+
display: grid; gap: 14px;
40+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
41+
}
42+
.akurate-hv-card {
43+
background: #111; border: 1px solid #2a2a2a; border-radius: 6px;
44+
overflow: hidden; cursor: pointer; transition: border-color 0.12s, transform 0.12s;
45+
display: flex; flex-direction: column;
46+
}
47+
.akurate-hv-card:hover { border-color: #4a90e2; transform: translateY(-1px); }
48+
.akurate-hv-thumb {
49+
width: 100%; aspect-ratio: 1 / 1; background: #0a0a0a;
50+
display: flex; align-items: center; justify-content: center;
51+
color: #555; font-size: 36px;
52+
}
53+
.akurate-hv-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
54+
.akurate-hv-meta {
55+
padding: 8px 10px; font-size: 12px; line-height: 1.35;
56+
display: flex; flex-direction: column; gap: 2px; min-height: 54px;
57+
}
58+
.akurate-hv-label { color: #eee; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
59+
.akurate-hv-label.empty { color: #666; font-style: italic; font-weight: 400; }
60+
.akurate-hv-sub { color: #888; font-size: 11px; }
61+
.akurate-hv-empty {
62+
text-align: center; color: #666; padding: 60px 20px; font-size: 14px;
63+
}
64+
.akurate-hv-toast {
65+
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
66+
background: #2a2a2a; color: #eee; border: 1px solid #4a90e2;
67+
padding: 10px 18px; border-radius: 6px; font-size: 13px;
68+
z-index: 10001; opacity: 0; transition: opacity 0.2s;
69+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
70+
}
71+
.akurate-hv-toast.show { opacity: 1; }
72+
`;
73+
74+
function injectStyle() {
75+
if (document.getElementById("akurate-hv-style")) return;
76+
const el = document.createElement("style");
77+
el.id = "akurate-hv-style";
78+
el.textContent = STYLE;
79+
document.head.appendChild(el);
80+
}
81+
82+
function formatBytes(n) {
83+
if (!n) return "—";
84+
if (n < 1024) return `${n} B`;
85+
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
86+
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
87+
return `${(n / 1024 ** 3).toFixed(2)} GB`;
88+
}
89+
90+
function relativeAge(isoString) {
91+
if (!isoString) return "unknown";
92+
const then = new Date(isoString).getTime();
93+
const now = Date.now();
94+
const secs = Math.max(0, (now - then) / 1000);
95+
if (secs < 60) return "just now";
96+
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
97+
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
98+
if (secs < 86400 * 30) return `${Math.round(secs / 86400)}d ago`;
99+
if (secs < 86400 * 365) return `${Math.round(secs / (86400 * 30))}mo ago`;
100+
return `${Math.round(secs / (86400 * 365))}y ago`;
101+
}
102+
103+
function showToast(text) {
104+
const toast = document.createElement("div");
105+
toast.className = "akurate-hv-toast";
106+
toast.textContent = text;
107+
document.body.appendChild(toast);
108+
requestAnimationFrame(() => toast.classList.add("show"));
109+
setTimeout(() => {
110+
toast.classList.remove("show");
111+
setTimeout(() => toast.remove(), 250);
112+
}, 1600);
113+
}
114+
115+
function buildCard(entry) {
116+
const card = document.createElement("div");
117+
card.className = "akurate-hv-card";
118+
119+
const thumb = document.createElement("div");
120+
thumb.className = "akurate-hv-thumb";
121+
if (entry.thumbnail) {
122+
const img = document.createElement("img");
123+
img.src = THUMB_URL(entry.hash_key, entry.thumbnail);
124+
img.loading = "lazy";
125+
img.onerror = () => {
126+
thumb.innerHTML = "";
127+
thumb.textContent = "⋄";
128+
};
129+
thumb.appendChild(img);
130+
} else {
131+
const kind = entry.payload?.kind || "?";
132+
thumb.textContent = kind === "dict" ? "{…}" : kind === "tensor" ? "⊞" : "⋄";
133+
}
134+
card.appendChild(thumb);
135+
136+
const meta = document.createElement("div");
137+
meta.className = "akurate-hv-meta";
138+
139+
const label = document.createElement("div");
140+
label.className = "akurate-hv-label";
141+
if (entry.label) {
142+
label.textContent = entry.label;
143+
} else {
144+
label.classList.add("empty");
145+
label.textContent = entry.hash_key.slice(0, 12);
146+
}
147+
148+
const sub = document.createElement("div");
149+
sub.className = "akurate-hv-sub";
150+
sub.textContent = `${relativeAge(entry.last_accessed_at)} · ${formatBytes(entry.pt_size)}`;
151+
152+
meta.appendChild(label);
153+
meta.appendChild(sub);
154+
card.appendChild(meta);
155+
156+
card.addEventListener("click", () => {
157+
navigator.clipboard.writeText(entry.hash_key).then(
158+
() => showToast(`Hash copied: ${entry.hash_key.slice(0, 12)}…`),
159+
() => showToast("Copy failed — selection blocked"),
160+
);
161+
});
162+
163+
return card;
164+
}
165+
166+
function buildModal(entries) {
167+
const backdrop = document.createElement("div");
168+
backdrop.className = "akurate-hv-backdrop";
169+
170+
const modal = document.createElement("div");
171+
modal.className = "akurate-hv-modal";
172+
173+
const header = document.createElement("div");
174+
header.className = "akurate-hv-header";
175+
const title = document.createElement("h2");
176+
title.textContent = "Hash Vault Browser";
177+
const count = document.createElement("span");
178+
count.className = "akurate-hv-count";
179+
const search = document.createElement("input");
180+
search.className = "akurate-hv-search";
181+
search.placeholder = "Filter by label…";
182+
search.type = "text";
183+
const close = document.createElement("button");
184+
close.className = "akurate-hv-close";
185+
close.textContent = "Close";
186+
header.append(title, count, search, close);
187+
188+
const body = document.createElement("div");
189+
body.className = "akurate-hv-body";
190+
const grid = document.createElement("div");
191+
grid.className = "akurate-hv-grid";
192+
body.appendChild(grid);
193+
194+
modal.append(header, body);
195+
backdrop.appendChild(modal);
196+
197+
function render(filter) {
198+
const needle = (filter || "").trim().toLowerCase();
199+
grid.innerHTML = "";
200+
let shown = 0;
201+
for (const entry of entries) {
202+
if (needle) {
203+
const hay = (entry.label || "").toLowerCase() + " " + entry.hash_key.toLowerCase();
204+
if (!hay.includes(needle)) continue;
205+
}
206+
grid.appendChild(buildCard(entry));
207+
shown++;
208+
}
209+
if (shown === 0) {
210+
const empty = document.createElement("div");
211+
empty.className = "akurate-hv-empty";
212+
empty.textContent = entries.length === 0
213+
? "Vault is empty — save something via Hash Vault (Save API Result) first."
214+
: "No entries match the filter.";
215+
grid.appendChild(empty);
216+
}
217+
count.textContent = needle ? `${shown} of ${entries.length}` : `${entries.length} entries`;
218+
}
219+
220+
function dismiss() {
221+
backdrop.remove();
222+
document.removeEventListener("keydown", onKey);
223+
}
224+
function onKey(e) {
225+
if (e.key === "Escape") dismiss();
226+
}
227+
228+
close.addEventListener("click", dismiss);
229+
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) dismiss(); });
230+
document.addEventListener("keydown", onKey);
231+
search.addEventListener("input", () => render(search.value));
232+
233+
document.body.appendChild(backdrop);
234+
search.focus();
235+
render("");
236+
}
237+
238+
async function openBrowser() {
239+
injectStyle();
240+
try {
241+
const resp = await fetch(LIST_URL);
242+
if (!resp.ok) {
243+
showToast(`Vault list failed: HTTP ${resp.status}`);
244+
return;
245+
}
246+
const data = await resp.json();
247+
buildModal(data.entries || []);
248+
} catch (e) {
249+
showToast(`Vault list failed: ${e.message}`);
250+
}
251+
}
252+
253+
app.registerExtension({
254+
name: "akurate.HashVaultBrowser",
255+
commands: [
256+
{
257+
id: "akurate.HashVaultBrowser.open",
258+
label: "AKURATE: Hash Vault Browser",
259+
icon: "pi pi-database",
260+
function: openBrowser,
261+
},
262+
],
263+
menuCommands: [
264+
{
265+
path: ["Extensions"],
266+
commands: ["akurate.HashVaultBrowser.open"],
267+
},
268+
],
269+
});

0 commit comments

Comments
 (0)