Files
Comfy-Org-comfy-remote-nodes/serialization.py
Jedrzej KosinskiandAmp 1f136526cf Add client-side cross-node task_handle decoder + TaskHandle dataclass
Mirrors comfy-rnp-server PR #39 on the client. Adds the surfaces
needed for chained partner provider nodes (Tripo Texture/Refine/
Rig/Retarget/Conversion, Kling VideoExtend, future partner chained
nodes) to pass vendor-native task / video / job IDs through a
workflow with their lineage metadata intact.

Surfaces:

* ``protocol.Capability.IO_TASK_HANDLE = "io:task_handle"`` and
  ``"task_handle"`` added to ``protocol.HEAVY_TYPES`` so
  ``is_envelope`` recognises the new envelope.
* ``serialization.TaskHandle`` dataclass with the wire-shape fields
  (``vendor``, ``kind``, ``native_id``, ``origin_node_id``,
  ``parent_chain``). ``__str__`` surfaces ``native_id`` for log /
  UI preview without leaking the lineage chain.
* ``serialization.decode_task_handle_envelope`` validates every
  field (mirroring server-side ``_validate_task_handle_ref`` so a
  bad envelope surfaces the same error message on both sides) and
  returns the dataclass. Rejects:
  - wrong encoding (only ``vendor_inline`` recognised);
  - missing / empty top-level fields;
  - comma-union ``kind`` strings (the input-acceptance string used
    by some provider nodes, NOT an emitted handle kind);
  - non-list ``parent_chain``;
  - malformed parent_chain entries (missing fields, non-dict,
    comma-union kind in lineage).
* ``serialization.encode_task_handle`` re-encodes the dataclass
  back to wire shape — symmetric counterpart of the decoder so a
  handle decoded from one provider's output and fed into another
  provider's input round-trips losslessly without ``proxy_node``
  having to know anything provider-specific.
* ``decode_envelope`` dispatcher routes ``type="task_handle"`` to
  the new decoder so the generic ``_deserialize_output`` path picks
  it up.
* ``client.CLIENT_CAPABILITIES`` advertises
  ``Capability.IO_TASK_HANDLE`` so server-side capability-gated
  descriptors (whole-descriptor gating — partial-socket synthesis
  is rejected because a chain that breaks meaningfully if either
  end is missing is worse than no chain at all) negotiate cleanly.

The dataclass return (NOT a plain ``str``) is deliberate: a plain
string would silently drop ``parent_chain`` when the value is
passed through the workflow, breaking the lineage the server uses
for future cascading-replay semantics (deferred to a follow-up PR
alongside server-side replay-cache storage — see PR #39 for the
replay design notes).

Smoke test ``notes/run_task_handle_decoder.py`` mirrors the pattern
used by ``run_model3d_bundle_decoder.py``: stubs out the ``client``
import for the relative ``from . import client as rnp_client`` at
the top of serialization.py, then exercises the 9 contract points
end-to-end. End-to-end check imports the server's
``make_task_handle_envelope`` from the comfy-rnp-server worktree
and verifies the wire shape round-trips losslessly through
decode -> encode.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e3431-c416-742b-973c-e262ab13f2ff
2026-05-16 21:34:05 -07:00

