This commit is contained in:
dseditor
2026-01-01 14:25:39 +08:00
parent aa3b1f3dff
commit 0fff1e5ba5
3 changed files with 414 additions and 1 deletions
+410
View File
@@ -0,0 +1,410 @@
import os
import sys
import re
import subprocess
import folder_paths
from typing import List, Dict, Tuple
class ModelDownloader:
"""
Model Downloader Node
Download models to ComfyUI/models folders using S3impleClient
Supports HuggingFace Hub acceleration (if huggingface_hub is installed)
"""
@classmethod
def INPUT_TYPES(cls):
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": "Download list format:\nFolder name\nURL1\nURL2\n..."
}),
"chunk_size_mb": ("INT", {
"default": 4,
"min": 1,
"max": 128,
"step": 1,
"tooltip": "Chunk size per HTTP request (MB)"
}),
"max_workers": ("INT", {
"default": 8,
"min": 1,
"max": 32,
"step": 1,
"tooltip": "Maximum number of parallel download workers"
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status_message",)
FUNCTION = "download_models"
OUTPUT_NODE = True
CATEGORY = "ListHelper"
def download_models(self, download_list: str, chunk_size_mb: int, max_workers: int) -> Tuple[str]:
"""
Download models to ComfyUI/models folders
Args:
download_list: Download list text
chunk_size_mb: Chunk size (MB)
max_workers: Maximum parallel workers
Returns:
Status message
"""
# Ensure S3impleClient is installed
s3c_installed = self._ensure_s3impleclient()
if not s3c_installed:
return ("Error: Failed to install or import S3impleClient. Please install manually: pip install s3impleclient",)
# Parse download list
download_map = self._parse_download_list(download_list)
if not download_map:
return ("Error: Download list format is incorrect or empty",)
# Get ComfyUI models root directory
models_root = self._get_models_root()
print(f"ModelDownloader: Models root directory = {models_root}")
# Check for duplicate files before download
skipped_files = []
for folder_name, urls in download_map.items():
target_folder = self._get_target_folder(models_root, folder_name)
for url in urls:
filename = self._extract_filename(url)
dest_path = os.path.join(target_folder, filename)
if os.path.exists(dest_path):
skipped_files.append(f"{folder_name}/{filename}")
print(f"ModelDownloader: File already exists, skipping: {dest_path}")
# Remove URLs that point to existing files
if skipped_files:
filtered_download_map = {}
for folder_name, urls in download_map.items():
target_folder = self._get_target_folder(models_root, folder_name)
filtered_urls = []
for url in urls:
filename = self._extract_filename(url)
dest_path = os.path.join(target_folder, filename)
if not os.path.exists(dest_path):
filtered_urls.append(url)
if filtered_urls:
filtered_download_map[folder_name] = filtered_urls
download_map = filtered_download_map
if not download_map:
skip_msg = f"All files already exist. Skipped:\n" + "\n".join(f" - {f}" for f in skipped_files)
print(f"ModelDownloader: {skip_msg}")
return (skip_msg,)
# Auto-detect if should use HuggingFace patch
all_hf_urls = all(
self._is_huggingface_url(url)
for urls in download_map.values()
for url in urls
)
# Initialize downloader
try:
import s3impleclient as s3c
# Configure download settings
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: Configuration complete - chunk_size={chunk_size_mb}MB, workers={max_workers}")
except Exception as e:
return (f"Error: Failed to configure S3impleClient: {str(e)}",)
# Auto-detect and apply HuggingFace patch if conditions are met
hf_available = False
patch_applied = False
if all_hf_urls:
try:
import huggingface_hub
hf_available = True
# Try to apply patch, but continue with HTTP if it fails
try:
s3c.patch_huggingface_hub()
patch_applied = True
print("ModelDownloader: HuggingFace Hub acceleration enabled")
except Exception as patch_error:
print(f"ModelDownloader: Failed to apply HF patch ({str(patch_error)}), using standard HTTP download")
hf_available = False
except ImportError:
print("ModelDownloader: huggingface_hub not installed, using standard HTTP download")
hf_available = False
# Start downloading
results = []
total_files = sum(len(urls) for urls in download_map.values())
current_file = 0
try:
for folder_name, urls in download_map.items():
# Get target folder path
target_folder = self._get_target_folder(models_root, folder_name)
# Ensure folder exists
os.makedirs(target_folder, exist_ok=True)
print(f"\nModelDownloader: Target folder = {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}] Downloading: {filename}")
print(f" URL: {url}")
print(f" Destination: {dest_path}")
try:
# Use HuggingFace Hub or S3impleClient for download
if hf_available and patch_applied and self._is_huggingface_url(url):
# Use hf_hub_download with S3C acceleration
repo_id, file_path = self._parse_hf_url(url)
from huggingface_hub import hf_hub_download
downloaded_path = hf_hub_download(
repo_id=repo_id,
filename=file_path,
force_download=False,
local_dir=target_folder,
local_dir_use_symlinks=False,
)
results.append(f"✓ {filename} (HF accelerated)")
print(f" ✓ Download successful (with HF acceleration)")
else:
# Use S3impleClient direct download
result = s3c.download(
url=url,
dest=dest_path,
)
if result.success:
size_mb = result.total_bytes / (1024 * 1024)
results.append(f"✓ {filename} ({size_mb:.2f} MB)")
print(f" ✓ Download successful - {size_mb:.2f} MB")
else:
results.append(f"✗ {filename} - Failed")
print(f" ✗ Download failed")
except Exception as e:
error_msg = f"✗ {filename} - Error: {str(e)}"
results.append(error_msg)
print(f" {error_msg}")
finally:
# Restore HF patch
if patch_applied:
try:
s3c.unpatch_huggingface_hub()
print("\nModelDownloader: HuggingFace Hub original settings restored")
except Exception as unpatch_error:
print(f"\nModelDownloader: Failed to unpatch HF ({str(unpatch_error)})")
# Generate final status message
success_count = sum(1 for r in results if r.startswith("✓"))
fail_count = sum(1 for r in results if r.startswith("✗"))
# Include skipped files in status message
status_lines = [f"Download complete!"]
if skipped_files:
status_lines.append(f"Skipped (already exists): {len(skipped_files)}")
status_lines.append(f"Success: {success_count}/{total_files}")
status_lines.append(f"Failed: {fail_count}")
status_lines.append("")
status_lines.append("Details:")
if skipped_files:
status_lines.append(" Skipped files:")
for f in skipped_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,)
def _ensure_s3impleclient(self) -> bool:
"""Ensure S3impleClient is installed"""
try:
import s3impleclient
print("ModelDownloader: S3impleClient is installed")
return True
except ImportError:
print("ModelDownloader: S3impleClient not installed, attempting auto-install...")
try:
python_exe = sys.executable
install_cmd = [python_exe, "-m", "pip", "install", "s3impleclient"]
print(f"ModelDownloader: Running install command: {' '.join(install_cmd)}")
result = subprocess.run(
install_cmd,
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
print("ModelDownloader: S3impleClient installation successful!")
print(result.stdout)
# Try to re-import
try:
import s3impleclient
return True
except ImportError:
print("ModelDownloader: Installation succeeded but import failed, may need to restart ComfyUI")
return False
else:
print(f"ModelDownloader: Installation failed\nStdout: {result.stdout}\nStderr: {result.stderr}")
return False
except Exception as e:
print(f"ModelDownloader: Auto-installation failed: {str(e)}")
return False
def _parse_download_list(self, text: str) -> Dict[str, List[str]]:
"""
Parse download list
Format:
Folder name
URL1
URL2
Folder name2
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:
# Check if line is a URL
if line.startswith('http://') or line.startswith('https://'):
if current_folder:
download_map[current_folder].append(line)
else:
print(f"Warning: URL {line} has no corresponding folder name, skipped")
else:
# This is a folder name
current_folder = line
if current_folder not in download_map:
download_map[current_folder] = []
print(f"ModelDownloader: Parsed {len(download_map)} folders")
for folder, urls in download_map.items():
print(f" {folder}: {len(urls)} files")
return download_map
def _get_models_root(self) -> str:
"""Get ComfyUI models root directory"""
# Use folder_paths to get models directory
# Usually ComfyUI/models
try:
# Try to get any folder path, then find models directory
checkpoints_path = folder_paths.get_folder_paths("checkpoints")
if checkpoints_path:
# checkpoints is usually in models/checkpoints
models_root = os.path.dirname(checkpoints_path[0])
return models_root
except:
pass
# Fallback: navigate up from current project
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
models_path = os.path.join(current_dir, "models")
if os.path.exists(models_path):
return models_path
# Final fallback: create new one
os.makedirs(models_path, exist_ok=True)
return models_path
def _get_target_folder(self, models_root: str, folder_name: str) -> str:
"""
Get target folder path
Folder name is case-insensitive
"""
# Normalize folder name (lowercase)
folder_name_lower = folder_name.lower()
# Check if corresponding folder already exists (case-insensitive)
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() == folder_name_lower:
print(f" Found existing folder: {existing_folder}")
return existing_path
# If not exists, create using user-provided name
return os.path.join(models_root, folder_name)
def _is_huggingface_url(self, url: str) -> bool:
"""Check if URL is a HuggingFace URL"""
return 'huggingface.co' in url.lower()
def _parse_hf_url(self, url: str) -> Tuple[str, str]:
"""
Parse HuggingFace URL
Example: https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q8_0.gguf
Returns: ("unsloth/Qwen-Image-2512-GGUF", "qwen-image-2512-Q8_0.gguf")
"""
# Match HuggingFace URL format
# https://huggingface.co/{org}/{repo}/resolve/{branch}/{filepath}
pattern = r'huggingface\.co/([^/]+/[^/]+)/resolve/[^/]+/(.+)'
match = re.search(pattern, url)
if match:
repo_id = match.group(1)
file_path = match.group(2)
return repo_id, file_path
# If unable to parse, raise error
raise ValueError(f"Unable to parse HuggingFace URL: {url}")
def _extract_filename(self, url: str) -> str:
"""Extract filename from URL"""
# Remove query parameters
url = url.split('?')[0]
# Get last path segment
filename = url.split('/')[-1]
return filename
# Node mappings
NODE_CLASS_MAPPINGS = {
"ModelDownloader": ModelDownloader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ModelDownloader": "Model Downloader",
}
+3
View File
@@ -25,6 +25,7 @@ from random import Random
from datetime import datetime
from .qwen_inference import QwenGPUInference
from .gguf_inference import GGUFInference
from .model_downloader import ModelDownloader
class AudioListGenerator:
@classmethod
@@ -1276,6 +1277,7 @@ NODE_CLASS_MAPPINGS = {
"QwenGPUInference": QwenGPUInference,
"GGUFInference": GGUFInference,
"BatchToPSD": BatchToPSD,
"ModelDownloader": ModelDownloader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -1292,5 +1294,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"QwenGPUInference": "Qwen_TE_LLM",
"GGUFInference": "GGUF_LLM",
"BatchToPSD": "Batch to PSD",
"ModelDownloader": "模型下載器",
}
+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.2.2"
version = "1.2.3"
license = {file = "LICENSE"}
dependencies = ["regex", "accelerate"]