Files
Setmaster fb28135b82 Add file size display for models
- Show model file sizes (GB/MB) in the sidebar next to last used time
- Use ComfyUI's folder_paths for reliable model path resolution
- Support all configured model locations including extra_model_paths
- Handle GGUF models that can be in unet or clip folders
- Update README with new features
2026-01-15 12:27:04 -05:00

316 lines
9.9 KiB
Python

"""
ModelPulse - Core Tracking Logic
Extracts models from prompts and records usage statistics.
"""
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from .model_types import MODEL_LOADERS, LOADER_PATTERNS, CATEGORY_FOLDERS
from .storage import load_data, save_data, cleanup_old_usage_logs
# Try to import ComfyUI's folder_paths for proper model path resolution
try:
import folder_paths
HAS_FOLDER_PATHS = True
except ImportError:
HAS_FOLDER_PATHS = False
def get_model_file_size(category: str, name: str) -> int | None:
"""
Get the file size of a model in bytes.
Uses ComfyUI's folder_paths to find models in all configured locations.
Args:
category: The model category (e.g., 'checkpoint', 'lora')
name: The model filename
Returns:
File size in bytes, or None if file not found
"""
if not HAS_FOLDER_PATHS:
return None
# Map our categories to ComfyUI's folder_paths keys
folder_key = CATEGORY_FOLDERS.get(category)
# For GGUF, try multiple folders since models can be unet or clip
if category == "gguf":
folder_keys = ["unet", "diffusion_models", "clip"]
elif category == "unet":
folder_keys = ["unet", "diffusion_models"]
elif folder_key:
folder_keys = [folder_key]
else:
return None
for key in folder_keys:
try:
full_path = folder_paths.get_full_path(key, name)
if full_path:
return Path(full_path).stat().st_size
except Exception:
continue
return None
class ModelTracker:
"""Tracks model usage across ComfyUI workflow executions."""
def __init__(self):
self._data: dict[str, Any] | None = None
@property
def data(self) -> dict[str, Any]:
"""Lazy-load data from storage."""
if self._data is None:
self._data = load_data()
return self._data
def _save(self) -> None:
"""Save current data to storage."""
if self._data is not None:
save_data(self._data)
def extract_models_from_prompt(self, prompt: dict[str, Any]) -> list[dict[str, str]]:
"""
Extract all model references from a ComfyUI prompt.
Args:
prompt: The prompt dictionary from ComfyUI execution
Returns:
List of dicts with 'category', 'name', and 'model_id' keys
"""
models = []
seen = set() # Avoid duplicates within same prompt
for node_id, node_data in prompt.items():
class_type = node_data.get("class_type", "")
inputs = node_data.get("inputs", {})
# Check standard loaders first
if class_type in MODEL_LOADERS:
category, input_keys = MODEL_LOADERS[class_type]
extracted = self._extract_from_inputs(category, input_keys, inputs)
for model in extracted:
if model["model_id"] not in seen:
seen.add(model["model_id"])
models.append(model)
# Check pattern-based loaders for custom nodes
elif "Loader" in class_type:
extracted = self._extract_from_patterns(class_type, inputs)
for model in extracted:
if model["model_id"] not in seen:
seen.add(model["model_id"])
models.append(model)
return models
def _extract_from_inputs(
self, category: str, input_keys: str | list[str], inputs: dict[str, Any]
) -> list[dict[str, str]]:
"""Extract model names from node inputs."""
models = []
if isinstance(input_keys, str):
input_keys = [input_keys]
for key in input_keys:
value = inputs.get(key)
if value and isinstance(value, str):
model_id = f"{category}/{value}"
models.append({
"category": category,
"name": value,
"model_id": model_id,
})
return models
def _extract_from_patterns(
self, class_type: str, inputs: dict[str, Any]
) -> list[dict[str, str]]:
"""Extract models using pattern matching for custom node loaders."""
models = []
for pattern, category, possible_keys in LOADER_PATTERNS:
if pattern.lower() in class_type.lower():
for key in possible_keys:
value = inputs.get(key)
if value and isinstance(value, str):
model_id = f"{category}/{value}"
models.append({
"category": category,
"name": value,
"model_id": model_id,
})
break # Found a match for this pattern
break # Only match first pattern
return models
def record_usage(self, models: list[dict[str, str]]) -> None:
"""
Record usage for a list of models.
Args:
models: List of model dicts from extract_models_from_prompt
"""
if not models:
return
now = datetime.utcnow()
now_iso = now.isoformat() + "Z"
today = now.date().isoformat()
for model in models:
model_id = model["model_id"]
if model_id not in self.data["models"]:
# New model - create entry
self.data["models"][model_id] = {
"category": model["category"],
"name": model["name"],
"path": model_id, # We don't have full path, use model_id
"first_used": now_iso,
"last_used": now_iso,
"usage_count": 0,
"usage_log": [],
}
model_data = self.data["models"][model_id]
# Update timestamps and count
model_data["last_used"] = now_iso
model_data["usage_count"] += 1
# Update daily usage log
usage_log = model_data["usage_log"]
if usage_log and usage_log[-1]["date"] == today:
usage_log[-1]["count"] += 1
else:
usage_log.append({"date": today, "count": 1})
self._save()
def extract_and_record_models(self, prompt: dict[str, Any]) -> None:
"""
Extract models from prompt and record their usage.
This is the main entry point called from the PromptExecutor hook.
"""
models = self.extract_models_from_prompt(prompt)
self.record_usage(models)
def get_usage_data(
self,
timeframe: str = "all",
sort_by: str = "last_used",
category: str | None = None,
) -> dict[str, Any]:
"""
Get filtered and sorted usage data.
Args:
timeframe: "all", "month", or "week"
sort_by: "last_used", "usage_count", or "name"
category: Optional category filter
Returns:
Dict with 'models' list and 'metadata'
"""
models_list = []
now = datetime.utcnow()
# Calculate timeframe boundaries
if timeframe == "week":
cutoff = now - timedelta(days=7)
elif timeframe == "month":
cutoff = now - timedelta(days=30)
else:
cutoff = None
cutoff_date = cutoff.date().isoformat() if cutoff else None
for model_id, model_data in self.data["models"].items():
# Filter by category if specified
if category and model_data["category"] != category:
continue
# Calculate timeframe-specific usage count
if cutoff_date:
timeframe_count = sum(
entry["count"]
for entry in model_data.get("usage_log", [])
if entry["date"] >= cutoff_date
)
else:
timeframe_count = model_data["usage_count"]
# Get file size
file_size = get_model_file_size(model_data["category"], model_data["name"])
models_list.append({
"model_id": model_id,
"category": model_data["category"],
"name": model_data["name"],
"first_used": model_data["first_used"],
"last_used": model_data["last_used"],
"usage_count": model_data["usage_count"],
"timeframe_count": timeframe_count,
"file_size": file_size,
})
# Sort the results
if sort_by == "usage_count":
models_list.sort(key=lambda x: x["timeframe_count"], reverse=True)
elif sort_by == "name":
models_list.sort(key=lambda x: x["name"].lower())
else: # last_used (default)
models_list.sort(key=lambda x: x["last_used"], reverse=True)
return {
"models": models_list,
"metadata": self.data["metadata"],
"timeframe": timeframe,
"sort_by": sort_by,
}
def get_model_detail(self, model_id: str) -> dict[str, Any] | None:
"""
Get detailed usage data for a specific model.
Args:
model_id: The model identifier (category/filename)
Returns:
Model data dict or None if not found
"""
model_data = self.data["models"].get(model_id)
if not model_data:
return None
return {
"model_id": model_id,
**model_data,
}
def reset(self) -> None:
"""Reset all tracking data."""
from .storage import create_empty_data
self._data = create_empty_data()
self._save()
def cleanup(self, max_days: int = 365) -> None:
"""Clean up old usage logs."""
self._data = cleanup_old_usage_logs(self.data, max_days)
self._save()