Make Quark matches directly downloadable
This commit is contained in:
@@ -22,10 +22,6 @@ candidate direct-download URLs from Civitai and Hugging Face.
|
||||
ComfyUI model folder.
|
||||
- Shows candidate file sizes and loads successfully downloaded files into their workflow nodes.
|
||||
- Searches the configured Quark Netdisk libraries directly for missing model filenames.
|
||||
- Audits existing model locations and reports only files whose expected official folder
|
||||
can be determined clearly. The audit never moves files.
|
||||
- Leaves ambiguous checkpoint/diffusion model files in place instead of guessing their
|
||||
architecture from names such as `flux`, `wan`, or `z_image`.
|
||||
- Ignores serialized workflow metadata, URLs, free-text fields, and generic `model`
|
||||
fields on non-loader custom nodes to prevent false missing-model reports.
|
||||
- Does not move, delete, or overwrite model files.
|
||||
@@ -53,7 +49,7 @@ No extra Python packages are required.
|
||||
3. Review local replacement suggestions.
|
||||
4. Click **一键加载模型** to load safe high-confidence local matches into their nodes, or apply an
|
||||
individual suggestion.
|
||||
5. For missing models, click **查找下载来源**. You can download a Civitai or Hugging
|
||||
5. For missing models, click **下载缺失模型**. You can download a Civitai or Hugging
|
||||
Face candidate or a public match from the two configured Quark Netdisk model libraries
|
||||
directly into its matching configured model folder.
|
||||
|
||||
@@ -61,9 +57,10 @@ The download results are search candidates. Verify the model page, license, base
|
||||
and filename before downloading. Some Civitai files require authentication, so their
|
||||
direct URL may redirect to a sign-in page.
|
||||
|
||||
Only sources whose filename exactly matches the missing workflow filename and whose
|
||||
download endpoint is currently usable are shown. Similar filenames and restricted Quark
|
||||
files are excluded.
|
||||
Only sources whose filename exactly matches the missing workflow filename are shown.
|
||||
Matching Quark files are shown as download buttons instead of fallback search links.
|
||||
The download endpoint rejects unknown model categories rather than placing files in an
|
||||
incorrect folder.
|
||||
|
||||
Downloads are accepted only from approved Civitai and Hugging Face HTTPS hosts. Existing
|
||||
files are never overwritten. Completed downloads clear ComfyUI's model filename cache so
|
||||
@@ -91,7 +88,7 @@ python -m unittest discover -s tests -v
|
||||
## Privacy
|
||||
|
||||
Workflow model filenames are sent to Civitai, Hugging Face, and the two configured public
|
||||
Quark shares only when you explicitly click **查找下载来源**. Regular scans remain local.
|
||||
Quark shares only when you explicitly click **下载缺失模型**. Regular scans remain local.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+7
-85
@@ -55,19 +55,6 @@ DOWNLOAD_CATEGORY_ALIASES = {
|
||||
"upscale_models": ("upscale_models",),
|
||||
"embeddings": ("embeddings",),
|
||||
}
|
||||
OFFICIAL_MODEL_FOLDERS = {
|
||||
"checkpoints": "checkpoints",
|
||||
"loras": "loras",
|
||||
"vae": "vae",
|
||||
"controlnet": "controlnet",
|
||||
"clip_vision": "clip_vision",
|
||||
"text_encoders": "text_encoders",
|
||||
"diffusion_models": "diffusion_models",
|
||||
"upscale_models": "upscale_models",
|
||||
"embeddings": "embeddings",
|
||||
}
|
||||
|
||||
|
||||
def _safe_query(name: str) -> str:
|
||||
return re.sub(r"[_\-.]+", " ", basename(name).rsplit(".", 1)[0]).strip()
|
||||
|
||||
@@ -118,7 +105,10 @@ def _is_model_payload(path: Path) -> bool:
|
||||
|
||||
|
||||
def _target_directory(category: str) -> Path:
|
||||
for candidate in DOWNLOAD_CATEGORY_ALIASES.get(category, (category,)):
|
||||
aliases = DOWNLOAD_CATEGORY_ALIASES.get(category)
|
||||
if not aliases:
|
||||
raise web.HTTPBadRequest(text=f"无法确定模型对应的 ComfyUI 文件夹: {category}")
|
||||
for candidate in aliases:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(candidate)
|
||||
except Exception:
|
||||
@@ -137,54 +127,6 @@ def _clear_filename_cache(category: str) -> None:
|
||||
cache.pop(candidate, None)
|
||||
|
||||
|
||||
def _comfy_model_root() -> Path:
|
||||
base = getattr(folder_paths, "models_dir", None)
|
||||
if not base:
|
||||
checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
if not checkpoint_paths:
|
||||
raise web.HTTPBadRequest(text="无法确定 ComfyUI models 目录")
|
||||
base = Path(checkpoint_paths[0]).parent
|
||||
return Path(base).resolve()
|
||||
|
||||
|
||||
def _classify_existing_file(path: Path) -> str | None:
|
||||
text = path.as_posix().lower()
|
||||
name = path.name.lower()
|
||||
if any(term in text for term in ("lora", "lycoris")):
|
||||
return "loras"
|
||||
if "controlnet" in text or "control_net" in text:
|
||||
return "controlnet"
|
||||
if "clip_vision" in text:
|
||||
return "clip_vision"
|
||||
if any(term in text for term in ("text_encoder", "/clip/", "/t5")):
|
||||
return "text_encoders"
|
||||
if "embedding" in text:
|
||||
return "embeddings"
|
||||
if any(term in text for term in ("upscale", "esrgan", "rife", "frame_interpolation")):
|
||||
return "upscale_models"
|
||||
if "/vae" in text or name.startswith("vae") or "_vae" in name:
|
||||
return "vae"
|
||||
if any(term in text for term in ("diffusion_model", "/unet/")):
|
||||
return "diffusion_models"
|
||||
return None
|
||||
|
||||
|
||||
def _audit_model_locations() -> list[dict[str, str]]:
|
||||
root = _comfy_model_root()
|
||||
issues = []
|
||||
for source in root.rglob("*"):
|
||||
if not source.is_file() or source.suffix.lower() not in MODEL_EXTENSIONS:
|
||||
continue
|
||||
category = _classify_existing_file(source)
|
||||
if not category:
|
||||
continue
|
||||
target_dir = (root / OFFICIAL_MODEL_FOLDERS[category]).resolve()
|
||||
if source.parent == target_dir or target_dir in source.parents:
|
||||
continue
|
||||
issues.append({"expected": category, "path": str(source)})
|
||||
return issues
|
||||
|
||||
|
||||
async def _get_json(session: aiohttp.ClientSession, url: str) -> Any:
|
||||
try:
|
||||
async with session.get(url, headers={"User-Agent": "ComfyUI_FindModels/1.0"}) as response:
|
||||
@@ -387,20 +329,10 @@ async def find_sources(request: web.Request) -> web.Response:
|
||||
*(_validate_web_candidate(session, candidate) for candidate in exact_web),
|
||||
return_exceptions=True,
|
||||
)
|
||||
checked_quark = await asyncio.gather(
|
||||
*(_validate_quark_candidate(session, candidate) for candidate in exact_quark),
|
||||
return_exceptions=True,
|
||||
)
|
||||
candidates = [
|
||||
candidate for candidate in [*checked_web, *checked_quark] if isinstance(candidate, dict)
|
||||
candidate for candidate in [*checked_web, *exact_quark] if isinstance(candidate, dict)
|
||||
]
|
||||
return web.json_response({"name": name, "candidates": candidates[:12], "quark_libraries": QUARK_MODEL_LIBRARIES})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/findmodels/audit")
|
||||
async def audit_models(request: web.Request) -> web.Response:
|
||||
items = _audit_model_locations()
|
||||
return web.json_response({"count": len(items), "items": items})
|
||||
return web.json_response({"name": name, "candidates": candidates[:12]})
|
||||
|
||||
|
||||
async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str, Any]) -> str:
|
||||
@@ -429,17 +361,7 @@ async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str,
|
||||
if _allowed_quark_download_url(url):
|
||||
return url
|
||||
detail = errors[0] if errors else "需要登录或文件超过公开下载大小限制"
|
||||
raise web.HTTPBadGateway(text=f"夸克无法公开下载:{detail}")
|
||||
|
||||
|
||||
async def _validate_quark_candidate(
|
||||
session: aiohttp.ClientSession, candidate: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
await _quark_download_url(session, candidate["quark"])
|
||||
return candidate
|
||||
except web.HTTPException:
|
||||
return None
|
||||
raise web.HTTPBadGateway(text=f"夸克拒绝直链下载:{detail}")
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/findmodels/download")
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ class Reference:
|
||||
|
||||
|
||||
def normalize_path(value: str) -> str:
|
||||
return value.replace("\\", "/").strip()
|
||||
return value.replace("\\", "/").strip().lstrip("/")
|
||||
|
||||
|
||||
def basename(value: str) -> str:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-findmodels"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
description = "Find and adapt missing models in the current ComfyUI workflow"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -33,8 +33,6 @@ from ComfyUI_FindModels.find_models import (
|
||||
_safe_filename,
|
||||
_size_value,
|
||||
_target_directory,
|
||||
_classify_existing_file,
|
||||
_audit_model_locations,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +74,10 @@ class DownloadHelperTests(unittest.TestCase):
|
||||
def test_uses_matching_comfyui_model_folder(self):
|
||||
self.assertEqual(_target_directory("loras"), (Path.cwd() / "models" / "loras").resolve())
|
||||
|
||||
def test_rejects_unknown_download_folder(self):
|
||||
with self.assertRaises(Exception):
|
||||
_target_directory("unknown")
|
||||
|
||||
def test_supports_legacy_comfyui_folder_aliases(self):
|
||||
original = folder_paths.get_folder_paths
|
||||
folder_paths.get_folder_paths = lambda category: [] if category == "text_encoders" else [str(Path.cwd() / "models" / category)]
|
||||
@@ -99,29 +101,5 @@ class DownloadHelperTests(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_classifies_files_for_official_folders(self):
|
||||
self.assertEqual(_classify_existing_file(Path("misc/loras/hero.safetensors")), "loras")
|
||||
self.assertEqual(_classify_existing_file(Path("misc/rife47.pth")), "upscale_models")
|
||||
self.assertEqual(_classify_existing_file(Path("misc/vae_model.safetensors")), "vae")
|
||||
self.assertIsNone(_classify_existing_file(Path("checkpoints/z_image_turbo_bf16.safetensors")))
|
||||
self.assertIsNone(_classify_existing_file(Path("misc/unknown_model.safetensors")))
|
||||
|
||||
def test_audit_reports_only_clearly_misplaced_files(self):
|
||||
root = Path(folder_paths.models_dir)
|
||||
misplaced = root / "checkpoints" / "hero_lora.safetensors"
|
||||
correct = root / "loras" / "people" / "correct.safetensors"
|
||||
misplaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
correct.parent.mkdir(parents=True, exist_ok=True)
|
||||
misplaced.write_bytes(b"x")
|
||||
correct.write_bytes(b"x")
|
||||
try:
|
||||
issues = _audit_model_locations()
|
||||
self.assertTrue(any(item["path"] == str(misplaced.resolve()) for item in issues))
|
||||
self.assertFalse(any(item["path"] == str(correct.resolve()) for item in issues))
|
||||
finally:
|
||||
misplaced.unlink()
|
||||
correct.unlink()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -27,6 +27,12 @@ class ModelFinderTests(unittest.TestCase):
|
||||
def test_normalized_stem_removes_common_precision_suffix(self):
|
||||
self.assertEqual(normalized_stem("Hero_v2_fp16.safetensors"), "hero")
|
||||
|
||||
def test_removes_leading_path_separators(self):
|
||||
refs = extract_references(
|
||||
{"nodes": [{"id": 6, "type": "VAELoader", "widgets": [{"name": "vae_name", "value": "\\/folder/model.safetensors"}]}]}
|
||||
)
|
||||
self.assertEqual(refs[0].name, "folder/model.safetensors")
|
||||
|
||||
def test_extracts_widget_reference_with_location(self):
|
||||
refs = extract_references(
|
||||
{"nodes": [{"id": 7, "type": "LoraLoader", "widgets": [{"name": "lora_name", "value": "Hero.safetensors"}]}]}
|
||||
|
||||
+5
-35
@@ -47,12 +47,6 @@ async function post(path, body) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const response = await api.fetchApi(path);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function findTargetWidget(node, model) {
|
||||
const widgets = node?.widgets || [];
|
||||
return widgets.find((item) => item.name === model.widget)
|
||||
@@ -112,7 +106,6 @@ function ensurePanel() {
|
||||
<div class="fm-actions">
|
||||
<button data-action="scan">扫描当前工作流</button>
|
||||
<button data-action="adapt">一键加载模型</button>
|
||||
<button data-action="audit">检查模型位置</button>
|
||||
</div>
|
||||
<div class="fm-summary">尚未扫描</div><div class="fm-list"></div>`;
|
||||
document.body.appendChild(panel);
|
||||
@@ -133,31 +126,11 @@ function ensurePanel() {
|
||||
button.disabled = false;
|
||||
if (count) window.setTimeout(() => scan(false), 100);
|
||||
};
|
||||
panel.querySelector('[data-action="audit"]').onclick = async () => {
|
||||
const button = panel.querySelector('[data-action="audit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const audit = await get("/findmodels/audit");
|
||||
if (!audit.count) {
|
||||
panel.querySelector(".fm-summary").textContent = "未发现能够明确判断为放错位置的模型。";
|
||||
return;
|
||||
}
|
||||
const preview = audit.items.slice(0, 50).map((item) =>
|
||||
`${item.expected}: ${item.path}`,
|
||||
).join("\n");
|
||||
window.alert(`发现 ${audit.count} 个能够明确判断的位置问题。\n本功能只检查,不移动文件。\n\n${preview}`);
|
||||
panel.querySelector(".fm-summary").textContent = `发现 ${audit.count} 个明确的位置问题,未移动任何文件。`;
|
||||
} catch (error) {
|
||||
panel.querySelector(".fm-summary").textContent = `检查失败:${error.message}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
panel.addEventListener("click", async (event) => {
|
||||
const sourceButton = event.target.closest("[data-source]");
|
||||
if (sourceButton) {
|
||||
sourceButton.disabled = true;
|
||||
sourceButton.textContent = "查询中…";
|
||||
sourceButton.textContent = "正在获取下载项…";
|
||||
const model = lastResult?.models?.find((item) => item.name === sourceButton.dataset.source);
|
||||
const target = sourceButton.closest(".fm-item").querySelector(".fm-sources");
|
||||
try {
|
||||
@@ -166,15 +139,12 @@ function ensurePanel() {
|
||||
category: model.category,
|
||||
});
|
||||
const links = data.candidates.map((item) => sourceHtml(item, model)).join("");
|
||||
const quarkFallback = data.quark_libraries.map((item) =>
|
||||
`<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.name)}备用查找</a>`,
|
||||
).join("");
|
||||
target.innerHTML = `${links || "<span>未找到文件名完全一致且可直接下载的模型</span>"}${quarkFallback}`;
|
||||
target.innerHTML = links || "<span>未找到文件名完全一致的可下载模型</span>";
|
||||
} catch (error) {
|
||||
target.textContent = `查询失败:${error.message}`;
|
||||
target.textContent = `获取下载项失败:${error.message}`;
|
||||
} finally {
|
||||
sourceButton.disabled = false;
|
||||
sourceButton.textContent = "查找下载来源";
|
||||
sourceButton.textContent = "下载缺失模型";
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -219,7 +189,7 @@ function render(result, quiet) {
|
||||
<article class="fm-item fm-${escapeHtml(model.status)}">
|
||||
<div><strong>${escapeHtml(model.name)}</strong><span>${escapeHtml(model.category)} · ${escapeHtml(model.status)}</span></div>
|
||||
${model.match ? `<div class="fm-match">本地候选:${escapeHtml(model.match.name)} (${Math.round(model.match.confidence * 100)}%) ${model.match.auto_apply ? `<button data-apply="${escapeHtml(model.node_id)}:${escapeHtml(model.widget)}">加载</button>` : ""}</div>` : ""}
|
||||
${model.status === "missing" ? `<button data-source="${escapeHtml(model.name)}">查找下载来源</button><div class="fm-sources"></div>` : ""}
|
||||
${model.status === "missing" ? `<button data-source="${escapeHtml(model.name)}">下载缺失模型</button><div class="fm-sources"></div>` : ""}
|
||||
</article>`).join("") || "<p>当前工作流中未识别到模型引用。</p>";
|
||||
panel.querySelectorAll("[data-apply]").forEach((button) => {
|
||||
button.onclick = async () => {
|
||||
|
||||
Reference in New Issue
Block a user