Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

376 lines
13 KiB
Python

from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any
from invokeai.app.invocations.baseinvocation import Classification, InvocationRegistry
from invokeai.app.invocations.call_saved_workflow import (
CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX,
parse_call_saved_workflow_dynamic_input,
)
from invokeai.app.services.shared.graph import Edge, EdgeConnection, Graph
CONNECTOR_INPUT_HANDLE = "in"
CONNECTOR_OUTPUT_HANDLE = "out"
class UnsupportedWorkflowNodeError(ValueError):
pass
class InvalidWorkflowInputError(ValueError):
pass
def _is_mapping(value: Any) -> bool:
return isinstance(value, Mapping)
def _is_invocation_node(node: Any) -> bool:
return _is_mapping(node) and node.get("type") == "invocation" and _is_mapping(node.get("data"))
def _is_connector_node(node: Any) -> bool:
return _is_mapping(node) and node.get("type") == "connector"
def _build_dynamic_input_name(node_id: str, field_name: str) -> str:
return f"{CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX}{node_id}::{field_name}"
def _get_form_elements(workflow: Mapping[str, Any]) -> tuple[Mapping[str, Any], str | None]:
form = workflow.get("form")
if not _is_mapping(form):
return {}, None
elements = form.get("elements")
root_element_id = form.get("rootElementId")
if not _is_mapping(elements) or not isinstance(root_element_id, str):
return {}, None
return elements, root_element_id
def _collect_exposed_inputs_from_form(workflow: Mapping[str, Any]) -> set[str]:
elements, root_element_id = _get_form_elements(workflow)
if not elements or root_element_id is None:
return set()
exposed_inputs: set[str] = set()
stack = [root_element_id]
visited: set[str] = set()
while stack:
element_id = stack.pop()
if element_id in visited:
continue
visited.add(element_id)
element = elements.get(element_id)
if not _is_mapping(element):
continue
if element.get("type") == "node-field":
data = element.get("data")
if _is_mapping(data):
field_identifier = data.get("fieldIdentifier")
if _is_mapping(field_identifier):
node_id = field_identifier.get("nodeId")
field_name = field_identifier.get("fieldName")
if isinstance(node_id, str) and isinstance(field_name, str):
exposed_inputs.add(_build_dynamic_input_name(node_id, field_name))
data = element.get("data")
if _is_mapping(data):
children = data.get("children")
if isinstance(children, Sequence):
for child_id in reversed(children):
if isinstance(child_id, str):
stack.append(child_id)
return exposed_inputs
def get_exposed_workflow_input_names(workflow: Mapping[str, Any]) -> set[str]:
exposed_inputs = _collect_exposed_inputs_from_form(workflow)
if exposed_inputs:
return exposed_inputs
workflow_exposed_fields = workflow.get("exposedFields", [])
if not isinstance(workflow_exposed_fields, Sequence):
return set()
fallback_inputs: set[str] = set()
for field in workflow_exposed_fields:
if not _is_mapping(field):
continue
node_id = field.get("nodeId")
field_name = field.get("fieldName")
if isinstance(node_id, str) and isinstance(field_name, str):
fallback_inputs.add(_build_dynamic_input_name(node_id, field_name))
return fallback_inputs
def apply_workflow_inputs_to_workflow(workflow: MutableMapping[str, Any], workflow_inputs: Mapping[str, Any]) -> None:
if not workflow_inputs:
return
allowed_inputs = get_exposed_workflow_input_names(workflow)
for input_name, value in workflow_inputs.items():
if input_name not in allowed_inputs:
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' is not exposed by the selected workflow"
)
node_id, field_name = parse_call_saved_workflow_dynamic_input(input_name)
workflow_nodes = workflow.get("nodes", [])
if not isinstance(workflow_nodes, list):
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
)
matching_node = next(
(
node
for node in workflow_nodes
if _is_mapping(node)
and _is_mapping(node.get("data"))
and node.get("id") == node_id
and node["data"].get("id") == node_id
),
None,
)
if matching_node is None:
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
)
matching_node_data = matching_node["data"]
node_type = matching_node_data.get("type")
if not isinstance(node_type, str):
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
)
invocation_class = InvocationRegistry.get_invocation_for_type(node_type)
if invocation_class is None or field_name not in invocation_class.model_fields:
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow field '{field_name}'"
)
inputs = matching_node_data.setdefault("inputs", {})
if not _is_mapping(inputs):
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets invalid child workflow inputs on '{node_id}'"
)
inputs[field_name] = {"value": value}
def apply_workflow_inputs_to_graph(
graph: Graph, workflow: Mapping[str, Any], workflow_inputs: Mapping[str, Any]
) -> None:
if not workflow_inputs:
return
mutable_workflow = dict(workflow)
apply_workflow_inputs_to_workflow(mutable_workflow, workflow_inputs)
for input_name, value in workflow_inputs.items():
node_id, field_name = parse_call_saved_workflow_dynamic_input(input_name)
node = graph.nodes.get(node_id)
if node is None:
continue
setattr(node, field_name, value)
def _raise_if_unsupported_invocation_type(node_type: str, node_id: str) -> None:
invocation_class = InvocationRegistry.get_invocation_for_type(node_type)
if invocation_class is None:
return
if (
invocation_class.UIConfig.category == "batch"
and invocation_class.UIConfig.classification == Classification.Special
and not node_type.endswith("_generator")
):
raise UnsupportedWorkflowNodeError(
f"call_saved_workflow does not yet support batch-special child workflow nodes such as "
f"'{node_type}' (node '{node_id}')"
)
def _validate_callable_workflow_nodes(workflow_nodes: Sequence[Any]) -> None:
workflow_return_node_ids: list[str] = []
for node in workflow_nodes:
if not _is_invocation_node(node):
continue
data = node["data"]
node_id = data.get("id")
node_type = data.get("type")
if not isinstance(node_id, str) or not isinstance(node_type, str):
continue
_raise_if_unsupported_invocation_type(node_type, node_id)
if node_type == "workflow_return":
workflow_return_node_ids.append(node_id)
if len(workflow_return_node_ids) != 1:
raise UnsupportedWorkflowNodeError(
"call_saved_workflow requires the selected workflow to contain exactly one workflow_return node"
)
def _get_default_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]]:
return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "default"]
def _get_connector_input_edge(
connector_id: str, workflow_edges: Sequence[Mapping[str, Any]]
) -> Mapping[str, Any] | None:
return next(
(
edge
for edge in workflow_edges
if edge.get("target") == connector_id and edge.get("targetHandle") == CONNECTOR_INPUT_HANDLE
),
None,
)
def _resolve_connector_source(
connector_id: str, workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]]
) -> tuple[str, str] | None:
visited: set[str] = set()
def resolve(node_id: str) -> tuple[str, str] | None:
if node_id in visited:
return None
visited.add(node_id)
incoming_edge = _get_connector_input_edge(node_id, workflow_edges)
if incoming_edge is None:
return None
source_id = incoming_edge.get("source")
source_handle = incoming_edge.get("sourceHandle")
if not isinstance(source_id, str) or not isinstance(source_handle, str):
return None
source_node = workflow_nodes.get(source_id)
if source_node is None:
return None
if _is_invocation_node(source_node):
return (source_id, source_handle)
if _is_connector_node(source_node):
return resolve(source_id)
return None
return resolve(connector_id)
def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph:
workflow_nodes_raw = workflow.get("nodes", [])
workflow_edges_raw = workflow.get("edges", [])
_validate_callable_workflow_nodes(workflow_nodes_raw if isinstance(workflow_nodes_raw, Sequence) else [])
workflow_nodes = {
node["id"]: node for node in workflow_nodes_raw if _is_mapping(node) and isinstance(node.get("id"), str)
}
default_edges = _get_default_edges(workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else [])
parsed_nodes: dict[str, dict[str, Any]] = {}
for node in workflow_nodes.values():
if not _is_invocation_node(node):
continue
data = node["data"]
node_id = data.get("id")
node_type = data.get("type")
if not isinstance(node_id, str) or not isinstance(node_type, str):
continue
graph_node: dict[str, Any] = {
"id": node_id,
"type": node_type,
"use_cache": data.get("useCache", False),
"is_intermediate": data.get("isIntermediate", False),
}
inputs = data.get("inputs", {})
if _is_mapping(inputs):
for field_name, field_value in inputs.items():
if not isinstance(field_name, str) or not _is_mapping(field_value):
continue
# Saved workflows may include input metadata for unfilled optional fields without a "value".
# Omit those fields so invocation defaults are applied instead of forcing None.
if "value" in field_value:
# The frontend board picker may persist sentinel strings; graph nodes model both
# automatic and no-board selection as the board field default, None.
if field_name == "board" and field_value["value"] in ("auto", "none"):
continue
graph_node[field_name] = field_value["value"]
parsed_nodes[node_id] = graph_node
parsed_edges: list[dict[str, dict[str, str]]] = []
seen_edges: set[tuple[str, str, str, str]] = set()
for edge in default_edges:
source_id = edge.get("source")
target_id = edge.get("target")
source_handle = edge.get("sourceHandle")
target_handle = edge.get("targetHandle")
if not all(isinstance(v, str) for v in (source_id, target_id, source_handle, target_handle)):
continue
target_node = workflow_nodes.get(target_id)
if not _is_invocation_node(target_node):
continue
source_node = workflow_nodes.get(source_id)
resolved_source: tuple[str, str] | None = None
if _is_invocation_node(source_node):
resolved_source = (source_id, source_handle)
elif _is_connector_node(source_node):
resolved_source = _resolve_connector_source(source_id, workflow_nodes, default_edges)
if resolved_source is None:
continue
resolved_source_id, resolved_source_handle = resolved_source
edge_key = (resolved_source_id, resolved_source_handle, target_id, target_handle)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
parsed_edges.append(
{
"source": {
"node_id": resolved_source_id,
"field": resolved_source_handle,
},
"destination": {
"node_id": target_id,
"field": target_handle,
},
}
)
for edge in parsed_edges:
destination_node_id = edge["destination"]["node_id"]
destination_field = edge["destination"]["field"]
parsed_nodes[destination_node_id].pop(destination_field, None)
return Graph.model_validate(
{
"nodes": parsed_nodes,
"edges": [
Edge(
source=EdgeConnection(**edge["source"]),
destination=EdgeConnection(**edge["destination"]),
)
for edge in parsed_edges
],
}
)