Files
dseditorandClaude Opus 5 ad233e8398 v1.4.0: remove reportlab hard dependency, simplify ModelDownloader
移除 reportlab 硬相依,改為延遲載入:
- photo_magazine_generator.py 頂層不再 import reportlab,避免缺套件時
  整個 ListHelper 節點套件 import 失敗(nodes.py 的 import 鏈無 try/except)
- 新增 ensure_reportlab():PhotoMagazineMaker 執行時才載入,缺少時才
  以 sys.executable -m pip 自動安裝,安裝失敗回傳友善訊息而非拋例外
- create_full_bleed_image 的預設參數 210*mm/297*mm 改為 None sentinel
  (預設值在 import 時求值,會讓延遲載入失效)
- requirements.txt / pyproject.toml 移除 reportlab;刪除未使用的 pdfutils

ModelDownloader 簡化為單純節點:
- 刪除 web/js/modelDownloader.js 的節點下方進度面板
- 移除 PromptServer WebSocket 推送、_send_progress、hidden unique_id
  與兩個下載方法的 progress_callback 參數鏈
- 下載進度僅輸出到終端機(已知大小每 10%、未知每 10MB)
- 修正未知檔案大小時的進度判斷:原本 downloaded % 10MB == 0 只在
  chunk size 恰好整除時才會輸出,改為門檻遞增

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 23:06:45 +08:00

