ComfyUI / download_missing_models.sh
aleph65's picture
download_missing_models.sh: always sync workflows/ from the repo before planning (add --no-refresh to opt out) β€” stale baked-in workflows made the plan miss newly added models
63dcaed verified
Raw
History Blame
28.1 kB
#!/usr/bin/env bash
# download_missing_models.sh
# Interactively pick workflows from ./workflows, then:
# 1. Find every custom-node pack the workflows depend on (via each node's
# cnr_id/aux_id metadata, with Comfy Registry + ComfyUI-Manager DB lookups
# as fallbacks), git-clone them into ComfyUI/custom_nodes, and install
# their Python requirements β€” so they're live on the next ComfyUI restart.
# 2. Find every model they reference, locate those models in the HF repo
# aleph65/ComfyUI (which mirrors ComfyUI/models/), and download the
# missing ones into place, in parallel, with progress.
#
# Usage:
# ./download_missing_models.sh # interactive workflow picker
# ./download_missing_models.sh --all # select every workflow
# ./download_missing_models.sh qwen-edit.json # select specific workflow(s)
# ./download_missing_models.sh --dry-run ... # show the plan, install/download nothing
# ./download_missing_models.sh --skip-nodes .. # models only, skip custom-node installs
# ./download_missing_models.sh --yes ... # skip the "Proceed?" confirmation
# ./download_missing_models.sh --no-refresh .. # don't re-sync ./workflows from the HF repo first
#
# Auth: uses the HUGGING_FACE_ACCESS_TOKEN environment variable if set
# (falls back to HF_TOKEN), otherwise prompts for a token.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export HF_HUB_ENABLE_HF_TRANSFER=1
# never let git sit on an interactive credential/hostkey prompt
export GIT_TERMINAL_PROMPT=0
# 1) Hugging Face token: HUGGING_FACE_ACCESS_TOKEN, then HF_TOKEN, then prompt.
if [[ -n "${HUGGING_FACE_ACCESS_TOKEN:-}" ]]; then
HF_TOKEN="$HUGGING_FACE_ACCESS_TOKEN"
echo "Using HF token from environment (HUGGING_FACE_ACCESS_TOKEN)."
elif [[ -n "${HF_TOKEN:-}" ]]; then
echo "Using HF token from environment (HF_TOKEN)."
else
read -r -s -p "Enter your Hugging Face token (input hidden): " HF_TOKEN
echo
[[ -n "$HF_TOKEN" ]] || { echo "No token provided β€” aborting."; exit 1; }
fi
export HF_TOKEN
# 2) Ensure deps (huggingface_hub >= 1.x ships fast Xet downloads built in and no
# longer provides the hf-transfer extra, so hf_transfer is best-effort only)
if ! python3 -c "import huggingface_hub" >/dev/null 2>&1; then
echo "Installing huggingface_hub[hf_transfer]..."
pip install -q -U "huggingface_hub[hf_transfer]" || pip install -q -U huggingface_hub
fi
python3 -c "import hf_transfer" >/dev/null 2>&1 || pip install -q hf_transfer >/dev/null 2>&1 || true
# 3) Run the downloader.
# Load the Python code into a variable (NOT via stdin, which must stay attached
# to the terminal for the interactive prompts).
PYCODE=$(cat <<'PYEOF'
import json
import os
import re
import socket
import subprocess
import sys
import glob
import threading
import time
import urllib.request
# belt-and-braces: no bare socket (HF API pagination etc.) may stall forever
socket.setdefaulttimeout(60)
WORKSPACE = os.environ.get("COMFY_WORKSPACE", "/workspace")
WORKFLOWS_DIR = os.path.join(WORKSPACE, "workflows")
COMFY_DIR = os.path.join(WORKSPACE, "ComfyUI")
CUSTOM_NODES_DIR = os.path.join(COMFY_DIR, "custom_nodes")
REPO_ID = "aleph65/ComfyUI"
MODEL_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx")
SKIP_NODE_TYPES = {"MarkdownNote", "Note", "PrimitiveString", "String"}
# node types that never imply a custom-node pack
DEP_SKIP_TYPES = {"MarkdownNote", "Note", "Reroute", "PrimitiveNode"}
REGISTRY_URL = "https://api.comfy.org/nodes/{}"
NODE_MAP_URL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json"
C_RESET, C_GREEN, C_YELLOW, C_RED, C_CYAN, C_BOLD = "\033[0m", "\033[32m", "\033[33m", "\033[31m", "\033[36m", "\033[1m"
def human(n):
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024 or unit == "TB":
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
n /= 1024
# ---------------- workflow selection ----------------
def pick_workflows(files, argv):
names = [os.path.basename(f) for f in files]
args = [a for a in argv if not a.startswith("-")]
if "--all" in argv:
return files
if args:
chosen = []
for a in args:
matches = [f for f in files if os.path.basename(f) == a or os.path.basename(f) == a + ".json"]
if not matches:
sys.exit(f"No workflow named '{a}' in {WORKFLOWS_DIR}")
chosen += matches
return chosen
print(f"\n{C_BOLD}Available workflows:{C_RESET}")
for i, name in enumerate(names, 1):
print(f" {i}) {name}")
print()
while True:
try:
raw = input("Select workflows (e.g. '1 3', '1,3', or 'all'; q to quit): ").strip().lower()
except EOFError:
sys.exit("\nNo input available. Run interactively, or pass workflow names or --all.")
if raw in ("q", "quit", "exit"):
sys.exit("Aborted.")
if raw in ("all", "a", "*"):
return files
picks = raw.replace(",", " ").split()
if picks and all(p.isdigit() and 1 <= int(p) <= len(names) for p in picks):
return [files[int(p) - 1] for p in dict.fromkeys(picks)]
print(f" Invalid selection β€” enter numbers 1-{len(names)}, or 'all'.")
# ---------------- model extraction ----------------
def looks_like_model(v):
if not isinstance(v, str):
return False
v = v.strip()
if not v.lower().endswith(MODEL_EXTS):
return False
if "\n" in v or "http://" in v or "https://" in v or len(v) > 300:
return False
return True
def iter_graph_nodes(data):
"""Yield every node dict in a workflow, including subgraph instances/definitions."""
def walk(nodes):
for n in nodes:
if not isinstance(n, dict):
continue
yield n
sub = n.get("subgraph")
if isinstance(sub, dict) and isinstance(sub.get("nodes"), list):
yield from walk(sub["nodes"])
if isinstance(data, dict) and isinstance(data.get("nodes"), list):
yield from walk(data["nodes"])
for sg in (data.get("definitions") or {}).get("subgraphs", []) or []:
if isinstance(sg, dict) and isinstance(sg.get("nodes"), list):
yield from walk(sg["nodes"])
elif isinstance(data, dict):
# API format: {"1": {"class_type": ..., "inputs": {...}}, ...}
for n in data.values():
if isinstance(n, dict) and "class_type" in n:
yield n
def extract_models(data):
"""Return set of model refs (may include subdirs like 'flux2/klein/x.safetensors')."""
refs = set()
def scan_values(values):
stack = [values]
while stack:
v = stack.pop()
if isinstance(v, str) and looks_like_model(v):
refs.add(v.strip().replace("\\", "/"))
elif isinstance(v, list):
stack.extend(v)
elif isinstance(v, dict):
stack.extend(v.values())
for n in iter_graph_nodes(data):
if n.get("type") in SKIP_NODE_TYPES or n.get("class_type") in SKIP_NODE_TYPES:
continue
if "widgets_values" in n:
scan_values(n["widgets_values"])
if isinstance(n.get("inputs"), dict):
scan_values(list(n["inputs"].values()))
return refs
# ---------------- custom-node dependency extraction ----------------
def norm_name(s):
return re.sub(r"[-_.\s]", "", (s or "").lower())
def fetch_json(url, timeout=30, deadline=None):
"""GET a JSON URL. `timeout` bounds each socket op; `deadline` bounds the
whole download (a slow-dripping CDN can otherwise stretch a 2 MB fetch
to many silent minutes without ever tripping the per-read timeout)."""
req = urllib.request.Request(url, headers={"User-Agent": "comfy-dep-installer"})
with urllib.request.urlopen(req, timeout=timeout) as r:
if deadline is None:
return json.load(r)
start, chunks = time.time(), []
while True:
if time.time() - start > deadline:
raise TimeoutError(f"download exceeded {deadline}s")
chunk = r.read(65536)
if not chunk:
break
chunks.append(chunk)
return json.loads(b"".join(chunks))
_node_map_cache = None
def node_map():
"""ComfyUI-Manager's extension-node-map: repo URL -> list of node types.
Cached on disk for a day β€” the fetch can crawl on pod networks."""
global _node_map_cache
if _node_map_cache is None:
cache_file = os.path.join(WORKSPACE, ".cache", "extension-node-map.json")
try:
if os.path.exists(cache_file) and time.time() - os.path.getmtime(cache_file) < 86400:
with open(cache_file) as fh:
raw = json.load(fh)
else:
print(f" {C_CYAN}fetching ComfyUI-Manager node map (~2 MB β€” can take a minute on slow networks) ...{C_RESET}")
raw = fetch_json(NODE_MAP_URL, deadline=180)
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
with open(cache_file, "w") as fh:
json.dump(raw, fh)
_node_map_cache = {url: entry[0] for url, entry in raw.items()
if isinstance(entry, list) and entry and isinstance(entry[0], list)}
except Exception as e:
print(f" {C_YELLOW}⚠ could not fetch ComfyUI-Manager node map ({e}){C_RESET}")
_node_map_cache = {}
return _node_map_cache
def extract_node_deps(data):
"""Return ({pack_key: dep}, {propertyless node types}).
dep = {'cnr_id', 'aux_id', 'ver', 'types': set}"""
deps, orphan_types = {}, set()
uuid_re = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
for n in iter_graph_nodes(data):
ntype = n.get("type") or n.get("class_type") or ""
if ntype in DEP_SKIP_TYPES or uuid_re.match(ntype):
continue
props = n.get("properties") or {}
cnr_id, aux_id, ver = props.get("cnr_id"), props.get("aux_id"), props.get("ver")
if cnr_id == "comfy-core":
continue
if cnr_id or aux_id:
key = norm_name(cnr_id or aux_id.split("/")[-1])
dep = deps.setdefault(key, {"cnr_id": cnr_id, "aux_id": aux_id, "ver": ver, "types": set()})
dep["types"].add(ntype)
# a commit-hash ver is more specific than semver; keep the hash if seen
if ver and re.fullmatch(r"[0-9a-f]{40}", str(dep.get("ver") or ""), re.I) is None:
dep["ver"] = ver
elif ntype:
orphan_types.add(ntype)
return deps, orphan_types
def grep_defines(ntype, paths):
"""True if any python file under paths mentions the node type as a whole word."""
targets = [p for p in paths if os.path.exists(p)]
if not targets:
return False
r = subprocess.run(["grep", "-rlwF", "--include=*.py", "-e", ntype] + targets,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return r.returncode == 0
def is_core_node(ntype):
return grep_defines(ntype, [
os.path.join(COMFY_DIR, "nodes.py"),
os.path.join(COMFY_DIR, "comfy_extras"),
os.path.join(COMFY_DIR, "comfy_api_nodes"),
])
def installed_packs():
"""norm_name(dir) -> dir for every pack already in custom_nodes."""
out = {}
if os.path.isdir(CUSTOM_NODES_DIR):
for d in os.listdir(CUSTOM_NODES_DIR):
full = os.path.join(CUSTOM_NODES_DIR, d)
if os.path.isdir(full) and not d.startswith((".", "__")):
out[norm_name(d)] = d
return out
def provided_by_installed(ntype):
"""True if an installed pack plausibly defines this node type. Display
names like 'Power Lora Loader (rgthree)' are often built dynamically, so
the exact string never appears in .py source β€” also try the base name and
match a parenthetical suffix against installed pack dir names."""
if grep_defines(ntype, [CUSTOM_NODES_DIR]):
return True
m = re.match(r"^(.+?)\s*\(([^()]+)\)$", ntype)
if m:
base, suffix = m.group(1), norm_name(m.group(2))
if suffix and any(suffix in key for key in installed_packs()):
return True
if grep_defines(base, [CUSTOM_NODES_DIR]):
return True
return False
def resolve_repo_url(dep):
"""Find the git URL for a dep. Registry first, then aux_id, then Manager DB."""
if dep.get("cnr_id"):
try:
info = fetch_json(REGISTRY_URL.format(dep["cnr_id"]))
if info.get("repository"):
return info["repository"], "comfy registry"
except Exception:
pass
if dep.get("aux_id") and "/" in dep["aux_id"]:
return f"https://github.com/{dep['aux_id']}", "aux_id"
# fall back to ComfyUI-Manager's node map: which repos provide these node types?
want_key = norm_name(dep.get("cnr_id") or "")
candidates = [url for url, types in node_map().items()
if dep["types"] & set(types)]
if candidates:
# prefer a repo whose name matches the cnr_id
exact = [u for u in candidates if norm_name(u.rstrip("/").split("/")[-1]) == want_key]
return (exact or candidates)[0], "ComfyUI-Manager node map"
return None, None
GIT_ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
def install_pack(name, url, ver):
dest = os.path.join(CUSTOM_NODES_DIR, url.rstrip("/").split("/")[-1].removesuffix(".git"))
print(f"\n{C_CYAN}Installing {name} from {url} ...{C_RESET}")
if not os.path.isdir(dest):
subprocess.run(["git", "clone", "--recursive", "--progress", url, dest],
check=True, env=GIT_ENV, timeout=900, stdin=subprocess.DEVNULL)
if ver and re.fullmatch(r"[0-9a-f]{40}", str(ver), re.I):
r = subprocess.run(["git", "-C", dest, "checkout", ver], env=GIT_ENV, timeout=120,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f" pinned to commit {ver[:12]}" if r.returncode == 0
else f" {C_YELLOW}⚠ could not pin to {ver[:12]}, staying on latest{C_RESET}")
req = os.path.join(dest, "requirements.txt")
if os.path.exists(req):
print(" installing python requirements (can take a few minutes) ...")
r = subprocess.run([sys.executable, "-m", "pip", "install", "-r", req,
"--progress-bar", "off"],
timeout=1800, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if r.returncode != 0:
print(r.stdout)
print(f" {C_YELLOW}⚠ pip install had errors β€” see output above{C_RESET}")
else:
installed = [l for l in r.stdout.splitlines() if l.startswith("Successfully installed")]
print(f" {installed[0] if installed else 'requirements already satisfied'}")
inst = os.path.join(dest, "install.py")
if os.path.exists(inst):
print(" running install.py ...")
r = subprocess.run([sys.executable, inst], cwd=dest, timeout=1800,
stdin=subprocess.DEVNULL)
if r.returncode != 0:
print(f" {C_YELLOW}⚠ install.py exited with {r.returncode}{C_RESET}")
return dest
def plan_node_deps(chosen_data, dry_run):
"""Collect deps across workflows, resolve, and return list of (name, url, ver) to install."""
all_deps, all_orphans = {}, set()
for wf_name, data in chosen_data:
deps, orphans = extract_node_deps(data)
for k, d in deps.items():
tgt = all_deps.setdefault(k, d)
if tgt is not d:
tgt["types"] |= d["types"]
# prefer a commit-hash pin over a semver, else keep the first seen
if d["ver"] and not re.fullmatch(r"[0-9a-f]{40}", str(tgt["ver"] or ""), re.I):
tgt["ver"] = d["ver"]
all_orphans |= orphans
installed = installed_packs()
# nodes with no pack metadata: fine if core or provided by an installed pack,
# otherwise try to identify the pack via the Manager node map
unresolved_types = []
for t in sorted(all_orphans):
if any(t in d["types"] for d in all_deps.values()):
continue
if is_core_node(t) or provided_by_installed(t):
continue
hits = [url for url, types in node_map().items() if t in types]
if hits:
key = norm_name(hits[0].rstrip("/").split("/")[-1])
dep = all_deps.setdefault(key, {"cnr_id": None, "aux_id": None, "ver": None, "types": set()})
dep["types"].add(t)
dep["_url"] = hits[0]
else:
unresolved_types.append(t)
to_install, already, failed = [], [], []
for key, dep in sorted(all_deps.items()):
name = dep.get("cnr_id") or dep.get("aux_id") or key
if key in installed:
already.append((name, installed[key]))
continue
url, source = (dep.get("_url"), "ComfyUI-Manager node map") if dep.get("_url") else resolve_repo_url(dep)
if url:
repo_dir = norm_name(url.rstrip("/").split("/")[-1].removesuffix(".git"))
if repo_dir in installed:
already.append((name, installed[repo_dir]))
else:
to_install.append((name, url, dep.get("ver"), source))
else:
failed.append((name, dep["types"]))
print(f"\n{C_BOLD}Custom-node dependencies:{C_RESET}")
if not (to_install or already or failed):
print(f" {C_GREEN}βœ” none needed β€” selected workflows only use core nodes.{C_RESET}")
for name, d in already:
print(f" {C_GREEN}βœ” installed{C_RESET} {name} (custom_nodes/{d})")
for name, url, ver, source in to_install:
pin = f", pin {str(ver)[:12]}" if ver and re.fullmatch(r"[0-9a-f]{40}", str(ver), re.I) else ""
print(f" {C_YELLOW}↓ install{C_RESET} {name} ({url}{pin}, via {source})")
for name, types in failed:
print(f" {C_RED}✘ unknown{C_RESET} {name} β€” could not find a repo (nodes: {', '.join(sorted(types))})")
for t in unresolved_types:
print(f" {C_RED}✘ unknown{C_RESET} node type '{t}' β€” not core, not installed, not in the Manager DB")
return to_install
# ---------------- main ----------------
def cleanup_debris():
"""Remove partial-download fragments and stale locks left by interrupted runs."""
cache = os.path.join(COMFY_DIR, ".cache", "huggingface", "download")
freed, count = 0, 0
for root, _, names in os.walk(cache):
for name in names:
if name.endswith((".incomplete", ".lock")):
p = os.path.join(root, name)
try:
freed += os.path.getsize(p)
os.remove(p)
count += 1
except OSError:
pass
if count:
print(f"{C_YELLOW}Cleaned up {count} leftover partial-download file(s) ({human(freed)} freed).{C_RESET}")
def download_heartbeat(to_download):
"""Print bytes-on-disk every 20s so a big download never looks hung."""
total = sum(s for _, _, s, _ in to_download)
stop = threading.Event()
cache = os.path.join(COMFY_DIR, ".cache", "huggingface", "download")
def run():
while not stop.wait(20):
done = 0
for _, _, size, dest in to_download:
if os.path.exists(dest):
done += min(os.path.getsize(dest), size)
for root, _, names in os.walk(cache):
for n in names:
if n.endswith(".incomplete"):
try:
done += os.path.getsize(os.path.join(root, n))
except OSError:
pass
print(f" ... still downloading: ~{human(min(done, total))} of {human(total)} on disk", flush=True)
t = threading.Thread(target=run, daemon=True)
t.start()
return stop
def main():
argv = sys.argv[1:]
dry_run = "--dry-run" in argv
skip_nodes = "--skip-nodes" in argv
assume_yes = "--yes" in argv or "-y" in argv
cleanup_debris()
if not os.path.isdir(WORKFLOWS_DIR):
print(f"{C_YELLOW}{WORKFLOWS_DIR} not found β€” downloading workflows from hf.co/{REPO_ID} ...{C_RESET}")
from huggingface_hub import snapshot_download
snapshot_download(
repo_id=REPO_ID,
allow_patterns=["workflows/*.json"],
local_dir=WORKSPACE,
token=os.environ.get("HF_TOKEN"),
)
elif "--no-refresh" not in argv:
# The workflows baked into the pod image can lag the HF repo, and a
# stale copy makes the model plan silently miss anything added to a
# workflow since the image was built. Sync workflows/*.json from the
# repo before planning; the repo wins over local edits, so pass
# --no-refresh to plan against the local copies instead.
print(f"{C_CYAN}Syncing workflows from hf.co/{REPO_ID} (--no-refresh to skip) ...{C_RESET}")
try:
from huggingface_hub import snapshot_download
snapshot_download(
repo_id=REPO_ID,
allow_patterns=["workflows/*.json"],
local_dir=WORKSPACE,
token=os.environ.get("HF_TOKEN"),
)
except Exception as e:
print(f"{C_YELLOW}⚠ workflow sync failed ({e}) β€” planning against the local copies.{C_RESET}")
files = sorted(glob.glob(os.path.join(WORKFLOWS_DIR, "*.json")))
if not files:
sys.exit(f"No workflow JSONs found in {WORKFLOWS_DIR}")
chosen = pick_workflows(files, argv)
chosen_data = []
print(f"\n{C_BOLD}Selected workflows:{C_RESET}")
wanted = {} # ref -> [workflow names]
for f in chosen:
with open(f) as fh:
data = json.load(fh)
chosen_data.append((os.path.basename(f), data))
refs = extract_models(data)
print(f" β€’ {os.path.basename(f)} ({len(refs)} model refs)")
for r in refs:
wanted.setdefault(r, []).append(os.path.basename(f))
# -------- custom-node packs --------
node_installs = [] if skip_nodes else plan_node_deps(chosen_data, dry_run)
# -------- models --------
to_download, have, not_found = [], [], []
if wanted:
print(f"\n{C_CYAN}Listing files in hf.co/{REPO_ID} ...{C_RESET}")
from huggingface_hub import HfApi
api = HfApi(token=os.environ.get("HF_TOKEN"))
repo_files = {} # repo path -> size
for entry in api.list_repo_tree(REPO_ID, recursive=True):
if hasattr(entry, "size") and entry.path.startswith("models/"):
repo_files[entry.path] = entry.size or 0
# index by basename and by suffix path
by_basename = {}
for p in repo_files:
by_basename.setdefault(os.path.basename(p), []).append(p)
for ref, wfs in sorted(wanted.items()):
base = os.path.basename(ref)
candidates = by_basename.get(base, [])
# prefer a repo path that ends with the workflow's relative path (handles lora subdirs)
match = next((p for p in candidates if p.endswith("/" + ref) or p == "models/" + ref), None)
if match is None and candidates:
match = candidates[0]
if match is None:
not_found.append((ref, wfs))
continue
dest = os.path.join(COMFY_DIR, match)
size = repo_files[match]
if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
have.append((ref, match, size))
else:
to_download.append((ref, match, size, dest))
print(f"\n{C_BOLD}Model plan:{C_RESET}")
for ref, match, size in have:
print(f" {C_GREEN}βœ” have{C_RESET} {match} ({human(size)})")
for ref, match, size, dest in to_download:
print(f" {C_YELLOW}↓ fetch{C_RESET} {match} ({human(size)})")
for ref, wfs in not_found:
print(f" {C_RED}✘ missing{C_RESET} {ref} β€” not in hf.co/{REPO_ID} (used by {', '.join(wfs)})")
total = sum(s for _, _, s, _ in to_download)
print(f"\n{len(have)} model(s) already present, {len(to_download)} to download ({human(total)}), "
f"{len(not_found)} not in repo; {len(node_installs)} node pack(s) to install.")
else:
total = 0
print(f"\nNo model references found; {len(node_installs)} node pack(s) to install.")
if not to_download and not node_installs:
print(f"{C_GREEN}Nothing to do β€” all set!{C_RESET}")
return
if dry_run:
print("(dry run β€” nothing installed or downloaded)")
return
if sys.stdin.isatty() and not assume_yes:
parts = []
if node_installs:
parts.append(f"install {len(node_installs)} node pack(s)")
if to_download:
parts.append(f"download {len(to_download)} file(s) ({human(total)})")
resp = input(f"\nProceed: {' and '.join(parts)}? [Y/n] ").strip().lower()
if resp not in ("", "y", "yes"):
sys.exit("Aborted.")
# -------- install custom-node packs --------
pack_failures = []
for name, url, ver, _source in node_installs:
try:
install_pack(name, url, ver)
except Exception as e:
pack_failures.append((name, str(e)))
print(f" {C_RED}✘ failed to install {name}: {e}{C_RESET}")
# -------- download models --------
if to_download:
# hf_transfer parallelizes chunks within a file; snapshot_download parallelizes across files.
print(f"\n{C_CYAN}Downloading with hf_transfer (parallel)...{C_RESET}\n")
from huggingface_hub import snapshot_download
patterns = [m for _, m, _, _ in to_download]
heartbeat = download_heartbeat(to_download)
try:
snapshot_download(
repo_id=REPO_ID,
allow_patterns=patterns,
local_dir=COMFY_DIR,
token=os.environ.get("HF_TOKEN"),
max_workers=8,
)
finally:
heartbeat.set()
print(f"\n{C_BOLD}Verifying:{C_RESET}")
ok = True
for name, url, ver, _source in node_installs:
dest = os.path.join(CUSTOM_NODES_DIR, url.rstrip("/").split("/")[-1].removesuffix(".git"))
if os.path.isdir(dest) and not any(name == f[0] for f in pack_failures):
print(f" {C_GREEN}βœ”{C_RESET} custom_nodes/{os.path.basename(dest)}")
else:
ok = False
print(f" {C_RED}✘{C_RESET} {name} (install failed)")
for _, match, size, dest in to_download:
if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
print(f" {C_GREEN}βœ”{C_RESET} {dest} ({human(os.path.getsize(dest))})")
else:
ok = False
print(f" {C_RED}✘{C_RESET} {dest} (incomplete or missing)")
if not_found:
print(f"\n{C_YELLOW}Note:{C_RESET} {len(not_found)} model(s) were not found in the repo (listed above) β€” "
f"you'll need to source those elsewhere.")
if node_installs and ok:
print(f"\n{C_CYAN}Restart ComfyUI to load the newly installed node packs.{C_RESET}")
print(f"\n{C_GREEN if ok else C_RED}{'Done β€” all installs and downloads verified.' if ok else 'Done, but some steps failed β€” re-run to retry.'}{C_RESET}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit("\nInterrupted.")
PYEOF
)
# -u: unbuffered stdout, so progress lines appear immediately even when piped (e.g. | tee)
exec python3 -u -c "$PYCODE" "$@"