adding frame node

This commit is contained in:
dseditor
2026-02-09 00:11:36 +08:00
parent fbb474b89b
commit c1c57dc5c9
8 changed files with 711 additions and 62 deletions
+291 -23
View File
@@ -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 '<html' in content.lower():
os.remove(temp_path)
raise ValueError(f"Civitai 下載失敗,可能需要登入或 API Token。回應內容:{content[:200]}")
# 下載完成,搬移到最終位置
shutil.move(temp_path, dest_path)
size_mb = os.path.getsize(dest_path) / (1024 * 1024)
size_bytes = os.path.getsize(dest_path)
size_mb = size_bytes / (1024 * 1024)
if progress_callback:
progress_callback(filename, size_bytes, size_bytes, current_file, total_files)
result = f"✓ {filename} ({size_mb:.2f} MB) (HTTP)"
print(f" ✓ 下載成功 - {size_mb:.2f} MB")
return result
except urllib.error.HTTPError as e:
# 清理暫存檔案
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
if e.code == 401 or e.code == 403:
raise ValueError(f"HTTP {e.code}:需要認證。請確認 Civitai URL 包含有效的 API Token(?token=xxx)")
elif e.code == 404:
raise ValueError(f"HTTP 404:找不到檔案。請確認 URL 是否正確")
else:
raise ValueError(f"HTTP 錯誤 {e.code}:{e.reason}")
except Exception as e:
# 清理暫存檔案
if os.path.exists(temp_path):
@@ -590,7 +846,19 @@ class ModelDownloader:
raise ValueError(f"無法解析 HuggingFace URL:{url}")
def _extract_filename(self, url: str) -> 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]
-23
View File
@@ -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"
]
}
]
}
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"endpoint": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"model_name": "huihui_ai/qwen3-vl-abliterated:4b-instruct"
}
-5
View File
@@ -1,5 +0,0 @@
{
"endpoint": "https://generativelanguage.googleapis.com/v1beta/chat/completions",
"api_key": "",
"model_name": "gemini-2.5-flash"
}
-5
View File
@@ -1,5 +0,0 @@
{
"endpoint": "https://api.openai.com/v1/chat/completions",
"api_key": "",
"model_name": "gpt-5.2"
}
+55
View File
@@ -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",
}
+1 -1
View File
@@ -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"]
+364
View File
@@ -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);
}