add basics for image preview

This commit is contained in:
millerlight
2025-08-06 21:52:26 +02:00
parent aff04f6271
commit 451a5c7d66
+326 -426
View File
@@ -1,438 +1,338 @@
// KVTools: KVGet UI — dropdown + live preview + robust sync + defaults + optional auto-run
// Served at /extensions/ComfyUI-KVTools/*
# ComfyUI-KVTools: Key/Value Utilities + sicherer Image-Preview-Loader
# Robuste Version: gibt NIE None als IMAGE zurück (immer Placeholder).
# UI/Dropdown/Defaults/Auto-Run ist in web/extension.js.
import { app } from "/scripts/app.js";
import json
import re
import os
const EXT_NAME = "ComfyUI-KVTools.UI";
print("[KVTools] loaded from", __file__)
// ------------------------------ Registry cache ------------------------------
let REGISTRY = null;
async function loadRegistry() {
const paths = [
"/extensions/ComfyUI-KVTools/kv_registry.json",
"/extensions/ComfyUI-KVTools/web/kv_registry.json",
"/extensions/comfyui-kvtools/kv_registry.json",
"/extensions/comfyui-kvtools/web/kv_registry.json",
];
for (const p of paths) {
try {
const r = await fetch(p, { cache: "no-store" });
if (r.ok) {
const j = await r.json();
if (j && j.files) { REGISTRY = j; return REGISTRY; }
}
} catch {}
}
REGISTRY = { files: {} };
return REGISTRY;
}
# ---------------------------------------------------------------------
# Parsing/Dumping Helpers
# ---------------------------------------------------------------------
// ------------------------------ Helpers ------------------------------
function W(node, name) { return (node.widgets || []).find(w => w.name === name); }
function hideWidget(w) {
if (!w) return;
w.hidden = true;
w.widget_options = { hidden: true };
w.draw = () => {};
w.computeSize = () => [0, -4];
}
function moveWidgetToTop(node, w) {
if (!node.widgets || !w) return;
const i = node.widgets.indexOf(w);
if (i > 0) { node.widgets.splice(i, 1); node.widgets.unshift(w); }
}
function setDropdownValues(combo, values) {
combo.options = combo.options || {};
combo.options.values = Array.isArray(values) ? values.slice() : [];
if (!combo.options.values.includes(combo.value)) {
combo.value = combo.options.values[0] ?? "";
}
}
function ensureAsTypeDefault(node) {
const w = W(node, "as_type");
if (w && !["string", "int", "float", "bool"].includes(String(w.value))) {
w.value = "string";
const i = node.widgets.indexOf(w);
if (i >= 0 && Array.isArray(node.widgets_values)) node.widgets_values[i] = "string";
}
}
function upstreamNode(node) {
const inp = (node.inputs || []).find(i => i && i.name === "store");
if (!inp || inp.link == null) return null;
const link = node.graph.links[inp.link];
return link ? node.graph.getNodeById(link.origin_id) : null;
}
def _parse_data(data: str, fmt: str):
data = (data or "").strip()
if fmt == "auto":
if (data.startswith("{") and data.endswith("}")) or (data.startswith("[") and data.endswith("]")):
fmt = "json"
else:
fmt = "kv"
if fmt == "json":
try:
obj = json.loads(data) if data else {}
if isinstance(obj, dict):
return obj
raise ValueError("JSON is not an object (dict).")
except Exception as e:
raise ValueError(f"JSON-Error: {e}")
elif fmt == "kv":
out = {}
for line in data.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^\s*([^=:#]+)\s*[:=]\s*(.*)\s*$", line)
if not m:
raise ValueError(f"not a valid KV-line: {line}")
k, v = m.group(1).strip(), m.group(2).strip()
out[k] = v
return out
else:
raise ValueError(f"Unknown Format: {fmt}")
// ---- Inline parse ----
function parseStoreObject(text) {
const s = String(text ?? "").trim();
if (!s) return {};
try {
const obj = JSON.parse(s);
if (obj && typeof obj === "object" && !Array.isArray(obj)) return obj;
} catch {}
const out = {};
for (const raw of s.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const m = line.match(/^\s*([^=:#]+)\s*[:=]\s*(.*)\s*$/);
if (m) out[String(m[1]).trim()] = String(m[2]).trim();
}
return out;
}
function keysFromInline(up) {
const dataW = W(up, "data");
if (!dataW) return [];
const obj = parseStoreObject(dataW.value ?? "");
return Object.keys(obj).sort();
}
def _dump_data(obj: dict, fmt: str, pretty: bool = True) -> str:
if fmt == "json":
return json.dumps(obj, ensure_ascii=False, indent=(2 if pretty else None))
elif fmt == "kv":
lines = []
for k, v in obj.items():
if isinstance(v, (dict, list)):
v = json.dumps(v, ensure_ascii=False)
lines.append(f"{k}={v}")
return "\n".join(lines)
else:
raise ValueError(f"Unknown Format to write: {fmt}")
// ---- Registry keys ----
function upstreamFileName(node) {
const up = upstreamNode(node);
if (!up) return null;
const fnW = W(up, "file_name");
if (fnW && fnW.value) return String(fnW.value).trim();
const pathW = W(up, "path");
if (pathW && pathW.value) {
const p = String(pathW.value).trim();
const parts = p.split(/[\\/]/);
return parts[parts.length - 1] || null;
}
return null;
}
function keysFromRegistryByFile(fileName) {
if (!fileName) return [];
const entry = REGISTRY?.files?.[fileName];
return Array.isArray(entry?.keys) ? entry.keys.slice() : [];
}
def _cast(value, as_type: str):
if as_type == "string":
return "" if value is None else str(value)
if as_type == "int":
return int(value)
if as_type == "float":
return float(value)
if as_type == "bool":
s = str(value).strip().lower()
return s in ("1", "true", "yes", "y", "on")
return value
// ---- Unified keys collector ----
function collectKeys(node) {
const up = upstreamNode(node);
if (!up) return [];
if (up.comfyClass === "KVLoadInline") return keysFromInline(up);
if (up.comfyClass === "KVLoadFromRegistry") {
const fname = upstreamFileName(node);
return keysFromRegistryByFile(fname);
}
return [];
}
# ---------------------------------------------------------------------
# Nodes: KV
# ---------------------------------------------------------------------
// ---- Default logic aware picker ----
function pickNewKey(node, list) {
if (!Array.isArray(list) || !list.length) return "";
const dkW = W(node, "default_key");
const dk = String(dkW?.value || "").trim();
if (dk && list.includes(dk)) return dk;
const ks = W(node, "key_select");
if (ks && list.includes(String(ks.value))) return String(ks.value);
return list[0];
}
// ------------------------------ Preview ------------------------------
function ensurePreviewWidget(node) {
let w = W(node, "_kvtools_preview_value");
if (!w) {
w = node.addWidget("text", "_kvtools_preview_value", "", null);
w.serialize = false;
}
try { if (w.inputEl) { w.inputEl.readOnly = true; w.inputEl.style.opacity = 0.9; } } catch {}
return w;
}
// Optional server routes — if present, we will use them.
async function serverRefreshRegistry() { try { await fetch("/kvtools/refresh_registry", { method: "POST" }); } catch {} }
async function serverPeek(fileName, key) {
if (!fileName || !key) return "";
try {
const r = await fetch("/kvtools/peek", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ file_name: fileName, key })
});
if (!r.ok) return "";
const j = await r.json();
return j && j.ok ? String(j.value ?? "") : "";
} catch { return ""; }
}
async function updatePreview(node) {
const pv = ensurePreviewWidget(node);
const keyW = W(node, "key");
const key = String(keyW?.value || "").trim();
if (!key) { pv.value = ""; node.setDirtyCanvas(true, true); return; }
const up = upstreamNode(node);
if (!up) { pv.value = ""; node.setDirtyCanvas(true, true); return; }
// Inline: parse directly
if (up.comfyClass === "KVLoadInline") {
const dataW = W(up, "data");
const obj = parseStoreObject(dataW?.value ?? "");
let v = obj[key];
if (v && typeof v === "object") { try { v = JSON.stringify(v); } catch {} }
pv.value = String(v ?? "");
node.setDirtyCanvas(true, true);
return;
}
// Registry: try server peek (if endpoints exist)
if (up.comfyClass === "KVLoadFromRegistry") {
await serverRefreshRegistry().catch(() => {});
await loadRegistry().catch(() => {});
const fileName = upstreamFileName(node);
const v = await serverPeek(fileName, key).catch(() => "");
pv.value = String(v || "");
node.setDirtyCanvas(true, true);
return;
}
pv.value = "";
node.setDirtyCanvas(true, true);
}
// ------------------------------ Auto-run (to update outputs) ------------------------------
function ensureAutoRunToggle(node) {
let w = W(node, "_kvtools_autorun");
if (!w) {
w = node.addWidget("checkbox", "_kvtools_autorun", false, null, { label: "Auto run on change" });
w.serialize = true;
}
return w;
}
async function tryQueueRun() {
if (app?.queuePrompt) {
try { await app.queuePrompt(); return; } catch {}
}
}
// ------------------------------ Defaults (storage + buttons) ------------------------------
function ensureDefaultKeyStorage(node) {
let w = W(node, "default_key");
if (!w) {
w = node.addWidget("text", "default_key", "", null);
w.serialize = true; // persist in workflow
}
hideWidget(w);
return w;
}
function ensureDefaultButtons(node) {
if (!node.__kvtools_setdef_btn) {
const b = node.addWidget("button", "Set default (current key)", null, () => {
const ks = W(node, "key_select");
const dk = ensureDefaultKeyStorage(node);
if (ks && dk) {
dk.value = String(ks.value || "");
node.setDirtyCanvas(true, true);
}
});
b.serialize = false;
node.__kvtools_setdef_btn = b;
}
if (!node.__kvtools_loadd_btn) {
const b2 = node.addWidget("button", "Load default", null, async () => {
const dk = W(node, "default_key");
const ks = W(node, "key_select");
const keyW = W(node, "key");
if (dk && ks && keyW) {
const list = collectKeys(node);
const val = String(dk.value || "").trim();
if (val && list.includes(val)) {
ks.value = val;
keyW.value = val;
node.setDirtyCanvas(true, true);
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
class KVLoadInline:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"data": ("STRING", {"multiline": True, "placeholder":
"{\n \"speaker\":\"Tom\",\n \"lang\":\"de\"\n}\n"
"# oder:\n# speaker=Tom\n# lang=de"}),
}
}
}
});
b2.serialize = false;
node.__kvtools_loadd_btn = b2;
}
RETURN_TYPES = ("KV",)
RETURN_NAMES = ("store",)
FUNCTION = "create"
CATEGORY = "Utils/KV"
def create(self, data):
store = _parse_data(data, "auto")
return (store,)
class KVGet:
"""Read one value from a key/value store"""
CATEGORY = "KVTools"
FUNCTION = "kv_get"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"store": ("KV",),
"key": ("STRING", {
"default": "",
"multiline": False,
"forceInput": False,
}),
"default": ("STRING", {
"default": "",
"multiline": True,
}),
"as_type": (["string", "int", "float", "bool"], {
"default": "string",
}),
},
"optional": {
"keys_hint": ("STRING", {
"default": "(dropdown enabled)",
"multiline": False,
"visible": False,
}),
"default_key": ("STRING", {
"default": "",
"multiline": False,
"visible": False,
}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("value", "key")
OUTPUT_NODE = True
def __init__(self):
self.default = ""
def kv_get(self, store, key, default, as_type, **kwargs):
if default is None:
default = ""
value = store.get(key, default)
if as_type == "int":
try:
value = str(int(value))
except:
value = default
elif as_type == "float":
try:
value = str(float(value))
except:
value = default
elif as_type == "bool":
if str(value).strip().lower() in ("1", "true", "yes"):
value = "true"
else:
value = "false"
else:
value = str(value)
return (value, key)
class KVLoadFromRegistry:
_BASE = os.path.join(os.getcwd(), "custom_kv_stores")
try:
os.makedirs(_BASE, exist_ok=True)
_FILES = sorted([n for n in os.listdir(_BASE) if n.lower().endswith(".json")]) or ["(none)"]
except Exception:
_FILES = ["(none)"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_name": (cls._FILES, {"default": cls._FILES[0]}),
}
}
RETURN_TYPES = ("KV", "STRING")
RETURN_NAMES = ("store", "path")
FUNCTION = "load"
CATEGORY = "Utils/KV"
def load(self, file_name):
if not file_name or file_name == "(none)":
return ({}, "")
path = os.path.join(self._BASE, file_name)
if not os.path.isfile(path):
raise FileNotFoundError(f"KVLoadFromRegistry: File not found: {path}")
with open(path, "r", encoding="utf-8") as f:
text = f.read()
store = _parse_data(text, "auto")
return (store, path)
# ---------------------------------------------------------------------
# Sicherer Image-Preview (nur unter custom_kv_stores/images)
# Struktur: <ComfyUI>/custom_kv_stores/images/<json-basisname>/<key>.png
# Immer gültiges IMAGE zurückgeben (Platzhalter bei Missing/Error).
# ---------------------------------------------------------------------
def _images_root():
return os.path.join(os.getcwd(), "custom_kv_stores", "images")
def _sanitize_name(s: str) -> str:
s = os.path.basename(str(s or "").strip())
return re.sub(r"[^A-Za-z0-9._ \-]+", "_", s)
# Platzhalter vorab bauen (1x1 schwarz), damit wir nie None zurückgeben
try:
import torch as _torch
_KVTOOLS_PLACEHOLDER = _torch.zeros((1, 1, 1, 3), dtype=_torch.float32) # [B=1,H=1,W=1,C=3]
except Exception:
_KVTOOLS_PLACEHOLDER = None # Sollte in ComfyUI nicht passieren; als Fallback unten nochmal abgefangen.
def _placeholder_image():
global _KVTOOLS_PLACEHOLDER
if _KVTOOLS_PLACEHOLDER is None:
# letztes Sicherheitsnetz, falls torch import fehlschlug
try:
import torch
_KVTOOLS_PLACEHOLDER = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
except Exception:
# letzte Instanz: gib irgendwas zurück, was kein None ist (riskant, aber verhindert None)
_KVTOOLS_PLACEHOLDER = 0
return _KVTOOLS_PLACEHOLDER
try:
os.makedirs(_images_root(), exist_ok=True)
except Exception:
pass
class KVPreviewImageFromRegistry:
"""
Sucht Bild unter:
<ComfyUI>/custom_kv_stores/images/<json-basisname>/<key>.png
Gibt bei fehlender Datei KEINEN Fehler aus, sondern einen 1x1-Platzhalter als IMAGE
sowie einen leeren Pfad (""). Bei Erfolg: echtes Bild + voller Pfad.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"key": ("STRING", {"forceInput": True, "multiline": False}),
"registry_path": ("STRING", {"forceInput": True, "multiline": False}),
"ext": (["png"], {"default": "png"}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "path")
FUNCTION = "load"
CATEGORY = "Utils/KV"
def load(self, key, registry_path, ext):
# Standard-Outputs vorbereiten
img_out = _placeholder_image()
path_out = ""
try:
key = str(key or "").strip()
registry_path = str(registry_path or "").strip()
if not key or not registry_path:
return (img_out, path_out)
base = _images_root()
json_base = os.path.splitext(os.path.basename(registry_path))[0]
folder = _sanitize_name(json_base)
fname = f"{_sanitize_name(key)}.{ext}"
full_path = os.path.join(base, folder, fname)
if not os.path.isfile(full_path):
return (img_out, path_out) # Platzhalter + leerer Pfad
# Bild laden
from PIL import Image
import numpy as np
import torch
img = Image.open(full_path).convert("RGB")
arr = (np.asarray(img).astype("float32") / 255.0) # H,W,3
t = torch.from_numpy(arr)[None, ...] # 1,H,W,3
return (t, full_path)
except Exception:
# Niemals None als IMAGE zurückgeben
return (img_out, path_out)
class KVImagePathFromRegistry:
"""
Baut nur den Pfad (STRING), ohne zu laden:
<ComfyUI>/custom_kv_stores/images/<json-basisname>/<key>.png
Falls die Datei fehlt, wird trotzdem ein String-Pfad zurückgegeben.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"key": ("STRING", {"forceInput": True, "multiline": False}),
"registry_path": ("STRING", {"forceInput": True, "multiline": False}),
"ext": (["png"], {"default": "png"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("path",)
FUNCTION = "build"
CATEGORY = "Utils/KV"
def build(self, key, registry_path, ext):
key = str(key or "").strip()
registry_path = str(registry_path or "").strip()
if not key or not registry_path:
return ("",)
base = _images_root()
json_base = os.path.splitext(os.path.basename(registry_path))[0]
folder = _sanitize_name(json_base)
fname = f"{_sanitize_name(key)}.{ext}"
path = os.path.join(base, folder, fname)
return (path,)
# ---------------------------------------------------------------------
# Node Mappings
# ---------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"KVLoadInline": KVLoadInline,
"KVLoadFromRegistry": KVLoadFromRegistry,
"KVGet": KVGet,
"KVPreviewImageFromRegistry": KVPreviewImageFromRegistry,
"KVImagePathFromRegistry": KVImagePathFromRegistry,
}
// ------------------------------ UI build / sync ------------------------------
function buildOrEnsureCombo(node) {
if (node.__kvtools_combo && node.widgets.includes(node.__kvtools_combo)) {
return node.__kvtools_combo;
}
const combo = node.addWidget("combo", "key_select", "", null, { values: [] });
combo.serialize = false;
node.__kvtools_combo = combo;
moveWidgetToTop(node, combo);
return combo;
NODE_DISPLAY_NAME_MAPPINGS = {
"KVLoadInline": "KV Load Inline",
"KVLoadFromRegistry": "KV Load from Registry",
"KVGet": "KV Get Value",
"KVPreviewImageFromRegistry": "KV Preview Image (Registry Key)",
"KVImagePathFromRegistry": "KV Image Path (Registry Key)",
}
async function syncKeyList(node) {
const ks = buildOrEnsureCombo(node);
const keyW = W(node, "key");
if (!ks || !keyW) return;
const list = collectKeys(node);
setDropdownValues(ks, list);
const cur = String(keyW.value || "").trim();
if (list.includes(cur)) {
ks.value = cur;
} else {
const pick = pickNewKey(node, list); // prefers default_key if valid
ks.value = pick;
keyW.value = pick;
}
node.setDirtyCanvas(true, true);
}
function hideBuiltIns(node) {
hideWidget(W(node, "key"));
hideWidget(W(node, "keys_hint"));
hideWidget(W(node, "default"));
hideWidget(W(node, "default_key"));
setTimeout(() => {
hideWidget(W(node, "key"));
hideWidget(W(node, "keys_hint"));
hideWidget(W(node, "default"));
hideWidget(W(node, "default_key"));
}, 0);
}
function ensureRefreshButton(node) {
if (node.__kvtools_refresh_btn) return node.__kvtools_refresh_btn;
const btn = node.addWidget("button", "KVTools: refresh keys", null, async () => {
await loadRegistry();
await syncKeyList(node);
ensureAsTypeDefault(node);
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
});
btn.serialize = false;
node.__kvtools_refresh_btn = btn;
return btn;
}
// Upstream changes listeners
function hookUpstream(node) {
const up = upstreamNode(node);
if (!up) return;
// Inline: react on text changes
if (up.comfyClass === "KVLoadInline") {
const dataW = W(up, "data");
if (dataW && !dataW._kvtoolsHooked) {
dataW._kvtoolsHooked = true;
const orig = dataW.callback;
dataW.callback = async () => {
if (orig) orig();
await syncKeyList(node);
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
};
}
}
// Registry: react on file_name changes
if (up.comfyClass === "KVLoadFromRegistry") {
const fnW = W(up, "file_name");
if (fnW && !fnW._kvtoolsHooked) {
fnW._kvtoolsHooked = true;
const orig = fnW.callback;
fnW.callback = async () => {
if (orig) orig();
await loadRegistry();
await syncKeyList(node); // pickNewKey prefers default_key
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
};
}
}
}
// ------------------------------ Attach ------------------------------
function isKVGet(node) { return node?.comfyClass === "KVGet"; }
function attachToKVGet(node) {
if (!isKVGet(node)) return;
if (node.__kvtools_attached) return;
node.__kvtools_attached = true;
hideBuiltIns(node);
ensureAsTypeDefault(node);
buildOrEnsureCombo(node);
ensurePreviewWidget(node);
ensureRefreshButton(node);
ensureDefaultKeyStorage(node);
ensureDefaultButtons(node);
ensureAutoRunToggle(node);
// Dropdown callback
const ks = W(node, "key_select");
if (ks && !ks.__kvtools_cb) {
ks.__kvtools_cb = true;
ks.callback = async () => {
const keyW = W(node, "key");
if (keyW) keyW.value = String(ks.value || "");
node.setDirtyCanvas(true, true);
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
};
}
// Manual key changes
const keyW = W(node, "key");
if (keyW && !keyW.__kvtools_cb) {
keyW.__kvtools_cb = true;
const orig = keyW.callback;
keyW.callback = async () => {
if (orig) orig();
const ks2 = W(node, "key_select");
if (ks2 && ks2.options?.values?.includes(keyW.value)) ks2.value = keyW.value;
node.setDirtyCanvas(true, true);
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
};
}
// Connection changes (e.g., new upstream)
const origConn = node.onConnectionsChange?.bind(node);
node.onConnectionsChange = async (...args) => {
if (origConn) origConn(...args);
hookUpstream(node);
await loadRegistry();
await syncKeyList(node);
ensureAsTypeDefault(node);
await updatePreview(node);
if (W(node, "_kvtools_autorun")?.value) await tryQueueRun();
};
// Initial after graph load (wait until upstream is connected)
const wait = setInterval(async () => {
const up = upstreamNode(node);
if (up) {
clearInterval(wait);
hookUpstream(node);
await loadRegistry();
await syncKeyList(node);
await updatePreview(node);
// no autorun on initial attach
}
}, 200);
}
// ------------------------------ Register extension ------------------------------
app.registerExtension({
name: EXT_NAME,
setup() {
const ready = setInterval(() => {
if (app.graph && Array.isArray(app.graph._nodes)) {
clearInterval(ready);
for (const n of app.graph._nodes) attachToKVGet(n);
const origAdd = app.graph.add;
app.graph.add = function(node) {
const res = origAdd.call(this, node);
attachToKVGet(node);
return res;
};
}
}, 200);
},
});