Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf6ce1a5d | ||
|
|
b778c3581a | ||
|
|
819843c4ee | ||
|
|
997da34a3c | ||
|
|
4048b9a8e0 | ||
|
|
04e31f5b77 | ||
|
|
84e074766d | ||
|
|
eda9826431 |
@@ -1,41 +1,80 @@
|
||||
# ComfyUI API Optimizer
|
||||
|
||||
A suite of production-grade custom nodes for ComfyUI designed for workflows that rely on external remote APIs (Kling 3.0, Magnific, Banana.dev, RunPod, etc.).
|
||||

|
||||
|
||||
When your compute moves to the cloud, your bottlenecks shift from VRAM limitations to **API Costs, Latency, and Serialization**. This custom node pack solves these problems natively inside ComfyUI.
|
||||
A pack of custom nodes for ComfyUI, built for workflows that call external remote APIs (Kling 3.0, Magnific, Banana.dev, RunPod, etc.).
|
||||
|
||||
## Included Nodes
|
||||
When compute moves to the cloud, the bottleneck moves with it: from VRAM to API cost, latency, and serialization. This pack handles all three inside ComfyUI.
|
||||
|
||||
## Included nodes
|
||||
|
||||
### 1. API Cost & Quota Tracker
|
||||
|
||||
Acts as a circuit-breaker for your wallet. Pass your prompt or image through this node before it hits your API node.
|
||||
It's a circuit breaker for your wallet. Wire your prompt or image through this node before it reaches your API node.
|
||||
|
||||
- **Budget Enforcement:** Set a `$ Budget Limit` and `$ Cost Per Run`. A persistent ledger tracks all charges. If the next run would exceed the budget, execution halts *before* the API is charged.
|
||||
- **Precise Arithmetic:** Uses `decimal.Decimal` internally — no floating-point drift on large batch runs.
|
||||
- **Transaction Audit Log:** Every charge is appended to `api_transactions.jsonl` with timestamps for full traceability.
|
||||
- **Safe Resets:** Resetting the budget archives the previous ledger instead of discarding it.
|
||||
- **Concurrent-Safe:** File locking prevents corruption when multiple ComfyUI instances share the same output directory.
|
||||
Set a `$ Budget Limit` and `$ Cost Per Run`, and a persistent ledger tracks every charge; if the next run would exceed the budget, execution halts before the API gets billed. The arithmetic runs on `decimal.Decimal` internally, so there's no floating-point drift on large batch runs. Every charge is appended to `api_transactions.jsonl` with a timestamp, for a full audit trail. Resetting the budget archives the previous ledger instead of discarding it. File locking prevents corruption when multiple ComfyUI instances share the same output directory.
|
||||
|
||||
### 2. The Deterministic Hash Vault Suite (3 Nodes)
|
||||
### 2. The Deterministic Hash Vault Suite (3 nodes)
|
||||
|
||||
ComfyUI's native caching often breaks with external API nodes (dynamic timestamps, non-deterministic seeds). The Hash Vault is an aggressive disk-caching layer that strictly hashes your prompt, parameters, and input tensors.
|
||||
ComfyUI's native caching often breaks with external API nodes (dynamic timestamps, non-deterministic seeds). The Hash Vault is a disk-caching layer that hashes your prompt, parameters, and input tensors.
|
||||
|
||||
- **Hash Vault (Check Cache):** Hashes any combination of a prompt STRING and up to four `any_input` slots — wire an image, a converted-widget dropdown, a converted-widget float, whatever defines uniqueness for your API call. All inputs are optional; any subset you connect factors into the cache key. No prompt? Hash on image + widget values alone.
|
||||
- **Lazy API Switch:** Uses ComfyUI's `{"lazy": True}` evaluation engine. On a cache hit, this switch **physically prevents** the upstream API node from executing — saving money and time.
|
||||
- **Hash Vault (Save Result):** Writes new API outputs to the vault for future cache hits.
|
||||
- **Hash Vault (Check Cache):** hashes any combination of a prompt STRING and up to four `any_input` slots. Wire an image, a converted-widget dropdown, a converted-widget float, whatever defines uniqueness for your API call. All inputs are optional; any subset you connect factors into the cache key. No prompt? Hash on image and widget values alone.
|
||||
- **Lazy API Switch:** uses ComfyUI's `{"lazy": True}` evaluation engine. On a cache hit, this switch prevents the upstream API node from executing, so you save money and time.
|
||||
- **Hash Vault (Save Result):** writes new API outputs to the vault for future cache hits.
|
||||
|
||||
#### Key Features
|
||||
#### Key features
|
||||
|
||||
- **Full-Content Tensor Hashing:** Hashes the complete byte representation of tensors (including dtype and shape metadata) — no lossy approximations.
|
||||
- **Recursive Hashing:** Correctly handles nested data structures (dicts, lists, tuples) common in ComfyUI latents and conditioning.
|
||||
- **Cache TTL:** Optional time-to-live for cache entries. Expired entries are automatically removed and treated as cache misses. Set to `0` for entries that never expire.
|
||||
- **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.
|
||||
Hashing covers the full byte representation of tensors, including dtype and shape metadata, so there are no lossy approximations. Nested structures (dicts, lists, tuples) hash recursively, which covers the latents and conditioning ComfyUI actually passes around. Cache entries support an optional TTL: expired entries get removed automatically and count as a miss, and setting TTL to `0` means an entry never expires. Tensors save to CPU and load with `map_location="cpu"`, so cache files work regardless of GPU configuration. Cache files write to a temp file first and get atomically replaced, so an interrupted write can't corrupt anything. File locking covers every cache read and write.
|
||||
|
||||
## How to Use the Hash Vault
|
||||
**Sidecar metadata (v1.3.0):** every saved entry gets a `{hash_key}.json` sidecar with a human-readable `label`, `created_at`, `last_accessed_at`, and a 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 the cache. This is what the Hash Vault Browser indexes.
|
||||
|
||||
To properly bypass an API node, sandwich it with the vault nodes:
|
||||
**Hash Vault Browser (v1.4.0+):** a modal gallery over the entire vault. Three ways to open it:
|
||||
1. **Keyboard:** `Ctrl+Shift+H` from anywhere in ComfyUI (added v1.4.1)
|
||||
2. **Sidebar:** click the `Hash Vault` tab in the left sidebar, then `Open Browser` (added v1.4.1)
|
||||
3. **Menu:** `Extensions → AKURATE: Hash Vault Browser` in the top menu bar
|
||||
|
||||
It shows every cached entry as a card with thumbnail, label, relative age, and `.pt` size, with a live substring filter on label and hash. Click a card to copy its hash to clipboard. Cards sort by last-accessed, most recent first. There's no load-into-workflow action yet; that's v1.5+.
|
||||
|
||||
### 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 surfaces it when you need to find a specific past run. The label lives in the sidecar JSON only, so changing it never breaks existing cache hits.
|
||||
|
||||
### Auto-labeling with the Label Builder node (v1.4.2)
|
||||
|
||||
Typing labels by hand gets old fast. The `🏷️ Hash Vault Label Builder` node concatenates up to four any-type inputs into one STRING, ready to wire into `Save Result`'s label input. Wire it once per workflow and never type a label again.
|
||||
|
||||
For the Gemini Style Transfer pattern:
|
||||
- `in_1` ← Settings node's `style` output (e.g. `"Alec Soth"`)
|
||||
- `in_2` ← Settings node's `variant` output (e.g. `"Niagara"`)
|
||||
- `in_3` ← Settings node's `intensity` output (e.g. `"full"`)
|
||||
- `separator` = `" / "`
|
||||
- → outputs `"Alec Soth / Niagara / full"`
|
||||
|
||||
For a Prompt Studio → Gemini Image Generate pattern:
|
||||
- `in_1` ← Prompt Studio's `style` output
|
||||
- `in_2` ← the assembled prompt (first line, or a short identifier)
|
||||
- → outputs `"Sebastião Salgado / Serra Pelada gold mine workers"`
|
||||
|
||||
Empty or None inputs are skipped, so partial wiring stays clean. Non-string inputs are stringified at join time.
|
||||
|
||||
### 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
|
||||
|
||||
To bypass an API node, sandwich it with the vault nodes:
|
||||
|
||||
1. Connect your Prompt/Image to **Check Cache**.
|
||||
2. Connect the `is_cached` output to the **Lazy API Switch**.
|
||||
@@ -45,11 +84,9 @@ To properly bypass an API node, sandwich it with the vault nodes:
|
||||
|
||||
### What to feed Check Cache
|
||||
|
||||
Check Cache has one STRING socket (`payload_string`) and four any-type sockets (`any_input`, `any_input_2`, `any_input_3`, `any_input_4`). All are optional — connect whatever defines uniqueness for your API call:
|
||||
Check Cache has one STRING socket (`payload_string`) and four any-type sockets (`any_input`, `any_input_2`, `any_input_3`, `any_input_4`). All are optional; connect whatever defines uniqueness for your API call.
|
||||
|
||||
- **Prompt-driven API (e.g. Gemini Image Generate):** wire the prompt STRING to `payload_string`. Done.
|
||||
- **Image + prompt API (e.g. image edit):** prompt → `payload_string`, image → `any_input`.
|
||||
- **Image-only API with widgets (e.g. Gemini Style Transfer — no prompt, but a style dropdown and strength float):** right-click the style widget → Convert Widget to Input, same for strength. Wire image → `any_input`, style → `any_input_2`, strength → `any_input_3`. All three factor into the hash; changing any of them produces a new cache key.
|
||||
For a prompt-driven API (Gemini Image Generate, say), wire the prompt STRING to `payload_string` and you're done. For an image + prompt API (an image edit call), wire the prompt to `payload_string` and the image to `any_input`. For an image-only API with widgets and no prompt, like Gemini Style Transfer with its style dropdown and strength float: right-click the style widget, Convert Widget to Input, do the same for strength, then wire image → `any_input`, style → `any_input_2`, strength → `any_input_3`. All three factor into the hash; change any of them and you get a new cache key.
|
||||
|
||||
Any subset of slots works; unused slots contribute nothing to the hash. Adding or ignoring `any_input_2/3/4` in a new workflow doesn't invalidate existing cache keys from older workflows that only used `payload_string` + `any_input`.
|
||||
|
||||
@@ -65,21 +102,23 @@ Any subset of slots works; unused slots contribute nothing to the hash. Adding o
|
||||
│ cached_data ────────────────────┘
|
||||
```
|
||||
|
||||
## Example Workflows
|
||||
## Example workflows
|
||||
|
||||
### Prompt-driven API — [`workflows/hash_vault_basic.json`](workflows/hash_vault_basic.json)
|
||||
### Prompt-driven API: [`workflows/hash_vault_basic.json`](workflows/hash_vault_basic.json)
|
||||
|
||||
The classic pattern for an API node whose uniqueness lives in a prompt STRING (e.g. Gemini Image Generate). One `StringConstantMultiline` feeds both Hash Vault's `payload_string` and the API node's prompt. Drag it in, set your Gemini API key, press Queue twice: first run generates and caches, second run returns the cached image with zero API call.
|
||||
The classic pattern for an API node whose uniqueness lives in a prompt STRING (Gemini Image Generate, for example). One `StringConstantMultiline` feeds both Hash Vault's `payload_string` and the API node's prompt. Drag it in, set your Gemini API key, press Queue twice: the first run generates and caches, the second returns the cached image with zero API calls.
|
||||
|
||||
### Image-only API with widget inputs — [`workflows/hash_vault_image_only.json`](workflows/hash_vault_image_only.json)
|
||||
### Image-only API with widget inputs: [`workflows/hash_vault_image_only.json`](workflows/hash_vault_image_only.json)
|
||||
|
||||
Shows the v1.2.0 pattern for an API that has no prompt STRING but does have meaningful widgets — Gemini Style Transfer (image + style dropdown + intensity dropdown). The style and intensity widgets on the Style Transfer node are converted to inputs and driven by two `StringConstantMultiline` nodes, which also feed Hash Vault's `any_input_2` and `any_input_3`. The image fans out to Style Transfer AND Hash Vault's `any_input`. All three factor into the hash; change any one of them → new cache key → API runs once.
|
||||
Shows the v1.2.0 pattern for an API with no prompt STRING but meaningful widgets, using Gemini Style Transfer (image + style dropdown + intensity dropdown). The Style Transfer Settings node owns the real dropdowns and emits `style` and `intensity` as STRING outputs. Each output fans out to both the matching Style Transfer input (widget converted to input) and a Hash Vault `any_input_N` slot. The image fans out to Style Transfer and to Hash Vault's `any_input`. All three factor into the hash; change any one of them and you get a new cache key, so the API runs once.
|
||||
|
||||
The Settings node exists specifically to keep the dropdown UX. A raw STRING primitive driving a converted-widget input has no validation and no typeahead, so a typo silently breaks the cache (the hash is still valid, but Style Transfer errors on the bad value at runtime). The Settings node provides the same ComfyUI-native combo box the Style Transfer widget has, so the wired value is always a known-valid option.
|
||||
|
||||
### Requires
|
||||
|
||||
Both workflows use [ComfyUI-Gemini-Direct](https://github.com/jeremieLouvaert/ComfyUI-Gemini-Direct) as the API node and [ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes)'s `StringConstantMultiline` as the value source. Any STRING primitive works in place of KJNodes'; the point is that one source drives both the API node and Hash Vault so they see identical inputs.
|
||||
Both workflows use [ComfyUI-Gemini-Direct](https://github.com/jeremieLouvaert/ComfyUI-Gemini-Direct) as the API node. The prompt-driven workflow uses [ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes)'s `StringConstantMultiline` as the prompt source; any STRING primitive works. The image-only workflow uses Gemini-Direct's own `Gemini Style Transfer Settings` node for the style + intensity dropdowns.
|
||||
|
||||
## Output Files
|
||||
## Output files
|
||||
|
||||
All data is stored under your ComfyUI output directory:
|
||||
|
||||
@@ -89,6 +128,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
|
||||
|
||||
@@ -104,5 +145,5 @@ Restart ComfyUI.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **PyTorch** — already present in any ComfyUI installation
|
||||
- **filelock** — typically already installed as a transitive dependency of PyTorch/HuggingFace. If not, `pip install filelock`.
|
||||
- **PyTorch:** already present in any ComfyUI installation
|
||||
- **filelock:** usually already installed as a transitive dependency of PyTorch/HuggingFace. If not, `pip install filelock`.
|
||||
|
||||
+5
-2
@@ -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")
|
||||
__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")
|
||||
+220
-3
@@ -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,)
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -340,6 +509,52 @@ class LazyAPISwitch:
|
||||
print("🔀 [Lazy Switch] Cache Miss routed! API was executed.")
|
||||
return (api_data,)
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# NODE 5: Hash Vault Label Builder (v1.4.2)
|
||||
# ------------------------------------------------------------------------
|
||||
class HashVaultLabelBuilder:
|
||||
"""Concatenate up to 4 any-type inputs into one STRING for Hash Vault Save's label.
|
||||
|
||||
Wire style/variant/intensity (or prompt/model/params) once per workflow and never
|
||||
manually type a label again. Empty or None inputs are skipped so the output stays
|
||||
clean when only some slots are wired.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"separator": ("STRING", {
|
||||
"default": " / ",
|
||||
"multiline": False,
|
||||
"tooltip": "Separator placed between each non-empty input. Defaults to ' / '."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"in_1": (any_type, {"tooltip": "First input. Any type — stringified at join time. None or empty strings are skipped."}),
|
||||
"in_2": (any_type, {"tooltip": "Second input."}),
|
||||
"in_3": (any_type, {"tooltip": "Third input."}),
|
||||
"in_4": (any_type, {"tooltip": "Fourth input."}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("label",)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "API Optimization"
|
||||
|
||||
def build(self, separator, in_1=None, in_2=None, in_3=None, in_4=None):
|
||||
parts = []
|
||||
for value in (in_1, in_2, in_3, in_4):
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
parts.append(text)
|
||||
return (separator.join(parts),)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# MAPPINGS: Registering the nodes with ComfyUI
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -348,6 +563,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"DeterministicHashVault": DeterministicHashVault,
|
||||
"HashVaultSave": HashVaultSave,
|
||||
"LazyAPISwitch": LazyAPISwitch,
|
||||
"HashVaultLabelBuilder": HashVaultLabelBuilder,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -355,4 +571,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DeterministicHashVault": "🔍 Hash Vault (Check Cache)",
|
||||
"HashVaultSave": "💾 Hash Vault (Save API Result)",
|
||||
"LazyAPISwitch": "🔀 Lazy API Switch (Bypass)",
|
||||
"HashVaultLabelBuilder": "🏷️ Hash Vault Label Builder",
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 601 KiB |
@@ -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)
|
||||
+1
-1
@@ -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.1"
|
||||
version = "1.4.2"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,338 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSidebarTab(el) {
|
||||
el.innerHTML = "";
|
||||
el.style.padding = "16px";
|
||||
el.style.color = "#ddd";
|
||||
el.style.fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = "Hash Vault";
|
||||
title.style.fontSize = "14px";
|
||||
title.style.fontWeight = "600";
|
||||
title.style.marginBottom = "4px";
|
||||
|
||||
const sub = document.createElement("div");
|
||||
sub.textContent = "Loading…";
|
||||
sub.style.fontSize = "12px";
|
||||
sub.style.color = "#888";
|
||||
sub.style.marginBottom = "14px";
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = "Open Browser";
|
||||
btn.style.cssText = `
|
||||
width: 100%; padding: 8px 12px; font-size: 13px;
|
||||
background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a;
|
||||
border-radius: 4px; cursor: pointer; font-family: inherit;
|
||||
`;
|
||||
btn.addEventListener("mouseenter", () => { btn.style.borderColor = "#4a90e2"; });
|
||||
btn.addEventListener("mouseleave", () => { btn.style.borderColor = "#3a3a3a"; });
|
||||
btn.addEventListener("click", openBrowser);
|
||||
|
||||
const hint = document.createElement("div");
|
||||
hint.textContent = "Shortcut: Ctrl+Shift+H";
|
||||
hint.style.cssText = "font-size: 11px; color: #666; margin-top: 10px; text-align: center;";
|
||||
|
||||
el.append(title, sub, btn, hint);
|
||||
|
||||
// Populate count asynchronously
|
||||
fetch(LIST_URL)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
||||
.then((d) => {
|
||||
const n = d.count ?? (d.entries || []).length;
|
||||
sub.textContent = `${n} cached ${n === 1 ? "entry" : "entries"}`;
|
||||
})
|
||||
.catch(() => { sub.textContent = "Vault list unavailable"; });
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "akurate.HashVaultBrowser",
|
||||
commands: [
|
||||
{
|
||||
id: "akurate.HashVaultBrowser.open",
|
||||
label: "AKURATE: Hash Vault Browser",
|
||||
icon: "pi pi-database",
|
||||
function: openBrowser,
|
||||
},
|
||||
],
|
||||
keybindings: [
|
||||
{
|
||||
combo: { key: "H", ctrl: true, shift: true },
|
||||
commandId: "akurate.HashVaultBrowser.open",
|
||||
},
|
||||
],
|
||||
menuCommands: [
|
||||
{
|
||||
path: ["Extensions"],
|
||||
commands: ["akurate.HashVaultBrowser.open"],
|
||||
},
|
||||
],
|
||||
setup() {
|
||||
// Register sidebar tab. Guard in case the frontend is older than
|
||||
// extensionManager.registerSidebarTab — fall back to menu-only.
|
||||
try {
|
||||
if (app.extensionManager?.registerSidebarTab) {
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "akurate.HashVaultBrowser.tab",
|
||||
icon: "pi pi-database",
|
||||
title: "Hash Vault",
|
||||
tooltip: "AKURATE Hash Vault Browser",
|
||||
type: "custom",
|
||||
render: renderSidebarTab,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[akurate.HashVaultBrowser] sidebar tab registration failed:", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -13,7 +13,7 @@
|
||||
],
|
||||
"size": [
|
||||
1620,
|
||||
180
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
@@ -22,7 +22,7 @@
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"HASH VAULT (image-only API) — caching the Gemini Style Transfer node\n\nStyle Transfer takes (image, style, intensity, ...) and emits a styled image — no prompt STRING anywhere. v1.2.0 of API-Optimizer made Hash Vault's payload_string optional and added any_input_2/3/4, so we can hash purely on image + converted-widget values.\n\nWIRING\nLoadImage routes to BOTH Style Transfer.image AND Hash Vault.any_input.\nThe two StringConstantMultiline nodes hold the style and intensity values; each routes to BOTH the corresponding Style Transfer input (the widgets are converted to inputs) AND Hash Vault.any_input_2 / any_input_3.\n\nResult: same image + same style + same intensity → cache hit → no API call. Change any of the three → new hash → API runs once, then cached.\n\nNOT CACHED: the Style Transfer node still has its own model / seed / aspect_ratio / resolution / direction widgets that DON'T factor into the hash here. If you change those and want a fresh render, clear the matching .pt file in output/hash_vault/ first. Or wire those through additional any_input slots if you have them free.\n\nREQUIRES\nComfyUI-API-Optimizer v1.2.0+, ComfyUI-Gemini-Direct, ComfyUI-KJNodes (StringConstantMultiline). Any STRING primitive substitutes for KJNodes if you don't have it."
|
||||
"HASH VAULT (image-only API) \u2014 caching the Gemini Style Transfer node\n\nStyle Transfer takes (image, style, intensity, ...) and emits a styled image \u2014 no prompt STRING anywhere. v1.2.0 of API-Optimizer made Hash Vault's payload_string optional and added any_input_2/3/4, so we can hash purely on image + widget values.\n\nWIRING\nLoadImage routes to BOTH Style Transfer.image AND Hash Vault.any_input.\nThe Style Transfer Settings node owns the style + intensity dropdowns and emits each as a STRING. Its outputs fan out to BOTH the matching Style Transfer inputs (widgets converted to inputs) AND Hash Vault.any_input_2 / any_input_3.\n\nWhy a Settings node instead of StringConstantMultiline: the Settings node keeps the native ComfyUI dropdown UX \u2014 typeahead, validation, no typos. Typing 'Saul Leiter / Intimate' as free text into a StringConstantMultiline silently breaks the cache the moment you mistype.\n\nResult: same image + same style + same intensity \u2192 cache hit \u2192 no API call. Change any of the three \u2192 new hash \u2192 API runs once, then cached.\n\nNOT CACHED: the Style Transfer node still has its own model / seed / aspect_ratio / resolution / direction widgets that DON'T factor into the hash here. If you change those and want a fresh render, clear the matching .pt file in output/hash_vault/ first. Or wire those through additional any_input slots if you have them free.\n\nREQUIRES\nComfyUI-API-Optimizer v1.2.0+, ComfyUI-Gemini-Direct (Settings node shipped in the release that includes GeminiStyleTransferSettings)."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -90,116 +90,69 @@
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "StringConstantMultiline",
|
||||
"type": "GeminiStyleTransferSettings",
|
||||
"pos": [
|
||||
40,
|
||||
600
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
160
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string",
|
||||
"name": "string",
|
||||
"type": "STRING",
|
||||
"localized_name": "style",
|
||||
"name": "style",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "string"
|
||||
"name": "style"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "strip_newlines",
|
||||
"name": "strip_newlines",
|
||||
"type": "BOOLEAN",
|
||||
"localized_name": "intensity",
|
||||
"name": "intensity",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "strip_newlines"
|
||||
"name": "intensity"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"localized_name": "style",
|
||||
"name": "style",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
3,
|
||||
4
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-kjnodes",
|
||||
"ver": "1.1.6",
|
||||
"Node name for S&R": "StringConstantMultiline",
|
||||
"aux_id": "kijai/ComfyUI-KJNodes"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Saul Leiter / Intimate",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "StringConstantMultiline",
|
||||
"pos": [
|
||||
40,
|
||||
800
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string",
|
||||
"name": "string",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "strip_newlines",
|
||||
"name": "strip_newlines",
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "strip_newlines"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"localized_name": "intensity",
|
||||
"name": "intensity",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
5,
|
||||
6
|
||||
],
|
||||
"slot_index": 0
|
||||
"slot_index": 1
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-kjnodes",
|
||||
"ver": "1.1.6",
|
||||
"Node name for S&R": "StringConstantMultiline",
|
||||
"aux_id": "kijai/ComfyUI-KJNodes"
|
||||
"cnr_id": "comfyui-gemini-direct",
|
||||
"ver": "1.1.0",
|
||||
"Node name for S&R": "GeminiStyleTransferSettings",
|
||||
"aux_id": "jeremieLouvaert/ComfyUI-Gemini-Direct"
|
||||
},
|
||||
"widgets_values": [
|
||||
"full",
|
||||
true
|
||||
"Saul Leiter / Intimate",
|
||||
"full"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -214,7 +167,7 @@
|
||||
220
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -294,7 +247,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"ver": "1.2.0",
|
||||
"ver": "1.2.2",
|
||||
"Node name for S&R": "DeterministicHashVault",
|
||||
"aux_id": "jeremieLouvaert/ComfyUI-API-Optimizer"
|
||||
},
|
||||
@@ -314,7 +267,7 @@
|
||||
380
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -425,7 +378,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-gemini-direct",
|
||||
"ver": "1.0.0",
|
||||
"ver": "1.1.0",
|
||||
"Node name for S&R": "GeminiStyleTransfer",
|
||||
"aux_id": "jeremieLouvaert/ComfyUI-Gemini-Direct"
|
||||
},
|
||||
@@ -452,7 +405,7 @@
|
||||
80
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -481,7 +434,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"ver": "1.2.0",
|
||||
"ver": "1.2.2",
|
||||
"Node name for S&R": "HashVaultSave",
|
||||
"aux_id": "jeremieLouvaert/ComfyUI-API-Optimizer"
|
||||
},
|
||||
@@ -499,7 +452,7 @@
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -536,7 +489,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"ver": "1.2.0",
|
||||
"ver": "1.2.2",
|
||||
"Node name for S&R": "LazyAPISwitch",
|
||||
"aux_id": "jeremieLouvaert/ComfyUI-API-Optimizer"
|
||||
},
|
||||
@@ -554,7 +507,7 @@
|
||||
500
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -608,16 +561,16 @@
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
5,
|
||||
3,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
6,
|
||||
2,
|
||||
"STRING"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
{
|
||||
"id": "akurate-promptstudio-styled-demo",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Note",
|
||||
"pos": [40, 20],
|
||||
"size": [1900, 220],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"PROMPT STUDIO + GEMINI + HASH VAULT — generate a styled image from a creative brief, with full caching and style-aware cache keys.\n\nFLOW\nThe Prompt Studio Settings node is the single source of truth for the photographer style. Its STRING output drives BOTH Prompt Studio's converted-to-input style slot AND Hash Vault's any_input_2 slot. Brief drives Hash Vault's payload_string. So the cache key = (brief + style). Re-run with the same brief AND same style → hit, $0.00 billed. Change either one → new hash → Prompt Studio enhances, Gemini generates, Save persists.\n\nLAZY BYPASS: on a cache hit, the Lazy API Switch demands ONLY the cached_data branch. Prompt Studio + Gemini + Save never execute, so no Flash and no Pro Image cost.\n\nSTYLE: pick a photographer on the Prompt Studio Settings dropdown. The DNA prompt for that photographer is fed to Gemini Flash via Prompt Studio, which expands your brief into an authentic styled prompt. This is the GENERATION path — use it when you want a fresh image in a photographer's voice. (Style Transfer is the other path: image-in → image-out polish, only works when source DNA is close to target.)\n\nLABEL BUILDER auto-formats the Hash Vault label as 'brief / style' so the Browser shows what was generated. Both inputs are wired upstream so the label always reflects the actual generation parameters — nothing to keep in sync manually.\n\nREQUIRES: ComfyUI-API-Optimizer >= 1.4.2 (Hash Vault + Label Builder), ComfyUI-Gemini-Direct >= 1.2.0 (Prompt Studio + Prompt Studio Settings + Gemini Image Generate), ComfyUI-KJNodes (StringConstantMultiline). Gemini API key in gemini_api_key.txt at ComfyUI root."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "StringConstantMultiline",
|
||||
"pos": [40, 260],
|
||||
"size": [360, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "string", "type": "STRING", "widget": {"name": "string"}, "link": null},
|
||||
{"name": "strip_newlines", "type": "BOOLEAN", "widget": {"name": "strip_newlines"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "STRING", "type": "STRING", "links": [1, 2, 3], "slot_index": 0}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-kjnodes",
|
||||
"Node name for S&R": "StringConstantMultiline"
|
||||
},
|
||||
"widgets_values": [
|
||||
"An elderly Parisian bookseller in his shop, golden hour side light through the window, leather-bound books out of focus behind him",
|
||||
false
|
||||
],
|
||||
"title": "Brief"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PromptStudioSettings",
|
||||
"pos": [40, 500],
|
||||
"size": [360, 80],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "style", "type": "COMBO", "widget": {"name": "style"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "style", "type": "*", "links": [13, 14], "slot_index": 0}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-gemini-direct",
|
||||
"Node name for S&R": "PromptStudioSettings"
|
||||
},
|
||||
"widgets_values": ["Alec Soth"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PromptStudio",
|
||||
"pos": [440, 240],
|
||||
"size": [400, 380],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "brief", "type": "STRING", "widget": {"name": "brief"}, "link": 1},
|
||||
{"name": "mode", "type": "COMBO", "widget": {"name": "mode"}, "link": null},
|
||||
{"name": "model", "type": "COMBO", "widget": {"name": "model"}, "link": null},
|
||||
{"name": "style", "type": "*", "widget": {"name": "style"}, "link": 13},
|
||||
{"name": "images", "type": "IMAGE", "shape": 7, "link": null},
|
||||
{"name": "previous_prompt", "type": "STRING", "shape": 7, "link": null},
|
||||
{"name": "feedback", "type": "STRING", "widget": {"name": "feedback"}, "shape": 7, "link": null},
|
||||
{"name": "custom_instructions", "type": "STRING", "widget": {"name": "custom_instructions"}, "shape": 7, "link": null},
|
||||
{"name": "api_key", "type": "STRING", "widget": {"name": "api_key"}, "shape": 7, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "enhanced_prompt", "type": "STRING", "links": [4], "slot_index": 0},
|
||||
{"name": "analysis", "type": "STRING", "links": [], "slot_index": 1}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-gemini-direct",
|
||||
"Node name for S&R": "PromptStudio"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"Expand",
|
||||
"gemini-3-flash-preview",
|
||||
"Alec Soth",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "DeterministicHashVault",
|
||||
"pos": [440, 660],
|
||||
"size": [340, 180],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "payload_string", "type": "STRING", "link": 2},
|
||||
{"name": "any_input", "type": "*", "shape": 7, "link": null},
|
||||
{"name": "any_input_2", "type": "*", "shape": 7, "link": 14},
|
||||
{"name": "any_input_3", "type": "*", "shape": 7, "link": null},
|
||||
{"name": "any_input_4", "type": "*", "shape": 7, "link": null},
|
||||
{"name": "cache_ttl_hours", "type": "FLOAT", "widget": {"name": "cache_ttl_hours"}, "shape": 7, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "cached_data", "type": "*", "links": [5], "slot_index": 0},
|
||||
{"name": "is_cached", "type": "INT", "links": [6], "slot_index": 1},
|
||||
{"name": "hash_key", "type": "STRING", "links": [7], "slot_index": 2}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"Node name for S&R": "DeterministicHashVault"
|
||||
},
|
||||
"widgets_values": [0.0]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "GeminiImageGenerate",
|
||||
"pos": [880, 240],
|
||||
"size": [380, 320],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "prompt", "type": "STRING", "widget": {"name": "prompt"}, "link": 4},
|
||||
{"name": "model", "type": "COMBO", "widget": {"name": "model"}, "link": null},
|
||||
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": null},
|
||||
{"name": "aspect_ratio", "type": "COMBO", "widget": {"name": "aspect_ratio"}, "link": null},
|
||||
{"name": "resolution", "type": "COMBO", "widget": {"name": "resolution"}, "link": null},
|
||||
{"name": "response_modalities", "type": "COMBO", "widget": {"name": "response_modalities"}, "link": null},
|
||||
{"name": "images", "type": "IMAGE", "shape": 7, "link": null},
|
||||
{"name": "system_prompt", "type": "STRING", "widget": {"name": "system_prompt"}, "shape": 7, "link": null},
|
||||
{"name": "api_key", "type": "STRING", "widget": {"name": "api_key"}, "shape": 7, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "images", "type": "IMAGE", "links": [9], "slot_index": 0},
|
||||
{"name": "text", "type": "STRING", "links": [], "slot_index": 1},
|
||||
{"name": "cost_info", "type": "STRING", "links": [], "slot_index": 2},
|
||||
{"name": "cache_key", "type": "STRING", "links": [], "slot_index": 3}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-gemini-direct",
|
||||
"Node name for S&R": "GeminiImageGenerate"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"gemini-3-pro-image-preview",
|
||||
0,
|
||||
"2:3",
|
||||
"2K",
|
||||
"IMAGE+TEXT",
|
||||
"",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "HashVaultLabelBuilder",
|
||||
"pos": [880, 600],
|
||||
"size": [340, 180],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "separator", "type": "STRING", "widget": {"name": "separator"}, "link": null},
|
||||
{"name": "in_1", "type": "*", "shape": 7, "link": 3},
|
||||
{"name": "in_2", "type": "*", "shape": 7, "link": 11},
|
||||
{"name": "in_3", "type": "*", "shape": 7, "link": null},
|
||||
{"name": "in_4", "type": "*", "shape": 7, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "label", "type": "STRING", "links": [10], "slot_index": 0}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"Node name for S&R": "HashVaultLabelBuilder"
|
||||
},
|
||||
"widgets_values": [" / "]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "HashVaultSave",
|
||||
"pos": [1300, 240],
|
||||
"size": [320, 110],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "hash_key", "type": "STRING", "link": 7},
|
||||
{"name": "api_output", "type": "*", "link": 9},
|
||||
{"name": "label", "type": "STRING", "widget": {"name": "label"}, "shape": 7, "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "api_output", "type": "*", "links": [12], "slot_index": 0}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"Node name for S&R": "HashVaultSave"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "LazyAPISwitch",
|
||||
"pos": [1660, 240],
|
||||
"size": [320, 130],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "is_cached", "type": "INT", "link": 6},
|
||||
{"name": "cached_data", "type": "*", "shape": 7, "link": 5},
|
||||
{"name": "api_data", "type": "*", "shape": 7, "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "final_output", "type": "*", "links": [8], "slot_index": 0}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-api-optimizer",
|
||||
"Node name for S&R": "LazyAPISwitch"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1660, 420],
|
||||
"size": [420, 400],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 8}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 4, 0, "STRING"],
|
||||
[2, 2, 0, 5, 0, "STRING"],
|
||||
[3, 2, 0, 7, 1, "*"],
|
||||
[4, 4, 0, 6, 0, "STRING"],
|
||||
[5, 5, 0, 9, 1, "*"],
|
||||
[6, 5, 1, 9, 0, "INT"],
|
||||
[7, 5, 2, 8, 0, "STRING"],
|
||||
[8, 9, 0, 10, 0, "IMAGE"],
|
||||
[9, 6, 0, 8, 1, "*"],
|
||||
[10, 7, 0, 8, 2, "STRING"],
|
||||
[11, 11, 0, 7, 2, "*"],
|
||||
[12, 8, 0, 9, 2, "*"],
|
||||
[13, 11, 0, 4, 3, "*"],
|
||||
[14, 11, 0, 5, 2, "*"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
Reference in New Issue
Block a user