Files

220 lines
7.7 KiB
Python

"""Pure Workflow Recipe graph validation and fingerprinting."""
import hashlib
import json
import re
from .recipe_constants import *
def _normalise_workflow_key(value):
return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
def _volatile_widget_indexes(node):
"""Known serialized LiteGraph widget positions for native sampler seeds."""
node_type = _node_type(node).lower()
if node_type == "ksampler":
return (0,)
if node_type == "ksampleradvanced":
return (1,)
return ()
def _clean_volatile_params(value):
"""Produce a stable graph view while retaining all generation-defining data."""
if isinstance(value, list):
return [_clean_volatile_params(item) for item in value]
if not isinstance(value, dict):
return value
cleaned = {}
for key, nested_value in value.items():
if _normalise_workflow_key(key) in VOLATILE_WORKFLOW_KEYS:
continue
cleaned[str(key)] = _clean_volatile_params(nested_value)
widgets = cleaned.get("widgets_values")
if isinstance(widgets, list):
for index in _volatile_widget_indexes(value):
if 0 <= index < len(widgets):
widgets[index] = "__anomalous_volatile_seed__"
return cleaned
def _workflow_fingerprint(workflow):
"""Hash the workflow structure after removing known run-volatile values."""
canonical = json.dumps(
_clean_volatile_params(workflow),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-structural-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _parameter_signature(workflow):
"""Hash node types and parameter values for UI workflows or API prompts."""
if not isinstance(workflow, dict):
return {"algorithm": "sha256-params-v1", "value": ""}
nodes = workflow.get("nodes")
is_ui_workflow = isinstance(nodes, list)
if not is_ui_workflow:
nodes = list(workflow.values())
cleaned_nodes = []
for node in nodes:
if not isinstance(node, dict):
continue
if is_ui_workflow:
parameters = {"widgets": _clean_volatile_params(node.get("widgets_values") or [])}
widgets = parameters["widgets"]
if isinstance(widgets, list):
for index in _volatile_widget_indexes(node):
if 0 <= index < len(widgets):
widgets[index] = "__anomalous_volatile_seed__"
else:
parameters = {"inputs": _clean_volatile_params(node.get("inputs") or {})}
cleaned_nodes.append({
"type": _node_type(node),
**parameters,
})
cleaned_nodes.sort(key=lambda x: (x["type"], json.dumps(x, sort_keys=True, ensure_ascii=False)))
canonical = json.dumps(
cleaned_nodes,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-params-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _workflow_node_types(workflow):
"""Extract only node class names from either UI workflow or API prompt data."""
if not isinstance(workflow, dict):
return []
nodes = workflow.get("nodes")
if isinstance(nodes, list):
values = nodes
else:
values = workflow.values()
result = []
for node in values:
if not isinstance(node, dict):
continue
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
if node_type:
result.append(node_type)
return sorted(result, key=lambda value: value.casefold())
def _workflow_node_signature(workflow):
"""Stable node-composition signature used for tolerant result discovery."""
canonical = json.dumps(
_workflow_node_types(workflow),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-node-types-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _node_type(node):
return str(node.get("type") or node.get("class_type") or "").strip()
def _node_title(node):
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
return str(meta.get("title") or node.get("title") or _node_type(node) or "Unknown node").strip()
def _widget_values(node):
values = node.get("widgets_values")
return values if isinstance(values, list) else []
def _workflow_node_key(value):
if isinstance(value, bool) or not isinstance(value, (int, str)):
raise ValueError("Invalid workflow node id")
value = str(value).strip()
if not value:
raise ValueError("Invalid workflow node id")
return value
def _workflow_link_records(workflow):
links = workflow.get("links", [])
if links is None:
return []
if isinstance(links, list):
return links
if isinstance(links, dict):
return list(links.values())
raise ValueError("Invalid workflow links")
def _validate_workflow(workflow):
"""Reject malformed graph topology before it reaches user recipe storage."""
if not isinstance(workflow, dict):
raise ValueError("Invalid recipe workflow")
nodes = workflow.get("nodes")
if not isinstance(nodes, list) or len(nodes) > MAX_WORKFLOW_NODES:
raise ValueError("Invalid workflow nodes")
node_ids = set()
for node in nodes:
if not isinstance(node, dict):
raise ValueError("Invalid workflow node")
node_key = _workflow_node_key(node.get("id"))
if node_key in node_ids:
raise ValueError("Duplicate workflow node id")
node_ids.add(node_key)
values = node.get("widgets_values")
if values is not None and (not isinstance(values, list) or len(values) > MAX_WIDGET_VALUES_PER_NODE):
raise ValueError("Invalid workflow widget values")
groups = workflow.get("groups", [])
if groups is not None and (not isinstance(groups, list) or len(groups) > MAX_WORKFLOW_GROUPS):
raise ValueError("Invalid workflow groups")
links = _workflow_link_records(workflow)
if len(links) > MAX_WORKFLOW_LINKS:
raise ValueError("Too many workflow links")
for link in links:
if isinstance(link, list) and len(link) >= 5:
origin_id, target_id = link[1], link[3]
elif isinstance(link, dict):
origin_id, target_id = link.get("origin_id"), link.get("target_id")
else:
raise ValueError("Invalid workflow link")
if _workflow_node_key(origin_id) not in node_ids or _workflow_node_key(target_id) not in node_ids:
raise ValueError("Dangling workflow link")
def _recipe_receipt(recipe, filename):
"""Return a small, user-visible confirmation for the accepted graph."""
workflow = recipe.get("workflow") if isinstance(recipe, dict) else {}
params = recipe.get("params") if isinstance(recipe, dict) else {}
return {
"filename": filename,
"node_count": len(workflow.get("nodes", [])) if isinstance(workflow, dict) else 0,
"link_count": len(_workflow_link_records(workflow)) if isinstance(workflow, dict) else 0,
"group_count": len(workflow.get("groups", [])) if isinstance(workflow.get("groups", []), list) else 0,
"parameter_node_count": len(params.get("nodes", [])) if isinstance(params, dict) and isinstance(params.get("nodes", []), list) else 0,
"pinned_count": len(params.get("pinned", [])) if isinstance(params, dict) and isinstance(params.get("pinned", []), list) else 0,
"workflow_fingerprint": recipe.get("workflow_fingerprint") if isinstance(recipe, dict) else None,
}