Files
ttulttul-ComfyUI-Modal/modal_executor_node.py
T

976 lines
37 KiB
Python

"""Dynamic Modal proxy nodes for ComfyUI execution."""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import logging
import threading
from collections import OrderedDict
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, Protocol
from comfy_api.latest import _io as io
from .serialization import deserialize_node_outputs, serialize_node_inputs, split_mapped_value
logger = logging.getLogger(__name__)
MODAL_MAP_INPUT_NODE_ID = "ModalMapInput"
MODAL_ARTIFACT_FINALIZER_NODE_ID = "ModalArtifactFinalizer"
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID = "ModalParallelLocalPassthrough"
MODAL_COMPONENT_COMPLETION_OUTPUT_NAME = "modal_component_complete"
MODAL_ARTIFACT_FINALIZER_MAX_COMPONENTS = 100
_PROXY_CACHE_CONTEXT_ID_KEY = "__comfy_modal_proxy_cache_context_id__"
_VOLATILE_PROXY_CACHE_KEYS = frozenset(
{
"prompt_id",
"remote_session",
"clear_remote_session",
"extra_data",
"requires_volume_reload",
"volume_reload_marker",
"uploaded_volume_paths",
}
)
class RemoteExecutorClient(Protocol):
"""Execution client interface used by Modal proxy nodes."""
def execute_payload(self, payload: Mapping[str, Any], kwargs: Mapping[str, Any]) -> Sequence[Any]:
"""Execute a serialized Modal payload and return its outputs."""
async def execute_payload_async(
self,
payload: Mapping[str, Any],
kwargs: Mapping[str, Any],
) -> Sequence[Any]:
"""Execute a serialized Modal payload asynchronously and return its outputs."""
class ModalRemoteExecutorClient:
"""Default execution client backed by the remote Modal app module."""
def execute_payload(self, payload: Mapping[str, Any], kwargs: Mapping[str, Any]) -> Sequence[Any]:
"""Serialize inputs, invoke the remote engine, and deserialize outputs."""
from .remote.modal_app import invoke_remote_engine
response = invoke_remote_engine(dict(payload), serialize_node_inputs(kwargs))
return deserialize_node_outputs(response)
async def execute_payload_async(
self,
payload: Mapping[str, Any],
kwargs: Mapping[str, Any],
) -> Sequence[Any]:
"""Serialize inputs, invoke the remote engine asynchronously, and deserialize outputs."""
from .remote.modal_app import invoke_remote_engine_async
response = await invoke_remote_engine_async(dict(payload), serialize_node_inputs(kwargs))
return deserialize_node_outputs(response)
_REMOTE_EXECUTOR_CLIENT_FACTORY: Callable[[], RemoteExecutorClient] = ModalRemoteExecutorClient
_PROXY_NODE_CACHE: dict[str, type[io.ComfyNode]] = {}
_PROXY_EXECUTION_CONTEXTS_LOCK = threading.Lock()
_PROXY_EXECUTION_CONTEXTS: dict[str, "_ProxyExecutionContext"] = {}
_MODAL_MAP_WARMUP_CONTEXTS_LOCK = threading.Lock()
_MODAL_MAP_WARMUP_CONTEXTS: dict[str, "_ModalMapWarmupContext"] = {}
_MODAL_WORKFLOW_EXECUTION_GATE = threading.Condition()
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID: str | None = None
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS = 0
_MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID: dict[str, int] = {}
_MODAL_PARALLEL_DISPATCH_EVENTS_LOCK = threading.Lock()
_MODAL_PARALLEL_DISPATCH_EVENTS: OrderedDict[
tuple[str, str], asyncio.Event
] = OrderedDict()
_MODAL_PARALLEL_DISPATCH_EVENT_LIMIT = 512
@dataclass(frozen=True)
class _ProxyExecutionContext:
"""Run-scoped execution context used to rehydrate cache-friendly proxy payloads."""
execution_payload: dict[str, Any]
@dataclass(frozen=True)
class _ModalMapWarmupContext:
"""Run-scoped warmup context used by one local Modal Map Input node."""
execution_payload: dict[str, Any]
mapped_io_type: str
def set_remote_executor_client_factory(
factory: Callable[[], RemoteExecutorClient] | None,
) -> None:
"""Install a custom client factory, primarily for tests."""
global _REMOTE_EXECUTOR_CLIENT_FACTORY
_REMOTE_EXECUTOR_CLIENT_FACTORY = factory or ModalRemoteExecutorClient
def get_remote_executor_client() -> RemoteExecutorClient:
"""Instantiate the configured execution client."""
return _REMOTE_EXECUTOR_CLIENT_FACTORY()
def _acquire_modal_workflow_execution_slot(
prompt_id: str | None,
component_id: Any,
) -> bool:
"""Try to reserve a remote execution slot for one prompt."""
if prompt_id is None:
return False
global _MODAL_WORKFLOW_ACTIVE_PROMPT_ID
global _MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS
with _MODAL_WORKFLOW_EXECUTION_GATE:
if (
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID is not None
and _MODAL_WORKFLOW_ACTIVE_PROMPT_ID != prompt_id
):
return False
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID = prompt_id
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS += 1
logger.debug(
"Acquired Modal workflow execution slot for prompt=%s component=%s active_remote_calls=%d.",
prompt_id,
component_id,
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS,
)
return True
def _release_modal_workflow_execution_slot(prompt_id: str | None, component_id: Any) -> None:
"""Release a prompt-scoped remote execution slot and unblock the next prompt if idle."""
if prompt_id is None:
return
global _MODAL_WORKFLOW_ACTIVE_PROMPT_ID
global _MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS
global _MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID
with _MODAL_WORKFLOW_EXECUTION_GATE:
if _MODAL_WORKFLOW_ACTIVE_PROMPT_ID != prompt_id:
abandoned_release_count = _MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID.get(
prompt_id,
0,
)
if abandoned_release_count > 0:
logger.debug(
"Ignoring Modal workflow gate release for abandoned prompt=%s component=%s.",
prompt_id,
component_id,
)
if abandoned_release_count == 1:
_MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID.pop(prompt_id, None)
else:
_MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID[prompt_id] = (
abandoned_release_count - 1
)
else:
logger.warning(
"Ignoring Modal workflow gate release for prompt=%s component=%s because active prompt is %s.",
prompt_id,
component_id,
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID,
)
return
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS = max(0, _MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS - 1)
logger.debug(
"Released Modal workflow execution slot for prompt=%s component=%s active_remote_calls=%d.",
prompt_id,
component_id,
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS,
)
if _MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS == 0:
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID = None
_MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID.pop(prompt_id, None)
_MODAL_WORKFLOW_EXECUTION_GATE.notify_all()
def abandon_modal_workflow_execution_prompt(prompt_id: str | None, reason: str) -> None:
"""Release a prompt-wide Modal gate when ComfyUI has cancelled that prompt locally."""
normalized_prompt_id = _normalize_prompt_id(prompt_id)
if normalized_prompt_id is None:
return
_clear_parallel_dispatch_events(normalized_prompt_id)
global _MODAL_WORKFLOW_ACTIVE_PROMPT_ID
global _MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS
global _MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID
with _MODAL_WORKFLOW_EXECUTION_GATE:
if _MODAL_WORKFLOW_ACTIVE_PROMPT_ID != normalized_prompt_id:
return
logger.info(
"Abandoning Modal workflow execution slot for prompt=%s with %d active remote call(s): %s.",
normalized_prompt_id,
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS,
reason,
)
_MODAL_WORKFLOW_ABANDONED_RELEASES_BY_PROMPT_ID[normalized_prompt_id] = (
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS
)
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID = None
_MODAL_WORKFLOW_ACTIVE_REMOTE_CALLS = 0
_MODAL_WORKFLOW_EXECUTION_GATE.notify_all()
@asynccontextmanager
async def _modal_workflow_execution_slot(payload: Mapping[str, Any]) -> AsyncIterator[None]:
"""Async context that serializes remote work across different prompt ids."""
prompt_id = _normalize_prompt_id(payload.get("prompt_id"))
component_id = payload.get("component_id")
if prompt_id is None:
yield
return
acquired = False
waiting_logged = False
while not acquired:
acquired = await asyncio.to_thread(
_acquire_modal_workflow_execution_slot,
prompt_id,
component_id,
)
if not acquired:
if not waiting_logged:
logger.info(
"Waiting to start Modal component=%s for prompt=%s until active prompt=%s finishes remote execution.",
component_id,
prompt_id,
_MODAL_WORKFLOW_ACTIVE_PROMPT_ID,
)
waiting_logged = True
await asyncio.sleep(0.1)
try:
yield
finally:
if acquired:
await asyncio.to_thread(
_release_modal_workflow_execution_slot,
prompt_id,
component_id,
)
def _parallel_dispatch_key(
dispatch_group_id: Any,
component_id: Any,
) -> tuple[str, str] | None:
"""Return one normalized parallel-preview dispatch key."""
normalized_group_id = _normalize_prompt_id(dispatch_group_id)
normalized_component_id = _normalize_prompt_id(component_id)
if normalized_group_id is None or normalized_component_id is None:
return None
return (normalized_group_id, normalized_component_id)
def _parallel_dispatch_event(
dispatch_group_id: Any,
component_id: Any,
) -> asyncio.Event | None:
"""Return the bounded dispatch event for one remote continuation."""
dispatch_key = _parallel_dispatch_key(dispatch_group_id, component_id)
if dispatch_key is None:
return None
with _MODAL_PARALLEL_DISPATCH_EVENTS_LOCK:
dispatch_event = _MODAL_PARALLEL_DISPATCH_EVENTS.get(dispatch_key)
if dispatch_event is None:
dispatch_event = asyncio.Event()
_MODAL_PARALLEL_DISPATCH_EVENTS[dispatch_key] = dispatch_event
while len(_MODAL_PARALLEL_DISPATCH_EVENTS) > (
_MODAL_PARALLEL_DISPATCH_EVENT_LIMIT
):
completed_dispatch_key = next(
(
candidate_key
for candidate_key, candidate_event in (
_MODAL_PARALLEL_DISPATCH_EVENTS.items()
)
if candidate_event.is_set()
),
None,
)
if completed_dispatch_key is None:
break
_MODAL_PARALLEL_DISPATCH_EVENTS.pop(completed_dispatch_key, None)
else:
_MODAL_PARALLEL_DISPATCH_EVENTS.move_to_end(dispatch_key)
return dispatch_event
def _signal_parallel_local_dispatch(payload: Mapping[str, Any]) -> bool:
"""Signal that one remote continuation acquired its local dispatch slot."""
if not bool(payload.get("signal_parallel_local_dispatch")):
return False
dispatch_event = _parallel_dispatch_event(
payload.get("parallel_local_dispatch_group_id"),
payload.get("component_id"),
)
if dispatch_event is None:
return False
dispatch_event.set()
logger.info(
"Released parallel local branches after remote dispatch started group=%s component=%s.",
payload.get("parallel_local_dispatch_group_id"),
payload.get("component_id"),
)
return True
async def _wait_for_parallel_local_dispatches(
dispatch_context: Mapping[str, Any],
) -> None:
"""Wait until every nearest downstream remote continuation has started."""
dispatch_group_id = dispatch_context.get("dispatch_group_id")
component_ids = dispatch_context.get("component_ids")
if not isinstance(component_ids, Sequence) or isinstance(
component_ids,
str | bytes | bytearray,
):
raise TypeError("Parallel local dispatch context must define component_ids.")
dispatch_events = [
dispatch_event
for component_id in component_ids
if (
dispatch_event := _parallel_dispatch_event(
dispatch_group_id,
component_id,
)
)
is not None
]
if not dispatch_events:
raise RuntimeError(
"Parallel local dispatch context did not identify a remote continuation."
)
await asyncio.gather(*(dispatch_event.wait() for dispatch_event in dispatch_events))
def _clear_parallel_dispatch_events(dispatch_group_id: str) -> None:
"""Forget dispatch events for one completed or abandoned prompt group."""
with _MODAL_PARALLEL_DISPATCH_EVENTS_LOCK:
dispatch_keys = [
dispatch_key
for dispatch_key in _MODAL_PARALLEL_DISPATCH_EVENTS
if dispatch_key[0] == dispatch_group_id
]
for dispatch_key in dispatch_keys:
_MODAL_PARALLEL_DISPATCH_EVENTS.pop(dispatch_key, None)
async def _execute_payload_async(
client: RemoteExecutorClient,
payload: Mapping[str, Any],
kwargs: Mapping[str, Any],
) -> Sequence[Any]:
"""Execute one Modal payload through the client, adapting sync clients when needed."""
execute_payload_async = getattr(client, "execute_payload_async", None)
if callable(execute_payload_async):
result = execute_payload_async(payload, kwargs)
if inspect.isawaitable(result):
return await result
return result
execute_payload = getattr(client, "execute_payload", None)
if not callable(execute_payload):
raise TypeError("Remote executor client must define execute_payload or execute_payload_async.")
return await asyncio.to_thread(execute_payload, payload, kwargs)
def _output_spec(io_type: str, name: str, is_list: bool) -> io.Output:
"""Create a v3 output specification from a legacy ComfyUI return type."""
comfy_type = io.AnyType if io_type == "*" else io.Custom(io_type)
return comfy_type.Output(display_name=name, is_output_list=is_list)
def _normalize_proxy_kwargs(kwargs: Mapping[str, Any]) -> dict[str, Any]:
"""Convert ComfyUI INPUT_IS_LIST proxy kwargs back into ordinary runtime values."""
normalized_kwargs: dict[str, Any] = {}
for input_name, input_value in kwargs.items():
if isinstance(input_value, list) and len(input_value) == 1:
normalized_kwargs[str(input_name)] = input_value[0]
continue
normalized_kwargs[str(input_name)] = input_value
return normalized_kwargs
def _normalize_proxy_payload(payload: Any) -> Mapping[str, Any]:
"""Convert ComfyUI INPUT_IS_LIST payload wrappers back into one payload mapping."""
if isinstance(payload, list) and len(payload) == 1:
payload = payload[0]
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, Mapping):
raise TypeError("original_node_data must be a mapping or JSON object.")
return payload
def _normalize_scheduler_list_outputs(
payload: Mapping[str, Any],
outputs: Sequence[Any],
) -> tuple[Any, ...]:
"""Make remote output containers match the proxy's scheduler-list declarations."""
normalized_outputs = list(outputs)
boundary_outputs = payload.get("boundary_outputs")
if not isinstance(boundary_outputs, Sequence) or isinstance(
boundary_outputs,
str | bytes | bytearray,
):
return tuple(normalized_outputs)
for output_index, boundary_output in enumerate(boundary_outputs):
if output_index >= len(normalized_outputs):
break
if not isinstance(boundary_output, Mapping):
continue
if not bool(boundary_output.get("scheduler_is_list", False)):
continue
if isinstance(normalized_outputs[output_index], list):
continue
normalized_outputs[output_index] = [normalized_outputs[output_index]]
logger.debug(
"Wrapped singleton remote output %d for component=%s to satisfy its scheduler-list contract.",
output_index,
payload.get("component_id"),
)
return tuple(normalized_outputs)
def _normalize_prompt_id(value: Any) -> str | None:
"""Return one non-empty prompt id string when available."""
if value is None:
return None
prompt_id = str(value).strip()
return prompt_id or None
def _payload_is_local_cache_safe(payload: Mapping[str, Any]) -> bool:
"""Return whether one proxy payload can safely reuse local ComfyUI outputs across prompt runs."""
split_proxy_payloads = payload.get("split_proxy_payloads")
if isinstance(split_proxy_payloads, Mapping):
return all(
isinstance(nested_payload, Mapping) and _payload_is_local_cache_safe(nested_payload)
for nested_payload in split_proxy_payloads.values()
)
if isinstance(split_proxy_payloads, Sequence) and not isinstance(split_proxy_payloads, (str, bytes, bytearray)):
return all(
isinstance(nested_payload, Mapping) and _payload_is_local_cache_safe(nested_payload)
for nested_payload in split_proxy_payloads
)
for phase_name in ("static_phase", "mapped_phase"):
phase_payload = payload.get(phase_name)
if isinstance(phase_payload, Mapping) and not _payload_is_local_cache_safe(phase_payload):
return False
return True
def _sanitize_cache_surface_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
"""Strip run-scoped fields from one proxy payload before exposing it to ComfyUI caching."""
sanitized_payload = dict(payload)
for field_name in _VOLATILE_PROXY_CACHE_KEYS:
sanitized_payload.pop(field_name, None)
split_proxy_payloads = sanitized_payload.get("split_proxy_payloads")
if isinstance(split_proxy_payloads, Mapping):
sanitized_payload["split_proxy_payloads"] = {
str(phase_name): _sanitize_cache_surface_payload(dict(phase_payload))
for phase_name, phase_payload in split_proxy_payloads.items()
if isinstance(phase_payload, Mapping)
}
elif isinstance(split_proxy_payloads, Sequence) and not isinstance(
split_proxy_payloads,
(str, bytes, bytearray),
):
sanitized_payload["split_proxy_payloads"] = [
_sanitize_cache_surface_payload(dict(phase_payload))
for phase_payload in split_proxy_payloads
if isinstance(phase_payload, Mapping)
]
for phase_name in ("static_phase", "mapped_phase"):
phase_payload = sanitized_payload.get(phase_name)
if isinstance(phase_payload, Mapping):
sanitized_payload[phase_name] = _sanitize_cache_surface_payload(dict(phase_payload))
return sanitized_payload
def register_cache_friendly_proxy_payload(
node_id: str,
payload: Mapping[str, Any],
) -> Mapping[str, Any]:
"""Return the payload that should be embedded in the proxy node input for local cache reuse."""
if not _payload_is_local_cache_safe(payload):
return dict(payload)
sanitized_payload = _sanitize_cache_surface_payload(payload)
sanitized_payload[_PROXY_CACHE_CONTEXT_ID_KEY] = str(node_id)
with _PROXY_EXECUTION_CONTEXTS_LOCK:
_PROXY_EXECUTION_CONTEXTS[str(node_id)] = _ProxyExecutionContext(
execution_payload=dict(payload),
)
logger.debug(
"Registered cache-friendly Modal proxy payload for node_id=%s prompt_id=%s session_backed=%s.",
node_id,
_normalize_prompt_id(payload.get("prompt_id")),
payload.get("remote_session") is not None,
)
return sanitized_payload
def update_registered_proxy_payload_fields(
node_id: str,
embedded_payload: Mapping[str, Any],
fields: Mapping[str, Any],
) -> Mapping[str, Any]:
"""Update both the embedded cache surface and run-scoped execution payload."""
with _PROXY_EXECUTION_CONTEXTS_LOCK:
context = _PROXY_EXECUTION_CONTEXTS.get(str(node_id))
execution_payload = dict(
context.execution_payload if context is not None else embedded_payload
)
execution_payload.update(fields)
return register_cache_friendly_proxy_payload(node_id, execution_payload)
def register_modal_map_input_warmup_context(
node_id: str,
payload: Mapping[str, Any],
mapped_io_type: str,
) -> None:
"""Register prompt-scoped warmup metadata for one local Modal Map Input node."""
with _MODAL_MAP_WARMUP_CONTEXTS_LOCK:
_MODAL_MAP_WARMUP_CONTEXTS[str(node_id)] = _ModalMapWarmupContext(
execution_payload=dict(payload),
mapped_io_type=str(mapped_io_type or "*"),
)
logger.debug(
"Registered Modal Map Input warmup context for node_id=%s component_id=%s prompt_id=%s io_type=%s.",
node_id,
payload.get("component_id"),
_normalize_prompt_id(payload.get("prompt_id")),
mapped_io_type,
)
def _boost_modal_map_input_warmup(
unique_id: str | None,
value: Any,
) -> None:
"""Best-effort exact warmup boost for one local Modal Map Input execution."""
if unique_id is None:
return
with _MODAL_MAP_WARMUP_CONTEXTS_LOCK:
context = _MODAL_MAP_WARMUP_CONTEXTS.get(str(unique_id))
if context is None:
return
try:
total_items = len(split_mapped_value(value, context.mapped_io_type))
except (TypeError, ValueError) as exc:
logger.debug(
"Skipping Modal Map Input warmup boost for node_id=%s because the runtime value was not splittable as io_type=%s: %s",
unique_id,
context.mapped_io_type,
exc,
)
return
from .remote.modal_app import boost_mapped_component_warmup
boost_mapped_component_warmup(
payload=context.execution_payload,
total_items=total_items,
reason="modal_map_input_execute",
)
def _rehydrate_proxy_payload(
payload: Mapping[str, Any],
*,
unique_id: str | None,
) -> Mapping[str, Any]:
"""Restore any execution-scoped fields stripped from a cache-friendly proxy payload."""
context_id = unique_id
if context_id is None:
candidate_context_id = payload.get(_PROXY_CACHE_CONTEXT_ID_KEY)
if candidate_context_id is not None:
normalized_context_id = str(candidate_context_id).strip()
context_id = normalized_context_id or None
if context_id is None:
return payload
with _PROXY_EXECUTION_CONTEXTS_LOCK:
context = _PROXY_EXECUTION_CONTEXTS.get(str(context_id))
if context is None:
return payload
return dict(context.execution_payload)
def _normalized_output_metadata(
original_class: type[Any],
) -> tuple[tuple[str, ...], tuple[str, ...], tuple[bool, ...]]:
"""Normalize output metadata from a source node class."""
if hasattr(original_class, "GET_SCHEMA"):
original_class.GET_SCHEMA()
output_types = tuple(getattr(original_class, "RETURN_TYPES", ("*",))) or ("*",)
default_names = tuple(f"output_{index}" for index, _ in enumerate(output_types))
output_names = tuple(getattr(original_class, "RETURN_NAMES", default_names))
output_is_list = tuple(getattr(original_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 _proxy_node_id(original_class_type: str, output_types: Sequence[str]) -> str:
"""Build a stable proxy node identifier for an original node signature."""
digest = hashlib.sha256(
json.dumps({"class_type": original_class_type, "outputs": list(output_types)}).encode(
"utf-8"
)
).hexdigest()[:12]
return f"ModalUniversalExecutor_{digest}"
def _build_proxy_node_class(
node_id: str,
proxy_display_name: str,
payload_input_name: str,
output_types: tuple[str, ...],
output_names: tuple[str, ...],
output_is_list: tuple[bool, ...],
*,
is_output_node: bool,
include_completion_output: bool = False,
) -> type[io.ComfyNode]:
"""Create a v3 proxy node that mirrors an original node output signature."""
class _DynamicModalExecutor(io.ComfyNode):
"""Internal proxy node that forwards execution to Modal."""
OUTPUT_NODE = is_output_node
@classmethod
def define_schema(cls) -> io.Schema:
"""Return a schema that accepts any original node inputs."""
outputs = [
_output_spec(io_type, name, is_list)
for io_type, name, is_list in zip(output_types, output_names, output_is_list, strict=False)
]
if include_completion_output:
outputs.append(
io.Boolean.Output(
display_name=MODAL_COMPONENT_COMPLETION_OUTPUT_NAME,
)
)
return io.Schema(
node_id=node_id,
display_name=proxy_display_name,
category="Modal",
description=(
"Internal proxy node that forwards a rewritten Modal execution "
"payload to a Modal-backed runtime."
),
inputs=[
io.AnyType.Input(payload_input_name),
],
outputs=outputs,
is_input_list=True,
accept_all_inputs=True,
hidden=[io.Hidden.unique_id],
is_dev_only=True,
is_experimental=True,
)
@classmethod
async def execute(cls, **kwargs: Any) -> io.NodeOutput:
"""Forward the execution payload to the configured remote executor."""
unique_id = _normalize_prompt_id(kwargs.pop(io.Hidden.unique_id.name, None))
payload = _rehydrate_proxy_payload(
_normalize_proxy_payload(kwargs.pop(payload_input_name, None)),
unique_id=unique_id,
)
async with _modal_workflow_execution_slot(payload):
_signal_parallel_local_dispatch(payload)
outputs = _normalize_scheduler_list_outputs(
payload,
await _execute_payload_async(
get_remote_executor_client(),
payload,
_normalize_proxy_kwargs(kwargs),
),
)
logger.debug(
"Remote execution completed for payload kind=%s with %d outputs.",
payload.get("payload_kind"),
len(outputs),
)
if include_completion_output:
return io.NodeOutput(*outputs, True)
return io.NodeOutput(*outputs)
_DynamicModalExecutor.__name__ = f"DynamicModalExecutor_{node_id}"
return _DynamicModalExecutor
def ensure_modal_proxy_node_registered(
original_class_type: str,
original_class: type[Any],
nodes_module: Any,
) -> str:
"""Register and return a proxy node id for the supplied original node class."""
output_types, output_names, output_is_list = _normalized_output_metadata(original_class)
proxy_node_id = _proxy_node_id(original_class_type, output_types)
if proxy_node_id in _PROXY_NODE_CACHE:
nodes_module.NODE_CLASS_MAPPINGS[proxy_node_id] = _PROXY_NODE_CACHE[proxy_node_id]
nodes_module.NODE_DISPLAY_NAME_MAPPINGS[proxy_node_id] = "Modal Universal Executor"
return proxy_node_id
proxy_class = _build_proxy_node_class(
node_id=proxy_node_id,
proxy_display_name="Modal Universal Executor",
payload_input_name="original_node_data",
output_types=output_types,
output_names=output_names,
output_is_list=output_is_list,
is_output_node=False,
)
nodes_module.NODE_CLASS_MAPPINGS[proxy_node_id] = proxy_class
nodes_module.NODE_DISPLAY_NAME_MAPPINGS[proxy_node_id] = "Modal Universal Executor"
_PROXY_NODE_CACHE[proxy_node_id] = proxy_class
logger.info("Registered Modal proxy node %s for %s", proxy_node_id, original_class_type)
return proxy_node_id
def ensure_modal_component_proxy_node_registered(
output_types: Sequence[str],
output_names: Sequence[str],
output_is_list: Sequence[bool],
nodes_module: Any,
*,
is_output_node: bool,
include_completion_output: bool = False,
) -> str:
"""Register and return a proxy node id for a remote component signature."""
normalized_output_types = tuple(output_types)
normalized_output_names = tuple(output_names)
normalized_output_is_list = tuple(output_is_list)
proxy_node_id = _proxy_node_id(
"ModalRemoteComponent",
normalized_output_types
+ tuple(f"name:{name}" for name in normalized_output_names)
+ tuple(f"list:{is_list}" for is_list in normalized_output_is_list)
+ (str(is_output_node), str(include_completion_output)),
)
if proxy_node_id in _PROXY_NODE_CACHE:
nodes_module.NODE_CLASS_MAPPINGS[proxy_node_id] = _PROXY_NODE_CACHE[proxy_node_id]
nodes_module.NODE_DISPLAY_NAME_MAPPINGS[proxy_node_id] = "Modal Remote Component"
return proxy_node_id
proxy_class = _build_proxy_node_class(
node_id=proxy_node_id,
proxy_display_name="Modal Remote Component",
payload_input_name="original_node_data",
output_types=normalized_output_types,
output_names=normalized_output_names,
output_is_list=normalized_output_is_list,
is_output_node=is_output_node,
include_completion_output=include_completion_output,
)
nodes_module.NODE_CLASS_MAPPINGS[proxy_node_id] = proxy_class
nodes_module.NODE_DISPLAY_NAME_MAPPINGS[proxy_node_id] = "Modal Remote Component"
_PROXY_NODE_CACHE[proxy_node_id] = proxy_class
logger.info("Registered Modal component proxy node %s", proxy_node_id)
return proxy_node_id
def ensure_modal_artifact_finalizer_registered(nodes_module: Any) -> None:
"""Register the internal artifact-finalization sink in a ComfyUI node mapping."""
nodes_module.NODE_CLASS_MAPPINGS[MODAL_ARTIFACT_FINALIZER_NODE_ID] = (
ModalArtifactFinalizer
)
nodes_module.NODE_DISPLAY_NAME_MAPPINGS[MODAL_ARTIFACT_FINALIZER_NODE_ID] = (
"Modal Artifact Finalizer"
)
def ensure_modal_parallel_local_passthrough_registered(nodes_module: Any) -> None:
"""Register the internal parallel local-branch dispatch gate."""
nodes_module.NODE_CLASS_MAPPINGS[MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID] = (
ModalParallelLocalPassthrough
)
nodes_module.NODE_DISPLAY_NAME_MAPPINGS[
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID
] = "Modal Parallel Local Passthrough"
class ModalUniversalExecutor(io.ComfyNode):
"""Base debug node for Modal execution routing."""
@classmethod
def define_schema(cls) -> io.Schema:
"""Define a minimal dev-only schema for the base proxy node."""
return io.Schema(
node_id="ModalUniversalExecutor",
display_name="Modal Universal Executor",
category="Modal",
description=(
"Debug entrypoint for Modal-backed execution. Production rewrites use "
"signature-preserving dynamic proxy variants."
),
inputs=[io.AnyType.Input("original_node_data")],
outputs=[io.AnyType.Output(display_name="output")],
accept_all_inputs=True,
is_dev_only=True,
is_experimental=True,
)
@classmethod
def execute(cls, original_node_data: Any, **kwargs: Any) -> io.NodeOutput:
"""Execute the base debug proxy node through the configured client."""
if isinstance(original_node_data, str):
original_node_data = json.loads(original_node_data)
outputs = tuple(get_remote_executor_client().execute_node(original_node_data, kwargs))
return io.NodeOutput(*outputs)
class ModalMapInput(io.ComfyNode):
"""Queue-time marker node that turns one remote boundary input into mapped parallel work."""
@classmethod
def define_schema(cls) -> io.Schema:
"""Expose a simple any-to-any adapter node for mapped remote execution."""
return io.Schema(
node_id=MODAL_MAP_INPUT_NODE_ID,
display_name="Modal Map Input",
category="Modal",
description=(
"Pass-through marker for data-parallel Modal execution. "
"When used inside a remote-marked component, list inputs and batched tensors "
"can fan out across multiple Modal executions and reassemble automatically."
),
inputs=[io.AnyType.Input("value")],
outputs=[io.AnyType.Output(display_name="value")],
hidden=[io.Hidden.unique_id],
is_experimental=True,
)
@classmethod
def execute(cls, value: Any, unique_id: str | None = None) -> io.NodeOutput:
"""Pass the input value through unchanged at runtime."""
_boost_modal_map_input_warmup(
_normalize_prompt_id(unique_id),
value,
)
return io.NodeOutput(value)
class ModalArtifactFinalizer(io.ComfyNode):
"""Internal output sink that anchors remote execution and artifact materialization."""
@classmethod
def define_schema(cls) -> io.Schema:
"""Accept one completion token from every rewritten terminal Modal component."""
completion_template = io.Autogrow.TemplatePrefix(
input=io.Boolean.Input("completion", force_input=True),
prefix="component_",
min=1,
max=MODAL_ARTIFACT_FINALIZER_MAX_COMPONENTS,
)
return io.Schema(
node_id=MODAL_ARTIFACT_FINALIZER_NODE_ID,
display_name="Modal Artifact Finalizer",
category="Modal",
description=(
"Internal output sink inserted into rewritten prompts so Modal components "
"run and materialize their remote output artifacts locally."
),
inputs=[
io.Autogrow.Input(
"components",
template=completion_template,
)
],
outputs=[],
is_output_node=True,
is_dev_only=True,
is_experimental=True,
)
@classmethod
def execute(cls, components: io.Autogrow.Type) -> io.NodeOutput:
"""Confirm every upstream Modal component completed before the prompt finishes."""
incomplete_component_names = [
str(component_name)
for component_name, completed in components.items()
if completed is not True
]
if incomplete_component_names:
raise RuntimeError(
"Modal artifact finalization received incomplete component tokens: "
f"{incomplete_component_names}."
)
logger.info(
"Modal artifact finalization completed after %d remote component(s).",
len(components),
)
return io.NodeOutput()
class ModalParallelLocalPassthrough(io.ComfyNode):
"""Release a local-only branch once continuing remote work is in flight."""
@classmethod
def define_schema(cls) -> io.Schema:
"""Accept one arbitrary value plus remote dispatch metadata."""
return io.Schema(
node_id=MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID,
display_name="Modal Parallel Local Passthrough",
category="Modal",
description=(
"Internal async gate that releases an independent local tap once "
"downstream Modal execution has started."
),
inputs=[
io.AnyType.Input("value"),
io.AnyType.Input("dispatch_context"),
],
outputs=[io.AnyType.Output(display_name="value")],
is_dev_only=True,
is_experimental=True,
)
@classmethod
async def execute(
cls,
value: Any,
dispatch_context: Mapping[str, Any],
) -> io.NodeOutput:
"""Return the original value once the remote continuation is in flight."""
await _wait_for_parallel_local_dispatches(dispatch_context)
return io.NodeOutput(value)