Files
ComfyUI-UTFCN/utfcn_core.py
T
Ethanfel 16f4e93a3a Initial commit: UTFCN — Use The (F***ing) Core Nodes
A ComfyUI companion that suggests core / available equivalents for custom
nodes and replaces them in a workflow.

- Backend (utfcn_core.py, __init__.py): read-only /utfcn/scan analysis that
  ranks equivalents in three tiers (curated → exact-signature → partial
  heuristic) from the live node registry.
- Frontend (web/utfcn.js): on-add mode (Off / Suggest / Force auto-replace),
  bulk "Replace with core / available…" command + Extensions menu with a
  preview-then-confirm dialog, and a right-click single-node replace. The swap
  engine only rewires losslessly.
- mappings.json: 7 hand-verified curated rules mined from installed packs
  (essentials, KJNodes, WAS); user_mappings.json for user overrides.
- Docs + branding: README, icon and social banner (SVG + PNG), GPL-3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:26:30 +02:00

265 lines
10 KiB
Python

"""
UTFCN — Use The F***ing Core Nodes. Backend analysis engine.
This module runs inside the ComfyUI server process, so it can see the live node
registry (``nodes.NODE_CLASS_MAPPINGS``) with every node's real INPUT_TYPES /
RETURN_TYPES and its source module. That's exactly the ground truth needed to
answer the only interesting question here:
"This custom node — is there a CORE node (or, failing that, a node from a
DIFFERENT installed pack) that does the same job, and could I swap it in
without breaking the graph?"
We answer it in three tiers, from most to least trustworthy:
curated a hand-written rule in mappings.json / user_mappings.json.
Carries explicit input/widget/output name remaps. Verified.
exact the candidate's signature (input name→type map + ordered output
types) is IDENTICAL to the source's. Safe to remap by name.
Verified.
partial the candidate can structurally accept every input the source has
and provides every output type the source has, but names / extra
slots differ. A *suggestion* only — never auto-applied.
The frontend consumes the result: `verified` candidates power auto-replace,
`partial` ones are shown for the user to confirm.
"""
import json
import os
from collections import Counter, defaultdict
# Top-level python modules we consider "core" (shipped with ComfyUI itself).
# server.py exposes each class's origin as RELATIVE_PYTHON_MODULE (default "nodes").
CORE_TOPLEVEL = ("nodes", "comfy_extras", "comfy_api_nodes", "comfy_api")
# Widget-ish primitive types. These are values the user types, not graph links,
# so they matter for widget-value transfer but not for link compatibility.
WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"})
def _module_of(cls):
return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes"
def _source_kind(module):
top = module.split(".", 1)[0]
if top == "custom_nodes":
return "custom"
if top in CORE_TOPLEVEL:
return "core"
return "core" # anything unexpected is treated as first-party
def _pack_of(module):
parts = module.split(".")
if parts[0] == "custom_nodes" and len(parts) > 1:
return parts[1]
return parts[0]
def _spec_type(spec):
"""Reduce an INPUT_TYPES spec (``("IMAGE",)`` / ``(["a","b"], {...})``) to a type string."""
t = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
if isinstance(t, list): # a list of choices == a combo/dropdown widget
return "COMBO"
return str(t)
def _signature(cls):
"""Extract a comparable signature: inputs {name->type}, required names, ordered output types."""
try:
it = cls.INPUT_TYPES()
except Exception:
it = {}
inputs, required = {}, set()
for section in ("required", "optional"):
for name, spec in (it.get(section) or {}).items():
try:
inputs[name] = _spec_type(spec)
except Exception:
inputs[name] = "*"
if section == "required":
required.add(name)
outputs = [str(t) for t in (getattr(cls, "RETURN_TYPES", ()) or ())]
out_names = [str(n) for n in (getattr(cls, "RETURN_NAMES", ()) or [])]
return {"inputs": inputs, "required": required, "outputs": outputs, "output_names": out_names}
def _first_output_type(sig):
return sig["outputs"][0] if sig["outputs"] else ""
def _is_exact(a, b):
"""Identical enough that a name-based remap is trivially safe."""
return a["inputs"] == b["inputs"] and a["outputs"] == b["outputs"]
def _feasible(src, cand):
"""Can `cand` structurally stand in for `src`? (accepts all its inputs, provides all its outputs)"""
src_in = Counter(src["inputs"].values())
cand_in = Counter(cand["inputs"].values())
in_ok = not (src_in - cand_in) # every source input type available on candidate
src_out = Counter(src["outputs"])
cand_out = Counter(cand["outputs"])
out_ok = not (src_out - cand_out) # candidate provides every source output type
return in_ok and out_ok
def _score(src, cand):
"""Signature-overlap score in [0,1]; higher = more alike. Rewards matching names too."""
src_in, cand_in = Counter(src["inputs"].values()), Counter(cand["inputs"].values())
src_out, cand_out = Counter(src["outputs"]), Counter(cand["outputs"])
overlap = sum((src_in & cand_in).values()) + sum((src_out & cand_out).values())
total = sum(src_in.values()) + sum(src_out.values())
base = overlap / total if total else 0.0
# small bonus for shared input names — a strong signal of a deliberate re-implementation
shared_names = len(set(src["inputs"]) & set(cand["inputs"]))
name_bonus = 0.15 * (shared_names / len(src["inputs"])) if src["inputs"] else 0.0
return min(1.0, base + name_bonus)
# score below which a partial match isn't worth surfacing
_PARTIAL_THRESHOLD = 0.5
# max candidates returned per source node
_MAX_CANDIDATES = 6
def _normalise_rules(raw):
"""Accept both {source: {...single...}} and {source: [ {...}, {...} ]} shapes."""
out = {}
for src, val in (raw.get("rules") or {}).items():
targets = val if isinstance(val, list) else [val]
out[src] = [t for t in targets if isinstance(t, dict) and t.get("to")]
return out
def load_rules(base_dir):
"""Load builtin mappings.json, then deep-merge user_mappings.json on top (user wins per source)."""
merged = {}
for fname in ("mappings.json", "user_mappings.json"):
path = os.path.join(base_dir, fname)
if not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8") as f:
merged.update(_normalise_rules(json.load(f)))
except Exception as e: # a broken user file must never take the server down
print(f"[UTFCN] failed to read {fname}: {e}")
return merged
def build_index(rules):
"""
Build the full equivalence index for the current node registry.
`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
classes = nodes.NODE_CLASS_MAPPINGS
displays = getattr(nodes, "NODE_DISPLAY_NAME_MAPPINGS", {})
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)}
sigs[name] = _signature(cls)
# Bucket every potential *target* by its first output type so a source only
# gets compared against nodes that could plausibly feed the same downstream.
by_out = defaultdict(list)
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":
continue
src_sig = sigs[src_name]
src_pack = meta["pack"]
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 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), [])
ranked = []
for cand_name in bucket:
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:
continue
cand_sig = sigs[cand_name]
if not _feasible(src_sig, cand_sig):
continue
if _is_exact(src_sig, cand_sig):
ranked.append((cand_name, "exact", 1.0))
else:
sc = _score(src_sig, cand_sig)
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],
))
for cand_name, tier, sc in ranked:
if cand_name in seen:
continue
seen.add(cand_name)
found.append(_candidate(cand_name, sources, tier, sc, None))
if found:
candidates[src_name] = found[:_MAX_CANDIDATES]
if any(c["verified"] for c in candidates[src_name]):
verified_count += 1
stats = {
"nodes": len(sources),
"custom": sum(1 for m in sources.values() if m["source"] == "custom"),
"replaceable": len(candidates),
"verified": verified_count,
}
return {"sources": sources, "candidates": candidates, "stats": stats}
def _candidate(to, sources, tier, score, rule):
meta = sources[to]
cand = {
"to": to,
"to_display": meta["display"],
"source": meta["source"], # "core" | "custom"
"pack": meta["pack"],
"tier": tier, # "curated" | "exact" | "partial"
"verified": tier in ("curated", "exact"),
"score": round(float(score), 3),
}
if rule:
# explicit name remaps travel to the frontend so the swap is exact
for key in ("inputs", "widgets", "outputs"):
if isinstance(rule.get(key), dict):
cand[key] = rule[key]
if rule.get("note"):
cand["note"] = rule["note"]
return cand