diff --git a/web/extensions/extension.js b/web/extensions/extension.js index 9d1b9c7..d56f6ef 100644 --- a/web/extensions/extension.js +++ b/web/extensions/extension.js @@ -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: /custom_kv_stores/images//.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: + /custom_kv_stores/images//.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: + /custom_kv_stores/images//.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); - }, -});