commit 16f4e93a3a65d9a19470a7588084f0db03c765d4 Author: Ethanfel Date: Thu Jul 2 10:26:30 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5f3a7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.DS_Store +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd84958 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +UTFCN — Use The F***ing Core Nodes +Copyright (C) 2026 ethanfel + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . + +SPDX-License-Identifier: GPL-3.0-or-later +The full license text is available at: https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..782652a --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +

+ UTFCN — Use The Core Nodes +

+ +# UTFCN — Use The F***ing Core Nodes + +A ComfyUI companion that nudges your workflows back toward **core nodes**. Over +time a graph accumulates custom nodes that just re-implement things ComfyUI now +ships itself. UTFCN spots those and helps you swap them out — fewer dependencies, +more portable workflows. + +It does three things: + +1. **On add** — drop a custom node that has a core (or otherwise installed) + equivalent and, depending on the mode, UTFCN either shows a quiet tip or + (in **Force mode**) auto-replaces it with the equivalent on the spot. +2. **Replace across a workflow** — `Extensions ▸ UTFCN ▸ Replace custom nodes + with core / available…` (also in the command palette). It scans the open + graph and shows a **preview** of every swap before anything changes. +3. **Replace one node** — right-click any custom node ▸ **Replace with core / + available**. + +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. + +## How it decides what's equivalent + +The backend reads the live node registry (real `INPUT_TYPES` / `RETURN_TYPES` +and each node's source module) and ranks candidates in three tiers: + +| Tier | Meaning | Auto-applied? | +|------|---------|---------------| +| **curated** | a hand-written rule in `mappings.json` / `user_mappings.json` | yes (verified) | +| **exact** | identical input names+types and output types to a core/other-pack node | yes (verified) | +| **partial** | can structurally stand in (accepts all inputs, provides all outputs) but names/slots differ | suggestion only | + +"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. + +## Shipped equivalences + +`mappings.json` ships a small, hand-verified set (each checked lossless against +the real core signatures): + +- `GetImageSize+` (essentials) → core `GetImageSize` +- `MaskPreview+` (essentials) → core `MaskPreview` +- `BOOLConstant` / `INTConstant` / `FloatConstant` (KJNodes) → core + `PrimitiveBoolean` / `PrimitiveInt` / `PrimitiveFloat` +- `Convert Masks to Images` / `Mask Invert` (WAS) → core `MaskToImage` / + `InvertMask` + +Rules for nodes you don't have installed are simply ignored. Everything else is +found live: exact-signature matches auto-apply, looser ones are suggested. + +## Adding your own equivalences + +To bless a partial match as safe, or to fix up slots whose names differ, add a +rule to **`user_mappings.json`** (merged over the shipped `mappings.json`, +survives updates): + +```json +{ + "rules": { + "SomeCustomNode": [ + { + "to": "CoreNode", + "note": "why they're equivalent (shown in the preview)", + "inputs": { "old_input": "new_input" }, + "widgets": { "old_widget": "new_widget" }, + "outputs": { "old_output": "new_output" } + } + ] + } +} +``` + +List targets in preference order (put the core node first). Any slot you don't +list is matched by identical name, then by type + order. After editing, run +`Extensions ▸ UTFCN ▸ Refresh equivalence index` (no restart needed). + +## Settings + +**UTFCN ▸ On add ▸ When adding a custom node that has a core / available +equivalent:** + +- **Off** — do nothing. +- **Suggest** (default) — show a tip pointing at the equivalent. +- **Force (auto-replace with core)** — immediately swap it for the equivalent. + Force only ever applies **verified** matches (curated or exact-signature); it + never auto-applies a heuristic guess, and it never fires while you're opening + or importing a workflow — only on nodes *you* add. Undo with Ctrl+Z. + +## Install + +Clone into `ComfyUI/custom_nodes/` and restart ComfyUI: + +``` +git clone https://github.com/ethanfel/ComfyUI-UTFCN +``` + +No Python dependencies. The node adds a single read-only server route +(`/utfcn/scan`) and a frontend extension. + +## License + +GPL-3.0-or-later. See [LICENSE](LICENSE). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0edc859 --- /dev/null +++ b/__init__.py @@ -0,0 +1,62 @@ +""" +UTFCN — Use The F***ing Core Nodes. + +A ComfyUI companion that nudges workflows back toward core nodes: + + 1. Suggests a core (or otherwise-available) equivalent when you add a custom + node that re-implements something ComfyUI already ships. + 2. Adds a "Replace custom nodes with core / available…" command + menu entry + that scans the open graph and, after a preview, swaps in equivalents. + 3. Adds a right-click "Replace with core / available" item on individual nodes. + +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 } + +Curated overrides live in mappings.json (shipped) and user_mappings.json (yours). +""" + +import os + +from aiohttp import web +from server import PromptServer + +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. +_INDEX_CACHE = None + + +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) + return _INDEX_CACHE + + +routes = PromptServer.instance.routes + + +@routes.get("/utfcn/scan") +async def utfcn_scan(request): + refresh = request.query.get("refresh") in ("1", "true", "yes") + try: + return web.json_response(_get_index(refresh)) + except Exception as e: + # never let a scan failure break the editor — the frontend degrades gracefully + print(f"[UTFCN] scan failed: {e}") + return web.json_response({"sources": {}, "candidates": {}, "stats": {}, "error": str(e)}, status=500) + + +WEB_DIRECTORY = "./web" +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] + +print(f"[UTFCN] loaded (v{VERSION}) — Use The F***ing Core Nodes") diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..2868b60 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..cedd08b --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..e082525 Binary files /dev/null and b/assets/social-preview.png differ diff --git a/assets/social-preview.svg b/assets/social-preview.svg new file mode 100644 index 0000000..39dad61 --- /dev/null +++ b/assets/social-preview.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + COMFYUI · CUSTOM NODE + UTFCN + Use The Core Nodes + Spots custom nodes that re-implement core, and + replaces them with the built-in equivalent — safely. + + + + + Suggest + + Replace + + Force mode + + + github.com/ethanfel/ComfyUI-UTFCN + + + + + + + + + + custom + + + + + core + + + + + + + + + + diff --git a/mappings.json b/mappings.json new file mode 100644 index 0000000..c2f3aae --- /dev/null +++ b/mappings.json @@ -0,0 +1,84 @@ +{ + "_readme": [ + "UTFCN curated equivalence rules (the 'verified' tier — safe to auto-apply, incl. Force mode).", + "", + "You rarely NEED rules: UTFCN already auto-detects any custom node whose input", + "name->type map and ordered output types are IDENTICAL to a core node ('exact'", + "tier), and surfaces looser 'partial' matches as suggestions. Add a rule here", + "only to (a) bless a partial match as safe to auto-apply, or (b) fix up slots", + "whose NAMES differ so the swap stays lossless.", + "", + "Schema — 'rules' maps a source node type to ONE target or an ordered list of", + "targets (first installed one wins, so put the core node first):", + "", + " \"rules\": {", + " \"SourceNodeType\": [", + " {", + " \"to\": \"TargetNodeType\",", + " \"note\": \"why this is equivalent (shown in the preview)\",", + " \"inputs\": { \"srcInputName\": \"dstInputName\" },", + " \"widgets\": { \"srcWidgetName\": \"dstWidgetName\" },", + " \"outputs\": { \"srcOutputName\": \"dstOutputName\" }", + " }", + " ]", + " }", + "", + "Any input/widget/output slot you don't list is matched by identical name, then", + "by type+order. Don't edit this file for your own rules — put those in", + "user_mappings.json (same schema); it is merged on top and survives updates.", + "", + "The rules below were mined from installed packs (ComfyUI_essentials, KJNodes,", + "was-node-suite) and verified lossless against the real core signatures. Rules", + "for nodes you don't have installed are simply ignored." + ], + + "rules": { + "GetImageSize+": [ + { + "to": "GetImageSize", + "note": "essentials Get Image Size == core Get Image Size; 'count' is core's 'batch_size'.", + "outputs": { "count": "batch_size" } + } + ], + "MaskPreview+": [ + { + "to": "MaskPreview", + "note": "essentials mask preview == core MaskPreview (same single-MASK preview node)." + } + ], + + "BOOLConstant": [ + { + "to": "PrimitiveBoolean", + "note": "KJ boolean constant == core Primitive Boolean (single BOOLEAN passthrough)." + } + ], + "INTConstant": [ + { + "to": "PrimitiveInt", + "note": "KJ int constant == core Primitive Int. Core adds a (fixed) control_after_generate; value is identical." + } + ], + "FloatConstant": [ + { + "to": "PrimitiveFloat", + "note": "KJ float constant == core Primitive Float. KJ rounds to 6 decimals; negligible for a UI constant." + } + ], + + "Convert Masks to Images": [ + { + "to": "MaskToImage", + "note": "WAS mask->image == core MaskToImage (broadcast MASK to a 3-channel IMAGE).", + "inputs": { "masks": "mask" } + } + ], + "Mask Invert": [ + { + "to": "InvertMask", + "note": "WAS mask invert == core InvertMask (1.0 - mask).", + "inputs": { "masks": "mask" } + } + ] + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0474eb6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "comfyui-utfcn" +description = "Use The F***ing Core Nodes — suggests core (or otherwise-available) equivalents for custom nodes, and replaces them across a workflow after a preview." +version = "1.0.0" +license = { text = "GPL-3.0-or-later" } +dependencies = [] + +[project.urls] +Repository = "https://github.com/ethanfel/ComfyUI-UTFCN" +Documentation = "https://github.com/ethanfel/ComfyUI-UTFCN#readme" + +[tool.comfy] +PublisherId = "ethanfel" +DisplayName = "UTFCN — Use The Core Nodes" +Icon = "https://raw.githubusercontent.com/ethanfel/ComfyUI-UTFCN/main/assets/icon.png" diff --git a/user_mappings.json b/user_mappings.json new file mode 100644 index 0000000..5e0c14a --- /dev/null +++ b/user_mappings.json @@ -0,0 +1,4 @@ +{ + "_readme": "Your own curated equivalence rules — merged on top of mappings.json (yours win). Same schema; see mappings.json's _readme. This file is safe to keep across updates.", + "rules": {} +} diff --git a/utfcn_core.py b/utfcn_core.py new file mode 100644 index 0000000..5c07cef --- /dev/null +++ b/utfcn_core.py @@ -0,0 +1,264 @@ +""" +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 diff --git a/web/utfcn.js b/web/utfcn.js new file mode 100644 index 0000000..88a769d --- /dev/null +++ b/web/utfcn.js @@ -0,0 +1,489 @@ +import { app } from "../../scripts/app.js"; + +/* + * UTFCN — Use The F***ing Core Nodes (frontend). + * + * The backend (/utfcn/scan) tells us, for every custom node type, which core (or + * other-pack) nodes could stand in for it, split into: + * verified — curated rule or an identical signature; safe to auto-apply. + * partial — structurally compatible but looser; a suggestion to confirm. + * + * This file turns that into three things: + * 1. a toast tip when you interactively drop a replaceable custom node; + * 2. a "Replace custom nodes with core / available…" command + Extensions menu + * entry that previews every swap in the open graph before applying; + * 3. a right-click "Replace with core / available" item on individual nodes. + * + * Every actual swap goes through the same engine (planSwap → applySwap): it only + * touches slots it can rewire losslessly and reports anything it can't. + */ + +const EXT = "UTFCN"; +let INDEX = null; // { sources, candidates, stats } +const shapeCache = new Map(); // targetType -> { inputs, outputs, widgetNames } | null + +/* -------------------------------------------------------------------------- */ +/* data */ +/* -------------------------------------------------------------------------- */ + +async function loadIndex(refresh = false) { + try { + const r = await app.api.fetchApi("/utfcn/scan" + (refresh ? "?refresh=1" : "")); + INDEX = await r.json(); + } catch (e) { + INDEX = { sources: {}, candidates: {}, stats: {} }; + console.error("[UTFCN] scan failed:", e); + } + if (refresh) shapeCache.clear(); + return INDEX; +} + +const sourceInfo = (type) => INDEX?.sources?.[type]; +const isCustom = (type) => sourceInfo(type)?.source === "custom"; +const candidatesFor = (type) => INDEX?.candidates?.[type] || []; + +function toast(severity, detail, life = 5000) { + try { app.extensionManager?.toast?.add?.({ severity, summary: EXT, detail, life }); } + catch { /* older ComfyUI: no toast API */ } + if (severity === "error") console.error("[UTFCN]", detail); +} + +/* -------------------------------------------------------------------------- */ +/* swap engine */ +/* -------------------------------------------------------------------------- */ + +/** True if two slot type strings can be connected (handles "*" and "A,B" unions). */ +function typeOk(a, b) { + if (a == null || b == null) return false; + if (a === "*" || b === "*" || a === "" || b === "") return true; + const A = String(a).split(","), B = String(b).split(","); + return A.some((x) => B.includes(x)); +} + +/** A widget the user converted into an input slot — its value lives on the input, not the widget. */ +const isConvertedWidget = (w) => w?.type === "converted-widget" || w?.type === "hidden"; + +/** Inspect a target type's slot/widget layout once (creating a throwaway node) and cache it. */ +function targetShape(type) { + if (shapeCache.has(type)) return shapeCache.get(type); + let node = null; + try { node = window.LiteGraph.createNode(type); } catch { /* unregistered */ } + const shape = node && { + inputs: (node.inputs || []).map((s) => ({ name: s.name, type: s.type })), + outputs: (node.outputs || []).map((s) => ({ name: s.name, type: s.type })), + widgetNames: (node.widgets || []).map((w) => w.name), + }; + shapeCache.set(type, shape || null); + return shape || null; +} + +/** + * Work out exactly how `node` would map onto `targetType`, honouring an optional + * curated `rule` (name remaps). Only *connected* inputs and *linked* outputs must + * map — an unmappable one is a hard problem; a dropped widget value is a warning. + */ +function planSwap(node, targetType, rule) { + const shape = targetShape(targetType); + if (!shape) return { ok: false, problems: [`“${targetType}” is not available`], warns: [], targetType }; + + const problems = [], warns = [], inMap = [], outMap = [], wMap = []; + const usedIn = new Set(), usedOut = new Set(); + + (node.inputs || []).forEach((inp, i) => { + if (inp.link == null) return; // unconnected → nothing to carry + const want = rule?.inputs?.[inp.name] ?? inp.name; + let j = shape.inputs.findIndex((s, k) => !usedIn.has(k) && s.name === want); + if (j < 0) j = shape.inputs.findIndex((s, k) => !usedIn.has(k) && typeOk(inp.type, s.type)); + if (j < 0) { problems.push(`input “${inp.name}” (${inp.type}) has no match`); return; } + if (!typeOk(inp.type, shape.inputs[j].type)) { problems.push(`input “${inp.name}”: ${inp.type} ≠ ${shape.inputs[j].type}`); return; } + usedIn.add(j); inMap.push({ src: i, dst: j }); + }); + + (node.outputs || []).forEach((out, i) => { + const links = (out.links || []).length; + if (!links) return; // no downstream → nothing to carry + const want = rule?.outputs?.[out.name] ?? out.name; + let j = shape.outputs.findIndex((s, k) => !usedOut.has(k) && s.name === want); + if (j < 0) j = shape.outputs.findIndex((s, k) => !usedOut.has(k) && typeOk(out.type, s.type)); + if (j < 0) { problems.push(`output “${out.name}” (${out.type}, ${links} link${links > 1 ? "s" : ""}) has no match`); return; } + if (!typeOk(shape.outputs[j].type, out.type)) { problems.push(`output “${out.name}”: ${out.type} ≠ ${shape.outputs[j].type}`); return; } + usedOut.add(j); outMap.push({ src: i, dst: j }); + }); + + (node.widgets || []).forEach((w) => { + if (w.name == null || isConvertedWidget(w)) return; + const want = rule?.widgets?.[w.name] ?? w.name; + if (shape.widgetNames.includes(want)) wMap.push({ from: w.name, to: want }); + else if (w.value !== undefined && w.value !== null && w.value !== "") warns.push(`widget “${w.name}” value not carried`); + }); + + return { ok: problems.length === 0, problems, warns, inMap, outMap, wMap, targetType }; +} + +/** Perform the swap described by `plan`: create the target, move links + widget values, delete the source. Returns the new node (or null). */ +function applySwap(node, plan, rule) { + const graph = node.graph; + if (!graph || !plan.ok) return null; + + graph.beforeChange?.(); + const t = window.LiteGraph.createNode(plan.targetType); + if (!t) { graph.afterChange?.(); return null; } + graph.add(t); + t.pos = [node.pos[0], node.pos[1]]; + if (node.color) t.color = node.color; + if (node.bgcolor) t.bgcolor = node.bgcolor; + + // widget values first (setting them may lay out extra widgets) + plan.wMap.forEach((m) => { + const sw = (node.widgets || []).find((w) => w.name === m.from); + const tw = (t.widgets || []).find((w) => w.name === m.to); + if (sw && tw && sw.value !== undefined) { tw.value = sw.value; try { tw.callback?.(tw.value); } catch {} } + }); + + // snapshot link records BEFORE we start mutating the graph + const inLinks = plan.inMap + .map((m) => ({ dst: m.dst, l: graph.links[node.inputs[m.src].link] })) + .filter((x) => x.l); + const outLinks = []; + plan.outMap.forEach((m) => { + (node.outputs[m.src].links || []).slice().forEach((id) => { + const l = graph.links[id]; + if (l) outLinks.push({ dst: m.dst, l }); + }); + }); + + // upstream → target + inLinks.forEach(({ dst, l }) => graph.getNodeById(l.origin_id)?.connect(l.origin_slot, t, dst)); + // target → downstream + outLinks.forEach(({ dst, l }) => { const d = graph.getNodeById(l.target_id); if (d) t.connect(dst, d, l.target_slot); }); + + graph.remove(node); + graph.afterChange?.(); + app.canvas?.setDirty(true, true); + return t; +} + +/** First verified candidate whose swap is feasible right now (used by force mode). */ +function firstVerifiedPlan(node) { + for (const c of candidatesFor(node.type)) { + if (!c.verified) continue; + const plan = planSwap(node, c.to, c); + if (plan.ok) return { cand: c, plan }; + } + return null; +} + +/* -------------------------------------------------------------------------- */ +/* preview dialog */ +/* -------------------------------------------------------------------------- */ + +function injectStyle() { + if (document.getElementById("utfcn-style")) return; + const s = document.createElement("style"); + s.id = "utfcn-style"; + s.textContent = ` + .utfcn-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:10000;display:flex;align-items:center;justify-content:center;font-family:sans-serif} + .utfcn-modal{background:var(--comfy-menu-bg,#202020);color:var(--fg-color,#ddd);border:1px solid #444;border-radius:8px;max-width:820px;width:92%;max-height:82vh;display:flex;flex-direction:column;box-shadow:0 8px 40px rgba(0,0,0,.5)} + .utfcn-modal h2{margin:0;padding:14px 18px;font-size:15px;border-bottom:1px solid #3a3a3a;display:flex;gap:8px;align-items:baseline} + .utfcn-modal h2 small{color:#888;font-weight:400;font-size:12px} + .utfcn-body{overflow:auto;padding:6px 0} + .utfcn-body table{width:100%;border-collapse:collapse;font-size:12.5px} + .utfcn-body td,.utfcn-body th{padding:6px 12px;text-align:left;border-bottom:1px solid #2e2e2e;vertical-align:middle} + .utfcn-body th{position:sticky;top:0;background:var(--comfy-menu-bg,#202020);color:#9aa;font-weight:600;z-index:1} + .utfcn-body tr.dis{opacity:.5} + .utfcn-arrow{color:#666;padding:0 2px} + .utfcn-from{color:#e0a}.utfcn-to{color:#6c9} + .utfcn-pack{color:#888;font-size:11px} + .utfcn-badge{font-size:11px;padding:1px 6px;border-radius:4px;white-space:nowrap} + .utfcn-ok{background:#1e3a24;color:#8fdca0}.utfcn-warn{background:#3a331e;color:#e6cf7a}.utfcn-no{background:#3a1e1e;color:#e69a9a} + .utfcn-modal select{background:#111;color:#ddd;border:1px solid #444;border-radius:4px;padding:2px 4px;max-width:260px} + .utfcn-foot{display:flex;gap:10px;justify-content:space-between;align-items:center;padding:12px 18px;border-top:1px solid #3a3a3a} + .utfcn-foot .sp{color:#888;font-size:12px} + .utfcn-btn{background:#333;color:#eee;border:1px solid #555;border-radius:6px;padding:7px 16px;cursor:pointer;font-size:13px} + .utfcn-btn:hover{background:#3d3d3d} + .utfcn-btn.primary{background:#2d6cdf;border-color:#2d6cdf}.utfcn-btn.primary:hover{background:#3b78e7} + .utfcn-btn:disabled{opacity:.5;cursor:not-allowed} + `; + document.head.appendChild(s); +} + +/** + * Show the preview table for `rows` ([{node, cands}]) and apply the ones the user keeps checked. + * Verified + feasible swaps start checked; partials and infeasible ones don't. + */ +function showPreview(rows) { + injectStyle(); + + // per-row UI state: chosen candidate index + its plan + const state = rows.map(({ node, cands }) => { + let sel = cands.findIndex((c) => c.verified && planSwap(node, c.to, c).ok); + if (sel < 0) sel = cands.findIndex((c) => planSwap(node, c.to, c).ok); + if (sel < 0) sel = 0; + return { sel }; + }); + + const overlay = document.createElement("div"); + overlay.className = "utfcn-overlay"; + overlay.innerHTML = ` +
+

