diff --git a/README.md b/README.md index 782652a..78d6e55 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ It does three things: Nothing is ever swapped without your say-so, and the engine only rewires slots it can move *losslessly* — anything it can't map is reported, not guessed. +### Works on uninstalled ("missing") nodes + +The headline case: open a downloaded workflow full of red **missing** nodes and +replace them with core equivalents **without installing the packs at all**. +ComfyUI keeps each missing node as a placeholder that remembers its original +type and wiring, so UTFCN can still match and swap it — via a curated rule (by +name) or by matching the node's *serialized* signature against your core nodes. +Both "Replace…" and the right-click item work on them; the bulk dialog labels +them `⚠ not installed`. (Widget values aren't carried for a node whose +definition you don't have — links are.) + ## How it decides what's equivalent The backend reads the live node registry (real `INPUT_TYPES` / `RETURN_TYPES` @@ -37,6 +48,10 @@ and each node's source module) and ranks candidates in three tiers: "Available" means core is preferred, and if there's no core match it will offer an equivalent from a **different installed pack** as a fallback. +For an **uninstalled** node only *curated* (by name) and *partial* (by its +serialized link signature) can apply — the exact tier needs the widget-level +signature, which a node you haven't installed can't provide. + ## Shipped equivalences `mappings.json` ships a small, hand-verified set (each checked lossless against diff --git a/__init__.py b/__init__.py index 0edc859..501f651 100644 --- a/__init__.py +++ b/__init__.py @@ -13,7 +13,9 @@ All of that is frontend behaviour (see web/utfcn.js). This backend only serves the *analysis*: it has the live node registry, so it computes — accurately, from real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents. - GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats } + GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats } + POST /utfcn/match {nodes:[{type,inputs,outputs,output_names}]} -> { candidates } + (for UNINSTALLED / missing nodes in a workflow) Curated overrides live in mappings.json (shipped) and user_mappings.json (yours). """ @@ -28,15 +30,23 @@ from . import utfcn_core VERSION = "1.0.0" _DIR = os.path.dirname(os.path.realpath(__file__)) -# The scan walks the whole registry, so we cache it and only rebuild on demand. +# Snapshotting the registry is the expensive part, so we cache the context and +# the derived scan index, rebuilding only on ?refresh=1. +_CTX_CACHE = None _INDEX_CACHE = None +def _get_ctx(refresh=False): + global _CTX_CACHE + if refresh or _CTX_CACHE is None: + _CTX_CACHE = utfcn_core.build_context(utfcn_core.load_rules(_DIR)) + return _CTX_CACHE + + def _get_index(refresh=False): global _INDEX_CACHE if refresh or _INDEX_CACHE is None: - rules = utfcn_core.load_rules(_DIR) - _INDEX_CACHE = utfcn_core.build_index(rules) + _INDEX_CACHE = utfcn_core.build_index(_get_ctx(refresh)) return _INDEX_CACHE @@ -54,6 +64,20 @@ async def utfcn_scan(request): return web.json_response({"sources": {}, "candidates": {}, "stats": {}, "error": str(e)}, status=500) +@routes.post("/utfcn/match") +async def utfcn_match(request): + """Match uninstalled/missing nodes by their serialized signature.""" + try: + data = await request.json() + except Exception: + return web.json_response({"candidates": {}, "error": "invalid json"}, status=400) + try: + return web.json_response({"candidates": utfcn_core.match(_get_ctx(), data.get("nodes") or [])}) + except Exception as e: + print(f"[UTFCN] match failed: {e}") + return web.json_response({"candidates": {}, "error": str(e)}, status=500) + + WEB_DIRECTORY = "./web" NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/utfcn_core.py b/utfcn_core.py index 5c07cef..ae07d6d 100644 --- a/utfcn_core.py +++ b/utfcn_core.py @@ -149,18 +149,15 @@ def load_rules(base_dir): return merged -def build_index(rules): +def build_context(rules): """ - Build the full equivalence index for the current node registry. + Snapshot the live node registry once (signatures + source of every node). + + Returned context is reused by build_index() (the /utfcn/scan payload) and by + match() (per-workflow matching of UNINSTALLED nodes), so the expensive walk + only happens on refresh. `rules` is the merged curated mapping: {sourceType: [ {to, note, inputs, widgets, outputs}, ... ]}. - - Returns: - { - "sources": {type: {"source": "core"|"custom", "pack": str, "display": str}}, - "candidates": {customType: [candidate, ...]}, # only custom nodes with >=1 candidate - "stats": {...}, - } """ import nodes # imported here so the module stays importable outside ComfyUI @@ -170,8 +167,7 @@ def build_index(rules): sources, sigs = {}, {} for name, cls in classes.items(): module = _module_of(cls) - kind = _source_kind(module) - sources[name] = {"source": kind, "pack": _pack_of(module), "display": displays.get(name, name)} + sources[name] = {"source": _source_kind(module), "pack": _pack_of(module), "display": displays.get(name, name)} sigs[name] = _signature(cls) # Bucket every potential *target* by its first output type so a source only @@ -180,32 +176,38 @@ def build_index(rules): for name in classes: by_out[_first_output_type(sigs[name])].append(name) - candidates = {} - verified_count = 0 - for src_name, meta in sources.items(): - if meta["source"] != "custom": + return {"sources": sources, "sigs": sigs, "by_out": by_out, "rules": rules} + + +def _candidates_for(src_name, src_sig, src_pack, ctx): + """ + Rank replacement candidates for one source node. + + `src_sig` may be None (an uninstalled node we know only by name) — then only + curated rules apply. If a signature is given (installed node, or a missing + node's serialized signature), exact/partial tiers are added too. + `src_pack` is None for uninstalled/unknown sources (skips same-pack exclusion). + """ + sources, sigs, by_out, rules = ctx["sources"], ctx["sigs"], ctx["by_out"], ctx["rules"] + found, seen = [], set() + + # --- tier 1: curated rules (ordered preference; core-first is the author's job) --- + for rule in rules.get(src_name, []): + to = rule.get("to") + if not to or to == src_name or to not in sources or to in seen: continue - src_sig = sigs[src_name] - src_pack = meta["pack"] - found, seen = [], set() + seen.add(to) + found.append(_candidate(to, sources, "curated", 1.0, rule)) - # --- tier 1: curated rules (ordered preference; core-first is the author's job) --- - for rule in rules.get(src_name, []): - to = rule.get("to") - if not to or to == src_name or to not in classes or to in seen: - continue - seen.add(to) - found.append(_candidate(to, sources, "curated", 1.0, rule)) - - # --- tiers 2 & 3: signature matching within the same output bucket --- - bucket = by_out.get(_first_output_type(src_sig), []) + # --- tiers 2 & 3: signature matching within the same output bucket --- + if src_sig is not None: ranked = [] - for cand_name in bucket: + for cand_name in by_out.get(_first_output_type(src_sig), []): if cand_name in seen or cand_name == src_name: continue cand_meta = sources[cand_name] # target must be core, or a DIFFERENT installed pack (fallback-to-available) - if cand_meta["source"] == "custom" and cand_meta["pack"] == src_pack: + if cand_meta["source"] == "custom" and src_pack is not None and cand_meta["pack"] == src_pack: continue cand_sig = sigs[cand_name] if not _feasible(src_sig, cand_sig): @@ -217,11 +219,10 @@ def build_index(rules): if sc >= _PARTIAL_THRESHOLD: ranked.append((cand_name, "partial", sc)) - # order: core before pack; exact before partial; higher score first ranked.sort(key=lambda r: ( - 0 if sources[r[0]]["source"] == "core" else 1, - 0 if r[1] == "exact" else 1, - -r[2], + 0 if sources[r[0]]["source"] == "core" else 1, # core before pack + 0 if r[1] == "exact" else 1, # exact before partial + -r[2], # higher score first )) for cand_name, tier, sc in ranked: if cand_name in seen: @@ -229,20 +230,79 @@ def build_index(rules): seen.add(cand_name) found.append(_candidate(cand_name, sources, tier, sc, None)) + return found[:_MAX_CANDIDATES] + + +def build_index(ctx): + """ + Build the /utfcn/scan payload from a context. + + Covers INSTALLED custom nodes (curated + signature tiers) AND uninstalled + source types that a curated rule targets an installed node for — so a rule + still fires on a node whose pack you never installed. + + Returns { "sources": {...}, "candidates": {srcType: [candidate,...]}, "stats": {...} }. + """ + sources = ctx["sources"] + candidates = {} + + for src_name, meta in sources.items(): + if meta["source"] != "custom": + continue + found = _candidates_for(src_name, ctx["sigs"][src_name], meta["pack"], ctx) if found: - candidates[src_name] = found[:_MAX_CANDIDATES] - if any(c["verified"] for c in candidates[src_name]): - verified_count += 1 + candidates[src_name] = found + + # curated rules whose SOURCE isn't installed (the "replace a missing node + # without installing its pack" case) — no signature, so curated-only. + uninstalled = 0 + for src_name in ctx["rules"]: + if src_name in sources or src_name in candidates: + continue + found = _candidates_for(src_name, None, None, ctx) + if found: + candidates[src_name] = found + uninstalled += 1 stats = { "nodes": len(sources), "custom": sum(1 for m in sources.values() if m["source"] == "custom"), "replaceable": len(candidates), - "verified": verified_count, + "verified": sum(1 for cl in candidates.values() if any(c["verified"] for c in cl)), + "uninstalled": uninstalled, } return {"sources": sources, "candidates": candidates, "stats": stats} +def match(ctx, items): + """ + Match a batch of nodes given only their (possibly serialized) signature — + used for UNINSTALLED / missing nodes in an open workflow. + + `items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ]. + Serialized nodes only carry link slots (not widget values), so 'exact' rarely + fires; curated rules (by type name) and 'partial' link-type matches do. + + Returns { type: [candidate, ...] }. + """ + out = {} + for it in items: + t = it.get("type") + if not t or t in out: + continue + inputs = {k: str(v) for k, v in (it.get("inputs") or {}).items()} + sig = { + "inputs": inputs, + "required": set(inputs), + "outputs": [str(x) for x in (it.get("outputs") or [])], + "output_names": list(it.get("output_names") or []), + } + found = _candidates_for(t, sig, None, ctx) + if found: + out[t] = found + return out + + def _candidate(to, sources, tier, score, rule): meta = sources[to] cand = { diff --git a/web/utfcn.js b/web/utfcn.js index 88a769d..f86b36a 100644 --- a/web/utfcn.js +++ b/web/utfcn.js @@ -42,6 +42,36 @@ const sourceInfo = (type) => INDEX?.sources?.[type]; const isCustom = (type) => sourceInfo(type)?.source === "custom"; const candidatesFor = (type) => INDEX?.candidates?.[type] || []; +// The type key to look a node up by. ComfyUI keeps an UNINSTALLED ("missing") +// node as a placeholder whose original type lives in last_serialization.type. +const nodeType = (n) => n?.last_serialization?.type || n?.comfyClass || n?.type; +const isMissing = (n) => !!n?.has_errors || (INDEX && !INDEX.sources?.[nodeType(n)]); + +// Missing nodes aren't in the registry, so /utfcn/scan can't know their signature. +// Ask the backend to match them from the serialized slots ComfyUI preserved, and +// fold the results into INDEX.candidates so the rest of the code is agnostic. +async function matchMissing() { + if (!INDEX) await loadIndex(); + const items = [], seen = new Set(); + for (const n of app.graph?._nodes || []) { + const t = nodeType(n); + if (!t || seen.has(t) || INDEX.candidates[t] || INDEX.sources[t]) continue; // known/installed + const s = n.last_serialization; + if (!s) continue; + seen.add(t); + const inputs = {}; + (s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; }); + items.push({ type: t, inputs, outputs: (s.outputs || []).map((o) => o.type), output_names: (s.outputs || []).map((o) => o.name) }); + } + if (!items.length) return; + try { + const r = await app.api.fetchApi("/utfcn/match", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nodes: items }), + }); + Object.assign(INDEX.candidates, (await r.json()).candidates || {}); + } catch (e) { console.error("[UTFCN] match failed:", e); } +} + function toast(severity, detail, life = 5000) { try { app.extensionManager?.toast?.add?.({ severity, summary: EXT, detail, life }); } catch { /* older ComfyUI: no toast API */ } @@ -165,7 +195,7 @@ function applySwap(node, plan, rule) { /** First verified candidate whose swap is feasible right now (used by force mode). */ function firstVerifiedPlan(node) { - for (const c of candidatesFor(node.type)) { + for (const c of candidatesFor(nodeType(node))) { if (!c.verified) continue; const plan = planSwap(node, c.to, c); if (plan.ok) return { cand: c, plan }; @@ -268,13 +298,14 @@ function showPreview(rows) { } rows.forEach(({ node, cands }, i) => { - const info = sourceInfo(node.type); + const t = nodeType(node); + const pack = sourceInfo(t)?.pack || (isMissing(node) ? "⚠ not installed" : "?"); const opts = cands.map((c, k) => ``).join(""); const tr = document.createElement("tr"); tr.innerHTML = `