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] || []; // 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) { 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(nodeType(node))) { 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 = `
| Node | Replace with | Status |
|---|