🔁 UTFCN — Replace with core / available ${rows.length} candidate node${rows.length === 1 ? "" : "s"} in this workflow

+
+ + +
NodeReplace withStatus
+
+ + +
+
`; + + const tbody = overlay.querySelector("tbody"); + const summary = overlay.querySelector(".sp"); + const applyBtn = overlay.querySelector(".apply"); + const close = () => overlay.remove(); + + function planForRow(i) { + const { node, cands } = rows[i]; + const c = cands[state[i].sel]; + return { c, plan: c ? planSwap(node, c.to, c) : { ok: false, problems: ["no candidate"], warns: [] } }; + } + + function renderRow(i) { + const { c, plan } = planForRow(i); + const tr = tbody.children[i]; + const cb = tr.querySelector("input[type=checkbox]"); + const status = tr.querySelector(".utfcn-status"); + + cb.disabled = !plan.ok; + tr.classList.toggle("dis", !plan.ok); + if (!plan.ok) { + cb.checked = false; + status.innerHTML = `✗ ${plan.problems[0]}`; + } else if (c.verified) { + status.innerHTML = `✓ ${c.tier === "curated" ? "curated" : "exact match"}` + + (plan.warns.length ? ` ${plan.warns.length} note` : ""); + } else { + status.innerHTML = `⚠ heuristic ${(c.score * 100) | 0}%`; + } + } + + rows.forEach(({ node, cands }, i) => { + const info = sourceInfo(node.type); + const opts = cands.map((c, k) => + ``).join(""); + const tr = document.createElement("tr"); + tr.innerHTML = ` + + ${node.title || node.type} #${node.id} · ${info?.pack || "?"} + + `; + tbody.appendChild(tr); + + const sel = tr.querySelector("select"); + sel.value = String(state[i].sel); + sel.addEventListener("change", () => { state[i].sel = +sel.value; renderRow(i); updateSummary(); }); + tr.querySelector("input[type=checkbox]").addEventListener("change", updateSummary); + }); + + function updateSummary() { + let checked = 0; + tbody.querySelectorAll("input[type=checkbox]").forEach((cb) => { if (cb.checked) checked++; }); + summary.textContent = `${checked} of ${rows.length} selected`; + applyBtn.disabled = checked === 0; + } + + // initial render + default-check verified feasible rows + rows.forEach((_, i) => { + renderRow(i); + const { c, plan } = planForRow(i); + tbody.children[i].querySelector("input[type=checkbox]").checked = !!(plan.ok && c?.verified); + }); + updateSummary(); + + overlay.querySelector(".cancel").addEventListener("click", close); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + applyBtn.addEventListener("click", () => { + let done = 0, failed = 0, notes = 0; + rows.forEach((row, i) => { + const cb = tbody.children[i].querySelector("input[type=checkbox]"); + if (!cb.checked) return; + const { c, plan } = planForRow(i); + if (applySwap(row.node, plan, c)) { done++; notes += plan.warns.length; } else failed++; + }); + close(); + if (done) toast("success", `Replaced ${done} node${done === 1 ? "" : "s"}${notes ? ` · ${notes} widget value(s) not carried` : ""}`); + if (failed) toast("error", `${failed} replacement(s) failed`); + if (!done && !failed) toast("info", "Nothing was selected"); + }); + + document.body.appendChild(overlay); +} + +/* -------------------------------------------------------------------------- */ +/* feature 2: bulk replace (command + menu) */ +/* -------------------------------------------------------------------------- */ + +async function openBulkDialog() { + if (!INDEX) await loadIndex(); + const rows = []; + for (const node of app.graph?._nodes || []) { + if (!isCustom(node.type)) continue; + const cands = candidatesFor(node.type); + if (cands.length) rows.push({ node, cands }); + } + if (!rows.length) { toast("info", "No custom nodes with a known core / available equivalent here 🎉"); return; } + showPreview(rows); +} + +/* -------------------------------------------------------------------------- */ +/* feature 3: single-node right-click */ +/* -------------------------------------------------------------------------- */ + +function replaceSingle(node, cand) { + const plan = planSwap(node, cand.to, cand); + if (!plan.ok) { toast("warn", `Can't replace “${node.title || node.type}”: ${plan.problems[0]}`); return; } + if (applySwap(node, plan, cand)) { + toast("success", `Replaced with ${cand.to_display}${plan.warns.length ? ` · ${plan.warns.length} widget value(s) not carried` : ""}`); + } else { + toast("error", "Replacement failed"); + } +} + +function addContextMenu(nodeType) { + const orig = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (canvas, options) { + orig?.apply(this, arguments); + try { + if (!isCustom(this.type)) return; + const cands = candidatesFor(this.type); + if (!cands.length) return; + const submenu = cands.map((c) => ({ + content: `${c.verified ? "✓" : "⚠"} ${c.to_display} ${c.source === "core" ? "(core)" : "(" + c.pack + ")"}`, + callback: () => replaceSingle(this, c), + })); + options.push(null); // separator + options.push({ content: "🔁 Replace with core / available", has_submenu: true, submenu: { options: submenu } }); + } catch (e) { console.error("[UTFCN] menu error:", e); } + }; +} + +/* -------------------------------------------------------------------------- */ +/* feature 1: on add — Off / Suggest / Force */ +/* -------------------------------------------------------------------------- */ + +// "Off" | "Suggest" | "Force (auto-replace with core)" +let ADD_MODE = "Suggest"; +let loadingGraph = false; +let addQueue = [], addTimer = null; +const isForce = () => ADD_MODE.startsWith("Force"); + +// Never act while a workflow is loading — force mode must not silently rewrite +// graphs the user opens/imports; it only touches nodes they add themselves. +function guardGraphLoading() { + const orig = app.loadGraphData?.bind(app); + if (!orig) return; + app.loadGraphData = async function (...a) { + loadingGraph = true; + try { return await orig(...a); } + finally { setTimeout(() => { loadingGraph = false; }, 150); } + }; +} + +function onNodeAdded(node) { + if (loadingGraph || ADD_MODE === "Off") return; + if (!isCustom(node.type) || !candidatesFor(node.type).length) return; + addQueue.push(node); + clearTimeout(addTimer); + addTimer = setTimeout(flushAdds, 250); // let the add settle, and batch pastes +} + +function flushAdds() { + const nodes = addQueue.filter((n) => n?.graph); // still in the graph + addQueue = []; + if (!nodes.length) return; + + if (isForce()) { + // auto-swap only VERIFIED candidates — heuristics are never applied silently + let swapped = 0, last = null; + for (const node of nodes) { + const pick = firstVerifiedPlan(node); + if (!pick) continue; + const t = applySwap(node, pick.plan, pick.cand); + if (t) { swapped++; last = t; } + } + if (swapped) { + if (last) try { app.canvas?.selectNode?.(last); } catch {} + toast("success", `Force mode: switched ${swapped} node${swapped === 1 ? "" : "s"} to core / available`); + } + return; + } + + // Suggest mode: one quiet tip per unique type (stay silent on big pastes) + const types = [...new Set(nodes.map((n) => n.type))]; + if (types.length > 4) return; + types.forEach((tp) => { + const cands = candidatesFor(tp); + const best = cands.find((c) => c.verified) || cands[0]; + if (!best) return; + const where = best.source === "core" ? "a core node" : `“${best.pack}”`; + toast("info", `“${sourceInfo(tp)?.display || tp}” has ${where} equivalent: “${best.to_display}”. Right-click ▸ Replace with core / available.`, 7000); + }); +} + +function hookNodeAdded() { + const g = app.graph; + if (!g || g.__utfcn_hooked) return; + g.__utfcn_hooked = true; + const prev = g.onNodeAdded; + g.onNodeAdded = function (node) { + prev?.call(this, node); + try { onNodeAdded(node); } catch {} + }; +} + +/* -------------------------------------------------------------------------- */ +/* registration */ +/* -------------------------------------------------------------------------- */ + +app.registerExtension({ + name: "utfcn.core", + + settings: [ + { + id: "UTFCN.onAdd", + name: "When adding a custom node that has a core / available equivalent", + tooltip: "Off: do nothing. Suggest: show a tip. Force: automatically replace it with the equivalent (verified matches only).", + category: ["UTFCN", "On add", "mode"], + type: "combo", + options: ["Off", "Suggest", "Force (auto-replace with core)"], + defaultValue: "Suggest", + onChange: (v) => { if (v) ADD_MODE = v; }, + }, + ], + + commands: [ + { id: "UTFCN.replaceAll", label: "UTFCN: Replace custom nodes with core / available…", function: openBulkDialog }, + { + id: "UTFCN.refresh", label: "UTFCN: Refresh equivalence index", + function: async () => { await loadIndex(true); toast("success", `Index refreshed · ${INDEX?.stats?.replaceable ?? 0} replaceable node type(s)`); }, + }, + ], + + menuCommands: [ + { path: ["Extensions", "UTFCN"], commands: ["UTFCN.replaceAll", "UTFCN.refresh"] }, + ], + + // installed for every node type; the body no-ops unless the node is a custom + // node that actually has a candidate (checked live at click time). + beforeRegisterNodeDef(nodeType) { + addContextMenu(nodeType); + }, + + async setup() { + await loadIndex(); + guardGraphLoading(); + hookNodeAdded(); + const s = INDEX?.stats; + if (s?.replaceable) console.log(`[UTFCN] ${s.replaceable}/${s.custom} custom node type(s) have a core/available equivalent (${s.verified} verified).`); + }, +});