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:
dseditor
2026-09-15 23:06:45 +08:00
co-authored by Claude Opus 5
parent c1c57dc5c9
commit ad233e8398
5 changed files with 106 additions and 495 deletions
+13 -120
View File
@@ -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
+91 -8
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,2 @@
regex
accelerate
reportlab
-364
View File
@@ -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);
}