Compare commits
1 Commits
9000b5500b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd3e51301c |
@@ -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) |
|
| **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) |
|
| **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
|
"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.
|
||||||
@@ -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
|
bundled generated signature for that node type, then the serialized link
|
||||||
signature preserved in the workflow. Generated exact signatures can produce
|
signature preserved in the workflow. Generated exact signatures can produce
|
||||||
verified exact matches, but name-only metadata never can; loose structural
|
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
|
## Shipped equivalences
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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 }
|
POST /utfcn/match {nodes:[{type,display,inputs,outputs,output_names}]} -> { candidates }
|
||||||
(for UNINSTALLED / missing nodes in a workflow)
|
(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).
|
||||||
|
|||||||
@@ -158,6 +158,30 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
|
|||||||
"outputs": ["IMAGE"],
|
"outputs": ["IMAGE"],
|
||||||
"output_names": ["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": {
|
"CuratedTarget": {
|
||||||
"inputs": {"image": "IMAGE"},
|
"inputs": {"image": "IMAGE"},
|
||||||
"required": {"image"},
|
"required": {"image"},
|
||||||
@@ -169,6 +193,10 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
|
|||||||
"CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"},
|
"CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"},
|
||||||
"CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"},
|
"CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"},
|
||||||
"CoreImagePassthrough": {"source": "core", "pack": "nodes", "display": "Core Image Passthrough"},
|
"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"},
|
"CuratedTarget": {"source": "core", "pack": "nodes", "display": "Curated Target"},
|
||||||
}
|
}
|
||||||
by_out = defaultdict(list)
|
by_out = defaultdict(list)
|
||||||
@@ -359,6 +387,79 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
|
|||||||
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
|
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
|
||||||
self.assertFalse(result["SerializedMaskInvert"][0]["verified"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+130
-7
@@ -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
|
exact the candidate's signature (input name→type map + ordered output
|
||||||
types) is IDENTICAL to the source's. Safe to remap by name.
|
types) is IDENTICAL to the source's. Safe to remap by name.
|
||||||
Verified.
|
Verified.
|
||||||
partial the candidate can structurally accept every input the source has
|
partial the candidate can structurally accept every input the source has,
|
||||||
and provides every output type the source has, but names / extra
|
provides every output type the source has, and matches the same
|
||||||
slots differ. A *suggestion* only — never auto-applied.
|
feature intent. A *suggestion* only — never auto-applied.
|
||||||
|
|
||||||
The frontend consumes the result: `verified` candidates power auto-replace,
|
The frontend consumes the result: `verified` candidates power auto-replace,
|
||||||
`partial` ones are shown for the user to confirm.
|
`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 json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
# Top-level python modules we consider "core" (shipped with ComfyUI itself).
|
# 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.
|
# so they matter for widget-value transfer but not for link compatibility.
|
||||||
WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"})
|
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):
|
def _module_of(cls):
|
||||||
return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes"
|
return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes"
|
||||||
@@ -119,6 +162,81 @@ def _score(src, cand):
|
|||||||
return min(1.0, base + name_bonus)
|
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
|
# score below which a partial match isn't worth surfacing
|
||||||
_PARTIAL_THRESHOLD = 0.5
|
_PARTIAL_THRESHOLD = 0.5
|
||||||
# max candidates returned per source node
|
# 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.
|
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).
|
`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"]
|
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()
|
found, seen = [], set()
|
||||||
|
|
||||||
# --- tier 1: curated rules (ordered preference; core-first is the author's job) ---
|
# --- 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]
|
cand_sig = sigs[cand_name]
|
||||||
if not _feasible(src_sig, cand_sig):
|
if not _feasible(src_sig, cand_sig):
|
||||||
continue
|
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):
|
if _is_exact(src_sig, cand_sig):
|
||||||
ranked.append((cand_name, "exact", 1.0))
|
ranked.append((cand_name, "exact", 1.0))
|
||||||
else:
|
else:
|
||||||
@@ -435,7 +557,7 @@ def match(ctx, items):
|
|||||||
`items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ].
|
`items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ].
|
||||||
Serialized nodes only carry link slots (not widget values), so 'exact' rarely
|
Serialized nodes only carry link slots (not widget values), so 'exact' rarely
|
||||||
fires; curated rules (by type name), bundled generated signatures, and
|
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.
|
Returns a mapping from source node type to candidate list.
|
||||||
"""
|
"""
|
||||||
@@ -464,12 +586,13 @@ def match(ctx, items):
|
|||||||
if not isinstance(gen_meta, dict):
|
if not isinstance(gen_meta, dict):
|
||||||
gen_meta = {}
|
gen_meta = {}
|
||||||
gen_pack = gen_meta.get("pack")
|
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:
|
if found:
|
||||||
out[t] = found
|
out[t] = found
|
||||||
continue
|
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:
|
if found:
|
||||||
out[t] = found
|
out[t] = found
|
||||||
return out
|
return out
|
||||||
|
|||||||
+8
-2
@@ -6,7 +6,7 @@ import { app } from "../../scripts/app.js";
|
|||||||
* The backend (/utfcn/scan) tells us, for every custom node type, which core (or
|
* The backend (/utfcn/scan) tells us, for every custom node type, which core (or
|
||||||
* other-pack) nodes could stand in for it, split into:
|
* other-pack) nodes could stand in for it, split into:
|
||||||
* verified — curated rule or an identical signature; safe to auto-apply.
|
* 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:
|
* This file turns that into three things:
|
||||||
* 1. a toast tip when you interactively drop a replaceable custom node;
|
* 1. a toast tip when you interactively drop a replaceable custom node;
|
||||||
@@ -61,7 +61,13 @@ async function matchMissing() {
|
|||||||
seen.add(t);
|
seen.add(t);
|
||||||
const inputs = {};
|
const inputs = {};
|
||||||
(s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; });
|
(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;
|
if (!items.length) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user