225 lines
6.0 KiB
Python
225 lines
6.0 KiB
Python
"""
|
|
Workflow fingerprinting for Graph ↔ Asset bridge.
|
|
|
|
Generates stable hashes of ComfyUI workflows for matching assets to graphs.
|
|
Canonicalizes workflow JSON to ensure consistent hashes regardless of:
|
|
- Node IDs (may change on copy/paste)
|
|
- Node positions (UI layout irrelevant)
|
|
- Execution order (non-deterministic)
|
|
- UI properties (colors, minimized state, etc.)
|
|
"""
|
|
|
|
import json
|
|
import hashlib
|
|
from typing import Any, Dict, Set
|
|
|
|
|
|
# Fields to exclude from canonicalization (volatile/UI-only)
|
|
EXCLUDE_KEYS: Set[str] = {
|
|
"id", # Node IDs may change
|
|
"pos", # Position irrelevant
|
|
"size", # Size irrelevant
|
|
"order", # Execution order may vary
|
|
"mode", # Mode (active/muted) irrelevant
|
|
"properties", # UI properties
|
|
"color", # Node color
|
|
"bgcolor", # Background color
|
|
"flags", # UI flags
|
|
"widgets_values", # Widget positions (use inputs instead)
|
|
}
|
|
|
|
|
|
def deep_copy_without(obj: Any, exclude_keys: Set[str]) -> Any:
|
|
"""
|
|
Deep copy dict/list, excluding specified keys.
|
|
|
|
Args:
|
|
obj: Object to copy (dict, list, or primitive)
|
|
exclude_keys: Set of keys to exclude from dicts
|
|
|
|
Returns:
|
|
Deep copy with excluded keys removed
|
|
"""
|
|
if isinstance(obj, dict):
|
|
return {
|
|
k: deep_copy_without(v, exclude_keys)
|
|
for k, v in obj.items()
|
|
if k not in exclude_keys
|
|
}
|
|
elif isinstance(obj, list):
|
|
return [deep_copy_without(item, exclude_keys) for item in obj]
|
|
else:
|
|
return obj
|
|
|
|
|
|
def canonicalize_workflow(workflow: Dict[str, Any]) -> str:
|
|
"""
|
|
Convert workflow to canonical JSON for hashing.
|
|
|
|
Removes volatile fields and sorts keys recursively.
|
|
|
|
Args:
|
|
workflow: ComfyUI workflow dict
|
|
|
|
Returns:
|
|
Canonical JSON string (sorted, compact)
|
|
"""
|
|
# Remove volatile fields
|
|
clean = deep_copy_without(workflow, EXCLUDE_KEYS)
|
|
|
|
# Sort all dict keys recursively
|
|
canonical = json.dumps(clean, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
|
return canonical
|
|
|
|
|
|
def hash_workflow(workflow: Dict[str, Any]) -> str:
|
|
"""
|
|
Generate stable SHA1 hash of workflow.
|
|
|
|
Args:
|
|
workflow: ComfyUI workflow dict
|
|
|
|
Returns:
|
|
40-character hex SHA1 hash or empty string
|
|
"""
|
|
if not workflow:
|
|
return ""
|
|
|
|
try:
|
|
canonical = canonicalize_workflow(workflow)
|
|
# Check if canonical is empty (avoids hash of empty string)
|
|
if not canonical or canonical in ("{}", "[]", "null"):
|
|
return ""
|
|
|
|
return hashlib.sha1(canonical.encode('utf-8')).hexdigest()
|
|
except Exception as e:
|
|
# Log error but don't crash indexing
|
|
from .logger import get_logger
|
|
log = get_logger(__name__)
|
|
log.warning(f"[Majoor] Failed to hash workflow: {e}")
|
|
return ""
|
|
|
|
|
|
def hash_workflow_robust(workflow: Any) -> str:
|
|
"""
|
|
Robust workflow hashing with fallback for invalid data.
|
|
|
|
Handles:
|
|
- None/empty workflows
|
|
- Non-dict workflows
|
|
- Malformed JSON
|
|
|
|
Args:
|
|
workflow: ComfyUI workflow (dict, str, or None)
|
|
|
|
Returns:
|
|
Workflow hash or empty string on failure
|
|
"""
|
|
if not workflow:
|
|
return ""
|
|
|
|
# If workflow is a string, try to parse it
|
|
if isinstance(workflow, str):
|
|
try:
|
|
workflow = json.loads(workflow)
|
|
except:
|
|
return ""
|
|
|
|
# Ensure it's a dict
|
|
if not isinstance(workflow, dict):
|
|
return ""
|
|
|
|
return hash_workflow(workflow)
|
|
|
|
|
|
# ===== Client-side compatible hashing (for frontend) =====
|
|
|
|
def get_hash_algorithm_info() -> Dict[str, str]:
|
|
"""
|
|
Get information about the hashing algorithm for client-side implementation.
|
|
|
|
Returns:
|
|
Dict with keys: algorithm, exclude_keys, description
|
|
"""
|
|
return {
|
|
"algorithm": "SHA1",
|
|
"exclude_keys": list(EXCLUDE_KEYS),
|
|
"description": "Canonical JSON (sorted keys, no whitespace) -> SHA1 hex",
|
|
}
|
|
|
|
|
|
# ===== Testing/Validation =====
|
|
|
|
def _test_workflow_hashing():
|
|
"""Self-test for workflow hashing consistency."""
|
|
# Test workflow
|
|
workflow1 = {
|
|
"nodes": [
|
|
{
|
|
"id": 1,
|
|
"type": "KSampler",
|
|
"pos": [100, 200],
|
|
"size": [300, 400],
|
|
"inputs": {"seed": 42, "steps": 20},
|
|
"properties": {"note": "test"},
|
|
}
|
|
],
|
|
"links": [[1, 0, 2, 0, "LATENT"]],
|
|
}
|
|
|
|
# Same workflow, different IDs/positions
|
|
workflow2 = {
|
|
"nodes": [
|
|
{
|
|
"id": 999, # Different ID
|
|
"type": "KSampler",
|
|
"pos": [500, 600], # Different position
|
|
"size": [100, 100], # Different size
|
|
"inputs": {"seed": 42, "steps": 20}, # Same inputs
|
|
"properties": {"note": "different"}, # Different properties
|
|
}
|
|
],
|
|
"links": [[1, 0, 2, 0, "LATENT"]],
|
|
}
|
|
|
|
# Different workflow (different inputs)
|
|
workflow3 = {
|
|
"nodes": [
|
|
{
|
|
"id": 1,
|
|
"type": "KSampler",
|
|
"pos": [100, 200],
|
|
"inputs": {"seed": 99, "steps": 30}, # Different
|
|
}
|
|
],
|
|
"links": [[1, 0, 2, 0, "LATENT"]],
|
|
}
|
|
|
|
hash1 = hash_workflow(workflow1)
|
|
hash2 = hash_workflow(workflow2)
|
|
hash3 = hash_workflow(workflow3)
|
|
|
|
# workflow1 and workflow2 should have same hash
|
|
assert hash1 == hash2, f"Hash mismatch: {hash1} != {hash2}"
|
|
|
|
# workflow1 and workflow3 should have different hashes
|
|
assert hash1 != hash3, f"Hash collision: {hash1} == {hash3}"
|
|
|
|
# Hash should be 40 characters (SHA1)
|
|
assert len(hash1) == 40, f"Invalid hash length: {len(hash1)}"
|
|
|
|
print("[Majoor] Workflow hashing tests passed")
|
|
|
|
|
|
# Run self-test on import
|
|
if __name__ != "__main__":
|
|
try:
|
|
_test_workflow_hashing()
|
|
except Exception as e:
|
|
try:
|
|
from .logger import get_logger
|
|
log = get_logger(__name__)
|
|
log.error(f"[Majoor] Workflow hashing self-test failed: {e}")
|
|
except:
|
|
pass # Standalone mode
|