Fix Model Check hashing same-size files inside ComfyUI

model_resolution imported calculate_sha256 and the header inference from
scraper.py, which only works when the plugin folder is on sys.path (the
tests); inside ComfyUI the import failed, so a renamed model the scan had
not hashed was reported missing and the route answered 500. Both
functions now live in model_identity.py, which the API and the scanner
already share; scraper.py keeps its printing wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-03 14:46:57 +08:00
co-authored by Claude Opus 5.5
parent 4cfff5382e
commit e04fa66b0d
5 changed files with 60 additions and 54 deletions
+3 -1
View File
@@ -114,7 +114,9 @@ covered by the `styles.css` manifest.
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
`api/folder_types.py` own the formerly mixed utility route families.
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner.
- `model_identity.py` owns file SHA-256 evidence and the base model a safetensors header
tells, shared with the standalone scanner (which runs as its own process, so the API
never imports `scraper.py`).
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
+1
View File
@@ -66,6 +66,7 @@
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI.
### Fixes
- **Model Check finds renamed models by fingerprint again (按指纹找回改名的模型)**: when a workflow's model was renamed or moved and no scan had hashed the file yet, Model Check failed to hash the same-size files itself (an error in the background) and called the model missing. It now hashes them and finds it.
- **Model Check is honest about same-name files (模型检查不再把同名当成同一个文件)**: for a workflow without fingerprints, a missing LoRA or checkpoint with a file of the same name on this computer was listed as "the same file under another name or folder" and counted among those to put in at once. It is now a candidate you take or leave on its row: "same name", or "same name and exactly the same size, very likely the one" (listed first). VAE, CLIP and other foundation components still go by fingerprint only. Model Check now also checks GGUF models and finds upscale models moved to another folder.
- **GGUF and .pth models show up (GGUF、.pth 模型能看到了)**: the Models page, the folder list's counts, search and model lookups only knew `.safetensors`, `.ckpt` and `.pt`, in lower case, so quantized GGUF models (Wan, Flux, Qwen…), most upscalers (`.pth`), `.sft` and `.bin` files and names like `MODEL.SAFETENSORS` were missing. They are all listed now. Scans still read only `.safetensors`; the others stay under **Not scanned yet**.
- **Language follows ComfyUI again (语言跟随 ComfyUI)**: with the language left on "Follow ComfyUI" (the default), changing ComfyUI's language now switches the browser the next time it opens; before, it kept the old language until the page was reloaded.
+3 -6
View File
@@ -3,16 +3,15 @@
import asyncio
import json
import os
import struct
import threading
from aiohttp import web
import folder_paths
try:
from ..model_identity import computed_file_identity, normalise_sha256
from ..model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
except ImportError:
from model_identity import computed_file_identity, normalise_sha256
from model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
try:
from ..model_policies import requires_hash_for_model_recovery
except ImportError:
@@ -113,7 +112,6 @@ def _compute_and_save_fallback_info(file_path, file_hash):
with open(info_path, encoding='utf-8') as source:
info_data = json.load(source)
else:
from scraper import infer_base_model_from_header
filename = os.path.basename(file_path)
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
if inferred_base == 'Unknown':
@@ -159,10 +157,9 @@ def _resolve_from_candidates(candidates, target_hash="", target_size=None, filen
# dynamically compute their hash on-demand to test against target_hash
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
if unhashed_size_matches:
from scraper import calculate_sha256
for candidate in unhashed_size_matches:
try:
computed_hash = calculate_sha256(candidate["path"]).upper()
computed_hash = file_sha256(candidate["path"]).upper()
candidate.setdefault("hashes", set()).add(computed_hash)
if computed_hash == target_hash:
_compute_and_save_fallback_info(candidate["path"], computed_hash)
+51 -1
View File
@@ -1,4 +1,5 @@
"""File SHA-256 evidence and the model sidecars, shared by the standalone scanner and API.
"""File SHA-256 evidence, the base model a safetensors header tells, and the model
sidecars, shared by the standalone scanner and API.
A model's information comes in three layers, the user's first:
- <model>.anomalous.json what the user set in the model editor (only the editor writes it);
@@ -7,9 +8,11 @@ A model's information comes in three layers, the user's first:
- the model file itself.
"""
import hashlib
import json
import os
import re
import struct
USER_INFO_SUFFIX = ".anomalous.json"
@@ -92,3 +95,50 @@ def sidecar_file_hash(data, path, selected_file):
return "", ""
hashes = selected_file.get("hashes", {}) if isinstance(selected_file, dict) else {}
return normalise_sha256(hashes.get("SHA256")), "sidecar file SHA-256"
def file_sha256(path):
"""The complete file's SHA-256, hex."""
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(4096 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def infer_base_model_from_header(file_path: str) -> str:
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
try:
with open(file_path, "rb") as f:
header_size_bytes = f.read(8)
if len(header_size_bytes) < 8: return 'Unknown'
header_size = struct.unpack('<Q', header_size_bytes)[0]
if header_size > 100 * 1024 * 1024: return 'Unknown'
header_json = json.loads(f.read(header_size).decode('utf-8'))
# 1. 尝试从 __metadata__ 提取
metadata = header_json.get('__metadata__', {})
arch = metadata.get('modelspec.architecture', '')
if 'stable-diffusion-xl' in arch.lower(): return 'SDXL'
if 'stable-diffusion-v1' in arch.lower() or 'runwayml/stable-diffusion-v1-5' in arch.lower(): return 'SD 1.5'
if 'flux' in arch.lower(): return 'Flux.1 D'
if 'sd3' in arch.lower(): return 'SD3'
# 2. 暴力张量键名指纹匹配 (Tensor Fingerprinting)
# 把前 500 个键拼接成字符串以提高检索效率,大部分核心键都在前面
keys_str = " ".join(list(header_json.keys())[:500])
# Flux 指纹
if 'double_blocks.0.img_attn' in keys_str or 'img_in.weight' in keys_str: return 'Flux.1 D'
# SD3 指纹
if 'joint_blocks.0.x_block' in keys_str: return 'SD3'
# SDXL 指纹 (包含两套 text encoder)
if 'conditioner.embedders.1.model' in keys_str or 'label_emb.0.0.weight' in keys_str: return 'SDXL'
# SD 1.5 指纹
if 'cond_stage_model.transformer.text_model' in keys_str or 'model.diffusion_model.input_blocks.0.0.weight' in keys_str: return 'SD 1.5'
return 'Unknown'
except Exception as e:
print(f"[-] 离线底模推断失败: {e}")
return 'Unknown'
+2 -46
View File
@@ -13,14 +13,13 @@ except:
pass
import time
import json
import hashlib
import re
import urllib.request
import argparse
import shutil
from model_policies import is_physical_rename_protected
from recycle_bin import move_to_trash
from model_identity import USER_INFO_SUFFIX, computed_file_identity, is_unmatched, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
from civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
@@ -89,51 +88,8 @@ def count_scan_files(target_folder, target_files_basenames, unmatched_only=False
def calculate_sha256(file_path: str) -> str:
"""计算文件的 SHA256 哈希值 (用于 Civitai 匹配)"""
sha256_hash = hashlib.sha256()
print(f"[*] 正在计算 Hash (大文件可能需要几分钟): {os.path.basename(file_path)}")
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096 * 1024), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
import struct
def infer_base_model_from_header(file_path: str) -> str:
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
try:
with open(file_path, "rb") as f:
header_size_bytes = f.read(8)
if len(header_size_bytes) < 8: return 'Unknown'
header_size = struct.unpack('<Q', header_size_bytes)[0]
if header_size > 100 * 1024 * 1024: return 'Unknown'
header_json = json.loads(f.read(header_size).decode('utf-8'))
# 1. 尝试从 __metadata__ 提取
metadata = header_json.get('__metadata__', {})
arch = metadata.get('modelspec.architecture', '')
if 'stable-diffusion-xl' in arch.lower(): return 'SDXL'
if 'stable-diffusion-v1' in arch.lower() or 'runwayml/stable-diffusion-v1-5' in arch.lower(): return 'SD 1.5'
if 'flux' in arch.lower(): return 'Flux.1 D'
if 'sd3' in arch.lower(): return 'SD3'
# 2. 暴力张量键名指纹匹配 (Tensor Fingerprinting)
# 把前 500 个键拼接成字符串以提高检索效率,大部分核心键都在前面
keys_str = " ".join(list(header_json.keys())[:500])
# Flux 指纹
if 'double_blocks.0.img_attn' in keys_str or 'img_in.weight' in keys_str: return 'Flux.1 D'
# SD3 指纹
if 'joint_blocks.0.x_block' in keys_str: return 'SD3'
# SDXL 指纹 (包含两套 text encoder)
if 'conditioner.embedders.1.model' in keys_str or 'label_emb.0.0.weight' in keys_str: return 'SDXL'
# SD 1.5 指纹
if 'cond_stage_model.transformer.text_model' in keys_str or 'model.diffusion_model.input_blocks.0.0.weight' in keys_str: return 'SD 1.5'
return 'Unknown'
except Exception as e:
print(f"[-] 离线底模推断失败: {e}")
return 'Unknown'
return file_sha256(file_path)
def sanitize_filename(name: str) -> str:
"""清理文件名中的非法字符"""