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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c1c57dc5c9
commit
ad233e8398
+13
-120
@@ -2,16 +2,9 @@ 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:
|
||||
"""
|
||||
@@ -73,9 +66,6 @@ class ModelDownloader:
|
||||
"default": "無",
|
||||
"tooltip": "選擇模型範本。選擇範本後將使用範本中的下載列表,忽略上方手動輸入的內容。\n選擇「無」時使用上方手動輸入的下載列表。"
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,20 +75,8 @@ 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,
|
||||
unique_id=None) -> Tuple[str]:
|
||||
chunk_size_mb: int, max_workers: int, model_template: str) -> Tuple[str]:
|
||||
"""
|
||||
下載模型到 ComfyUI/models 資料夾
|
||||
|
||||
@@ -109,7 +87,6 @@ class ModelDownloader:
|
||||
chunk_size_mb: 區塊大小 (MB)
|
||||
max_workers: 最大平行執行緒數
|
||||
model_template: 模型範本名稱
|
||||
unique_id: 節點唯一 ID(由 ComfyUI 自動傳入)
|
||||
|
||||
Returns:
|
||||
狀態訊息
|
||||
@@ -152,14 +129,7 @@ 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,
|
||||
})
|
||||
print("ModelDownloader: 正在檢查現有檔案...")
|
||||
|
||||
# 檢查檔案是否已存在且完整
|
||||
files_to_download, skipped_files, incomplete_files = self._check_existing_files(
|
||||
@@ -169,13 +139,6 @@ 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,)
|
||||
|
||||
# 開始下載
|
||||
@@ -183,20 +146,6 @@ 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)
|
||||
@@ -212,31 +161,13 @@ 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,
|
||||
progress_callback=progress_callback,
|
||||
current_file=current_file,
|
||||
total_files=total_files)
|
||||
result = self._download_with_hf_hub(url, dest_path, s3c, patch_applied)
|
||||
else:
|
||||
# 使用普通 HTTP 下載
|
||||
result = self._download_with_http(url, dest_path,
|
||||
progress_callback=progress_callback,
|
||||
current_file=current_file,
|
||||
total_files=total_files)
|
||||
result = self._download_with_http(url, dest_path)
|
||||
|
||||
results.append(result)
|
||||
|
||||
@@ -244,14 +175,6 @@ 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
|
||||
@@ -262,17 +185,9 @@ 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,
|
||||
})
|
||||
print(f"\nModelDownloader: 下載完成!成功:{success_count}/{total_files},失敗:{fail_count}")
|
||||
|
||||
# 產生最終狀態訊息
|
||||
return self._generate_status_message(results, skipped_files, incomplete_files, total_files)
|
||||
@@ -506,9 +421,7 @@ class ModelDownloader:
|
||||
# ==================== 下載方法 ====================
|
||||
|
||||
def _download_with_hf_hub(self, url: str, dest_path: str,
|
||||
s3c: Optional[object], patch_applied: bool,
|
||||
progress_callback=None, current_file: int = 0,
|
||||
total_files: int = 0) -> str:
|
||||
s3c: Optional[object], patch_applied: bool) -> str:
|
||||
"""
|
||||
使用 HuggingFace Hub 下載,然後搬移到目標資料夾
|
||||
|
||||
@@ -517,9 +430,6 @@ class ModelDownloader:
|
||||
dest_path: 最終目標路徑
|
||||
s3c: s3impleclient module(如果可用)
|
||||
patch_applied: S3C patch 是否已套用
|
||||
progress_callback: 進度回調函數
|
||||
current_file: 當前檔案序號
|
||||
total_files: 總檔案數
|
||||
|
||||
Returns:
|
||||
結果訊息
|
||||
@@ -532,9 +442,7 @@ 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 自行在終端機輸出下載進度條
|
||||
|
||||
# 使用 HF Hub 下載到快取
|
||||
downloaded_path = hf_hub_download(
|
||||
@@ -558,8 +466,6 @@ class ModelDownloader:
|
||||
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
|
||||
@@ -571,24 +477,17 @@ class ModelDownloader:
|
||||
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,
|
||||
progress_callback=None, current_file: int = 0,
|
||||
total_files: int = 0) -> str:
|
||||
def _download_with_http(self, url: str, dest_path: str) -> str:
|
||||
"""
|
||||
使用普通 HTTP 下載(支援 Civitai 等需要特殊處理的網站)
|
||||
|
||||
Args:
|
||||
url: 下載 URL
|
||||
dest_path: 目標路徑
|
||||
progress_callback: 進度回調函數
|
||||
current_file: 當前檔案序號
|
||||
total_files: 總檔案數
|
||||
|
||||
Returns:
|
||||
結果訊息
|
||||
@@ -676,7 +575,7 @@ class ModelDownloader:
|
||||
downloaded = 0
|
||||
chunk_size = 8192 * 4 # 增加 chunk size 提升效能
|
||||
last_progress = 0
|
||||
last_callback_time = time.time()
|
||||
next_size_milestone = 10 * 1024 * 1024 # 未知總大小時每 10MB 回報一次
|
||||
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
@@ -686,20 +585,16 @@ class ModelDownloader:
|
||||
out_file.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# 每 10% 顯示進度(終端機)
|
||||
# 終端機進度:已知總大小時每 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 % (10 * 1024 * 1024) == 0: # 每 10MB 顯示一次
|
||||
elif downloaded >= next_size_milestone:
|
||||
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
|
||||
# 用門檻而非整除判斷,chunk 大小改變時仍然可靠
|
||||
next_size_milestone = downloaded + 10 * 1024 * 1024
|
||||
|
||||
# 檢查下載的檔案是否有效(不是錯誤頁面)
|
||||
downloaded_size = os.path.getsize(temp_path)
|
||||
@@ -716,8 +611,6 @@ class ModelDownloader:
|
||||
|
||||
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
|
||||
|
||||
@@ -3,13 +3,84 @@ import os
|
||||
from PIL import Image
|
||||
import torch
|
||||
import numpy as np
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.pdfbase import pdfutils
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase.pdfmetrics import registerFont, stringWidth
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# ==================== reportlab 延遲載入 ====================
|
||||
# reportlab 只有 PhotoMagazineMaker 產生 PDF 時才需要。
|
||||
# 若在模組頂層直接 import,缺少套件時會讓整個 ListHelper 節點套件載入失敗,
|
||||
# 因此改為延遲載入:真正要出 PDF 時才 import,缺少時才即時安裝。
|
||||
A4 = None
|
||||
mm = None
|
||||
canvas = None
|
||||
HexColor = None
|
||||
TTFont = None
|
||||
registerFont = None
|
||||
stringWidth = None
|
||||
|
||||
_REPORTLAB_READY = False
|
||||
|
||||
|
||||
def _bind_reportlab():
|
||||
"""把 reportlab 的符號綁定到模組全域變數(供既有程式碼直接使用)"""
|
||||
global A4, mm, canvas, HexColor, TTFont, registerFont, stringWidth, _REPORTLAB_READY
|
||||
|
||||
from reportlab.lib.pagesizes import A4 as _A4
|
||||
from reportlab.lib.units import mm as _mm
|
||||
from reportlab.pdfgen import canvas as _canvas
|
||||
from reportlab.lib.colors import HexColor as _HexColor
|
||||
from reportlab.pdfbase.ttfonts import TTFont as _TTFont
|
||||
from reportlab.pdfbase.pdfmetrics import registerFont as _registerFont, stringWidth as _stringWidth
|
||||
|
||||
A4 = _A4
|
||||
mm = _mm
|
||||
canvas = _canvas
|
||||
HexColor = _HexColor
|
||||
TTFont = _TTFont
|
||||
registerFont = _registerFont
|
||||
stringWidth = _stringWidth
|
||||
_REPORTLAB_READY = True
|
||||
|
||||
|
||||
def ensure_reportlab(auto_install=True):
|
||||
"""
|
||||
確保 reportlab 可用。
|
||||
|
||||
Args:
|
||||
auto_install: 找不到套件時是否自動安裝
|
||||
|
||||
Raises:
|
||||
RuntimeError: 套件不存在且安裝失敗
|
||||
"""
|
||||
if _REPORTLAB_READY:
|
||||
return
|
||||
|
||||
try:
|
||||
_bind_reportlab()
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not auto_install:
|
||||
raise RuntimeError("需要 reportlab 套件才能產生 PDF,請執行:pip install reportlab")
|
||||
|
||||
print("PhotoMagazine: 未偵測到 reportlab,正在自動安裝(僅需一次)...")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "reportlab"])
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"reportlab 自動安裝失敗:{e}\n請手動執行:{sys.executable} -m pip install reportlab"
|
||||
)
|
||||
|
||||
try:
|
||||
_bind_reportlab()
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
f"reportlab 安裝後仍無法載入:{e}\n請重新啟動 ComfyUI 後再試一次。"
|
||||
)
|
||||
|
||||
print("PhotoMagazine: reportlab 安裝完成")
|
||||
|
||||
|
||||
class PhotoMagazinePromptGenerator:
|
||||
"""
|
||||
@@ -518,8 +589,13 @@ class PhotoMagazineMaker:
|
||||
import random
|
||||
return random.choice(all_images)
|
||||
|
||||
def create_full_bleed_image(self, pil_image, width=210*mm, height=297*mm):
|
||||
def create_full_bleed_image(self, pil_image, width=None, height=None):
|
||||
"""創建滿版圖片"""
|
||||
# 預設 A4 尺寸需在呼叫時才計算(mm 由 ensure_reportlab 延遲綁定)
|
||||
if width is None:
|
||||
width = 210 * mm
|
||||
if height is None:
|
||||
height = 297 * mm
|
||||
try:
|
||||
# 計算目標比例
|
||||
target_ratio = width / height
|
||||
@@ -1769,6 +1845,13 @@ class PhotoMagazineMaker:
|
||||
|
||||
def make_photo_magazine(self, images, json_data, template, layout, font, compress_pdf, disable_cover_layout, output_path):
|
||||
"""製作寫真雜誌"""
|
||||
# 此節點是唯一需要 reportlab 的地方,執行時才載入(必要時自動安裝)
|
||||
try:
|
||||
ensure_reportlab()
|
||||
except RuntimeError as e:
|
||||
print(f"PhotoMagazineMaker: {e}")
|
||||
return (str(e),)
|
||||
|
||||
try:
|
||||
# 根據ComfyUI規範,當INPUT_IS_LIST=True時,所有參數都是列表
|
||||
# 從列表中提取實際值
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
[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.1"
|
||||
version = "1.4.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["regex", "accelerate", "reportlab"]
|
||||
dependencies = ["regex", "accelerate"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/dseditor/ComfyUI-ListHelper"
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
regex
|
||||
accelerate
|
||||
reportlab
|
||||
@@ -1,364 +0,0 @@
|
||||
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