| #!/usr/bin/env bash |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| set -euo pipefail |
|
|
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| export HF_HUB_ENABLE_HF_TRANSFER=1 |
| |
| export GIT_TERMINAL_PROMPT=0 |
|
|
| |
| 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 |
|
|
| |
| |
| 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 |
|
|
| |
| |
| |
| PYCODE=$(cat <<'PYEOF' |
| import json |
| import os |
| import re |
| import socket |
| import subprocess |
| import sys |
| import glob |
| import threading |
| import time |
| import urllib.request |
|
|
| |
| 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"} |
| |
| 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 |
|
|
| |
|
|
| 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'.") |
|
|
| |
|
|
| 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): |
| |
| 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 |
|
|
| |
|
|
| 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) |
| |
| 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" |
| |
| 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: |
| |
| 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"] |
| |
| 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() |
|
|
| |
| |
| 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 |
|
|
| |
|
|
| 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: |
| |
| |
| |
| |
| |
| 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 = {} |
| 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)) |
|
|
| |
| node_installs = [] if skip_nodes else plan_node_deps(chosen_data, dry_run) |
|
|
| |
| 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 = {} |
| 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 |
|
|
| |
| 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, []) |
| |
| 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.") |
|
|
| |
| 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}") |
|
|
| |
| if to_download: |
| |
| 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 |
| ) |
| |
| exec python3 -u -c "$PYCODE" "$@" |
|
|