ComfyUI templates annotate loader nodes with properties.models, giving the download URL and target folder for every file they need. The new "Workflow Models" tab reads that metadata so a template's models can be fetched in one click instead of hunting down each link by hand. Sources: the graph currently open on the canvas, or any of the bundled ComfyUI templates, grouped by category and filterable by title, description or model name. The right panel groups the results by model type and shows size, destination folder and whether each file is already on disk, with a "Download all missing" action. Rows reuse the existing file-row renderer, so progress bars, stop buttons and download polling work unchanged. Extraction walks the workflow recursively rather than scanning workflow["nodes"]. Newer subgraphed templates keep their loaders under definitions.subgraphs[].nodes[], where a top-level scan finds nothing at all — 03_video_wan2_2_14B_i2v_subgraphed.json has zero models on top-level nodes and six inside its subgraph. Of 378 installed template files, 162 declare models, 641 entries in total. The template's own "directory" field decides the destination; only when it is absent does the filename classifier take over, and such rows are marked as guessed. Size lookups run eight at a time and are skipped past 40 files so the tab cannot stall on a slow host. Template index loading only caches a non-empty result. The index can fail transiently while ComfyUI is still starting, and caching an empty array left the tab permanently blank for the rest of the session; a failure now reports the reason and offers a retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""
|
|
Extract the models a ComfyUI workflow needs.
|
|
|
|
ComfyUI templates (and workflows saved from them) annotate loader nodes with a
|
|
`properties.models` list holding the download URL and target folder for each
|
|
file. This module pulls those out so they can be downloaded in one go.
|
|
"""
|
|
import json
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import urlparse, unquote
|
|
|
|
import requests
|
|
|
|
from .model_manager import classify_file, scan_local_models, url_headers
|
|
from .link_resolver import _remote_file_info
|
|
|
|
# Above this many files, skip the size lookups rather than fire off a HEAD
|
|
# request per model and make the tab feel stuck.
|
|
_MAX_SIZE_LOOKUPS = 40
|
|
|
|
|
|
def _walk_models(obj, out: list):
|
|
"""Collect every properties.models entry, at any depth.
|
|
|
|
Subgraphed templates keep their loaders under
|
|
definitions.subgraphs[].nodes[], so scanning workflow["nodes"] alone
|
|
misses them entirely.
|
|
"""
|
|
if isinstance(obj, dict):
|
|
props = obj.get("properties")
|
|
if isinstance(props, dict):
|
|
models = props.get("models")
|
|
if isinstance(models, list):
|
|
node_type = obj.get("type") if isinstance(obj.get("type"), str) else ""
|
|
title = obj.get("title") if isinstance(obj.get("title"), str) else ""
|
|
for m in models:
|
|
if isinstance(m, dict) and m.get("url"):
|
|
out.append((m, node_type, title))
|
|
for value in obj.values():
|
|
_walk_models(value, out)
|
|
elif isinstance(obj, list):
|
|
for value in obj:
|
|
_walk_models(value, out)
|
|
|
|
|
|
def extract_workflow_models(workflow: dict, with_sizes: bool = True) -> list[dict]:
|
|
"""Return the model files a workflow references, ready to download."""
|
|
found: list = []
|
|
_walk_models(workflow, found)
|
|
|
|
entries: list[dict] = []
|
|
seen: set = set()
|
|
for meta, node_type, title in found:
|
|
url = str(meta.get("url") or "").strip()
|
|
if not url or urlparse(url).scheme not in ("http", "https"):
|
|
continue
|
|
|
|
name = Path(str(meta.get("name") or "")).name
|
|
if not name:
|
|
name = Path(unquote(urlparse(url).path)).name
|
|
if not name:
|
|
continue
|
|
|
|
key = (name.lower(), url)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
# The template states where the file belongs; fall back to the same
|
|
# classifier the rest of the plugin uses when it does not.
|
|
folder = str(meta.get("directory") or "").strip()
|
|
guessed = False
|
|
if not folder:
|
|
folder = classify_file(name, "")[0] or "checkpoints"
|
|
guessed = True
|
|
|
|
source = node_type or title or "workflow"
|
|
entries.append({
|
|
"filename": name,
|
|
"path": name,
|
|
"local_folder": folder,
|
|
"category": folder,
|
|
"size": None,
|
|
"direct_url": url,
|
|
"repo_id": "workflow",
|
|
"file_type": "model",
|
|
"downloaded": False,
|
|
"node_type": source,
|
|
"note": f"{source} → {folder}" + (" (folder guessed)" if guessed else ""),
|
|
})
|
|
|
|
_mark_downloaded(entries)
|
|
if with_sizes and 0 < len(entries) <= _MAX_SIZE_LOOKUPS:
|
|
_fill_sizes(entries)
|
|
return entries
|
|
|
|
|
|
def _mark_downloaded(entries: list[dict]) -> None:
|
|
local = scan_local_models()
|
|
for e in entries:
|
|
e["downloaded"] = e["filename"] in (local.get(e["local_folder"]) or [])
|
|
|
|
|
|
def _fill_sizes(entries: list[dict]) -> None:
|
|
"""Look up file sizes in parallel; a failure just leaves size as None."""
|
|
def probe(entry: dict):
|
|
try:
|
|
size, _name, status = _remote_file_info(entry["direct_url"])
|
|
if status and status < 400:
|
|
entry["size"] = size
|
|
elif status in (401, 403):
|
|
entry["note"] += " · needs auth"
|
|
elif status == 404:
|
|
entry["note"] += " · link is dead (404)"
|
|
except Exception:
|
|
pass
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
list(pool.map(probe, entries))
|
|
|
|
|
|
def fetch_workflow(url: str) -> dict:
|
|
"""Load a workflow JSON from a URL (a template path or any http(s) link)."""
|
|
resp = requests.get(url, headers={"User-Agent": "ComfyUI-ModelDownloader/1.0",
|
|
**url_headers(url)}, timeout=20)
|
|
resp.raise_for_status()
|
|
return resp.json()
|