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>
This commit is contained in:
2026-07-02 10:26:30 +02:00
commit 16f4e93a3a
13 changed files with 1141 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.DS_Store
*.log
+17
View File
@@ -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 <https://www.gnu.org/licenses/>.
SPDX-License-Identifier: GPL-3.0-or-later
The full license text is available at: https://www.gnu.org/licenses/gpl-3.0.txt
+106
View File
@@ -0,0 +1,106 @@
<p align="center">
<img src="assets/social-preview.png" alt="UTFCN — Use The Core Nodes" width="100%">
</p>
# 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).
+62
View File
@@ -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")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#15161a"/>
<stop offset="1" stop-color="#202127"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.42" r="0.62">
<stop offset="0" stop-color="#34d399" stop-opacity="0.30"/>
<stop offset="1" stop-color="#34d399" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<rect width="256" height="256" rx="56" fill="url(#glow)"/>
<rect x="6" y="6" width="244" height="244" rx="50" fill="none" stroke="#34d399" stroke-opacity="0.45" stroke-width="3"/>
<!-- custom node (amber) — the one being replaced -->
<rect x="30" y="92" width="60" height="72" rx="16" fill="#e0a244" fill-opacity="0.12" stroke="#e0a244" stroke-width="8" stroke-linejoin="round"/>
<circle cx="90" cy="112" r="5" fill="#e0a244"/>
<circle cx="90" cy="144" r="5" fill="#e0a244"/>
<!-- core node (green) — the replacement -->
<rect x="166" y="92" width="60" height="72" rx="16" fill="#34d399" fill-opacity="0.16" stroke="#5be08a" stroke-width="8" stroke-linejoin="round"/>
<path d="M181 129 l11 12 l18 -23" fill="none" stroke="#5be08a" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<!-- swap arrows in the gap: custom -> core, core -> custom -->
<g stroke="#e8eaed" stroke-width="9" stroke-linecap="round">
<line x1="102" y1="112" x2="150" y2="112"/>
<line x1="154" y1="146" x2="106" y2="146"/>
</g>
<path d="M150 100 L166 112 L150 124 Z" fill="#e8eaed"/>
<path d="M106 134 L90 146 L106 158 Z" fill="#e8eaed"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+63
View File
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" font-family="'DejaVu Sans','Noto Sans',sans-serif">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#15161a"/>
<stop offset="1" stop-color="#202127"/>
</linearGradient>
<radialGradient id="glow" cx="0.74" cy="0.42" r="0.6">
<stop offset="0" stop-color="#34d399" stop-opacity="0.28"/>
<stop offset="1" stop-color="#34d399" stop-opacity="0"/>
</radialGradient>
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#5be08a"/>
<stop offset="1" stop-color="#37c8c3"/>
</linearGradient>
</defs>
<rect width="1280" height="640" fill="url(#bg)"/>
<rect width="1280" height="640" fill="url(#glow)"/>
<rect x="8" y="8" width="1264" height="624" rx="28" fill="none" stroke="#34d399" stroke-opacity="0.22" stroke-width="2"/>
<!-- ==== left: text ==== -->
<text x="92" y="150" fill="#7f8797" font-size="24" letter-spacing="6" font-weight="600">COMFYUI · CUSTOM NODE</text>
<text x="86" y="286" fill="url(#title)" font-size="150" font-weight="800" letter-spacing="2">UTFCN</text>
<text x="92" y="346" fill="#e8eaed" font-size="46" font-weight="700">Use The Core Nodes</text>
<text x="92" y="402" fill="#9aa0ac" font-size="25">Spots custom nodes that re-implement core, and</text>
<text x="92" y="436" fill="#9aa0ac" font-size="25">replaces them with the built-in equivalent — safely.</text>
<!-- feature pills -->
<g font-size="22" font-weight="600" text-anchor="middle">
<rect x="92" y="474" width="132" height="48" rx="24" fill="#34d399" fill-opacity="0.12" stroke="#34d399" stroke-opacity="0.45" stroke-width="2"/>
<text x="158" y="505" fill="#cfead9">Suggest</text>
<rect x="240" y="474" width="132" height="48" rx="24" fill="#34d399" fill-opacity="0.12" stroke="#34d399" stroke-opacity="0.45" stroke-width="2"/>
<text x="306" y="505" fill="#cfead9">Replace</text>
<rect x="388" y="474" width="176" height="48" rx="24" fill="#34d399" fill-opacity="0.12" stroke="#34d399" stroke-opacity="0.45" stroke-width="2"/>
<text x="476" y="505" fill="#cfead9">Force mode</text>
</g>
<text x="92" y="576" fill="#6f7684" font-size="23">github.com/ethanfel/ComfyUI-UTFCN</text>
<!-- ==== right: node-swap vignette ==== -->
<g>
<rect x="806" y="188" width="404" height="304" rx="30" fill="#ffffff" fill-opacity="0.03" stroke="#34d399" stroke-opacity="0.16" stroke-width="2"/>
<!-- custom node (amber) -->
<rect x="838" y="262" width="140" height="140" rx="20" fill="#e0a244" fill-opacity="0.12" stroke="#e0a244" stroke-width="7" stroke-linejoin="round"/>
<circle cx="978" cy="300" r="6" fill="#e0a244"/>
<circle cx="978" cy="364" r="6" fill="#e0a244"/>
<text x="908" y="442" fill="#e0a244" font-size="26" font-weight="700" text-anchor="middle">custom</text>
<!-- core node (green) -->
<rect x="1038" y="262" width="140" height="140" rx="20" fill="#34d399" fill-opacity="0.16" stroke="#5be08a" stroke-width="7" stroke-linejoin="round"/>
<path d="M1086 332 l16 17 l26 -33" fill="none" stroke="#5be08a" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<text x="1108" y="442" fill="#5be08a" font-size="26" font-weight="700" text-anchor="middle">core</text>
<!-- swap arrows in the gap (custom -> core, core -> custom) -->
<g stroke="#e8eaed" stroke-width="8" stroke-linecap="round">
<line x1="984" y1="306" x2="1020" y2="306"/>
<line x1="1032" y1="358" x2="996" y2="358"/>
</g>
<path d="M1020 295 L1034 306 L1020 317 Z" fill="#e8eaed"/>
<path d="M996 347 L982 358 L996 369 Z" fill="#e8eaed"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+84
View File
@@ -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" }
}
]
}
}
+15
View File
@@ -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"
+4
View File
@@ -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": {}
}
+264
View File
@@ -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
+489
View File
@@ -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 = `
<div class="utfcn-modal">
<h2>🔁 UTFCN — Replace with core / available <small>${rows.length} candidate node${rows.length === 1 ? "" : "s"} in this workflow</small></h2>
<div class="utfcn-body"><table>
<thead><tr><th></th><th>Node</th><th>Replace with</th><th>Status</th></tr></thead>
<tbody></tbody>
</table></div>
<div class="utfcn-foot">
<span class="sp"></span>
<span><button class="utfcn-btn cancel">Cancel</button> <button class="utfcn-btn primary apply">Apply selected</button></span>
</div>
</div>`;
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 = `<span class="utfcn-badge utfcn-no">✗ ${plan.problems[0]}</span>`;
} else if (c.verified) {
status.innerHTML = `<span class="utfcn-badge utfcn-ok">✓ ${c.tier === "curated" ? "curated" : "exact match"}</span>` +
(plan.warns.length ? ` <span class="utfcn-badge utfcn-warn">${plan.warns.length} note</span>` : "");
} else {
status.innerHTML = `<span class="utfcn-badge utfcn-warn">⚠ heuristic ${(c.score * 100) | 0}%</span>`;
}
}
rows.forEach(({ node, cands }, i) => {
const info = sourceInfo(node.type);
const opts = cands.map((c, k) =>
`<option value="${k}">${c.verified ? "✓" : "⚠"} ${c.to_display} · ${c.source === "core" ? "core" : c.pack}</option>`).join("");
const tr = document.createElement("tr");
tr.innerHTML = `
<td><input type="checkbox"></td>
<td><span class="utfcn-from">${node.title || node.type}</span> <span class="utfcn-pack">#${node.id} · ${info?.pack || "?"}</span></td>
<td><span class="utfcn-arrow">→</span> <select>${opts}</select></td>
<td class="utfcn-status"></td>`;
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).`);
},
});