saliacoel commited on
Commit
7cf0344
·
verified ·
1 Parent(s): 6d8ebaf

Upload hf_x_nodes.py

Browse files
Files changed (1) hide show
  1. hf_x_nodes.py +763 -0
hf_x_nodes.py ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # hf_private_repo_nodes.py
2
+ #
3
+ # ComfyUI Custom Nodes (single file):
4
+ # 1) HF_PrivateRepo_Uploader (IMAGE -> upload ZIP of PNG(s))
5
+ # 2) HF_PrivateRepo_Downloader (download ZIP of PNG(s) -> IMAGE) with polling + trigger
6
+ # 3) HF_Data_BAM_Uploader (STRING -> upload Data/BAM.txt)
7
+ # 4) HF_Data_BAM_Downloader (download Data/BAM.txt -> STRING)
8
+ #
9
+ # Repo layout:
10
+ # Images ZIP: {Version}/{ID}/{Category}/{Direction}/{Filename}.zip
11
+ # Text: {Version}/{ID}/Data/BAM.txt
12
+ #
13
+ # ZIP behavior:
14
+ # - One uploaded image batch becomes one ZIP file in the repo.
15
+ # - The ZIP filename NEVER gets an image-count suffix.
16
+ # Example: "Walk" -> "Walk.zip" even if it contains 50 images.
17
+ # - The downloader only looks for that exact ZIP filename.
18
+ # Example: "Walk" -> download "Walk.zip" (not "Walk_50.zip").
19
+ # - PNG files inside the ZIP are ordered and named from the ZIP base name:
20
+ # Walk.zip -> Walk.png (single image)
21
+ # Walk.zip -> Walk_00.png..Walk_49.png (batch)
22
+ #
23
+ # Logging:
24
+ # - A local append-only log file is written next to this node file.
25
+ # - The log file is created automatically if it does not exist.
26
+ #
27
+ # IMPORTANT (per your request):
28
+ # Hardcoded placeholder token. Replace safely later.
29
+ # Secret_Token = "hf_???"
30
+ #
31
+ # Dependencies:
32
+ # pip install huggingface_hub pillow numpy
33
+ #
34
+ # Put this file into:
35
+ # ComfyUI/custom_nodes/hf_private_repo_nodes.py
36
+ # then restart ComfyUI.
37
+
38
+ import os
39
+ import io
40
+ import re
41
+ import time
42
+ import zipfile
43
+ from typing import List, Tuple, Optional
44
+
45
+ import numpy as np
46
+ import torch
47
+ from PIL import Image
48
+
49
+ # --- Hardcoded per your request (replace safely later) ---
50
+ Secret_Token = "hf_???"
51
+
52
+ # --- Hardcoded repo + version ---
53
+ REPO_ID = "saliacoel/v1"
54
+ REPO_TYPE = "model" # keep for upload APIs
55
+ Version = "1"
56
+
57
+ ALLOWED_CATEGORIES = ["HD", "Unity", "RpgMaker", "Misc"]
58
+ ALLOWED_DIRECTIONS = [
59
+ "Front",
60
+ "Side_Right",
61
+ "Side_Left",
62
+ "Rear",
63
+ "Diagonal_Front_Right",
64
+ "Diagonal_Front_Left",
65
+ "Diagonal_Rear_Left",
66
+ "Diagonal_Rear_Right",
67
+ "Misc",
68
+ ]
69
+
70
+ # Poll schedule (absolute seconds since start):
71
+ # - 1 immediate poll (t=0)
72
+ # - 5 polls every 2 sec for 10 sec (t=2,4,6,8,10)
73
+ # - wait 80 sec (next at t=90)
74
+ # - 4 polls every 5 sec (t=90,95,100,105)
75
+ # - wait 30 sec (next at t=135)
76
+ # - 3 polls every 5 sec (t=135,140,145)
77
+ # - wait 20 sec (next at t=165)
78
+ # - 2 polls every 5 sec (t=165,170)
79
+ # - wait 15 sec (next at t=185)
80
+ # - 2 polls every 5 sec (t=185,190) final
81
+ POLL_TIMES_SECONDS = [0, 2, 4, 6, 8, 10, 90, 95, 100, 105, 135, 140, 145, 165, 170, 185, 190]
82
+
83
+ LOG_FILENAME = "hf_private_repo_nodes.log"
84
+ LOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), LOG_FILENAME)
85
+
86
+
87
+ def _append_log(message: str) -> None:
88
+ """
89
+ Append a line to the local log file.
90
+ The file is created automatically if it does not exist.
91
+ Logging must never break node execution, so all errors are swallowed.
92
+ """
93
+ try:
94
+ log_dir = os.path.dirname(LOG_PATH)
95
+ if log_dir:
96
+ os.makedirs(log_dir, exist_ok=True)
97
+
98
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
99
+ line = f"[{timestamp}] {str(message).rstrip()}\n"
100
+
101
+ with open(LOG_PATH, "a", encoding="utf-8") as f:
102
+ f.write(line)
103
+ except Exception:
104
+ pass
105
+
106
+
107
+ def _sanitize_component(s: str) -> str:
108
+ """Sanitize a single path component so it cannot create unintended subfolders."""
109
+ s = str(s).strip()
110
+ s = s.replace("\\", "_").replace("/", "_")
111
+ while ".." in s:
112
+ s = s.replace("..", "_")
113
+ return s
114
+
115
+
116
+ def _normalize_id(id_int: int, id_str: str) -> str:
117
+ if id_str is not None and str(id_str).strip() != "":
118
+ return _sanitize_component(str(id_str))
119
+ return _sanitize_component(str(int(id_int)))
120
+
121
+
122
+ def _normalize_category(category: str) -> str:
123
+ return category if category in ALLOWED_CATEGORIES else "Misc"
124
+
125
+
126
+ def _normalize_direction(direction: str) -> str:
127
+ return direction if direction in ALLOWED_DIRECTIONS else "Misc"
128
+
129
+
130
+ def _normalize_zip_filename(name: str) -> str:
131
+ """
132
+ Normalize a user-entered filename into a repo ZIP filename.
133
+
134
+ Behavior:
135
+ "" -> "_.zip"
136
+ "Walk" -> "Walk.zip"
137
+ "Walk.zip" -> "Walk.zip"
138
+ "Walk.png" -> "Walk.zip"
139
+ "folder/Walk" -> "Walk.zip"
140
+ """
141
+ name = "" if name is None else str(name)
142
+ name = os.path.basename(name).strip()
143
+ name = _sanitize_component(name)
144
+
145
+ if name == "":
146
+ return "_.zip"
147
+
148
+ base, ext = os.path.splitext(name)
149
+ if ext.lower() in (".zip", ".png", ".jpg", ".jpeg", ".webp"):
150
+ name = base.strip()
151
+ else:
152
+ name = name.strip()
153
+
154
+ if name == "":
155
+ name = "_"
156
+
157
+ return f"{name}.zip"
158
+
159
+
160
+ def _zip_base_name(zip_filename: str) -> str:
161
+ base = os.path.splitext(_normalize_zip_filename(zip_filename))[0].strip()
162
+ return base if base else "_"
163
+
164
+
165
+ def _make_png_entry_names_for_zip(zip_filename: str, batch_size: int) -> List[str]:
166
+ """
167
+ Internal ZIP entry names.
168
+ - single image: base.png
169
+ - batch: base_00.png, base_01.png, ...
170
+ """
171
+ if batch_size < 1:
172
+ raise ValueError(f"batch_size must be >= 1, got {batch_size}")
173
+
174
+ base = _zip_base_name(zip_filename)
175
+ if batch_size == 1:
176
+ return [f"{base}.png"]
177
+
178
+ width = max(2, len(str(batch_size - 1)))
179
+ joiner = "" if base.endswith("_") else "_"
180
+ return [f"{base}{joiner}{i:0{width}d}.png" for i in range(batch_size)]
181
+
182
+
183
+ def _ensure_batch(images: torch.Tensor) -> torch.Tensor:
184
+ """Ensure IMAGES is 4D [B,H,W,C] or [B,C,H,W]. If 3D, wrap to batch size 1."""
185
+ if not isinstance(images, torch.Tensor):
186
+ raise TypeError(f"IMAGES must be a torch.Tensor, got {type(images)}")
187
+ if images.ndim == 3:
188
+ return images.unsqueeze(0)
189
+ if images.ndim == 4:
190
+ return images
191
+ raise ValueError(f"IMAGES must have 3 or 4 dims. Got shape {tuple(images.shape)}")
192
+
193
+
194
+ def _select_first_as_single_image(images: torch.Tensor) -> torch.Tensor:
195
+ """Return a batch with exactly 1 image (4D)."""
196
+ if not isinstance(images, torch.Tensor):
197
+ raise TypeError(f"dummy_image must be a torch.Tensor, got {type(images)}")
198
+ if images.ndim == 3:
199
+ return images.unsqueeze(0)
200
+ if images.ndim == 4:
201
+ return images[:1]
202
+ raise ValueError(f"dummy_image must have 3 or 4 dims. Got shape {tuple(images.shape)}")
203
+
204
+
205
+ def _tensor_to_png_bytes(img: torch.Tensor) -> bytes:
206
+ """
207
+ Convert a single image tensor to PNG bytes.
208
+ Supports:
209
+ - HWC with C=3 or C=4
210
+ - CHW with C=3 or C=4
211
+ Values are clamped to [0,255]. If values look like [0,1], they are scaled.
212
+ """
213
+ if not isinstance(img, torch.Tensor):
214
+ raise TypeError(f"Expected torch.Tensor, got {type(img)}")
215
+
216
+ t = img.detach()
217
+ if t.device.type != "cpu":
218
+ t = t.to("cpu")
219
+
220
+ if t.ndim != 3:
221
+ raise ValueError(f"Expected 3D tensor for single image, got shape {tuple(t.shape)}")
222
+
223
+ # Detect HWC vs CHW
224
+ if t.shape[-1] in (3, 4):
225
+ arr = t.numpy()
226
+ c = arr.shape[-1]
227
+ elif t.shape[0] in (3, 4):
228
+ arr = np.transpose(t.numpy(), (1, 2, 0))
229
+ c = arr.shape[-1]
230
+ else:
231
+ raise ValueError(
232
+ f"Cannot infer channel dimension. Got shape {tuple(t.shape)}; expected HWC or CHW with C=3 or C=4."
233
+ )
234
+
235
+ if c not in (3, 4):
236
+ raise ValueError(f"Unsupported channel count: {c}. Only RGB(3) or RGBA(4) supported.")
237
+
238
+ if arr.dtype != np.uint8:
239
+ maxv = float(np.nanmax(arr)) if arr.size else 0.0
240
+ if maxv <= 1.0 + 1e-6:
241
+ arr = arr * 255.0
242
+ arr = np.rint(arr)
243
+ arr = np.clip(arr, 0, 255).astype(np.uint8)
244
+
245
+ mode = "RGBA" if c == 4 else "RGB"
246
+ pil = Image.fromarray(arr, mode=mode)
247
+
248
+ buf = io.BytesIO()
249
+ pil.save(buf, format="PNG")
250
+ return buf.getvalue()
251
+
252
+
253
+ def _images_to_zip_bytes(batch: torch.Tensor, zip_filename: str) -> Tuple[bytes, List[str]]:
254
+ """
255
+ Convert a batch of images to one ZIP file containing PNG files.
256
+ Returns (zip_bytes, zip_entry_names).
257
+ """
258
+ batch = _ensure_batch(batch)
259
+ batch_size = int(batch.shape[0])
260
+ entry_names = _make_png_entry_names_for_zip(zip_filename, batch_size)
261
+
262
+ buf = io.BytesIO()
263
+ with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
264
+ for i, entry_name in enumerate(entry_names):
265
+ zf.writestr(entry_name, _tensor_to_png_bytes(batch[i]))
266
+
267
+ return buf.getvalue(), entry_names
268
+
269
+
270
+ def _natural_sort_key(s: str):
271
+ return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", s.lower())]
272
+
273
+
274
+ def _zip_bytes_to_png_bytes_list(zip_bytes: bytes) -> Tuple[List[bytes], List[str]]:
275
+ """
276
+ Extract PNG files from ZIP bytes.
277
+ Returns (png_bytes_list, entry_names) ordered by natural filename sort.
278
+ """
279
+ try:
280
+ with zipfile.ZipFile(io.BytesIO(zip_bytes), mode="r") as zf:
281
+ infos = [
282
+ info
283
+ for info in zf.infolist()
284
+ if not info.is_dir() and info.filename.lower().endswith(".png")
285
+ ]
286
+ infos.sort(key=lambda info: _natural_sort_key(info.filename))
287
+
288
+ if not infos:
289
+ raise ValueError("ZIP contains no PNG files.")
290
+
291
+ png_bytes_list: List[bytes] = []
292
+ entry_names: List[str] = []
293
+ for info in infos:
294
+ png_bytes_list.append(zf.read(info.filename))
295
+ entry_names.append(info.filename)
296
+
297
+ return png_bytes_list, entry_names
298
+ except zipfile.BadZipFile as e:
299
+ raise ValueError(f"Downloaded file is not a valid ZIP: {e}")
300
+
301
+
302
+ def _build_image_path_in_repo(_id: str, category: str, direction: str, filename: str) -> str:
303
+ # {Version}/{ID}/{Category}/{Direction}/{Filename}
304
+ return f"{Version}/{_id}/{category}/{direction}/{filename}".replace("\\", "/")
305
+
306
+
307
+ def _build_bam_path_in_repo(_id: str) -> str:
308
+ # {Version}/{ID}/Data/BAM.txt
309
+ return f"{Version}/{_id}/Data/BAM.txt".replace("\\", "/")
310
+
311
+
312
+ def _resolve_main_url(path_in_repo: str) -> str:
313
+ # Matches what you described:
314
+ # https://huggingface.co/saliacoel/v1/resolve/main/<path_in_repo>
315
+ # NOTE: Do NOT include any token in the URL. Token is sent via Authorization header.
316
+ from urllib.parse import quote
317
+
318
+ safe_path = quote(path_in_repo, safe="/")
319
+ return f"https://huggingface.co/{REPO_ID}/resolve/main/{safe_path}"
320
+
321
+
322
+ def _http_get_bytes(url: str, token: str, timeout_sec: float = 30.0) -> Tuple[bool, Optional[bytes], str, Optional[int]]:
323
+ """
324
+ Minimal HTTP GET (supports private repo via Bearer token).
325
+ Returns: (ok, bytes, err_msg, status_code)
326
+ """
327
+ from urllib.request import Request, urlopen
328
+ from urllib.error import HTTPError, URLError
329
+
330
+ req = Request(url, method="GET")
331
+ req.add_header("Authorization", f"Bearer {token}")
332
+ req.add_header("User-Agent", "ComfyUI-HF-PrivateRepo-Nodes/2.0")
333
+
334
+ try:
335
+ with urlopen(req, timeout=timeout_sec) as resp:
336
+ data = resp.read()
337
+ return True, data, "", getattr(resp, "status", 200)
338
+ except HTTPError as e:
339
+ try:
340
+ body = e.read()
341
+ snippet = body[:200].decode("utf-8", errors="replace") if body else ""
342
+ except Exception:
343
+ snippet = ""
344
+ msg = f"HTTP {e.code}"
345
+ if snippet:
346
+ msg += f" | {snippet}"
347
+ return False, None, msg, int(e.code)
348
+ except URLError as e:
349
+ return False, None, f"URL error: {e}", None
350
+ except Exception as e:
351
+ return False, None, str(e), None
352
+
353
+
354
+ def _try_download_file_bytes(path_in_repo: str) -> Tuple[bool, Optional[bytes], str]:
355
+ """
356
+ Download file bytes for a repo path.
357
+ Strategy:
358
+ 1) Direct GET on /resolve/main/... (avoids commit-hash caching issues during polling)
359
+ 2) Fallback to huggingface_hub hf_hub_download (force_download) if direct fails
360
+ with non-404 errors.
361
+ Returns: (ok, bytes, err_msg)
362
+ """
363
+ url = _resolve_main_url(path_in_repo)
364
+ ok, data, err, status = _http_get_bytes(url, Secret_Token, timeout_sec=30.0)
365
+ if ok and data is not None:
366
+ return True, data, ""
367
+
368
+ if status == 404:
369
+ return False, None, err
370
+
371
+ try:
372
+ from huggingface_hub import hf_hub_download
373
+ except Exception as e:
374
+ return False, None, f"{err} | fallback huggingface_hub missing: {e}"
375
+
376
+ try:
377
+ local_path = hf_hub_download(
378
+ repo_id=REPO_ID,
379
+ filename=path_in_repo,
380
+ repo_type=REPO_TYPE,
381
+ token=Secret_Token,
382
+ revision="main",
383
+ force_download=True,
384
+ )
385
+ with open(local_path, "rb") as f:
386
+ return True, f.read(), ""
387
+ except Exception as e:
388
+ return False, None, f"{err} | fallback failed: {e}"
389
+
390
+
391
+ def _png_bytes_list_to_batch(png_bytes_list: List[bytes]) -> torch.Tensor:
392
+ """
393
+ Decode PNG bytes -> IMAGE batch tensor [B,H,W,C], float32 in [0,1].
394
+ If any image has alpha, all are output as RGBA (C=4) to keep batch consistent.
395
+ """
396
+ if not png_bytes_list:
397
+ raise ValueError("No PNG bytes to decode.")
398
+
399
+ any_alpha = False
400
+ pil_images: List[Image.Image] = []
401
+ for b in png_bytes_list:
402
+ im = Image.open(io.BytesIO(b))
403
+ im.load()
404
+ pil_images.append(im)
405
+
406
+ if im.mode in ("RGBA", "LA"):
407
+ any_alpha = True
408
+ elif im.mode == "P" and "transparency" in im.info:
409
+ any_alpha = True
410
+
411
+ tensors = []
412
+ for im in pil_images:
413
+ im2 = im.convert("RGBA" if any_alpha else "RGB")
414
+ arr = np.asarray(im2).astype(np.float32) / 255.0
415
+ tensors.append(torch.from_numpy(arr))
416
+
417
+ return torch.stack(tensors, dim=0)
418
+
419
+
420
+ # -------------------------------------------------------------------------------------
421
+ # 1) IMAGE ZIP UPLOADER
422
+ # -------------------------------------------------------------------------------------
423
+ class HF_X_Uploader:
424
+ """
425
+ Upload IMAGES to HF repo as one ZIP at:
426
+ {Version}/{ID}/{Category}/{Direction}/{Filename}.zip
427
+ """
428
+
429
+ @classmethod
430
+ def INPUT_TYPES(cls):
431
+ return {
432
+ "required": {
433
+ "IMAGES": ("IMAGE",),
434
+ "ID_int": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
435
+ "ID_str": ("STRING", {"default": "", "multiline": False}),
436
+ "Category": (ALLOWED_CATEGORIES,),
437
+ "Direction": (ALLOWED_DIRECTIONS,),
438
+ "Filename": ("STRING", {"default": "", "multiline": False}),
439
+ }
440
+ }
441
+
442
+ RETURN_TYPES = ("IMAGE", "STRING")
443
+ RETURN_NAMES = ("IMAGES", "uploaded_paths")
444
+ FUNCTION = "upload"
445
+ CATEGORY = "hf"
446
+ OUTPUT_NODE = True
447
+
448
+ @classmethod
449
+ def IS_CHANGED(cls, **kwargs):
450
+ return float("nan")
451
+
452
+ def upload(self, IMAGES, ID_int, ID_str, Category, Direction, Filename):
453
+ try:
454
+ from huggingface_hub import HfApi, CommitOperationAdd
455
+ except Exception as e:
456
+ msg = (
457
+ "Missing dependency: huggingface_hub. Install it in your ComfyUI environment:\n"
458
+ " pip install huggingface_hub\n"
459
+ f"Original import error: {e}"
460
+ )
461
+ _append_log(f"UPLOAD FAIL import_error repo={REPO_ID} error={e}")
462
+ raise ImportError(msg)
463
+
464
+ _id = _normalize_id(ID_int, ID_str)
465
+ _cat = _normalize_category(Category)
466
+ _dir = _normalize_direction(Direction)
467
+ zip_filename = _normalize_zip_filename(Filename)
468
+
469
+ batch = _ensure_batch(IMAGES)
470
+ batch_size = int(batch.shape[0])
471
+
472
+ try:
473
+ zip_bytes, entry_names = _images_to_zip_bytes(batch, zip_filename)
474
+ except Exception as e:
475
+ report = f"FAIL could not build ZIP for upload: {e}"
476
+ _append_log(f"UPLOAD FAIL zip_build_error repo={REPO_ID} id={_id} cat={_cat} dir={_dir} file={zip_filename} error={e}")
477
+ raise RuntimeError(report)
478
+
479
+ path_in_repo = _build_image_path_in_repo(_id, _cat, _dir, zip_filename)
480
+
481
+ api = HfApi(token=Secret_Token)
482
+ try:
483
+ commit_info = api.create_commit(
484
+ repo_id=REPO_ID,
485
+ repo_type=REPO_TYPE,
486
+ operations=[
487
+ CommitOperationAdd(
488
+ path_in_repo=path_in_repo,
489
+ path_or_fileobj=io.BytesIO(zip_bytes),
490
+ )
491
+ ],
492
+ commit_message=f"Upload ZIP with {batch_size} image(s) to {Version}/{_id}/{_cat}/{_dir}",
493
+ token=Secret_Token,
494
+ )
495
+ except Exception as e:
496
+ report = (
497
+ "Hugging Face upload failed.\n"
498
+ f"Repo: {REPO_ID} (type={REPO_TYPE})\n"
499
+ f"Target: {path_in_repo}\n"
500
+ f"Error: {e}"
501
+ )
502
+ _append_log(f"UPLOAD FAIL repo={REPO_ID} target={path_in_repo} error={e}")
503
+ raise RuntimeError(report)
504
+
505
+ commit_url = getattr(commit_info, "commit_url", None)
506
+ out_lines = []
507
+ if commit_url:
508
+ out_lines.append(f"commit_url: {commit_url}")
509
+ out_lines.append(f"zip_path: {path_in_repo}")
510
+ out_lines.append(f"zip_entries_count: {len(entry_names)}")
511
+ out_lines.append("zip_entries:")
512
+ out_lines.extend(entry_names)
513
+ out_text = "\n".join(out_lines)
514
+
515
+ _append_log(
516
+ f"UPLOAD OK repo={REPO_ID} target={path_in_repo} images={len(entry_names)}"
517
+ + (f" commit_url={commit_url}" if commit_url else "")
518
+ )
519
+
520
+ return {
521
+ "ui": {"text": [out_text]},
522
+ "result": (IMAGES, out_text),
523
+ }
524
+
525
+
526
+ # -------------------------------------------------------------------------------------
527
+ # 2) IMAGE ZIP DOWNLOADER (with polling + trigger)
528
+ # -------------------------------------------------------------------------------------
529
+ class HF_X_Downloader:
530
+ """
531
+ Download a ZIP from:
532
+ {Version}/{ID}/{Category}/{Direction}/{Filename}.zip
533
+
534
+ No expected_count input is needed.
535
+ The downloader polls for exactly one ZIP filename and decodes every PNG inside it.
536
+ """
537
+
538
+ @classmethod
539
+ def INPUT_TYPES(cls):
540
+ return {
541
+ "required": {
542
+ "dummy_image": ("IMAGE",),
543
+ "trigger_string": ("STRING", {"forceInput": True}),
544
+ "ID_int": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
545
+ "ID_str": ("STRING", {"default": "", "multiline": False}),
546
+ "Category": (ALLOWED_CATEGORIES,),
547
+ "Direction": (ALLOWED_DIRECTIONS,),
548
+ "Filename": ("STRING", {"default": "", "multiline": False}),
549
+ }
550
+ }
551
+
552
+ RETURN_TYPES = ("IMAGE", "STRING")
553
+ RETURN_NAMES = ("IMAGES", "report")
554
+ FUNCTION = "download"
555
+ CATEGORY = "hf"
556
+ OUTPUT_NODE = True
557
+
558
+ @classmethod
559
+ def IS_CHANGED(cls, **kwargs):
560
+ return float("nan")
561
+
562
+ def download(
563
+ self,
564
+ dummy_image,
565
+ trigger_string,
566
+ ID_int,
567
+ ID_str,
568
+ Category,
569
+ Direction,
570
+ Filename,
571
+ ):
572
+ if trigger_string is None or str(trigger_string).strip() == "":
573
+ dummy_one = _select_first_as_single_image(dummy_image)
574
+ report = "FAIL trigger_string missing/empty (node did not attempt download)"
575
+ _append_log(f"DOWNLOAD FAIL trigger_missing repo={REPO_ID}")
576
+ return {"ui": {"text": [report]}, "result": (dummy_one, report)}
577
+
578
+ _id = _normalize_id(ID_int, ID_str)
579
+ _cat = _normalize_category(Category)
580
+ _dir = _normalize_direction(Direction)
581
+ zip_filename = _normalize_zip_filename(Filename)
582
+ path_in_repo = _build_image_path_in_repo(_id, _cat, _dir, zip_filename)
583
+
584
+ zip_bytes: Optional[bytes] = None
585
+ last_error: str = ""
586
+ total_attempts = 0
587
+ start = time.time()
588
+
589
+ for poll_t in POLL_TIMES_SECONDS:
590
+ target_time = start + float(poll_t)
591
+ now = time.time()
592
+ if target_time > now:
593
+ time.sleep(target_time - now)
594
+
595
+ ok, data, err = _try_download_file_bytes(path_in_repo)
596
+ total_attempts += 1
597
+ if ok and data is not None:
598
+ zip_bytes = data
599
+ break
600
+
601
+ last_error = err
602
+
603
+ if zip_bytes is not None:
604
+ try:
605
+ png_bytes_list, entry_names = _zip_bytes_to_png_bytes_list(zip_bytes)
606
+ batch = _png_bytes_list_to_batch(png_bytes_list)
607
+ except Exception as e:
608
+ dummy_one = _select_first_as_single_image(dummy_image)
609
+ report = (
610
+ f"FAIL downloaded ZIP but failed to decode PNG(s): {e}\n"
611
+ f"zip_path: {path_in_repo}"
612
+ )
613
+ _append_log(f"DOWNLOAD FAIL decode_error repo={REPO_ID} target={path_in_repo} error={e}")
614
+ return {"ui": {"text": [report]}, "result": (dummy_one, report)}
615
+
616
+ report = (
617
+ f"OK downloaded ZIP with {len(entry_names)} image(s) "
618
+ f"from {path_in_repo} in {int(time.time() - start)}s; attempts={total_attempts}\n"
619
+ f"zip_entries:\n" + "\n".join(entry_names)
620
+ )
621
+ _append_log(f"DOWNLOAD OK repo={REPO_ID} target={path_in_repo} images={len(entry_names)} attempts={total_attempts}")
622
+ return {"ui": {"text": [report]}, "result": (batch, report)}
623
+
624
+ dummy_one = _select_first_as_single_image(dummy_image)
625
+ report = (
626
+ f"FAIL ZIP not found; path={path_in_repo}; attempts={total_attempts}; last_error={last_error}"
627
+ )
628
+ _append_log(f"DOWNLOAD FAIL repo={REPO_ID} target={path_in_repo} attempts={total_attempts} error={last_error}")
629
+ return {"ui": {"text": [report]}, "result": (dummy_one, report)}
630
+
631
+
632
+ # -------------------------------------------------------------------------------------
633
+ # 3) TEXT UPLOADER -> {Version}/{ID}/Data/BAM.txt
634
+ # -------------------------------------------------------------------------------------
635
+ class HF_BAM_Uploader:
636
+ """
637
+ Upload a string to:
638
+ {Version}/{ID}/Data/BAM.txt
639
+ Outputs "OK" or "FAIL ..."
640
+ """
641
+
642
+ @classmethod
643
+ def INPUT_TYPES(cls):
644
+ return {
645
+ "required": {
646
+ "ID": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
647
+ "BAM": ("STRING", {"default": "my car is red", "multiline": True}),
648
+ }
649
+ }
650
+
651
+ RETURN_TYPES = ("STRING",)
652
+ RETURN_NAMES = ("status",)
653
+ FUNCTION = "upload_bam"
654
+ CATEGORY = "hf"
655
+
656
+ @classmethod
657
+ def IS_CHANGED(cls, **kwargs):
658
+ return float("nan")
659
+
660
+ def upload_bam(self, ID, BAM):
661
+ try:
662
+ from huggingface_hub import HfApi, CommitOperationAdd
663
+ except Exception as e:
664
+ status = f"FAIL missing huggingface_hub: {e}"
665
+ _append_log(f"BAM UPLOAD FAIL import_error repo={REPO_ID} id={ID} error={e}")
666
+ return {"ui": {"text": [status]}, "result": (status,)}
667
+
668
+ _id = _sanitize_component(str(int(ID)))
669
+ path_in_repo = _build_bam_path_in_repo(_id)
670
+
671
+ content = "" if BAM is None else str(BAM)
672
+ data = content.encode("utf-8")
673
+
674
+ api = HfApi(token=Secret_Token)
675
+ try:
676
+ api.create_commit(
677
+ repo_id=REPO_ID,
678
+ repo_type=REPO_TYPE,
679
+ operations=[
680
+ CommitOperationAdd(
681
+ path_in_repo=path_in_repo,
682
+ path_or_fileobj=io.BytesIO(data),
683
+ )
684
+ ],
685
+ commit_message=f"Upload BAM.txt to {Version}/{_id}/Data",
686
+ token=Secret_Token,
687
+ )
688
+ status = "OK"
689
+ _append_log(f"BAM UPLOAD OK repo={REPO_ID} target={path_in_repo} bytes={len(data)}")
690
+ except Exception as e:
691
+ status = f"FAIL {e}"
692
+ _append_log(f"BAM UPLOAD FAIL repo={REPO_ID} target={path_in_repo} error={e}")
693
+
694
+ return {"ui": {"text": [status]}, "result": (status,)}
695
+
696
+
697
+ # -------------------------------------------------------------------------------------
698
+ # 4) TEXT DOWNLOADER -> {Version}/{ID}/Data/BAM.txt (single attempt, no polling)
699
+ # -------------------------------------------------------------------------------------
700
+ class HF_BAM_Downloader:
701
+ """
702
+ Download:
703
+ {Version}/{ID}/Data/BAM.txt
704
+ Single attempt, no polling.
705
+ Outputs the file content as STRING (or "" on failure).
706
+ """
707
+
708
+ @classmethod
709
+ def INPUT_TYPES(cls):
710
+ return {
711
+ "required": {
712
+ "ID": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
713
+ }
714
+ }
715
+
716
+ RETURN_TYPES = ("STRING",)
717
+ RETURN_NAMES = ("BAM",)
718
+ FUNCTION = "download_bam"
719
+ CATEGORY = "hf"
720
+
721
+ @classmethod
722
+ def IS_CHANGED(cls, **kwargs):
723
+ return float("nan")
724
+
725
+ def download_bam(self, ID):
726
+ _id = _sanitize_component(str(int(ID)))
727
+ path_in_repo = _build_bam_path_in_repo(_id)
728
+
729
+ ok, data, err = _try_download_file_bytes(path_in_repo)
730
+ if not ok or data is None:
731
+ out = ""
732
+ report = f"FAIL {err}"
733
+ _append_log(f"BAM DOWNLOAD FAIL repo={REPO_ID} target={path_in_repo} error={err}")
734
+ return {"ui": {"text": [report]}, "result": (out,)}
735
+
736
+ try:
737
+ out = data.decode("utf-8", errors="replace")
738
+ report = "OK"
739
+ _append_log(f"BAM DOWNLOAD OK repo={REPO_ID} target={path_in_repo} bytes={len(data)}")
740
+ return {"ui": {"text": [report]}, "result": (out,)}
741
+ except Exception as e:
742
+ out = ""
743
+ report = f"FAIL {e}"
744
+ _append_log(f"BAM DOWNLOAD FAIL repo={REPO_ID} target={path_in_repo} decode_error={e}")
745
+ return {"ui": {"text": [report]}, "result": (out,)}
746
+
747
+
748
+ # -------------------------------------------------------------------------------------
749
+ # Node registration
750
+ # -------------------------------------------------------------------------------------
751
+ NODE_CLASS_MAPPINGS = {
752
+ "HF_X_Uploader": HF_X_Uploader,
753
+ "HF_X_Downloader": HF_X_Downloader,
754
+ "HF_BAM_Uploader": HF_BAM_Uploader,
755
+ "HF_BAM_Downloader": HF_BAM_Downloader,
756
+ }
757
+
758
+ NODE_DISPLAY_NAME_MAPPINGS = {
759
+ "HF_X_Uploader": "HF X Uploader (ZIP of PNGs)",
760
+ "HF_X_Downloader": "HF X (ZIP of PNGs, Polling)",
761
+ "HF_BAM_Uploader": "HF_BAM_Uploader",
762
+ "HF_BAM_Downloader": "HF_BAM_Downloader",
763
+ }