feat: extract richer local metadata from safetensors headers, improve base-model inference, and preserve local data when remote lookups fail

This commit is contained in:
Dariusz L
2026-07-05 04:22:25 +02:00
parent a079733130
commit 629bc791a6
5 changed files with 900 additions and 70 deletions
+105 -11
View File
@@ -1588,6 +1588,34 @@ class ModelResolverExtension:
clean_name = _os.path.splitext(filename)[0]
local_metadata_fields = (
"author",
"creator",
"license",
"usage_hint",
"usage_tips",
"preview_url",
"metadata_source",
"from_safetensors_header",
"local_metadata_available",
"header_metadata_keys",
"metadata_summary",
"base_model_raw",
"sha256_source",
"hashes",
"hash_status",
)
def add_local_metadata_fields(payload, result):
if not isinstance(payload, dict) or not isinstance(result, dict):
return payload
for key in local_metadata_fields:
value = result.get(key)
if value in (None, "", [], {}):
continue
payload[key] = value
return payload
# Get the file path to hash
file_path = resolved_path if resolved_path else None
file_location = ""
@@ -1625,7 +1653,7 @@ class ModelResolverExtension:
or infer_model_type_from_category(category)
)
return {
response = {
"filename": result.get("filename") or filename,
"category": category,
"file_path": result.get("file_path") or file_path or "",
@@ -1653,6 +1681,8 @@ class ModelResolverExtension:
"sha256": result.get("sha256") or provided_hash,
"size": size_value,
"base_model": result.get("base_model"),
"base_model_source": result.get("base_model_source"),
"base_model_inferred": bool(result.get("base_model_inferred")),
"tags": result.get("tags", []),
"trained_words": result.get("trained_words", []),
"images": result.get("images", []),
@@ -1664,6 +1694,7 @@ class ModelResolverExtension:
"metadata_checked": bool(civitai_checked),
"civitai_checked": bool(civitai_checked),
}
return add_local_metadata_fields(response, result)
def extract_result_sha256(result):
return extract_sha256_from_metadata(result)
@@ -1763,6 +1794,20 @@ class ModelResolverExtension:
pass
return result
def result_has_remote_identity(result):
if not isinstance(result, dict):
return False
return bool(
result.get("url")
or result.get("version_url")
or result.get("download_url")
or result.get("platform_url")
or result.get("repo_id")
or result.get("model_id")
or result.get("version_id")
or result.get("from_metadata")
)
def remote_link_is_marked_dead(item):
if not isinstance(item, dict):
return False
@@ -1911,6 +1956,8 @@ class ModelResolverExtension:
"sha256": result.get("sha256") or provided_hash,
"size": result.get("size"),
"base_model": result.get("base_model"),
"base_model_source": result.get("base_model_source"),
"base_model_inferred": bool(result.get("base_model_inferred")),
"tags": result.get("tags", []),
"trained_words": result.get("trained_words", []),
"images": result.get("images", []),
@@ -1935,6 +1982,7 @@ class ModelResolverExtension:
"path": result.get("path"),
},
}
add_local_metadata_fields(metadata_payload, result)
metadata_path = write_lora_manager_metadata(
file_path,
metadata_payload,
@@ -2011,6 +2059,8 @@ class ModelResolverExtension:
"sha256": result.get("sha256"),
"size": result.get("size"),
"base_model": result.get("base_model"),
"base_model_source": result.get("base_model_source"),
"base_model_inferred": bool(result.get("base_model_inferred")),
"tags": result.get("tags", []),
"trained_words": result.get("trained_words", []),
"images": result.get("images", []),
@@ -2034,6 +2084,7 @@ class ModelResolverExtension:
"imported_from": source_metadata_path,
},
}
add_local_metadata_fields(metadata_payload, result)
metadata_path = write_lora_manager_metadata(
file_path,
metadata_payload,
@@ -2094,15 +2145,24 @@ class ModelResolverExtension:
or result.get("version_url")
or result.get("from_metadata")
or result.get("trained_words")
or result.get("base_model_inferred")
or result.get("local_metadata_available")
or result.get("from_safetensors_header")
) and (
result.get("from_metadata")
or not provided_hash
or result_hash_matches(result)
):
result = prepare_remote_result(result, "civitai")
result_source = (
"local"
if result.get("source") == "local"
and not result_has_remote_identity(result)
else "civitai"
)
result = prepare_remote_result(result, result_source)
metadata_path, metadata_saved = save_remote_metadata(
result,
"civitai",
result_source,
)
return web.json_response(
@@ -2214,25 +2274,59 @@ class ModelResolverExtension:
f"{hf_attempt['label']} metadata lookup error: {e}"
)
# No result found
response = build_info_response(None, civitai_checked=True)
# No remote result found. Keep any useful local safetensors
# header metadata, including base-model fingerprints, model
# titles, descriptions, tags, trigger words, and author data.
local_fallback_result = None
if file_path and _os.path.exists(file_path):
try:
from .core.sources.civitai import get_model_info_for_file
local_candidate = get_model_info_for_file(
file_path,
local_only=True,
)
if (
isinstance(local_candidate, dict)
and (
local_candidate.get("base_model")
or local_candidate.get("local_metadata_available")
or local_candidate.get("from_safetensors_header")
)
):
local_fallback_result = local_candidate
except Exception as local_fallback_error:
self.logger.debug(
f"Local base model inference fallback failed: {local_fallback_error}"
)
response = build_info_response(
local_fallback_result,
civitai_checked=True,
local_payload=bool(local_fallback_result),
)
response["url"] = None
if force_refresh and file_path and _os.path.exists(file_path):
try:
metadata_payload = {
"filename": filename,
"category": category,
"model_name": clean_name,
"name": clean_name,
"model_type": infer_model_type_from_category(category),
"sha256": provided_hash,
"size": _os.path.getsize(file_path),
"model_name": response.get("model_name") or clean_name,
"name": response.get("model_name") or clean_name,
"model_type": response.get("model_type")
or infer_model_type_from_category(category),
"sha256": response.get("sha256") or provided_hash,
"size": response.get("size") or _os.path.getsize(file_path),
"base_model": response.get("base_model"),
"base_model_source": response.get("base_model_source"),
"base_model_inferred": bool(response.get("base_model_inferred")),
"civitai_deleted": True,
"civitai_checked": True,
"remote_metadata_missing": True,
"source": "local",
"details_source": "",
"details_source": response.get("details_source") or "",
}
add_local_metadata_fields(metadata_payload, response)
metadata_path = write_lora_manager_metadata(
file_path,
metadata_payload,
+603 -35
View File
@@ -8,7 +8,8 @@ and directory matching to be shared across all modules.
import os
import json
import hashlib
from typing import Any, Tuple, Optional, Callable, Iterable, List
import re
from typing import Any, Tuple, Optional, Callable, Iterable, List, Dict
_log = None
@@ -277,9 +278,19 @@ _NON_SHA256_HASH_KEY_PARTS = (
"crc32",
"md5",
"sha1",
"ss_new_sd_model_hash",
"ss_sd_model_hash",
"sshs_model_hash",
"sshs_legacy_hash",
"autov3",
"autov1",
"autov2",
)
_BASE_MODEL_ALIAS_CACHE: Optional[List[Tuple[str, str]]] = None
SAFETENSORS_METADATA_TAG_LIMIT = 40
SAFETENSORS_METADATA_KEY_LIMIT = 80
SAFETENSORS_METADATA_TEXT_MAX_CHARS = 20000
SAFETENSORS_METADATA_THUMBNAIL_MAX_CHARS = 512 * 1024
def _normalize_header_sha256(value: Any) -> str:
@@ -306,11 +317,11 @@ def _normalize_header_sha256(value: Any) -> str:
return candidate.lower()
def extract_safetensors_header_sha256(
def read_safetensors_header(
file_path: str,
max_header_size: int = SAFETENSORS_HEADER_MAX_BYTES,
) -> Optional[str]:
"""Return an embedded SHA256 from a safetensors metadata header when present."""
) -> Optional[Dict[str, Any]]:
"""Read and parse a safetensors JSON header without touching tensor payloads."""
if not file_path or not str(file_path).lower().endswith(".safetensors"):
return None
@@ -345,42 +356,599 @@ def extract_safetensors_header_sha256(
return None
header_json = json.loads(header_bytes.decode("utf-8"))
if not isinstance(header_json, dict):
return None
metadata = header_json.get("__metadata__")
if not isinstance(metadata, dict):
return None
for key in (
"modelspec.hash.sha256",
"modelspec.hash_sha256",
"sha256",
"SHA256",
):
sha256 = _normalize_header_sha256(metadata.get(key))
if sha256:
return sha256
for key, value in metadata.items():
key_lower = str(key).lower()
if not (
"sha256" in key_lower
or "hash" in key_lower
or "civitai" in key_lower
):
continue
if any(part in key_lower for part in _NON_SHA256_HASH_KEY_PARTS):
continue
sha256 = _normalize_header_sha256(value)
if sha256:
return sha256
if isinstance(header_json, dict):
return header_json
except Exception as e:
_get_log().debug(f"Could not read safetensors header hash for {file_path}: {e}")
_get_log().debug(f"Could not read safetensors header for {file_path}: {e}")
return None
def extract_safetensors_header_sha256(
file_path: str,
max_header_size: int = SAFETENSORS_HEADER_MAX_BYTES,
) -> Optional[str]:
"""Return an embedded SHA256 from a safetensors metadata header when present."""
header_json = read_safetensors_header(file_path, max_header_size=max_header_size)
if not isinstance(header_json, dict):
return None
metadata = header_json.get("__metadata__")
if not isinstance(metadata, dict):
return None
for key in (
"modelspec.hash.sha256",
"modelspec.hash_sha256",
"sha256",
"SHA256",
):
sha256 = _normalize_header_sha256(metadata.get(key))
if sha256:
return sha256
for key, value in metadata.items():
key_lower = str(key).lower()
if not (
"sha256" in key_lower
or "hash" in key_lower
or "civitai" in key_lower
):
continue
if any(part in key_lower for part in _NON_SHA256_HASH_KEY_PARTS):
continue
sha256 = _normalize_header_sha256(value)
if sha256:
return sha256
return None
def _normalize_base_model_token(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _load_base_model_aliases() -> List[Tuple[str, str]]:
"""Return normalized base-model aliases ordered from most specific first."""
global _BASE_MODEL_ALIAS_CACHE
if _BASE_MODEL_ALIAS_CACHE is not None:
return _BASE_MODEL_ALIAS_CACHE
aliases: List[Tuple[str, str]] = []
try:
data = read_json_safe(os.path.join(METADATA_DIR, "base-models.json"), {})
for entry in data.get("base_models", []) if isinstance(data, dict) else []:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
if not name:
continue
raw_aliases = entry.get("aliases") or []
if isinstance(raw_aliases, str):
raw_aliases = [raw_aliases]
if not isinstance(raw_aliases, (list, tuple, set)):
raw_aliases = []
for raw_alias in [name, *raw_aliases]:
alias = _normalize_base_model_token(raw_alias)
if alias:
aliases.append((alias, name))
except Exception as e:
_get_log().debug(f"Could not load base model aliases: {e}")
aliases.sort(key=lambda item: len(item[0]), reverse=True)
_BASE_MODEL_ALIAS_CACHE = aliases
return aliases
def _canonical_base_model_from_text(value: Any) -> str:
normalized = _normalize_base_model_token(value)
if not normalized:
return ""
for alias, name in _load_base_model_aliases():
if normalized == alias or (len(alias) >= 4 and alias in normalized):
return name
return ""
def infer_safetensors_base_model(
file_path: str,
max_tensor_keys: int = 500,
) -> str:
"""Infer a likely base model from safetensors metadata and tensor names."""
header_json = read_safetensors_header(file_path)
if not isinstance(header_json, dict):
return ""
metadata = header_json.get("__metadata__")
if not isinstance(metadata, dict):
metadata = {}
architecture = str(
metadata.get("modelspec.architecture")
or metadata.get("architecture")
or metadata.get("ss_base_model_version")
or ""
).lower()
if architecture:
if "stable-diffusion-xl" in architecture or "sdxl" in architecture:
return "SDXL 1.0"
if (
"stable-diffusion-v1" in architecture
or "stable-diffusion-1" in architecture
or "runwayml/stable-diffusion-v1-5" in architecture
or "sd1.5" in architecture
or "sd 1.5" in architecture
):
return "SD 1.5"
if "stable-diffusion-v2" in architecture or "sd2" in architecture:
return "SD 2.1"
if (
"stable-diffusion-3" in architecture
or "stable-diffusion-v3" in architecture
or "sd3" in architecture
):
return "SD 3"
if "flux" in architecture:
if "krea" in architecture:
return "Flux.1 Krea"
if "schnell" in architecture:
return "Flux.1 S"
if "kontext" in architecture:
return "Flux.1 Kontext"
return "Flux.1 D"
alias_match = _canonical_base_model_from_text(architecture)
if alias_match:
return alias_match
tensor_keys = [
str(key)
for key in header_json.keys()
if key != "__metadata__"
][:max_tensor_keys]
keys_text = " ".join(tensor_keys)
if "double_blocks.0.img_attn" in keys_text or "img_in.weight" in keys_text:
return "Flux.1 D"
if "joint_blocks.0.x_block" in keys_text:
return "SD 3"
if (
"conditioner.embedders.1.model" in keys_text
or "label_emb.0.0.weight" in keys_text
):
return "SDXL 1.0"
if (
"cond_stage_model.transformer.text_model" in keys_text
or "model.diffusion_model.input_blocks.0.0.weight" in keys_text
):
return "SD 1.5"
return ""
def _parse_safetensors_metadata_value(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return ""
if text[:1] in {"{", "["}:
try:
return json.loads(text)
except Exception:
return text
return text
def _metadata_get(metadata: Dict[str, Any], *keys: str) -> Any:
if not isinstance(metadata, dict):
return None
for key in keys:
if key in metadata:
return metadata.get(key)
lowered = {str(key).lower(): key for key in metadata.keys()}
for key in keys:
original_key = lowered.get(str(key).lower())
if original_key is not None:
return metadata.get(original_key)
return None
def _metadata_text(
metadata: Dict[str, Any],
*keys: str,
max_chars: int = SAFETENSORS_METADATA_TEXT_MAX_CHARS,
) -> str:
value = _parse_safetensors_metadata_value(_metadata_get(metadata, *keys))
if value is None or isinstance(value, (dict, list, tuple, set)):
return ""
text = str(value).strip()
if not text or text.lower() in {"none", "null", "undefined"}:
return ""
if max_chars and len(text) > max_chars:
return text[:max_chars].rstrip()
return text
def _dedupe_strings(values: Iterable[Any], limit: int = 0) -> List[str]:
result: List[str] = []
seen = set()
for value in values:
text = str(value or "").strip()
if not text:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
result.append(text)
if limit and len(result) >= limit:
break
return result
def _metadata_string_list(value: Any, limit: int = SAFETENSORS_METADATA_TAG_LIMIT) -> List[str]:
parsed = _parse_safetensors_metadata_value(value)
items: List[Any] = []
def collect(item: Any) -> None:
item = _parse_safetensors_metadata_value(item)
if item is None:
return
if isinstance(item, dict):
for key in (
"tags",
"model_tags",
"modelTags",
"trained_words",
"trainedWords",
"trigger",
"trigger_phrase",
"values",
"words",
):
if key in item:
collect(item.get(key))
return
for value_item in item.values():
collect(value_item)
return
if isinstance(item, (list, tuple, set)):
for value_item in item:
collect(value_item)
return
if isinstance(item, str):
parts = re.split(r"[\n,|;]+", item)
items.extend(part.strip() for part in parts if part.strip())
return
items.append(item)
collect(parsed)
return _dedupe_strings(items, limit=limit)
def _collect_tag_frequency_tags(value: Any, limit: int = 20) -> List[str]:
parsed = _parse_safetensors_metadata_value(value)
counts: Dict[str, float] = {}
def add_tag(tag: Any, count: Any) -> None:
text = str(tag or "").strip()
if (
not text
or len(text) > 80
or "/" in text
or "\\" in text
or text.lower().endswith((".safetensors", ".ckpt", ".pt"))
):
return
try:
amount = float(count)
except (TypeError, ValueError):
amount = 1.0
counts[text] = counts.get(text, 0.0) + max(amount, 0.0)
def walk(item: Any) -> None:
item = _parse_safetensors_metadata_value(item)
if isinstance(item, dict):
numeric_values = True
for value_item in item.values():
try:
float(value_item)
except (TypeError, ValueError):
numeric_values = False
break
if numeric_values:
for key, value_item in item.items():
add_tag(key, value_item)
return
for value_item in item.values():
walk(value_item)
elif isinstance(item, (list, tuple, set)):
for value_item in item:
walk(value_item)
walk(parsed)
ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0].lower()))
return [tag for tag, _count in ordered[:limit]]
def _limited_metadata_value(value: Any, *, max_chars: int = 1000, max_items: int = 8) -> Any:
parsed = _parse_safetensors_metadata_value(value)
if isinstance(parsed, str):
text = parsed.strip()
return text[:max_chars].rstrip() if len(text) > max_chars else text
if isinstance(parsed, (bool, int, float)) or parsed is None:
return parsed
if isinstance(parsed, dict):
limited: Dict[str, Any] = {}
for index, (key, item_value) in enumerate(parsed.items()):
if index >= max_items:
limited["_truncated"] = True
break
limited[str(key)] = _limited_metadata_value(
item_value,
max_chars=max_chars,
max_items=max_items,
)
return limited
if isinstance(parsed, (list, tuple, set)):
values = list(parsed)
limited_list = [
_limited_metadata_value(item, max_chars=max_chars, max_items=max_items)
for item in values[:max_items]
]
if len(values) > max_items:
limited_list.append({"_truncated": True})
return limited_list
return str(parsed)
def _metadata_preview_url(metadata: Dict[str, Any]) -> str:
value = _metadata_get(
metadata,
"modelspec.thumbnail",
"thumbnail",
"thumbnail_url",
"thumbnailUrl",
"preview_url",
"previewUrl",
"ssmd_cover_images",
)
parsed = _parse_safetensors_metadata_value(value)
def extract(item: Any) -> str:
item = _parse_safetensors_metadata_value(item)
if isinstance(item, str):
text = item.strip()
if not text or len(text) > SAFETENSORS_METADATA_THUMBNAIL_MAX_CHARS:
return ""
if text.startswith(("http://", "https://", "data:image/")):
return text
return ""
if isinstance(item, dict):
for key in ("url", "src", "image", "thumbnail", "preview", "data"):
preview = extract(item.get(key))
if preview:
return preview
if isinstance(item, (list, tuple)):
for value_item in item:
preview = extract(value_item)
if preview:
return preview
return ""
return extract(parsed)
def _metadata_number_or_text(metadata: Dict[str, Any], *keys: str) -> Any:
value = _metadata_text(metadata, *keys, max_chars=100)
if not value:
return None
try:
number = int(value)
if str(number) == value:
return number
except (TypeError, ValueError):
pass
return value
def extract_safetensors_header_metadata(file_path: str) -> Dict[str, Any]:
"""Extract displayable local model metadata from a safetensors header."""
header_json = read_safetensors_header(file_path)
if not isinstance(header_json, dict):
return {}
metadata = header_json.get("__metadata__")
if not isinstance(metadata, dict):
metadata = {}
result: Dict[str, Any] = {}
metadata_keys = sorted(str(key) for key in metadata.keys())
if metadata_keys:
result["header_metadata_keys"] = metadata_keys[:SAFETENSORS_METADATA_KEY_LIMIT]
title = _metadata_text(
metadata,
"modelspec.title",
"ssmd_display_name",
"ss_output_name",
"title",
"name",
"model_name",
max_chars=300,
)
if title:
result["model_name"] = title
description = _metadata_text(
metadata,
"modelspec.description",
"ssmd_description",
"description",
"model_description",
"ss_training_comment",
)
if description:
result["description"] = description
result["model_description"] = description
author = _metadata_text(
metadata,
"modelspec.author",
"ssmd_author",
"author",
"creator",
max_chars=200,
)
if author:
result["author"] = author
result["creator"] = {"username": author, "name": author}
license_value = _metadata_text(metadata, "modelspec.license", "license", max_chars=200)
if license_value:
result["license"] = license_value
usage_hint = _metadata_text(
metadata,
"modelspec.usage_hint",
"usage_hint",
"usage_tips",
max_chars=2000,
)
if usage_hint:
result["usage_hint"] = usage_hint
result["usage_tips"] = usage_hint
explicit_tags: List[str] = []
for key in ("modelspec.tags", "ssmd_tags", "tags", "model_tags"):
explicit_tags.extend(_metadata_string_list(_metadata_get(metadata, key)))
explicit_tags.extend(_collect_tag_frequency_tags(_metadata_get(metadata, "ss_tag_frequency")))
tags = _dedupe_strings(explicit_tags, limit=SAFETENSORS_METADATA_TAG_LIMIT)
if tags:
result["tags"] = tags
trained_words: List[str] = []
for key in (
"modelspec.trigger_phrase",
"modelspec.trigger_phrases",
"trigger_phrase",
"trigger_phrases",
"trigger",
"trained_words",
"trainedWords",
"ssmd_trained_words",
):
trained_words.extend(_metadata_string_list(_metadata_get(metadata, key), limit=20))
trained_words = _dedupe_strings(trained_words, limit=20)
if trained_words:
result["trained_words"] = trained_words
preview_url = _metadata_preview_url(metadata)
if preview_url:
result["preview_url"] = preview_url
result["images"] = [
{
"url": preview_url,
"metadata": {"source": "safetensors_header"},
}
]
clip_skip = _metadata_number_or_text(metadata, "ss_clip_skip", "clip_skip", "clipSkip")
if clip_skip not in (None, ""):
result["clip_skip"] = clip_skip
sha256 = extract_safetensors_header_sha256(file_path)
if sha256:
result["sha256"] = sha256
result["hash"] = sha256
result["hashes"] = {"SHA256": sha256}
result["sha256_source"] = "safetensors_header"
result["hash_status"] = "completed"
base_model = infer_safetensors_base_model(file_path)
if base_model:
result["base_model"] = base_model
result["base_model_source"] = "safetensors_header"
result["base_model_inferred"] = True
raw_base = _metadata_text(
metadata,
"ss_base_model_version",
"modelspec.architecture",
"architecture",
max_chars=300,
)
if raw_base:
result["base_model_raw"] = raw_base
if _metadata_get(metadata, "ss_network_module"):
result["model_type"] = "LORA"
summary_fields = {
"architecture": ("modelspec.architecture", "architecture"),
"base_model_version": ("ss_base_model_version",),
"resolution": ("modelspec.resolution", "ss_resolution"),
"prediction_type": ("modelspec.prediction_type", "prediction_type"),
"date": ("modelspec.date", "date"),
"implementation": ("modelspec.implementation",),
"network_module": ("ss_network_module",),
"network_dim": ("ss_network_dim",),
"network_alpha": ("ss_network_alpha",),
"model_hash": ("sshs_model_hash",),
"legacy_model_hash": ("sshs_legacy_hash",),
"training_model": ("ss_sd_model_name", "ss_new_sd_model_name"),
"vae": ("ss_vae_name",),
"encoder_layer": ("modelspec.encoder_layer",),
"merged_from": ("modelspec.merged_from",),
"merge_recipe": ("sd_merge_recipe",),
"merge_models": ("sd_merge_models",),
}
metadata_summary: Dict[str, Any] = {}
for output_key, source_keys in summary_fields.items():
value = _metadata_get(metadata, *source_keys)
if value in (None, ""):
continue
limited_value = _limited_metadata_value(value)
if limited_value not in (None, "", [], {}):
metadata_summary[output_key] = limited_value
if _metadata_get(metadata, "workflow") not in (None, ""):
metadata_summary["has_embedded_workflow"] = True
if _metadata_get(metadata, "prompt") not in (None, ""):
metadata_summary["has_embedded_prompt"] = True
if metadata_summary:
result["metadata_summary"] = metadata_summary
meaningful_keys = (
"model_name",
"description",
"author",
"license",
"usage_hint",
"tags",
"trained_words",
"preview_url",
"sha256",
"base_model",
"metadata_summary",
)
if any(result.get(key) for key in meaningful_keys):
result["from_safetensors_header"] = True
result["metadata_source"] = "safetensors_header"
result["details_source"] = "safetensors_header"
result["local_metadata_available"] = True
return result
return {}
def calculate_file_sha256(
file_path: str,
chunk_size: int = 131072,
+186 -22
View File
@@ -35,7 +35,14 @@ from ..type_utils import (
extract_trained_words,
)
from ..progress import report_progress, get_progress_reporter
from ..path_utils import calculate_file_sha256, get_filename_from_path, read_json_safe, find_metadata_sidecar_path
from ..path_utils import (
calculate_file_sha256,
extract_safetensors_header_metadata,
find_metadata_sidecar_path,
get_filename_from_path,
infer_safetensors_base_model,
read_json_safe,
)
from ..log_system import create_module_logger
log = create_module_logger(__name__)
@@ -1601,6 +1608,168 @@ def _format_model_location(file_path: str) -> str:
return location
def _has_metadata_value(value: Any) -> bool:
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple, dict, set)):
return bool(value)
return True
def _merge_unique_values(existing: Any, incoming: Any) -> List[Any]:
existing_list = existing if isinstance(existing, list) else ([existing] if existing else [])
incoming_list = incoming if isinstance(incoming, list) else ([incoming] if incoming else [])
result: List[Any] = []
seen = set()
for item in [*existing_list, *incoming_list]:
if item in (None, ""):
continue
if isinstance(item, dict):
key = json.dumps(item, sort_keys=True, default=str)
else:
key = str(item).strip().lower()
if not key or key in seen:
continue
seen.add(key)
result.append(item)
return result
def _apply_safetensors_header_metadata(
result: Dict[str, Any],
file_path: str,
) -> Dict[str, Any]:
if not isinstance(result, dict):
return result
header_metadata = extract_safetensors_header_metadata(file_path)
if not header_metadata:
return result
filename = get_filename_from_path(file_path)
stem = os.path.splitext(filename)[0]
header_name = header_metadata.get("model_name")
current_name = str(result.get("model_name") or "").strip()
if header_name and (
not current_name
or current_name == stem
or current_name == filename
or result.get("source") == "local"
):
result["model_name"] = header_name
for key in (
"description",
"model_description",
"author",
"creator",
"license",
"usage_hint",
"usage_tips",
"preview_url",
"clip_skip",
"sha256",
"hash",
"hashes",
"sha256_source",
"hash_status",
"model_type",
"base_model_raw",
):
if _has_metadata_value(header_metadata.get(key)) and not _has_metadata_value(result.get(key)):
result[key] = header_metadata[key]
for key in ("tags", "trained_words", "images"):
if _has_metadata_value(header_metadata.get(key)):
if _has_metadata_value(result.get(key)):
result[key] = _merge_unique_values(result.get(key), header_metadata.get(key))
else:
result[key] = header_metadata[key]
if header_metadata.get("base_model") and not result.get("base_model"):
result["base_model"] = header_metadata["base_model"]
result["base_model_source"] = header_metadata.get("base_model_source")
result["base_model_inferred"] = bool(header_metadata.get("base_model_inferred"))
for key in (
"metadata_summary",
"header_metadata_keys",
"metadata_source",
"from_safetensors_header",
"local_metadata_available",
):
if _has_metadata_value(header_metadata.get(key)) and not _has_metadata_value(result.get(key)):
result[key] = header_metadata[key]
if (
result.get("source") == "local"
and header_metadata.get("details_source")
):
result["details_source"] = header_metadata["details_source"]
return result
def _apply_inferred_base_model(result: Dict[str, Any], file_path: str) -> Dict[str, Any]:
if not isinstance(result, dict) or result.get("base_model"):
return result
inferred_base_model = infer_safetensors_base_model(file_path)
if inferred_base_model:
result["base_model"] = inferred_base_model
result["base_model_inferred"] = True
result["base_model_source"] = "safetensors_header"
log.info(
f"Inferred base model from safetensors tensor fingerprint: "
f"{inferred_base_model} ({file_path})"
)
return result
def _has_local_header_details(result: Optional[Dict[str, Any]]) -> bool:
if not isinstance(result, dict):
return False
return bool(
result.get("local_metadata_available")
or result.get("from_safetensors_header")
or result.get("base_model_inferred")
or result.get("metadata_summary")
)
def _build_local_model_info(
file_path: str,
*,
local_only: bool = False,
require_inferred: bool = False,
) -> Optional[Dict[str, Any]]:
filename = get_filename_from_path(file_path)
stem = os.path.splitext(filename)[0]
result = {
"source": "local",
"details_source": "local",
"filename": filename,
"file_path": file_path,
"resolved_path": file_path,
"model_name": stem,
"model_type": "",
"location": _format_model_location(file_path),
"from_metadata": False,
"local_only": local_only,
}
try:
result["size"] = os.path.getsize(file_path)
except Exception:
pass
_apply_safetensors_header_metadata(result, file_path)
_apply_inferred_base_model(result, file_path)
if require_inferred and not _has_local_header_details(result):
return None
return result
def _metadata_to_model_info(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
@@ -1805,6 +1974,8 @@ def get_model_info_for_file(
result["file_path"] = file_path
result["metadata_path"] = metadata_path
result["location"] = _format_model_location(file_path)
_apply_safetensors_header_metadata(result, file_path)
_apply_inferred_base_model(result, file_path)
if not result.get("size"):
try:
result["size"] = os.path.getsize(file_path)
@@ -1822,38 +1993,31 @@ def get_model_info_for_file(
log.info(f"Files in {directory}: {metadata_files}")
if local_only:
filename = get_filename_from_path(file_path)
stem = os.path.splitext(filename)[0]
result = {
"source": "local",
"filename": filename,
"file_path": file_path,
"resolved_path": file_path,
"model_name": stem,
"model_type": "",
"location": _format_model_location(file_path),
"from_metadata": False,
"local_only": True,
}
try:
result["size"] = os.path.getsize(file_path)
except Exception:
pass
return result
return _build_local_model_info(file_path, local_only=True)
# If no metadata file, compute hash and look up on CivitAI
file_hash = calculate_file_sha256(file_path)
if not file_hash:
return None
return _build_local_model_info(file_path, require_inferred=True)
try:
result = get_model_info_by_hash(file_hash, api_key)
except Exception as e:
log.warning(
f"CivitAI hash lookup failed; trying local tensor fingerprint fallback: {e}"
)
return _build_local_model_info(file_path, require_inferred=True)
result = get_model_info_by_hash(file_hash, api_key)
if result:
result["file_path"] = file_path
result["location"] = _format_model_location(file_path)
_apply_safetensors_header_metadata(result, file_path)
_apply_inferred_base_model(result, file_path)
if not result.get("size"):
try:
result["size"] = os.path.getsize(file_path)
except Exception:
pass
return result
return result
return _build_local_model_info(file_path, require_inferred=True)
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -3,15 +3,19 @@ name = "Comfyui-Model-Resolver"
description = "ComfyUI extension for automatically resolving missing models in loaded workflows. It features intelligent local fuzzy matching, direct cloud downloads, background download tracking, and automated in-place workflow updating."
version = "1.0.0"
license = { text = "MIT License" }
requires-python = ">=3.10"
dynamic = ["dependencies"]
[project.urls]
Repository = "https://github.com/Azornes/Comfyui-Model-Resolver"
Documentation = "https://github.com/Azornes/Comfyui-Model-Resolver/wiki"
"Bug Tracker" = "https://github.com/Azornes/Comfyui-Model-Resolver/issues"
[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt"] }
[tool.comfy]
PublisherId = "azornes"
DisplayName = "Comfyui-Model-Resolver"
DisplayName = "Comfyui Model Resolver"
Icon = ""
requires-comfyui = ">=0.26.0"