762 lines
28 KiB
Python
762 lines
28 KiB
Python
"""Serialization helpers for Modal node execution payloads."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import copy
|
|
import json
|
|
import logging
|
|
import struct
|
|
from collections.abc import Mapping, Sequence
|
|
from fractions import Fraction
|
|
from typing import Any, Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_KIND_KEY = "__comfy_modal_kind__"
|
|
_VALUE_KEY = "value"
|
|
_TENSOR_KIND = "tensor"
|
|
_BYTES_KIND = "bytes"
|
|
_TUPLE_KIND = "tuple"
|
|
_MAPPED_OUTPUT_KIND = "mapped_output"
|
|
_NESTED_TENSOR_KIND = "nested_tensor"
|
|
_VIDEO_KIND = "video"
|
|
_NESTED_TENSOR_PAYLOAD_VERSION = 1
|
|
_VIDEO_PAYLOAD_VERSION = 1
|
|
_BATCHABLE_TENSOR_IO_TYPES = frozenset({"IMAGE", "MASK", "NOISE", "SIGMAS"})
|
|
_BINARY_ENVELOPE_MAGIC = b"CMODALB1"
|
|
_BINARY_ENVELOPE_VERSION = 1
|
|
_BINARY_HEADER_LENGTH_BYTES = 8
|
|
_MAX_BINARY_HEADER_BYTES = 16 * 1024 * 1024
|
|
|
|
|
|
class MappedOutputValue(list[Any]):
|
|
"""Ordered value produced by mapped execution and intended for per-item reuse."""
|
|
|
|
def __init__(self, items: Sequence[Any], io_type: str, is_list: bool) -> None:
|
|
"""Initialize a list-like mapped output with scheduler metadata."""
|
|
super().__init__(items)
|
|
self.io_type = str(io_type)
|
|
self.is_list = bool(is_list)
|
|
|
|
@property
|
|
def items(self) -> tuple[Any, ...]:
|
|
"""Return mapped items as an immutable tuple."""
|
|
return tuple(self)
|
|
|
|
|
|
def unwrap_mapped_output_value(value: Any) -> Any:
|
|
"""Return the ordinary runtime value represented by a mapped output wrapper."""
|
|
if isinstance(value, MappedOutputValue):
|
|
if value.is_list:
|
|
flattened: list[Any] = []
|
|
for item in value.items:
|
|
if isinstance(item, list):
|
|
flattened.extend(item)
|
|
continue
|
|
flattened.append(item)
|
|
return flattened
|
|
return list(value.items)
|
|
return value
|
|
|
|
|
|
def is_mapped_output_value(value: Any) -> bool:
|
|
"""Return whether a value carries mapped-output item metadata."""
|
|
return isinstance(value, MappedOutputValue)
|
|
|
|
|
|
def _is_scalar(value: Any) -> bool:
|
|
"""Return whether a value is natively representable in JSON."""
|
|
return value is None or isinstance(value, bool | int | float | str)
|
|
|
|
|
|
def _import_torch() -> Any:
|
|
"""Import torch lazily so the module stays importable in light environments."""
|
|
import torch
|
|
|
|
return torch
|
|
|
|
|
|
def _serialize_tensor(value: Any) -> dict[str, str]:
|
|
"""Serialize a torch.Tensor into a base64 safetensors payload."""
|
|
from safetensors.torch import save
|
|
|
|
torch = _import_torch()
|
|
if not isinstance(value, torch.Tensor):
|
|
raise TypeError("Expected a torch.Tensor payload.")
|
|
|
|
tensor_bytes = save({_VALUE_KEY: value.detach().contiguous()})
|
|
encoded = base64.b64encode(tensor_bytes).decode("ascii")
|
|
return {_KIND_KEY: _TENSOR_KIND, "payload": encoded}
|
|
|
|
|
|
def _deserialize_tensor(payload: Mapping[str, Any]) -> Any:
|
|
"""Deserialize a base64 safetensors payload back into a tensor."""
|
|
from safetensors.torch import load
|
|
|
|
encoded = payload["payload"]
|
|
tensor_map = load(base64.b64decode(encoded.encode("ascii")))
|
|
return tensor_map[_VALUE_KEY]
|
|
|
|
|
|
def _serialize_tensor_bytes(value: Any) -> bytes:
|
|
"""Serialize a tensor directly to safetensors bytes without base64 expansion."""
|
|
from safetensors.torch import save
|
|
|
|
torch = _import_torch()
|
|
if not isinstance(value, torch.Tensor):
|
|
raise TypeError("Expected a torch.Tensor payload.")
|
|
return save({_VALUE_KEY: value.detach().contiguous()})
|
|
|
|
|
|
def _deserialize_tensor_bytes(payload: bytes) -> Any:
|
|
"""Deserialize direct safetensors bytes back into a tensor."""
|
|
from safetensors.torch import load
|
|
|
|
return load(payload)[_VALUE_KEY]
|
|
|
|
|
|
def _load_nested_tensor_transport_type() -> type[Any] | None:
|
|
"""Load ComfyUI's multimodal tensor wrapper when available."""
|
|
try:
|
|
from comfy.nested_tensor import NestedTensor
|
|
except (ImportError, AttributeError):
|
|
return None
|
|
return NestedTensor if isinstance(NestedTensor, type) else None
|
|
|
|
|
|
def _is_comfy_nested_tensor(value: Any) -> bool:
|
|
"""Return whether a value is ComfyUI's multimodal tensor wrapper."""
|
|
nested_tensor_type = _load_nested_tensor_transport_type()
|
|
return nested_tensor_type is not None and isinstance(value, nested_tensor_type)
|
|
|
|
|
|
def _serialize_nested_tensor(
|
|
value: Any,
|
|
serialize_item: Callable[[Any], Any],
|
|
) -> dict[str, Any]:
|
|
"""Serialize a ComfyUI NestedTensor as its ordered tensor members."""
|
|
return {
|
|
_KIND_KEY: _NESTED_TENSOR_KIND,
|
|
"version": _NESTED_TENSOR_PAYLOAD_VERSION,
|
|
"tensors": serialize_item(value.tensors),
|
|
}
|
|
|
|
|
|
def _deserialize_nested_tensor(
|
|
payload: Mapping[str, Any],
|
|
deserialize_item: Callable[[Any], Any],
|
|
) -> Any:
|
|
"""Reconstruct a ComfyUI NestedTensor from ordered tensor members."""
|
|
if payload.get("version") != _NESTED_TENSOR_PAYLOAD_VERSION:
|
|
raise ValueError(
|
|
"Unsupported serialized NestedTensor version "
|
|
f"{payload.get('version')!r}."
|
|
)
|
|
nested_tensor_type = _load_nested_tensor_transport_type()
|
|
if nested_tensor_type is None:
|
|
raise TypeError(
|
|
"Deserializing NestedTensor values requires a current ComfyUI runtime."
|
|
)
|
|
tensors = deserialize_item(payload.get("tensors"))
|
|
if not isinstance(tensors, list):
|
|
raise ValueError("Serialized NestedTensor members must decode to a list.")
|
|
return nested_tensor_type(tensors)
|
|
|
|
|
|
def _load_video_transport_types() -> tuple[type[Any], type[Any], type[Any]] | None:
|
|
"""Load current ComfyUI video protocol and implementation types when available."""
|
|
try:
|
|
from comfy_api.latest import Input, InputImpl, Types
|
|
except (ImportError, AttributeError):
|
|
return None
|
|
|
|
video_input_type = getattr(Input, "Video", None)
|
|
video_impl_type = getattr(InputImpl, "VideoFromComponents", None)
|
|
video_components_type = getattr(Types, "VideoComponents", None)
|
|
if not all(
|
|
isinstance(candidate, type)
|
|
for candidate in (video_input_type, video_impl_type, video_components_type)
|
|
):
|
|
return None
|
|
return video_input_type, video_impl_type, video_components_type
|
|
|
|
|
|
def _is_comfy_video_input(value: Any) -> bool:
|
|
"""Return whether a value implements the current ComfyUI VIDEO protocol."""
|
|
video_types = _load_video_transport_types()
|
|
return video_types is not None and isinstance(value, video_types[0])
|
|
|
|
|
|
def _serialize_video(
|
|
value: Any,
|
|
serialize_item: Callable[[Any], Any],
|
|
) -> dict[str, Any]:
|
|
"""Serialize a ComfyUI VIDEO as transportable tensor-backed components."""
|
|
components = value.get_components()
|
|
frame_rate = Fraction(components.frame_rate).limit_denominator(1_000_000)
|
|
get_bit_depth = getattr(value, "get_bit_depth", None)
|
|
bit_depth = int(get_bit_depth()) if callable(get_bit_depth) else 8
|
|
return {
|
|
_KIND_KEY: _VIDEO_KIND,
|
|
"version": _VIDEO_PAYLOAD_VERSION,
|
|
"images": serialize_item(components.images),
|
|
"audio": serialize_item(getattr(components, "audio", None)),
|
|
"alpha": serialize_item(getattr(components, "alpha", None)),
|
|
"metadata": serialize_item(getattr(components, "metadata", None)),
|
|
"frame_rate_numerator": frame_rate.numerator,
|
|
"frame_rate_denominator": frame_rate.denominator,
|
|
"bit_depth": bit_depth,
|
|
}
|
|
|
|
|
|
def _video_payload_integer(payload: Mapping[str, Any], key: str) -> int:
|
|
"""Return one validated integer field from a serialized VIDEO payload."""
|
|
value = payload.get(key)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError(f"Serialized VIDEO field '{key}' must be an integer.")
|
|
return value
|
|
|
|
|
|
def _deserialize_video(
|
|
payload: Mapping[str, Any],
|
|
deserialize_item: Callable[[Any], Any],
|
|
) -> Any:
|
|
"""Reconstruct a current ComfyUI VIDEO from tensor-backed components."""
|
|
if payload.get("version") != _VIDEO_PAYLOAD_VERSION:
|
|
raise ValueError(f"Unsupported serialized VIDEO version {payload.get('version')!r}.")
|
|
video_types = _load_video_transport_types()
|
|
if video_types is None:
|
|
raise TypeError(
|
|
"Deserializing VIDEO values requires a ComfyUI runtime with the current video API."
|
|
)
|
|
|
|
_, video_impl_type, video_components_type = video_types
|
|
numerator = _video_payload_integer(payload, "frame_rate_numerator")
|
|
denominator = _video_payload_integer(payload, "frame_rate_denominator")
|
|
if denominator <= 0:
|
|
raise ValueError("Serialized VIDEO frame-rate denominator must be positive.")
|
|
bit_depth = _video_payload_integer(payload, "bit_depth")
|
|
components = video_components_type(
|
|
images=deserialize_item(payload.get("images")),
|
|
frame_rate=Fraction(numerator, denominator),
|
|
audio=deserialize_item(payload.get("audio")),
|
|
metadata=deserialize_item(payload.get("metadata")),
|
|
alpha=deserialize_item(payload.get("alpha")),
|
|
)
|
|
return video_impl_type(components, bit_depth=bit_depth)
|
|
|
|
|
|
def serialize_value(value: Any) -> Any:
|
|
"""Convert a Python value into a JSON-safe execution payload."""
|
|
if _is_scalar(value):
|
|
return value
|
|
|
|
if isinstance(value, MappedOutputValue):
|
|
return {
|
|
_KIND_KEY: _MAPPED_OUTPUT_KIND,
|
|
"items": [serialize_value(item) for item in value.items],
|
|
"io_type": value.io_type,
|
|
"is_list": value.is_list,
|
|
}
|
|
|
|
try:
|
|
torch = _import_torch()
|
|
except ModuleNotFoundError:
|
|
torch = None
|
|
|
|
if torch is not None and isinstance(value, torch.Tensor):
|
|
return _serialize_tensor(value)
|
|
|
|
if _is_comfy_nested_tensor(value):
|
|
return _serialize_nested_tensor(value, serialize_value)
|
|
|
|
if _is_comfy_video_input(value):
|
|
return _serialize_video(value, serialize_value)
|
|
|
|
if isinstance(value, bytes):
|
|
return {
|
|
_KIND_KEY: _BYTES_KIND,
|
|
"payload": base64.b64encode(value).decode("ascii"),
|
|
}
|
|
|
|
if isinstance(value, tuple):
|
|
return {
|
|
_KIND_KEY: _TUPLE_KIND,
|
|
"items": [serialize_value(item) for item in value],
|
|
}
|
|
|
|
if isinstance(value, list):
|
|
return [serialize_value(item) for item in value]
|
|
|
|
if isinstance(value, Mapping):
|
|
return {str(key): serialize_value(item) for key, item in value.items()}
|
|
|
|
raise TypeError(
|
|
"ComfyUI-Modal can only serialize JSON-compatible values, bytes, "
|
|
"torch tensors, and ComfyUI NestedTensor and VIDEO values. "
|
|
"Unsupported value type: "
|
|
f"{type(value)!r}"
|
|
)
|
|
|
|
|
|
def serialize_mapping(mapping: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Convert a mapping into a JSON-safe payload using the Modal transport rules."""
|
|
return {str(key): serialize_value(item) for key, item in mapping.items()}
|
|
|
|
|
|
def deserialize_value(payload: Any) -> Any:
|
|
"""Reconstruct a serialized execution payload back into Python values."""
|
|
if _is_scalar(payload):
|
|
return payload
|
|
|
|
if isinstance(payload, list):
|
|
return [deserialize_value(item) for item in payload]
|
|
|
|
if not isinstance(payload, Mapping):
|
|
raise TypeError(f"Unsupported payload type: {type(payload)!r}")
|
|
|
|
kind = payload.get(_KIND_KEY)
|
|
if kind == _TENSOR_KIND:
|
|
return _deserialize_tensor(payload)
|
|
if kind == _BYTES_KIND:
|
|
encoded = payload["payload"]
|
|
return base64.b64decode(encoded.encode("ascii"))
|
|
if kind == _TUPLE_KIND:
|
|
return tuple(deserialize_value(item) for item in payload["items"])
|
|
if kind == _MAPPED_OUTPUT_KIND:
|
|
return MappedOutputValue(
|
|
items=tuple(deserialize_value(item) for item in payload["items"]),
|
|
io_type=str(payload.get("io_type", "*")),
|
|
is_list=bool(payload.get("is_list", False)),
|
|
)
|
|
if kind == _NESTED_TENSOR_KIND:
|
|
return _deserialize_nested_tensor(payload, deserialize_value)
|
|
if kind == _VIDEO_KIND:
|
|
return _deserialize_video(payload, deserialize_value)
|
|
|
|
return {str(key): deserialize_value(value) for key, value in payload.items()}
|
|
|
|
|
|
def _serialize_transport_value(value: Any, attachments: list[bytes]) -> Any:
|
|
"""Convert one value into JSON metadata plus raw binary attachments."""
|
|
if _is_scalar(value):
|
|
return value
|
|
|
|
if isinstance(value, MappedOutputValue):
|
|
return {
|
|
_KIND_KEY: _MAPPED_OUTPUT_KIND,
|
|
"items": [_serialize_transport_value(item, attachments) for item in value.items],
|
|
"io_type": value.io_type,
|
|
"is_list": value.is_list,
|
|
}
|
|
|
|
try:
|
|
torch = _import_torch()
|
|
except ModuleNotFoundError:
|
|
torch = None
|
|
|
|
if torch is not None and isinstance(value, torch.Tensor):
|
|
attachment_index = len(attachments)
|
|
attachments.append(_serialize_tensor_bytes(value))
|
|
return {_KIND_KEY: _TENSOR_KIND, "attachment": attachment_index}
|
|
|
|
if _is_comfy_nested_tensor(value):
|
|
return _serialize_nested_tensor(
|
|
value,
|
|
lambda item: _serialize_transport_value(item, attachments),
|
|
)
|
|
|
|
if _is_comfy_video_input(value):
|
|
return _serialize_video(
|
|
value,
|
|
lambda item: _serialize_transport_value(item, attachments),
|
|
)
|
|
|
|
if isinstance(value, bytes):
|
|
attachment_index = len(attachments)
|
|
attachments.append(value)
|
|
return {_KIND_KEY: _BYTES_KIND, "attachment": attachment_index}
|
|
|
|
if isinstance(value, tuple):
|
|
return {
|
|
_KIND_KEY: _TUPLE_KIND,
|
|
"items": [_serialize_transport_value(item, attachments) for item in value],
|
|
}
|
|
|
|
if isinstance(value, list):
|
|
return [_serialize_transport_value(item, attachments) for item in value]
|
|
|
|
if isinstance(value, Mapping):
|
|
return {
|
|
str(key): _serialize_transport_value(item, attachments)
|
|
for key, item in value.items()
|
|
}
|
|
|
|
raise TypeError(
|
|
"ComfyUI-Modal can only serialize JSON-compatible values, bytes, "
|
|
"torch tensors, and ComfyUI NestedTensor and VIDEO values. "
|
|
"Unsupported value type: "
|
|
f"{type(value)!r}"
|
|
)
|
|
|
|
|
|
def _serialize_transport_payload(value: Any, *, sort_keys: bool) -> bytes:
|
|
"""Serialize one transport payload, using raw attachments when needed."""
|
|
attachments: list[bytes] = []
|
|
metadata = _serialize_transport_value(value, attachments)
|
|
if not attachments:
|
|
return json.dumps(metadata, sort_keys=sort_keys).encode("utf-8")
|
|
|
|
header = json.dumps(
|
|
{
|
|
"version": _BINARY_ENVELOPE_VERSION,
|
|
"payload": metadata,
|
|
"attachment_lengths": [len(attachment) for attachment in attachments],
|
|
},
|
|
sort_keys=sort_keys,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return b"".join(
|
|
(
|
|
_BINARY_ENVELOPE_MAGIC,
|
|
struct.pack(">Q", len(header)),
|
|
header,
|
|
*attachments,
|
|
)
|
|
)
|
|
|
|
|
|
def _deserialize_transport_value(payload: Any, attachments: tuple[bytes, ...]) -> Any:
|
|
"""Reconstruct one value from metadata and binary attachments."""
|
|
if _is_scalar(payload):
|
|
return payload
|
|
if isinstance(payload, list):
|
|
return [_deserialize_transport_value(item, attachments) for item in payload]
|
|
if not isinstance(payload, Mapping):
|
|
raise TypeError(f"Unsupported payload type: {type(payload)!r}")
|
|
|
|
kind = payload.get(_KIND_KEY)
|
|
attachment_index = payload.get("attachment")
|
|
if kind in {_TENSOR_KIND, _BYTES_KIND} and attachment_index is not None:
|
|
if isinstance(attachment_index, bool) or not isinstance(attachment_index, int):
|
|
raise ValueError("Binary attachment indices must be integers.")
|
|
if attachment_index < 0 or attachment_index >= len(attachments):
|
|
raise ValueError(f"Binary attachment index {attachment_index} is out of range.")
|
|
attachment = attachments[attachment_index]
|
|
if kind == _TENSOR_KIND:
|
|
return _deserialize_tensor_bytes(attachment)
|
|
return attachment
|
|
if kind == _TENSOR_KIND:
|
|
return _deserialize_tensor(payload)
|
|
if kind == _BYTES_KIND:
|
|
encoded = payload["payload"]
|
|
return base64.b64decode(encoded.encode("ascii"))
|
|
if kind == _TUPLE_KIND:
|
|
return tuple(
|
|
_deserialize_transport_value(item, attachments)
|
|
for item in payload["items"]
|
|
)
|
|
if kind == _MAPPED_OUTPUT_KIND:
|
|
return MappedOutputValue(
|
|
items=tuple(
|
|
_deserialize_transport_value(item, attachments)
|
|
for item in payload["items"]
|
|
),
|
|
io_type=str(payload.get("io_type", "*")),
|
|
is_list=bool(payload.get("is_list", False)),
|
|
)
|
|
if kind == _NESTED_TENSOR_KIND:
|
|
return _deserialize_nested_tensor(
|
|
payload,
|
|
lambda item: _deserialize_transport_value(item, attachments),
|
|
)
|
|
if kind == _VIDEO_KIND:
|
|
return _deserialize_video(
|
|
payload,
|
|
lambda item: _deserialize_transport_value(item, attachments),
|
|
)
|
|
return {
|
|
str(key): _deserialize_transport_value(value, attachments)
|
|
for key, value in payload.items()
|
|
}
|
|
|
|
|
|
def _deserialize_binary_envelope(payload: bytes) -> tuple[Any, tuple[bytes, ...]]:
|
|
"""Parse one validated binary transport envelope."""
|
|
length_offset = len(_BINARY_ENVELOPE_MAGIC)
|
|
header_offset = length_offset + _BINARY_HEADER_LENGTH_BYTES
|
|
if len(payload) < header_offset:
|
|
raise ValueError("Binary transport envelope is truncated before its header.")
|
|
header_length = struct.unpack(">Q", payload[length_offset:header_offset])[0]
|
|
if header_length > _MAX_BINARY_HEADER_BYTES:
|
|
raise ValueError(
|
|
f"Binary transport header exceeds {_MAX_BINARY_HEADER_BYTES} bytes."
|
|
)
|
|
attachment_offset = header_offset + header_length
|
|
if attachment_offset > len(payload):
|
|
raise ValueError("Binary transport envelope is truncated inside its header.")
|
|
try:
|
|
header = json.loads(payload[header_offset:attachment_offset].decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError("Binary transport envelope header is invalid JSON.") from exc
|
|
if not isinstance(header, Mapping):
|
|
raise ValueError("Binary transport envelope header must be a mapping.")
|
|
if header.get("version") != _BINARY_ENVELOPE_VERSION:
|
|
raise ValueError(f"Unsupported binary transport version {header.get('version')!r}.")
|
|
attachment_lengths = header.get("attachment_lengths")
|
|
if not isinstance(attachment_lengths, list):
|
|
raise ValueError("Binary transport envelope is missing attachment lengths.")
|
|
|
|
attachments: list[bytes] = []
|
|
next_offset = attachment_offset
|
|
for attachment_length in attachment_lengths:
|
|
if (
|
|
isinstance(attachment_length, bool)
|
|
or not isinstance(attachment_length, int)
|
|
or attachment_length < 0
|
|
):
|
|
raise ValueError("Binary attachment lengths must be non-negative integers.")
|
|
next_attachment_offset = next_offset + attachment_length
|
|
if next_attachment_offset > len(payload):
|
|
raise ValueError("Binary transport envelope is truncated inside an attachment.")
|
|
attachments.append(bytes(memoryview(payload)[next_offset:next_attachment_offset]))
|
|
next_offset = next_attachment_offset
|
|
if next_offset != len(payload):
|
|
raise ValueError("Binary transport envelope contains trailing bytes.")
|
|
return header.get("payload"), tuple(attachments)
|
|
|
|
|
|
def _deserialize_transport_payload(payload: bytes | bytearray | str) -> tuple[Any, tuple[bytes, ...]]:
|
|
"""Decode either the binary transport or the legacy JSON representation."""
|
|
if isinstance(payload, str):
|
|
return json.loads(payload), ()
|
|
normalized_payload = bytes(payload)
|
|
if normalized_payload.startswith(_BINARY_ENVELOPE_MAGIC):
|
|
return _deserialize_binary_envelope(normalized_payload)
|
|
return json.loads(normalized_payload.decode("utf-8")), ()
|
|
|
|
|
|
def serialize_node_inputs(inputs: Mapping[str, Any]) -> bytes:
|
|
"""Serialize node keyword arguments into transport bytes."""
|
|
return _serialize_transport_payload(dict(inputs), sort_keys=True)
|
|
|
|
|
|
def deserialize_node_inputs(payload: bytes | bytearray | str | Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Deserialize node keyword arguments from transport bytes."""
|
|
if isinstance(payload, Mapping):
|
|
raw_payload = dict(payload)
|
|
attachments: tuple[bytes, ...] = ()
|
|
else:
|
|
raw_payload, attachments = _deserialize_transport_payload(payload)
|
|
|
|
if not isinstance(raw_payload, Mapping):
|
|
raise TypeError("Serialized node inputs must decode to a mapping.")
|
|
return {
|
|
str(key): _deserialize_transport_value(value, attachments)
|
|
for key, value in raw_payload.items()
|
|
}
|
|
|
|
|
|
def serialize_node_outputs(outputs: Sequence[Any]) -> bytes:
|
|
"""Serialize node outputs into transport bytes."""
|
|
return _serialize_transport_payload(list(outputs), sort_keys=False)
|
|
|
|
|
|
def coerce_serialized_node_outputs(outputs: bytes | bytearray | str | Sequence[Any] | Any) -> bytes:
|
|
"""Normalize raw or pre-serialized node outputs into transport bytes."""
|
|
if isinstance(outputs, bytes):
|
|
return outputs
|
|
if isinstance(outputs, bytearray):
|
|
return bytes(outputs)
|
|
if isinstance(outputs, str):
|
|
return outputs.encode("utf-8")
|
|
if isinstance(outputs, (list, tuple)):
|
|
return serialize_node_outputs(tuple(outputs))
|
|
return serialize_node_outputs((outputs,))
|
|
|
|
|
|
def deserialize_node_outputs(payload: bytes | bytearray | str | Sequence[Any]) -> tuple[Any, ...]:
|
|
"""Deserialize node outputs from transport bytes."""
|
|
if isinstance(payload, Sequence) and not isinstance(payload, (bytes, bytearray, str)):
|
|
raw_payload = list(payload)
|
|
attachments: tuple[bytes, ...] = ()
|
|
else:
|
|
raw_payload, attachments = _deserialize_transport_payload(payload)
|
|
|
|
if not isinstance(raw_payload, list):
|
|
raise TypeError("Serialized node outputs must decode to a list.")
|
|
return tuple(
|
|
_deserialize_transport_value(value, attachments)
|
|
for value in raw_payload
|
|
)
|
|
|
|
|
|
def _split_tensor_batch(value: Any) -> list[Any]:
|
|
"""Split one tensor batch into per-item tensors that retain the batch dimension."""
|
|
torch = _import_torch()
|
|
if not isinstance(value, torch.Tensor):
|
|
raise TypeError("Expected a tensor batch.")
|
|
if value.ndim == 0 or value.shape[0] == 0:
|
|
raise ValueError("Mapped tensor batches must have a non-zero leading batch dimension.")
|
|
return [value[index : index + 1] for index in range(int(value.shape[0]))]
|
|
|
|
|
|
def _latent_batch_size(value: Mapping[str, Any]) -> int:
|
|
"""Return the batch size of a ComfyUI LATENT-like mapping."""
|
|
torch = _import_torch()
|
|
samples = value.get("samples")
|
|
if not isinstance(samples, torch.Tensor) or samples.ndim == 0 or samples.shape[0] == 0:
|
|
raise TypeError("Mapped LATENT values must contain a batched 'samples' tensor.")
|
|
return int(samples.shape[0])
|
|
|
|
|
|
def _split_latent_batch(value: Mapping[str, Any]) -> list[Any]:
|
|
"""Split one ComfyUI LATENT mapping into per-item latent mappings."""
|
|
torch = _import_torch()
|
|
batch_size = _latent_batch_size(value)
|
|
items: list[dict[str, Any]] = []
|
|
for index in range(batch_size):
|
|
item: dict[str, Any] = {}
|
|
for key, entry in value.items():
|
|
if isinstance(entry, torch.Tensor) and entry.ndim > 0 and entry.shape[0] == batch_size:
|
|
item[str(key)] = entry[index : index + 1]
|
|
continue
|
|
if isinstance(entry, list) and len(entry) == batch_size:
|
|
item[str(key)] = [entry[index]]
|
|
continue
|
|
item[str(key)] = copy.deepcopy(entry)
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def split_mapped_value(value: Any, io_type: str) -> list[Any]:
|
|
"""Split one mapped input value into ordered per-item values."""
|
|
if isinstance(value, MappedOutputValue):
|
|
return list(value.items)
|
|
|
|
if isinstance(value, list):
|
|
if len(value) == 0:
|
|
raise ValueError("Mapped list inputs must contain at least one item.")
|
|
return list(value)
|
|
|
|
normalized_io_type = str(io_type)
|
|
if _is_scalar(value):
|
|
return [value]
|
|
|
|
if normalized_io_type == "LATENT" and isinstance(value, Mapping):
|
|
return _split_latent_batch(value)
|
|
|
|
try:
|
|
torch = _import_torch()
|
|
except ModuleNotFoundError as exc:
|
|
raise TypeError(
|
|
f"Mapped input type {normalized_io_type!r} requires torch to split batched values."
|
|
) from exc
|
|
|
|
if isinstance(value, torch.Tensor) and (
|
|
normalized_io_type in _BATCHABLE_TENSOR_IO_TYPES or value.ndim > 0
|
|
):
|
|
return _split_tensor_batch(value)
|
|
|
|
raise TypeError(
|
|
"Mapped execution only supports scalar values, Python lists, tensor batches, and LATENT dictionaries. "
|
|
f"Unsupported mapped value type {type(value)!r} for io_type={normalized_io_type!r}."
|
|
)
|
|
|
|
|
|
def _join_latent_batches(values: Sequence[Any]) -> Any:
|
|
"""Reassemble ordered per-item LATENT mappings into one batched latent mapping."""
|
|
torch = _import_torch()
|
|
if not values:
|
|
raise ValueError("Expected at least one mapped LATENT output to aggregate.")
|
|
if not all(isinstance(value, Mapping) for value in values):
|
|
raise TypeError("Mapped LATENT outputs must all be mappings.")
|
|
|
|
first_value = values[0]
|
|
merged: dict[str, Any] = {}
|
|
for key, first_entry in first_value.items():
|
|
entries = [value[key] for value in values]
|
|
if isinstance(first_entry, torch.Tensor):
|
|
merged[str(key)] = torch.cat(entries, dim=0)
|
|
continue
|
|
if isinstance(first_entry, list):
|
|
flattened: list[Any] = []
|
|
for entry in entries:
|
|
flattened.extend(entry)
|
|
merged[str(key)] = flattened
|
|
continue
|
|
merged[str(key)] = copy.deepcopy(first_entry)
|
|
return merged
|
|
|
|
|
|
def _join_mapped_values_as_list(values: Sequence[Any]) -> list[Any]:
|
|
"""Return mapped outputs as an ordered Python list."""
|
|
return list(values)
|
|
|
|
|
|
def join_mapped_values(values: Sequence[Any], io_type: str, is_list: bool) -> Any:
|
|
"""Reassemble ordered per-item mapped outputs into one proxy output value."""
|
|
if not values:
|
|
raise ValueError("Mapped execution produced no outputs to aggregate.")
|
|
|
|
normalized_io_type = str(io_type)
|
|
if normalized_io_type == "CONDITIONING":
|
|
return MappedOutputValue(
|
|
items=tuple(copy.deepcopy(value) for value in values),
|
|
io_type=normalized_io_type,
|
|
is_list=is_list,
|
|
)
|
|
|
|
if is_list:
|
|
flattened: list[Any] = []
|
|
for value in values:
|
|
if isinstance(value, list):
|
|
flattened.extend(value)
|
|
continue
|
|
flattened.append(value)
|
|
return flattened
|
|
|
|
if normalized_io_type == "LATENT":
|
|
try:
|
|
return _join_latent_batches(values)
|
|
except RuntimeError as exc:
|
|
logger.info(
|
|
"Falling back to list aggregation for mapped LATENT outputs because batch concatenation "
|
|
"failed: %s",
|
|
exc,
|
|
)
|
|
return _join_mapped_values_as_list(values)
|
|
|
|
try:
|
|
torch = _import_torch()
|
|
except ModuleNotFoundError:
|
|
torch = None
|
|
|
|
if torch is not None and all(isinstance(value, torch.Tensor) for value in values):
|
|
try:
|
|
return torch.cat(list(values), dim=0)
|
|
except RuntimeError as exc:
|
|
logger.info(
|
|
"Falling back to list aggregation for mapped %s outputs because tensor concatenation "
|
|
"failed: %s",
|
|
normalized_io_type,
|
|
exc,
|
|
)
|
|
return _join_mapped_values_as_list(values)
|
|
|
|
return _join_mapped_values_as_list(values)
|
|
|
|
|
|
def join_mapped_values_for_scheduler(
|
|
values: Sequence[Any],
|
|
io_type: str,
|
|
is_list: bool,
|
|
scheduler_is_list: bool,
|
|
) -> Any:
|
|
"""Join mapped values and adapt them to the proxy's ComfyUI list-output contract."""
|
|
joined_value = join_mapped_values(values, io_type=io_type, is_list=is_list)
|
|
if scheduler_is_list and not isinstance(joined_value, list):
|
|
return [joined_value]
|
|
return joined_value
|