208 lines
6.4 KiB
Python
208 lines
6.4 KiB
Python
"""Helpers for recognizing and normalizing ComfyUI workflow formats."""
|
|
|
|
from collections.abc import Mapping
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
UI_WORKFLOW_FORMAT = "ui"
|
|
API_WORKFLOW_FORMAT = "api"
|
|
|
|
_WORKFLOW_CONTAINER_KEYS = (
|
|
"workflow",
|
|
"prompt",
|
|
"comfy",
|
|
"comfyui",
|
|
"extra_pnginfo",
|
|
"extra_metadata",
|
|
"graph",
|
|
)
|
|
|
|
|
|
def _is_api_link(value: Any) -> bool:
|
|
"""Return whether a value has ComfyUI API link shape: [node_id, output]."""
|
|
return (
|
|
isinstance(value, (list, tuple))
|
|
and len(value) == 2
|
|
and not isinstance(value[1], bool)
|
|
and isinstance(value[1], int)
|
|
and not isinstance(value[0], bool)
|
|
and isinstance(value[0], (str, int))
|
|
)
|
|
|
|
|
|
def _is_api_node(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, Mapping)
|
|
and isinstance(value.get("class_type"), str)
|
|
and bool(value.get("class_type", "").strip())
|
|
and isinstance(value.get("inputs"), Mapping)
|
|
)
|
|
|
|
|
|
def detect_workflow_format(workflow: Any) -> Optional[str]:
|
|
"""Identify a ComfyUI editor workflow or API prompt graph."""
|
|
if not isinstance(workflow, Mapping):
|
|
return None
|
|
if isinstance(workflow.get("nodes"), list):
|
|
return UI_WORKFLOW_FORMAT
|
|
if any(_is_api_node(value) for value in workflow.values()):
|
|
return API_WORKFLOW_FORMAT
|
|
return None
|
|
|
|
|
|
def _parse_json_string(value: Any) -> Any:
|
|
if not isinstance(value, str):
|
|
return value
|
|
text = value.strip()
|
|
if not text or text[0] not in "[{":
|
|
return value
|
|
|
|
import json
|
|
|
|
try:
|
|
return json.loads(text)
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
return value
|
|
|
|
|
|
def find_workflow_payload(value: Any, *, max_depth: int = 6) -> Tuple[Any, Optional[str]]:
|
|
"""Find a workflow nested in common ComfyUI metadata containers."""
|
|
current = _parse_json_string(value)
|
|
if not isinstance(current, Mapping):
|
|
return None, None
|
|
|
|
detected = detect_workflow_format(current)
|
|
if detected:
|
|
return current, detected
|
|
if max_depth <= 0:
|
|
return None, None
|
|
|
|
for key in _WORKFLOW_CONTAINER_KEYS:
|
|
if key not in current:
|
|
continue
|
|
nested, detected = find_workflow_payload(
|
|
current.get(key),
|
|
max_depth=max_depth - 1,
|
|
)
|
|
if detected:
|
|
return nested, detected
|
|
return None, None
|
|
|
|
|
|
def _build_api_workflow(api_workflow: Mapping[str, Any]) -> Dict[str, Any]:
|
|
"""Convert an API prompt graph into a read-only editor-like workflow.
|
|
|
|
The analyzer consumes editor nodes, while API prompts store widget values
|
|
by input name and connections as ``[source_node, output_index]``. The
|
|
generated nodes retain input names so the existing category and custom-node
|
|
rules can inspect them without executing or mutating the prompt.
|
|
"""
|
|
entries = [
|
|
(str(node_id), node)
|
|
for node_id, node in api_workflow.items()
|
|
if _is_api_node(node)
|
|
]
|
|
|
|
link_ids: Dict[Tuple[str, int, str], int] = {}
|
|
output_links: Dict[Tuple[str, int], list[int]] = {}
|
|
next_link_id = 1
|
|
for target_node_id, node in entries:
|
|
inputs = node.get("inputs", {})
|
|
for _input_name, value in inputs.items():
|
|
if not _is_api_link(value):
|
|
continue
|
|
source_node_id = str(value[0])
|
|
output_index = int(value[1])
|
|
connection_key = (source_node_id, output_index, target_node_id)
|
|
link_id = link_ids.get(connection_key)
|
|
if link_id is None:
|
|
link_id = next_link_id
|
|
next_link_id += 1
|
|
link_ids[connection_key] = link_id
|
|
output_links.setdefault((source_node_id, output_index), []).append(
|
|
link_id
|
|
)
|
|
|
|
nodes = []
|
|
for node_id, node in entries:
|
|
inputs = node.get("inputs", {})
|
|
widgets_values = []
|
|
widget_items = []
|
|
input_items = []
|
|
for input_name, value in inputs.items():
|
|
input_name = str(input_name)
|
|
if _is_api_link(value):
|
|
connection_key = (str(value[0]), int(value[1]), node_id)
|
|
input_items.append(
|
|
{
|
|
"name": input_name,
|
|
"link": link_ids[connection_key],
|
|
}
|
|
)
|
|
continue
|
|
|
|
widgets_values.append(value)
|
|
widget_items.append({"name": input_name})
|
|
input_items.append(
|
|
{
|
|
"name": input_name,
|
|
"widget": {"name": input_name},
|
|
}
|
|
)
|
|
|
|
meta = node.get("_meta", {})
|
|
meta = meta if isinstance(meta, Mapping) else {}
|
|
title = str(meta.get("title", "") or "").strip()
|
|
|
|
output_indexes = [
|
|
output_index
|
|
for source_node_id, output_index in output_links
|
|
if source_node_id == node_id
|
|
]
|
|
outputs = []
|
|
if output_indexes:
|
|
for output_index in range(max(output_indexes) + 1):
|
|
outputs.append(
|
|
{
|
|
"links": output_links.get((node_id, output_index), []),
|
|
}
|
|
)
|
|
|
|
normalized_node = {
|
|
"id": node_id,
|
|
"type": str(node.get("class_type", "")),
|
|
"widgets_values": widgets_values,
|
|
"widgets": widget_items,
|
|
"inputs": input_items,
|
|
"outputs": outputs,
|
|
"mode": node.get("mode", 0),
|
|
}
|
|
if title:
|
|
normalized_node["title"] = title
|
|
if isinstance(node.get("properties"), Mapping):
|
|
normalized_node["properties"] = dict(node["properties"])
|
|
nodes.append(normalized_node)
|
|
|
|
return {"nodes": nodes}
|
|
|
|
|
|
def normalize_workflow_payload(workflow: Any) -> Tuple[Dict[str, Any], str]:
|
|
"""Return an analyzer-compatible workflow and its original format."""
|
|
payload, detected = find_workflow_payload(workflow)
|
|
if not detected or not isinstance(payload, Mapping):
|
|
raise ValueError(
|
|
"Unsupported workflow JSON. Expected a ComfyUI UI workflow "
|
|
"with a nodes array or an API prompt graph."
|
|
)
|
|
if detected == UI_WORKFLOW_FORMAT:
|
|
return dict(payload), detected
|
|
return _build_api_workflow(payload), detected
|
|
|
|
|
|
__all__ = [
|
|
"API_WORKFLOW_FORMAT",
|
|
"UI_WORKFLOW_FORMAT",
|
|
"detect_workflow_format",
|
|
"find_workflow_payload",
|
|
"normalize_workflow_payload",
|
|
]
|