Support uninstalled / missing nodes
Previously the index was built only from the live registry, so a custom node that wasn't installed (a red "missing" node in a downloaded workflow) was invisible — the main point of the tool. Now: - Backend: utfcn_core split into build_context / build_index / match. build_index also emits curated candidates for uninstalled source types (curated-only), and a new POST /utfcn/match matches missing nodes by their serialized signature against installed core/other-pack nodes. - Frontend: nodeType() reads a missing placeholder's last_serialization.type; matchMissing() feeds serialized slots to /utfcn/match and merges the results; the right-click item moved to a canvas-level getNodeMenuOptions patch so it reaches missing placeholders too. Bulk dialog labels them "not installed". Replace a missing node with core without installing its pack. Links are rewired losslessly; widget values can't be carried for a node whose def is absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,17 @@ It does three things:
|
|||||||
Nothing is ever swapped without your say-so, and the engine only rewires slots
|
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.
|
it can move *losslessly* — anything it can't map is reported, not guessed.
|
||||||
|
|
||||||
|
### Works on uninstalled ("missing") nodes
|
||||||
|
|
||||||
|
The headline case: open a downloaded workflow full of red **missing** nodes and
|
||||||
|
replace them with core equivalents **without installing the packs at all**.
|
||||||
|
ComfyUI keeps each missing node as a placeholder that remembers its original
|
||||||
|
type and wiring, so UTFCN can still match and swap it — via a curated rule (by
|
||||||
|
name) or by matching the node's *serialized* signature against your core nodes.
|
||||||
|
Both "Replace…" and the right-click item work on them; the bulk dialog labels
|
||||||
|
them `⚠ not installed`. (Widget values aren't carried for a node whose
|
||||||
|
definition you don't have — links are.)
|
||||||
|
|
||||||
## How it decides what's equivalent
|
## How it decides what's equivalent
|
||||||
|
|
||||||
The backend reads the live node registry (real `INPUT_TYPES` / `RETURN_TYPES`
|
The backend reads the live node registry (real `INPUT_TYPES` / `RETURN_TYPES`
|
||||||
@@ -37,6 +48,10 @@ and each node's source module) and ranks candidates in three tiers:
|
|||||||
"Available" means core is preferred, and if there's no core match it will offer
|
"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.
|
an equivalent from a **different installed pack** as a fallback.
|
||||||
|
|
||||||
|
For an **uninstalled** node only *curated* (by name) and *partial* (by its
|
||||||
|
serialized link signature) can apply — the exact tier needs the widget-level
|
||||||
|
signature, which a node you haven't installed can't provide.
|
||||||
|
|
||||||
## Shipped equivalences
|
## Shipped equivalences
|
||||||
|
|
||||||
`mappings.json` ships a small, hand-verified set (each checked lossless against
|
`mappings.json` ships a small, hand-verified set (each checked lossless against
|
||||||
|
|||||||
+28
-4
@@ -13,7 +13,9 @@ All of that is frontend behaviour (see web/utfcn.js). This backend only serves
|
|||||||
the *analysis*: it has the live node registry, so it computes — accurately, from
|
the *analysis*: it has the live node registry, so it computes — accurately, from
|
||||||
real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents.
|
real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents.
|
||||||
|
|
||||||
GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats }
|
GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats }
|
||||||
|
POST /utfcn/match {nodes:[{type,inputs,outputs,output_names}]} -> { candidates }
|
||||||
|
(for UNINSTALLED / missing nodes in a workflow)
|
||||||
|
|
||||||
Curated overrides live in mappings.json (shipped) and user_mappings.json (yours).
|
Curated overrides live in mappings.json (shipped) and user_mappings.json (yours).
|
||||||
"""
|
"""
|
||||||
@@ -28,15 +30,23 @@ from . import utfcn_core
|
|||||||
VERSION = "1.0.0"
|
VERSION = "1.0.0"
|
||||||
_DIR = os.path.dirname(os.path.realpath(__file__))
|
_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
|
||||||
# The scan walks the whole registry, so we cache it and only rebuild on demand.
|
# Snapshotting the registry is the expensive part, so we cache the context and
|
||||||
|
# the derived scan index, rebuilding only on ?refresh=1.
|
||||||
|
_CTX_CACHE = None
|
||||||
_INDEX_CACHE = None
|
_INDEX_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ctx(refresh=False):
|
||||||
|
global _CTX_CACHE
|
||||||
|
if refresh or _CTX_CACHE is None:
|
||||||
|
_CTX_CACHE = utfcn_core.build_context(utfcn_core.load_rules(_DIR))
|
||||||
|
return _CTX_CACHE
|
||||||
|
|
||||||
|
|
||||||
def _get_index(refresh=False):
|
def _get_index(refresh=False):
|
||||||
global _INDEX_CACHE
|
global _INDEX_CACHE
|
||||||
if refresh or _INDEX_CACHE is None:
|
if refresh or _INDEX_CACHE is None:
|
||||||
rules = utfcn_core.load_rules(_DIR)
|
_INDEX_CACHE = utfcn_core.build_index(_get_ctx(refresh))
|
||||||
_INDEX_CACHE = utfcn_core.build_index(rules)
|
|
||||||
return _INDEX_CACHE
|
return _INDEX_CACHE
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +64,20 @@ async def utfcn_scan(request):
|
|||||||
return web.json_response({"sources": {}, "candidates": {}, "stats": {}, "error": str(e)}, status=500)
|
return web.json_response({"sources": {}, "candidates": {}, "stats": {}, "error": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/utfcn/match")
|
||||||
|
async def utfcn_match(request):
|
||||||
|
"""Match uninstalled/missing nodes by their serialized signature."""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"candidates": {}, "error": "invalid json"}, status=400)
|
||||||
|
try:
|
||||||
|
return web.json_response({"candidates": utfcn_core.match(_get_ctx(), data.get("nodes") or [])})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UTFCN] match failed: {e}")
|
||||||
|
return web.json_response({"candidates": {}, "error": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web"
|
WEB_DIRECTORY = "./web"
|
||||||
NODE_CLASS_MAPPINGS = {}
|
NODE_CLASS_MAPPINGS = {}
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||||
|
|||||||
+98
-38
@@ -149,18 +149,15 @@ def load_rules(base_dir):
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def build_index(rules):
|
def build_context(rules):
|
||||||
"""
|
"""
|
||||||
Build the full equivalence index for the current node registry.
|
Snapshot the live node registry once (signatures + source of every node).
|
||||||
|
|
||||||
|
Returned context is reused by build_index() (the /utfcn/scan payload) and by
|
||||||
|
match() (per-workflow matching of UNINSTALLED nodes), so the expensive walk
|
||||||
|
only happens on refresh.
|
||||||
|
|
||||||
`rules` is the merged curated mapping: {sourceType: [ {to, note, inputs, widgets, outputs}, ... ]}.
|
`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
|
import nodes # imported here so the module stays importable outside ComfyUI
|
||||||
|
|
||||||
@@ -170,8 +167,7 @@ def build_index(rules):
|
|||||||
sources, sigs = {}, {}
|
sources, sigs = {}, {}
|
||||||
for name, cls in classes.items():
|
for name, cls in classes.items():
|
||||||
module = _module_of(cls)
|
module = _module_of(cls)
|
||||||
kind = _source_kind(module)
|
sources[name] = {"source": _source_kind(module), "pack": _pack_of(module), "display": displays.get(name, name)}
|
||||||
sources[name] = {"source": kind, "pack": _pack_of(module), "display": displays.get(name, name)}
|
|
||||||
sigs[name] = _signature(cls)
|
sigs[name] = _signature(cls)
|
||||||
|
|
||||||
# Bucket every potential *target* by its first output type so a source only
|
# Bucket every potential *target* by its first output type so a source only
|
||||||
@@ -180,32 +176,38 @@ def build_index(rules):
|
|||||||
for name in classes:
|
for name in classes:
|
||||||
by_out[_first_output_type(sigs[name])].append(name)
|
by_out[_first_output_type(sigs[name])].append(name)
|
||||||
|
|
||||||
candidates = {}
|
return {"sources": sources, "sigs": sigs, "by_out": by_out, "rules": rules}
|
||||||
verified_count = 0
|
|
||||||
for src_name, meta in sources.items():
|
|
||||||
if meta["source"] != "custom":
|
def _candidates_for(src_name, src_sig, src_pack, ctx):
|
||||||
|
"""
|
||||||
|
Rank replacement candidates for one source node.
|
||||||
|
|
||||||
|
`src_sig` may be None (an uninstalled node we know only by name) — then only
|
||||||
|
curated rules apply. If a signature is given (installed node, or a missing
|
||||||
|
node's serialized signature), exact/partial tiers are added too.
|
||||||
|
`src_pack` is None for uninstalled/unknown sources (skips same-pack exclusion).
|
||||||
|
"""
|
||||||
|
sources, sigs, by_out, rules = ctx["sources"], ctx["sigs"], ctx["by_out"], ctx["rules"]
|
||||||
|
found, seen = [], set()
|
||||||
|
|
||||||
|
# --- tier 1: curated rules (ordered preference; core-first is the author's job) ---
|
||||||
|
for rule in rules.get(src_name, []):
|
||||||
|
to = rule.get("to")
|
||||||
|
if not to or to == src_name or to not in sources or to in seen:
|
||||||
continue
|
continue
|
||||||
src_sig = sigs[src_name]
|
seen.add(to)
|
||||||
src_pack = meta["pack"]
|
found.append(_candidate(to, sources, "curated", 1.0, rule))
|
||||||
found, seen = [], set()
|
|
||||||
|
|
||||||
# --- tier 1: curated rules (ordered preference; core-first is the author's job) ---
|
# --- tiers 2 & 3: signature matching within the same output bucket ---
|
||||||
for rule in rules.get(src_name, []):
|
if src_sig is not None:
|
||||||
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 = []
|
ranked = []
|
||||||
for cand_name in bucket:
|
for cand_name in by_out.get(_first_output_type(src_sig), []):
|
||||||
if cand_name in seen or cand_name == src_name:
|
if cand_name in seen or cand_name == src_name:
|
||||||
continue
|
continue
|
||||||
cand_meta = sources[cand_name]
|
cand_meta = sources[cand_name]
|
||||||
# target must be core, or a DIFFERENT installed pack (fallback-to-available)
|
# target must be core, or a DIFFERENT installed pack (fallback-to-available)
|
||||||
if cand_meta["source"] == "custom" and cand_meta["pack"] == src_pack:
|
if cand_meta["source"] == "custom" and src_pack is not None and cand_meta["pack"] == src_pack:
|
||||||
continue
|
continue
|
||||||
cand_sig = sigs[cand_name]
|
cand_sig = sigs[cand_name]
|
||||||
if not _feasible(src_sig, cand_sig):
|
if not _feasible(src_sig, cand_sig):
|
||||||
@@ -217,11 +219,10 @@ def build_index(rules):
|
|||||||
if sc >= _PARTIAL_THRESHOLD:
|
if sc >= _PARTIAL_THRESHOLD:
|
||||||
ranked.append((cand_name, "partial", sc))
|
ranked.append((cand_name, "partial", sc))
|
||||||
|
|
||||||
# order: core before pack; exact before partial; higher score first
|
|
||||||
ranked.sort(key=lambda r: (
|
ranked.sort(key=lambda r: (
|
||||||
0 if sources[r[0]]["source"] == "core" else 1,
|
0 if sources[r[0]]["source"] == "core" else 1, # core before pack
|
||||||
0 if r[1] == "exact" else 1,
|
0 if r[1] == "exact" else 1, # exact before partial
|
||||||
-r[2],
|
-r[2], # higher score first
|
||||||
))
|
))
|
||||||
for cand_name, tier, sc in ranked:
|
for cand_name, tier, sc in ranked:
|
||||||
if cand_name in seen:
|
if cand_name in seen:
|
||||||
@@ -229,20 +230,79 @@ def build_index(rules):
|
|||||||
seen.add(cand_name)
|
seen.add(cand_name)
|
||||||
found.append(_candidate(cand_name, sources, tier, sc, None))
|
found.append(_candidate(cand_name, sources, tier, sc, None))
|
||||||
|
|
||||||
|
return found[:_MAX_CANDIDATES]
|
||||||
|
|
||||||
|
|
||||||
|
def build_index(ctx):
|
||||||
|
"""
|
||||||
|
Build the /utfcn/scan payload from a context.
|
||||||
|
|
||||||
|
Covers INSTALLED custom nodes (curated + signature tiers) AND uninstalled
|
||||||
|
source types that a curated rule targets an installed node for — so a rule
|
||||||
|
still fires on a node whose pack you never installed.
|
||||||
|
|
||||||
|
Returns { "sources": {...}, "candidates": {srcType: [candidate,...]}, "stats": {...} }.
|
||||||
|
"""
|
||||||
|
sources = ctx["sources"]
|
||||||
|
candidates = {}
|
||||||
|
|
||||||
|
for src_name, meta in sources.items():
|
||||||
|
if meta["source"] != "custom":
|
||||||
|
continue
|
||||||
|
found = _candidates_for(src_name, ctx["sigs"][src_name], meta["pack"], ctx)
|
||||||
if found:
|
if found:
|
||||||
candidates[src_name] = found[:_MAX_CANDIDATES]
|
candidates[src_name] = found
|
||||||
if any(c["verified"] for c in candidates[src_name]):
|
|
||||||
verified_count += 1
|
# curated rules whose SOURCE isn't installed (the "replace a missing node
|
||||||
|
# without installing its pack" case) — no signature, so curated-only.
|
||||||
|
uninstalled = 0
|
||||||
|
for src_name in ctx["rules"]:
|
||||||
|
if src_name in sources or src_name in candidates:
|
||||||
|
continue
|
||||||
|
found = _candidates_for(src_name, None, None, ctx)
|
||||||
|
if found:
|
||||||
|
candidates[src_name] = found
|
||||||
|
uninstalled += 1
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"nodes": len(sources),
|
"nodes": len(sources),
|
||||||
"custom": sum(1 for m in sources.values() if m["source"] == "custom"),
|
"custom": sum(1 for m in sources.values() if m["source"] == "custom"),
|
||||||
"replaceable": len(candidates),
|
"replaceable": len(candidates),
|
||||||
"verified": verified_count,
|
"verified": sum(1 for cl in candidates.values() if any(c["verified"] for c in cl)),
|
||||||
|
"uninstalled": uninstalled,
|
||||||
}
|
}
|
||||||
return {"sources": sources, "candidates": candidates, "stats": stats}
|
return {"sources": sources, "candidates": candidates, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def match(ctx, items):
|
||||||
|
"""
|
||||||
|
Match a batch of nodes given only their (possibly serialized) signature —
|
||||||
|
used for UNINSTALLED / missing nodes in an open workflow.
|
||||||
|
|
||||||
|
`items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ].
|
||||||
|
Serialized nodes only carry link slots (not widget values), so 'exact' rarely
|
||||||
|
fires; curated rules (by type name) and 'partial' link-type matches do.
|
||||||
|
|
||||||
|
Returns { type: [candidate, ...] }.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for it in items:
|
||||||
|
t = it.get("type")
|
||||||
|
if not t or t in out:
|
||||||
|
continue
|
||||||
|
inputs = {k: str(v) for k, v in (it.get("inputs") or {}).items()}
|
||||||
|
sig = {
|
||||||
|
"inputs": inputs,
|
||||||
|
"required": set(inputs),
|
||||||
|
"outputs": [str(x) for x in (it.get("outputs") or [])],
|
||||||
|
"output_names": list(it.get("output_names") or []),
|
||||||
|
}
|
||||||
|
found = _candidates_for(t, sig, None, ctx)
|
||||||
|
if found:
|
||||||
|
out[t] = found
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _candidate(to, sources, tier, score, rule):
|
def _candidate(to, sources, tier, score, rule):
|
||||||
meta = sources[to]
|
meta = sources[to]
|
||||||
cand = {
|
cand = {
|
||||||
|
|||||||
+61
-28
@@ -42,6 +42,36 @@ const sourceInfo = (type) => INDEX?.sources?.[type];
|
|||||||
const isCustom = (type) => sourceInfo(type)?.source === "custom";
|
const isCustom = (type) => sourceInfo(type)?.source === "custom";
|
||||||
const candidatesFor = (type) => INDEX?.candidates?.[type] || [];
|
const candidatesFor = (type) => INDEX?.candidates?.[type] || [];
|
||||||
|
|
||||||
|
// The type key to look a node up by. ComfyUI keeps an UNINSTALLED ("missing")
|
||||||
|
// node as a placeholder whose original type lives in last_serialization.type.
|
||||||
|
const nodeType = (n) => n?.last_serialization?.type || n?.comfyClass || n?.type;
|
||||||
|
const isMissing = (n) => !!n?.has_errors || (INDEX && !INDEX.sources?.[nodeType(n)]);
|
||||||
|
|
||||||
|
// Missing nodes aren't in the registry, so /utfcn/scan can't know their signature.
|
||||||
|
// Ask the backend to match them from the serialized slots ComfyUI preserved, and
|
||||||
|
// fold the results into INDEX.candidates so the rest of the code is agnostic.
|
||||||
|
async function matchMissing() {
|
||||||
|
if (!INDEX) await loadIndex();
|
||||||
|
const items = [], seen = new Set();
|
||||||
|
for (const n of app.graph?._nodes || []) {
|
||||||
|
const t = nodeType(n);
|
||||||
|
if (!t || seen.has(t) || INDEX.candidates[t] || INDEX.sources[t]) continue; // known/installed
|
||||||
|
const s = n.last_serialization;
|
||||||
|
if (!s) continue;
|
||||||
|
seen.add(t);
|
||||||
|
const inputs = {};
|
||||||
|
(s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; });
|
||||||
|
items.push({ type: t, inputs, outputs: (s.outputs || []).map((o) => o.type), output_names: (s.outputs || []).map((o) => o.name) });
|
||||||
|
}
|
||||||
|
if (!items.length) return;
|
||||||
|
try {
|
||||||
|
const r = await app.api.fetchApi("/utfcn/match", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nodes: items }),
|
||||||
|
});
|
||||||
|
Object.assign(INDEX.candidates, (await r.json()).candidates || {});
|
||||||
|
} catch (e) { console.error("[UTFCN] match failed:", e); }
|
||||||
|
}
|
||||||
|
|
||||||
function toast(severity, detail, life = 5000) {
|
function toast(severity, detail, life = 5000) {
|
||||||
try { app.extensionManager?.toast?.add?.({ severity, summary: EXT, detail, life }); }
|
try { app.extensionManager?.toast?.add?.({ severity, summary: EXT, detail, life }); }
|
||||||
catch { /* older ComfyUI: no toast API */ }
|
catch { /* older ComfyUI: no toast API */ }
|
||||||
@@ -165,7 +195,7 @@ function applySwap(node, plan, rule) {
|
|||||||
|
|
||||||
/** First verified candidate whose swap is feasible right now (used by force mode). */
|
/** First verified candidate whose swap is feasible right now (used by force mode). */
|
||||||
function firstVerifiedPlan(node) {
|
function firstVerifiedPlan(node) {
|
||||||
for (const c of candidatesFor(node.type)) {
|
for (const c of candidatesFor(nodeType(node))) {
|
||||||
if (!c.verified) continue;
|
if (!c.verified) continue;
|
||||||
const plan = planSwap(node, c.to, c);
|
const plan = planSwap(node, c.to, c);
|
||||||
if (plan.ok) return { cand: c, plan };
|
if (plan.ok) return { cand: c, plan };
|
||||||
@@ -268,13 +298,14 @@ function showPreview(rows) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows.forEach(({ node, cands }, i) => {
|
rows.forEach(({ node, cands }, i) => {
|
||||||
const info = sourceInfo(node.type);
|
const t = nodeType(node);
|
||||||
|
const pack = sourceInfo(t)?.pack || (isMissing(node) ? "⚠ not installed" : "?");
|
||||||
const opts = cands.map((c, k) =>
|
const opts = cands.map((c, k) =>
|
||||||
`<option value="${k}">${c.verified ? "✓" : "⚠"} ${c.to_display} · ${c.source === "core" ? "core" : c.pack}</option>`).join("");
|
`<option value="${k}">${c.verified ? "✓" : "⚠"} ${c.to_display} · ${c.source === "core" ? "core" : c.pack}</option>`).join("");
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td><input type="checkbox"></td>
|
<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-from">${node.title || t}</span> <span class="utfcn-pack">#${node.id} · ${pack}</span></td>
|
||||||
<td><span class="utfcn-arrow">→</span> <select>${opts}</select></td>
|
<td><span class="utfcn-arrow">→</span> <select>${opts}</select></td>
|
||||||
<td class="utfcn-status"></td>`;
|
<td class="utfcn-status"></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -325,10 +356,10 @@ function showPreview(rows) {
|
|||||||
|
|
||||||
async function openBulkDialog() {
|
async function openBulkDialog() {
|
||||||
if (!INDEX) await loadIndex();
|
if (!INDEX) await loadIndex();
|
||||||
|
await matchMissing(); // include uninstalled / red "missing" nodes
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const node of app.graph?._nodes || []) {
|
for (const node of app.graph?._nodes || []) {
|
||||||
if (!isCustom(node.type)) continue;
|
const cands = candidatesFor(nodeType(node));
|
||||||
const cands = candidatesFor(node.type);
|
|
||||||
if (cands.length) rows.push({ node, cands });
|
if (cands.length) rows.push({ node, cands });
|
||||||
}
|
}
|
||||||
if (!rows.length) { toast("info", "No custom nodes with a known core / available equivalent here 🎉"); return; }
|
if (!rows.length) { toast("info", "No custom nodes with a known core / available equivalent here 🎉"); return; }
|
||||||
@@ -349,21 +380,26 @@ function replaceSingle(node, cand) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addContextMenu(nodeType) {
|
// Patch the canvas-level menu builder (not per-node-type) so the item also
|
||||||
const orig = nodeType.prototype.getExtraMenuOptions;
|
// appears on UNINSTALLED "missing" placeholders, which never register a type.
|
||||||
nodeType.prototype.getExtraMenuOptions = function (canvas, options) {
|
function installMenu() {
|
||||||
orig?.apply(this, arguments);
|
const C = window.LGraphCanvas;
|
||||||
|
if (!C || C.prototype.__utfcn_menu) return;
|
||||||
|
C.prototype.__utfcn_menu = true;
|
||||||
|
const orig = C.prototype.getNodeMenuOptions;
|
||||||
|
C.prototype.getNodeMenuOptions = function (node) {
|
||||||
|
const options = orig ? orig.apply(this, arguments) : [];
|
||||||
try {
|
try {
|
||||||
if (!isCustom(this.type)) return;
|
const cands = candidatesFor(nodeType(node));
|
||||||
const cands = candidatesFor(this.type);
|
if (cands.length) {
|
||||||
if (!cands.length) return;
|
const submenu = cands.map((c) => ({
|
||||||
const submenu = cands.map((c) => ({
|
content: `${c.verified ? "✓" : "⚠"} ${c.to_display} ${c.source === "core" ? "(core)" : "(" + c.pack + ")"}`,
|
||||||
content: `${c.verified ? "✓" : "⚠"} ${c.to_display} ${c.source === "core" ? "(core)" : "(" + c.pack + ")"}`,
|
callback: () => replaceSingle(node, c),
|
||||||
callback: () => replaceSingle(this, c),
|
}));
|
||||||
}));
|
options.push(null, { content: "🔁 Replace with core / available", has_submenu: true, submenu: { options: submenu } });
|
||||||
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); }
|
} catch (e) { console.error("[UTFCN] menu error:", e); }
|
||||||
|
return options;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,13 +421,14 @@ function guardGraphLoading() {
|
|||||||
app.loadGraphData = async function (...a) {
|
app.loadGraphData = async function (...a) {
|
||||||
loadingGraph = true;
|
loadingGraph = true;
|
||||||
try { return await orig(...a); }
|
try { return await orig(...a); }
|
||||||
finally { setTimeout(() => { loadingGraph = false; }, 150); }
|
finally { setTimeout(() => { loadingGraph = false; matchMissing(); }, 150); } // pick up missing nodes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNodeAdded(node) {
|
function onNodeAdded(node) {
|
||||||
if (loadingGraph || ADD_MODE === "Off") return;
|
if (loadingGraph || ADD_MODE === "Off") return;
|
||||||
if (!isCustom(node.type) || !candidatesFor(node.type).length) return;
|
const t = nodeType(node);
|
||||||
|
if (!isCustom(t) || !candidatesFor(t).length) return;
|
||||||
addQueue.push(node);
|
addQueue.push(node);
|
||||||
clearTimeout(addTimer);
|
clearTimeout(addTimer);
|
||||||
addTimer = setTimeout(flushAdds, 250); // let the add settle, and batch pastes
|
addTimer = setTimeout(flushAdds, 250); // let the add settle, and batch pastes
|
||||||
@@ -419,7 +456,7 @@ function flushAdds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Suggest mode: one quiet tip per unique type (stay silent on big pastes)
|
// Suggest mode: one quiet tip per unique type (stay silent on big pastes)
|
||||||
const types = [...new Set(nodes.map((n) => n.type))];
|
const types = [...new Set(nodes.map((n) => nodeType(n)))];
|
||||||
if (types.length > 4) return;
|
if (types.length > 4) return;
|
||||||
types.forEach((tp) => {
|
types.forEach((tp) => {
|
||||||
const cands = candidatesFor(tp);
|
const cands = candidatesFor(tp);
|
||||||
@@ -473,17 +510,13 @@ app.registerExtension({
|
|||||||
{ path: ["Extensions", "UTFCN"], commands: ["UTFCN.replaceAll", "UTFCN.refresh"] },
|
{ 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() {
|
async setup() {
|
||||||
await loadIndex();
|
await loadIndex();
|
||||||
|
installMenu(); // right-click item (covers installed AND missing nodes)
|
||||||
guardGraphLoading();
|
guardGraphLoading();
|
||||||
hookNodeAdded();
|
hookNodeAdded();
|
||||||
|
matchMissing(); // in case a workflow is already open at startup
|
||||||
const s = INDEX?.stats;
|
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).`);
|
if (s?.replaceable) console.log(`[UTFCN] ${s.replaceable} replaceable type(s): ${s.verified} verified, ${s.uninstalled ?? 0} for uninstalled packs.`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user