ComfyUI keeps no lasting note of what a run used, so AMB now notes each queued run's node types and model files (on_prompt hook, user/anomalous/ usage_ledger.json). "Not used lately" reads it with the outputs, now sounds too, and the saved workflows, and judges nothing that came in the last 30 days; while the record is younger than the group shown, the page says so. Node packs get their own page: installed for how long, whether they add to the page, which packs take what only they make, and a button opening ComfyUI Manager's node list searched for the pack. Packs without nodes are listed apart, as nothing can tell whether they are used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
13 KiB
Python
299 lines
13 KiB
Python
"""When each model and node pack here was last used (the models page's Tidy tab, "Not used lately").
|
|
|
|
GET /anomalous/usage/check -> {models, packs, seen, now, ledger_since}
|
|
Evidence, read only when the user asks:
|
|
- the run record (usage_ledger.py): every run since it started, kept or not;
|
|
- the outputs from before that: PNG images (their prompt names model files and node types;
|
|
image_search.cached_record) and FLAC / Ogg sounds (audio_metadata), dated by the file;
|
|
- the saved ComfyUI workflows, recipes, combos and parameter sets (any string naming a model
|
|
file, any node type, any SHA-256).
|
|
A model counts as used when its path, its file name (two files of one name: both count, the
|
|
safe side) or its scanned SHA-256 appears.
|
|
Nothing new is judged: a model or pack counts as idle since its last use or since it came
|
|
here (`added`: the file's or folder's creation date), whichever is later.
|
|
models: idle for 30 days or more, with {type, root, rel, size, name, version, preview_url,
|
|
last_used (seconds or null), outputs, runs, saved (saved files naming it), added}.
|
|
packs: the node packs in custom_nodes but this plugin and ComfyUI Manager, {name, nodes,
|
|
last_used, outputs, runs, saved, added, web (it adds to ComfyUI's page too), feeds (packs
|
|
whose nodes take a kind of thing only its nodes make), words (what to search the Manager
|
|
for)}; a pack of no nodes only works on the page or the server, nothing here can tell.
|
|
seen: {outputs, saved, capped} what was read (capped: some outputs or files were not).
|
|
ledger_since: when the run record started (null: nothing run since).
|
|
|
|
What none of this can show (a workflow from before the record, dropped in and never saved) is
|
|
said on the page; nothing here deletes or disables anything.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
|
|
import folder_paths
|
|
from aiohttp import web
|
|
|
|
from . import usage_ledger
|
|
from .audio_metadata import read_audio_tags
|
|
from .backup import part_dirs
|
|
from .image_search import MODEL_FILE, cached_record
|
|
from .model_placement import _card, _known_hash, library
|
|
|
|
RECENT_SECONDS = 30 * 24 * 3600 # used or added within this: not listed
|
|
MAX_IMAGES = 100_000 # outputs read at most, images and sounds
|
|
MAX_SAVED = 20_000
|
|
MAX_JSON_BYTES = 20 * 1024 * 1024
|
|
SAVED_PARTS = ("comfy_workflows", "recipes", "combos", "parameters")
|
|
SOUNDS = (".flac", ".ogg", ".opus")
|
|
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
NOT_LISTED = {os.path.basename(PLUGIN_DIR).lower(), "comfyui-manager"} # the tools doing the tidying
|
|
FEEDS_SHOWN = 3
|
|
# Words left out of what the Manager is searched for: its search wants all words, in order, in
|
|
# one of a pack's title, author or description, and pack folders carry these where titles do not.
|
|
SEARCH_NOISE = {"comfyui", "comfy", "custom", "customnodes", "node", "nodes", "pack", "main", "master"}
|
|
|
|
|
|
def _refs(value):
|
|
ref = str(value).replace("\\", "/").strip().lower()
|
|
return {ref, ref.rsplit("/", 1)[-1]}
|
|
|
|
|
|
def _arrived(stat, changed=True):
|
|
"""When a file or folder came here: its creation date, or its last change when that is later
|
|
(`changed`; a copied file keeps its old change date, a folder's changes with every update)."""
|
|
born = getattr(stat, "st_birthtime", None) or stat.st_ctime
|
|
return max(born, stat.st_mtime) if changed else born
|
|
|
|
|
|
class Evidence:
|
|
"""Per model reference / hash / node type: how many outputs, runs and saved files, and the latest time."""
|
|
|
|
def __init__(self):
|
|
self.outputs, self.runs, self.saved, self.last = {}, {}, {}, {}
|
|
self.seen = {"outputs": 0, "saved": 0, "capped": False}
|
|
|
|
def add(self, keys, when=None):
|
|
for key in keys:
|
|
if when is None:
|
|
self.saved[key] = self.saved.get(key, 0) + 1
|
|
else:
|
|
self.outputs[key] = self.outputs.get(key, 0) + 1
|
|
self.last[key] = max(self.last.get(key, 0), when)
|
|
|
|
def add_runs(self, key, when, runs):
|
|
self.runs[key] = self.runs.get(key, 0) + runs
|
|
self.last[key] = max(self.last.get(key, 0), when)
|
|
|
|
def of(self, keys):
|
|
keys = [key for key in keys if key]
|
|
last = max((self.last.get(key, 0) for key in keys), default=0)
|
|
return {"last_used": last or None,
|
|
**{kind: max((counts.get(key, 0) for key in keys), default=0)
|
|
for kind, counts in (("outputs", self.outputs), ("runs", self.runs), ("saved", self.saved))}}
|
|
|
|
|
|
def _walk_json(value, found):
|
|
"""Model references ('m:'), SHA-256s ('h:') and node types ('t:') anywhere in saved JSON."""
|
|
stack = [value]
|
|
while stack:
|
|
item = stack.pop()
|
|
if isinstance(item, dict):
|
|
for key in ("type", "class_type"):
|
|
if isinstance(item.get(key), str) and item[key]:
|
|
found.add("t:" + item[key])
|
|
stack.extend(item.values())
|
|
elif isinstance(item, list):
|
|
stack.extend(item)
|
|
elif isinstance(item, str):
|
|
if MODEL_FILE.search(item):
|
|
found.update("m:" + ref for ref in _refs(item))
|
|
elif SHA256.match(item):
|
|
found.add("h:" + item.upper())
|
|
|
|
|
|
def _read_saved(evidence):
|
|
dirs = part_dirs()
|
|
for part in SAVED_PARTS:
|
|
base = dirs.get(part)
|
|
if not base or not os.path.isdir(base):
|
|
continue
|
|
for current, folders, names in os.walk(base):
|
|
folders[:] = [name for name in folders if not name.startswith(".")]
|
|
for name in names:
|
|
if not name.lower().endswith(".json"):
|
|
continue
|
|
if evidence.seen["saved"] >= MAX_SAVED:
|
|
evidence.seen["capped"] = True
|
|
return
|
|
path = os.path.join(current, name)
|
|
try:
|
|
if os.path.getsize(path) > MAX_JSON_BYTES:
|
|
continue
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, ValueError):
|
|
continue
|
|
found = set()
|
|
_walk_json(data, found)
|
|
evidence.seen["saved"] += 1
|
|
evidence.add(found)
|
|
|
|
|
|
def _sound_keys(path):
|
|
"""What a sound's embedded prompt names, as evidence keys; empty without one."""
|
|
try:
|
|
prompt = json.loads(read_audio_tags(path).get("prompt") or "null")
|
|
except (OSError, ValueError):
|
|
return set()
|
|
if not isinstance(prompt, dict):
|
|
return set()
|
|
types, models = usage_ledger.prompt_uses(prompt)
|
|
return {"t:" + node_type for node_type in types} | {"m:" + ref for model in models for ref in _refs(model)}
|
|
|
|
|
|
def _read_outputs(evidence):
|
|
base = folder_paths.get_output_directory()
|
|
for current, folders, names in os.walk(base):
|
|
folders[:] = [name for name in folders if not name.startswith(".")]
|
|
for name in names:
|
|
lower = name.lower()
|
|
if not lower.endswith((".png",) + SOUNDS):
|
|
continue
|
|
if evidence.seen["outputs"] >= MAX_IMAGES:
|
|
evidence.seen["capped"] = True
|
|
return
|
|
path = os.path.join(current, name)
|
|
try:
|
|
when = os.stat(path).st_mtime
|
|
except OSError:
|
|
continue
|
|
if lower.endswith(SOUNDS):
|
|
keys = _sound_keys(path)
|
|
else:
|
|
record = cached_record(path, when)
|
|
if not record:
|
|
continue
|
|
keys = {"m:" + ref for ref in record["models"]} | {"h:" + sha for sha in record["hashes"]}
|
|
keys |= {"t:" + node_type for node_type in record.get("types", ())}
|
|
if not keys:
|
|
continue
|
|
evidence.seen["outputs"] += 1
|
|
evidence.add(keys, when)
|
|
|
|
|
|
def _read_ledger(evidence):
|
|
"""Every run since the record started; its start, or None."""
|
|
ledger = usage_ledger.read()
|
|
for node_type, (when, runs) in ledger["types"].items():
|
|
evidence.add_runs("t:" + node_type, when, runs)
|
|
for model, (when, runs) in ledger["models"].items():
|
|
for ref in _refs(model):
|
|
evidence.add_runs("m:" + ref, when, runs)
|
|
return ledger["started"]
|
|
|
|
|
|
def _io_types(cls):
|
|
"""(kinds of thing a node takes, kinds it makes); combo lists and '*' left out."""
|
|
taken, made = set(), set()
|
|
try:
|
|
inputs = cls.INPUT_TYPES() or {}
|
|
for group in ("required", "optional"):
|
|
for spec in (inputs.get(group) or {}).values():
|
|
kind = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
|
|
if isinstance(kind, str):
|
|
taken.add(kind)
|
|
except Exception: # noqa: BLE001 - a pack's own code; it only means "takes nothing known"
|
|
pass
|
|
try:
|
|
made = {kind for kind in (cls.RETURN_TYPES or ()) if isinstance(kind, str)}
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return taken - {"*"}, made - {"*"}
|
|
|
|
|
|
def _search_words(name):
|
|
words = [word for word in re.split(r"[\s_.\-]+", name.lower()) if word and word not in SEARCH_NOISE]
|
|
return " ".join(words) or name.lower()
|
|
|
|
|
|
def _packs():
|
|
"""[{name, path, dir, types, web, feeds}] for the node packs ComfyUI loaded from custom_nodes."""
|
|
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
|
|
|
|
roots = []
|
|
try:
|
|
roots = [os.path.normcase(os.path.realpath(root)) for root in folder_paths.get_folder_paths("custom_nodes") or []]
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
packs = {}
|
|
for module, directory in (getattr(nodes, "LOADED_MODULE_DIRS", {}) or {}).items():
|
|
real = os.path.normcase(os.path.realpath(directory))
|
|
name = os.path.splitext(os.path.basename(module))[0]
|
|
if real in roots: # a single .py file in custom_nodes
|
|
packs[name] = {"name": name, "path": os.path.join(directory, name + ".py"), "dir": None, "types": []}
|
|
elif os.path.normcase(os.path.realpath(os.path.dirname(directory))) in roots:
|
|
packs[name] = {"name": name, "path": directory, "dir": real, "types": []}
|
|
|
|
io, core = {}, set()
|
|
for node_type, cls in (getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}).items():
|
|
module = str(getattr(cls, "RELATIVE_PYTHON_MODULE", "") or "")
|
|
taken, made = _io_types(cls)
|
|
pack = module.split(".", 2)[1] if module.startswith("custom_nodes.") else None
|
|
if pack in packs:
|
|
packs[pack]["types"].append(node_type)
|
|
io.setdefault(pack, [set(), set()])
|
|
io[pack][0] |= taken
|
|
io[pack][1] |= made
|
|
elif pack is None:
|
|
core |= taken | made
|
|
|
|
web_dirs = [os.path.normcase(os.path.realpath(path)) for path in (getattr(nodes, "EXTENSION_WEB_DIRS", {}) or {}).values()]
|
|
for name, pack in packs.items():
|
|
pack["web"] = bool(pack["dir"]) and any(path == pack["dir"] or path.startswith(pack["dir"] + os.sep) for path in web_dirs)
|
|
own = io.get(name, [set(), set()])[1] - core
|
|
pack["feeds"] = sorted((other for other, (taken, _) in io.items() if other != name and own & taken), key=str.lower)
|
|
return [pack for name, pack in packs.items() if name.lower() not in NOT_LISTED]
|
|
|
|
|
|
def check(now=None):
|
|
now = time.time() if now is None else now
|
|
evidence = Evidence()
|
|
ledger_since = _read_ledger(evidence)
|
|
_read_saved(evidence)
|
|
_read_outputs(evidence)
|
|
|
|
models = []
|
|
for item in library():
|
|
keys = ["m:" + ref for ref in _refs(item["rel"])]
|
|
sha = _known_hash(item)
|
|
if sha:
|
|
keys.append("h:" + sha.upper())
|
|
use = evidence.of(keys)
|
|
added = _arrived(item["stat"]) if item.get("stat") else None
|
|
if now - max(use["last_used"] or 0, added or 0) < RECENT_SECONDS:
|
|
continue
|
|
models.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
|
|
"added": added, **_card(item), **use})
|
|
|
|
packs = []
|
|
for pack in sorted(_packs(), key=lambda entry: entry["name"].lower()):
|
|
uses = [evidence.of(["t:" + node_type]) for node_type in pack["types"]]
|
|
try:
|
|
added = _arrived(os.stat(pack["path"]), changed=False)
|
|
except OSError:
|
|
added = None
|
|
packs.append({"name": pack["name"], "nodes": len(pack["types"]), "added": added, "web": pack["web"],
|
|
"feeds": pack["feeds"][:FEEDS_SHOWN], "words": _search_words(pack["name"]),
|
|
"last_used": max((use["last_used"] or 0 for use in uses), default=0) or None,
|
|
**{kind: max((use[kind] for use in uses), default=0) for kind in ("outputs", "runs", "saved")}})
|
|
return {"models": models, "packs": packs, "seen": evidence.seen, "now": now, "ledger_since": ledger_since}
|
|
|
|
|
|
async def api_check(request):
|
|
return web.json_response(await asyncio.to_thread(check))
|
|
|
|
|
|
def register_routes(app):
|
|
app.router.add_get("/anomalous/usage/check", api_check)
|