Detect custom loader models and show download progress
This commit is contained in:
@@ -21,6 +21,7 @@ candidate direct-download URLs from Civitai and Hugging Face.
|
||||
- Downloads selected Civitai, Hugging Face, or public Quark-share candidates into the matching configured
|
||||
ComfyUI model folder.
|
||||
- Shows candidate file sizes and loads successfully downloaded files into their workflow nodes.
|
||||
- Shows live downloaded bytes, total size, and percentage while a model download is running.
|
||||
- Searches the configured Quark Netdisk libraries directly for missing model filenames.
|
||||
- Ignores serialized workflow metadata, URLs, free-text fields, and generic `model`
|
||||
fields on non-loader custom nodes to prevent false missing-model reports.
|
||||
|
||||
+92
-13
@@ -5,6 +5,8 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -73,6 +75,8 @@ KNOWN_MODEL_SOURCES = {
|
||||
"confidence": 1.0,
|
||||
},
|
||||
}
|
||||
DOWNLOAD_JOBS: dict[str, dict[str, Any]] = {}
|
||||
DOWNLOAD_TASKS: dict[str, asyncio.Task[Any]] = {}
|
||||
|
||||
|
||||
def _safe_query(name: str) -> str:
|
||||
@@ -458,9 +462,7 @@ async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str,
|
||||
raise web.HTTPBadGateway(text=f"夸克拒绝直链下载:{detail}")
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/findmodels/download")
|
||||
async def download_model(request: web.Request) -> web.Response:
|
||||
payload = await request.json()
|
||||
async def _download_model_payload(payload: dict[str, Any], progress: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
url = str(payload.get("url", "")).strip()
|
||||
quark = payload.get("quark")
|
||||
category = str(payload.get("category", "")).strip()
|
||||
@@ -478,6 +480,15 @@ async def download_model(request: web.Request) -> web.Response:
|
||||
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
if progress is not None:
|
||||
progress.update(
|
||||
status="downloading",
|
||||
filename=filename,
|
||||
category=category,
|
||||
downloaded=0,
|
||||
total=expected_size,
|
||||
updated_at=time.time(),
|
||||
)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{filename}.", suffix=".part", dir=target_dir)
|
||||
os.close(fd)
|
||||
temp_path = Path(temp_name)
|
||||
@@ -495,9 +506,21 @@ async def download_model(request: web.Request) -> web.Response:
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
if "text/html" in content_type or "application/json" in content_type:
|
||||
raise web.HTTPBadGateway(text="下载地址返回了网页或错误信息,而不是模型文件")
|
||||
response_size = _size_value(
|
||||
response.headers.get("X-Linked-Size")
|
||||
or response.headers.get("X-File-Size")
|
||||
or response.headers.get("Content-Length")
|
||||
)
|
||||
total = expected_size or response_size
|
||||
downloaded = 0
|
||||
if progress is not None:
|
||||
progress.update(total=total, updated_at=time.time())
|
||||
with temp_path.open("wb") as output:
|
||||
async for chunk in response.content.iter_chunked(1024 * 1024):
|
||||
output.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress is not None:
|
||||
progress.update(downloaded=downloaded, total=total, updated_at=time.time())
|
||||
if not _is_model_payload(temp_path):
|
||||
raise web.HTTPBadGateway(text="下载结果不是有效模型文件,可能是登录页面、错误信息或 Git LFS 指针")
|
||||
temp_path.replace(target)
|
||||
@@ -514,13 +537,69 @@ async def download_model(request: web.Request) -> web.Response:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"downloaded": True,
|
||||
"filename": filename,
|
||||
"relative_name": filename,
|
||||
"category": category,
|
||||
"path": str(target),
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"downloaded": True,
|
||||
"filename": filename,
|
||||
"relative_name": filename,
|
||||
"category": category,
|
||||
"path": str(target),
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
async def _run_download_job(job_id: str, payload: dict[str, Any]) -> None:
|
||||
progress = DOWNLOAD_JOBS[job_id]
|
||||
try:
|
||||
result = await _download_model_payload(payload, progress)
|
||||
progress.update(status="completed", result=result, downloaded=result["size"], total=result["size"])
|
||||
except Exception as error:
|
||||
progress.update(status="failed", error=getattr(error, "text", None) or str(error))
|
||||
finally:
|
||||
progress["updated_at"] = time.time()
|
||||
DOWNLOAD_TASKS.pop(job_id, None)
|
||||
|
||||
|
||||
def _purge_download_jobs() -> None:
|
||||
cutoff = time.time() - 3600
|
||||
for job_id, job in list(DOWNLOAD_JOBS.items()):
|
||||
if job.get("status") in {"completed", "failed"} and job.get("updated_at", 0) < cutoff:
|
||||
DOWNLOAD_JOBS.pop(job_id, None)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/findmodels/download/start")
|
||||
async def start_download_model(request: web.Request) -> web.Response:
|
||||
payload = await request.json()
|
||||
_safe_filename(str(payload.get("filename", "")))
|
||||
category = str(payload.get("category", "")).strip()
|
||||
_target_directory(category)
|
||||
if not payload.get("quark") and not _allowed_download_url(str(payload.get("url", "")).strip()):
|
||||
raise web.HTTPBadRequest(text="Only approved HTTPS model providers are allowed")
|
||||
_purge_download_jobs()
|
||||
job_id = uuid.uuid4().hex
|
||||
DOWNLOAD_JOBS[job_id] = {
|
||||
"id": job_id,
|
||||
"status": "queued",
|
||||
"filename": basename(str(payload.get("filename", ""))),
|
||||
"category": category,
|
||||
"downloaded": 0,
|
||||
"total": _size_value(payload.get("size")),
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
DOWNLOAD_TASKS[job_id] = asyncio.create_task(_run_download_job(job_id, payload))
|
||||
return web.json_response(DOWNLOAD_JOBS[job_id])
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/findmodels/download/progress")
|
||||
async def download_model_progress(request: web.Request) -> web.Response:
|
||||
payload = await request.json()
|
||||
job_id = str(payload.get("job_id", "")).strip()
|
||||
job = DOWNLOAD_JOBS.get(job_id)
|
||||
if not job:
|
||||
raise web.HTTPNotFound(text="Download task not found")
|
||||
return web.json_response(job)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/findmodels/download")
|
||||
async def download_model(request: web.Request) -> web.Response:
|
||||
return web.json_response(await _download_model_payload(await request.json()))
|
||||
|
||||
+7
-3
@@ -63,7 +63,7 @@ EXPLICIT_MODEL_WIDGETS = (
|
||||
)
|
||||
|
||||
CATEGORY_HINTS = (
|
||||
("diffusion_models", ("fantasytalking", "fantasyportrait")),
|
||||
("diffusion_models", ("fantasytalking", "fantasyportrait", "wanvideo")),
|
||||
("upscale_models", ("rife", "frame_interpolation", "film", "upscale", "esrgan", "super_resolution", "superresolution")),
|
||||
("controlnet", ("controlnet", "control_net", "control")),
|
||||
("embeddings", ("embedding", "textual_inversion")),
|
||||
@@ -168,7 +168,10 @@ def extract_references(payload: Any) -> list[Reference]:
|
||||
node_hint = re.sub(r"[^a-z0-9_]+", "", str(node_type or "").lower())
|
||||
if node_id is not None:
|
||||
explicit_widget = widget_hint in EXPLICIT_MODEL_WIDGETS
|
||||
loader_widget = "loader" in node_hint and any(term in widget_hint for term in MODEL_WIDGET_HINTS)
|
||||
# Custom loaders frequently translate the widget label (for example
|
||||
# "模型"), so the file extension plus the loader node type is the
|
||||
# reliable signal. Non-loader custom nodes remain excluded.
|
||||
loader_widget = "loader" in node_hint
|
||||
if not explicit_widget and not loader_widget and not model_selector:
|
||||
return
|
||||
ref = Reference(
|
||||
@@ -178,7 +181,8 @@ def extract_references(payload: Any) -> list[Reference]:
|
||||
widget=None if widget is None else str(widget),
|
||||
node_type=None if node_type is None else str(node_type),
|
||||
strict=model_selector
|
||||
or any(term in re.sub(r"[^a-z0-9_]+", "", str(node_type or "").lower()) for term in STANDARD_LOADER_HINTS),
|
||||
or "loader" in node_hint
|
||||
or any(term in node_hint for term in STANDARD_LOADER_HINTS),
|
||||
)
|
||||
found[(ref.name.lower(), ref.category, ref.node_id, ref.widget)] = ref
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-findmodels"
|
||||
version = "1.9.1"
|
||||
version = "1.10.0"
|
||||
description = "Find and adapt missing models in the current ComfyUI workflow"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -180,6 +180,41 @@ class ModelFinderTests(unittest.TestCase):
|
||||
result = analyze(payload, lambda category: [])
|
||||
self.assertEqual(result["models"][0]["category"], "diffusion_models")
|
||||
|
||||
def test_detects_missing_model_in_custom_loader_with_translated_widget(self):
|
||||
payload = {
|
||||
"nodes": [{
|
||||
"id": 22,
|
||||
"type": "WanVideo Model Loader",
|
||||
"widgets": [{
|
||||
"name": "模型",
|
||||
"value": "Wan/Wan14Bi2vFusioniX_fp8.safetensors",
|
||||
"model_selector": False,
|
||||
}],
|
||||
}]
|
||||
}
|
||||
result = analyze(payload, lambda category: [])
|
||||
self.assertEqual(result["summary"]["missing"], 1)
|
||||
self.assertEqual(result["models"][0]["category"], "diffusion_models")
|
||||
self.assertEqual(result["models"][0]["name"], "Wan/Wan14Bi2vFusioniX_fp8.safetensors")
|
||||
|
||||
def test_hides_installed_model_in_custom_loader_with_translated_widget(self):
|
||||
payload = {
|
||||
"nodes": [{
|
||||
"id": 22,
|
||||
"type": "WanVideo Model Loader",
|
||||
"widgets": [{
|
||||
"name": "模型",
|
||||
"value": "Wan/Wan14Bi2vFusioniX_fp8.safetensors",
|
||||
"model_selector": False,
|
||||
}],
|
||||
}]
|
||||
}
|
||||
result = analyze(
|
||||
payload,
|
||||
lambda category: ["Wan/Wan14Bi2vFusioniX_fp8.safetensors"] if category == "diffusion_models" else [],
|
||||
)
|
||||
self.assertEqual(result["models"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -30,5 +30,8 @@
|
||||
.fm-sources { display: grid; gap: 5px; margin-top: 7px; }
|
||||
.fm-node-candidates { display: grid; gap: 5px; margin-top: 7px; }
|
||||
.fm-source { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; }
|
||||
.fm-download-progress {
|
||||
grid-column: 1 / -1; min-height: 16px; color: #8fd3a8; font-size: 12px;
|
||||
}
|
||||
.fm-sources a { color: #7fb9ff; overflow-wrap: anywhere; }
|
||||
#find-models-panel button { padding: 5px 8px; color: #eee; background: #333; border: 1px solid #666; border-radius: 5px; cursor: pointer; }
|
||||
|
||||
+31
-2
@@ -19,13 +19,39 @@ function formatSize(value) {
|
||||
return `${(size / (1024 ** index)).toFixed(index > 1 ? 2 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function downloadProgressText(job) {
|
||||
const downloaded = formatSize(job.downloaded);
|
||||
const total = Number(job.total);
|
||||
if (!Number.isFinite(total) || total <= 0) return `已下载 ${downloaded}`;
|
||||
const percent = Math.min(100, Math.round((Number(job.downloaded || 0) / total) * 100));
|
||||
return `已下载 ${downloaded} / ${formatSize(total)} · ${percent}%`;
|
||||
}
|
||||
|
||||
async function waitForDownload(jobId, progressElement) {
|
||||
while (true) {
|
||||
const job = await post("/findmodels/download/progress", { job_id: jobId });
|
||||
progressElement.textContent = downloadProgressText(job);
|
||||
if (job.status === "completed") return job.result;
|
||||
if (job.status === "failed") throw new Error(job.error || "下载失败");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
function workflowSnapshot() {
|
||||
return {
|
||||
nodes: app.graph?._nodes?.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
widgets: (node.widgets || []).flatMap((widget) => {
|
||||
const values = Array.isArray(widget.options?.values) ? widget.options.values : [];
|
||||
let rawValues = widget.options?.values;
|
||||
if (typeof rawValues === "function") {
|
||||
try {
|
||||
rawValues = rawValues();
|
||||
} catch (error) {
|
||||
console.debug("[ComfyUI_FindModels] Unable to inspect combo values", widget.name, error);
|
||||
}
|
||||
}
|
||||
const values = Array.isArray(rawValues) ? rawValues : [];
|
||||
const isModelSelector = values.some((value) =>
|
||||
typeof value === "string" && /\.(bin|ckpt|gguf|onnx|pt|pth|safetensors)$/i.test(value),
|
||||
);
|
||||
@@ -91,6 +117,7 @@ function sourceHtml(item, model) {
|
||||
data-size="${escapeHtml(item.size || "")}"
|
||||
data-category="${escapeHtml(model.category)}" data-node-id="${escapeHtml(model.node_id)}"
|
||||
data-widget="${escapeHtml(model.widget)}" data-original="${escapeHtml(model.name)}">下载到模型目录</button>
|
||||
<div class="fm-download-progress" aria-live="polite"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -184,14 +211,16 @@ function ensurePanel() {
|
||||
if (!downloadButton) return;
|
||||
downloadButton.disabled = true;
|
||||
downloadButton.textContent = "正在下载…";
|
||||
const progressElement = downloadButton.closest(".fm-source").querySelector(".fm-download-progress");
|
||||
try {
|
||||
const downloaded = await post("/findmodels/download", {
|
||||
const job = await post("/findmodels/download/start", {
|
||||
url: downloadButton.dataset.download,
|
||||
quark: downloadButton.dataset.quarkDownload ? JSON.parse(downloadButton.dataset.quarkDownload) : null,
|
||||
filename: downloadButton.dataset.filename,
|
||||
size: downloadButton.dataset.size || null,
|
||||
category: downloadButton.dataset.category,
|
||||
});
|
||||
const downloaded = await waitForDownload(job.id, progressElement);
|
||||
downloadButton.textContent = "下载完成,正在加载";
|
||||
await applyMatch({
|
||||
node_id: downloadButton.dataset.nodeId,
|
||||
|
||||
Reference in New Issue
Block a user