Detect custom loader models and show download progress

This commit is contained in:
ComfyUI_FindModels
2026-06-13 10:33:31 +08:00
parent 5af35bbe35
commit 4c1fc273d3
7 changed files with 170 additions and 19 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+35
View File
@@ -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()
+3
View File
@@ -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
View File
@@ -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,