Compare commits
84 Commits
cc728eb50b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd3e51301c | |||
| 9000b5500b | |||
| 5dd37c859b | |||
| 5b511ef295 | |||
| d60fc5d14e | |||
| c6c0551ae0 | |||
| 12d0f87968 | |||
| 33690683b7 | |||
| 28186698d0 | |||
| 1895a0e677 | |||
| dddb136b16 | |||
| f0b83b5505 | |||
| a2a5b44436 | |||
| d7c3fc86c1 | |||
| ee8496174f | |||
| 126f5db959 | |||
| f23d4ae69a | |||
| 42aeafd0e9 | |||
| 49666141fa | |||
| 11acb12658 | |||
| 75224982ba | |||
| ecd8f7c082 | |||
| 25b3f69d0d | |||
| 447a5c72dd | |||
| 9792989216 | |||
| 7e7479fb6a | |||
| 2d951c759a | |||
| 52ac447e0e | |||
| c45bf3c230 | |||
| 1b56798018 | |||
| 39b991800a | |||
| a3bb718bd2 | |||
| 73bdca9e1e | |||
| 3cf4a5eb52 | |||
| f7143e7bac | |||
| bf46f9b389 | |||
| 3219ec0c39 | |||
| c6d2b2d645 | |||
| 07822bc3ec | |||
| 79d9921ba6 | |||
| 5844a0a433 | |||
| b560f238a1 | |||
| 7c4b83ed0e | |||
| 065c9ae7ec | |||
| 34e53e8692 | |||
| 97e0126a1d | |||
| 2ad3cd3a09 | |||
| d1f49e7c95 | |||
| f26e441e03 | |||
| 2c9452ae67 | |||
| 7e4e85a0bd | |||
| 9752248ee9 | |||
| 05fa411d47 | |||
| f5d72b494d | |||
| 86ea12924c | |||
| 51db0d16e5 | |||
| fc92d1db24 | |||
| 8f872baf0b | |||
| 317788572e | |||
| 65c3a57052 | |||
| 45e3cbaad8 | |||
| 6c2653c803 | |||
| 99d2bb25da | |||
| 034e07269d | |||
| bebd4e09aa | |||
| 67b875cdd5 | |||
| 90b7e3e872 | |||
| 0456e8033a | |||
| 479a70f377 | |||
| c8c1205bde | |||
| b2b1bd16bd | |||
| 2845daf90c | |||
| fae0c312bc | |||
| 21a29b8846 | |||
| 6be0fe9d1e | |||
| 6a4617f002 | |||
| 960c41b330 | |||
| 669f209930 | |||
| bf2f6f3b95 | |||
| 9c17083298 | |||
| 38c142d42e | |||
| 8c45016c00 | |||
| 55e59f5e1d | |||
| fef2b8b7f1 |
@@ -34,6 +34,15 @@ 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
|
them `⚠ not installed`. (Widget values aren't carried for a node whose
|
||||||
definition you don't have — links are.)
|
definition you don't have — links are.)
|
||||||
|
|
||||||
|
### Popular missing-node signatures
|
||||||
|
|
||||||
|
UTFCN ships a generated `popular_node_signatures.json` artifact built from
|
||||||
|
ComfyUI-Manager metadata and static scans of public GitHub repos.
|
||||||
|
The file helps match common missing nodes by their real node signatures even
|
||||||
|
when the original pack is not installed. It is loaded locally at ComfyUI startup;
|
||||||
|
UTFCN does not contact GitHub, ComfyUI-Manager, or the Registry while you use the
|
||||||
|
editor.
|
||||||
|
|
||||||
## 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`
|
||||||
@@ -43,14 +52,16 @@ 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.
|
||||||
|
|
||||||
For an **uninstalled** node only *curated* (by name) and *partial* (by its
|
For an **uninstalled** node, UTFCN tries curated rules by name first, then any
|
||||||
serialized link signature) can apply — the exact tier needs the widget-level
|
bundled generated signature for that node type, then the serialized link
|
||||||
signature, which a node you haven't installed can't provide.
|
signature preserved in the workflow. Generated exact signatures can produce
|
||||||
|
verified exact matches, but name-only metadata never can; loose structural
|
||||||
|
matches are filtered by feature intent and remain suggestions.
|
||||||
|
|
||||||
## Shipped equivalences
|
## Shipped equivalences
|
||||||
|
|
||||||
@@ -105,6 +116,19 @@ equivalent:**
|
|||||||
never auto-applies a heuristic guess, and it never fires while you're opening
|
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.
|
or importing a workflow — only on nodes *you* add. Undo with Ctrl+Z.
|
||||||
|
|
||||||
|
## Refreshing the generated popular-node artifact
|
||||||
|
|
||||||
|
Maintainers can refresh the bundled signature artifact with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/generate_popular_node_signatures.py --limit 1000 --output popular_node_signatures.json --cache-dir /tmp/utfcn-popular-node-repos
|
||||||
|
```
|
||||||
|
|
||||||
|
The generator uses only Python's standard library plus `git`. It parses custom
|
||||||
|
node repositories statically with `ast`; it does not import or execute the
|
||||||
|
downloaded node code. Repositories with dynamic signatures are skipped until a
|
||||||
|
parser case exists for them.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Clone into `ComfyUI/custom_nodes/` and restart ComfyUI:
|
Clone into `ComfyUI/custom_nodes/` and restart ComfyUI:
|
||||||
|
|||||||
+4
-2
@@ -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).
|
||||||
@@ -39,7 +39,9 @@ _INDEX_CACHE = None
|
|||||||
def _get_ctx(refresh=False):
|
def _get_ctx(refresh=False):
|
||||||
global _CTX_CACHE
|
global _CTX_CACHE
|
||||||
if refresh or _CTX_CACHE is None:
|
if refresh or _CTX_CACHE is None:
|
||||||
_CTX_CACHE = utfcn_core.build_context(utfcn_core.load_rules(_DIR))
|
rules = utfcn_core.load_rules(_DIR)
|
||||||
|
generated = utfcn_core.load_generated_signatures(_DIR)
|
||||||
|
_CTX_CACHE = utfcn_core.build_context(rules, generated)
|
||||||
return _CTX_CACHE
|
return _CTX_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
|||||||
|
# Popular Node Signature Intelligence Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
UTFCN currently ranks replacements from the live ComfyUI node registry and a small curated `mappings.json` file. That works well for installed custom nodes because the backend can inspect real `INPUT_TYPES`, `RETURN_TYPES`, display names, and source modules. It is weaker for missing or uninstalled nodes because ComfyUI preserves only the serialized workflow slots, which often omit widget-level signature data.
|
||||||
|
|
||||||
|
ComfyUI-Manager and the Comfy Registry expose broad custom-node metadata. Manager's list is useful for pack discovery and repository URLs. The Registry can add popularity signals such as downloads, stars, search ranking, and preempted node names. GitHub repositories can often be scanned offline to recover node class declarations and signatures. This design uses those public sources to improve coverage without changing UTFCN's runtime safety model.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Add broad pre-scanned coverage for popular custom nodes and node packs, targeting up to 1000 ranked entries from Manager and/or the Comfy Registry per generation run.
|
||||||
|
- Improve replacement suggestions for missing or uninstalled nodes by matching against bundled signatures instead of relying only on sparse serialized workflow slots.
|
||||||
|
- Keep runtime startup and scan behavior local, deterministic, and fast. The ComfyUI server must not fetch GitHub or registry data during normal use.
|
||||||
|
- Preserve UTFCN's trust model: curated and exact matches are verified; heuristic matches are suggestions only; Force mode never applies heuristics.
|
||||||
|
- Make the generated data reproducible and reviewable so future updates can refresh coverage without hand-editing large JSON blobs.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not treat Manager or Registry metadata alone as proof that a node is equivalent to a core node.
|
||||||
|
- Do not auto-install custom nodes or their dependencies.
|
||||||
|
- Do not execute arbitrary custom-node repository code during runtime.
|
||||||
|
- Do not silently replace missing nodes based only on similar names.
|
||||||
|
- Do not require ComfyUI-Manager to be installed.
|
||||||
|
|
||||||
|
## Proposed Approach
|
||||||
|
|
||||||
|
Add a generated data artifact named `popular_node_signatures.json` and an update script that can regenerate it from public metadata. The artifact is committed to the repo and loaded by the existing backend alongside `mappings.json` and `user_mappings.json`.
|
||||||
|
|
||||||
|
The update script is a developer tool, not a runtime dependency. It fetches Manager and/or Registry metadata, ranks entries by available popularity signals, scans reachable GitHub repositories, extracts ComfyUI node signatures when feasible, and writes normalized JSON. Repositories that cannot be fetched or parsed are skipped with a recorded reason in generation metadata.
|
||||||
|
|
||||||
|
Runtime matching then has three signature sources:
|
||||||
|
|
||||||
|
1. Live installed signatures from `nodes.NODE_CLASS_MAPPINGS`.
|
||||||
|
2. Curated mappings from `mappings.json` and `user_mappings.json`.
|
||||||
|
3. Bundled popular-node signatures from `popular_node_signatures.json`.
|
||||||
|
|
||||||
|
Installed custom nodes continue to prefer live signatures because they are the strongest local truth. Missing nodes use curated mappings first, then bundled signatures by node type, then the existing serialized-slot heuristic fallback.
|
||||||
|
|
||||||
|
## Data Artifact
|
||||||
|
|
||||||
|
`popular_node_signatures.json` is machine-generated and stable enough for code review. It contains:
|
||||||
|
|
||||||
|
- `schema_version`: integer for future migrations.
|
||||||
|
- `generated_at`: ISO timestamp.
|
||||||
|
- `sources`: generation inputs, including Manager list URL, Registry query details when used, and limits.
|
||||||
|
- `packs`: map of pack id to pack metadata such as title, repository, ranking signals, and extraction status.
|
||||||
|
- `nodes`: map of ComfyUI node type to normalized signature and pack metadata.
|
||||||
|
|
||||||
|
Each node entry contains:
|
||||||
|
|
||||||
|
- `type`: ComfyUI class mapping key.
|
||||||
|
- `display`: display name if discoverable.
|
||||||
|
- `pack`: normalized pack id.
|
||||||
|
- `repository`: source repository URL.
|
||||||
|
- `inputs`: map of input name to reduced UTFCN type string.
|
||||||
|
- `required`: list of required input names.
|
||||||
|
- `outputs`: ordered list of output type strings.
|
||||||
|
- `output_names`: ordered list of output names when discoverable.
|
||||||
|
- `confidence`: extraction confidence such as `static_exact`, `static_partial`, or `metadata_only`.
|
||||||
|
|
||||||
|
Only entries with usable signature data participate in exact/partial matching. Metadata-only entries may be used for display or diagnostics but must not create replacement candidates by themselves.
|
||||||
|
|
||||||
|
## Extraction Strategy
|
||||||
|
|
||||||
|
The generator should start conservatively:
|
||||||
|
|
||||||
|
- Fetch Manager's `custom-node-list.json` and optionally the Registry `/nodes` API.
|
||||||
|
- Prefer entries with a GitHub repository URL.
|
||||||
|
- Rank by Registry downloads when available, then GitHub stars, search ranking, and Manager order as tie-breakers.
|
||||||
|
- Clone or fetch repository contents into a temporary cache outside the committed tree.
|
||||||
|
- Parse Python files with `ast` instead of importing repository code.
|
||||||
|
- Detect class-level `RETURN_TYPES`, `RETURN_NAMES`, `NODE_DISPLAY_NAME_MAPPINGS`, and `NODE_CLASS_MAPPINGS`.
|
||||||
|
- Extract common static `INPUT_TYPES` shapes when the method returns a literal dict or simple literal-compatible expression.
|
||||||
|
- Mark dynamic or unsupported shapes as skipped rather than guessing.
|
||||||
|
|
||||||
|
The first implementation can intentionally miss complex dynamic nodes. Coverage can improve over time by adding parser cases backed by fixtures.
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
Add a loader in `utfcn_core.py` for the generated artifact. If the file is absent, malformed, or has an unsupported schema version, UTFCN logs a warning and behaves as it does today.
|
||||||
|
|
||||||
|
Extend `build_context()` to include generated signatures in a separate namespace from live signatures. Live signatures and live source metadata remain authoritative for installed nodes. Generated signatures are used for uninstalled source nodes and as optional supplemental metadata.
|
||||||
|
|
||||||
|
Candidate ranking should keep the existing tiers:
|
||||||
|
|
||||||
|
- `curated`: explicit rule, verified.
|
||||||
|
- `exact`: identical signature, verified only when based on live installed source signatures or generated signatures with usable extracted data.
|
||||||
|
- `partial`: structurally feasible but not identical, suggestion only.
|
||||||
|
|
||||||
|
For missing nodes, matching order is:
|
||||||
|
|
||||||
|
1. Curated rule by node type.
|
||||||
|
2. Generated signature for that node type, if present and usable.
|
||||||
|
3. Serialized workflow signature fallback.
|
||||||
|
|
||||||
|
This order lets missing nodes benefit from full pre-scanned signatures while preserving the existing behavior for unknown nodes.
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
The current frontend can remain mostly unchanged because it consumes backend candidates. Small UI improvements are acceptable if needed:
|
||||||
|
|
||||||
|
- Show generated-source matches with the existing tier labels.
|
||||||
|
- Keep Force mode limited to verified candidates returned by the backend.
|
||||||
|
- Keep preview behavior unchanged for partial matches.
|
||||||
|
|
||||||
|
No network access or GitHub-specific logic belongs in `web/utfcn.js`.
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Runtime never fetches remote metadata.
|
||||||
|
- Runtime never imports scanned third-party repository code from the generated dataset.
|
||||||
|
- A generated signature must include concrete input and output type data before it can influence matching.
|
||||||
|
- Metadata-only node names cannot produce verified matches.
|
||||||
|
- Name similarity can affect ranking only for suggestions, not Force mode.
|
||||||
|
- `user_mappings.json` continues to override shipped curated mappings for local user control.
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
Add focused backend tests that do not require a running ComfyUI instance:
|
||||||
|
|
||||||
|
- Rule loading still merges shipped and user mappings correctly.
|
||||||
|
- Generated artifact loading accepts the expected schema and rejects malformed data gracefully.
|
||||||
|
- Missing-node matching prefers curated rules over generated signatures.
|
||||||
|
- Generated exact signatures can produce verified exact matches against core targets.
|
||||||
|
- Generated partial matches remain unverified.
|
||||||
|
- Metadata-only entries do not produce candidates.
|
||||||
|
|
||||||
|
Add generator tests with small fixture repositories:
|
||||||
|
|
||||||
|
- Extracts static `NODE_CLASS_MAPPINGS` and `INPUT_TYPES`.
|
||||||
|
- Extracts `RETURN_TYPES` and optional `RETURN_NAMES`.
|
||||||
|
- Skips dynamic or unsupported `INPUT_TYPES` without failing the whole run.
|
||||||
|
- Produces deterministic JSON ordering.
|
||||||
|
|
||||||
|
Manual verification should include opening a workflow with a missing node whose type exists in the generated artifact and confirming the preview offers the expected replacement without installing the original pack.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Implement the generator and backend loader behind the generated artifact.
|
||||||
|
2. Add tests using small fixtures before generating the large dataset.
|
||||||
|
3. Generate an initial artifact from a limited sample and verify behavior.
|
||||||
|
4. Expand to up to 1000 ranked entries once extraction and matching are stable.
|
||||||
|
5. Document the refresh command and the trust model in `README.md`.
|
||||||
|
|
||||||
|
The initial commit may include a smaller sample artifact if full top-1000 extraction exposes parser or network edge cases. The runtime code should not depend on the artifact being complete.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import utfcn_core
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_generated():
|
||||||
|
return {"sigs": {}, "meta": {}, "by_out": defaultdict(list)}
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedSignatureLoaderTests(unittest.TestCase):
|
||||||
|
def test_missing_generated_file_returns_empty_indexes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
generated = utfcn_core.load_generated_signatures(tmp)
|
||||||
|
|
||||||
|
self.assertEqual({}, generated["sigs"])
|
||||||
|
self.assertEqual({}, generated["meta"])
|
||||||
|
self.assertEqual({}, dict(generated["by_out"]))
|
||||||
|
|
||||||
|
def test_loads_usable_static_signature(self):
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_at": "2026-07-02T00:00:00Z",
|
||||||
|
"sources": {"limit": 1},
|
||||||
|
"packs": {
|
||||||
|
"sample-pack": {
|
||||||
|
"title": "Sample Pack",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"SampleImageSize": {
|
||||||
|
"type": "SampleImageSize",
|
||||||
|
"display": "Sample Image Size",
|
||||||
|
"pack": "sample-pack",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
"inputs": {"image": "IMAGE"},
|
||||||
|
"required": ["image"],
|
||||||
|
"outputs": ["INT", "INT"],
|
||||||
|
"output_names": ["width", "height"],
|
||||||
|
"confidence": "static_exact",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
Path(tmp, "popular_node_signatures.json").write_text(
|
||||||
|
json.dumps(payload),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
generated = utfcn_core.load_generated_signatures(tmp)
|
||||||
|
|
||||||
|
self.assertEqual({"image": "IMAGE"}, generated["sigs"]["SampleImageSize"]["inputs"])
|
||||||
|
self.assertEqual({"image"}, generated["sigs"]["SampleImageSize"]["required"])
|
||||||
|
self.assertEqual(["INT", "INT"], generated["sigs"]["SampleImageSize"]["outputs"])
|
||||||
|
self.assertEqual(["width", "height"], generated["sigs"]["SampleImageSize"]["output_names"])
|
||||||
|
self.assertEqual("sample-pack", generated["meta"]["SampleImageSize"]["pack"])
|
||||||
|
self.assertEqual("Sample Image Size", generated["meta"]["SampleImageSize"]["display"])
|
||||||
|
self.assertEqual(["SampleImageSize"], generated["by_out"]["INT"])
|
||||||
|
|
||||||
|
def test_rejects_metadata_only_entries_for_matching(self):
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_at": "2026-07-02T00:00:00Z",
|
||||||
|
"sources": {},
|
||||||
|
"packs": {},
|
||||||
|
"nodes": {
|
||||||
|
"NameOnlyNode": {
|
||||||
|
"type": "NameOnlyNode",
|
||||||
|
"display": "Name Only",
|
||||||
|
"pack": "name-only",
|
||||||
|
"repository": "https://github.com/example/name-only",
|
||||||
|
"inputs": {},
|
||||||
|
"required": [],
|
||||||
|
"outputs": [],
|
||||||
|
"output_names": [],
|
||||||
|
"confidence": "metadata_only",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
Path(tmp, "popular_node_signatures.json").write_text(
|
||||||
|
json.dumps(payload),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
generated = utfcn_core.load_generated_signatures(tmp)
|
||||||
|
|
||||||
|
self.assertNotIn("NameOnlyNode", generated["sigs"])
|
||||||
|
self.assertNotIn("NameOnlyNode", generated["meta"])
|
||||||
|
self.assertEqual({}, dict(generated["by_out"]))
|
||||||
|
|
||||||
|
def test_malformed_generated_file_returns_empty_indexes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
Path(tmp, "popular_node_signatures.json").write_text("{broken", encoding="utf-8")
|
||||||
|
generated = utfcn_core.load_generated_signatures(tmp)
|
||||||
|
|
||||||
|
self.assertEqual({}, generated["sigs"])
|
||||||
|
self.assertEqual({}, generated["meta"])
|
||||||
|
self.assertEqual({}, dict(generated["by_out"]))
|
||||||
|
|
||||||
|
def test_unsupported_schema_returns_empty_indexes(self):
|
||||||
|
payload = {
|
||||||
|
"schema_version": 99,
|
||||||
|
"generated_at": "2026-07-02T00:00:00Z",
|
||||||
|
"sources": {},
|
||||||
|
"packs": {},
|
||||||
|
"nodes": {},
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
Path(tmp, "popular_node_signatures.json").write_text(
|
||||||
|
json.dumps(payload),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
generated = utfcn_core.load_generated_signatures(tmp)
|
||||||
|
|
||||||
|
self.assertEqual({}, generated["sigs"])
|
||||||
|
self.assertEqual({}, generated["meta"])
|
||||||
|
self.assertEqual({}, dict(generated["by_out"]))
|
||||||
|
|
||||||
|
def test_repository_artifact_loads_when_present(self):
|
||||||
|
repo_dir = Path(__file__).resolve().parents[1]
|
||||||
|
generated = utfcn_core.load_generated_signatures(str(repo_dir))
|
||||||
|
|
||||||
|
node_type = "RGB_HexToHSV //Inspire"
|
||||||
|
self.assertEqual({"rgb_hex": "STRING"}, generated["sigs"][node_type]["inputs"])
|
||||||
|
self.assertEqual({"rgb_hex"}, generated["sigs"][node_type]["required"])
|
||||||
|
self.assertEqual(["FLOAT", "FLOAT", "FLOAT"], generated["sigs"][node_type]["outputs"])
|
||||||
|
self.assertEqual(["hue", "saturation", "value"], generated["sigs"][node_type]["output_names"])
|
||||||
|
self.assertEqual("inspire", generated["meta"][node_type]["pack"])
|
||||||
|
self.assertEqual("RGB Hex To HSV (Inspire)", generated["meta"][node_type]["display"])
|
||||||
|
self.assertEqual(
|
||||||
|
"https://github.com/ltdrdata/ComfyUI-Inspire-Pack",
|
||||||
|
generated["meta"][node_type]["repository"],
|
||||||
|
)
|
||||||
|
self.assertIn(node_type, generated["by_out"]["FLOAT"])
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedSignatureMatchingTests(unittest.TestCase):
|
||||||
|
def _ctx(self, rules=None, generated=None):
|
||||||
|
live_sigs = {
|
||||||
|
"CoreImageSize": {
|
||||||
|
"inputs": {"image": "IMAGE"},
|
||||||
|
"required": {"image"},
|
||||||
|
"outputs": ["INT", "INT"],
|
||||||
|
"output_names": ["width", "height"],
|
||||||
|
},
|
||||||
|
"CoreMaskInvert": {
|
||||||
|
"inputs": {"mask": "MASK"},
|
||||||
|
"required": {"mask"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
},
|
||||||
|
"CoreImagePassthrough": {
|
||||||
|
"inputs": {"image": "IMAGE"},
|
||||||
|
"required": {"image"},
|
||||||
|
"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"},
|
||||||
|
"outputs": ["INT", "INT"],
|
||||||
|
"output_names": ["width", "height"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sources = {
|
||||||
|
"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)
|
||||||
|
for name, sig in live_sigs.items():
|
||||||
|
by_out[sig["outputs"][0]].append(name)
|
||||||
|
return {
|
||||||
|
"sources": sources,
|
||||||
|
"sigs": live_sigs,
|
||||||
|
"by_out": by_out,
|
||||||
|
"rules": rules or {},
|
||||||
|
"generated": generated or _empty_generated(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_generated_exact_signature_matches_missing_node_as_verified(self):
|
||||||
|
generated = _empty_generated()
|
||||||
|
generated["sigs"]["SampleImageSize"] = {
|
||||||
|
"inputs": {"image": "IMAGE"},
|
||||||
|
"required": {"image"},
|
||||||
|
"outputs": ["INT", "INT"],
|
||||||
|
"output_names": ["width", "height"],
|
||||||
|
}
|
||||||
|
generated["meta"]["SampleImageSize"] = {
|
||||||
|
"source": "generated",
|
||||||
|
"pack": "sample-pack",
|
||||||
|
"display": "Sample Image Size",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
"confidence": "static_exact",
|
||||||
|
}
|
||||||
|
generated["by_out"]["INT"].append("SampleImageSize")
|
||||||
|
|
||||||
|
result = utfcn_core.match(self._ctx(generated=generated), [{"type": "SampleImageSize"}])
|
||||||
|
|
||||||
|
self.assertEqual("CoreImageSize", result["SampleImageSize"][0]["to"])
|
||||||
|
self.assertEqual("exact", result["SampleImageSize"][0]["tier"])
|
||||||
|
self.assertTrue(result["SampleImageSize"][0]["verified"])
|
||||||
|
|
||||||
|
def test_curated_rule_stays_first_before_generated_exact_match(self):
|
||||||
|
generated = _empty_generated()
|
||||||
|
generated["sigs"]["SampleImageSize"] = {
|
||||||
|
"inputs": {"image": "IMAGE"},
|
||||||
|
"required": {"image"},
|
||||||
|
"outputs": ["INT", "INT"],
|
||||||
|
"output_names": ["width", "height"],
|
||||||
|
}
|
||||||
|
generated["meta"]["SampleImageSize"] = {
|
||||||
|
"source": "generated",
|
||||||
|
"pack": "sample-pack",
|
||||||
|
"display": "Sample Image Size",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
"confidence": "static_exact",
|
||||||
|
}
|
||||||
|
generated["by_out"]["INT"].append("SampleImageSize")
|
||||||
|
rules = {
|
||||||
|
"SampleImageSize": [
|
||||||
|
{
|
||||||
|
"to": "CuratedTarget",
|
||||||
|
"note": "Curated replacement wins over generated exact signature.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = utfcn_core.match(self._ctx(rules=rules, generated=generated), [{"type": "SampleImageSize"}])
|
||||||
|
|
||||||
|
self.assertEqual("CuratedTarget", result["SampleImageSize"][0]["to"])
|
||||||
|
self.assertEqual("curated", result["SampleImageSize"][0]["tier"])
|
||||||
|
self.assertTrue(result["SampleImageSize"][0]["verified"])
|
||||||
|
|
||||||
|
def test_generated_partial_signature_matches_but_is_not_verified(self):
|
||||||
|
generated = _empty_generated()
|
||||||
|
generated["sigs"]["SampleMaskInvert"] = {
|
||||||
|
"inputs": {"masks": "MASK"},
|
||||||
|
"required": {"masks"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
}
|
||||||
|
generated["meta"]["SampleMaskInvert"] = {
|
||||||
|
"source": "generated",
|
||||||
|
"pack": "sample-pack",
|
||||||
|
"display": "Sample Mask Invert",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
"confidence": "static_exact",
|
||||||
|
}
|
||||||
|
generated["by_out"]["MASK"].append("SampleMaskInvert")
|
||||||
|
|
||||||
|
result = utfcn_core.match(self._ctx(generated=generated), [{"type": "SampleMaskInvert"}])
|
||||||
|
|
||||||
|
self.assertEqual("CoreMaskInvert", result["SampleMaskInvert"][0]["to"])
|
||||||
|
self.assertEqual("partial", result["SampleMaskInvert"][0]["tier"])
|
||||||
|
self.assertFalse(result["SampleMaskInvert"][0]["verified"])
|
||||||
|
|
||||||
|
def test_contradictory_generated_signature_falls_back_to_serialized_signature(self):
|
||||||
|
generated = _empty_generated()
|
||||||
|
generated["sigs"]["SampleMaskInvert"] = {
|
||||||
|
"inputs": {"image": "IMAGE"},
|
||||||
|
"required": {"image"},
|
||||||
|
"outputs": ["IMAGE"],
|
||||||
|
"output_names": ["image"],
|
||||||
|
}
|
||||||
|
generated["meta"]["SampleMaskInvert"] = {
|
||||||
|
"source": "generated",
|
||||||
|
"pack": "sample-pack",
|
||||||
|
"display": "Sample Mask Invert",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
"confidence": "static_exact",
|
||||||
|
}
|
||||||
|
generated["by_out"]["IMAGE"].append("SampleMaskInvert")
|
||||||
|
|
||||||
|
result = utfcn_core.match(
|
||||||
|
self._ctx(generated=generated),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "SampleMaskInvert",
|
||||||
|
"inputs": {"masks": "MASK"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("CoreMaskInvert", result["SampleMaskInvert"][0]["to"])
|
||||||
|
self.assertEqual("partial", result["SampleMaskInvert"][0]["tier"])
|
||||||
|
self.assertFalse(result["SampleMaskInvert"][0]["verified"])
|
||||||
|
|
||||||
|
def test_generated_signature_with_different_input_name_falls_back_to_serialized_signature(self):
|
||||||
|
generated = _empty_generated()
|
||||||
|
generated["sigs"]["SampleMaskInvert"] = {
|
||||||
|
"inputs": {"mask": "MASK"},
|
||||||
|
"required": {"mask"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
}
|
||||||
|
generated["meta"]["SampleMaskInvert"] = {
|
||||||
|
"source": "generated",
|
||||||
|
"pack": "sample-pack",
|
||||||
|
"display": "Sample Mask Invert",
|
||||||
|
"repository": "https://github.com/example/sample-pack",
|
||||||
|
"confidence": "static_exact",
|
||||||
|
}
|
||||||
|
generated["by_out"]["MASK"].append("SampleMaskInvert")
|
||||||
|
|
||||||
|
result = utfcn_core.match(
|
||||||
|
self._ctx(generated=generated),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "SampleMaskInvert",
|
||||||
|
"inputs": {"masks": "MASK"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("CoreMaskInvert", result["SampleMaskInvert"][0]["to"])
|
||||||
|
self.assertEqual("partial", result["SampleMaskInvert"][0]["tier"])
|
||||||
|
self.assertFalse(result["SampleMaskInvert"][0]["verified"])
|
||||||
|
|
||||||
|
def test_malformed_generated_context_falls_back_without_raising(self):
|
||||||
|
result = utfcn_core.match(
|
||||||
|
self._ctx(generated={"sigs": "bad", "meta": "bad"}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "SerializedMaskInvert",
|
||||||
|
"inputs": {"masks": "MASK"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("CoreMaskInvert", result["SerializedMaskInvert"][0]["to"])
|
||||||
|
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
|
||||||
|
self.assertFalse(result["SerializedMaskInvert"][0]["verified"])
|
||||||
|
|
||||||
|
def test_serialized_signature_fallback_still_handles_unknown_generated_node(self):
|
||||||
|
result = utfcn_core.match(
|
||||||
|
self._ctx(),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "SerializedMaskInvert",
|
||||||
|
"inputs": {"masks": "MASK"},
|
||||||
|
"outputs": ["MASK"],
|
||||||
|
"output_names": ["mask"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("CoreMaskInvert", result["SerializedMaskInvert"][0]["to"])
|
||||||
|
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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility scripts for UTFCN development."""
|
||||||
File diff suppressed because it is too large
Load Diff
+311
-16
@@ -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,11 +162,168 @@ 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
|
||||||
_MAX_CANDIDATES = 6
|
_MAX_CANDIDATES = 6
|
||||||
|
|
||||||
|
_GENERATED_SCHEMA_VERSION = 1
|
||||||
|
_GENERATED_SIGNATURES_FILE = "popular_node_signatures.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_generated_signatures():
|
||||||
|
return {"sigs": {}, "meta": {}, "by_out": defaultdict(list)}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_generated_signature(node_type, entry):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
if str(entry.get("confidence") or "") == "metadata_only":
|
||||||
|
return None
|
||||||
|
|
||||||
|
inputs_raw = entry.get("inputs") or {}
|
||||||
|
if not isinstance(inputs_raw, dict):
|
||||||
|
return None
|
||||||
|
outputs_raw = entry.get("outputs") or []
|
||||||
|
if not isinstance(outputs_raw, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
inputs = {str(k): str(v) for k, v in inputs_raw.items() if k is not None}
|
||||||
|
outputs = [str(v) for v in outputs_raw if v is not None]
|
||||||
|
if not inputs and not outputs:
|
||||||
|
return None
|
||||||
|
|
||||||
|
required_raw = entry.get("required") or []
|
||||||
|
if not isinstance(required_raw, list):
|
||||||
|
required_raw = []
|
||||||
|
output_names_raw = entry.get("output_names") or []
|
||||||
|
if not isinstance(output_names_raw, list):
|
||||||
|
output_names_raw = []
|
||||||
|
|
||||||
|
sig = {
|
||||||
|
"inputs": inputs,
|
||||||
|
"required": {str(v) for v in required_raw if str(v) in inputs},
|
||||||
|
"outputs": outputs,
|
||||||
|
"output_names": [str(v) for v in output_names_raw],
|
||||||
|
}
|
||||||
|
meta = {
|
||||||
|
"source": "generated",
|
||||||
|
"pack": str(entry.get("pack") or ""),
|
||||||
|
"display": str(entry.get("display") or entry.get("type") or node_type),
|
||||||
|
"repository": str(entry.get("repository") or ""),
|
||||||
|
"confidence": str(entry.get("confidence") or ""),
|
||||||
|
}
|
||||||
|
return sig, meta
|
||||||
|
|
||||||
|
|
||||||
|
def load_generated_signatures(base_dir):
|
||||||
|
path = os.path.join(base_dir, _GENERATED_SIGNATURES_FILE)
|
||||||
|
generated = _empty_generated_signatures()
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return generated
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UTFCN] failed to read {_GENERATED_SIGNATURES_FILE}: {e}")
|
||||||
|
return generated
|
||||||
|
|
||||||
|
if not isinstance(raw, dict) or raw.get("schema_version") != _GENERATED_SCHEMA_VERSION:
|
||||||
|
print(f"[UTFCN] ignored {_GENERATED_SIGNATURES_FILE}: unsupported schema")
|
||||||
|
return generated
|
||||||
|
|
||||||
|
nodes = raw.get("nodes") or {}
|
||||||
|
if not isinstance(nodes, dict):
|
||||||
|
print(f"[UTFCN] ignored {_GENERATED_SIGNATURES_FILE}: nodes must be an object")
|
||||||
|
return generated
|
||||||
|
|
||||||
|
for node_type, entry in nodes.items():
|
||||||
|
normalised = _normalise_generated_signature(str(node_type), entry)
|
||||||
|
if normalised is None:
|
||||||
|
continue
|
||||||
|
sig, meta = normalised
|
||||||
|
generated["sigs"][str(node_type)] = sig
|
||||||
|
generated["meta"][str(node_type)] = meta
|
||||||
|
generated["by_out"][_first_output_type(sig)].append(str(node_type))
|
||||||
|
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
def _normalise_rules(raw):
|
def _normalise_rules(raw):
|
||||||
"""Accept both {source: {...single...}} and {source: [ {...}, {...} ]} shapes."""
|
"""Accept both {source: {...single...}} and {source: [ {...}, {...} ]} shapes."""
|
||||||
@@ -149,7 +349,7 @@ def load_rules(base_dir):
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def build_context(rules):
|
def build_context(rules, generated=None):
|
||||||
"""
|
"""
|
||||||
Snapshot the live node registry once (signatures + source of every node).
|
Snapshot the live node registry once (signatures + source of every node).
|
||||||
|
|
||||||
@@ -176,10 +376,16 @@ def build_context(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)
|
||||||
|
|
||||||
return {"sources": sources, "sigs": sigs, "by_out": by_out, "rules": rules}
|
return {
|
||||||
|
"sources": sources,
|
||||||
|
"sigs": sigs,
|
||||||
|
"by_out": by_out,
|
||||||
|
"rules": rules,
|
||||||
|
"generated": generated or _empty_generated_signatures(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -189,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) ---
|
||||||
@@ -212,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:
|
||||||
@@ -274,6 +484,71 @@ def build_index(ctx):
|
|||||||
return {"sources": sources, "candidates": candidates, "stats": stats}
|
return {"sources": sources, "candidates": candidates, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def _signature_from_item(it):
|
||||||
|
inputs_raw = it.get("inputs") or {}
|
||||||
|
if not isinstance(inputs_raw, dict):
|
||||||
|
inputs_raw = {}
|
||||||
|
outputs_raw = it.get("outputs") or []
|
||||||
|
if not isinstance(outputs_raw, list):
|
||||||
|
outputs_raw = []
|
||||||
|
output_names_raw = it.get("output_names") or []
|
||||||
|
if not isinstance(output_names_raw, list):
|
||||||
|
output_names_raw = []
|
||||||
|
|
||||||
|
inputs = {str(k): str(v) for k, v in inputs_raw.items() if k is not None}
|
||||||
|
return {
|
||||||
|
"inputs": inputs,
|
||||||
|
"required": set(inputs),
|
||||||
|
"outputs": [str(x) for x in outputs_raw],
|
||||||
|
"output_names": [str(x) for x in output_names_raw],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_signature_usable(sig):
|
||||||
|
return isinstance(sig, dict) and isinstance(sig.get("inputs"), dict) and isinstance(sig.get("outputs"), list)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalised_generated_signature(sig):
|
||||||
|
if not _generated_signature_usable(sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
inputs = {str(k): str(v) for k, v in sig["inputs"].items() if k is not None}
|
||||||
|
outputs = [str(x) for x in sig["outputs"]]
|
||||||
|
required_raw = sig.get("required") or []
|
||||||
|
if not isinstance(required_raw, (list, set, tuple)):
|
||||||
|
required_raw = []
|
||||||
|
output_names_raw = sig.get("output_names") or []
|
||||||
|
if not isinstance(output_names_raw, list):
|
||||||
|
output_names_raw = []
|
||||||
|
return {
|
||||||
|
"inputs": inputs,
|
||||||
|
"required": {str(v) for v in required_raw if str(v) in inputs},
|
||||||
|
"outputs": outputs,
|
||||||
|
"output_names": [str(x) for x in output_names_raw],
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_signature_conflicts(serialized_sig, generated_sig):
|
||||||
|
if not serialized_sig["inputs"] and not serialized_sig["outputs"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
generated_inputs = generated_sig["inputs"]
|
||||||
|
for name, typ in serialized_sig["inputs"].items():
|
||||||
|
if name in generated_inputs:
|
||||||
|
if generated_inputs[name] != typ:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if Counter(serialized_sig["outputs"]) - Counter(generated_sig["outputs"]):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def match(ctx, items):
|
def match(ctx, items):
|
||||||
"""
|
"""
|
||||||
Match a batch of nodes given only their (possibly serialized) signature —
|
Match a batch of nodes given only their (possibly serialized) signature —
|
||||||
@@ -281,23 +556,43 @@ 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) and 'partial' link-type matches do.
|
fires; curated rules (by type name), bundled generated signatures, and
|
||||||
|
feature-gated partial link-type matches do.
|
||||||
|
|
||||||
Returns { type: [candidate, ...] }.
|
Returns a mapping from source node type to candidate list.
|
||||||
"""
|
"""
|
||||||
out = {}
|
out = {}
|
||||||
|
generated = ctx.get("generated") or {}
|
||||||
|
if not isinstance(generated, dict):
|
||||||
|
generated = {}
|
||||||
|
generated_sigs = generated.get("sigs") or {}
|
||||||
|
if not isinstance(generated_sigs, dict):
|
||||||
|
generated_sigs = {}
|
||||||
|
generated_meta = generated.get("meta") or {}
|
||||||
|
if not isinstance(generated_meta, dict):
|
||||||
|
generated_meta = {}
|
||||||
|
|
||||||
for it in items:
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
t = it.get("type")
|
t = it.get("type")
|
||||||
if not t or t in out:
|
if not t or t in out:
|
||||||
continue
|
continue
|
||||||
inputs = {k: str(v) for k, v in (it.get("inputs") or {}).items()}
|
|
||||||
sig = {
|
sig = _signature_from_item(it)
|
||||||
"inputs": inputs,
|
gen_sig = _normalised_generated_signature(generated_sigs.get(t))
|
||||||
"required": set(inputs),
|
if gen_sig is not None and not _generated_signature_conflicts(sig, gen_sig):
|
||||||
"outputs": [str(x) for x in (it.get("outputs") or [])],
|
gen_meta = generated_meta.get(t) or {}
|
||||||
"output_names": list(it.get("output_names") or []),
|
if not isinstance(gen_meta, dict):
|
||||||
}
|
gen_meta = {}
|
||||||
found = _candidates_for(t, sig, None, ctx)
|
gen_pack = gen_meta.get("pack")
|
||||||
|
found = _candidates_for(t, gen_sig, gen_pack, ctx, gen_meta)
|
||||||
|
if found:
|
||||||
|
out[t] = found
|
||||||
|
continue
|
||||||
|
|
||||||
|
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