diff --git a/model_downloader.py b/model_downloader.py index 34c3a6c..6cd0da0 100644 --- a/model_downloader.py +++ b/model_downloader.py @@ -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 diff --git a/photo_magazine_generator.py b/photo_magazine_generator.py index e061810..b62c829 100644 --- a/photo_magazine_generator.py +++ b/photo_magazine_generator.py @@ -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時,所有參數都是列表 # 從列表中提取實際值 diff --git a/pyproject.toml b/pyproject.toml index 9d09cb4..c6c3cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt index fe9c24a..bde05f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ regex accelerate -reportlab \ No newline at end of file diff --git a/web/js/modelDownloader.js b/web/js/modelDownloader.js deleted file mode 100644 index 636f62a..0000000 --- a/web/js/modelDownloader.js +++ /dev/null @@ -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); -}