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:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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 |
@@ -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 |
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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).`);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user