468 lines
18 KiB
Python
468 lines
18 KiB
Python
import threading
|
||
import asyncio
|
||
import time
|
||
import server
|
||
from aiohttp import web
|
||
import os
|
||
import shutil
|
||
from ..utils.logger import log
|
||
from ..utils.modelscope_gateway import ModelScopeGateway
|
||
import folder_paths
|
||
|
||
|
||
# 全局下载进度存储
|
||
download_progress = {}
|
||
download_lock = threading.Lock()
|
||
|
||
# 下载进度回调类
|
||
class DownloadProgressCallback:
|
||
def __init__(self, id: str, filename: str, file_size: int, download_id: str):
|
||
self.id = id
|
||
self.filename = filename
|
||
self.file_size = file_size
|
||
self.download_id = download_id
|
||
self.progress = 0
|
||
self.status = "downloading" # downloading, completed, failed
|
||
self.error_message = None
|
||
self.start_time = time.time()
|
||
|
||
# 初始化进度记录
|
||
with download_lock:
|
||
download_progress[download_id] = {
|
||
"id": id,
|
||
"filename": filename,
|
||
"file_size": file_size,
|
||
"progress": 0,
|
||
"percentage": 0.0,
|
||
"status": "downloading",
|
||
"start_time": self.start_time,
|
||
"estimated_time": None,
|
||
"speed": 0.0,
|
||
"error_message": None
|
||
}
|
||
|
||
def update(self, size: int):
|
||
"""更新下载进度"""
|
||
self.progress += size
|
||
current_time = time.time()
|
||
elapsed_time = current_time - self.start_time
|
||
|
||
# 计算下载速度和预估时间
|
||
if elapsed_time > 0:
|
||
speed = self.progress / elapsed_time # bytes per second
|
||
if speed > 0 and self.progress < self.file_size:
|
||
remaining_bytes = self.file_size - self.progress
|
||
estimated_time = remaining_bytes / speed
|
||
else:
|
||
estimated_time = None
|
||
else:
|
||
speed = 0.0
|
||
estimated_time = None
|
||
|
||
percentage = (self.progress / self.file_size) * 100 if self.file_size > 0 else 0
|
||
|
||
# 更新全局进度
|
||
with download_lock:
|
||
if self.download_id in download_progress:
|
||
# 直接更新字典的值,而不是调用update方法
|
||
progress_dict = download_progress[self.download_id]
|
||
progress_dict["progress"] = self.progress
|
||
progress_dict["percentage"] = round(percentage, 2)
|
||
progress_dict["speed"] = round(speed, 2)
|
||
progress_dict["estimated_time"] = round(estimated_time, 2) if estimated_time else None
|
||
|
||
def end(self, success: bool = True, error_message: str = None):
|
||
"""下载结束回调"""
|
||
current_time = time.time()
|
||
total_time = current_time - self.start_time
|
||
|
||
if success:
|
||
self.status = "completed"
|
||
# 验证下载完整性
|
||
assert self.progress == self.file_size, f"Download incomplete: {self.progress}/{self.file_size}"
|
||
else:
|
||
self.status = "failed"
|
||
self.error_message = error_message
|
||
|
||
# 更新最终状态
|
||
with download_lock:
|
||
if self.download_id in download_progress:
|
||
# 直接更新字典的值,而不是调用update方法
|
||
progress_dict = download_progress[self.download_id]
|
||
progress_dict["status"] = self.status
|
||
progress_dict["progress"] = self.file_size if success else self.progress
|
||
if self.file_size > 0:
|
||
progress_dict["percentage"] = 100.0 if success else (self.progress / self.file_size) * 100
|
||
else:
|
||
progress_dict["percentage"] = 0.0
|
||
progress_dict["total_time"] = round(total_time, 2)
|
||
progress_dict["error_message"] = self.error_message
|
||
|
||
def fail(self, error_message: str):
|
||
"""下载失败回调"""
|
||
self.end(success=False, error_message=error_message)
|
||
|
||
|
||
# 生成唯一下载ID
|
||
def generate_download_id() -> str:
|
||
import uuid
|
||
return str(uuid.uuid4())
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/api/download-model")
|
||
async def download_model(request):
|
||
"""
|
||
Download model from ModelScope using SDK
|
||
"""
|
||
log.info("Received download-model request")
|
||
req_json = await request.json()
|
||
|
||
try:
|
||
id = req_json.get('id')
|
||
model_id = req_json.get('model_id')
|
||
model_type = req_json.get('model_type')
|
||
dest_dir = req_json.get('dest_dir')
|
||
|
||
# 验证必需参数
|
||
if not id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: id"
|
||
})
|
||
|
||
if not model_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: model_id"
|
||
})
|
||
|
||
if not model_type:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: model_type"
|
||
})
|
||
|
||
log.info(f"Downloading model: {model_id} (type: {model_type})")
|
||
|
||
# 生成下载ID
|
||
download_id = generate_download_id()
|
||
|
||
# 计算目标目录:优先使用传入的dest_dir,否则使用ComfyUI的models目录下对应类型
|
||
resolved_dest_dir = None
|
||
if dest_dir:
|
||
resolved_dest_dir = os.path.abspath(os.path.expanduser(f"models/{dest_dir}"))
|
||
else:
|
||
try:
|
||
model_type_paths = folder_paths.get_folder_paths(model_type)
|
||
resolved_dest_dir = model_type_paths[0] if model_type_paths else os.path.join(folder_paths.models_dir,
|
||
model_type)
|
||
except Exception:
|
||
resolved_dest_dir = os.path.join(folder_paths.models_dir, model_type)
|
||
|
||
# 创建进度回调
|
||
progress_callback = DownloadProgressCallback(
|
||
id=id,
|
||
filename=f"{model_id}.{model_type}",
|
||
file_size=0, # 实际大小会在下载过程中获取
|
||
download_id=download_id
|
||
)
|
||
|
||
# 启动下载任务(异步执行)
|
||
async def download_task():
|
||
try:
|
||
# 调用下载方法 - 使用snapshot_download
|
||
from modelscope import snapshot_download
|
||
|
||
# 创建进度回调包装器 - 实现ModelScope期望的接口
|
||
class ProgressWrapper:
|
||
"""Factory that returns a per-file progress object with update/end."""
|
||
|
||
def __init__(self, download_id: str):
|
||
self.download_id = download_id
|
||
|
||
def __call__(self, file_name: str, file_size: int):
|
||
# Create a per-file progress tracker expected by ModelScope
|
||
download_id = self.download_id
|
||
|
||
class _PerFileProgress:
|
||
def __init__(self, fname: str, fsize: int):
|
||
self.file_name = fname
|
||
self.file_size = max(int(fsize or 0), 0)
|
||
self.progress = 0
|
||
self.last_update_time = time.time()
|
||
self.last_downloaded = 0
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
# If unknown size, keep 0 to avoid div-by-zero
|
||
download_progress[download_id]["file_size"] = self.file_size
|
||
|
||
def update(self, size: int):
|
||
try:
|
||
self.progress += int(size or 0)
|
||
now = time.time()
|
||
# Update global progress
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
dp = download_progress[download_id]
|
||
dp["progress"] = self.progress
|
||
if self.file_size > 0:
|
||
dp["percentage"] = round(self.progress * 100.0 / self.file_size, 2)
|
||
# speed
|
||
elapsed = max(now - self.last_update_time, 1e-6)
|
||
speed = (self.progress - self.last_downloaded) / elapsed
|
||
dp["speed"] = round(speed, 2)
|
||
self.last_update_time = now
|
||
self.last_downloaded = self.progress
|
||
except Exception as e:
|
||
log.error(f"Error in progress update: {e}")
|
||
|
||
def end(self):
|
||
# Called by modelscope when a file finishes
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
dp = download_progress[download_id]
|
||
if self.file_size > 0:
|
||
dp["progress"] = self.file_size
|
||
dp["percentage"] = 100.0
|
||
|
||
return _PerFileProgress(file_name, file_size)
|
||
|
||
progress_wrapper = ProgressWrapper(download_id)
|
||
|
||
# 添加调试日志
|
||
log.info(f"Starting download with progress wrapper: {download_id}")
|
||
|
||
# 在线程中执行阻塞的下载,避免阻塞事件循环
|
||
from functools import partial
|
||
local_dir = await asyncio.to_thread(
|
||
partial(
|
||
snapshot_download,
|
||
model_id=model_id,
|
||
cache_dir=resolved_dest_dir,
|
||
progress_callbacks=[progress_wrapper]
|
||
)
|
||
)
|
||
|
||
# 下载完成
|
||
progress_callback.end(success=True)
|
||
log.info(f"Model downloaded successfully to: {local_dir}")
|
||
|
||
# 下载后遍历目录,将所有重要权重/资源文件移动到最外层(与目录同级,即 resolved_dest_dir)
|
||
try:
|
||
moved_count = 0
|
||
allowed_exts = {
|
||
".safetensors", ".ckpt", ".pt", ".pth", ".bin"
|
||
# ".msgpack", ".json", ".yaml", ".yml", ".toml", ".png", ".onnx"
|
||
}
|
||
for root, dirs, files in os.walk(local_dir):
|
||
for name in files:
|
||
ext = os.path.splitext(name)[1].lower()
|
||
if ext in allowed_exts:
|
||
src_path = os.path.join(root, name)
|
||
target_dir = resolved_dest_dir
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
target_path = os.path.join(target_dir, name)
|
||
# 如果已经在目标目录则跳过
|
||
if os.path.abspath(os.path.dirname(src_path)) == os.path.abspath(target_dir):
|
||
continue
|
||
# 处理重名情况:自动追加 _1, _2 ...
|
||
if os.path.exists(target_path):
|
||
base, ext_real = os.path.splitext(name)
|
||
idx = 1
|
||
while True:
|
||
candidate = f"{base}_{idx}{ext_real}"
|
||
candidate_path = os.path.join(target_dir, candidate)
|
||
if not os.path.exists(candidate_path):
|
||
target_path = candidate_path
|
||
break
|
||
idx += 1
|
||
shutil.move(src_path, target_path)
|
||
moved_count += 1
|
||
log.info(
|
||
f"Moved {moved_count} files with extensions {sorted(list(allowed_exts))} to: {resolved_dest_dir}")
|
||
except Exception as move_err:
|
||
log.error(f"Post-download move failed: {move_err}")
|
||
|
||
except Exception as e:
|
||
progress_callback.fail(str(e))
|
||
log.error(f"Download failed: {str(e)}")
|
||
|
||
# 启动异步下载任务
|
||
asyncio.create_task(download_task())
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"download_id": download_id,
|
||
"id": id,
|
||
"model_id": model_id,
|
||
"model_type": model_type,
|
||
"dest_dir": resolved_dest_dir,
|
||
"status": "started"
|
||
},
|
||
"message": f"Download started for model '{model_id}'"
|
||
})
|
||
|
||
except ImportError as e:
|
||
log.error(f"ModelScope SDK not installed: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "ModelScope SDK not installed. Please install with: pip install modelscope"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error starting download: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Failed to start download: {str(e)}"
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/api/download-progress/{download_id}")
|
||
async def get_download_progress(request):
|
||
"""
|
||
Get download progress by download ID
|
||
"""
|
||
download_id = request.match_info.get('download_id')
|
||
|
||
if not download_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing download_id parameter"
|
||
})
|
||
|
||
with download_lock:
|
||
progress_info = download_progress.get(download_id)
|
||
|
||
if not progress_info:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Download ID {download_id} not found"
|
||
})
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": progress_info,
|
||
"message": "Download progress retrieved successfully"
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/api/download-progress")
|
||
async def list_downloads(request):
|
||
"""
|
||
List all active downloads
|
||
"""
|
||
with download_lock:
|
||
downloads = list(download_progress.keys())
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"downloads": downloads,
|
||
"count": len(downloads)
|
||
},
|
||
"message": "Download list retrieved successfully"
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.delete("/api/download-progress/{download_id}")
|
||
async def clear_download_progress(request):
|
||
"""
|
||
Clear download progress record (for cleanup)
|
||
"""
|
||
download_id = request.match_info.get('download_id')
|
||
|
||
if not download_id:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing download_id parameter"
|
||
})
|
||
|
||
with download_lock:
|
||
if download_id in download_progress:
|
||
del download_progress[download_id]
|
||
return web.json_response({
|
||
"success": True,
|
||
"message": f"Download progress {download_id} cleared successfully"
|
||
})
|
||
else:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Download ID {download_id} not found"
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/api/model-searchs")
|
||
async def model_suggests(request):
|
||
"""
|
||
Get model search list by keyword
|
||
"""
|
||
log.info("Received model-search request")
|
||
try:
|
||
keyword = request.query.get('keyword')
|
||
|
||
if not keyword:
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "Missing required parameter: keyword"
|
||
})
|
||
|
||
# 创建ModelScope网关实例
|
||
gateway = ModelScopeGateway()
|
||
|
||
suggests = gateway.search(name=keyword)
|
||
|
||
list = suggests["data"] if suggests.get("data") else []
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"searchs": list,
|
||
"total": len(list)
|
||
},
|
||
"message": f"Get searchs successfully"
|
||
})
|
||
|
||
except ImportError as e:
|
||
log.error(f"ModelScope SDK not installed: {str(e)}")
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": "ModelScope SDK not installed. Please install with: pip install modelscope"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error get model searchs: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Get model searchs failed: {str(e)}"
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/api/model-paths")
|
||
async def model_paths(request):
|
||
"""
|
||
Get model paths by type
|
||
"""
|
||
log.info("Received model-paths request")
|
||
try:
|
||
model_paths = list(folder_paths.folder_names_and_paths.keys())
|
||
return web.json_response({
|
||
"success": True,
|
||
"data": {
|
||
"paths": model_paths,
|
||
},
|
||
"message": f"Get paths successfully"
|
||
})
|
||
|
||
except Exception as e:
|
||
log.error(f"Error get model path: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({
|
||
"success": False,
|
||
"message": f"Get model failed: {str(e)}"
|
||
}) |