283 lines
15 KiB
Markdown
283 lines
15 KiB
Markdown
# Standalone `comfy-media-v1` endpoint bridge
|
||
|
||
This directory is independently installable. It registers two internal Comfy V3
|
||
nodes, `MediaImportV1` and `MediaExportV1`, and provides the asynchronous HTTP
|
||
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.
|
||
|
||
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
|
||
|
||
1. Copy this complete directory to `<ComfyUI>/custom_nodes/serverless_bridge`.
|
||
Use a compatible ComfyUI with `comfy_api.v0_0_2` and the native VIDEO classes
|
||
used by [video_codec.py](video_codec.py). These exact-class/private-state
|
||
adapters are tied to the supported core implementation, not arbitrary VIDEO
|
||
plugins or future core layouts.
|
||
2. Restart ComfyUI so `comfy_entrypoint()` registers the two nodes. Keep the
|
||
workflow's own models and custom nodes installed separately.
|
||
3. Make `<ComfyUI>/custom_nodes` importable by the handler process (for example,
|
||
via its `PYTHONPATH`). The handler uses `aiohttp`; the nodes/codecs use
|
||
ComfyUI's PyTorch/native media dependencies. The handler module itself does
|
||
not import ComfyUI, PyTorch, Runpod, or Distributed bootstrap code.
|
||
4. Give the handler filesystem access to **the same input directory** used by
|
||
that ComfyUI instance, plus its trusted HTTP URL. These are operator settings,
|
||
never request-controlled paths/URLs. Staging/cleanup requires POSIX directory
|
||
descriptor and no-follow file operations.
|
||
|
||
Installing the nodes alone is insufficient: the endpoint must route media-v1
|
||
inputs through this executor and implement the uncertainty policy below. A
|
||
legacy image handler cannot interpret the typed envelope.
|
||
|
||
## Callable integration
|
||
|
||
```python
|
||
from serverless_bridge.handler import execute_media_request
|
||
|
||
|
||
async def execute_endpoint_input(
|
||
input_data, *, comfy_url, input_dir, temp_dir=None,
|
||
download=None, publish=None,
|
||
):
|
||
# Invoke only under a supervisor implementing the uncertainty policy below.
|
||
# Exceptions intentionally propagate; this is NOT a provider worker loop.
|
||
return await execute_media_request(
|
||
input_data,
|
||
comfy_url=comfy_url,
|
||
input_dir=input_dir,
|
||
timeout_seconds=1800,
|
||
session=None,
|
||
download=download,
|
||
publish=publish,
|
||
temp_dir=temp_dir,
|
||
)
|
||
```
|
||
|
||
Pass the provider job's **`input` object**, not the whole job. Return the resulting
|
||
`{"media": ...}` object as provider output; Runpod exposes it as `output.media`.
|
||
Do not add another `output` wrapper inside it. `timeout_seconds` must be finite
|
||
and positive. If supplied, `session` is a caller-owned `aiohttp.ClientSession`
|
||
for local Comfy HTTP and remains open; otherwise the executor creates/closes its
|
||
own session. Input dictionaries are copied before graph rewriting.
|
||
|
||
**This callable is not a turnkey Runpod handler.** An outer SDK that catches every
|
||
exception and immediately reuses the worker is unsafe. The supervisor must
|
||
recognize `serverless_bridge.handler.MediaExecutionUncertain`, stop intake, and
|
||
quarantine/reconcile or terminate the owning environment before reuse. Merely
|
||
returning `{"error": ...}` or marking a provider job `FAILED` is not that policy.
|
||
|
||
## Request and response
|
||
|
||
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
|
||
import uuid
|
||
|
||
|
||
def make_text_request(text="hello"):
|
||
request_id = uuid.uuid4().hex
|
||
binding = {
|
||
"binding_id": "text-binding",
|
||
"collector_id": "collector-1",
|
||
"job_id": "collector-job-1",
|
||
"participant_id": "serverless:example:0",
|
||
"node_id": "export-1",
|
||
"socket": "texts",
|
||
"kind": "STRING",
|
||
}
|
||
return {
|
||
"workflow": {
|
||
"text-1": {"class_type": "PrimitiveString", "inputs": {"value": text}},
|
||
"export-1": {
|
||
"class_type": "MediaExportV1",
|
||
"inputs": {
|
||
"request_id": request_id,
|
||
"binding_json": json.dumps(binding),
|
||
"texts": ["text-1", 0],
|
||
},
|
||
},
|
||
},
|
||
"images": [],
|
||
"media": {
|
||
"version": 1,
|
||
"profile": "comfy-media-v1",
|
||
"request_id": request_id,
|
||
"artifacts": [],
|
||
"inputs": [],
|
||
"outputs": [binding],
|
||
},
|
||
}
|
||
```
|
||
|
||
`workflow` is an API graph, not an editor workflow. Top-level input fields are
|
||
exactly `workflow`, `media`, and optional `images`. `media` has exactly the six
|
||
fields shown above. `media.outputs` declares one exporter per connected socket;
|
||
its binding must match that node's `binding_json` and request ID exactly. Socket
|
||
names/types are `images/IMAGE`, `audio/AUDIO`, `masks/MASK`, `texts/STRING`, and
|
||
`video/VIDEO`. Use unique request IDs. Identifiers are 1–200 characters from
|
||
`[A-Za-z0-9_.:/-]`; request scopes additionally reject empty, `.` or `..` slash
|
||
components. The executor validates the graph and submits **once**, with exactly
|
||
the declared exporter IDs as `partial_execution_targets`. Unrelated graph roots,
|
||
cycles, undeclared exporters, or caller-supplied `MediaImportV1` nodes fail.
|
||
|
||
For typed inputs, `media.artifacts` contains records with exactly:
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| `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 |
|
||
|
||
Each `media.inputs` entry is
|
||
`{"node_id": "consumer", "input_name": "socket", "artifact_id": "artifact-id"}`.
|
||
It replaces an existing consumer input with a generated import link; every
|
||
artifact must be used, and destinations cannot repeat. The executor verifies
|
||
bytes/checksums, stages owned files, inserts imports, then prunes disconnected
|
||
loader ancestors. Optional legacy `images` entries are `{ "name": "input.png",
|
||
"image": "<base64>" }`, must match stock `LoadImage` references, and are rewritten
|
||
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 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.
|
||
|
||
All identity, completeness, type, size, and checksum checks must pass; do not
|
||
consume a partial result. The internal history transport `comfy-file` is local
|
||
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. 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.
|
||
|
||
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 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
|
||
PyAV codec must be available; missing encoders fail without automatic fallback.
|
||
Compressed IMAGE artifacts use `image-transfer-v1`; decode through the shared
|
||
`codec.decode_payload`, which enforces payload and decoded-shape limits.
|
||
See [image transfer choices](../docs/serverless-media.md#image-return-format-and-quality).
|
||
|
||
## Transport budgets and ownership
|
||
|
||
- Contract maxima: 256 MiB per artifact, 512 MiB aggregate artifact bytes,
|
||
128 artifacts, 64 items per binding. Request input totals include decoded
|
||
legacy-image bytes. Decoders validate tensor/VIDEO sizes and layouts before
|
||
allocating native storage; these are payload limits, **not peak-RSS guarantees**.
|
||
- The built-in master currently emits inline inputs only. Its complete Runpod
|
||
request JSON is capped at **10,000,000 bytes**, including base64, graph and
|
||
policy. The larger contract limits do not bypass this cap; there is no
|
||
built-in large-input/object-storage uploader.
|
||
- The executor's result JSON is capped at **10,000,000 bytes**. Inline results
|
||
are the default. When needed, `publish` is called for selected larger
|
||
artifacts to fit that budget; supplying it does not force all results to URLs.
|
||
Without a publisher, an oversized response fails instead of being truncated.
|
||
- Optional **async** `download(url, *, max_bytes)` returns `bytes`. The executor
|
||
supplies the declared artifact length and checks exact length and SHA-256.
|
||
The callback must enforce public HTTPS, DNS/IP restrictions, redirect checks,
|
||
and bounded streaming **before allocation**; syntactic contract validation
|
||
does not provide SSRF protection. No downloader is installed by default.
|
||
- Optional **async** `publish(metadata, payload)` uploads the exact `bytes` and
|
||
returns exactly `{"type": "url", "url": "https://...", "expires_at": "..."}`.
|
||
`metadata` is the artifact record without `transport`. Expiry must be a future,
|
||
timezone-aware ISO timestamp; URLs use HTTPS port 443, no userinfo/fragment,
|
||
and at most 8192 characters. Keep objects available long enough for collection.
|
||
The callback/operator owns upload credentials, object expiry and cleanup,
|
||
including orphan uploads after failure. There is no bridge storage backend.
|
||
- The master uses a separate bounded public-HTTPS downloader without provider
|
||
credentials. Never send provider credentials to artifact hosts or log signed
|
||
URLs. Endpoint callbacks must enforce equivalent protections themselves.
|
||
|
||
Callbacks must cooperate with async cancellation, finish/abort their own I/O,
|
||
and not detach readers/uploads that outlive the callback. Synchronous code that
|
||
blocks the event loop cannot be hard-timed-out by this executor.
|
||
|
||
## Cancellation, quarantine, and file retention
|
||
|
||
`timeout_seconds` covers staging, submission, polling, downloads and publication.
|
||
On failure/cancellation, there is a separate **10-second callback-drain grace**
|
||
and, when needed, a **10-second prompt-reconciliation grace**. These are recovery
|
||
allowances, not evidence that execution stopped. Ambiguous `/prompt` submission
|
||
is never retried. An identified pending prompt may be deleted from `/queue`,
|
||
with exact read-back; running prompts are never sent `/interrupt`. The pinned
|
||
core route has a snapshot/global-interrupt race even when given a prompt ID.
|
||
|
||
`MediaExecutionUncertain` means prompt acceptance/termination **or callback
|
||
teardown** is unconfirmed. Even successful terminal Comfy history is insufficient
|
||
if an async downloader/publisher ignores cancellation. The bridge retains input
|
||
and exporter files in that case; late callback exit does not automatically
|
||
remove retained files. Before accepting another job on that environment:
|
||
|
||
1. Quarantine it and stop new intake; do not automatically resubmit the request.
|
||
2. Reconcile the local prompt and all readers, or terminate the owning execution
|
||
environment when their termination cannot be established.
|
||
3. Only then clean retained files and allow reuse/replacement. A provider terminal
|
||
job status alone cannot certify a surviving local Comfy process or callback.
|
||
|
||
Staged inputs use random exclusive files under
|
||
`input_dir/distributed-media-v1/<sha256(request_id)>/`. Cleanup removes only files
|
||
created by that invocation, after confirmed terminal state (or before submission)
|
||
and reader teardown; it never recursively deletes a shared namespace.
|
||
|
||
`temp_dir=None` leaves exporter retention to the Comfy temp-directory/environment
|
||
lifecycle. Optionally pass the **trusted local Comfy temp root** to enable
|
||
best-effort cleanup of validated, invocation-owned exporter UUID `.bin` files
|
||
under the corresponding hashed namespace, only after terminal history and
|
||
collection/publisher teardown. Symlinked, hard-linked, replaced, missing, or
|
||
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.
|
||
|
||
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.
|