Files
ttulttul-ComfyUI-Modal/comfyui_modal_sync_cloud.py
T

386 lines
14 KiB
Python

"""Stable Modal cloud entrypoint for ComfyUI Modal-Sync."""
from __future__ import annotations
import copy
import hashlib
import json
import logging
import os
import sys
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
_REPO_ROOT = Path(__file__).resolve().parent
_REMOTE_REPO_ROOT = Path("/root/comfyui_modal_sync_repo")
_LOCAL_COMFYUI_ROOT = (Path.home() / "git" / "ComfyUI").resolve()
_REMOTE_COMFYUI_ROOT = Path("/root/comfyui_src")
for candidate in (_REPO_ROOT, _REMOTE_REPO_ROOT, _LOCAL_COMFYUI_ROOT, _REMOTE_COMFYUI_ROOT):
candidate_str = str(candidate)
try:
candidate_exists = candidate.exists()
except PermissionError:
candidate_exists = False
if candidate_exists and candidate_str not in sys.path:
sys.path.insert(0, candidate_str)
from serialization import deserialize_node_inputs, serialize_node_outputs
from settings import get_settings
logger = logging.getLogger(__name__)
try:
import modal # type: ignore
except ModuleNotFoundError: # pragma: no cover - remote entrypoint only.
modal = None
class RemoteSubgraphExecutionError(RuntimeError):
"""Raised when remote subgraph execution fails."""
class _NullPromptServer:
"""Minimal PromptExecutor server stub for headless subgraph execution."""
def __init__(self) -> None:
"""Initialize the no-op prompt server state."""
self.client_id: str | None = None
self.last_node_id: str | None = None
def send_sync(self, event: str, data: dict[str, Any], client_id: str | None) -> None:
"""Discard PromptExecutor progress and status events."""
logger.debug("Suppressed remote prompt event %s for client %s.", event, client_id)
@contextmanager
def _temporary_node_mapping(node_mapping: dict[str, type[Any]] | None) -> Iterator[None]:
"""Temporarily overlay node mappings for tests or custom runtimes."""
if node_mapping is None:
yield
return
import nodes
original_mappings = dict(nodes.NODE_CLASS_MAPPINGS)
original_display_mappings = dict(getattr(nodes, "NODE_DISPLAY_NAME_MAPPINGS", {}))
try:
nodes.NODE_CLASS_MAPPINGS.update(node_mapping)
for class_type in node_mapping:
nodes.NODE_DISPLAY_NAME_MAPPINGS.setdefault(class_type, class_type)
yield
finally:
nodes.NODE_CLASS_MAPPINGS.clear()
nodes.NODE_CLASS_MAPPINGS.update(original_mappings)
if hasattr(nodes, "NODE_DISPLAY_NAME_MAPPINGS"):
nodes.NODE_DISPLAY_NAME_MAPPINGS.clear()
nodes.NODE_DISPLAY_NAME_MAPPINGS.update(original_display_mappings)
def _extract_custom_nodes_bundle(bundle_path: str | None) -> None:
"""Extract a mirrored custom_nodes archive into a temporary import path."""
if not bundle_path:
return
settings = get_settings()
storage_roots = [Path(settings.remote_storage_root)]
if settings.local_storage_root is not None:
storage_roots.append(settings.local_storage_root)
local_bundle: Path | None = None
for storage_root in storage_roots:
candidate = storage_root / bundle_path.lstrip("/")
if candidate.exists():
local_bundle = candidate
break
if local_bundle is None:
logger.warning("Custom nodes bundle %s was not found in any known storage root.", bundle_path)
return
extraction_root = Path(tempfile.gettempdir()) / "comfy-modal-sync-custom-nodes"
extraction_root.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(local_bundle, "r") as archive:
archive.extractall(extraction_root)
if str(extraction_root) not in sys.path:
sys.path.insert(0, str(extraction_root))
logger.info("Extracted remote custom_nodes bundle to %s", extraction_root)
def _load_execution_module() -> Any:
"""Import the ComfyUI execution module lazily."""
import execution
return execution
def _load_nodes_module() -> Any:
"""Import the ComfyUI nodes module lazily."""
import nodes
return nodes
def _invoke_original_node(
node_class: type[Any],
node_data: dict[str, Any],
kwargs: dict[str, Any],
) -> tuple[Any, ...]:
"""Execute an original V1 or V3 node class and normalize its outputs."""
class_type = node_data["class_type"]
logger.info("Executing remote node %s", class_type)
if hasattr(node_class, "GET_SCHEMA"):
node_output = node_class.execute(**kwargs)
if hasattr(node_output, "result"):
result = node_output.result
return tuple(result) if result is not None else tuple()
return tuple(node_output)
instance = node_class()
function_name = getattr(node_class, "FUNCTION", "execute")
function = getattr(instance, function_name)
result = function(**kwargs)
if result is None:
return tuple()
if isinstance(result, tuple):
return result
if isinstance(result, list):
return tuple(result)
return (result,)
def execute_node_locally(
node_data: dict[str, Any],
kwargs_payload: bytes | bytearray | str | dict[str, Any],
node_mapping: dict[str, type[Any]] | None = None,
) -> bytes:
"""Execute a single target node in-process and return serialized outputs."""
_extract_custom_nodes_bundle(node_data.get("custom_nodes_bundle"))
kwargs = deserialize_node_inputs(kwargs_payload)
if node_mapping is not None:
class_type = node_data["class_type"]
if class_type not in node_mapping:
raise KeyError(f"Remote node class {class_type!r} is not registered.")
outputs = _invoke_original_node(node_mapping[class_type], node_data, kwargs)
return serialize_node_outputs(outputs)
with _temporary_node_mapping(node_mapping):
resolved_node_mapping = _load_nodes_module().NODE_CLASS_MAPPINGS
class_type = node_data["class_type"]
if class_type not in resolved_node_mapping:
raise KeyError(f"Remote node class {class_type!r} is not registered.")
outputs = _invoke_original_node(resolved_node_mapping[class_type], node_data, kwargs)
return serialize_node_outputs(outputs)
def _apply_boundary_inputs(
prompt: dict[str, Any],
boundary_input_specs: list[dict[str, Any]],
hydrated_inputs: dict[str, Any],
) -> None:
"""Inject hydrated local boundary inputs into a remote subgraph prompt."""
for boundary_input in boundary_input_specs:
proxy_input_name = str(boundary_input["proxy_input_name"])
if proxy_input_name not in hydrated_inputs:
raise KeyError(f"Missing hydrated boundary input {proxy_input_name!r}.")
value = hydrated_inputs[proxy_input_name]
for target in boundary_input.get("targets", []):
node_id = str(target["node_id"])
input_name = str(target["input_name"])
prompt[node_id]["inputs"][input_name] = value
def _collapse_cache_slot(slot_values: Any, is_list: bool) -> Any:
"""Convert a PromptExecutor cache slot back into a node-style output value."""
if is_list:
return slot_values
if not isinstance(slot_values, list):
return slot_values
if len(slot_values) == 1:
return slot_values[0]
return slot_values
def _extract_prompt_executor_error(executor: Any) -> str:
"""Extract a useful failure message from a PromptExecutor run."""
for event, data in reversed(executor.status_messages):
if event == "execution_error":
return str(data.get("exception_message") or "Remote subgraph execution failed.")
if event == "execution_interrupted":
return "Remote subgraph execution was interrupted."
return "Remote subgraph execution failed."
def _execute_subgraph_prompt(
payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
) -> tuple[Any, ...]:
"""Execute a remote component prompt and return its exported outputs."""
prompt = copy.deepcopy(payload["subgraph_prompt"])
_apply_boundary_inputs(
prompt=prompt,
boundary_input_specs=list(payload.get("boundary_inputs", [])),
hydrated_inputs=hydrated_inputs,
)
execution = _load_execution_module()
with _temporary_node_mapping(None):
executor = execution.PromptExecutor(_NullPromptServer())
executor.execute(
prompt=prompt,
prompt_id=str(payload.get("component_id", "modal-subgraph")),
extra_data=copy.deepcopy(payload.get("extra_data") or {}),
execute_outputs=list(payload.get("execute_node_ids", [])),
)
if not executor.success:
raise RemoteSubgraphExecutionError(_extract_prompt_executor_error(executor))
outputs: list[Any] = []
for boundary_output in payload.get("boundary_outputs", []):
node_id = str(boundary_output["node_id"])
output_index = int(boundary_output["output_index"])
cache_entry = executor.caches.outputs.get(node_id)
if cache_entry is None:
raise RemoteSubgraphExecutionError(
f"Remote subgraph did not produce cache entry for node {node_id}."
)
if output_index >= len(cache_entry.outputs):
raise RemoteSubgraphExecutionError(
f"Remote subgraph output index {output_index} is missing for node {node_id}."
)
outputs.append(
_collapse_cache_slot(
slot_values=cache_entry.outputs[output_index],
is_list=bool(boundary_output.get("is_list", False)),
)
)
return tuple(outputs)
def execute_subgraph_locally(
payload: dict[str, Any],
kwargs_payload: bytes | bytearray | str | dict[str, Any],
) -> bytes:
"""Execute a rewritten remote component in-process and return serialized outputs."""
_extract_custom_nodes_bundle(payload.get("custom_nodes_bundle"))
hydrated_inputs = deserialize_node_inputs(kwargs_payload)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_execute_subgraph_prompt, payload, hydrated_inputs)
outputs = future.result()
return serialize_node_outputs(outputs)
def _should_ignore_repo_path(path: Path) -> bool:
"""Return whether a local repo path should be omitted from the Modal image mount."""
parts = set(path.parts)
if {".git", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} & parts:
return True
return path.suffix.lower() in {".log", ".pyc", ".pyo", ".swp", ".tmp"}
def _should_ignore_comfyui_path(path: Path) -> bool:
"""Return whether a local ComfyUI path should be omitted from the Modal image mount."""
parts = set(path.parts)
if {
".git",
".venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
"input",
"models",
"output",
"temp",
"user",
} & parts:
return True
return path.suffix.lower() in {".bin", ".ckpt", ".log", ".pt", ".pyc", ".pyo", ".safetensors", ".swp", ".tmp"}
def _comfyui_runtime_packages() -> tuple[str, ...]:
"""Return the Python packages needed to import and execute ComfyUI core inside Modal."""
return (
"aiohttp",
"av",
"einops",
"numpy",
"opencv-python-headless",
"packaging",
"pillow",
"psutil",
"pydantic",
"pyyaml",
"requests",
"safetensors",
"scipy",
"sentencepiece",
"sqlalchemy",
"torch",
"torchsde",
"torchvision",
"tqdm",
"transformers",
)
if modal is not None: # pragma: no branch - remote entrypoint configuration.
settings = get_settings()
app = modal.App(settings.app_name)
vol = modal.Volume.from_name(settings.volume_name, create_if_missing=True)
image = (
modal.Image.debian_slim()
.pip_install(*_comfyui_runtime_packages())
.add_local_dir(
_REPO_ROOT,
remote_path="/root/comfyui_modal_sync_repo",
ignore=_should_ignore_repo_path,
)
)
if settings.comfyui_root is not None and settings.comfyui_root.exists():
image = image.add_local_dir(
settings.comfyui_root,
remote_path=str(_REMOTE_COMFYUI_ROOT),
ignore=_should_ignore_comfyui_path,
)
logger.info(
"Including local ComfyUI checkout %s in Modal image at %s.",
settings.comfyui_root,
_REMOTE_COMFYUI_ROOT,
)
else:
logger.warning(
"No local ComfyUI checkout was discovered; remote Modal execution may fail to import ComfyUI core modules."
)
@app.cls(
gpu="A100",
volumes={settings.remote_storage_root: vol},
scaledown_window=60,
image=image,
)
class RemoteEngine:
"""Modal runtime class that executes proxied ComfyUI payloads."""
@modal.enter()
def setup(self) -> None:
"""Prepare the container process for headless node execution."""
logger.info("RemoteEngine setup complete.")
@modal.method()
def execute_payload(self, payload: dict[str, Any], kwargs_payload: bytes) -> bytes:
"""Execute a proxied node or subgraph inside the Modal container."""
if payload.get("payload_kind") == "subgraph":
return execute_subgraph_locally(payload, kwargs_payload)
return execute_node_locally(payload, kwargs_payload)
else:
app = None
RemoteEngine = None