1348 lines
50 KiB
Python
1348 lines
50 KiB
Python
"""Workflow mapping, transportability, and remote graph partitioning."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections import defaultdict, deque
|
|
from typing import Any, Callable, Iterator, Mapping
|
|
|
|
if __package__:
|
|
from .execution_environments import ExecutionProvider, WorkflowExecutionPreferences
|
|
from .llm_profiles import get_llm_profile
|
|
from .modal_executor_node import (
|
|
MODAL_ARTIFACT_FINALIZER_NODE_ID,
|
|
MODAL_LOCAL_BRIDGE_MATERIALIZER_NODE_ID,
|
|
MODAL_MAP_INPUT_NODE_ID,
|
|
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID,
|
|
)
|
|
from .remote_configuration_nodes import REMOTE_CONFIGURATION_NODE_IDS
|
|
from .remote_plan_types import (
|
|
InputTarget,
|
|
LinkedOutputRef,
|
|
RemoteExpansionReason,
|
|
RemoteNodeAnalysis,
|
|
)
|
|
from .settings import ModalSyncSettings, get_settings
|
|
from .workflow_prompt_mapping import (
|
|
_best_workflow_path_for_prompt_node,
|
|
_build_workflow_prompt_resolution_maps,
|
|
_extract_marked_workflow_node_paths,
|
|
_iter_workflow_nodes,
|
|
_iter_workflow_nodes_with_ancestors,
|
|
_looks_like_workflow_node,
|
|
_resolve_prompt_node_ids_for_workflow_node,
|
|
_resolve_requested_prompt_node_ids,
|
|
_resolve_workflow_node_paths_for_prompt_nodes,
|
|
_workflow_node_path,
|
|
_workflow_subgraph_definitions,
|
|
)
|
|
else: # pragma: no cover - flat import inside the Modal container.
|
|
from execution_environments import ExecutionProvider, WorkflowExecutionPreferences
|
|
from llm_profiles import get_llm_profile
|
|
from modal_executor_node import (
|
|
MODAL_ARTIFACT_FINALIZER_NODE_ID,
|
|
MODAL_LOCAL_BRIDGE_MATERIALIZER_NODE_ID,
|
|
MODAL_MAP_INPUT_NODE_ID,
|
|
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID,
|
|
)
|
|
from remote_configuration_nodes import REMOTE_CONFIGURATION_NODE_IDS
|
|
from remote_plan_types import (
|
|
InputTarget,
|
|
LinkedOutputRef,
|
|
RemoteExpansionReason,
|
|
RemoteNodeAnalysis,
|
|
)
|
|
from settings import ModalSyncSettings, get_settings
|
|
from workflow_prompt_mapping import (
|
|
_best_workflow_path_for_prompt_node,
|
|
_build_workflow_prompt_resolution_maps,
|
|
_extract_marked_workflow_node_paths,
|
|
_iter_workflow_nodes,
|
|
_iter_workflow_nodes_with_ancestors,
|
|
_looks_like_workflow_node,
|
|
_resolve_prompt_node_ids_for_workflow_node,
|
|
_resolve_requested_prompt_node_ids,
|
|
_resolve_workflow_node_paths_for_prompt_nodes,
|
|
_workflow_node_path,
|
|
_workflow_subgraph_definitions,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_REMOTE_TOGGLE_WIDGET_NAME = "Run Remotely"
|
|
_LEGACY_REMOTE_TOGGLE_WIDGET_NAME = "Run on Modal"
|
|
_LOCAL_ONLY_REMOTE_CLASS_TYPES = frozenset(
|
|
{
|
|
"ModalEndpointChat",
|
|
"VastAILeaseConfiguration",
|
|
*REMOTE_CONFIGURATION_NODE_IDS,
|
|
MODAL_ARTIFACT_FINALIZER_NODE_ID,
|
|
MODAL_LOCAL_BRIDGE_MATERIALIZER_NODE_ID,
|
|
MODAL_MAP_INPUT_NODE_ID,
|
|
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID,
|
|
}
|
|
)
|
|
_TRANSPORTABLE_OUTPUT_TYPES = frozenset(
|
|
{
|
|
"*",
|
|
"AUDIO",
|
|
"BOOLEAN",
|
|
"FLOAT",
|
|
"IMAGE",
|
|
"INT",
|
|
"LATENT",
|
|
"MASK",
|
|
"SIGMAS",
|
|
"STRING",
|
|
"VIDEO",
|
|
}
|
|
)
|
|
_INEXPENSIVE_REMOTE_BOUNDARY_TYPES = frozenset(
|
|
{
|
|
"BOOLEAN",
|
|
"FLOAT",
|
|
"INT",
|
|
"STRING",
|
|
}
|
|
)
|
|
|
|
|
|
def _get_nodes_module() -> Any:
|
|
"""Import the ComfyUI nodes module lazily."""
|
|
import nodes
|
|
|
|
return nodes
|
|
|
|
|
|
def _is_link(value: Any) -> bool:
|
|
"""Return whether a prompt input value is a ComfyUI link."""
|
|
return (
|
|
isinstance(value, list)
|
|
and len(value) == 2
|
|
and all(not isinstance(item, dict) for item in value)
|
|
)
|
|
|
|
|
|
def _prompt_node_required_provider(
|
|
prompt_node: Mapping[str, Any],
|
|
) -> ExecutionProvider | None:
|
|
"""Return the provider required by one curated node backend, when known."""
|
|
if str(prompt_node.get("class_type") or "") != "ModalLLM":
|
|
return None
|
|
inputs = prompt_node.get("inputs")
|
|
if not isinstance(inputs, Mapping):
|
|
return None
|
|
model_reference = inputs.get("model_profile")
|
|
if not isinstance(model_reference, str) or not model_reference.strip():
|
|
return None
|
|
|
|
try:
|
|
profile = get_llm_profile(model_reference.strip())
|
|
except ValueError:
|
|
return None
|
|
if profile.backend == "llama_cpp_server":
|
|
return ExecutionProvider.SSH_DOCKER
|
|
return None
|
|
|
|
|
|
def _iter_payload_input_strings(value: Any) -> Iterator[str]:
|
|
"""Yield string literals nested inside one serialized prompt input value."""
|
|
if isinstance(value, str):
|
|
yield value
|
|
return
|
|
if isinstance(value, list):
|
|
if len(value) == 2 and isinstance(value[0], str):
|
|
return
|
|
for item in value:
|
|
yield from _iter_payload_input_strings(item)
|
|
return
|
|
if isinstance(value, dict):
|
|
for nested_value in value.values():
|
|
yield from _iter_payload_input_strings(nested_value)
|
|
|
|
|
|
def extract_remote_node_ids(
|
|
workflow: dict[str, Any] | None,
|
|
settings: ModalSyncSettings | None = None,
|
|
prompt_node_ids: set[str] | None = None,
|
|
) -> set[str]:
|
|
"""Return the node ids marked for remote execution in the workflow metadata."""
|
|
if workflow is None:
|
|
return set()
|
|
|
|
marker = (settings or get_settings()).marker_property
|
|
remote_node_ids: set[str] = set()
|
|
for node, ancestor_node_ids in _iter_workflow_nodes_with_ancestors(workflow):
|
|
if _workflow_node_remote_enabled(node, marker):
|
|
node_id = str(node.get("id"))
|
|
if prompt_node_ids is None:
|
|
remote_node_ids.add(node_id)
|
|
continue
|
|
|
|
resolved_prompt_node_ids = _resolve_prompt_node_ids_for_workflow_node(
|
|
node_id,
|
|
ancestor_node_ids,
|
|
prompt_node_ids,
|
|
)
|
|
if resolved_prompt_node_ids:
|
|
remote_node_ids.update(resolved_prompt_node_ids)
|
|
logger.info(
|
|
"Mapped workflow Modal marker from node %s with ancestors %s to prompt nodes %s.",
|
|
node_id,
|
|
list(ancestor_node_ids),
|
|
sorted(resolved_prompt_node_ids),
|
|
)
|
|
return remote_node_ids
|
|
|
|
|
|
def requested_remote_node_ids(
|
|
*,
|
|
prompt: Mapping[str, Any],
|
|
workflow: Mapping[str, Any] | None,
|
|
settings: ModalSyncSettings,
|
|
) -> set[str]:
|
|
"""Return explicit remote markers or every eligible node for auto placement."""
|
|
preferences = WorkflowExecutionPreferences.from_workflow(workflow)
|
|
if not preferences.auto_place:
|
|
return extract_remote_node_ids(
|
|
dict(workflow) if workflow is not None else None,
|
|
settings,
|
|
set(prompt),
|
|
)
|
|
selected_node_ids = {
|
|
str(node_id)
|
|
for node_id, prompt_node in prompt.items()
|
|
if isinstance(prompt_node, Mapping)
|
|
and str(prompt_node.get("class_type") or "")
|
|
not in _LOCAL_ONLY_REMOTE_CLASS_TYPES
|
|
and not str(prompt_node.get("class_type") or "").startswith(
|
|
"ModalUniversalExecutor"
|
|
)
|
|
}
|
|
logger.info(
|
|
"Automatic remote placement selected %d eligible prompt nodes.",
|
|
len(selected_node_ids),
|
|
)
|
|
return selected_node_ids
|
|
|
|
|
|
def _workflow_node_remote_enabled(node: Mapping[str, Any], marker: str) -> bool:
|
|
"""Return the visible remote toggle, falling back to the saved marker property."""
|
|
named_widget_values = node.get("widgets_values_named")
|
|
if isinstance(named_widget_values, Mapping):
|
|
visible_toggle_value = named_widget_values.get(_REMOTE_TOGGLE_WIDGET_NAME)
|
|
if not isinstance(visible_toggle_value, bool):
|
|
visible_toggle_value = named_widget_values.get(
|
|
_LEGACY_REMOTE_TOGGLE_WIDGET_NAME
|
|
)
|
|
if isinstance(visible_toggle_value, bool):
|
|
return visible_toggle_value
|
|
|
|
properties = node.get("properties")
|
|
return isinstance(properties, Mapping) and bool(properties.get(marker))
|
|
|
|
|
|
def _normalize_output_metadata(
|
|
node_class: type[Any],
|
|
) -> tuple[tuple[str, ...], tuple[str, ...], tuple[bool, ...]]:
|
|
"""Return normalized output metadata for a node class."""
|
|
if hasattr(node_class, "GET_SCHEMA"):
|
|
node_class.GET_SCHEMA()
|
|
|
|
output_types = tuple(getattr(node_class, "RETURN_TYPES", ("*",))) or ("*",)
|
|
default_names = tuple(f"output_{index}" for index, _ in enumerate(output_types))
|
|
output_names = tuple(getattr(node_class, "RETURN_NAMES", default_names))
|
|
output_is_list = tuple(
|
|
getattr(node_class, "OUTPUT_IS_LIST", (False,) * len(output_types))
|
|
)
|
|
|
|
if len(output_names) < len(output_types):
|
|
output_names = output_names + default_names[len(output_names) :]
|
|
if len(output_is_list) < len(output_types):
|
|
output_is_list = output_is_list + (False,) * (
|
|
len(output_types) - len(output_is_list)
|
|
)
|
|
|
|
return (
|
|
output_types,
|
|
output_names[: len(output_types)],
|
|
output_is_list[: len(output_types)],
|
|
)
|
|
|
|
|
|
def _is_transportable_output_type(io_type: str) -> bool:
|
|
"""Return whether a ComfyUI output type can cross the current transport."""
|
|
normalized_parts = [part.strip() for part in io_type.split(",") if part.strip()]
|
|
return bool(normalized_parts) and all(
|
|
part in _TRANSPORTABLE_OUTPUT_TYPES for part in normalized_parts
|
|
)
|
|
|
|
|
|
def _is_inexpensive_remote_boundary_type(io_type: str) -> bool:
|
|
"""Return whether a remote edge is cheap enough to remain a component boundary."""
|
|
normalized_parts = [part.strip() for part in io_type.split(",") if part.strip()]
|
|
return bool(normalized_parts) and all(
|
|
part in _INEXPENSIVE_REMOTE_BOUNDARY_TYPES for part in normalized_parts
|
|
)
|
|
|
|
|
|
def _build_consumer_map(
|
|
prompt: dict[str, Any]
|
|
) -> dict[LinkedOutputRef, list[InputTarget]]:
|
|
"""Build a reverse map from node outputs to downstream prompt inputs."""
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]] = defaultdict(list)
|
|
for node_id, prompt_node in prompt.items():
|
|
for input_name, input_value in (prompt_node.get("inputs") or {}).items():
|
|
if not _is_link(input_value):
|
|
continue
|
|
source = LinkedOutputRef(
|
|
node_id=str(input_value[0]), output_index=int(input_value[1])
|
|
)
|
|
consumers[source].append(
|
|
InputTarget(node_id=str(node_id), input_name=str(input_name))
|
|
)
|
|
return consumers
|
|
|
|
|
|
def _sandwiched_local_node_ids(
|
|
prompt: dict[str, Any],
|
|
remote_node_ids: set[str],
|
|
) -> set[str]:
|
|
"""Return local nodes on paths that leave and then re-enter remote execution."""
|
|
downstream_node_ids: dict[str, set[str]] = defaultdict(set)
|
|
upstream_node_ids: dict[str, set[str]] = defaultdict(set)
|
|
for target_node_id, prompt_node in prompt.items():
|
|
for input_value in (prompt_node.get("inputs") or {}).values():
|
|
if not _is_link(input_value):
|
|
continue
|
|
source_node_id = str(input_value[0])
|
|
safe_target_node_id = str(target_node_id)
|
|
downstream_node_ids[source_node_id].add(safe_target_node_id)
|
|
upstream_node_ids[safe_target_node_id].add(source_node_id)
|
|
|
|
def local_closure(adjacency: Mapping[str, set[str]]) -> set[str]:
|
|
"""Return local nodes reachable from the remote seed set in one direction."""
|
|
reachable_local_node_ids: set[str] = set()
|
|
pending_node_ids = list(sorted(remote_node_ids))
|
|
visited_node_ids = set(remote_node_ids)
|
|
while pending_node_ids:
|
|
current_node_id = pending_node_ids.pop()
|
|
for adjacent_node_id in adjacency.get(current_node_id, set()):
|
|
if adjacent_node_id in visited_node_ids:
|
|
continue
|
|
visited_node_ids.add(adjacent_node_id)
|
|
if adjacent_node_id in remote_node_ids:
|
|
continue
|
|
if adjacent_node_id not in prompt:
|
|
continue
|
|
reachable_local_node_ids.add(adjacent_node_id)
|
|
pending_node_ids.append(adjacent_node_id)
|
|
return reachable_local_node_ids
|
|
|
|
downstream_local_node_ids = local_closure(downstream_node_ids)
|
|
upstream_local_node_ids = local_closure(upstream_node_ids)
|
|
return downstream_local_node_ids & upstream_local_node_ids
|
|
|
|
|
|
def _node_output_refs(
|
|
prompt: dict[str, Any], node_id: str, nodes_module: Any
|
|
) -> list[LinkedOutputRef]:
|
|
"""Return declared output refs for one prompt node."""
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
return []
|
|
node_class = nodes_module.NODE_CLASS_MAPPINGS.get(
|
|
str(prompt_node.get("class_type"))
|
|
)
|
|
if node_class is None:
|
|
return []
|
|
output_types, _, _ = _normalize_output_metadata(node_class)
|
|
return [
|
|
LinkedOutputRef(node_id=node_id, output_index=output_index)
|
|
for output_index, _io_type in enumerate(output_types)
|
|
]
|
|
|
|
|
|
def _downstream_node_ids_from_targets(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
seed_targets: list[InputTarget],
|
|
nodes_module: Any,
|
|
) -> set[str]:
|
|
"""Return every node reachable downstream from the supplied input targets."""
|
|
visited_node_ids: set[str] = set()
|
|
pending_node_ids = deque(str(target.node_id) for target in seed_targets)
|
|
while pending_node_ids:
|
|
node_id = pending_node_ids.popleft()
|
|
if node_id in visited_node_ids or node_id not in prompt:
|
|
continue
|
|
visited_node_ids.add(node_id)
|
|
for output_ref in _node_output_refs(prompt, node_id, nodes_module):
|
|
for downstream_target in consumers.get(output_ref, []):
|
|
downstream_node_id = str(downstream_target.node_id)
|
|
if downstream_node_id not in visited_node_ids:
|
|
pending_node_ids.append(downstream_node_id)
|
|
return visited_node_ids
|
|
|
|
|
|
def _is_non_returning_tap_terminal_node(
|
|
*,
|
|
prompt_node: dict[str, Any] | None,
|
|
nodes_module: Any,
|
|
) -> bool:
|
|
"""Return whether a local tap node should stay local instead of being absorbed."""
|
|
if prompt_node is None:
|
|
return False
|
|
class_type = str(prompt_node.get("class_type"))
|
|
if class_type == "PreviewImage":
|
|
return True
|
|
node_class = nodes_module.NODE_CLASS_MAPPINGS.get(class_type)
|
|
return bool(getattr(node_class, "OUTPUT_NODE", False))
|
|
|
|
|
|
def _non_returning_local_tap_node_ids(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
component_node_ids: set[str],
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
) -> tuple[set[str], set[str]]:
|
|
"""Return non-returning local tap branches without making local nodes remote."""
|
|
local_tap_node_ids: set[str] = set()
|
|
local_tap_terminal_node_ids: set[str] = set()
|
|
for component_node_id in sorted(component_node_ids):
|
|
for source in _node_output_refs(prompt, component_node_id, nodes_module):
|
|
output_consumers = consumers.get(source, [])
|
|
local_consumers = [
|
|
target
|
|
for target in output_consumers
|
|
if target.node_id not in component_node_ids
|
|
and target.node_id not in remote_node_ids
|
|
]
|
|
remote_consumers = [
|
|
target
|
|
for target in output_consumers
|
|
if target.node_id in remote_node_ids
|
|
]
|
|
if not local_consumers or not remote_consumers:
|
|
continue
|
|
for local_consumer in local_consumers:
|
|
branch_node_ids = _downstream_node_ids_from_targets(
|
|
prompt=prompt,
|
|
consumers=consumers,
|
|
seed_targets=[local_consumer],
|
|
nodes_module=nodes_module,
|
|
)
|
|
if branch_node_ids & remote_node_ids:
|
|
continue
|
|
local_tap_terminal_node_ids.update(branch_node_ids)
|
|
return local_tap_node_ids, local_tap_terminal_node_ids
|
|
|
|
|
|
def _non_returning_local_output_consumers(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
source: LinkedOutputRef,
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
) -> tuple[list[InputTarget], list[InputTarget]]:
|
|
"""Partition local consumers by whether their branch later returns remote."""
|
|
non_returning_consumers: list[InputTarget] = []
|
|
returning_consumers: list[InputTarget] = []
|
|
for target in consumers.get(source, []):
|
|
if target.node_id in remote_node_ids:
|
|
continue
|
|
branch_node_ids = _downstream_node_ids_from_targets(
|
|
prompt=prompt,
|
|
consumers=consumers,
|
|
seed_targets=[target],
|
|
nodes_module=nodes_module,
|
|
)
|
|
if branch_node_ids and not (branch_node_ids & remote_node_ids):
|
|
non_returning_consumers.append(target)
|
|
else:
|
|
returning_consumers.append(target)
|
|
return non_returning_consumers, returning_consumers
|
|
|
|
|
|
def _output_supports_parallel_local_materialization(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
source: LinkedOutputRef,
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
) -> bool:
|
|
"""Return whether every local consumer is independent of later remote work."""
|
|
(
|
|
non_returning_consumers,
|
|
returning_consumers,
|
|
) = _non_returning_local_output_consumers(
|
|
prompt=prompt,
|
|
source=source,
|
|
remote_node_ids=remote_node_ids,
|
|
consumers=consumers,
|
|
nodes_module=nodes_module,
|
|
)
|
|
return bool(non_returning_consumers) and not returning_consumers
|
|
|
|
|
|
def _expand_component_for_non_transportable_local_outputs(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
component_node_ids: set[str],
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
) -> tuple[set[str], set[str]]:
|
|
"""Absorb local consumers needed to avoid non-transportable component outputs."""
|
|
expanded_node_ids = set(component_node_ids)
|
|
added_node_ids: set[str] = set()
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
(
|
|
upstream_expanded_node_ids,
|
|
_expansion_reasons,
|
|
) = _expand_remote_node_ids_for_non_transportable_inputs(
|
|
prompt=prompt,
|
|
remote_node_ids=expanded_node_ids,
|
|
nodes_module=nodes_module,
|
|
)
|
|
upstream_added_node_ids = upstream_expanded_node_ids - expanded_node_ids
|
|
if upstream_added_node_ids:
|
|
expanded_node_ids = upstream_expanded_node_ids
|
|
added_node_ids.update(upstream_added_node_ids)
|
|
changed = True
|
|
|
|
for node_id in sorted(expanded_node_ids):
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
continue
|
|
node_class = nodes_module.NODE_CLASS_MAPPINGS.get(
|
|
str(prompt_node.get("class_type"))
|
|
)
|
|
if node_class is None:
|
|
continue
|
|
output_types, _, _ = _normalize_output_metadata(node_class)
|
|
for output_index, io_type in enumerate(output_types):
|
|
if _is_transportable_output_type(str(io_type)):
|
|
continue
|
|
source = LinkedOutputRef(node_id=node_id, output_index=output_index)
|
|
for target in consumers.get(source, []):
|
|
target_node_id = str(target.node_id)
|
|
if (
|
|
target_node_id in expanded_node_ids
|
|
or target_node_id in remote_node_ids
|
|
):
|
|
continue
|
|
if target_node_id not in prompt:
|
|
continue
|
|
if _is_non_returning_tap_terminal_node(
|
|
prompt_node=prompt.get(target_node_id),
|
|
nodes_module=nodes_module,
|
|
):
|
|
continue
|
|
expanded_node_ids.add(target_node_id)
|
|
added_node_ids.add(target_node_id)
|
|
changed = True
|
|
logger.info(
|
|
"Absorbing local node %s because it consumes non-transportable output %s:%d from a preview-tap remote component.",
|
|
target_node_id,
|
|
source.node_id,
|
|
source.output_index,
|
|
)
|
|
return expanded_node_ids, added_node_ids
|
|
|
|
|
|
def _remote_output_io_type(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
node_id: str,
|
|
output_index: int,
|
|
nodes_module: Any,
|
|
) -> str | None:
|
|
"""Return the declared output type for one prompt node output when available."""
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
return None
|
|
|
|
class_type = str(prompt_node["class_type"])
|
|
node_class = nodes_module.NODE_CLASS_MAPPINGS.get(class_type)
|
|
if node_class is None:
|
|
return None
|
|
|
|
output_types, _, _ = _normalize_output_metadata(node_class)
|
|
if output_index < 0 or output_index >= len(output_types):
|
|
return None
|
|
return str(output_types[output_index])
|
|
|
|
|
|
def _remote_output_is_list(
|
|
*,
|
|
prompt: dict[str, Any],
|
|
node_id: str,
|
|
output_index: int,
|
|
nodes_module: Any,
|
|
) -> bool:
|
|
"""Return whether one prompt node output is declared as a list output."""
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
return False
|
|
|
|
class_type = str(prompt_node["class_type"])
|
|
node_class = nodes_module.NODE_CLASS_MAPPINGS.get(class_type)
|
|
if node_class is None:
|
|
return False
|
|
|
|
output_types, _output_names, output_is_list = _normalize_output_metadata(node_class)
|
|
if output_index < 0 or output_index >= len(output_types):
|
|
return False
|
|
return bool(output_is_list[output_index])
|
|
|
|
|
|
def _remote_group_dependency_edges(
|
|
prompt: Mapping[str, Any],
|
|
remote_node_ids: set[str],
|
|
group_for_node: Callable[[str], str],
|
|
) -> dict[str, set[str]]:
|
|
"""Return current coarse dependency edges for a remote-node grouping."""
|
|
dependency_edges: dict[str, set[str]] = defaultdict(set)
|
|
for downstream_node_id in remote_node_ids:
|
|
downstream_node = prompt.get(downstream_node_id)
|
|
if not isinstance(downstream_node, Mapping):
|
|
continue
|
|
downstream_group = group_for_node(downstream_node_id)
|
|
for input_value in (downstream_node.get("inputs") or {}).values():
|
|
if not _is_link(input_value):
|
|
continue
|
|
upstream_node_id = str(input_value[0])
|
|
if upstream_node_id not in remote_node_ids:
|
|
continue
|
|
upstream_group = group_for_node(upstream_node_id)
|
|
if upstream_group != downstream_group:
|
|
dependency_edges[upstream_group].add(downstream_group)
|
|
return dependency_edges
|
|
|
|
|
|
def _has_alternate_group_path_through_protected_group(
|
|
dependency_edges: Mapping[str, set[str]],
|
|
start_group: str,
|
|
target_group: str,
|
|
protected_groups: set[str],
|
|
) -> bool:
|
|
"""Return whether a non-direct path crosses a provider-constrained group."""
|
|
pending = [
|
|
(downstream_group, downstream_group in protected_groups)
|
|
for downstream_group in dependency_edges.get(start_group, set())
|
|
if downstream_group != target_group
|
|
]
|
|
visited: set[tuple[str, bool]] = set()
|
|
while pending:
|
|
current_group, crossed_protected_group = pending.pop()
|
|
if current_group == target_group and crossed_protected_group:
|
|
return True
|
|
state = (current_group, crossed_protected_group)
|
|
if state in visited:
|
|
continue
|
|
visited.add(state)
|
|
pending.extend(
|
|
(
|
|
downstream_group,
|
|
crossed_protected_group or downstream_group in protected_groups,
|
|
)
|
|
for downstream_group in dependency_edges.get(current_group, set())
|
|
)
|
|
return False
|
|
|
|
|
|
def _remote_component_partition_groups(
|
|
prompt: dict[str, Any],
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
*,
|
|
preserve_nontransportable_affinity: bool = True,
|
|
) -> dict[str, set[str]]:
|
|
"""Return component groups after merging remote nodes across costly boundaries."""
|
|
parent: dict[str, str] = {node_id: node_id for node_id in remote_node_ids}
|
|
downstream_remote_node_ids_by_node_id: dict[str, set[str]] = defaultdict(set)
|
|
provider_constrained_node_ids = {
|
|
node_id
|
|
for node_id in remote_node_ids
|
|
if isinstance((prompt_node := prompt.get(node_id)), Mapping)
|
|
and _prompt_node_required_provider(prompt_node) is not None
|
|
}
|
|
|
|
def find(node_id: str) -> str:
|
|
"""Return the canonical union-find representative for one remote node."""
|
|
root = parent[node_id]
|
|
while root != parent[root]:
|
|
root = parent[root]
|
|
while node_id != root:
|
|
next_node_id = parent[node_id]
|
|
parent[node_id] = root
|
|
node_id = next_node_id
|
|
return root
|
|
|
|
def union(
|
|
left_node_id: str,
|
|
right_node_id: str,
|
|
*,
|
|
preserve_coarse_dag: bool | None = None,
|
|
) -> bool:
|
|
"""Merge two remote nodes unless doing so creates a coarse dependency cycle."""
|
|
left_root = find(left_node_id)
|
|
right_root = find(right_node_id)
|
|
if left_root == right_root:
|
|
return True
|
|
should_preserve_coarse_dag = (
|
|
bool(provider_constrained_node_ids)
|
|
if preserve_coarse_dag is None
|
|
else preserve_coarse_dag
|
|
)
|
|
if should_preserve_coarse_dag:
|
|
dependency_edges = _remote_group_dependency_edges(
|
|
prompt,
|
|
remote_node_ids,
|
|
find,
|
|
)
|
|
protected_groups = {
|
|
find(node_id) for node_id in provider_constrained_node_ids
|
|
}
|
|
creates_cycle = _has_alternate_group_path_through_protected_group(
|
|
dependency_edges,
|
|
left_root,
|
|
right_root,
|
|
protected_groups,
|
|
) or _has_alternate_group_path_through_protected_group(
|
|
dependency_edges,
|
|
right_root,
|
|
left_root,
|
|
protected_groups,
|
|
)
|
|
if creates_cycle:
|
|
logger.info(
|
|
"Splitting remote nodes %s and %s to preserve an acyclic "
|
|
"component graph.",
|
|
left_node_id,
|
|
right_node_id,
|
|
)
|
|
return False
|
|
canonical_root = min(left_root, right_root)
|
|
merged_root = max(left_root, right_root)
|
|
parent[merged_root] = canonical_root
|
|
return True
|
|
|
|
for node_id in sorted(remote_node_ids):
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
continue
|
|
for input_value in (prompt_node.get("inputs") or {}).values():
|
|
if not _is_link(input_value):
|
|
continue
|
|
upstream_node_id = str(input_value[0])
|
|
if upstream_node_id not in remote_node_ids:
|
|
continue
|
|
downstream_remote_node_ids_by_node_id[upstream_node_id].add(node_id)
|
|
upstream_prompt_node = prompt.get(upstream_node_id)
|
|
source = LinkedOutputRef(
|
|
node_id=upstream_node_id, output_index=int(input_value[1])
|
|
)
|
|
if (
|
|
upstream_prompt_node is not None
|
|
and str(upstream_prompt_node.get("class_type"))
|
|
== MODAL_MAP_INPUT_NODE_ID
|
|
):
|
|
union(node_id, upstream_node_id, preserve_coarse_dag=False)
|
|
continue
|
|
io_type = _remote_output_io_type(
|
|
prompt=prompt,
|
|
node_id=upstream_node_id,
|
|
output_index=source.output_index,
|
|
nodes_module=nodes_module,
|
|
)
|
|
if (
|
|
preserve_nontransportable_affinity
|
|
and io_type is not None
|
|
and not _is_transportable_output_type(io_type)
|
|
):
|
|
logger.info(
|
|
"Co-locating remote nodes %s -> %s because %s output %d "
|
|
"cannot cross a component boundary.",
|
|
upstream_node_id,
|
|
node_id,
|
|
io_type,
|
|
source.output_index,
|
|
)
|
|
union(node_id, upstream_node_id, preserve_coarse_dag=False)
|
|
continue
|
|
downstream_provider = _prompt_node_required_provider(prompt_node)
|
|
upstream_provider = (
|
|
_prompt_node_required_provider(upstream_prompt_node)
|
|
if isinstance(upstream_prompt_node, Mapping)
|
|
else None
|
|
)
|
|
if downstream_provider != upstream_provider and (
|
|
downstream_provider is not None or upstream_provider is not None
|
|
):
|
|
logger.info(
|
|
"Splitting remote nodes %s -> %s across provider boundary %s -> %s.",
|
|
upstream_node_id,
|
|
node_id,
|
|
upstream_provider.value if upstream_provider is not None else "any",
|
|
downstream_provider.value
|
|
if downstream_provider is not None
|
|
else "any",
|
|
)
|
|
continue
|
|
if _output_supports_parallel_local_materialization(
|
|
prompt=prompt,
|
|
source=source,
|
|
remote_node_ids=remote_node_ids,
|
|
consumers=consumers,
|
|
nodes_module=nodes_module,
|
|
):
|
|
logger.info(
|
|
"Splitting remote nodes %s -> %s because output %d also feeds only non-returning local work.",
|
|
upstream_node_id,
|
|
node_id,
|
|
source.output_index,
|
|
)
|
|
continue
|
|
if io_type is not None and _is_inexpensive_remote_boundary_type(io_type):
|
|
continue
|
|
if io_type is not None and _is_transportable_output_type(io_type):
|
|
logger.info(
|
|
"Co-locating remote nodes %s -> %s because %s output %d is "
|
|
"transportable but expensive.",
|
|
upstream_node_id,
|
|
node_id,
|
|
io_type,
|
|
source.output_index,
|
|
)
|
|
union(node_id, upstream_node_id)
|
|
|
|
for remote_node_id in sorted(remote_node_ids):
|
|
prompt_node = prompt.get(remote_node_id)
|
|
if (
|
|
prompt_node is None
|
|
or str(prompt_node.get("class_type")) != MODAL_MAP_INPUT_NODE_ID
|
|
):
|
|
continue
|
|
pending_node_ids = [remote_node_id]
|
|
visited_node_ids: set[str] = set()
|
|
while pending_node_ids:
|
|
current_node_id = pending_node_ids.pop()
|
|
if current_node_id in visited_node_ids:
|
|
continue
|
|
visited_node_ids.add(current_node_id)
|
|
union(
|
|
remote_node_id,
|
|
current_node_id,
|
|
preserve_coarse_dag=False,
|
|
)
|
|
for downstream_node_id in sorted(
|
|
downstream_remote_node_ids_by_node_id.get(current_node_id, set())
|
|
):
|
|
pending_node_ids.append(downstream_node_id)
|
|
|
|
groups: dict[str, set[str]] = defaultdict(set)
|
|
for node_id in sorted(remote_node_ids):
|
|
groups[find(node_id)].add(node_id)
|
|
return groups
|
|
|
|
|
|
def _component_topological_order(
|
|
prompt: dict[str, Any],
|
|
component_groups: dict[str, set[str]],
|
|
) -> list[list[str]]:
|
|
"""Return component node ids ordered from upstream to downstream."""
|
|
_, dependency_edges, indegree_by_component_id = _component_dependency_graph(
|
|
prompt, component_groups
|
|
)
|
|
merged_component_groups = _merge_cyclic_component_groups(
|
|
component_groups=component_groups,
|
|
dependency_edges=dependency_edges,
|
|
)
|
|
if merged_component_groups != component_groups:
|
|
return _component_topological_order(prompt, merged_component_groups)
|
|
|
|
ready_component_ids = deque(
|
|
sorted(
|
|
[
|
|
component_id
|
|
for component_id, indegree in indegree_by_component_id.items()
|
|
if indegree == 0
|
|
]
|
|
)
|
|
)
|
|
ordered_components: list[list[str]] = []
|
|
emitted_component_ids: set[str] = set()
|
|
|
|
while ready_component_ids:
|
|
component_id = ready_component_ids.popleft()
|
|
if component_id in emitted_component_ids:
|
|
continue
|
|
emitted_component_ids.add(component_id)
|
|
ordered_components.append(sorted(component_groups[component_id]))
|
|
for downstream_component_id in sorted(dependency_edges[component_id]):
|
|
indegree_by_component_id[downstream_component_id] -= 1
|
|
if indegree_by_component_id[downstream_component_id] == 0:
|
|
ready_component_ids.append(downstream_component_id)
|
|
|
|
if len(emitted_component_ids) == len(component_groups):
|
|
return ordered_components
|
|
|
|
logger.warning(
|
|
"Transport-aware component ordering encountered a cycle or unresolved dependency; falling back to stable component order."
|
|
)
|
|
for component_id in sorted(component_groups):
|
|
if component_id in emitted_component_ids:
|
|
continue
|
|
ordered_components.append(sorted(component_groups[component_id]))
|
|
return ordered_components
|
|
|
|
|
|
def _component_dependency_graph(
|
|
prompt: dict[str, Any],
|
|
component_groups: dict[str, set[str]],
|
|
) -> tuple[dict[str, str], dict[str, set[str]], dict[str, int]]:
|
|
"""Return component membership, downstream edges, and indegrees for the coarse component DAG."""
|
|
component_id_by_node_id: dict[str, str] = {}
|
|
for representative_node_id, component_node_ids in component_groups.items():
|
|
for node_id in component_node_ids:
|
|
component_id_by_node_id[node_id] = representative_node_id
|
|
|
|
dependency_edges: dict[str, set[str]] = {
|
|
representative_node_id: set() for representative_node_id in component_groups
|
|
}
|
|
indegree_by_component_id: dict[str, int] = {
|
|
representative_node_id: 0 for representative_node_id in component_groups
|
|
}
|
|
|
|
for node_id, representative_node_id in component_id_by_node_id.items():
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
continue
|
|
for input_value in (prompt_node.get("inputs") or {}).values():
|
|
if not _is_link(input_value):
|
|
continue
|
|
upstream_node_id = str(input_value[0])
|
|
upstream_component_id = component_id_by_node_id.get(upstream_node_id)
|
|
if (
|
|
upstream_component_id is None
|
|
or upstream_component_id == representative_node_id
|
|
):
|
|
continue
|
|
if representative_node_id in dependency_edges[upstream_component_id]:
|
|
continue
|
|
dependency_edges[upstream_component_id].add(representative_node_id)
|
|
indegree_by_component_id[representative_node_id] += 1
|
|
|
|
return component_id_by_node_id, dependency_edges, indegree_by_component_id
|
|
|
|
|
|
def _component_execution_stages(
|
|
prompt: dict[str, Any],
|
|
component_groups: dict[str, set[str]],
|
|
) -> list[list[str]]:
|
|
"""Return one best-effort stage decomposition for concurrent remote component execution."""
|
|
_, dependency_edges, indegree_by_component_id = _component_dependency_graph(
|
|
prompt, component_groups
|
|
)
|
|
merged_component_groups = _merge_cyclic_component_groups(
|
|
component_groups=component_groups,
|
|
dependency_edges=dependency_edges,
|
|
)
|
|
if merged_component_groups != component_groups:
|
|
return _component_execution_stages(prompt, merged_component_groups)
|
|
|
|
remaining_indegrees = dict(indegree_by_component_id)
|
|
ready_component_ids = sorted(
|
|
[
|
|
component_id
|
|
for component_id, indegree in remaining_indegrees.items()
|
|
if indegree == 0
|
|
]
|
|
)
|
|
execution_stages: list[list[str]] = []
|
|
emitted_component_ids: set[str] = set()
|
|
|
|
while ready_component_ids:
|
|
current_stage = list(ready_component_ids)
|
|
execution_stages.append(current_stage)
|
|
next_ready_component_ids: set[str] = set()
|
|
for component_id in current_stage:
|
|
emitted_component_ids.add(component_id)
|
|
for downstream_component_id in sorted(
|
|
dependency_edges.get(component_id, set())
|
|
):
|
|
remaining_indegrees[downstream_component_id] -= 1
|
|
if remaining_indegrees[downstream_component_id] == 0:
|
|
next_ready_component_ids.add(downstream_component_id)
|
|
ready_component_ids = sorted(
|
|
component_id
|
|
for component_id in next_ready_component_ids
|
|
if component_id not in emitted_component_ids
|
|
)
|
|
|
|
if len(emitted_component_ids) == len(component_groups):
|
|
return execution_stages
|
|
|
|
fallback_stage = [
|
|
component_id
|
|
for component_id in sorted(component_groups)
|
|
if component_id not in emitted_component_ids
|
|
]
|
|
if fallback_stage:
|
|
logger.warning(
|
|
"Transport-aware execution-stage planning encountered a cycle or unresolved dependency; appending fallback stage %s.",
|
|
fallback_stage,
|
|
)
|
|
execution_stages.append(fallback_stage)
|
|
return execution_stages
|
|
|
|
|
|
def _estimated_stage_parallelism(
|
|
execution_stages: list[list[str]],
|
|
mapped_component_ids: set[str],
|
|
*,
|
|
mapped_component_weight: int,
|
|
max_parallelism_cap: int | None = None,
|
|
) -> int:
|
|
"""Return the weighted best-effort parallelism estimate over staged remote execution."""
|
|
stage_parallelism = 0
|
|
for stage in execution_stages:
|
|
current_stage_parallelism = sum(
|
|
mapped_component_weight if component_id in mapped_component_ids else 1
|
|
for component_id in stage
|
|
)
|
|
stage_parallelism = max(stage_parallelism, current_stage_parallelism)
|
|
if max_parallelism_cap is not None:
|
|
return min(stage_parallelism, max_parallelism_cap)
|
|
return stage_parallelism
|
|
|
|
|
|
def _merge_cyclic_component_groups(
|
|
*,
|
|
component_groups: dict[str, set[str]],
|
|
dependency_edges: dict[str, set[str]],
|
|
) -> dict[str, set[str]]:
|
|
"""Collapse cyclic coarse component groups into SCC-merged groups."""
|
|
component_ids = sorted(component_groups)
|
|
reverse_edges: dict[str, set[str]] = {
|
|
component_id: set() for component_id in component_ids
|
|
}
|
|
for upstream_component_id, downstream_component_ids in dependency_edges.items():
|
|
for downstream_component_id in downstream_component_ids:
|
|
reverse_edges.setdefault(downstream_component_id, set()).add(
|
|
upstream_component_id
|
|
)
|
|
|
|
visited_component_ids: set[str] = set()
|
|
finish_order: list[str] = []
|
|
|
|
def visit_forward(component_id: str) -> None:
|
|
"""Record reverse-topological finish order over the coarse graph."""
|
|
if component_id in visited_component_ids:
|
|
return
|
|
visited_component_ids.add(component_id)
|
|
for downstream_component_id in sorted(
|
|
dependency_edges.get(component_id, set())
|
|
):
|
|
visit_forward(downstream_component_id)
|
|
finish_order.append(component_id)
|
|
|
|
for component_id in component_ids:
|
|
visit_forward(component_id)
|
|
|
|
assigned_component_ids: set[str] = set()
|
|
merged_groups: dict[str, set[str]] = {}
|
|
merged_sccs = 0
|
|
|
|
def visit_reverse(component_id: str, scc_component_ids: set[str]) -> None:
|
|
"""Collect one SCC by walking reverse edges from the finish-order seed."""
|
|
if component_id in assigned_component_ids:
|
|
return
|
|
assigned_component_ids.add(component_id)
|
|
scc_component_ids.add(component_id)
|
|
for upstream_component_id in sorted(reverse_edges.get(component_id, set())):
|
|
visit_reverse(upstream_component_id, scc_component_ids)
|
|
|
|
for component_id in reversed(finish_order):
|
|
if component_id in assigned_component_ids:
|
|
continue
|
|
scc_component_ids: set[str] = set()
|
|
visit_reverse(component_id, scc_component_ids)
|
|
merged_node_ids: set[str] = set()
|
|
for scc_component_id in scc_component_ids:
|
|
merged_node_ids.update(component_groups[scc_component_id])
|
|
representative_node_id = min(merged_node_ids)
|
|
merged_groups[representative_node_id] = merged_node_ids
|
|
if len(scc_component_ids) > 1:
|
|
merged_sccs += 1
|
|
|
|
if merged_sccs:
|
|
logger.warning(
|
|
"Transport-aware coarse component graph contained %d cyclic SCC(s); merging them back into larger remote components.",
|
|
merged_sccs,
|
|
)
|
|
return merged_groups
|
|
|
|
|
|
def _build_remote_components(
|
|
prompt: dict[str, Any],
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
) -> list[list[str]]:
|
|
"""Partition remote-marked nodes into transport-aware DAG components."""
|
|
component_groups = _remote_component_partition_groups(
|
|
prompt,
|
|
remote_node_ids,
|
|
consumers,
|
|
nodes_module,
|
|
)
|
|
components = _component_topological_order(prompt, component_groups)
|
|
logger.info(
|
|
"Partitioned %d remote nodes into %d transport-aware remote components: %s",
|
|
len(remote_node_ids),
|
|
len(components),
|
|
components,
|
|
)
|
|
return components
|
|
|
|
|
|
def _expand_remote_node_ids_for_non_transportable_inputs(
|
|
prompt: dict[str, Any],
|
|
remote_node_ids: set[str],
|
|
nodes_module: Any,
|
|
) -> tuple[set[str], list[RemoteExpansionReason]]:
|
|
"""Grow the remote set upstream until non-transportable inputs stay inside the remote island."""
|
|
expanded_remote_node_ids = set(remote_node_ids)
|
|
added_node_ids: set[str] = set()
|
|
reasons: list[RemoteExpansionReason] = []
|
|
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for node_id in sorted(expanded_remote_node_ids):
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None:
|
|
continue
|
|
for input_value in (prompt_node.get("inputs") or {}).values():
|
|
if not _is_link(input_value):
|
|
continue
|
|
upstream_node_id = str(input_value[0])
|
|
if upstream_node_id in expanded_remote_node_ids:
|
|
continue
|
|
|
|
upstream_prompt_node = prompt.get(upstream_node_id)
|
|
if upstream_prompt_node is None:
|
|
continue
|
|
|
|
upstream_class_type = str(upstream_prompt_node["class_type"])
|
|
upstream_class = nodes_module.NODE_CLASS_MAPPINGS.get(
|
|
upstream_class_type
|
|
)
|
|
if upstream_class is None:
|
|
continue
|
|
|
|
output_types, _, _ = _normalize_output_metadata(upstream_class)
|
|
output_index = int(input_value[1])
|
|
if output_index >= len(output_types):
|
|
continue
|
|
|
|
io_type = str(output_types[output_index])
|
|
if _is_transportable_output_type(io_type):
|
|
continue
|
|
|
|
expanded_remote_node_ids.add(upstream_node_id)
|
|
reason = RemoteExpansionReason(
|
|
node_id=upstream_node_id,
|
|
class_type=upstream_class_type,
|
|
required_by_node_id=node_id,
|
|
required_by_class_type=str(prompt_node["class_type"]),
|
|
output_index=output_index,
|
|
io_type=io_type,
|
|
)
|
|
reasons.append(reason)
|
|
added_node_ids.add(upstream_node_id)
|
|
changed = True
|
|
logger.info(
|
|
"Auto-expanded remote execution upstream: added node %s (%s) because node %s depends on non-transportable type '%s'.",
|
|
reason.node_id,
|
|
reason.class_type,
|
|
reason.required_by_node_id,
|
|
reason.io_type,
|
|
)
|
|
break
|
|
if changed:
|
|
break
|
|
|
|
if added_node_ids:
|
|
logger.info(
|
|
"Expanded remote node set from %d to %d nodes by absorbing upstream non-transportable dependencies: %s",
|
|
len(remote_node_ids),
|
|
len(expanded_remote_node_ids),
|
|
sorted(added_node_ids),
|
|
)
|
|
return expanded_remote_node_ids, reasons
|
|
|
|
|
|
def _terminal_remote_video_source(
|
|
*,
|
|
node_id: str,
|
|
prompt: dict[str, Any],
|
|
remote_node_ids: set[str],
|
|
consumers: dict[LinkedOutputRef, list[InputTarget]],
|
|
nodes_module: Any,
|
|
) -> LinkedOutputRef | None:
|
|
"""Return the remote VIDEO feeding one safe terminal SaveVideo artifact sink."""
|
|
prompt_node = prompt.get(node_id)
|
|
if prompt_node is None or str(prompt_node.get("class_type")) != "SaveVideo":
|
|
return None
|
|
inputs = prompt_node.get("inputs") or {}
|
|
video_input = inputs.get("video")
|
|
if not _is_link(video_input):
|
|
return None
|
|
video_source = LinkedOutputRef(str(video_input[0]), int(video_input[1]))
|
|
linked_source_node_ids = {
|
|
str(input_value[0]) for input_value in inputs.values() if _is_link(input_value)
|
|
}
|
|
if not linked_source_node_ids.issubset(remote_node_ids):
|
|
return None
|
|
if any(
|
|
consumers.get(output_ref)
|
|
for output_ref in _node_output_refs(prompt, node_id, nodes_module)
|
|
):
|
|
return None
|
|
io_type = _remote_output_io_type(
|
|
prompt=prompt,
|
|
node_id=video_source.node_id,
|
|
output_index=video_source.output_index,
|
|
nodes_module=nodes_module,
|
|
)
|
|
return video_source if io_type == "VIDEO" else None
|
|
|
|
|
|
def _expand_remote_node_ids_for_terminal_video_sinks(
|
|
prompt: dict[str, Any],
|
|
remote_node_ids: set[str],
|
|
nodes_module: Any,
|
|
) -> set[str]:
|
|
"""Run terminal SaveVideo consumers remotely to avoid exporting raw VIDEO tensors."""
|
|
expanded_remote_node_ids = set(remote_node_ids)
|
|
consumers = _build_consumer_map(prompt)
|
|
added_node_ids: set[str] = set()
|
|
for node_id in sorted(prompt):
|
|
if node_id in expanded_remote_node_ids:
|
|
continue
|
|
video_source = _terminal_remote_video_source(
|
|
node_id=node_id,
|
|
prompt=prompt,
|
|
remote_node_ids=expanded_remote_node_ids,
|
|
consumers=consumers,
|
|
nodes_module=nodes_module,
|
|
)
|
|
if video_source is None:
|
|
continue
|
|
expanded_remote_node_ids.add(node_id)
|
|
added_node_ids.add(node_id)
|
|
logger.info(
|
|
"Auto-expanded terminal SaveVideo node %s into remote execution because its "
|
|
"VIDEO input comes from remote node %s; the encoded output artifact will be "
|
|
"materialized locally without exporting raw frames.",
|
|
node_id,
|
|
video_source.node_id,
|
|
)
|
|
|
|
if added_node_ids:
|
|
logger.info(
|
|
"Expanded remote node set with terminal video artifact sinks: %s.",
|
|
sorted(added_node_ids),
|
|
)
|
|
return expanded_remote_node_ids
|
|
|
|
|
|
def analyze_remote_node_selection(
|
|
prompt: dict[str, Any],
|
|
workflow: dict[str, Any] | None,
|
|
seed_workflow_node_paths: list[str],
|
|
settings: ModalSyncSettings | None = None,
|
|
nodes_module: Any | None = None,
|
|
) -> RemoteNodeAnalysis:
|
|
"""Return the nodes the UI should mark remote for one context-menu expansion request."""
|
|
resolved_settings = settings or get_settings()
|
|
resolved_nodes_module = nodes_module or _get_nodes_module()
|
|
prompt_node_ids = {str(node_id) for node_id in prompt.keys()}
|
|
requested_workflow_node_paths = {
|
|
str(seed_workflow_node_path)
|
|
for seed_workflow_node_path in seed_workflow_node_paths
|
|
if str(seed_workflow_node_path)
|
|
}
|
|
current_remote_node_ids = extract_remote_node_ids(
|
|
workflow,
|
|
resolved_settings,
|
|
prompt_node_ids,
|
|
)
|
|
current_remote_workflow_node_paths = _extract_marked_workflow_node_paths(
|
|
workflow,
|
|
resolved_settings,
|
|
)
|
|
(
|
|
workflow_path_to_prompt_node_ids,
|
|
prompt_node_id_to_workflow_paths,
|
|
) = _build_workflow_prompt_resolution_maps(workflow, prompt_node_ids)
|
|
requested_node_ids = _resolve_requested_prompt_node_ids(
|
|
requested_workflow_node_paths,
|
|
prompt_node_ids,
|
|
workflow_path_to_prompt_node_ids,
|
|
)
|
|
initial_remote_node_ids = current_remote_node_ids | requested_node_ids
|
|
(
|
|
resolved_remote_node_ids,
|
|
reasons,
|
|
) = _expand_remote_node_ids_for_non_transportable_inputs(
|
|
prompt=prompt,
|
|
remote_node_ids=initial_remote_node_ids,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
resolved_remote_node_ids = _expand_remote_node_ids_for_terminal_video_sinks(
|
|
prompt=prompt,
|
|
remote_node_ids=resolved_remote_node_ids,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
sandwiched_local_node_ids = _sandwiched_local_node_ids(
|
|
prompt,
|
|
resolved_remote_node_ids,
|
|
)
|
|
resolved_workflow_node_paths = (
|
|
_resolve_workflow_node_paths_for_prompt_nodes(
|
|
resolved_remote_node_ids,
|
|
prompt_node_id_to_workflow_paths,
|
|
)
|
|
| current_remote_workflow_node_paths
|
|
)
|
|
added_node_ids = resolved_remote_node_ids - current_remote_node_ids
|
|
added_workflow_node_paths = (
|
|
resolved_workflow_node_paths - current_remote_workflow_node_paths
|
|
)
|
|
|
|
return RemoteNodeAnalysis(
|
|
requested_node_ids=sorted(requested_node_ids),
|
|
requested_workflow_node_paths=sorted(requested_workflow_node_paths),
|
|
current_remote_node_ids=sorted(current_remote_node_ids),
|
|
current_remote_workflow_node_paths=sorted(current_remote_workflow_node_paths),
|
|
resolved_remote_node_ids=sorted(resolved_remote_node_ids),
|
|
resolved_workflow_node_paths=sorted(resolved_workflow_node_paths),
|
|
added_node_ids=sorted(added_node_ids),
|
|
added_workflow_node_paths=sorted(added_workflow_node_paths),
|
|
sandwiched_local_node_ids=sorted(sandwiched_local_node_ids),
|
|
reasons=reasons,
|
|
)
|