aleph65 commited on
Commit
429ecf7
·
verified ·
1 Parent(s): a3fddd6

Add download_missing_models.sh — interactive workflow model downloader

Browse files
Files changed (1) hide show
  1. download_missing_models.sh +277 -0
download_missing_models.sh ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # download_missing_models.sh
3
+ # Interactively pick workflows from ./workflows, find every model they reference,
4
+ # locate those models in the HF repo aleph65/ComfyUI (which mirrors ComfyUI/models/),
5
+ # and download the missing ones into place, in parallel, with progress.
6
+ #
7
+ # Usage:
8
+ # ./download_missing_models.sh # interactive workflow picker
9
+ # ./download_missing_models.sh --all # select every workflow
10
+ # ./download_missing_models.sh qwen-edit.json # select specific workflow(s)
11
+ # ./download_missing_models.sh --dry-run ... # show the plan, download nothing
12
+ #
13
+ # Auth: uses the HF_TOKEN environment variable if set, otherwise prompts for a token.
14
+
15
+ set -euo pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ export HF_HUB_ENABLE_HF_TRANSFER=1
19
+
20
+ # 1) Hugging Face token: use HF_TOKEN from the environment, otherwise prompt.
21
+ if [[ -n "${HF_TOKEN:-}" ]]; then
22
+ echo "Using HF token from environment (HF_TOKEN)."
23
+ else
24
+ read -r -s -p "Enter your Hugging Face token (input hidden): " HF_TOKEN
25
+ echo
26
+ [[ -n "$HF_TOKEN" ]] || { echo "No token provided — aborting."; exit 1; }
27
+ fi
28
+ export HF_TOKEN
29
+
30
+ # 2) Ensure deps (huggingface_hub >= 1.x ships fast Xet downloads built in and no
31
+ # longer provides the hf-transfer extra, so hf_transfer is best-effort only)
32
+ if ! python3 -c "import huggingface_hub" >/dev/null 2>&1; then
33
+ echo "Installing huggingface_hub[hf_transfer]..."
34
+ pip install -q -U "huggingface_hub[hf_transfer]" || pip install -q -U huggingface_hub
35
+ fi
36
+ python3 -c "import hf_transfer" >/dev/null 2>&1 || pip install -q hf_transfer >/dev/null 2>&1 || true
37
+
38
+ # 3) Run the downloader.
39
+ # Load the Python code into a variable (NOT via stdin, which must stay attached
40
+ # to the terminal for the interactive prompts).
41
+ PYCODE=$(cat <<'PYEOF'
42
+ import json
43
+ import os
44
+ import sys
45
+ import glob
46
+
47
+ WORKSPACE = os.environ.get("COMFY_WORKSPACE", "/workspace")
48
+ WORKFLOWS_DIR = os.path.join(WORKSPACE, "workflows")
49
+ COMFY_DIR = os.path.join(WORKSPACE, "ComfyUI")
50
+ REPO_ID = "aleph65/ComfyUI"
51
+ MODEL_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx")
52
+ SKIP_NODE_TYPES = {"MarkdownNote", "Note", "PrimitiveString", "String"}
53
+
54
+ C_RESET, C_GREEN, C_YELLOW, C_RED, C_CYAN, C_BOLD = "\033[0m", "\033[32m", "\033[33m", "\033[31m", "\033[36m", "\033[1m"
55
+
56
+ def human(n):
57
+ for unit in ("B", "KB", "MB", "GB", "TB"):
58
+ if n < 1024 or unit == "TB":
59
+ return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
60
+ n /= 1024
61
+
62
+ # ---------------- workflow selection ----------------
63
+
64
+ def pick_workflows(files, argv):
65
+ names = [os.path.basename(f) for f in files]
66
+ args = [a for a in argv if not a.startswith("--")]
67
+ if "--all" in argv:
68
+ return files
69
+ if args:
70
+ chosen = []
71
+ for a in args:
72
+ matches = [f for f in files if os.path.basename(f) == a or os.path.basename(f) == a + ".json"]
73
+ if not matches:
74
+ sys.exit(f"No workflow named '{a}' in {WORKFLOWS_DIR}")
75
+ chosen += matches
76
+ return chosen
77
+ print(f"\n{C_BOLD}Available workflows:{C_RESET}")
78
+ for i, name in enumerate(names, 1):
79
+ print(f" {i}) {name}")
80
+ print()
81
+ while True:
82
+ try:
83
+ raw = input("Select workflows (e.g. '1 3', '1,3', or 'all'; q to quit): ").strip().lower()
84
+ except EOFError:
85
+ sys.exit("\nNo input available. Run interactively, or pass workflow names or --all.")
86
+ if raw in ("q", "quit", "exit"):
87
+ sys.exit("Aborted.")
88
+ if raw in ("all", "a", "*"):
89
+ return files
90
+ picks = raw.replace(",", " ").split()
91
+ if picks and all(p.isdigit() and 1 <= int(p) <= len(names) for p in picks):
92
+ return [files[int(p) - 1] for p in dict.fromkeys(picks)]
93
+ print(f" Invalid selection — enter numbers 1-{len(names)}, or 'all'.")
94
+
95
+ # ---------------- model extraction ----------------
96
+
97
+ def looks_like_model(v):
98
+ if not isinstance(v, str):
99
+ return False
100
+ v = v.strip()
101
+ if not v.lower().endswith(MODEL_EXTS):
102
+ return False
103
+ if "\n" in v or "http://" in v or "https://" in v or len(v) > 300:
104
+ return False
105
+ return True
106
+
107
+ def extract_models(path):
108
+ """Return set of model refs (may include subdirs like 'flux2/klein/x.safetensors')."""
109
+ with open(path) as fh:
110
+ data = json.load(fh)
111
+ refs = set()
112
+
113
+ def scan_values(values):
114
+ stack = [values]
115
+ while stack:
116
+ v = stack.pop()
117
+ if isinstance(v, str) and looks_like_model(v):
118
+ refs.add(v.strip().replace("\\", "/"))
119
+ elif isinstance(v, list):
120
+ stack.extend(v)
121
+ elif isinstance(v, dict):
122
+ stack.extend(v.values())
123
+
124
+ def scan_nodes(nodes):
125
+ for n in nodes:
126
+ if n.get("type") in SKIP_NODE_TYPES or n.get("class_type") in SKIP_NODE_TYPES:
127
+ continue
128
+ if "widgets_values" in n:
129
+ scan_values(n["widgets_values"])
130
+ if "inputs" in n and isinstance(n["inputs"], dict):
131
+ scan_values(list(n["inputs"].values()))
132
+ # subgraph instances store nodes too
133
+ for sub in (n.get("subgraph") or {}).get("nodes", []) if isinstance(n.get("subgraph"), dict) else []:
134
+ scan_nodes([sub])
135
+
136
+ if isinstance(data, dict) and isinstance(data.get("nodes"), list):
137
+ scan_nodes(data["nodes"])
138
+ # subgraph definitions (ComfyUI >= subgraph support)
139
+ defs = (data.get("definitions") or {})
140
+ for sg in defs.get("subgraphs", []) or []:
141
+ if isinstance(sg.get("nodes"), list):
142
+ scan_nodes(sg["nodes"])
143
+ elif isinstance(data, dict):
144
+ # API format: {"1": {"class_type": ..., "inputs": {...}}, ...}
145
+ for n in data.values():
146
+ if isinstance(n, dict) and "class_type" in n:
147
+ if n.get("class_type") in SKIP_NODE_TYPES:
148
+ continue
149
+ scan_values(list((n.get("inputs") or {}).values()))
150
+ return refs
151
+
152
+ # ---------------- main ----------------
153
+
154
+ def cleanup_debris():
155
+ """Remove partial-download fragments and stale locks left by interrupted runs."""
156
+ cache = os.path.join(COMFY_DIR, ".cache", "huggingface", "download")
157
+ freed, count = 0, 0
158
+ for root, _, names in os.walk(cache):
159
+ for name in names:
160
+ if name.endswith((".incomplete", ".lock")):
161
+ p = os.path.join(root, name)
162
+ try:
163
+ freed += os.path.getsize(p)
164
+ os.remove(p)
165
+ count += 1
166
+ except OSError:
167
+ pass
168
+ if count:
169
+ print(f"{C_YELLOW}Cleaned up {count} leftover partial-download file(s) ({human(freed)} freed).{C_RESET}")
170
+
171
+ def main():
172
+ argv = sys.argv[1:]
173
+ dry_run = "--dry-run" in argv
174
+
175
+ cleanup_debris()
176
+ files = sorted(glob.glob(os.path.join(WORKFLOWS_DIR, "*.json")))
177
+ if not files:
178
+ sys.exit(f"No workflow JSONs found in {WORKFLOWS_DIR}")
179
+
180
+ chosen = pick_workflows(files, argv)
181
+ print(f"\n{C_BOLD}Selected workflows:{C_RESET}")
182
+ wanted = {} # ref -> [workflow names]
183
+ for f in chosen:
184
+ refs = extract_models(f)
185
+ print(f" • {os.path.basename(f)} ({len(refs)} model refs)")
186
+ for r in refs:
187
+ wanted.setdefault(r, []).append(os.path.basename(f))
188
+ if not wanted:
189
+ sys.exit("No model references found in the selected workflows.")
190
+
191
+ print(f"\n{C_CYAN}Listing files in hf.co/{REPO_ID} ...{C_RESET}")
192
+ from huggingface_hub import HfApi
193
+ api = HfApi(token=os.environ.get("HF_TOKEN"))
194
+ repo_files = {} # repo path -> size
195
+ for entry in api.list_repo_tree(REPO_ID, recursive=True):
196
+ if hasattr(entry, "size") and entry.path.startswith("models/"):
197
+ repo_files[entry.path] = entry.size or 0
198
+
199
+ # index by basename and by suffix path
200
+ by_basename = {}
201
+ for p in repo_files:
202
+ by_basename.setdefault(os.path.basename(p), []).append(p)
203
+
204
+ to_download, have, not_found = [], [], []
205
+ for ref, wfs in sorted(wanted.items()):
206
+ base = os.path.basename(ref)
207
+ candidates = by_basename.get(base, [])
208
+ # prefer a repo path that ends with the workflow's relative path (handles lora subdirs)
209
+ match = next((p for p in candidates if p.endswith("/" + ref) or p == "models/" + ref), None)
210
+ if match is None and candidates:
211
+ match = candidates[0]
212
+ if match is None:
213
+ not_found.append((ref, wfs))
214
+ continue
215
+ dest = os.path.join(COMFY_DIR, match)
216
+ size = repo_files[match]
217
+ if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
218
+ have.append((ref, match, size))
219
+ else:
220
+ to_download.append((ref, match, size, dest))
221
+
222
+ print(f"\n{C_BOLD}Plan:{C_RESET}")
223
+ for ref, match, size in have:
224
+ print(f" {C_GREEN}✔ have{C_RESET} {match} ({human(size)})")
225
+ for ref, match, size, dest in to_download:
226
+ print(f" {C_YELLOW}↓ fetch{C_RESET} {match} ({human(size)})")
227
+ for ref, wfs in not_found:
228
+ print(f" {C_RED}✘ missing{C_RESET} {ref} — not in hf.co/{REPO_ID} (used by {', '.join(wfs)})")
229
+
230
+ total = sum(s for _, _, s, _ in to_download)
231
+ print(f"\n{len(have)} already present, {len(to_download)} to download ({human(total)}), {len(not_found)} not in repo.")
232
+
233
+ if not to_download:
234
+ print(f"{C_GREEN}Nothing to download — all set!{C_RESET}")
235
+ return
236
+ if dry_run:
237
+ print("(dry run — nothing downloaded)")
238
+ return
239
+
240
+ if sys.stdin.isatty():
241
+ resp = input(f"\nDownload {len(to_download)} files ({human(total)}) to {COMFY_DIR}/models? [Y/n] ").strip().lower()
242
+ if resp not in ("", "y", "yes"):
243
+ sys.exit("Aborted.")
244
+
245
+ # hf_transfer parallelizes chunks within a file; snapshot_download parallelizes across files.
246
+ print(f"\n{C_CYAN}Downloading with hf_transfer (parallel)...{C_RESET}\n")
247
+ from huggingface_hub import snapshot_download
248
+ patterns = [m for _, m, _, _ in to_download]
249
+ snapshot_download(
250
+ repo_id=REPO_ID,
251
+ allow_patterns=patterns,
252
+ local_dir=COMFY_DIR,
253
+ token=os.environ.get("HF_TOKEN"),
254
+ max_workers=8,
255
+ )
256
+
257
+ print(f"\n{C_BOLD}Verifying:{C_RESET}")
258
+ ok = True
259
+ for _, match, size, dest in to_download:
260
+ if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
261
+ print(f" {C_GREEN}✔{C_RESET} {dest} ({human(os.path.getsize(dest))})")
262
+ else:
263
+ ok = False
264
+ print(f" {C_RED}✘{C_RESET} {dest} (incomplete or missing)")
265
+ if not_found:
266
+ print(f"\n{C_YELLOW}Note:{C_RESET} {len(not_found)} model(s) were not found in the repo (listed above) — "
267
+ f"you'll need to source those elsewhere.")
268
+ print(f"\n{C_GREEN if ok else C_RED}{'Done — all downloads verified.' if ok else 'Done, but some files failed — re-run to retry.'}{C_RESET}")
269
+
270
+ if __name__ == "__main__":
271
+ try:
272
+ main()
273
+ except KeyboardInterrupt:
274
+ sys.exit("\nInterrupted.")
275
+ PYEOF
276
+ )
277
+ exec python3 -c "$PYCODE" "$@"