"""Local ComfyUI websocket event emission for remote execution.""" from __future__ import annotations from io import BytesIO import logging import time from typing import Any, Mapping from .local_execution import _iter_prompt_links logger = logging.getLogger(__name__) def _lookup_local_prompt_server() -> Any | None: """Return the live local ComfyUI PromptServer instance when available.""" try: import server except ModuleNotFoundError: return None return getattr(server.PromptServer, "instance", None) def _record_local_modal_ui_event( event: str, payload: Mapping[str, Any], client_id: str | None ) -> None: """Record one local Modal UI event for browser refocus replay when available.""" if client_id is None: return try: from ..api_intercept import record_modal_ui_event except (ImportError, AttributeError): logger.debug( "Modal UI event replay recorder is unavailable for event %s.", event ) return record_modal_ui_event(event, payload, client_id) def _emit_local_modal_status( *, prompt_id: str | None, client_id: str | None, phase: str, node_ids: list[str], modal_gpu: str | None = None, active_node_id: str | None = None, active_node_class_type: str | None = None, active_node_role: str | None = None, error_message: str | None = None, status_message: str | None = None, status_current: int | None = None, status_total: int | None = None, completed_ancestor_node_ids: list[str] | None = None, execution_provider: str | None = None, execution_environment_id: str | None = None, execution_location: str | None = None, ) -> None: """Forward remote execution progress into the local ComfyUI websocket stream.""" if client_id is None: return prompt_server = _lookup_local_prompt_server() if prompt_server is None: return payload: dict[str, Any] = { "phase": phase, "prompt_id": prompt_id, "node_ids": list(node_ids), } if modal_gpu is not None: payload["modal_gpu"] = modal_gpu if active_node_id is not None: payload["active_node_id"] = active_node_id if active_node_class_type is not None: payload["active_node_class_type"] = active_node_class_type if active_node_role is not None: payload["active_node_role"] = active_node_role if error_message is not None: payload["error_message"] = error_message if status_message is not None: payload["status_message"] = status_message if status_current is not None: payload["status_current"] = int(status_current) if status_total is not None: payload["status_total"] = int(status_total) if completed_ancestor_node_ids: payload["completed_ancestor_node_ids"] = list(completed_ancestor_node_ids) if execution_provider: payload["execution_provider"] = execution_provider if execution_environment_id: payload["execution_environment_id"] = execution_environment_id if execution_location: payload["execution_location"] = execution_location _record_local_modal_ui_event("modal_status", payload, client_id) prompt_server.send_sync("modal_status", payload, client_id) def _remote_execution_destination(payload: Mapping[str, Any]) -> str: """Return the user-facing destination for a provider-stamped payload.""" environment_id = str(payload.get("execution_environment_id") or "").strip() provider = str(payload.get("execution_provider") or "modal").strip() return ( environment_id if provider != "modal" and environment_id else "Modal" ) def _remote_execution_identity( payload: Mapping[str, Any], modal_task_id: str | None = None, ) -> dict[str, str]: """Return non-empty provider, environment, and runtime location fields.""" provider = str( payload.get("execution_provider") or ("modal" if modal_task_id else "") ).strip() environment_id = str(payload.get("execution_environment_id") or "").strip() location = ( modal_task_id if provider == "modal" and modal_task_id else str(payload.get("execution_location") or "").strip() ) return { key: value for key, value in { "execution_provider": provider, "execution_environment_id": environment_id, "execution_location": location, }.items() if value } def _emit_local_remote_dispatch_status(payload: dict[str, Any]) -> None: """Tell the local UI a remote component was dispatched before progress streams.""" _emit_local_remote_startup_status( payload, phase="starting", status_message=( f"Starting remote component on {_remote_execution_destination(payload)}" ), ) def _emit_local_remote_startup_status( payload: Mapping[str, Any], *, phase: str, status_message: str, ) -> None: """Show one prompt-scoped Modal startup phase in the local global status pill.""" prompt_id = ( str(payload.get("prompt_id")) if payload.get("prompt_id") is not None else None ) extra_data = payload.get("extra_data") or {} client_id = ( str(extra_data.get("client_id")) if extra_data.get("client_id") is not None else None ) node_ids = [ str(node_id) for node_id in payload.get("component_node_ids", []) if str(node_id) ] if not node_ids and payload.get("component_id") is not None: node_ids = [str(payload["component_id"])] if not prompt_id or not client_id or not node_ids: return _emit_local_modal_status( prompt_id=prompt_id, client_id=client_id, phase=phase, node_ids=node_ids, modal_gpu=( str(payload["modal_gpu"]) if payload.get("modal_gpu") is not None else None ), status_message=status_message, **_remote_execution_identity(payload), ) def _emit_local_modal_progress( *, prompt_id: str | None, client_id: str | None, node_id: str, value: float, max_value: float, display_node_id: str | None = None, real_node_id: str | None = None, lane_id: str | None = None, clear: bool = False, item_index: int | None = None, aggregate_only: bool = False, setup_only: bool = False, cached_hit: bool = False, completed_ancestor_node_ids: list[str] | None = None, stage: str | None = None, message: str | None = None, unit: str | None = None, indeterminate: bool = False, elapsed_seconds: float | None = None, time_to_first_token_seconds: float | None = None, tokens_per_second: float | None = None, pre_gpu: bool = False, execution_provider: str | None = None, execution_environment_id: str | None = None, execution_location: str | None = None, ) -> None: """Forward remote numeric and stage progress into the local websocket stream.""" if client_id is None: return prompt_server = _lookup_local_prompt_server() if prompt_server is None: return payload: dict[str, Any] = { "prompt_id": prompt_id, "node_id": node_id, "value": float(value), "max": float(max_value), } if display_node_id is not None: payload["display_node_id"] = display_node_id if real_node_id is not None: payload["real_node_id"] = real_node_id if lane_id is not None: payload["lane_id"] = lane_id if clear: payload["clear"] = True if item_index is not None: payload["item_index"] = int(item_index) if aggregate_only: payload["aggregate_only"] = True if setup_only: payload["setup_only"] = True if cached_hit: payload["cached_hit"] = True if completed_ancestor_node_ids: payload["completed_ancestor_node_ids"] = list(completed_ancestor_node_ids) if stage: payload["stage"] = stage if message: payload["message"] = message if unit: payload["unit"] = unit if indeterminate: payload["indeterminate"] = True if elapsed_seconds is not None: payload["elapsed_seconds"] = float(elapsed_seconds) if time_to_first_token_seconds is not None: payload["time_to_first_token_seconds"] = float( time_to_first_token_seconds ) if tokens_per_second is not None: payload["tokens_per_second"] = float(tokens_per_second) if pre_gpu: payload["pre_gpu"] = True if execution_provider: payload["execution_provider"] = execution_provider if execution_environment_id: payload["execution_environment_id"] = execution_environment_id if execution_location: payload["execution_location"] = execution_location _record_local_modal_ui_event("modal_progress", payload, client_id) prompt_server.send_sync("modal_progress", payload, client_id) def _emit_local_executed_output( *, prompt_id: str | None, client_id: str | None, node_id: str, display_node_id: str | None, output_payload: Any, ) -> None: """Forward one remote node's executed UI payload into the local websocket stream.""" if client_id is None: return prompt_server = _lookup_local_prompt_server() if prompt_server is None: return payload = { "prompt_id": prompt_id, "node": node_id, "display_node": display_node_id or node_id, "output": output_payload, } prompt_server.send_sync("executed", payload, client_id) def _emit_local_preview_image( *, prompt_id: str | None, client_id: str | None, node_id: str, display_node_id: str | None, parent_node_id: str | None, real_node_id: str | None, image_type: str, image_bytes: bytes, max_size: int | None, ) -> None: """Forward one remote preview image into the local ComfyUI preview websocket path.""" if client_id is None: return prompt_server = _lookup_local_prompt_server() if prompt_server is None: return try: from PIL import Image from protocol import BinaryEventTypes except ModuleNotFoundError: logger.warning( "Preview forwarding is unavailable because Pillow or ComfyUI protocol imports failed." ) return with BytesIO(image_bytes) as image_buffer: image = Image.open(image_buffer) image.load() metadata: dict[str, Any] = { "node_id": node_id, "prompt_id": prompt_id, "display_node_id": display_node_id or node_id, "real_node_id": real_node_id or node_id, } if parent_node_id is not None: metadata["parent_node_id"] = parent_node_id prompt_server.send_sync( BinaryEventTypes.PREVIEW_IMAGE_WITH_METADATA, ((image_type, image, max_size), metadata), client_id, ) def _emit_local_preview_boundary_output( *, prompt_id: str | None, client_id: str | None, preview_target_node_ids: list[str], image_value: Any, ) -> None: """Render one streamed remote boundary IMAGE value into local PreviewImage UI events.""" if client_id is None or not preview_target_node_ids: return prompt_server = _lookup_local_prompt_server() if prompt_server is None: return try: import nodes except ModuleNotFoundError: logger.warning( "Preview boundary streaming is unavailable because ComfyUI nodes could not be imported." ) return preview_factory = getattr(nodes, "PreviewImage", None) if preview_factory is None: logger.warning( "Preview boundary streaming is unavailable because PreviewImage is not registered." ) return preview_result = preview_factory().save_images(images=image_value) if not isinstance(preview_result, dict): return output_payload = preview_result.get("ui") if not isinstance(output_payload, dict): return for preview_target_node_id in preview_target_node_ids: prompt_server.send_sync( "executed", { "prompt_id": prompt_id, "node": preview_target_node_id, "display_node": preview_target_node_id, "output": output_payload, }, client_id, ) def _allowed_suppressed_stream_node_ids(payload: dict[str, Any]) -> set[str]: """Return the node ids that may surface UI events for a suppressed mapped/static stream.""" allowed_node_ids = { str(node_id) for node_id in payload.get("execute_node_ids", []) if str(node_id) } allowed_node_ids.update( str(boundary_output["node_id"]) for boundary_output in payload.get("boundary_outputs", []) if boundary_output.get("node_id") is not None and str(boundary_output["node_id"]) ) return allowed_node_ids def _should_forward_suppressed_stream_event( payload: dict[str, Any], reported_node_id: Any, ) -> bool: """Return whether a suppressed mapped/static stream event belongs to this payload.""" if not bool(payload.get("suppress_status_stream")): return True if reported_node_id is None: return False allowed_node_ids = _allowed_suppressed_stream_node_ids(payload) if not allowed_node_ids: return True return str(reported_node_id) in allowed_node_ids def _progress_stream_event_node_id(stream_event: dict[str, Any]) -> str | None: """Return the best node id to use for progress filtering and forwarding.""" for candidate in ( stream_event.get("real_node_id"), stream_event.get("node_id"), stream_event.get("display_node_id"), ): if candidate is None: continue candidate_text = str(candidate) if candidate_text: return candidate_text return None def _progress_stream_event_metadata( stream_event: dict[str, Any] ) -> dict[str, str | None] | None: """Return normalized metadata for one streamed progress event.""" reported_node_id = ( str(stream_event["node_id"]) if stream_event.get("node_id") is not None else None ) display_node_id = ( str(stream_event["display_node_id"]) if stream_event.get("display_node_id") is not None else reported_node_id ) real_node_id = ( str(stream_event["real_node_id"]) if stream_event.get("real_node_id") is not None else None ) filter_node_id = real_node_id or reported_node_id or display_node_id if filter_node_id is None: return None return { "node_id": reported_node_id or filter_node_id, "display_node_id": display_node_id, "real_node_id": real_node_id, "filter_node_id": filter_node_id, } def _remote_prompt_ancestor_node_ids( payload: dict[str, Any], node_id: str | None ) -> list[str]: """Return subgraph dependency ancestors for one currently executing remote node.""" if node_id is None: return [] prompt = payload.get("subgraph_prompt", {}) if not isinstance(prompt, dict) or node_id not in prompt: return [] ancestors: set[str] = set() pending = [node_id] while pending: current_node_id = str(pending.pop()) prompt_node = prompt.get(current_node_id) if not isinstance(prompt_node, dict): continue inputs = prompt_node.get("inputs") or {} if not isinstance(inputs, dict): continue for input_value in inputs.values(): for input_link in _iter_prompt_links(input_value): upstream_node_id = str(input_link[0]) if upstream_node_id == node_id or upstream_node_id in ancestors: continue if upstream_node_id not in prompt: continue ancestors.add(upstream_node_id) pending.append(upstream_node_id) return sorted(ancestors) def _should_stream_remote_progress(payload: dict[str, Any]) -> bool: """Return whether the local client has enough context to mirror remote node progress.""" extra_data = payload.get("extra_data") or {} return ( payload.get("payload_kind") in {"subgraph", "mapped_subgraph"} and isinstance(payload.get("prompt_id"), str) and bool(payload.get("prompt_id")) and isinstance(extra_data.get("client_id"), str) and bool(extra_data.get("client_id")) and isinstance(payload.get("component_node_ids"), list) and len(payload.get("component_node_ids")) > 0 )