The pre-existing `serialization.maybe_externalize` only inspected the
singleton `data` field, so a multi-frame `frames=[…]` IMAGE envelope
whose summed base64 payload crossed the 8 MiB
`DEFAULT_MAX_INLINE_PAYLOAD_BYTES` cap silently rode the wire as a
30+ MiB POST body — defeating the entire reason the cap exists. A
9× 2048² Flux2 reference batch hits ~200 MB base64 inline; that's
the worst-case the new path is designed for.
This commit adds the **client side** of the lockstep fix: detect
oversize multi-frame batches (sum of per-frame raw bytes), upload
each frame as an individual presigned PUT in parallel via
`asyncio.gather`, and rewrite the envelope to the new
`image:png_base64_batch_uri` encoding with a parallel `uris` list.
Server side closes the loop with a matching encoding-string
dispatch in `resolve_image_envelope_pngs`.
Per-frame URIs (NOT a tar/zip blob) compose with the existing
per-frame fetch helpers and let the server fetch in parallel.
## Server
- See lockstep PR on `Comfy-Org/comfy-rnp-server` branch
`feat/batch-envelope-externalization` for the
`Capability.IMAGE_PNG_BASE64_BATCH_URI` token, the
`_resolve_png_base64_batch_uri` per-frame fetch helper, and the
encoding-string dispatch in `resolve_image_envelope_pngs`.
## Client
- `protocol.py`: mirror new `Capability.IMAGE_PNG_BASE64_BATCH_URI =
"image:png_base64_batch_uri"` token next to
`IMAGE_PNG_BASE64_BATCH`. Same comment block as the server-side
copy so the two stay byte-comparable.
- `client.py`: append `Capability.IMAGE_PNG_BASE64_BATCH_URI` to
`CLIENT_CAPABILITIES` so the server knows this client can emit
the externalised batch shape. Comment notes the opportunistic
posture (any input that already accepts `png_base64_batch`
transparently accepts the externalised variant — no descriptor
opt-in needed).
- `serialization.py`:
- `maybe_externalize` now branches on payload shape: if
`envelope["frames"]` is a populated list, dispatch to the new
`_maybe_externalize_batch` helper; otherwise fall through to
the existing singleton-`data` path. Docstring expanded to
describe both shapes and the encoding-flip-on-spill behaviour.
- New `_maybe_externalize_batch` helper sums per-frame raw bytes
(`len(b64) * 3 // 4`), short-circuits when the batch fits under
the cap, and otherwise uploads every frame in parallel via
`asyncio.gather` (preserves frame ordering — critical because
Flux2 maps `uris[k]` → `input_image_{k+1}` 1:1) before
rewriting the envelope to `{type, encoding:
png_base64_batch_uri, uris: [...], shape, dtype, color_space}`
with `frames` stripped.
- `import asyncio` added at module top so `asyncio.gather` is
available without per-call imports.
## Docs
- The pilot side-by-side workflow `notes/sxs_flux2_pro_multi_ref_xl.json`
(9× 2048² references) and the closed-gap entry in
`notes/remote-nodes-vocabulary-gaps.md` live in the server
worktree (this client repo has no `notes/` directory). Both ship
in the paired server PR.
## Verification
- AST parse of `protocol.py`, `serialization.py`, `client.py`
succeeds.
- In-process round-trip test (with a fake `comfy_api_nodes.util.client`
shim and a stubbed `upload_asset`):
* Under-cap batch envelope (1× 5 MiB raw, cap=8 MiB) → passes
through unchanged (object identity preserved).
* Over-cap batch envelope (3× 5 MiB raw, cap=8 MiB) → triggers
3 PUTs via the stub, returns
`{type: image, encoding: png_base64_batch_uri,
uris: ['…/asset/1','…/asset/2','…/asset/3'], shape: [3,1,1,3],
dtype: uint8, color_space: srgb}` with `frames` stripped.
* Singleton `data` envelope (10 MiB raw, cap=8 MiB) → still gets
the legacy `data → uri` swap (existing path unaffected).
* `Capability.IMAGE_PNG_BASE64_BATCH_URI in CLIENT_CAPABILITIES`
holds.
## Lockstep
- Paired server PR: https://github.com/Comfy-Org/comfy-rnp-server/pull/14
(`feat/batch-envelope-externalization`). Server PR was opened
first because the wire shape needs a server resolver to be
meaningful — the orchestrator merges them in lockstep order.
- Capability gating posture: `IMAGE_PNG_BASE64_BATCH_URI` is
opportunistic — this client emits it whenever a batch envelope it
produced overflows the inline cap, regardless of whether the
descriptor's `input_serialization` lists it. Server accepts on
any image input that already accepts `png_base64_batch` via the
shared `resolve_image_envelope_pngs` dispatch.
- Output-side decoder: server providers emit `png_base64_batch` (or
single-frame `png_base64`) on responses today; the URI shape is
consumed on input only, so the client decoder (`_DECODERS` /
`_decode_image_batch_envelope`) is unchanged. The existing
`decode_image_envelope` already dispatches on the `encoding`
string (not field presence), so adding a `png_base64_batch_uri`
decode branch is a drop-in if a future provider starts emitting
the URI shape on outputs. Logged as a known follow-up.
Amp-Thread-ID: T-019e29b4-d606-7278-8584-52910661c981
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2adb-6ddf-72ad-88e1-8b30c57e873f
Co-authored-by: Amp <amp@ampcode.com>
220 lines
8.2 KiB
Python
220 lines
8.2 KiB
Python
"""Vendored copy of the parts of ``comfy_rnp_protocol`` the client needs.
|
|
|
|
The full protocol package lives in ``comfy-rnp-server/comfy_rnp_protocol``
|
|
and is the source of truth for these constants and helpers — this file
|
|
is a thin mirror so the ComfyUI extension can ship without depending on
|
|
the server package being on ``PYTHONPATH``. Keep it byte-compatible with
|
|
the upstream module.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from typing import Any
|
|
|
|
PROTOCOL_NAME = "comfy-rnp"
|
|
PROTOCOL_VERSION = "1.0"
|
|
SCHEMA_HASH_ALGORITHM = "sha256"
|
|
DEFAULT_MAX_INLINE_PAYLOAD_BYTES = 8 * 1024 * 1024
|
|
DEFAULT_POLL_INTERVAL_S = 1.5
|
|
|
|
# Defaults for descriptor.remote.execution fields the client must honour.
|
|
# Values mirror comfy_api_nodes.util.client.poll_op_raw so RNP-routed
|
|
# nodes behave identically to a direct upstream call when the descriptor
|
|
# leaves a field unset.
|
|
#
|
|
# Lifetime budget design: the wire format declares ``soft_timeout_s``
|
|
# (server-side stuck threshold) and ``hard_timeout_s`` (server-side
|
|
# hard kill) as durations. The client's poll-loop cap is a derived
|
|
# value — ``max_poll_attempts = ceil((hard_timeout_s + DEFAULT_POLL_GRACE_S)
|
|
# / poll_interval_s)`` — not a separate wire field, so changing the
|
|
# poll cadence can never silently shrink or stretch the contract.
|
|
# Industry precedent: OAuth2 device flow §3.5, Kubernetes
|
|
# ``--timeout``, gRPC deadlines all express bounds as durations.
|
|
DEFAULT_SOFT_TIMEOUT_S = 240.0
|
|
DEFAULT_HARD_TIMEOUT_S = 300.0
|
|
DEFAULT_POLL_GRACE_S = 30.0
|
|
DEFAULT_TIMEOUT_PER_POLL_S = 120.0
|
|
DEFAULT_MAX_RETRIES_PER_POLL = 10
|
|
DEFAULT_RETRY_DELAY_PER_POLL_S = 1.0
|
|
DEFAULT_RETRY_BACKOFF_PER_POLL = 1.4
|
|
DEFAULT_CANCEL_TIMEOUT_S = 10.0
|
|
DEFAULT_SUBMIT_MAX_RETRIES = 2
|
|
DEFAULT_SUBMIT_RETRY_BACKOFF = 2.0
|
|
DEFAULT_SUBMIT_RETRY_DELAY_S = 1.0
|
|
|
|
|
|
class IdempotencyMode:
|
|
CLIENT_KEY = "client_key"
|
|
NONE = "none"
|
|
|
|
|
|
class ExecutionMode:
|
|
REQUEST_RESPONSE = "request_response"
|
|
ASYNC_POLLING = "async_polling"
|
|
|
|
|
|
class TaskStatus:
|
|
PENDING = "pending"
|
|
RUNNING = "running"
|
|
DONE = "done"
|
|
ERROR = "error"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
TERMINAL_TASK_STATES = frozenset({
|
|
TaskStatus.DONE,
|
|
TaskStatus.ERROR,
|
|
TaskStatus.CANCELLED,
|
|
})
|
|
|
|
|
|
class ErrorCode:
|
|
INPUT_INVALID = "INPUT_INVALID"
|
|
AUTH_FAILED = "AUTH_FAILED"
|
|
INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS"
|
|
RATE_LIMITED = "RATE_LIMITED"
|
|
PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
|
|
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
|
|
VERSION_INCOMPATIBLE = "VERSION_INCOMPATIBLE"
|
|
SCHEMA_HASH_MISMATCH = "SCHEMA_HASH_MISMATCH"
|
|
TIMEOUT = "TIMEOUT"
|
|
INTERNAL = "INTERNAL"
|
|
NOT_FOUND = "NOT_FOUND"
|
|
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
|
# 404 from a poll/cancel that carried X-RNP-Idempotency-Key — the
|
|
# server lost the task (typically a restart). The client replays
|
|
# the original submit with the same key to spawn a fresh task.
|
|
TASK_LOST = "TASK_LOST"
|
|
# Server-side back-pressure / operator drain — both ride 503 with
|
|
# Retry-After. SERVER_BUSY = transient saturation, MAINTENANCE =
|
|
# operator action.
|
|
SERVER_BUSY = "SERVER_BUSY"
|
|
MAINTENANCE = "MAINTENANCE"
|
|
|
|
|
|
class Header:
|
|
PROTOCOL_VERSION = "X-RNP-Protocol-Version"
|
|
CLIENT_VERSION = "X-RNP-Client-Version"
|
|
CLIENT_CAPABILITIES = "X-RNP-Client-Capabilities"
|
|
SCHEMA_HASH = "X-RNP-Schema-Hash"
|
|
IDEMPOTENCY_KEY = "X-RNP-Idempotency-Key"
|
|
|
|
|
|
class Capability:
|
|
IMAGE_PNG_BASE64 = "image:png_base64"
|
|
IMAGE_JPEG_BASE64 = "image:jpeg_base64"
|
|
IMAGE_RAW_CHW_UINT8 = "image:raw_b64_chw_uint8"
|
|
# Multi-frame IMAGE envelope: a single value carries N PNG-base64
|
|
# frames sharing the same H/W/C, decoded into a [B,H,W,C] tensor on
|
|
# this client. Mirrors the AUDIO sweep precedent (parallel encodings
|
|
# rather than extending ``png_base64``) so legacy server emits still
|
|
# decode correctly when the new token isn't advertised.
|
|
IMAGE_PNG_BASE64_BATCH = "image:png_base64_batch"
|
|
# Externalised counterpart of ``image:png_base64_batch``: same
|
|
# multi-frame IMAGE envelope shape, but every frame's PNG bytes
|
|
# are uploaded out-of-band via the presigned-PUT path (see
|
|
# ``serialization.maybe_externalize``) and the envelope carries
|
|
# a parallel ``uris`` list instead of inline ``frames``. Required
|
|
# because the original ``maybe_externalize`` only inspected the
|
|
# singleton ``data`` field, so a batch of N PNGs whose summed
|
|
# base64 payload crossed the 8 MiB inline cap silently rode the
|
|
# wire as a 30+ MiB POST body. Per-frame URIs (NOT a tar/zip
|
|
# blob) keep this composable with the existing per-frame fetch
|
|
# helpers and let the server fetch in parallel. Decoder dispatch
|
|
# keys off the ``encoding`` string — ``frames`` vs ``uris`` is a
|
|
# payload field, not a shape selector.
|
|
IMAGE_PNG_BASE64_BATCH_URI = "image:png_base64_batch_uri"
|
|
VIDEO_MP4_INLINE = "video:mp4_inline"
|
|
VIDEO_MP4_BASE64 = "video:mp4_base64"
|
|
AUDIO_MP3_INLINE = "audio:mp3_inline"
|
|
AUDIO_MP3_BASE64 = "audio:mp3_base64"
|
|
AUDIO_WAV_BASE64 = "audio:wav_base64"
|
|
AUDIO_WAV_INLINE = "audio:wav_inline"
|
|
MASK_PNG_BASE64 = "mask:png_base64"
|
|
SVG_XML_BASE64 = "svg:svg_xml_base64"
|
|
SCHEMA_V3 = "schema:v3"
|
|
ASYNC_EXECUTE = "execute:async"
|
|
# Pass-through custom-IO bucket: when an input/output type string
|
|
# is not in the standard primitive/media set, the proxy_node parser
|
|
# falls back to ``IO.Custom(<type>)`` so partner helper-config nodes
|
|
# (OpenAIInputFiles, OpenAIChatConfig, GeminiInputFiles, Recraft
|
|
# style/color/controls helpers, future partner helper-config types)
|
|
# round-trip without per-type encoder plumbing. The descriptor's
|
|
# original io_type string is preserved on the V3 input so node-to-
|
|
# node connections only chain when the strings match. Encoder
|
|
# dispatch passes opaque dict/list/string values through
|
|
# ``_encode_one`` unchanged — they're already JSON-serialisable.
|
|
IO_OPAQUE = "io:opaque"
|
|
|
|
|
|
HEAVY_TYPES = frozenset({"image", "video", "audio", "mask"})
|
|
|
|
|
|
def is_envelope(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, dict)
|
|
and isinstance(value.get("type"), str)
|
|
and value.get("type") in HEAVY_TYPES
|
|
and isinstance(value.get("encoding"), str)
|
|
)
|
|
|
|
|
|
def decode_envelope_data(envelope: dict[str, Any]) -> bytes:
|
|
"""Decode the inline base64 ``data`` of an envelope.
|
|
|
|
URI envelopes are resolved out-of-band by the caller (see
|
|
``proxy_node._deserialize_output``); by the time they reach this
|
|
function the bytes have already been fetched and re-stamped onto
|
|
``data``.
|
|
"""
|
|
if "data" not in envelope:
|
|
raise ValueError(
|
|
"envelope has no inline 'data' — caller must resolve "
|
|
f"{sorted(k for k in envelope.keys() if k != 'data')!r} first"
|
|
)
|
|
return base64.b64decode(envelope["data"])
|
|
|
|
|
|
class RnpProtocolError(Exception):
|
|
"""Raised when a server response can't be parsed as RNP/1, or when
|
|
a structured-error response is returned. Carries the parsed error
|
|
envelope so the caller can surface ``user_facing`` messages.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str = ErrorCode.INTERNAL,
|
|
status: int | None = None,
|
|
user_facing: bool = False,
|
|
retryable: bool = False,
|
|
details: dict[str, Any] | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.status = status
|
|
self.user_facing = user_facing
|
|
self.retryable = retryable
|
|
self.details = details
|
|
|
|
@classmethod
|
|
def from_response_body(
|
|
cls, body: dict[str, Any] | None, status: int
|
|
) -> "RnpProtocolError":
|
|
err = (body or {}).get("error") if isinstance(body, dict) else None
|
|
if not isinstance(err, dict):
|
|
return cls(
|
|
f"Unstructured error response (HTTP {status})",
|
|
code=ErrorCode.INTERNAL,
|
|
status=status,
|
|
)
|
|
return cls(
|
|
err.get("message") or f"HTTP {status}",
|
|
code=err.get("code") or ErrorCode.INTERNAL,
|
|
status=status,
|
|
user_facing=bool(err.get("user_facing")),
|
|
retryable=bool(err.get("retryable")),
|
|
details=err.get("details") if isinstance(err.get("details"), dict) else None,
|
|
)
|