adding frame node
This commit is contained in:
+291
-23
@@ -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]
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"endpoint": "http://localhost:11434/v1/chat/completions",
|
||||
"api_key": "ollama",
|
||||
"model_name": "huihui_ai/qwen3-vl-abliterated:4b-instruct"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"endpoint": "https://generativelanguage.googleapis.com/v1beta/chat/completions",
|
||||
"api_key": "",
|
||||
"model_name": "gemini-2.5-flash"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"endpoint": "https://api.openai.com/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"model_name": "gpt-5.2"
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user