Files
Ewald 8efd481008 fix: resolve node patching bugs, label generation issues, and backend stability
- Fix save node patching: early exit on matching targets, subgraph traversal, tighten isSaveLikeNode filters
- Fix project target prefix handling: dual prefix and filename_prefix support across preview and create_custom_out
- Optimize UI: 300ms debounce on target name input, patch nodes preview caching, clean asset name extraction
- Backend stability: audit logger fd cleanup, double close fix, registry locks, rate limiting cache eviction
- Model fixer: properly clear node errors and refresh combo lists in ComfyUI
2026-09-17 10:43:07 +02:00

374 lines
13 KiB
Python

"""
Registre communautaire de modèles.
Base de données locale partagée d'URLs vérifiées de modèles.
"""
from __future__ import annotations
import json
import hashlib
import logging
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from threading import Lock
logger = logging.getLogger(__name__)
class ModelRegistry:
"""
Registre communautaire de modèles.
Structure du fichier JSON:
{
"schema": 1,
"updated_at": "2026-01-22T10:30:00",
"models": {
"model_hash": {
"names": ["stable-diffusion-v1-5", "sd-v1-5", "sd15"],
"aliases": [...],
"sources": [
{
"url": "https://...",
"platform": "huggingface",
"filename": "model.safetensors",
"verified": true,
"verified_at": "2026-01-20",
"sha256": "abc123...",
"upvotes": 10,
"downvotes": 0,
"last_checked": "2026-01-22",
"size_mb": 4096
}
],
"metadata": {
"type": "checkpoint",
"base_model": "sd15",
"tags": ["realistic", "general"],
"description": "..."
}
}
}
}
"""
def __init__(self, registry_path: Path = None):
if registry_path is None:
from .project_store import safe_under_output
registry_path = safe_under_output("PROJECTS/_INDEX/model_registry.json")
self.registry_path = registry_path
self.lock = Lock()
self.data = self._load()
def _load(self) -> Dict[str, Any]:
"""Load the registry from disk."""
if not self.registry_path.exists():
return self._create_empty()
try:
# Guard against unexpectedly large files before reading.
try:
file_size = self.registry_path.stat().st_size
if file_size > 10 * 1024 * 1024: # 10 MB
logger.error("Registry file too large (%d bytes), resetting", file_size)
return self._create_empty()
except OSError:
pass
with open(self.registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get("schema") != 1:
logger.warning("Registry schema mismatch, resetting")
return self._create_empty()
return data
except Exception as e:
logger.error("Failed to load registry: %s", e)
return self._create_empty()
def _create_empty(self) -> Dict[str, Any]:
"""Create an empty registry."""
return {
"schema": 1,
"updated_at": datetime.now().isoformat(),
"models": {}
}
def _save(self):
"""Save the registry to disk atomically using a UUID-based temp file."""
try:
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
# Use a UUID-based temp name to prevent race conditions on concurrent saves.
temp_path = self.registry_path.with_name(
f"{self.registry_path.name}.{uuid.uuid4().hex}.tmp"
)
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, indent=2, ensure_ascii=False)
temp_path.replace(self.registry_path)
logger.debug("Registry saved to %s", self.registry_path)
except Exception as e:
logger.error("Failed to save registry: %s", e)
def _normalize_name(self, name: str) -> str:
"""Normalize a model name for search (lowercase, no extensions or separators)."""
import re
s = name.lower().strip()
s = re.sub(r'\.(safetensors|ckpt|pt|pth|bin)$', '', s)
s = re.sub(r'[-_\.]+', ' ', s)
s = re.sub(r'[^a-z0-9\s]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def _compute_model_hash(self, normalized_name: str) -> str:
"""Compute a stable hash key for a model name."""
return hashlib.sha256(normalized_name.encode('utf-8')).hexdigest()[:16]
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""
Search the community registry for models matching the query.
Args:
query: Model name to search for
limit: Maximum number of results
Returns:
List of sources with match_score >= 80
"""
results = []
query_norm = self._normalize_name(query)
with self.lock:
for model_hash, model_data in self.data.get("models", {}).items():
# Chercher dans les noms et aliases
names = model_data.get("names", [])
aliases = model_data.get("aliases", [])
all_names = names + aliases
# Score de matching
from .model_search_api import calculate_match_score
best_score = 0
best_match_name = ""
for name in all_names:
score, _ = calculate_match_score(query, name)
if score > best_score:
best_score = score
best_match_name = name
if best_score < 80:
continue
# Récupérer les sources vérifiées et valides
sources = model_data.get("sources", [])
verified_sources = [
s for s in sources
if s.get("verified") and s.get("upvotes", 0) > s.get("downvotes", 0)
]
if not verified_sources:
# Essayer sources non vérifiées mais avec upvotes
verified_sources = [
s for s in sources
if s.get("upvotes", 0) > s.get("downvotes", 0)
]
if not verified_sources:
continue
# Trier par score (upvotes - downvotes)
verified_sources.sort(
key=lambda s: s.get("upvotes", 0) - s.get("downvotes", 0),
reverse=True
)
# Prendre la meilleure source
best_source = verified_sources[0]
metadata = model_data.get("metadata", {})
results.append({
"platform": "community_registry",
"name": best_match_name,
"filename": best_source.get("filename", ""),
"url": best_source["url"],
"page_url": best_source.get("page_url", best_source["url"]),
"type": metadata.get("type", "checkpoints"),
"version": "verified",
"size_mb": best_source.get("size_mb", 0),
"sha256": best_source.get("sha256"),
"match_score": best_score + 5, # Bonus pour source communautaire
"match_level": f"Community verified ({best_source.get('upvotes', 0)}↑)",
"upvotes": best_source.get("upvotes", 0),
"verified_at": best_source.get("verified_at"),
})
results.sort(key=lambda x: x.get("match_score", 0), reverse=True)
return results[:limit]
def add_source(
self,
name: str,
url: str,
platform: str,
filename: str = "",
sha256: str = "",
model_type: str = "checkpoints",
size_mb: int = 0,
verified: bool = False
) -> bool:
"""
Add a new download source for a model.
Args:
name: Model name
url: Download URL
platform: Hosting platform (huggingface, civitai, etc.)
filename: Filename on disk
sha256: SHA-256 hash (optional)
model_type: Model type (checkpoints, loras, …)
size_mb: Size in MB
verified: Whether the source has been verified
Returns:
True if the source was added successfully
"""
normalized_name = self._normalize_name(name)
model_hash = self._compute_model_hash(normalized_name)
with self.lock:
models = self.data.setdefault("models", {})
if model_hash not in models:
models[model_hash] = {
"names": [name],
"aliases": [],
"sources": [],
"metadata": {
"type": model_type,
"tags": [],
"description": ""
}
}
model_data = models[model_hash]
# Ajouter le nom s'il n'existe pas
if name not in model_data["names"]:
model_data["names"].append(name)
# Vérifier si l'URL existe déjà
existing_urls = [s.get("url") for s in model_data.get("sources", [])]
if url in existing_urls:
logger.info(f"Source {url} already exists for {name}")
return False
# Ajouter la nouvelle source
source = {
"url": url,
"platform": platform,
"filename": filename,
"verified": verified,
"verified_at": datetime.now().isoformat() if verified else None,
"sha256": sha256,
"size_mb": size_mb,
"upvotes": 1 if verified else 0,
"downvotes": 0,
"last_checked": datetime.now().isoformat(),
}
model_data["sources"].append(source)
self.data["updated_at"] = datetime.now().isoformat()
self._save()
logger.info(f"Added source for {name}: {url}")
return True
def add_alias(self, name: str, alias: str) -> bool:
"""Add an alias for a model."""
normalized_name = self._normalize_name(name)
model_hash = self._compute_model_hash(normalized_name)
with self.lock:
models = self.data.get("models", {})
if model_hash in models:
aliases = models[model_hash].setdefault("aliases", [])
if alias not in aliases:
aliases.append(alias)
self._save()
return True
return False
def upvote_source(self, name: str, url: str) -> bool:
"""Upvote a source (mark as helpful)."""
normalized_name = self._normalize_name(name)
model_hash = self._compute_model_hash(normalized_name)
with self.lock:
models = self.data.get("models", {})
if model_hash in models:
for source in models[model_hash].get("sources", []):
if source.get("url") == url:
source["upvotes"] = source.get("upvotes", 0) + 1
source["last_checked"] = datetime.now().isoformat()
self._save()
return True
return False
def downvote_source(self, name: str, url: str) -> bool:
"""Downvote a source (broken link, wrong file, etc.)."""
normalized_name = self._normalize_name(name)
model_hash = self._compute_model_hash(normalized_name)
with self.lock:
models = self.data.get("models", {})
if model_hash in models:
for source in models[model_hash].get("sources", []):
if source.get("url") == url:
source["downvotes"] = source.get("downvotes", 0) + 1
source["last_checked"] = datetime.now().isoformat()
self._save()
return True
return False
def get_stats(self) -> Dict[str, Any]:
"""Return statistics about the registry."""
with self.lock:
models = self.data.get("models", {})
total_sources = sum(
len(m.get("sources", [])) for m in models.values()
)
verified_sources = sum(
sum(1 for s in m.get("sources", []) if s.get("verified"))
for m in models.values()
)
return {
"total_models": len(models),
"total_sources": total_sources,
"verified_sources": verified_sources,
"updated_at": self.data.get("updated_at", "")
}
# Global singleton with double-checked locking to prevent duplicate init under concurrency.
_registry: Optional[ModelRegistry] = None
_registry_lock = Lock()
def get_model_registry() -> ModelRegistry:
"""Return the singleton ModelRegistry instance (thread-safe)."""
global _registry
if _registry is None:
with _registry_lock:
if _registry is None:
_registry = ModelRegistry()
return _registry