From c1c57dc5c9589ca14b088c82f0cec00d15c413f4 Mon Sep 17 00:00:00 2001 From: dseditor Date: Mon, 9 Feb 2026 00:11:36 +0800 Subject: [PATCH] adding frame node --- model_downloader.py | 314 +++++++++++++++++++++++++++++--- model_templates.json | 23 --- modeldata/default.json | 5 - modeldata/gemini.json | 5 - modeldata/openai.json | 5 - nodes.py | 55 ++++++ pyproject.toml | 2 +- web/js/modelDownloader.js | 364 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 711 insertions(+), 62 deletions(-) delete mode 100644 modeldata/default.json delete mode 100644 modeldata/gemini.json delete mode 100644 modeldata/openai.json create mode 100644 web/js/modelDownloader.js diff --git a/model_downloader.py b/model_downloader.py index f22b8f3..34c3a6c 100644 --- a/model_downloader.py +++ b/model_downloader.py @@ -2,9 +2,16 @@ import os import re import shutil import json +import time import folder_paths from typing import List, Dict, Tuple, Optional +try: + from server import PromptServer + HAS_SERVER = True +except ImportError: + HAS_SERVER = False + class ModelDownloader: """ @@ -63,9 +70,12 @@ class ModelDownloader: "tooltip": "最大平行下載執行緒數" }), "model_template": (template_names, { - "default": "None", - "tooltip": "選擇模型範本。選擇範本後將使用範本中的下載列表,忽略上方手動輸入的內容。" + "default": "無", + "tooltip": "選擇模型範本。選擇範本後將使用範本中的下載列表,忽略上方手動輸入的內容。\n選擇「無」時使用上方手動輸入的下載列表。" }), + }, + "hidden": { + "unique_id": "UNIQUE_ID", } } @@ -75,8 +85,20 @@ class ModelDownloader: OUTPUT_NODE = True CATEGORY = "ListHelper/Tools" + def _send_progress(self, unique_id, data): + """透過 WebSocket 發送下載進度事件""" + if HAS_SERVER and unique_id: + try: + PromptServer.instance.send_sync("model_download_progress", { + "node_id": str(unique_id), + **data + }) + except Exception as e: + print(f"ModelDownloader: 進度推送失敗:{e}") + def download_models(self, download_list: str, use_s3c: bool, use_hf_download: bool, - chunk_size_mb: int, max_workers: int, model_template: str) -> Tuple[str]: + chunk_size_mb: int, max_workers: int, model_template: str, + unique_id=None) -> Tuple[str]: """ 下載模型到 ComfyUI/models 資料夾 @@ -87,18 +109,22 @@ class ModelDownloader: chunk_size_mb: 區塊大小 (MB) max_workers: 最大平行執行緒數 model_template: 模型範本名稱 + unique_id: 節點唯一 ID(由 ComfyUI 自動傳入) Returns: 狀態訊息 """ - # 若選擇了範本,使用範本的下載列表 - if model_template and model_template != "無": + # 若選擇了範本,使用範本的下載列表;否則使用手動輸入的 download_list + no_template_values = {"無", "none", "None", ""} + if model_template and model_template not in no_template_values: template_download_list = self._get_template_download_list(model_template) if template_download_list: download_list = template_download_list print(f"ModelDownloader: 使用範本「{model_template}」的下載列表") else: return (f"錯誤:無法載入範本「{model_template}」",) + else: + print(f"ModelDownloader: 未選擇範本,使用手動輸入的下載列表") # 解析下載列表 download_map = self._parse_download_list(download_list) @@ -126,6 +152,15 @@ class ModelDownloader: elif use_s3c and not s3c_available: print("ModelDownloader: 警告 - use_s3c 已開啟但 s3impleclient 未安裝") + # 發送檢查狀態 + self._send_progress(unique_id, { + "status": "checking", + "message": "正在檢查現有檔案...", + "progress": 0, + "current_file": 0, + "total_files": 0, + }) + # 檢查檔案是否已存在且完整 files_to_download, skipped_files, incomplete_files = self._check_existing_files( download_map, models_root @@ -134,6 +169,13 @@ class ModelDownloader: if not files_to_download: skip_msg = "所有檔案已存在且完整。\n已跳過:\n" + "\n".join(f" - {f}" for f in skipped_files) print(f"ModelDownloader: {skip_msg}") + self._send_progress(unique_id, { + "status": "complete", + "message": "所有檔案已存在,無需下載", + "progress": 100, + "current_file": 0, + "total_files": 0, + }) return (skip_msg,) # 開始下載 @@ -141,6 +183,20 @@ class ModelDownloader: total_files = sum(len(urls) for urls in files_to_download.values()) current_file = 0 + # 建立進度回調 + def progress_callback(filename, downloaded, total, cur_file, tot_files): + progress = (downloaded / total * 100) if total > 0 else 0 + self._send_progress(unique_id, { + "status": "downloading", + "filename": filename, + "progress": round(progress, 1), + "downloaded": downloaded, + "total": total, + "current_file": cur_file, + "total_files": tot_files, + "message": f"正在下載:{filename}", + }) + try: for folder_name, urls in files_to_download.items(): target_folder = self._get_target_folder(models_root, folder_name) @@ -156,13 +212,31 @@ class ModelDownloader: print(f" URL: {url}") print(f" 目標:{dest_path}") + # 發送開始下載事件 + self._send_progress(unique_id, { + "status": "downloading", + "filename": filename, + "progress": 0, + "downloaded": 0, + "total": 0, + "current_file": current_file, + "total_files": total_files, + "message": f"正在下載:{filename}", + }) + try: if use_hf_download and self._is_huggingface_url(url): # 使用 HF Hub 下載,然後搬移到目標資料夾 - result = self._download_with_hf_hub(url, dest_path, s3c, patch_applied) + result = self._download_with_hf_hub(url, dest_path, s3c, patch_applied, + progress_callback=progress_callback, + current_file=current_file, + total_files=total_files) else: # 使用普通 HTTP 下載 - result = self._download_with_http(url, dest_path) + result = self._download_with_http(url, dest_path, + progress_callback=progress_callback, + current_file=current_file, + total_files=total_files) results.append(result) @@ -170,6 +244,14 @@ class ModelDownloader: error_msg = f"✗ {filename} - 錯誤:{str(e)}" results.append(error_msg) print(f" {error_msg}") + self._send_progress(unique_id, { + "status": "error", + "filename": filename, + "progress": 0, + "current_file": current_file, + "total_files": total_files, + "message": f"下載失敗:{str(e)}", + }) finally: # 還原 HF patch @@ -180,6 +262,18 @@ class ModelDownloader: except Exception as e: print(f"\nModelDownloader: 無法還原 HF patch ({str(e)})") + # 發送下載完成事件 + success_count = sum(1 for r in results if r.startswith("✓")) + fail_count = sum(1 for r in results if r.startswith("✗")) + self._send_progress(unique_id, { + "status": "complete", + "message": f"下載完成!成功:{success_count}/{total_files},失敗:{fail_count}", + "progress": 100, + "current_file": total_files, + "total_files": total_files, + "has_downloads": True, + }) + # 產生最終狀態訊息 return self._generate_status_message(results, skipped_files, incomplete_files, total_files) @@ -346,20 +440,64 @@ class ModelDownloader: def _get_remote_file_size(self, url: str) -> Optional[int]: """ 取得遠端檔案大小(透過 HEAD 請求) + 支援 Civitai 等需要特殊處理的網站 Returns: 檔案大小(bytes),如果無法取得則返回 None """ try: import urllib.request + import ssl - request = urllib.request.Request(url, method='HEAD') - request.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') + # 建立自訂的 opener 以處理重定向 + cookie_handler = urllib.request.HTTPCookieProcessor() + redirect_handler = urllib.request.HTTPRedirectHandler() + ssl_context = ssl.create_default_context() + https_handler = urllib.request.HTTPSHandler(context=ssl_context) + + opener = urllib.request.build_opener( + https_handler, + cookie_handler, + redirect_handler + ) + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + } + + # Civitai 有時 HEAD 請求不返回 Content-Length,需要用 GET 請求的 Range header + is_civitai = 'civitai.com' in url.lower() + + if is_civitai: + # 對 Civitai 使用 GET 請求但只取 0 bytes 來獲取 headers + request = urllib.request.Request(url, headers=headers) + request.add_header('Range', 'bytes=0-0') + request.add_header('Referer', 'https://civitai.com/') + + with opener.open(request, timeout=30) as response: + # 從 Content-Range 取得總大小 + content_range = response.headers.get('Content-Range') + if content_range: + # 格式:bytes 0-0/12345678 + import re + match = re.search(r'/(\d+)', content_range) + if match: + return int(match.group(1)) + + # 備用:嘗試 Content-Length + content_length = response.headers.get('Content-Length') + if content_length: + return int(content_length) + else: + # 非 Civitai 使用標準 HEAD 請求 + request = urllib.request.Request(url, method='HEAD', headers=headers) + + with opener.open(request, timeout=30) as response: + content_length = response.headers.get('Content-Length') + if content_length: + return int(content_length) - with urllib.request.urlopen(request, timeout=30) as response: - content_length = response.headers.get('Content-Length') - if content_length: - return int(content_length) except Exception as e: print(f"ModelDownloader: 無法取得遠端檔案大小:{e}") @@ -368,7 +506,9 @@ class ModelDownloader: # ==================== 下載方法 ==================== def _download_with_hf_hub(self, url: str, dest_path: str, - s3c: Optional[object], patch_applied: bool) -> str: + s3c: Optional[object], patch_applied: bool, + progress_callback=None, current_file: int = 0, + total_files: int = 0) -> str: """ 使用 HuggingFace Hub 下載,然後搬移到目標資料夾 @@ -377,6 +517,9 @@ class ModelDownloader: dest_path: 最終目標路徑 s3c: s3impleclient module(如果可用) patch_applied: S3C patch 是否已套用 + progress_callback: 進度回調函數 + current_file: 當前檔案序號 + total_files: 總檔案數 Returns: 結果訊息 @@ -389,6 +532,10 @@ class ModelDownloader: print(f" 使用 HF Hub 下載...") print(f" Repo: {repo_id}, File: {file_path}") + # HF Hub 下載不支援細粒度進度,發送一個「下載中」狀態 + if progress_callback: + progress_callback(filename, 0, 0, current_file, total_files) + # 使用 HF Hub 下載到快取 downloaded_path = hf_hub_download( repo_id=repo_id, @@ -409,7 +556,10 @@ class ModelDownloader: # 驗證複製成功 if os.path.exists(dest_path): size_mb = os.path.getsize(dest_path) / (1024 * 1024) + size_bytes = os.path.getsize(dest_path) method = "HF Hub + S3C 加速" if patch_applied else "HF Hub" + if progress_callback: + progress_callback(filename, size_bytes, size_bytes, current_file, total_files) result = f"✓ {filename} ({size_mb:.2f} MB) ({method})" print(f" ✓ 下載並搬移成功 - {size_mb:.2f} MB") return result @@ -419,23 +569,33 @@ class ModelDownloader: return result else: size_mb = os.path.getsize(dest_path) / (1024 * 1024) + size_bytes = os.path.getsize(dest_path) method = "HF Hub + S3C 加速" if patch_applied else "HF Hub" + if progress_callback: + progress_callback(filename, size_bytes, size_bytes, current_file, total_files) result = f"✓ {filename} ({size_mb:.2f} MB) ({method})" print(f" ✓ 下載成功 - {size_mb:.2f} MB") return result - def _download_with_http(self, url: str, dest_path: str) -> str: + def _download_with_http(self, url: str, dest_path: str, + progress_callback=None, current_file: int = 0, + total_files: int = 0) -> str: """ - 使用普通 HTTP 下載 + 使用普通 HTTP 下載(支援 Civitai 等需要特殊處理的網站) Args: url: 下載 URL dest_path: 目標路徑 + progress_callback: 進度回調函數 + current_file: 當前檔案序號 + total_files: 總檔案數 Returns: 結果訊息 """ import urllib.request + import urllib.error + import ssl filename = self._extract_filename(url) print(f" 使用 HTTP 下載...") @@ -447,16 +607,76 @@ class ModelDownloader: temp_path = dest_path + '.tmp' try: - request = urllib.request.Request(url) - request.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') + # 建立自訂的 opener 以處理重定向和 cookies + # Civitai 需要完整的瀏覽器 headers 和 cookie 支援 + cookie_handler = urllib.request.HTTPCookieProcessor() + redirect_handler = urllib.request.HTTPRedirectHandler() + + # 建立 SSL context(某些網站需要) + ssl_context = ssl.create_default_context() + https_handler = urllib.request.HTTPSHandler(context=ssl_context) + + opener = urllib.request.build_opener( + https_handler, + cookie_handler, + redirect_handler + ) + + # 設定完整的瀏覽器 headers(Civitai 會檢查這些) + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'identity', # 不使用壓縮,方便計算進度 + 'Connection': 'keep-alive', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + } + + request = urllib.request.Request(url, headers=headers) + + # 檢查是否為 Civitai URL,需要特殊處理 + is_civitai = 'civitai.com' in url.lower() + if is_civitai: + print(f" 偵測到 Civitai 連結,使用特殊處理...") + # Civitai 有時需要 Referer + request.add_header('Referer', 'https://civitai.com/') + + with opener.open(request, timeout=300) as response: + # 取得最終 URL(處理重定向後的實際下載位置) + final_url = response.geturl() + if final_url != url: + print(f" 重定向到:{final_url[:100]}...") + # 從最終 URL 取得真正的檔案名稱(如果可能) + if is_civitai: + # Civitai 重定向後的 URL 可能包含真正的檔案名稱 + content_disposition = response.headers.get('Content-Disposition', '') + if content_disposition: + # 嘗試從 Content-Disposition 取得檔案名稱 + import re + cd_match = re.search(r'filename[*]?=["\']?([^"\';\r\n]+)', content_disposition) + if cd_match: + real_filename = cd_match.group(1).strip() + # URL 解碼 + real_filename = urllib.request.unquote(real_filename) + if real_filename and real_filename != filename: + print(f" 實際檔案名稱:{real_filename}") + # 更新目標路徑 + dest_path = os.path.join(os.path.dirname(dest_path), real_filename) + temp_path = dest_path + '.tmp' + filename = real_filename - with urllib.request.urlopen(request, timeout=300) as response: total_size = int(response.headers.get('Content-Length', 0)) + if total_size == 0 and is_civitai: + print(f" 警告:無法取得檔案大小,可能是 Civitai 連結問題") + with open(temp_path, 'wb') as out_file: downloaded = 0 - chunk_size = 8192 + chunk_size = 8192 * 4 # 增加 chunk size 提升效能 last_progress = 0 + last_callback_time = time.time() while True: chunk = response.read(chunk_size) @@ -466,21 +686,57 @@ class ModelDownloader: out_file.write(chunk) downloaded += len(chunk) - # 每 10% 顯示進度 + # 每 10% 顯示進度(終端機) if total_size > 0: progress = int(downloaded * 100 / total_size) if progress >= last_progress + 10: print(f" 進度:{progress}% ({downloaded / (1024*1024):.2f} MB / {total_size / (1024*1024):.2f} MB)") last_progress = progress + elif downloaded % (10 * 1024 * 1024) == 0: # 每 10MB 顯示一次 + print(f" 已下載:{downloaded / (1024*1024):.2f} MB") + + # WebSocket 進度推送(限制頻率,每 0.5 秒最多一次) + now = time.time() + if progress_callback and (now - last_callback_time >= 0.5): + progress_callback(filename, downloaded, total_size, current_file, total_files) + last_callback_time = now + + # 檢查下載的檔案是否有效(不是錯誤頁面) + downloaded_size = os.path.getsize(temp_path) + if downloaded_size < 1024 and is_civitai: + # 檔案太小,可能是錯誤訊息 + with open(temp_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + if 'error' in content.lower() or 'unauthorized' in content.lower() or ' str: - """從 URL 提取檔案名稱""" + """ + 從 URL 提取檔案名稱 + 對於 Civitai 等特殊 URL,會返回臨時檔名(下載時會從 Content-Disposition 取得真正檔名) + """ + # 檢查是否為 Civitai API 下載連結 + if 'civitai.com/api/download' in url.lower(): + # Civitai API URL 格式:/api/download/models/123456?... + # 從 URL 取得 model version ID 作為臨時檔名 + url_path = url.split('?')[0] + model_id = url_path.split('/')[-1] + # 返回臨時檔名,下載時會從 Content-Disposition 取得真正檔名 + return f"civitai_model_{model_id}.safetensors" + url = url.split('?')[0] return url.split('/')[-1] diff --git a/model_templates.json b/model_templates.json index 5a74622..db2c76e 100644 --- a/model_templates.json +++ b/model_templates.json @@ -173,29 +173,6 @@ ] } ] - }, - "ZimageTurbo BestPhoto (FP8)": { - "description": "Z-Image Turbo BestPhoto FP8 版本", - "download_list": [ - { - "folder": "diffusion_models", - "urls": [ - "https://civitai.com/api/download/models/2579970?type=Model&format=SafeTensor&size=full&fp=fp8" - ] - }, - { - "folder": "CLIP", - "urls": [ - "https://huggingface.co/Mungert/Qwen3-4B-abliterated-GGUF/resolve/main/Qwen3-4B-abliterated-q4_k_m.gguf" - ] - }, - { - "folder": "VAE", - "urls": [ - "https://huggingface.co/dseditor/Collection/resolve/main/aeultraflux.safetensors" - ] - } - ] } } } diff --git a/modeldata/default.json b/modeldata/default.json deleted file mode 100644 index a31fc3c..0000000 --- a/modeldata/default.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "endpoint": "http://localhost:11434/v1/chat/completions", - "api_key": "ollama", - "model_name": "huihui_ai/qwen3-vl-abliterated:4b-instruct" -} \ No newline at end of file diff --git a/modeldata/gemini.json b/modeldata/gemini.json deleted file mode 100644 index d6d4a49..0000000 --- a/modeldata/gemini.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "endpoint": "https://generativelanguage.googleapis.com/v1beta/chat/completions", - "api_key": "", - "model_name": "gemini-2.5-flash" -} \ No newline at end of file diff --git a/modeldata/openai.json b/modeldata/openai.json deleted file mode 100644 index 00b3d19..0000000 --- a/modeldata/openai.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "endpoint": "https://api.openai.com/v1/chat/completions", - "api_key": "", - "model_name": "gpt-5.2" -} \ No newline at end of file diff --git a/nodes.py b/nodes.py index 9ed5956..53e997b 100644 --- a/nodes.py +++ b/nodes.py @@ -1156,6 +1156,59 @@ class BatchToPSD: +class ImageListSplitHeadTail: + """ + 將圖片清單拆分為影片頭尾格系統。 + 輸入 N 張圖片,產生 N-1 組起始/結束幀配對, + 再透過 start_index 與 index_count 選取特定範圍的組數。 + 例如輸入 [1,2,3,4](共 3 組配對): + start_index=0, index_count=100 → 全部:(1,2)(2,3)(3,4) + start_index=0, index_count=1 → (1,2) + start_index=1, index_count=1 → (2,3) + start_index=1, index_count=2 → (2,3)(3,4) + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "start_index": ("INT", { + "default": 0, "min": 0, "max": 999, + "tooltip": "從第幾組配對開始取(0 = 第一組)" + }), + "index_count": ("INT", { + "default": 100, "min": 1, "max": 999, + "tooltip": "取幾組配對(預設 100,即全部)" + }), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE") + RETURN_NAMES = ("start_frames", "end_frames", "final_end_frame") + OUTPUT_IS_LIST = (True, True, False) + FUNCTION = "split_head_tail" + CATEGORY = "ListHelper/Tools" + + def split_head_tail(self, images, start_index, index_count): + n = images.shape[0] + + if n < 2: + raise ValueError("至少需要 2 張圖片才能拆分為頭尾格") + + total_pairs = n - 1 + + # 限制 start_index 範圍 + si = min(start_index, total_pairs - 1) + ei = min(si + index_count, total_pairs) + + start_frames = [images[i:i+1] for i in range(si, ei)] + end_frames = [images[i:i+1] for i in range(si + 1, ei + 1)] + final_end_frame = images[ei:ei+1] + + return (start_frames, end_frames, final_end_frame) + + NODE_CLASS_MAPPINGS = { "AudioListGenerator": AudioListGenerator, "AudioToFrameCount": AudioToFrameCount, @@ -1175,6 +1228,7 @@ NODE_CLASS_MAPPINGS = { "PhotoMagazinePromptGenerator": PhotoMagazinePromptGenerator, "PhotoMagazineParser": PhotoMagazineParser, "PhotoMagazineMaker": PhotoMagazineMaker, + "ImageListSplitHeadTail": ImageListSplitHeadTail, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -1195,5 +1249,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "PhotoMagazinePromptGenerator": "Photo Magazine Prompt Generator", "PhotoMagazineParser": "Photo Magazine Parser", "PhotoMagazineMaker": "Photo Magazine Maker", + "ImageListSplitHeadTail": "Image List Split Head Tail", } diff --git a/pyproject.toml b/pyproject.toml index 3ec9814..9d09cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "Listhelper" description = "The ListHelper collection is a comprehensive set of custom nodes for ComfyUI that provides powerful list manipulation capabilities. This collection includes audio processing, text splitting, and number generation tools for enhanced workflow automation.Qwen/GGUF Node For LLM function,SimpleWildCardSystem,BatchToPSDForQwenLayer" -version = "1.3.0" +version = "1.3.1" license = {file = "LICENSE"} dependencies = ["regex", "accelerate", "reportlab"] diff --git a/web/js/modelDownloader.js b/web/js/modelDownloader.js new file mode 100644 index 0000000..636f62a --- /dev/null +++ b/web/js/modelDownloader.js @@ -0,0 +1,364 @@ +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +/** + * Model Downloader progress bar extension for ComfyUI-ListHelper + * + * Design: The status panel is drawn BELOW the node body as an extension plate. + * This prevents widgets (especially multiline text areas) from expanding into + * and covering the panel area. The node's visual bounds include both the + * standard node body and the panel extension below it. + */ + +const PANEL_HEIGHT = 66; +const CORNER_RADIUS = 8; + +// Border color per status +const BORDER_COLORS = { + idle: "#555570", + checking: "#c0a030", + downloading: "#2979FF", + complete: "#43A047", + error: "#E53935", +}; + +app.registerExtension({ + name: "listhelper.ModelDownloader", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "ModelDownloader") return; + + // NOTE: We intentionally do NOT override computeSize. + // Adding PANEL_HEIGHT to computeSize causes widgets (especially + // multiline STRING inputs) to auto-expand into the panel area, + // completely covering the progress display. + // Instead, the panel is drawn outside the node body in onDrawForeground. + + // ── onDrawForeground: draw extension panel below the node body ── + const origOnDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + origOnDrawForeground?.apply(this, arguments); + + // Don't draw panel when node is collapsed + if (this.flags?.collapsed) return; + + const w = this.size[0]; + const h = this.size[1]; + const d = this._downloadProgress || { status: "idle" }; + const status = d.status || "idle"; + const panelY = h; // Panel starts right at the bottom edge of the node body + + ctx.save(); + + // ── 1. Bridge: fill the node's bottom rounded corners ── + // The node body has rounded bottom corners, creating small gaps. + // Fill them so the panel connects seamlessly to the node. + const bgcolor = this.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR || "#353535"; + ctx.fillStyle = bgcolor; + ctx.fillRect(0, h - CORNER_RADIUS, w, CORNER_RADIUS); + + // ── 2. Panel background plate (extends below the node) ── + ctx.fillStyle = "rgba(0, 0, 0, 0.22)"; + ctx.beginPath(); + ctx.roundRect(0, panelY, w, PANEL_HEIGHT, [0, 0, CORNER_RADIUS, CORNER_RADIUS]); + ctx.fill(); + + // ── 3. Separator line at the joint ── + ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(10, panelY + 1); + ctx.lineTo(w - 10, panelY + 1); + ctx.stroke(); + + // ── 4. Status content ── + if (status === "idle") { + drawIdleState(ctx, w, panelY); + } else if (status === "checking") { + drawCheckingState(ctx, w, panelY); + } else if (status === "downloading") { + drawDownloadingState(ctx, d, w, panelY); + } else if (status === "complete") { + drawCompleteState(ctx, d, w, panelY); + } else if (status === "error") { + drawErrorState(ctx, d, w, panelY); + } + + // ── 5. Unified accent border around node + panel ── + const titleH = LiteGraph.NODE_TITLE_HEIGHT || 30; + const totalH = h + PANEL_HEIGHT + titleH; + const borderColor = BORDER_COLORS[status] || BORDER_COLORS.idle; + ctx.strokeStyle = borderColor; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.roundRect(-0.5, -titleH - 0.5, w + 1, totalH + 1, CORNER_RADIUS); + ctx.stroke(); + + ctx.restore(); + }; + + // ── getBounding: extend bounding box to include the panel ── + // Without this, LiteGraph may clip the panel during render culling. + const origGetBounding = nodeType.prototype.getBounding; + nodeType.prototype.getBounding = function (out) { + const bounds = origGetBounding + ? origGetBounding.apply(this, arguments) + : LiteGraph.LGraphNode.prototype.getBounding.apply(this, arguments); + if (bounds && !this.flags?.collapsed) { + bounds[3] += PANEL_HEIGHT; + } + return bounds; + }; + + // ── onNodeCreated: initialise progress state ── + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + origOnNodeCreated?.apply(this, arguments); + this._downloadProgress = { status: "idle" }; + }; + + // ── onConfigure: restore progress state for saved workflows ── + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + origOnConfigure?.apply(this, arguments); + if (!this._downloadProgress) this._downloadProgress = { status: "idle" }; + }; + }, + + // ── WebSocket listener ── + async setup() { + api.addEventListener("model_download_progress", (event) => { + const data = event.detail; + if (!data || !data.node_id) return; + + const nodeId = String(data.node_id); + const node = app.graph._nodes_by_id?.[nodeId] + || app.graph._nodes?.find(n => String(n.id) === nodeId); + if (!node) return; + + node._downloadProgress = { + status: data.status || "idle", + filename: data.filename || "", + progress: data.progress || 0, + downloaded: data.downloaded || 0, + total: data.total || 0, + current_file: data.current_file || 0, + total_files: data.total_files || 0, + message: data.message || "", + has_downloads: data.has_downloads || false, + }; + + app.graph.setDirtyCanvas(true, false); + + if (data.status === "complete" && data.has_downloads) { + showDownloadCompleteToast(); + } + }); + }, +}); + +// ===================== Drawing helpers ===================== + +function drawIdleState(ctx, w, panelY) { + ctx.fillStyle = "#888899"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const cy = panelY + PANEL_HEIGHT / 2; + ctx.fillText("Enter URLs & folders, or select a", w / 2, cy - 8); + ctx.fillText("template to start downloading", w / 2, cy + 10); +} + +function drawCheckingState(ctx, w, panelY) { + ctx.fillStyle = "#c0a030"; + ctx.font = "bold 13px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Checking existing files\u2026", w / 2, panelY + PANEL_HEIGHT / 2); +} + +function drawDownloadingState(ctx, d, w, panelY) { + const px = 12; + const innerW = w - px * 2; + + // Row 1 — file label + const fileLabel = d.total_files > 0 + ? `[${d.current_file}/${d.total_files}] ${d.filename || ""}` + : (d.filename || "Preparing\u2026"); + ctx.fillStyle = "#D0D0D8"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(truncText(ctx, fileLabel, innerW), px, panelY + 16); + + // Row 2 — progress bar + const barX = px; + const barY = panelY + 23; + const barW = innerW; + const barH = 16; + + // track + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(barX, barY, barW, barH, 3); + ctx.fill(); + + // fill + const pct = clamp(d.progress || 0, 0, 100); + if (pct > 0) { + const fillW = Math.max(barW * pct / 100, 4); + const grad = ctx.createLinearGradient(barX, 0, barX + barW, 0); + grad.addColorStop(0, "#1565C0"); + grad.addColorStop(1, "#42A5F5"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.roundRect(barX, barY, fillW, barH, 3); + ctx.fill(); + } + + // percentage label + ctx.fillStyle = "#FFFFFF"; + ctx.font = "bold 10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(`${pct.toFixed(1)}%`, barX + barW / 2, barY + barH / 2); + + // Row 3 — size + ctx.textBaseline = "alphabetic"; + ctx.font = "11px sans-serif"; + ctx.textAlign = "right"; + if (d.total > 0) { + ctx.fillStyle = "#909099"; + ctx.fillText( + `${formatMB(d.downloaded)} / ${formatMB(d.total)} MB`, + w - px, panelY + 56 + ); + } else { + ctx.fillStyle = "#707078"; + ctx.fillText("Fetching file size\u2026", w - px, panelY + 56); + } +} + +function drawCompleteState(ctx, d, w, panelY) { + const cy = panelY + PANEL_HEIGHT / 2; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + if (d.has_downloads) { + ctx.fillStyle = "#43A047"; + ctx.font = "bold 13px sans-serif"; + ctx.fillText("Download complete!", w / 2, cy - 9); + ctx.fillStyle = "#FFD740"; + ctx.font = "12px sans-serif"; + ctx.fillText("Please refresh browser (F5)", w / 2, cy + 10); + } else { + ctx.fillStyle = "#43A047"; + ctx.font = "bold 13px sans-serif"; + ctx.fillText("All models verified \u2014 already downloaded", w / 2, cy); + } +} + +function drawErrorState(ctx, d, w, panelY) { + ctx.fillStyle = "#E53935"; + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const msg = d.message + ? "Error: " + d.message + : "Download failed"; + ctx.fillText(truncText(ctx, msg, w - 24), w / 2, panelY + PANEL_HEIGHT / 2); +} + +// ===================== Utilities ===================== + +function clamp(v, lo, hi) { return Math.min(Math.max(v, lo), hi); } + +function formatMB(bytes) { return (bytes / (1024 * 1024)).toFixed(1); } + +function truncText(ctx, text, maxWidth) { + if (ctx.measureText(text).width <= maxWidth) return text; + let t = text; + while (t.length > 0 && ctx.measureText(t + "\u2026").width > maxWidth) { + t = t.slice(0, -1); + } + return t + "\u2026"; +} + +// ===================== Toast notification ===================== + +function showDownloadCompleteToast() { + const existing = document.getElementById("md-download-toast"); + if (existing) existing.remove(); + + const toast = document.createElement("div"); + toast.id = "md-download-toast"; + Object.assign(toast.style, { + position: "fixed", top: "20px", left: "50%", + transform: "translateX(-50%)", + background: "linear-gradient(135deg, #1a5e1a, #2d8a2d)", + color: "white", padding: "16px 28px", borderRadius: "10px", + fontSize: "15px", fontFamily: "sans-serif", zIndex: "99999", + boxShadow: "0 6px 24px rgba(0,0,0,0.4)", + display: "flex", flexDirection: "column", alignItems: "center", + gap: "10px", maxWidth: "480px", + animation: "mdSlideDown 0.3s ease-out", + }); + + const titleEl = document.createElement("div"); + titleEl.style.fontWeight = "bold"; + titleEl.textContent = "Download complete!"; + + const hintEl = document.createElement("div"); + Object.assign(hintEl.style, { fontSize: "13px", color: "#FFD740" }); + hintEl.textContent = "Please refresh browser (F5) to load new models"; + + const btnRow = document.createElement("div"); + Object.assign(btnRow.style, { display: "flex", gap: "10px", marginTop: "4px" }); + + const refreshBtn = document.createElement("button"); + refreshBtn.textContent = "Refresh now"; + Object.assign(refreshBtn.style, { + background: "#FFD740", color: "#1a1a1a", border: "none", + padding: "6px 18px", borderRadius: "5px", cursor: "pointer", + fontSize: "13px", fontWeight: "bold", + }); + refreshBtn.addEventListener("click", () => location.reload()); + + const dismissBtn = document.createElement("button"); + dismissBtn.textContent = "Later"; + Object.assign(dismissBtn.style, { + background: "rgba(255,255,255,0.2)", color: "white", + border: "1px solid rgba(255,255,255,0.3)", + padding: "6px 18px", borderRadius: "5px", cursor: "pointer", + fontSize: "13px", + }); + dismissBtn.addEventListener("click", () => toast.remove()); + + btnRow.appendChild(refreshBtn); + btnRow.appendChild(dismissBtn); + toast.appendChild(titleEl); + toast.appendChild(hintEl); + toast.appendChild(btnRow); + + ensureToastStyle(); + document.body.appendChild(toast); + + setTimeout(() => { + if (toast.parentNode) { + toast.style.transition = "opacity 0.5s"; + toast.style.opacity = "0"; + setTimeout(() => toast.remove(), 500); + } + }, 30000); +} + +function ensureToastStyle() { + if (document.getElementById("md-toast-style")) return; + const s = document.createElement("style"); + s.id = "md-toast-style"; + s.textContent = `@keyframes mdSlideDown { + from { transform: translateX(-50%) translateY(-100%); opacity: 0; } + to { transform: translateX(-50%) translateY(0); opacity: 1; } + }`; + document.head.appendChild(s); +}