feat: integrate native image-list caching and exact serverless value overrides
This commit is contained in:
+44
-13
@@ -6,8 +6,12 @@ executor `execute_media_request`. It does **not** start ComfyUI, provision an
|
||||
endpoint, install models, or provide a provider worker/supervisor. The endpoint
|
||||
needs this bridge, not the full ComfyUI-Distributed extension.
|
||||
|
||||
For collector setup, list ordering, supported native VIDEO states, and PR2
|
||||
limitations, see [typed serverless media](../docs/serverless-media.md).
|
||||
The bridge's generic contract supports `IMAGE`, `AUDIO`, `MASK`, `STRING`, and
|
||||
native ComfyUI `VIDEO`. **Distributed Collector** (`DistributedCollector`) uses
|
||||
only the image/audio outputs and combines their returned lists into ordinary
|
||||
IMAGE/AUDIO outputs. It has no MASK/STRING/VIDEO outputs, and the separate
|
||||
`DistributedMediaCollector` node has been removed. For Collector setup, ordering,
|
||||
and limits, see [serverless images and audio](../docs/serverless-media.md).
|
||||
|
||||
## Install in the endpoint image
|
||||
|
||||
@@ -70,7 +74,8 @@ returning `{"error": ...}` or marking a provider job `FAILED` is not that policy
|
||||
|
||||
## Request and response
|
||||
|
||||
A minimal text request builder (no files or model required by this graph):
|
||||
A minimal generic bridge text request builder (no files or model required by this
|
||||
graph). This illustrates the bridge contract, not a Collector text output:
|
||||
|
||||
```python
|
||||
import json
|
||||
@@ -127,7 +132,7 @@ For typed inputs, `media.artifacts` contains records with exactly:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `id`, `kind` | Unique artifact ID and one of the five native types |
|
||||
| `id`, `kind` | Unique artifact ID and a native media type or internal `VALUE` |
|
||||
| `descriptor` | Kind-specific codec metadata; use the shared codec, not pickle |
|
||||
| `byte_length`, `sha256` | Exact payload byte count and lowercase SHA-256 hex |
|
||||
| `transport` | `{"type": "inline", "data_base64": "..."}` or the URL transport below |
|
||||
@@ -144,7 +149,7 @@ to owned staging paths. They are not an arbitrary filename-upload interface.
|
||||
The returned `media` object has exactly `version`, `profile`, `request_id`,
|
||||
`bindings`, `artifacts`, and `items`:
|
||||
|
||||
- `bindings`: every declared binding, unchanged, plus `count` (1–64).
|
||||
- `bindings`: every declared binding, unchanged, plus `count` (1–64 for media; exactly 1 for End value overrides).
|
||||
- `artifacts`: the same artifact schema above with inline or URL transport.
|
||||
- `items`: `{ "binding_id": "...", "item_index": 0, "artifact_id": "..." }`
|
||||
records covering every index exactly once, ordered by `item_index` when decoded.
|
||||
@@ -155,20 +160,46 @@ only and must not be returned to the master. The executor reads validated
|
||||
`media_exports` history records and bounded `/view` payloads before constructing
|
||||
the external envelope. [contract.py](contract.py) is the schema authority.
|
||||
|
||||
## Internal image-list values
|
||||
|
||||
Image-list End uses sparse `value_1` … `value_10` bindings of kind `VALUE`.
|
||||
These are internal exports, not new Collector sockets. `value-v1` descriptors
|
||||
preserve BOOLEAN, arbitrary-size INT, finite binary64 FLOAT, STRING, IMAGE and
|
||||
MASK distinctly; tensors/text reuse exact native codecs. Each binding returns
|
||||
exactly one value. Only explicit End overrides are exported; the master rebuilds
|
||||
unchanged values from its source items. Compression of the main IMAGE never
|
||||
changes value-tensor fidelity.
|
||||
|
||||
Deploy the complete updated bridge, including `value_codec.py`, before using
|
||||
End overrides. `MediaImportV1` retains its original five output positions and
|
||||
appends the internal wildcard VALUE output at slot 5. Older bridge installations
|
||||
do not support these bindings; ordinary provider health checks do not verify
|
||||
this support. Request/result size limits still apply.
|
||||
|
||||
## Optional image return encoding
|
||||
|
||||
IMAGE `MediaExportV1` nodes may also contain literal `transfer_format` and
|
||||
`transfer_quality` controls. Defaults are `exact` and `90`; old exact requests
|
||||
omit the controls and retain their original representation. Other media exports
|
||||
must not carry image controls. The master compiler supplies them from the typed
|
||||
Collector's widgets; callers should not alter binding identity metadata.
|
||||
`transfer_quality` controls. The **bridge-level defaults** are `exact` and `90`;
|
||||
generic callers that omit the controls retain that representation. Other media
|
||||
exports must not carry image controls. Callers should not alter binding identity
|
||||
metadata.
|
||||
|
||||
Formats: `exact`, `legacy_png`, `png`, `ffv1`, `jpeg`, `webp`, `h264`, `h265`, `av1`.
|
||||
The **Collector default is different**: its saved widget value remains
|
||||
`legacy_png` with quality `90`. For a `comfy-media-v1` pool, the master compiler
|
||||
maps that value to an explicit `png` export, so normal serverless image returns
|
||||
use compressed 8-bit PNG. `exact` is opt-in at the Collector. Other selected
|
||||
formats are unchanged. Direct workers also support opt-in exact IMAGE transport;
|
||||
an older master rejection is an error, not an 8-bit retry. A Collector requesting
|
||||
`exact` cannot dispatch to a `legacy-image-v1` pool.
|
||||
|
||||
Formats: `exact`, `legacy_png`, `png`, `ffv1`, `jpeg`, `webp`, `h264`,
|
||||
`h265` (**experimental**), `av1`.
|
||||
`exact` preserves float tensor bits. The image/video codecs quantize RGB/grayscale
|
||||
to 8-bit; PNG/FFV1 are lossless at that precision, while the other codecs are
|
||||
lossy. Quality is an integer 1–100 and affects only lossy formats. IMAGE batch
|
||||
order, shape and channel count are retained, and alpha is not discarded.
|
||||
Native VIDEO/MASK/AUDIO/STRING still use their original exact codecs.
|
||||
Native VIDEO/MASK/AUDIO/STRING bridge values still use their original exact codecs;
|
||||
the image-format controls do not add those sockets to the Collector.
|
||||
|
||||
Copy/update the complete bridge directory on both sides, including
|
||||
`image_transfer.py` and `image_codec.py`. Pillow and the selected CPU/software
|
||||
@@ -247,5 +278,5 @@ unidentifiable files are not claimed; directories and all unidentified leftovers
|
||||
remain lifecycle-owned. This is not a temp-directory sweep. Independently decoded
|
||||
VIDEO objects do not depend on either staging or exporter files.
|
||||
|
||||
Verification scope: source and offline tests. No live provider/GPU validation is
|
||||
claimed, and this directory is not a tested turnkey provider deployment.
|
||||
This guide describes the source contract, not a test result. No live provider/GPU
|
||||
validation is claimed, and this directory is not a turnkey provider deployment.
|
||||
|
||||
@@ -24,7 +24,7 @@ _LAYOUTS = {"IMAGE": "BHWC", "MASK": "BHW", "AUDIO": "BCT"}
|
||||
|
||||
|
||||
def _validate_kind(kind):
|
||||
if type(kind) is not str or kind not in ("IMAGE", "MASK", "STRING", "AUDIO"):
|
||||
if type(kind) is not str or kind not in ("IMAGE", "MASK", "STRING", "AUDIO", "VALUE"):
|
||||
raise ValueError("Unsupported payload kind")
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ def encode_payload(kind: str, value, *, max_bytes: int = MAX_PAYLOAD_BYTES) -> t
|
||||
"""
|
||||
_validate_kind(kind)
|
||||
limit = _byte_limit(max_bytes)
|
||||
if kind == "VALUE":
|
||||
from .value_codec import encode_value
|
||||
return encode_value(value, max_bytes=limit)
|
||||
if kind == "STRING":
|
||||
return _encode_string(value, limit)
|
||||
|
||||
@@ -146,6 +149,9 @@ def decode_payload(kind: str, descriptor: dict, payload: bytes, *, max_bytes: in
|
||||
raise ValueError("Payload must be bytes")
|
||||
if len(payload) > limit:
|
||||
raise ValueError("Payload exceeds byte limit")
|
||||
if kind == "VALUE":
|
||||
from .value_codec import decode_value
|
||||
return decode_value(descriptor, payload, max_bytes=limit)
|
||||
if kind == "IMAGE" and descriptor.get("encoding") == "image-transfer-v1":
|
||||
# Lazy and IMAGE-only: exact tensors and other kinds stay unchanged.
|
||||
from .image_codec import decode_image
|
||||
|
||||
@@ -19,6 +19,9 @@ MAX_ARTIFACTS = 128
|
||||
MAX_ITEMS = 64
|
||||
MAX_DESCRIPTOR_BYTES = 128 * 1024
|
||||
SOCKET_KINDS = {"images": "IMAGE", "audio": "AUDIO", "masks": "MASK", "texts": "STRING", "video": "VIDEO"}
|
||||
# Internal End slots do not extend the public Collector or legacy media sockets.
|
||||
VALUE_SOCKETS = tuple(f"value_{i}" for i in range(1, 11))
|
||||
ARTIFACT_KINDS = frozenset(SOCKET_KINDS.values()) | {"VALUE"}
|
||||
BINDING_FIELDS = {"binding_id", "collector_id", "job_id", "participant_id", "node_id", "socket", "kind"}
|
||||
_ID = re.compile(r"[A-Za-z0-9_.:/-]{1,200}\Z")
|
||||
_HASH = re.compile(r"[a-f0-9]{64}\Z")
|
||||
@@ -68,7 +71,8 @@ def validate_binding(binding):
|
||||
raise ValueError("Invalid media binding")
|
||||
for field in BINDING_FIELDS - {"socket", "kind"}:
|
||||
identifier(binding[field])
|
||||
if SOCKET_KINDS.get(binding["socket"]) != binding["kind"]:
|
||||
kind = "VALUE" if binding["socket"] in VALUE_SOCKETS else SOCKET_KINDS.get(binding["socket"])
|
||||
if kind is None or kind != binding["kind"]:
|
||||
raise ValueError("Media socket/type mismatch")
|
||||
return binding
|
||||
|
||||
@@ -113,7 +117,7 @@ def validate_artifact(artifact, *, max_bytes=MAX_ARTIFACT_BYTES):
|
||||
if not isinstance(artifact, dict) or set(artifact) != fields:
|
||||
raise ValueError("Invalid media artifact")
|
||||
identifier(artifact["id"])
|
||||
if artifact["kind"] not in SOCKET_KINDS.values():
|
||||
if artifact["kind"] not in ARTIFACT_KINDS:
|
||||
raise ValueError("Unsupported media kind")
|
||||
if not isinstance(artifact["descriptor"], dict):
|
||||
raise ValueError("Invalid media descriptor")
|
||||
@@ -191,7 +195,8 @@ def validate_response(response, *, request_id, bindings, max_total_bytes=MAX_TOT
|
||||
if bid in counts or expected.get(bid) != binding:
|
||||
raise ValueError("Media binding identity mismatch")
|
||||
# Connected outputs must produce at least one value. Empty STRING is a value.
|
||||
counts[bid] = bounded_int(row["count"], MAX_ITEMS, minimum=1)
|
||||
maximum = 1 if binding["socket"] in VALUE_SOCKETS else MAX_ITEMS
|
||||
counts[bid] = bounded_int(row["count"], maximum, minimum=1)
|
||||
artifacts = response["artifacts"]
|
||||
if not isinstance(artifacts, list) or not 1 <= len(artifacts) <= MAX_ARTIFACTS:
|
||||
raise ValueError("Invalid media artifacts count")
|
||||
|
||||
@@ -40,7 +40,7 @@ DRAIN_GRACE_SECONDS = 10.0
|
||||
HTTP_TIMEOUT_SECONDS = 15.0
|
||||
_UNCERTAIN_WORK = set()
|
||||
GET_ATTEMPTS = 3
|
||||
_IMPORT_SLOTS = {"IMAGE": 0, "AUDIO": 1, "MASK": 2, "STRING": 3, "VIDEO": 4}
|
||||
_IMPORT_SLOTS = {"IMAGE": 0, "AUDIO": 1, "MASK": 2, "STRING": 3, "VIDEO": 4, "VALUE": 5}
|
||||
# Used ONLY to run the shared manifest validator before /view. Never fetched.
|
||||
_VALIDATION_TRANSPORT = {"type": "url", "url": "https://media-validation.invalid/artifact",
|
||||
"expires_at": "9999-01-01T00:00:00Z"}
|
||||
@@ -194,8 +194,12 @@ def _validate_request(input_data):
|
||||
or aid not in indexed or (node_id, name) in destinations):
|
||||
raise ValueError("Unknown or duplicate media input reference")
|
||||
binding = exporters.get(node_id)
|
||||
if binding is not None and (name != binding["socket"] or indexed[aid]["kind"] != binding["kind"]):
|
||||
raise ValueError("Exporter input must match its declared media socket and kind")
|
||||
if binding is not None:
|
||||
# VALUE exports accept the six explicit runtime types. IMAGE/MASK
|
||||
# imports can feed both native consumers and a sparse override.
|
||||
allowed = {"VALUE", "IMAGE", "MASK", "STRING"} if binding["kind"] == "VALUE" else {binding["kind"]}
|
||||
if name != binding["socket"] or indexed[aid]["kind"] not in allowed:
|
||||
raise ValueError("Exporter input must match its declared media socket and kind")
|
||||
destinations.add((node_id, name))
|
||||
used.add(aid)
|
||||
if used != set(indexed):
|
||||
|
||||
@@ -11,7 +11,7 @@ from .codec import decode_payload, encode_payload
|
||||
from .image_transfer import (FORMATS, DEFAULT_FORMAT, DEFAULT_QUALITY,
|
||||
FORMAT_TOOLTIP, QUALITY_TOOLTIP, validate_options)
|
||||
from .contract import (
|
||||
MAX_ARTIFACT_BYTES, MAX_ITEMS, MAX_TOTAL_BYTES, SOCKET_KINDS,
|
||||
MAX_ARTIFACT_BYTES, MAX_ITEMS, MAX_TOTAL_BYTES, SOCKET_KINDS, VALUE_SOCKETS, ARTIFACT_KINDS,
|
||||
bounded_int, identifier, validate_binding, validate_json,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,9 @@ def _json(text):
|
||||
def _cpu(kind, value):
|
||||
# Runtime transfer only, never sampling or device discovery. Pure codecs
|
||||
# deliberately reject non-CPU storage rather than making hidden transfers.
|
||||
if kind == "VALUE":
|
||||
from .value_codec import value_kind
|
||||
kind = value_kind(value)
|
||||
if kind in ("IMAGE", "MASK"):
|
||||
return value.detach().cpu()
|
||||
if kind == "AUDIO":
|
||||
@@ -72,6 +75,8 @@ def export_values(request_id, binding, values, *, transfer_format=DEFAULT_FORMAT
|
||||
raise ValueError("Image transfer controls apply only to IMAGE exports")
|
||||
if not isinstance(values, list) or not 1 <= len(values) <= MAX_ITEMS:
|
||||
raise ValueError("Connected media output has no values or exceeds item limit")
|
||||
if binding["socket"] in VALUE_SOCKETS and len(values) != 1:
|
||||
raise ValueError("End override requires exactly one value")
|
||||
request_key = hashlib.sha256(request_id.encode()).hexdigest()
|
||||
subfolder = f"distributed-media-v1/{request_key}"
|
||||
root = Path(folder_paths.get_temp_directory()).resolve()
|
||||
@@ -114,7 +119,7 @@ def import_value(artifact, filename):
|
||||
if not isinstance(artifact, dict) or set(artifact) != fields:
|
||||
raise ValueError("Invalid staged media metadata")
|
||||
identifier(artifact["id"])
|
||||
if artifact["kind"] not in SOCKET_KINDS.values():
|
||||
if artifact["kind"] not in ARTIFACT_KINDS:
|
||||
raise ValueError("Invalid staged media kind")
|
||||
size = bounded_int(artifact["byte_length"], MAX_ARTIFACT_BYTES)
|
||||
if not isinstance(filename, str):
|
||||
@@ -138,13 +143,14 @@ class MediaImportV1(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(node_id="MediaImportV1", category="distributed/internal", is_deprecated=True,
|
||||
inputs=[io.String.Input("artifact_json"), io.String.Input("filename")],
|
||||
outputs=[io.Image.Output(), io.Audio.Output(), io.Mask.Output(), io.String.Output(), io.Video.Output()])
|
||||
outputs=[io.Image.Output(), io.Audio.Output(), io.Mask.Output(), io.String.Output(), io.Video.Output(),
|
||||
io.AnyType.Output()])
|
||||
|
||||
@classmethod
|
||||
def execute(cls, artifact_json, filename):
|
||||
artifact = _json(artifact_json)
|
||||
value = import_value(artifact, filename)
|
||||
return io.NodeOutput(*(value if kind == artifact["kind"] else None for kind in SOCKET_KINDS.values()))
|
||||
return io.NodeOutput(*(value if kind == artifact["kind"] else None for kind in (*SOCKET_KINDS.values(), "VALUE")))
|
||||
|
||||
|
||||
class MediaExportV1(io.ComfyNode):
|
||||
@@ -155,6 +161,7 @@ class MediaExportV1(io.ComfyNode):
|
||||
io.Image.Input("images", optional=True), io.Audio.Input("audio", optional=True),
|
||||
io.Mask.Input("masks", optional=True), io.String.Input("texts", optional=True, force_input=True),
|
||||
io.Video.Input("video", optional=True),
|
||||
*[io.AnyType.Input(name, optional=True) for name in VALUE_SOCKETS],
|
||||
io.Combo.Input("transfer_format", options=list(FORMATS), default=DEFAULT_FORMAT,
|
||||
optional=True, tooltip=FORMAT_TOOLTIP),
|
||||
io.Int.Input("transfer_quality", default=DEFAULT_QUALITY, min=1, max=100,
|
||||
@@ -163,10 +170,12 @@ class MediaExportV1(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, request_id, binding_json, images=None, audio=None, masks=None, texts=None, video=None,
|
||||
transfer_format=DEFAULT_FORMAT, transfer_quality=DEFAULT_QUALITY):
|
||||
transfer_format=DEFAULT_FORMAT, transfer_quality=DEFAULT_QUALITY, **overrides):
|
||||
binding = _json(_scalar(binding_json))
|
||||
validate_binding(binding)
|
||||
provided = dict(images=images, audio=audio, masks=masks, texts=texts, video=video)
|
||||
if set(overrides) - set(VALUE_SOCKETS):
|
||||
raise ValueError("Unknown End value slot")
|
||||
provided = dict(images=images, audio=audio, masks=masks, texts=texts, video=video, **overrides)
|
||||
if {name for name, value in provided.items() if value is not None} != {binding["socket"]}:
|
||||
raise ValueError("Media export must receive exactly its declared typed socket")
|
||||
exported = export_values(_scalar(request_id), binding, provided[binding["socket"]],
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Exact six-type values for internal sparse End exports; no pack imports.
|
||||
|
||||
Tensor/text payloads reuse the existing media codec. Scalars have distinct
|
||||
encodings: BOOLEAN is one byte, INT is minimal signed little-endian, FLOAT is
|
||||
IEEE binary64. No scalar passes through float() or a lossy JSON number parser.
|
||||
"""
|
||||
import math
|
||||
import struct
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
KINDS = frozenset(("IMAGE", "MASK", "STRING", "BOOLEAN", "INT", "FLOAT"))
|
||||
_SCALAR_ENCODINGS = {"BOOLEAN": "bool-v1", "INT": "int-le-v1", "FLOAT": "float64-le-v1"}
|
||||
|
||||
|
||||
def value_kind(value):
|
||||
if isinstance(value, torch.Tensor):
|
||||
if value.ndim == 4 and value.shape[-1] in (3, 4):
|
||||
return "IMAGE"
|
||||
if value.ndim == 3:
|
||||
return "MASK"
|
||||
raise ValueError("VALUE tensor requires IMAGE [B,H,W,3|4] or MASK [B,H,W]")
|
||||
kind = {bool: "BOOLEAN", int: "INT", float: "FLOAT", str: "STRING"}.get(type(value))
|
||||
if kind is None:
|
||||
raise ValueError("VALUE requires IMAGE, MASK, STRING, INT, FLOAT or BOOLEAN")
|
||||
if kind == "FLOAT" and not math.isfinite(value):
|
||||
raise ValueError("VALUE numbers must be finite")
|
||||
return kind
|
||||
|
||||
|
||||
def encode_value(value, *, max_bytes):
|
||||
from .codec import encode_payload
|
||||
kind = value_kind(value)
|
||||
if kind in ("IMAGE", "MASK", "STRING"):
|
||||
descriptor, payload = encode_payload(kind, value, max_bytes=max_bytes)
|
||||
else:
|
||||
descriptor = {"encoding": _SCALAR_ENCODINGS[kind]}
|
||||
size = ((value if value >= 0 else ~value).bit_length() + 8) // 8 if kind == "INT" else (8 if kind == "FLOAT" else 1)
|
||||
if size > max_bytes:
|
||||
raise ValueError("VALUE payload exceeds byte limit")
|
||||
if kind == "BOOLEAN":
|
||||
payload = b"\x01" if value else b"\x00"
|
||||
elif kind == "INT":
|
||||
payload = value.to_bytes(size, "little", signed=True)
|
||||
else:
|
||||
payload = struct.pack("<d", value)
|
||||
return {"encoding": "value-v1", "type": kind, "descriptor": descriptor}, payload
|
||||
|
||||
|
||||
def decode_value(descriptor, payload, *, max_bytes):
|
||||
from .codec import decode_payload
|
||||
if (set(descriptor) != {"encoding", "type", "descriptor"}
|
||||
or descriptor["encoding"] != "value-v1"
|
||||
or type(descriptor["type"]) is not str or descriptor["type"] not in KINDS):
|
||||
raise ValueError("Invalid VALUE descriptor")
|
||||
kind, inner = descriptor["type"], descriptor["descriptor"]
|
||||
if kind in ("IMAGE", "MASK", "STRING"):
|
||||
# Generic values always stay exact even if End.images is compressed.
|
||||
if not isinstance(inner, dict) or inner.get("encoding") != (
|
||||
"utf8-v1" if kind == "STRING" else "tensor-le-v1"):
|
||||
raise ValueError("VALUE requires exact typed payloads")
|
||||
value = decode_payload(kind, inner, payload, max_bytes=max_bytes)
|
||||
if value_kind(value) != kind:
|
||||
raise ValueError("VALUE tensor/type mismatch")
|
||||
return value
|
||||
if inner != {"encoding": _SCALAR_ENCODINGS[kind]}:
|
||||
raise ValueError("Invalid VALUE scalar encoding")
|
||||
if kind == "BOOLEAN":
|
||||
if payload not in (b"\x00", b"\x01"):
|
||||
raise ValueError("Invalid BOOLEAN payload")
|
||||
return payload == b"\x01"
|
||||
if kind == "FLOAT":
|
||||
if len(payload) != 8:
|
||||
raise ValueError("Invalid FLOAT payload length")
|
||||
value = struct.unpack("<d", payload)[0]
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("VALUE numbers must be finite")
|
||||
return value
|
||||
if not payload:
|
||||
raise ValueError("Invalid INT payload length")
|
||||
value = int.from_bytes(payload, "little", signed=True)
|
||||
if ((value if value >= 0 else ~value).bit_length() + 8) // 8 != len(payload):
|
||||
raise ValueError("Invalid INT payload length")
|
||||
return value
|
||||
Reference in New Issue
Block a user