diff --git a/README.md b/README.md index 96ea88b..3e9dde3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ and each node's source module) and ranks candidates in three tiers: |------|---------|---------------| | **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 | +| **partial** | can structurally and semantically stand in (compatible slots plus matching feature intent) 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. @@ -61,7 +61,7 @@ For an **uninstalled** node, UTFCN tries curated rules by name first, then any bundled generated signature for that node type, then the serialized link signature preserved in the workflow. Generated exact signatures can produce verified exact matches, but name-only metadata never can; loose structural -matches remain suggestions. +matches are filtered by feature intent and remain suggestions. ## Shipped equivalences diff --git a/__init__.py b/__init__.py index e254366..8fcac24 100644 --- a/__init__.py +++ b/__init__.py @@ -14,7 +14,7 @@ the *analysis*: it has the live node registry, so it computes — accurately, fr real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents. GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats } - POST /utfcn/match {nodes:[{type,inputs,outputs,output_names}]} -> { candidates } + POST /utfcn/match {nodes:[{type,display,inputs,outputs,output_names}]} -> { candidates } (for UNINSTALLED / missing nodes in a workflow) Curated overrides live in mappings.json (shipped) and user_mappings.json (yours). diff --git a/tests/test_utfcn_core_generated.py b/tests/test_utfcn_core_generated.py index e6691e3..b9d61a5 100644 --- a/tests/test_utfcn_core_generated.py +++ b/tests/test_utfcn_core_generated.py @@ -158,6 +158,30 @@ class GeneratedSignatureMatchingTests(unittest.TestCase): "outputs": ["IMAGE"], "output_names": ["image"], }, + "ImageBlur": { + "inputs": {"image": "IMAGE", "radius": "INT"}, + "required": {"image", "radius"}, + "outputs": ["IMAGE"], + "output_names": ["image"], + }, + "ImageResize": { + "inputs": {"image": "IMAGE", "width": "INT", "height": "INT"}, + "required": {"image", "width", "height"}, + "outputs": ["IMAGE"], + "output_names": ["image"], + }, + "PrimitiveString": { + "inputs": {"value": "STRING"}, + "required": {"value"}, + "outputs": ["STRING"], + "output_names": ["text"], + }, + "TextTruncate": { + "inputs": {"text": "STRING", "max_length": "INT"}, + "required": {"text", "max_length"}, + "outputs": ["STRING"], + "output_names": ["text"], + }, "CuratedTarget": { "inputs": {"image": "IMAGE"}, "required": {"image"}, @@ -169,6 +193,10 @@ class GeneratedSignatureMatchingTests(unittest.TestCase): "CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"}, "CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"}, "CoreImagePassthrough": {"source": "core", "pack": "nodes", "display": "Core Image Passthrough"}, + "ImageBlur": {"source": "core", "pack": "nodes", "display": "Image Blur"}, + "ImageResize": {"source": "core", "pack": "nodes", "display": "Image Resize"}, + "PrimitiveString": {"source": "core", "pack": "nodes", "display": "String"}, + "TextTruncate": {"source": "core", "pack": "nodes", "display": "Text Truncate"}, "CuratedTarget": {"source": "core", "pack": "nodes", "display": "Curated Target"}, } by_out = defaultdict(list) @@ -359,6 +387,79 @@ class GeneratedSignatureMatchingTests(unittest.TestCase): self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"]) self.assertFalse(result["SerializedMaskInvert"][0]["verified"]) + def test_text_entry_node_does_not_match_text_transform_candidate(self): + result = utfcn_core.match( + self._ctx(), + [ + { + "type": "SomeCustomTextBox", + "display": "Text Box", + "outputs": ["STRING"], + "output_names": ["text"], + } + ], + ) + + self.assertEqual(["PrimitiveString"], [cand["to"] for cand in result["SomeCustomTextBox"]]) + + def test_generated_text_entry_display_does_not_match_text_transform_candidate(self): + generated = _empty_generated() + generated["sigs"]["OpaqueCustomNode"] = { + "inputs": {}, + "required": set(), + "outputs": ["STRING"], + "output_names": ["text"], + } + generated["meta"]["OpaqueCustomNode"] = { + "source": "generated", + "pack": "text-pack", + "display": "Text Box", + "repository": "https://github.com/example/text-pack", + "confidence": "static_exact", + } + generated["by_out"]["STRING"].append("OpaqueCustomNode") + + result = utfcn_core.match( + self._ctx(generated=generated), + [{"type": "OpaqueCustomNode"}], + ) + + self.assertEqual(["PrimitiveString"], [cand["to"] for cand in result["OpaqueCustomNode"]]) + + def test_text_transform_node_can_match_same_transform_feature(self): + result = utfcn_core.match( + self._ctx(), + [ + { + "type": "LegacyTextTruncate", + "display": "Text Truncate", + "inputs": {"text": "STRING"}, + "outputs": ["STRING"], + "output_names": ["text"], + } + ], + ) + + self.assertEqual("TextTruncate", result["LegacyTextTruncate"][0]["to"]) + self.assertEqual("partial", result["LegacyTextTruncate"][0]["tier"]) + self.assertFalse(result["LegacyTextTruncate"][0]["verified"]) + + def test_image_transform_node_does_not_match_different_transform_feature(self): + result = utfcn_core.match( + self._ctx(), + [ + { + "type": "LegacyImageBlur", + "display": "Image Blur", + "inputs": {"image": "IMAGE"}, + "outputs": ["IMAGE"], + "output_names": ["image"], + } + ], + ) + + self.assertEqual(["ImageBlur"], [cand["to"] for cand in result["LegacyImageBlur"]]) + if __name__ == "__main__": unittest.main() diff --git a/utfcn_core.py b/utfcn_core.py index 71cdc00..de71cf2 100644 --- a/utfcn_core.py +++ b/utfcn_core.py @@ -17,9 +17,9 @@ We answer it in three tiers, from most to least trustworthy: 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. + partial the candidate can structurally accept every input the source has, + provides every output type the source has, and matches the same + feature intent. 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. @@ -27,6 +27,7 @@ The frontend consumes the result: `verified` candidates power auto-replace, import json import os +import re from collections import Counter, defaultdict # Top-level python modules we consider "core" (shipped with ComfyUI itself). @@ -37,6 +38,48 @@ CORE_TOPLEVEL = ("nodes", "comfy_extras", "comfy_api_nodes", "comfy_api") # so they matter for widget-value transfer but not for link compatibility. WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}) +_TEXT_TYPES = frozenset({"STRING", "STRING_LIST"}) +_TEXT_NEUTRAL_TOKENS = frozenset( + { + "any", + "box", + "constant", + "input", + "literal", + "multi", + "multiline", + "note", + "primitive", + "prompt", + "string", + "text", + "textarea", + "value", + "widget", + } +) +_ACTION_GROUPS = ( + ("blur", frozenset({"blur", "smooth"})), + ("crop", frozenset({"crop"})), + ("geometry", frozenset({"downscale", "resize", "rescale", "scale", "upscale"})), + ("invert", frozenset({"invert", "inversion"})), + ("passthrough", frozenset({"identity", "pass", "passthrough", "reroute"})), + ("preview", frozenset({"display", "preview", "show", "view"})), + ("size", frozenset({"dimension", "dimensions", "height", "resolution", "size", "width"})), + ("concat", frozenset({"append", "combine", "concat", "concatenate", "join", "merge"})), + ("convert", frozenset({"cast", "convert", "float", "int", "number"})), + ("encode", frozenset({"clip", "conditioning", "encode", "encoder", "tokenize", "tokenizer"})), + ("extract", frozenset({"extract", "find", "parse", "regex", "regexp", "select"})), + ("format", frozenset({"format", "template"})), + ("io", frozenset({"file", "load", "path", "read", "save", "url", "write"})), + ("replace", frozenset({"remove", "replace", "substitute"})), + ("split", frozenset({"separate", "split", "splitter"})), + ("strip", frozenset({"clean", "lstrip", "rstrip", "sanitize", "strip", "trim"})), + ("translate", frozenset({"translate", "translator"})), + ("truncate", frozenset({"chop", "slice", "substring", "truncate"})), + ("case", frozenset({"case", "lower", "upper"})), +) + def _module_of(cls): return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes" @@ -119,6 +162,81 @@ def _score(src, cand): return min(1.0, base + name_bonus) +def _semantic_tokens(*parts): + text = " ".join(str(part or "") for part in parts) + text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text) + text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text) + return { + token + for token in re.split(r"[^A-Za-z0-9]+", text.lower()) + if token + } + + +def _identity_tokens(name, meta, sig): + if not isinstance(meta, dict): + meta = {} + terms = [name, meta.get("display")] + terms.extend(sig.get("inputs", {}).keys()) + terms.extend(sig.get("output_names") or []) + return _semantic_tokens(*terms) + + +def _action_groups(tokens): + groups = { + group + for group, group_tokens in _ACTION_GROUPS + if tokens & group_tokens + } + if "to" in tokens and tokens & {"bool", "boolean", "float", "int", "number"}: + groups.add("convert") + return groups + + +def _text_signature_kind(sig): + values = set(sig.get("inputs", {}).values()) | set(sig.get("outputs", [])) + return bool(values & _TEXT_TYPES) + + +def _text_value_like(tokens, sig): + outputs = sig.get("outputs", []) + inputs = sig.get("inputs", {}) + if not outputs or not set(outputs) <= _TEXT_TYPES: + return False + if _action_groups(tokens): + return False + if len(inputs) > 1: + return False + if inputs: + name, typ = next(iter(inputs.items())) + if typ not in _TEXT_TYPES and typ != "COMBO": + return False + if not (_semantic_tokens(name) & _TEXT_NEUTRAL_TOKENS): + return False + return bool(tokens & _TEXT_NEUTRAL_TOKENS) + + +def _features_compatible(src_name, src_sig, src_meta, cand_name, cand_sig, cand_meta): + """ + Structural compatibility is too weak for primitive text nodes: a missing + text box serializes as STRING output only, which otherwise matches every + STRING utility. Gate text candidates by identity tokens so text-entry + sources do not suggest transforms such as truncate/split/replace. + """ + src_tokens = _identity_tokens(src_name, src_meta, src_sig) + cand_tokens = _identity_tokens(cand_name, cand_meta, cand_sig) + src_actions = _action_groups(src_tokens) + cand_actions = _action_groups(cand_tokens) + + if _text_signature_kind(src_sig) and _text_signature_kind(cand_sig) and _text_value_like(src_tokens, src_sig): + return not cand_actions and _text_value_like(cand_tokens, cand_sig) + + if src_actions or cand_actions: + return bool(src_actions & cand_actions) + + return True + + # score below which a partial match isn't worth surfacing _PARTIAL_THRESHOLD = 0.5 # max candidates returned per source node @@ -267,7 +385,7 @@ def build_context(rules, generated=None): } -def _candidates_for(src_name, src_sig, src_pack, ctx): +def _candidates_for(src_name, src_sig, src_pack, ctx, src_meta=None): """ Rank replacement candidates for one source node. @@ -277,6 +395,8 @@ def _candidates_for(src_name, src_sig, src_pack, ctx): `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"] + if not isinstance(src_meta, dict): + src_meta = sources.get(src_name, {}) found, seen = [], set() # --- tier 1: curated rules (ordered preference; core-first is the author's job) --- @@ -300,6 +420,8 @@ def _candidates_for(src_name, src_sig, src_pack, ctx): cand_sig = sigs[cand_name] if not _feasible(src_sig, cand_sig): continue + if not _features_compatible(src_name, src_sig, src_meta, cand_name, cand_sig, cand_meta): + continue if _is_exact(src_sig, cand_sig): ranked.append((cand_name, "exact", 1.0)) else: @@ -435,7 +557,7 @@ def match(ctx, items): `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), bundled generated signatures, and - partial link-type matches do. + feature-gated partial link-type matches do. Returns a mapping from source node type to candidate list. """ @@ -464,12 +586,13 @@ def match(ctx, items): if not isinstance(gen_meta, dict): gen_meta = {} gen_pack = gen_meta.get("pack") - found = _candidates_for(t, gen_sig, gen_pack, ctx) + found = _candidates_for(t, gen_sig, gen_pack, ctx, gen_meta) if found: out[t] = found continue - found = _candidates_for(t, sig, None, ctx) + item_meta = {"display": it.get("display") or t} + found = _candidates_for(t, sig, None, ctx, item_meta) if found: out[t] = found return out diff --git a/web/utfcn.js b/web/utfcn.js index f86b36a..443a272 100644 --- a/web/utfcn.js +++ b/web/utfcn.js @@ -6,7 +6,7 @@ import { app } from "../../scripts/app.js"; * 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. + * partial — structurally and semantically compatible but looser; confirm first. * * This file turns that into three things: * 1. a toast tip when you interactively drop a replaceable custom node; @@ -61,7 +61,13 @@ async function matchMissing() { 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) }); + items.push({ + type: t, + display: s.title || n.title || t, + inputs, + outputs: (s.outputs || []).map((o) => o.type), + output_names: (s.outputs || []).map((o) => o.name), + }); } if (!items.length) return; try {