v1.3.0: sidecar metadata + thumbnail + orphan migration

HashVaultSave now writes {hash_key}.json (label, timestamps, payload summary) and {hash_key}.thumb.png (256px preview) alongside the .pt, inside the existing FileLock. New optional label input on Save node, does not factor into the hash. DeterministicHashVault bumps last_accessed_at on cache hit (silent no-op for pre-v1.3.0 entries). folder_paths import is tolerant of being outside ComfyUI so the sidecar helpers are reusable from tooling.

tools/migrate_hash_vault.py backfills sidecars for pre-v1.3.0 entries, using file mtime as created_at and "(legacy)" as label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jeremie louvaert
2026-04-23 11:25:19 +02:00
co-authored by Claude Opus 4.7
parent eda9826431
commit 84e074766d
4 changed files with 341 additions and 4 deletions
+22
View File
@@ -32,6 +32,26 @@ 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 Label Input
The Save Result node has an optional `label` input (v1.3.0+). Wire a human-readable string like `"Alec Soth / Songbook / full"` and the Hash Vault Browser will surface it when you need to find a specific past run. The label is stored in the sidecar JSON only, so changing it never breaks existing cache hits.
### Migrating pre-v1.3.0 entries
Existing `.pt` files have no sidecar. To backfill:
```bash
# From the pack root, using the ComfyUI embedded Python:
python tools/migrate_hash_vault.py --dry-run # preview
python tools/migrate_hash_vault.py # execute
# Override the vault location:
python tools/migrate_hash_vault.py --vault-dir "F:/path/to/output/hash_vault"
```
Orphan entries are labelled `(legacy)` by default and assigned `created_at` from file mtime.
## How to Use the Hash Vault
@@ -91,6 +111,8 @@ All data is stored under your ComfyUI output directory:
| `output/api_metrics/api_transactions.jsonl` | Append-only audit log with timestamps |
| `output/api_metrics/api_costs_archive_*.json` | Archived ledgers from budget resets |
| `output/hash_vault/*.pt` | Cached API outputs (PyTorch format) |
| `output/hash_vault/*.json` | Sidecar metadata: label, timestamps, payload summary (v1.3.0+) |
| `output/hash_vault/*.thumb.png` | 256px preview for image outputs (v1.3.0+) |
## Installation
+172 -3
View File
@@ -3,10 +3,16 @@ import json
import hashlib
import time
import torch
import folder_paths
from decimal import Decimal
from datetime import datetime, timezone
try:
import folder_paths
except ImportError:
# Allow this module to be imported outside ComfyUI (e.g. by tools/migrate_hash_vault.py)
# so the sidecar helpers remain reusable. Node methods still require ComfyUI to run.
folder_paths = None
try:
from filelock import FileLock
except ImportError:
@@ -20,6 +26,12 @@ except ImportError:
def __exit__(self, *args):
pass
try:
from PIL import Image
_PIL_AVAILABLE = True
except ImportError:
_PIL_AVAILABLE = False
# ------------------------------------------------------------------------
# UTILITIES
# ------------------------------------------------------------------------
@@ -30,6 +42,150 @@ class AnyType(str):
any_type = AnyType("*")
# ------------------------------------------------------------------------
# SIDECAR METADATA HELPERS (v1.3.0)
# Every vault entry has a .json sidecar + optional .thumb.png alongside the .pt.
# This is what the Hash Vault Browser reads; .pt itself is opaque and expensive to load.
# ------------------------------------------------------------------------
THUMBNAIL_MAX_SIZE = 256
SIDECAR_SUFFIX = ".json"
THUMBNAIL_SUFFIX = ".thumb.png"
def _find_image_tensor(data):
"""Walk an arbitrary api_output and return the first tensor that looks like a ComfyUI image.
ComfyUI image convention: float tensor of shape [B, H, W, C] with C in {1, 3, 4}, values in [0, 1].
Returns None if nothing qualifies.
"""
if isinstance(data, torch.Tensor):
t = data
if t.ndim == 4 and t.shape[-1] in (1, 3, 4):
return t
if t.ndim == 3 and t.shape[-1] in (1, 3, 4):
return t
return None
if isinstance(data, dict):
# Skip "samples" (latent) — not a visible image
for key, value in data.items():
if key == "samples":
continue
found = _find_image_tensor(value)
if found is not None:
return found
return None
if isinstance(data, (list, tuple)):
for item in data:
found = _find_image_tensor(item)
if found is not None:
return found
return None
return None
def _generate_thumbnail(tensor, output_path):
"""Write a thumbnail PNG for an image tensor. Returns True on success, False otherwise."""
if not _PIL_AVAILABLE:
return False
try:
t = tensor
if t.ndim == 4:
t = t[0] # first batch item
if t.ndim != 3 or t.shape[-1] not in (1, 3, 4):
return False
arr = t.detach().cpu().clamp(0, 1).float().numpy()
arr = (arr * 255.0).round().astype("uint8")
if arr.shape[-1] == 1:
arr = arr.squeeze(-1)
mode = "L"
elif arr.shape[-1] == 3:
mode = "RGB"
else:
mode = "RGBA"
img = Image.fromarray(arr, mode=mode)
img.thumbnail((THUMBNAIL_MAX_SIZE, THUMBNAIL_MAX_SIZE), Image.LANCZOS)
tmp_path = output_path + ".tmp"
img.save(tmp_path, "PNG", optimize=True)
os.replace(tmp_path, output_path)
return True
except Exception as e:
print(f"⚠️ [API Vault] Thumbnail generation failed: {e}")
return False
def _summarize_payload(data):
"""Produce a lightweight JSON-safe description of api_output for the browser."""
if isinstance(data, torch.Tensor):
return {
"kind": "tensor",
"shape": list(data.shape),
"dtype": str(data.dtype).replace("torch.", ""),
}
if isinstance(data, dict):
return {
"kind": "dict",
"keys": sorted([str(k) for k in data.keys()])[:16],
"size": len(data),
}
if isinstance(data, (list, tuple)):
return {
"kind": type(data).__name__,
"length": len(data),
"first_item": _summarize_payload(data[0]) if len(data) > 0 else None,
}
return {"kind": type(data).__name__}
def _write_sidecar(vault_dir, hash_key, cpu_data, label):
"""Write {hash_key}.json (+ optional {hash_key}.thumb.png). Caller must already hold the vault lock."""
sidecar_path = os.path.join(vault_dir, f"{hash_key}{SIDECAR_SUFFIX}")
thumbnail_path = os.path.join(vault_dir, f"{hash_key}{THUMBNAIL_SUFFIX}")
now_iso = datetime.now(timezone.utc).isoformat()
image_tensor = _find_image_tensor(cpu_data)
has_thumbnail = False
if image_tensor is not None:
has_thumbnail = _generate_thumbnail(image_tensor, thumbnail_path)
sidecar = {
"hash_key": hash_key,
"label": (label or "").strip(),
"created_at": now_iso,
"last_accessed_at": now_iso,
"payload": _summarize_payload(cpu_data),
"thumbnail": f"{hash_key}{THUMBNAIL_SUFFIX}" if has_thumbnail else None,
"schema_version": 1,
}
tmp_path = sidecar_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(sidecar, f, indent=2)
os.replace(tmp_path, sidecar_path)
def _touch_sidecar(vault_dir, hash_key):
"""Bump last_accessed_at on cache hit. Silent no-op if the sidecar is missing (pre-migration entry)."""
sidecar_path = os.path.join(vault_dir, f"{hash_key}{SIDECAR_SUFFIX}")
if not os.path.exists(sidecar_path):
return
try:
with open(sidecar_path, "r", encoding="utf-8") as f:
sidecar = json.load(f)
sidecar["last_accessed_at"] = datetime.now(timezone.utc).isoformat()
tmp_path = sidecar_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(sidecar, f, indent=2)
os.replace(tmp_path, sidecar_path)
except Exception as e:
# Sidecar update failure must never break a cache hit
print(f"⚠️ [API Vault] Failed to update sidecar timestamp for {hash_key[:8]}: {e}")
# ------------------------------------------------------------------------
# NODE 1: API Cost & Quota Tracker
# ------------------------------------------------------------------------
@@ -233,6 +389,7 @@ class DeterministicHashVault:
# weights_only=False is required because cache may contain dicts/lists
# alongside tensors. Safe here — we only load files our own save node wrote.
cached_data = torch.load(file_path, map_location="cpu", weights_only=False)
_touch_sidecar(vault_dir, hash_key)
print(f"🟢 [API Vault] Cache Hit! Hash: {hash_key[:8]}")
return (cached_data, 1, hash_key)
except Exception as e:
@@ -255,6 +412,13 @@ class HashVaultSave:
"required": {
"hash_key": ("STRING", {"forceInput": True}),
"api_output": (any_type,),
},
"optional": {
"label": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Human-readable label for the Hash Vault Browser (e.g. 'Alec Soth / Songbook / full'). Written to sidecar metadata only — does NOT factor into the hash, so editing this never invalidates cache."
}),
}
}
@@ -277,7 +441,7 @@ class HashVaultSave:
return tuple(self._to_cpu(item) for item in data)
return data
def save_to_vault(self, hash_key, api_output):
def save_to_vault(self, hash_key, api_output, label=""):
vault_dir = os.path.join(folder_paths.get_output_directory(), "hash_vault")
os.makedirs(vault_dir, exist_ok=True)
file_path = os.path.join(vault_dir, f"{hash_key}.pt")
@@ -292,7 +456,12 @@ class HashVaultSave:
torch.save(cpu_data, tmp_path)
os.replace(tmp_path, file_path)
print(f"💾 [API Vault] Saved API output to Vault: {hash_key[:8]}")
# Sidecar + thumbnail (v1.3.0) — keep inside the lock so readers
# see a consistent (.pt, .json, .thumb.png) triple.
_write_sidecar(vault_dir, hash_key, cpu_data, label)
label_suffix = f" [{label}]" if label else ""
print(f"💾 [API Vault] Saved API output to Vault: {hash_key[:8]}{label_suffix}")
return (api_output,)
# ------------------------------------------------------------------------
+1 -1
View File
@@ -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.2.2"
version = "1.3.0"
license = { file = "LICENSE" }
[project.urls]
+146
View File
@@ -0,0 +1,146 @@
"""One-shot backfill for pre-v1.3.0 Hash Vault entries.
Before v1.3.0, HashVaultSave wrote only `{hash_key}.pt`. v1.3.0 adds a
`{hash_key}.json` sidecar (+ optional `{hash_key}.thumb.png`) so the Hash
Vault Browser can index entries by label, timestamp, and thumbnail.
This script scans a vault directory, finds orphan `.pt` files (no matching
sidecar), loads each, and writes the missing sidecar + thumbnail using file
mtime as `created_at` and `"(legacy)"` as `label`.
Usage (from the pack root, using the ComfyUI embedded Python):
python tools/migrate_hash_vault.py
python tools/migrate_hash_vault.py --vault-dir "F:/path/to/output/hash_vault"
python tools/migrate_hash_vault.py --dry-run
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import torch
SCRIPT_DIR = Path(__file__).resolve().parent
PACK_ROOT = SCRIPT_DIR.parent
sys.path.insert(0, str(PACK_ROOT))
from api_optimizer_nodes import ( # noqa: E402
SIDECAR_SUFFIX,
THUMBNAIL_SUFFIX,
_find_image_tensor,
_generate_thumbnail,
_summarize_payload,
)
DEFAULT_VAULT_DIR = Path(
"F:/ComfyUI_windows_portable_nvidia/ComfyUI_windows_portable/ComfyUI/output/hash_vault"
)
LEGACY_LABEL = "(legacy)"
def build_sidecar(hash_key: str, cpu_data, label: str, created_iso: str) -> dict:
return {
"hash_key": hash_key,
"label": label,
"created_at": created_iso,
"last_accessed_at": created_iso,
"payload": _summarize_payload(cpu_data),
"thumbnail": None, # Filled in after thumbnail write succeeds
"schema_version": 1,
}
def write_sidecar_atomic(sidecar_path: Path, sidecar: dict) -> None:
tmp_path = sidecar_path.with_suffix(sidecar_path.suffix + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(sidecar, f, indent=2)
os.replace(tmp_path, sidecar_path)
def migrate_vault(vault_dir: Path, label: str, dry_run: bool) -> dict:
if not vault_dir.exists():
print(f"[migrate] vault dir does not exist: {vault_dir}")
return {"scanned": 0, "skipped": 0, "migrated": 0, "failed": 0}
stats = {"scanned": 0, "skipped": 0, "migrated": 0, "failed": 0}
for pt_path in sorted(vault_dir.glob("*.pt")):
stats["scanned"] += 1
hash_key = pt_path.stem
sidecar_path = vault_dir / f"{hash_key}{SIDECAR_SUFFIX}"
thumbnail_path = vault_dir / f"{hash_key}{THUMBNAIL_SUFFIX}"
if sidecar_path.exists():
stats["skipped"] += 1
continue
try:
mtime = pt_path.stat().st_mtime
created_iso = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
if dry_run:
print(f"[migrate] would backfill {hash_key[:8]} (created {created_iso})")
stats["migrated"] += 1
continue
cached_data = torch.load(pt_path, map_location="cpu", weights_only=False)
sidecar = build_sidecar(hash_key, cached_data, label, created_iso)
image_tensor = _find_image_tensor(cached_data)
if image_tensor is not None:
if _generate_thumbnail(image_tensor, str(thumbnail_path)):
sidecar["thumbnail"] = f"{hash_key}{THUMBNAIL_SUFFIX}"
write_sidecar_atomic(sidecar_path, sidecar)
thumb_note = "+thumb" if sidecar["thumbnail"] else "no-thumb"
print(f"[migrate] backfilled {hash_key[:8]} ({thumb_note}) created {created_iso}")
stats["migrated"] += 1
except Exception as e:
print(f"[migrate] FAILED {hash_key[:8]}: {e}")
stats["failed"] += 1
return stats
def main() -> int:
parser = argparse.ArgumentParser(description="Backfill sidecars for pre-v1.3.0 Hash Vault entries.")
parser.add_argument(
"--vault-dir",
type=Path,
default=DEFAULT_VAULT_DIR,
help=f"Path to the hash_vault directory (default: {DEFAULT_VAULT_DIR})",
)
parser.add_argument(
"--label",
type=str,
default=LEGACY_LABEL,
help=f'Label to assign to orphan entries (default: "{LEGACY_LABEL}")',
)
parser.add_argument(
"--dry-run",
action="store_true",
help="List what would be migrated without writing anything.",
)
args = parser.parse_args()
print(f"[migrate] vault dir: {args.vault_dir}")
print(f"[migrate] label: {args.label!r}")
print(f"[migrate] dry run: {args.dry_run}")
print("-" * 60)
stats = migrate_vault(args.vault_dir, args.label, args.dry_run)
print("-" * 60)
print(f"[migrate] scanned: {stats['scanned']}")
print(f"[migrate] skipped: {stats['skipped']} (already had sidecar)")
print(f"[migrate] migrated: {stats['migrated']}")
print(f"[migrate] failed: {stats['failed']}")
return 0 if stats["failed"] == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())