1017 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tensor <-> RNP value-envelope helpers.
The proxy node receives raw ComfyUI Python values from the workflow
runtime (image tensors, mask tensors, audio dicts, Video objects) and
must turn them into wire-format envelopes before POSTing to the RNP
server. Conversely, each ``execute`` response returns envelopes that
must be deserialized into the native ComfyUI types so downstream nodes
in the workflow get exactly what they would have from a local node.
This module is the single home for that round-trip, so the proxy_node
only ever sees ``encode_*`` / ``decode_*`` pairs and stays focused on
schema construction and HTTP dispatch.
Byte-level encodings reuse ``comfy_api_nodes.util.conversions`` so a
remote node and a local one produce identical bytes for the same
tensor — no parallel dialect.
"""
from __future__ import annotations
import asyncio
import base64
import logging
from io import BytesIO
from typing import Any
from . import client as rnp_client
from .protocol import (
Capability,
ErrorCode,
RnpProtocolError,
decode_envelope_data,
is_envelope,
)
log = logging.getLogger("comfy_remote_nodes.serialization")
# ---------------------------------------------------------------------------
# Externalization (inline → presigned-upload swap)
# ---------------------------------------------------------------------------
# Per-type MIME content-type used when uploading bytes that an envelope
# would otherwise carry inline. The server stamps these on the asset
# record so subsequent fetches arrive with the right Content-Type.
_CONTENT_TYPE_FOR_TYPE = {
"image": "image/png",
"mask": "image/png",
"video": "video/mp4",
"audio": "audio/mpeg",
}
async def maybe_externalize(
envelope: dict[str, Any],
*,
server_url: str | None,
max_inline_bytes: int | None,
auth_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
"""If the envelope's inline payload exceeds ``max_inline_bytes``,
upload it via the presigned-PUT path and rewrite the envelope to
reference the uploaded asset(s) instead of inlining base64.
Two payload shapes are recognised:
* Singleton ``data`` envelopes (the common single-frame IMAGE /
MASK / VIDEO / AUDIO case). Oversize payloads upload as one
asset and the envelope's ``data`` field is swapped for ``uri``.
* Multi-frame ``frames=[…]`` IMAGE envelopes (encoding
``png_base64_batch``). The original ``maybe_externalize`` only
inspected ``data`` and so silently let a 9× 2048² Flux2 batch
ride the wire as a 200 MB POST body — defeating the entire
reason the cap exists. The new path computes the summed raw
byte budget (``sum(len(b64)) * 3 // 4`` — base64 expands 4:3 so
this is the exact payload the server would receive) and, if it
crosses the cap, uploads each frame as an individual presigned
PUT in parallel via :func:`asyncio.gather` then rewrites the
envelope to ``encoding=png_base64_batch_uri`` with a parallel
``uris`` list. ``shape`` / ``dtype`` / ``color_space`` ride
through unchanged so the server-side decoder still sees the
batch dimension. Per-frame URIs (NOT a tar/zip blob) compose
with the existing per-frame fetch helpers and let the server
fetch in parallel.
No-op when either argument is falsy, the envelope already has a
``uri`` / ``uris``, or the inline payload fits under the cap.
Dispatch keys off the ``encoding`` string — NOT field presence
(``frames`` vs. ``data``). An encoder that emitted ``frames`` for
a non-batch encoding by accident, or shipped both fields together,
used to silently take the batch branch and externalise garbage;
keying off the canonical encoding catches that as a hard ``frames``
check inside the batch branch itself.
"""
if not server_url or not max_inline_bytes:
return envelope
encoding = envelope.get("encoding")
if encoding == "png_base64_batch":
frames = envelope.get("frames")
if not isinstance(frames, list) or not frames:
# Mirror the server-side make_image_batch_envelope contract:
# an empty/missing frames list is a hard wire bug, NOT a
# silent no-op (which used to ship through under the old
# ``isinstance(frames, list) and frames`` dispatch and then
# blow up on the server's resolve_image_envelope_pngs check).
raise RnpProtocolError(
"png_base64_batch envelope requires a non-empty 'frames' list",
code=ErrorCode.INTERNAL,
)
return await _maybe_externalize_batch(
envelope, frames,
server_url=server_url,
max_inline_bytes=max_inline_bytes,
auth_headers=auth_headers,
)
data = envelope.get("data")
if not isinstance(data, str):
return envelope
# Cap is on raw bytes, not the base64 expansion.
if len(data) * 3 // 4 <= max_inline_bytes:
return envelope
raw = base64.b64decode(data)
content_type = _CONTENT_TYPE_FOR_TYPE.get(envelope.get("type", ""), "application/octet-stream")
download_url = await rnp_client.upload_asset(
server_url, raw, content_type=content_type, auth_headers=auth_headers,
)
log.info(
"RNP: externalized %s envelope (%d bytes) -> %s",
envelope.get("type"), len(raw), download_url,
)
out = {k: v for k, v in envelope.items() if k != "data"}
out["uri"] = download_url
return out
async def _maybe_externalize_batch(
envelope: dict[str, Any],
frames: list[Any],
*,
server_url: str,
max_inline_bytes: int,
auth_headers: dict[str, str] | None,
) -> dict[str, Any]:
"""Externalise a multi-frame IMAGE envelope when its summed inline
payload overflows ``max_inline_bytes``.
Per-frame upload (NOT a tar/zip blob): each frame becomes its own
presigned-PUT asset so the server can fetch in parallel and the
existing per-frame ``resolve_image_envelope_pngs`` helper picks
up the URI shape transparently. ``asyncio.gather`` issues every
PUT in parallel — the dominant latency for a 9-frame Flux2 batch
is the upload, not the server hop, so serialising would multiply
end-to-end submit time by N. Frame ordering is preserved (gather
returns positional results) — critical because a Flux2 provider
maps ``uris[k]`` → ``input_image_{k+1}`` 1:1.
"""
# Cap on raw byte count (base64 expands 4:3, so multiplying by
# 3/4 recovers the actual payload the server would receive).
total_raw = 0
for f in frames:
if isinstance(f, str):
total_raw += len(f) * 3 // 4
if total_raw <= max_inline_bytes:
return envelope
env_type = envelope.get("type", "")
content_type = _CONTENT_TYPE_FOR_TYPE.get(env_type, "application/octet-stream")
async def _upload_one(idx: int, frame_b64: Any) -> str:
if not isinstance(frame_b64, str):
raise RnpProtocolError(
f"image batch frame {idx} is not a base64 string "
f"(got {type(frame_b64).__name__})",
code=ErrorCode.INTERNAL,
)
raw = base64.b64decode(frame_b64)
return await rnp_client.upload_asset(
server_url, raw,
content_type=content_type,
auth_headers=auth_headers,
)
uris = await asyncio.gather(
*(_upload_one(i, f) for i, f in enumerate(frames))
)
log.info(
"RNP: externalized %s batch envelope (%d frames, %d raw bytes) "
"-> %d presigned URIs",
env_type, len(frames), total_raw, len(uris),
)
# Strip the inline ``frames`` field; preserve ``shape`` / ``dtype``
# / ``color_space`` so the server-side decoder still sees the
# batch dimension and per-frame metadata. Encoding flips to the
# URI variant so dispatch keys cleanly off the string (NOT field
# presence — frames vs. uris is a payload field, not a shape
# selector).
out: dict[str, Any] = {
k: v for k, v in envelope.items() if k != "frames"
}
out["encoding"] = "png_base64_batch_uri"
out["uris"] = list(uris)
return out
# ---------------------------------------------------------------------------
# Image encode / decode
# ---------------------------------------------------------------------------
def encode_image_tensor(tensor: Any, *, accepts_batch: bool = False) -> dict[str, Any]:
"""Encode a ComfyUI IMAGE tensor (B,H,W,C float32 in 0..1) as an
image envelope.
``accepts_batch`` reflects whether the descriptor's
``input_serialization`` for this input lists ``"png_base64_batch"``.
When True AND ``tensor.shape[0] > 1``, emit a multi-frame
``png_base64_batch`` envelope (``frames=[<png_b64>, …]``) so the
server decodes a real ``[B, H, W, C]`` source. Otherwise emit a
single-frame ``png_base64`` envelope for ``tensor[0]`` — preserves
backwards compat with descriptors that haven't migrated yet, and
matches the wire shape of the existing ``_decode_image_batch_envelope``
on the response path so both directions round-trip symmetrically.
"""
import numpy as np
from PIL import Image
rank = getattr(tensor, "dim", lambda: 0)()
if rank == 4 and accepts_batch and int(tensor.shape[0]) > 1:
frames_arr = (tensor.cpu().clamp(0.0, 1.0).numpy() * 255).astype(np.uint8)
b, height, width = frames_arr.shape[:3]
channels = frames_arr.shape[3] if frames_arr.ndim == 4 else 1
pil_mode = {1: "L", 3: "RGB", 4: "RGBA"}.get(channels, "RGB")
frames_b64: list[str] = []
for i in range(b):
img = Image.fromarray(frames_arr[i], mode=pil_mode)
buf = BytesIO()
img.save(buf, format="PNG")
frames_b64.append(base64.b64encode(buf.getvalue()).decode("ascii"))
return {
"type": "image",
"encoding": "png_base64_batch",
"frames": frames_b64,
"shape": [b, height, width, channels],
"dtype": "uint8",
"color_space": "srgb",
}
# Single-frame fallback: matches the old ``encode_image_tensor``
# contract (sends ``tensor[0]`` for a multi-frame batch when the
# descriptor doesn't advertise the batch encoding).
if rank == 4:
frame = tensor[0]
else:
frame = tensor
arr = (frame.cpu().clamp(0.0, 1.0).numpy() * 255).astype(np.uint8)
height, width = arr.shape[:2]
channels = arr.shape[2] if arr.ndim == 3 else 1
pil_mode = {1: "L", 3: "RGB", 4: "RGBA"}.get(channels, "RGB")
img = Image.fromarray(arr, mode=pil_mode)
buf = BytesIO()
img.save(buf, format="PNG")
return {
"type": "image",
"encoding": "png_base64",
"data": base64.b64encode(buf.getvalue()).decode("ascii"),
"shape": [1, height, width, channels],
"dtype": "uint8",
"color_space": "srgb",
}
def decode_image_envelope(envelope: dict[str, Any]) -> Any:
"""Decode an image envelope into a ComfyUI IMAGE tensor.
Single-frame encodings (``png_base64``, ``jpeg_base64``,
``webp_base64``) decode into ``[1, H, W, C]``. The multi-frame
``png_base64_batch`` encoding (a list of base64-PNG ``frames``)
stacks each frame into ``[B, H, W, C]`` — the native ComfyUI
image-batch shape — so providers whose upstream returns ``n > 1``
surface as a real batched tensor in the workflow runtime.
"""
encoding = envelope.get("encoding")
if encoding == "png_base64_batch":
return _decode_image_batch_envelope(envelope)
if encoding not in ("png_base64", "jpeg_base64", "webp_base64"):
raise RnpProtocolError(
f"Unsupported image encoding: {encoding!r}",
code=ErrorCode.INTERNAL,
)
import numpy as np
import torch
from PIL import Image
raw = decode_envelope_data(envelope)
# Server emits PNG (sRGB); RGB is enough for IMAGE — RGBA path
# would force downstream models to receive an alpha channel they
# don't expect.
img = Image.open(BytesIO(raw)).convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _decode_image_batch_envelope(envelope: dict[str, Any]) -> Any:
"""Decode a ``png_base64_batch`` envelope into ``[B, H, W, C]``.
The wire shape is ``{"type":"image", "encoding":"png_base64_batch",
"frames":[<png_b64>, ...], "shape":[B,H,W,C], ...}``. Each frame is
a PNG-encoded image; all frames must share the same H/W after the
RGB conversion (the server-side encoder enforces this). A frame
count mismatch against the ``shape[0]`` field surfaces as a hard
parse error rather than silently dropping frames — matches the SVG
decoder's ``count`` integrity check.
"""
import base64 as _b64
import numpy as np
import torch
from PIL import Image
frames = envelope.get("frames")
if not isinstance(frames, list) or not frames:
raise RnpProtocolError(
"image batch envelope missing or empty 'frames' list",
code=ErrorCode.INTERNAL,
)
declared_shape = envelope.get("shape")
if isinstance(declared_shape, list) and len(declared_shape) >= 1:
declared_b = int(declared_shape[0])
if declared_b != len(frames):
raise RnpProtocolError(
f"image batch envelope frame count {len(frames)} does not "
f"match declared shape[0]={declared_b}",
code=ErrorCode.INTERNAL,
)
arrs: list[Any] = []
base_hw: tuple[int, int] | None = None
for idx, frame_b64 in enumerate(frames):
if not isinstance(frame_b64, str):
raise RnpProtocolError(
f"image batch frame {idx} is not a base64 string",
code=ErrorCode.INTERNAL,
)
try:
raw = _b64.b64decode(frame_b64)
img = Image.open(BytesIO(raw)).convert("RGB")
except Exception as e:
raise RnpProtocolError(
f"image batch frame {idx} failed to decode: {e}",
code=ErrorCode.INTERNAL,
) from e
arr = np.array(img).astype(np.float32) / 255.0
if base_hw is None:
base_hw = (arr.shape[0], arr.shape[1])
elif (arr.shape[0], arr.shape[1]) != base_hw:
raise RnpProtocolError(
f"image batch frame {idx} size {arr.shape[:2]} does not "
f"match frame 0 size {base_hw} (server should have rejected "
f"this; likely a wire bug)",
code=ErrorCode.INTERNAL,
)
arrs.append(arr)
stacked = np.stack(arrs, axis=0)
return torch.from_numpy(stacked)
# ---------------------------------------------------------------------------
# Mask encode / decode
# ---------------------------------------------------------------------------
def encode_mask_tensor(tensor: Any) -> dict[str, Any]:
"""Encode a ComfyUI MASK tensor (B,H,W float32 in 0..1) as a
PNG-base64 mask envelope (single-channel, 0..255 grayscale)."""
import numpy as np
import torch # noqa: F401 (typing only)
from PIL import Image
if hasattr(tensor, "dim") and tensor.dim() == 3:
# Batches of masks: take first; multi-mask batches are wrapped
# by the caller as a list of envelopes when needed.
frame = tensor[0]
else:
frame = tensor
arr = (frame.cpu().clamp(0.0, 1.0).numpy() * 255).astype(np.uint8)
img = Image.fromarray(arr, mode="L")
buf = BytesIO()
img.save(buf, format="PNG")
height, width = arr.shape[:2]
return {
"type": "mask",
"encoding": "png_base64",
"data": base64.b64encode(buf.getvalue()).decode("ascii"),
"shape": [1, height, width],
"dtype": "uint8",
}
def decode_mask_envelope(envelope: dict[str, Any]) -> Any:
"""Decode a mask envelope into a ComfyUI MASK tensor (B,H,W float32)."""
import numpy as np
import torch
from PIL import Image
encoding = envelope.get("encoding")
if encoding != "png_base64":
raise RnpProtocolError(
f"Unsupported mask encoding: {encoding!r}",
code=ErrorCode.INTERNAL,
)
raw = decode_envelope_data(envelope)
img = Image.open(BytesIO(raw)).convert("L")
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
# ---------------------------------------------------------------------------
# Audio encode / decode
# ---------------------------------------------------------------------------
def encode_audio_input(audio: dict[str, Any]) -> dict[str, Any]:
"""Encode a ComfyUI AUDIO dict ({waveform, sample_rate}) as an
mp3-base64 audio envelope.
"""
from comfy_api_nodes.util.conversions import audio_input_to_mp3
mp3_buf = audio_input_to_mp3(audio)
sample_rate = int(audio.get("sample_rate", 0))
waveform = audio.get("waveform")
channels = int(waveform.shape[-2]) if waveform is not None else None
duration_s = (
float(waveform.shape[-1]) / sample_rate
if (waveform is not None and sample_rate)
else None
)
extra: dict[str, Any] = {}
if channels is not None:
extra["channels"] = channels
if sample_rate:
extra["sample_rate"] = sample_rate
if duration_s is not None:
extra["duration_s"] = duration_s
return {
"type": "audio",
"encoding": "mp3_base64",
"data": base64.b64encode(mp3_buf.getvalue()).decode("ascii"),
**extra,
}
def decode_audio_envelope(envelope: dict[str, Any]) -> dict[str, Any]:
"""Decode an audio envelope back into a ComfyUI AUDIO dict."""
encoding = envelope.get("encoding")
if encoding not in (
"mp3_base64", "mp3_inline",
"wav_base64", "wav_inline",
"opus_base64",
):
raise RnpProtocolError(
f"Unsupported audio encoding: {encoding!r}",
code=ErrorCode.INTERNAL,
)
from comfy_api_nodes.util.conversions import audio_bytes_to_audio_input
return audio_bytes_to_audio_input(decode_envelope_data(envelope))
# ---------------------------------------------------------------------------
# Video decode (encode lands when a remote node accepts VIDEO inputs)
# ---------------------------------------------------------------------------
def decode_video_envelope(envelope: dict[str, Any]) -> Any:
"""Decode a video envelope into a ComfyUI Video object (mp4 inline)."""
encoding = envelope.get("encoding")
if encoding not in ("mp4_inline", "mp4_base64"):
raise RnpProtocolError(
f"Unsupported video encoding: {encoding!r}",
code=ErrorCode.INTERNAL,
)
from comfy_api.latest import InputImpl
return InputImpl.VideoFromFile(BytesIO(decode_envelope_data(envelope)))
# ---------------------------------------------------------------------------
# 3D model decode (encode lands when a remote node accepts MODEL_3D inputs)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Multi-file 3D bundle: a ``File3D``-compatible class that carries the
# primary mesh plus N companion files (textures / .mtl / .bin / etc.).
#
# Built lazily as a ``File3D`` subclass on first use — importing
# ``File3D`` at module load pulls torch (via ``comfy_api.latest._util.
# geometry_types``), which is fine inside ComfyUI but breaks the
# smoke-test pattern that exercises serialization without the heavy
# comfy_api tree. The factory below caches the constructed class so
# every ``BundledFile3D`` instance shares the same dynamic class and
# ``isinstance(x, File3D)`` always succeeds.
# ---------------------------------------------------------------------------
_BUNDLED_FILE3D_CLS: Any = None
def _bundled_file3d_class() -> type:
"""Return the cached ``BundledFile3D`` class (a ``File3D`` subclass).
Constructs it on first call so the ``comfy_api.latest._util``
import (which pulls torch) is paid for only when a bundle
envelope is actually decoded — keeps the smoke-test stubbing
pattern intact for callers that don't exercise this path.
"""
global _BUNDLED_FILE3D_CLS
if _BUNDLED_FILE3D_CLS is not None:
return _BUNDLED_FILE3D_CLS
from comfy_api.latest._util.geometry_types import File3D as _File3D
class BundledFile3D(_File3D):
"""``File3D``-compatible wrapper for a multi-file 3D bundle.
Stores the primary mesh file plus N companion files (textures
/ .mtl / .bin / etc.) in memory as a ``path -> bytes`` dict;
lazily materialises the whole bundle into a temp directory the
first time anything cross-file-resolution-sensitive is asked
of it (``get_source()`` for an OBJ/GLTF that needs its
.mtl/.bin/textures on disk; ``save_to(dest)`` which copies the
*whole bundle* into the destination directory).
Cheap consumers that just want the primary file's bytes
(``get_data()`` / ``get_bytes()``) skip the temp-dir
materialise entirely.
The temp directory's lifetime is tied to this object: it is
cleaned up on garbage-collect via ``__del__`` (best-effort).
For workflows that need stable on-disk paths beyond this
object's lifetime, call ``save_to(dest)`` to copy the bundle
into a caller-controlled location.
"""
def __init__(
self,
files: dict[str, bytes],
primary_path: str,
roles: dict[str, str] | None = None,
formats: dict[str, str] | None = None,
) -> None:
if primary_path not in files:
raise ValueError(
f"primary_path {primary_path!r} not in bundle files: "
f"{sorted(files)!r}"
)
self._files: dict[str, bytes] = dict(files)
self._primary_path = primary_path
self._roles: dict[str, str] = dict(roles or {})
self._formats: dict[str, str] = dict(formats or {})
self._temp_dir: str | None = None
# Populate parent ``_source`` / ``_format`` so reads of
# those plain attributes on downstream consumers that
# haven't been updated still work. ``_source`` is a
# BytesIO of the primary file; ``_format`` is the primary
# file's extension.
primary_fmt = (
self._formats.get(primary_path)
or (
primary_path.rsplit(".", 1)[-1].lower()
if "." in primary_path
else ""
)
)
super().__init__(BytesIO(self._files[primary_path]), primary_fmt)
# --------------------------------------------------------------
# File3D-compatible accessors (overrides)
# --------------------------------------------------------------
def get_source(self) -> str:
"""Return the on-disk path to the primary file.
Materialises the whole bundle into a temp directory on
first call so cross-file refs (.obj -> .mtl -> texture;
.gltf -> .bin + textures) resolve via the filesystem.
"""
self._ensure_materialized()
from os.path import join as _join
return _join(self._temp_dir, *self._primary_path.split("/"))
def get_data(self) -> BytesIO:
"""Return the primary file's bytes (no disk I/O)."""
return BytesIO(self._files[self._primary_path])
def get_bytes(self) -> bytes:
"""Return the primary file's raw bytes (no disk I/O)."""
return self._files[self._primary_path]
def save_to(self, path: str) -> str:
"""Copy the WHOLE bundle into the destination directory.
``path`` is the destination for the *primary file*;
sidecars are copied alongside it preserving their relative
paths from the original bundle layout. E.g. for
primary_path = "model.obj"
files = {
"model.obj": <bytes>,
"model.mtl": <bytes>,
"textures/albedo.png": <bytes>,
}
``save_to("/tmp/out/scene.obj")`` writes:
/tmp/out/scene.obj
/tmp/out/model.mtl
/tmp/out/textures/albedo.png
Sidecars keep their ORIGINAL filenames (so cross-file
refs inside the .obj that point at ``model.mtl`` still
resolve); only the primary file is renamed to match
``path``. The destination directory is created if needed.
"""
from pathlib import Path
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(self._files[self._primary_path])
dest_parent_resolved = dest.parent.resolve()
for rel_path, raw in self._files.items():
if rel_path == self._primary_path:
continue
sidecar_dest = dest.parent.joinpath(*rel_path.split("/"))
# Path safety — rel_path was sanitized at envelope
# build time, but defence-in-depth check on the
# resolved path keeps us safe against pathological
# inputs that may have slipped through.
resolved = sidecar_dest.resolve()
if dest_parent_resolved not in resolved.parents and resolved != dest_parent_resolved:
continue
sidecar_dest.parent.mkdir(parents=True, exist_ok=True)
sidecar_dest.write_bytes(raw)
return str(dest)
# --------------------------------------------------------------
# Bundle-specific accessors (additive)
# --------------------------------------------------------------
@property
def primary_path(self) -> str:
return self._primary_path
@property
def file_paths(self) -> list[str]:
return sorted(self._files)
def file_bytes(self, path: str) -> bytes:
"""Raw bytes for one file in the bundle by relative path."""
return self._files[path]
def file_role(self, path: str) -> str | None:
"""Optional producer-supplied role hint (may be ``None``)."""
return self._roles.get(path)
def paths_with_role(self, role: str) -> list[str]:
"""All file paths whose role hint equals ``role``."""
return [p for p, r in self._roles.items() if r == role]
def materialize_to_temp_dir(self) -> str:
"""Force-materialise the whole bundle into a temp directory.
Returns the temp-directory path. Cleanup happens on GC;
callers that need stable lifetimes should ``save_to()`` to
a caller-controlled location instead.
"""
self._ensure_materialized()
return self._temp_dir
# --------------------------------------------------------------
# Internal
# --------------------------------------------------------------
def _ensure_materialized(self) -> None:
if self._temp_dir is not None:
return
import tempfile
from pathlib import Path
self._temp_dir = tempfile.mkdtemp(prefix="rnp_model3d_bundle_")
root = Path(self._temp_dir)
for rel_path, raw in self._files.items():
# rel_path was sanitized at envelope build time —
# split-on-'/' and re-join via Path is safe.
dest = root.joinpath(*rel_path.split("/"))
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(raw)
def __del__(self) -> None:
td = getattr(self, "_temp_dir", None)
if td is not None:
try:
import shutil
shutil.rmtree(td, ignore_errors=True)
except Exception: # noqa: BLE001
pass
def __repr__(self) -> str:
return (
f"BundledFile3D(primary={self._primary_path!r}, "
f"files={len(self._files)}, format={self._format!r})"
)
_BUNDLED_FILE3D_CLS = BundledFile3D
return _BUNDLED_FILE3D_CLS
def decode_model3d_envelope(envelope: dict[str, Any]) -> Any:
"""Decode a 3D-model envelope into a ComfyUI ``File3D`` object.
Dispatches on the envelope's ``encoding``:
* ``glb_inline`` — single-file binary glTF 2.0 (magic ``b"glTF"``
at offset 0). The bytes are handed to ``File3D`` as a
``BytesIO`` so downstream nodes (preview3d / SaveGLB / Load3D)
see exactly the same value shape they would from a local Tripo
/ Rodin / Meshy node. The envelope's optional ``format`` extra
is forwarded as the second constructor arg so the materialiser
picks the right file extension when persisting.
* ``bundle_inline`` — multi-file bundle. The primary mesh file
plus N companion files (textures / .mtl / .bin / etc.) are
decoded into a :class:`BundledFile3D` (``File3D``-compatible)
whose ``get_source()`` / ``save_to()`` materialise the whole
bundle on disk so cross-file refs resolve via the filesystem.
Path safety (no ``..`` / no absolute prefix / no Windows drive
letter / no case-insensitive collisions) was enforced at
envelope build time on the server; the client re-asserts the
``primary_path``-present invariant defensively and rejects any
file entry that lacks ``data``.
"""
encoding = envelope.get("encoding")
if encoding == "glb_inline":
from comfy_api.latest._util import File3D
file_format = envelope.get("format") or "glb"
return File3D(BytesIO(decode_envelope_data(envelope)), str(file_format))
if encoding == "bundle_inline":
bundle_cls = _bundled_file3d_class()
primary_path = envelope.get("primary_path")
if not isinstance(primary_path, str) or not primary_path:
raise RnpProtocolError(
"bundle_inline envelope missing 'primary_path'",
code=ErrorCode.INTERNAL,
)
files_spec = envelope.get("files")
if not isinstance(files_spec, list) or not files_spec:
raise RnpProtocolError(
"bundle_inline envelope missing non-empty 'files' list",
code=ErrorCode.INTERNAL,
)
files: dict[str, bytes] = {}
roles: dict[str, str] = {}
formats: dict[str, str] = {}
for entry in files_spec:
if not isinstance(entry, dict):
raise RnpProtocolError(
f"bundle_inline file entry not a dict: {entry!r}",
code=ErrorCode.INTERNAL,
)
path = entry.get("path")
if not isinstance(path, str) or not path:
raise RnpProtocolError(
f"bundle_inline file entry missing 'path': {entry!r}",
code=ErrorCode.INTERNAL,
)
data = entry.get("data")
if not isinstance(data, str):
raise RnpProtocolError(
f"bundle_inline file {path!r} missing inline 'data'",
code=ErrorCode.INTERNAL,
)
files[path] = base64.b64decode(data)
role = entry.get("role")
if isinstance(role, str):
roles[path] = role
fmt = entry.get("format")
if isinstance(fmt, str):
formats[path] = fmt
if primary_path not in files:
raise RnpProtocolError(
f"bundle_inline primary_path {primary_path!r} not in "
f"files list (have: {sorted(files)!r})",
code=ErrorCode.INTERNAL,
)
return bundle_cls(
files=files,
primary_path=primary_path,
roles=roles,
formats=formats,
)
raise RnpProtocolError(
f"Unsupported model_3d encoding: {encoding!r}",
code=ErrorCode.INTERNAL,
)
# ---------------------------------------------------------------------------
# Cross-node task handle
#
# A ``task_handle`` envelope wraps a vendor-native task / video / job
# ID (Tripo MODEL_TASK_ID, Kling video_id, ...) with lineage metadata
# so chained provider nodes can pass the handle through the workflow
# without losing the upstream chain. The decoder returns a
# :class:`TaskHandle` dataclass — re-encoding the same dataclass on a
# downstream submit (via :func:`encode_task_handle`) preserves the
# full ``parent_chain`` without the proxy_node having to know
# anything provider-specific. Plain ``str`` round-trip would silently
# drop lineage, so the decoder deliberately surfaces the rich type
# even at the cost of slightly more work on the consumer side.
# ---------------------------------------------------------------------------
from dataclasses import dataclass, field
@dataclass
class TaskHandle:
"""Cross-node handle to a vendor-native async task / video / job.
See :data:`comfy_remote_nodes.protocol.Capability.IO_TASK_HANDLE`
for the wire-shape contract. The dataclass mirrors the envelope
fields verbatim so :func:`encode_task_handle` is a trivial
round-trip. ``parent_chain`` is a flat list of
``{vendor, kind, native_id, origin_node_id}`` dicts in
root-to-immediate-parent order; treat it as opaque on the client
— the server is the only component that ever consumes it.
"""
vendor: str
kind: str
native_id: str
origin_node_id: str
parent_chain: list[dict[str, str]] = field(default_factory=list)
def __str__(self) -> str:
# Workflow runtime sometimes coerces socket values to ``str``
# (UI previews, log lines). Surfacing the native id keeps that
# output human-meaningful without leaking the lineage chain
# into UI strings.
return self.native_id
def _validate_task_handle_ref(ref: Any, *, context: str) -> dict[str, str]:
"""Validate one ``{vendor, kind, native_id, origin_node_id}`` ref.
Mirror of the server-side ``_validate_task_handle_ref`` —
rejects malformed parent_chain entries with the same field-by-
field error messages so a bad envelope surfaces the same way on
both sides.
"""
if not isinstance(ref, dict):
raise RnpProtocolError(
f"{context}: expected dict, got {type(ref).__name__}",
code=ErrorCode.INTERNAL,
)
out: dict[str, str] = {}
for fld in ("vendor", "kind", "native_id", "origin_node_id"):
val = ref.get(fld)
if not isinstance(val, str) or not val:
raise RnpProtocolError(
f"{context}: field {fld!r} must be a non-empty string "
f"(got {val!r})",
code=ErrorCode.INTERNAL,
)
out[fld] = val
if "," in out["kind"]:
raise RnpProtocolError(
f"{context}: 'kind' must be a singular socket kind, not a "
f"comma-union string (got {out['kind']!r})",
code=ErrorCode.INTERNAL,
)
return out
def decode_task_handle_envelope(envelope: dict[str, Any]) -> TaskHandle:
"""Decode a ``task_handle`` envelope into a :class:`TaskHandle`.
Only the ``vendor_inline`` encoding is recognised today (a future
sibling ``replay_ticket_inline`` would carry a signed server-
local replay token for cross-server portability — not in this
PR). All four top-level fields and every ``parent_chain`` entry
are validated; rejections raise ``RnpProtocolError(INTERNAL)``
with the same field-by-field messages the server emits at
envelope-build time.
"""
encoding = envelope.get("encoding")
if encoding != "vendor_inline":
raise RnpProtocolError(
f"Unsupported task_handle encoding: {encoding!r}",
code=ErrorCode.INTERNAL,
)
top = _validate_task_handle_ref(
{
"vendor": envelope.get("vendor"),
"kind": envelope.get("kind"),
"native_id": envelope.get("native_id"),
"origin_node_id": envelope.get("origin_node_id"),
},
context="task_handle",
)
chain_in = envelope.get("parent_chain", [])
if not isinstance(chain_in, list):
raise RnpProtocolError(
f"task_handle 'parent_chain' must be a list (got "
f"{type(chain_in).__name__})",
code=ErrorCode.INTERNAL,
)
chain_out = [
_validate_task_handle_ref(entry, context=f"parent_chain[{i}]")
for i, entry in enumerate(chain_in)
]
return TaskHandle(
vendor=top["vendor"],
kind=top["kind"],
native_id=top["native_id"],
origin_node_id=top["origin_node_id"],
parent_chain=chain_out,
)
def encode_task_handle(handle: TaskHandle) -> dict[str, Any]:
"""Encode a :class:`TaskHandle` back into a wire-format envelope.
Symmetric counterpart of :func:`decode_task_handle_envelope` so a
handle decoded from one provider's output and fed into another
provider's input round-trips losslessly — the proxy_node calls
this whenever it sees a ``TaskHandle`` in the input values for a
descriptor that accepts the ``task_handle`` envelope type.
The returned dict is the same shape the server's
``make_task_handle_envelope`` produces, so the server-side
decoder accepts it without any branching on "is this a fresh
handle or a re-encoded one" — the wire is the source of truth.
"""
return {
"type": "task_handle",
"encoding": "vendor_inline",
"vendor": handle.vendor,
"kind": handle.kind,
"native_id": handle.native_id,
"origin_node_id": handle.origin_node_id,
"parent_chain": [dict(entry) for entry in handle.parent_chain],
}
# ---------------------------------------------------------------------------
# Dispatch by envelope type
# ---------------------------------------------------------------------------
_DECODERS = {
"image": decode_image_envelope,
"mask": decode_mask_envelope,
"audio": decode_audio_envelope,
"video": decode_video_envelope,
"model_3d": decode_model3d_envelope,
"task_handle": decode_task_handle_envelope,
}
def decode_envelope(value: dict[str, Any]) -> Any:
"""Top-level dispatch: convert any envelope to its native ComfyUI type."""
decoder = _DECODERS.get(value.get("type", ""))
if decoder is None:
raise RnpProtocolError(
f"Unknown envelope type: {value.get('type')!r}",
code=ErrorCode.INTERNAL,
)
return decoder(value)
def is_image_tensor(value: Any) -> bool:
"""Best-effort check for a ComfyUI IMAGE tensor (B,H,W,C float)."""
return _is_torch_tensor(value) and getattr(value, "dim", lambda: 0)() in (3, 4)
def is_mask_tensor(value: Any) -> bool:
"""Best-effort check for a ComfyUI MASK tensor (B,H,W float or H,W)."""
return _is_torch_tensor(value) and getattr(value, "dim", lambda: 0)() in (2, 3)
def is_audio_input(value: Any) -> bool:
return (
isinstance(value, dict)
and "waveform" in value
and "sample_rate" in value
)
def _is_torch_tensor(value: Any) -> bool:
try:
import torch
except ImportError:
return False
return isinstance(value, torch.Tensor)
# Re-export for callers that already imported via this module.
__all__ = [
"encode_image_tensor",
"decode_image_envelope",
"encode_mask_tensor",
"decode_mask_envelope",
"encode_audio_input",
"decode_audio_envelope",
"decode_video_envelope",
"decode_model3d_envelope",
"_bundled_file3d_class",
"TaskHandle",
"decode_task_handle_envelope",
"encode_task_handle",
"decode_envelope",
"is_envelope",
"is_image_tensor",
"is_mask_tensor",
"is_audio_input",
]