801 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import shutil
import json
import folder_paths
from typing import List, Dict, Tuple, Optional
class ModelDownloader:
"""
Model Downloader Node
下載模型到 ComfyUI/models 資料夾
支援 HuggingFace Hub 加速下載(需安裝 huggingface_hub,可選 s3impleclient 加速)
"""
# 範本配置檔路徑
TEMPLATES_FILE = os.path.join(os.path.dirname(__file__), "model_templates.json")
@classmethod
def _load_template_names(cls) -> List[str]:
"""載入範本名稱列表"""
template_names = ["無"] # 預設選項
try:
if os.path.exists(cls.TEMPLATES_FILE):
with open(cls.TEMPLATES_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
templates = data.get("templates", {})
template_names.extend(templates.keys())
except Exception as e:
print(f"ModelDownloader: 載入範本列表失敗:{e}")
return template_names
@classmethod
def INPUT_TYPES(cls):
template_names = cls._load_template_names()
return {
"required": {
"download_list": ("STRING", {
"multiline": True,
"default": "diffusion_models\nhttps://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q8_0.gguf\nCLIP\nhttps://huggingface.co/unsloth/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct-Q8_0.gguf",
"tooltip": "下載列表格式:\n資料夾名稱\nURL1\nURL2\n...\n\n注意:若選擇了模型範本,此輸入將被忽略。"
}),
"use_s3c": ("BOOLEAN", {
"default": False,
"tooltip": "使用 S3impleClient 加速下載(需安裝 s3impleclient)。\n僅在開啟 HF 下載時有效。"
}),
"use_hf_download": ("BOOLEAN", {
"default": False,
"tooltip": "使用 HuggingFace Hub 下載。\n關閉時使用普通 HTTP 下載(相容性最高)。\n開啟時檔案會先下載到 HF 快取,再自動搬移到 ComfyUI models 資料夾。"
}),
"chunk_size_mb": ("INT", {
"default": 10,
"min": 1,
"max": 128,
"step": 1,
"tooltip": "每次 HTTP 請求的區塊大小 (MB)"
}),
"max_workers": ("INT", {
"default": 8,
"min": 1,
"max": 32,
"step": 1,
"tooltip": "最大平行下載執行緒數"
}),
"model_template": (template_names, {
"default": "無",
"tooltip": "選擇模型範本。選擇範本後將使用範本中的下載列表,忽略上方手動輸入的內容。\n選擇「無」時使用上方手動輸入的下載列表。"
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status_message",)
FUNCTION = "download_models"
OUTPUT_NODE = True
CATEGORY = "ListHelper/Tools"
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]:
"""
下載模型到 ComfyUI/models 資料夾
Args:
download_list: 下載列表文字
use_s3c: 使用 S3impleClient 加速(僅在 HF 下載時有效)
use_hf_download: 使用 HuggingFace Hub 下載(開啟時會自動搬移到 models 資料夾)
chunk_size_mb: 區塊大小 (MB)
max_workers: 最大平行執行緒數
model_template: 模型範本名稱
Returns:
狀態訊息
"""
# 若選擇了範本,使用範本的下載列表;否則使用手動輸入的 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)
if not download_map:
return ("錯誤:下載列表格式不正確或為空",)
# 取得 ComfyUI models 根目錄
models_root = self._get_models_root()
print(f"ModelDownloader: Models 根目錄 = {models_root}")
# 檢查相依套件
hf_available = self._check_huggingface_hub()
s3c_available = self._check_s3impleclient()
if use_hf_download and not hf_available:
return ("錯誤:使用 HF 下載需要安裝 huggingface_hub。\n"
"請執行:pip install huggingface_hub\n"
"或關閉「使用 HF 下載」選項使用普通 HTTP 下載。",)
# 設定 S3C(如果開啟 use_s3c、可用且使用 HF 下載)
s3c = None
patch_applied = False
if use_hf_download and use_s3c and s3c_available:
s3c, patch_applied = self._setup_s3c(chunk_size_mb, max_workers)
elif use_s3c and not s3c_available:
print("ModelDownloader: 警告 - use_s3c 已開啟但 s3impleclient 未安裝")
print("ModelDownloader: 正在檢查現有檔案...")
# 檢查檔案是否已存在且完整
files_to_download, skipped_files, incomplete_files = self._check_existing_files(
download_map, models_root
)
if not files_to_download:
skip_msg = "所有檔案已存在且完整。\n已跳過:\n" + "\n".join(f" - {f}" for f in skipped_files)
print(f"ModelDownloader: {skip_msg}")
return (skip_msg,)
# 開始下載
results = []
total_files = sum(len(urls) for urls in files_to_download.values())
current_file = 0
try:
for folder_name, urls in files_to_download.items():
target_folder = self._get_target_folder(models_root, folder_name)
os.makedirs(target_folder, exist_ok=True)
print(f"\nModelDownloader: 目標資料夾 = {target_folder}")
for url in urls:
current_file += 1
filename = self._extract_filename(url)
dest_path = os.path.join(target_folder, filename)
print(f"\n[{current_file}/{total_files}] 正在下載:{filename}")
print(f" URL: {url}")
print(f" 目標:{dest_path}")
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)
else:
# 使用普通 HTTP 下載
result = self._download_with_http(url, dest_path)
results.append(result)
except Exception as e:
error_msg = f"✗ {filename} - 錯誤:{str(e)}"
results.append(error_msg)
print(f" {error_msg}")
finally:
# 還原 HF patch
if patch_applied and s3c:
try:
s3c.unpatch_huggingface_hub()
print("\nModelDownloader: HuggingFace Hub 原始設定已還原")
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("✗"))
print(f"\nModelDownloader: 下載完成!成功:{success_count}/{total_files},失敗:{fail_count}")
# 產生最終狀態訊息
return self._generate_status_message(results, skipped_files, incomplete_files, total_files)
# ==================== 範本處理 ====================
def _get_template_download_list(self, template_name: str) -> Optional[str]:
"""
從範本配置檔取得下載列表
Args:
template_name: 範本名稱
Returns:
下載列表文字格式,如果範本不存在則返回 None
"""
try:
if not os.path.exists(self.TEMPLATES_FILE):
print(f"ModelDownloader: 範本檔案不存在:{self.TEMPLATES_FILE}")
return None
with open(self.TEMPLATES_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
templates = data.get("templates", {})
if template_name not in templates:
print(f"ModelDownloader: 找不到範本「{template_name}」")
return None
template = templates[template_name]
download_list_data = template.get("download_list", [])
# 將 JSON 格式轉換為文字格式
lines = []
for item in download_list_data:
folder = item.get("folder", "")
urls = item.get("urls", [])
if folder and urls:
lines.append(folder)
lines.extend(urls)
download_list_text = "\n".join(lines)
print(f"ModelDownloader: 範本「{template_name}」包含 {len(download_list_data)} 個資料夾")
return download_list_text
except json.JSONDecodeError as e:
print(f"ModelDownloader: 範本檔案格式錯誤:{e}")
return None
except Exception as e:
print(f"ModelDownloader: 載入範本失敗:{e}")
return None
# ==================== 相依套件檢查 ====================
def _check_huggingface_hub(self) -> bool:
"""檢查 huggingface_hub 是否已安裝"""
try:
import huggingface_hub
print("ModelDownloader: huggingface_hub 已安裝")
return True
except ImportError:
print("ModelDownloader: huggingface_hub 未安裝")
return False
def _check_s3impleclient(self) -> bool:
"""檢查 s3impleclient 是否已安裝"""
try:
import s3impleclient
print("ModelDownloader: s3impleclient 已安裝")
return True
except ImportError:
print("ModelDownloader: s3impleclient 未安裝(可選,用於加速下載)")
return False
def _setup_s3c(self, chunk_size_mb: int, max_workers: int) -> Tuple[Optional[object], bool]:
"""
設定 S3impleClient 並套用 HF patch
Returns:
(s3c module, patch_applied)
"""
try:
import s3impleclient as s3c
# 設定下載參數
chunk_size_bytes = chunk_size_mb * 1024 * 1024
s3c.configure_download(s3c.DownloadConfig(
chunk_size=chunk_size_bytes,
max_workers=max_workers,
timeout=300.0,
max_retries=5,
))
print(f"ModelDownloader: S3C 設定完成 - chunk_size={chunk_size_mb}MB, workers={max_workers}")
# 套用 HF patch
try:
s3c.patch_huggingface_hub()
print("ModelDownloader: HuggingFace Hub 加速已啟用(S3C patch 已套用)")
return s3c, True
except Exception as e:
print(f"ModelDownloader: 警告 - 無法套用 HF patch ({str(e)})")
return s3c, False
except Exception as e:
print(f"ModelDownloader: 警告 - S3C 設定失敗 ({str(e)})")
return None, False
# ==================== 檔案檢查 ====================
def _check_existing_files(self, download_map: Dict[str, List[str]],
models_root: str) -> Tuple[Dict[str, List[str]], List[str], List[str]]:
"""
檢查檔案是否已存在且完整
透過比對遠端檔案大小來判斷檔案是否完整,
避免下載中斷後的不完整檔案被誤判為已存在。
Returns:
(files_to_download, skipped_files, incomplete_files)
"""
files_to_download = {}
skipped_files = []
incomplete_files = []
for folder_name, urls in download_map.items():
target_folder = self._get_target_folder(models_root, folder_name)
urls_to_download = []
for url in urls:
filename = self._extract_filename(url)
dest_path = os.path.join(target_folder, filename)
if os.path.exists(dest_path):
# 檔案存在,檢查大小是否與遠端一致
local_size = os.path.getsize(dest_path)
remote_size = self._get_remote_file_size(url)
if remote_size is not None and local_size != remote_size:
# 檔案大小不一致,需要重新下載
incomplete_files.append(f"{folder_name}/{filename} (本地: {local_size / (1024*1024):.2f}MB, 遠端: {remote_size / (1024*1024):.2f}MB)")
print(f"ModelDownloader: 檔案不完整,將重新下載:{dest_path}")
print(f" 本地大小:{local_size} bytes, 遠端大小:{remote_size} bytes")
# 刪除不完整的檔案
try:
os.remove(dest_path)
except Exception as e:
print(f" 警告:無法刪除不完整檔案:{e}")
urls_to_download.append(url)
elif remote_size is None:
# 無法取得遠端大小,假設檔案完整
skipped_files.append(f"{folder_name}/{filename} (無法驗證)")
print(f"ModelDownloader: 檔案已存在(無法驗證完整性),跳過:{dest_path}")
else:
# 檔案大小一致,跳過
skipped_files.append(f"{folder_name}/{filename}")
print(f"ModelDownloader: 檔案已存在且完整,跳過:{dest_path}")
else:
urls_to_download.append(url)
if urls_to_download:
files_to_download[folder_name] = urls_to_download
return files_to_download, skipped_files, incomplete_files
def _get_remote_file_size(self, url: str) -> Optional[int]:
"""
取得遠端檔案大小(透過 HEAD 請求)
支援 Civitai 等需要特殊處理的網站
Returns:
檔案大小(bytes),如果無法取得則返回 None
"""
try:
import urllib.request
import ssl
# 建立自訂的 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)
except Exception as e:
print(f"ModelDownloader: 無法取得遠端檔案大小:{e}")
return None
# ==================== 下載方法 ====================
def _download_with_hf_hub(self, url: str, dest_path: str,
s3c: Optional[object], patch_applied: bool) -> str:
"""
使用 HuggingFace Hub 下載,然後搬移到目標資料夾
Args:
url: HuggingFace URL
dest_path: 最終目標路徑
s3c: s3impleclient module(如果可用)
patch_applied: S3C patch 是否已套用
Returns:
結果訊息
"""
from huggingface_hub import hf_hub_download
filename = self._extract_filename(url)
repo_id, file_path = self._parse_hf_url(url)
print(f" 使用 HF Hub 下載...")
print(f" Repo: {repo_id}, File: {file_path}")
# HF Hub 自行在終端機輸出下載進度條
# 使用 HF Hub 下載到快取
downloaded_path = hf_hub_download(
repo_id=repo_id,
filename=file_path,
force_download=False,
)
print(f" HF 快取路徑:{downloaded_path}")
# 搬移到目標資料夾
if downloaded_path != dest_path:
print(f" 搬移檔案到:{dest_path}")
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
# 複製檔案(保留 HF 快取中的原始檔案)
shutil.copy2(downloaded_path, dest_path)
# 驗證複製成功
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"
result = f"✓ {filename} ({size_mb:.2f} MB) ({method})"
print(f" ✓ 下載並搬移成功 - {size_mb:.2f} MB")
return result
else:
result = f"✗ {filename} - 搬移失敗"
print(f" ✗ 搬移失敗")
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"
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:
"""
使用普通 HTTP 下載(支援 Civitai 等需要特殊處理的網站)
Args:
url: 下載 URL
dest_path: 目標路徑
Returns:
結果訊息
"""
import urllib.request
import urllib.error
import ssl
filename = self._extract_filename(url)
print(f" 使用 HTTP 下載...")
# 確保目標資料夾存在
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
# 使用暫存檔案下載
temp_path = dest_path + '.tmp'
try:
# 建立自訂的 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
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 * 4 # 增加 chunk size 提升效能
last_progress = 0
next_size_milestone = 10 * 1024 * 1024 # 未知總大小時每 10MB 回報一次
while True:
chunk = response.read(chunk_size)
if not chunk:
break
out_file.write(chunk)
downloaded += len(chunk)
# 終端機進度:已知總大小時每 10%,未知時每 10MB
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 >= next_size_milestone:
print(f" 已下載:{downloaded / (1024*1024):.2f} MB")
# 用門檻而非整除判斷,chunk 大小改變時仍然可靠
next_size_milestone = downloaded + 10 * 1024 * 1024
# 檢查下載的檔案是否有效(不是錯誤頁面)
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_bytes = os.path.getsize(dest_path)
size_mb = size_bytes / (1024 * 1024)
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):
try:
os.remove(temp_path)
except:
pass
raise e
# ==================== 工具方法 ====================
def _parse_download_list(self, text: str) -> Dict[str, List[str]]:
"""
解析下載列表
格式:
資料夾名稱
URL1
URL2
資料夾名稱2
URL3
Returns:
{folder_name: [URL1, URL2, ...]}
"""
lines = [line.strip() for line in text.split('\n') if line.strip()]
download_map = {}
current_folder = None
for line in lines:
if line.startswith('http://') or line.startswith('https://'):
if current_folder:
download_map[current_folder].append(line)
else:
print(f"警告:URL {line} 沒有對應的資料夾名稱,已跳過")
else:
current_folder = line
if current_folder not in download_map:
download_map[current_folder] = []
print(f"ModelDownloader: 解析到 {len(download_map)} 個資料夾")
for folder, urls in download_map.items():
print(f" {folder}: {len(urls)} 個檔案")
return download_map
def _get_models_root(self) -> str:
"""取得 ComfyUI models 根目錄"""
try:
checkpoints_path = folder_paths.get_folder_paths("checkpoints")
if checkpoints_path:
return os.path.dirname(checkpoints_path[0])
except:
pass
# 備用方案
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
models_path = os.path.join(current_dir, "models")
if not os.path.exists(models_path):
os.makedirs(models_path, exist_ok=True)
return models_path
def _get_target_folder(self, models_root: str, folder_name: str) -> str:
"""
取得目標資料夾路徑
支援多層路徑(例如 "clip/split_files")
基礎資料夾名稱不區分大小寫
"""
folder_parts = folder_name.replace('\\', '/').split('/')
base_folder = folder_parts[0]
base_folder_lower = base_folder.lower()
# 尋找已存在的資料夾(不區分大小寫)
actual_base_folder = base_folder
if os.path.exists(models_root):
for existing_folder in os.listdir(models_root):
existing_path = os.path.join(models_root, existing_folder)
if os.path.isdir(existing_path) and existing_folder.lower() == base_folder_lower:
actual_base_folder = existing_folder
break
if len(folder_parts) > 1:
return os.path.join(models_root, actual_base_folder, *folder_parts[1:])
else:
return os.path.join(models_root, actual_base_folder)
def _is_huggingface_url(self, url: str) -> bool:
"""檢查是否為 HuggingFace URL"""
return 'huggingface.co' in url.lower()
def _parse_hf_url(self, url: str) -> Tuple[str, str]:
"""
解析 HuggingFace URL
範例: https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q8_0.gguf
返回: ("unsloth/Qwen-Image-2512-GGUF", "qwen-image-2512-Q8_0.gguf")
"""
pattern = r'huggingface\.co/([^/]+/[^/]+)/resolve/[^/]+/(.+)'
match = re.search(pattern, url)
if match:
return match.group(1), match.group(2)
raise ValueError(f"無法解析 HuggingFace URL:{url}")
def _extract_filename(self, url: str) -> str:
"""
從 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]
def _generate_status_message(self, results: List[str], skipped_files: List[str],
incomplete_files: List[str], total_files: int) -> Tuple[str]:
"""產生最終狀態訊息"""
success_count = sum(1 for r in results if r.startswith("✓"))
fail_count = sum(1 for r in results if r.startswith("✗"))
status_lines = ["下載完成!"]
if skipped_files:
status_lines.append(f"已跳過(檔案已存在):{len(skipped_files)}")
if incomplete_files:
status_lines.append(f"重新下載(檔案不完整):{len(incomplete_files)}")
status_lines.append(f"成功:{success_count}/{total_files}")
status_lines.append(f"失敗:{fail_count}")
status_lines.append("")
status_lines.append("詳細資訊:")
if skipped_files:
status_lines.append(" 已跳過的檔案:")
for f in skipped_files:
status_lines.append(f" ○ {f}")
if incomplete_files:
status_lines.append(" 重新下載的檔案:")
for f in incomplete_files:
status_lines.append(f" ⟳ {f}")
status_lines.extend(f" {r}" for r in results)
status_msg = "\n".join(status_lines)
print(f"\n{status_msg}")
return (status_msg,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ModelDownloader": ModelDownloader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ModelDownloader": "Model Downloader",
}