feat: integrate native image-list caching and exact serverless value overrides

This commit is contained in:
Robert Wojciechowski
2026-10-14 04:08:48 +00:00
55 changed files with 4414 additions and 1112 deletions
+6 -1
View File
@@ -189,7 +189,7 @@ async def dispatch_worker_prompt(
async def preflight_image_list_workers(workers, require_idle=True, timeout=3.0, *,
required_transfer_formats=()):
required_transfer_formats=(), require_value_results=False):
"""Require protocol/node support and report which direct workers are idle.
This performs read-only GETs only; it never submits a prompt or contacts a
@@ -261,6 +261,11 @@ async def preflight_image_list_workers(workers, require_idle=True, timeout=3.0,
f"{sorted(required_formats)}; upgrade that worker before compressed IMAGE returns; "
"no work was submitted"
)
if require_value_results and capability.get("value_results") is not True:
raise RuntimeError(
f"Image-list worker {worker_id} lacks explicit value_results support; "
"upgrade that worker before using End overrides; no work was submitted"
)
check = "queue"
queue_url = build_worker_url(worker, "/queue")
+2 -22
View File
@@ -401,20 +401,6 @@ def prune_prompt_for_worker(prompt_obj, use_worker_sink=False):
def prepare_delegate_master_prompt(prompt_obj, collector_ids):
"""Prune master prompt so it only executes post-collector nodes in delegate mode."""
# Typed collectors need no fabricated IMAGE placeholder. Sever all media
# links, including STRING scalar branches, before retained-config analysis.
# Their expected_media control was frozen by participant overrides.
if any((prompt_obj.get(cid) or {}).get("class_type") == "DistributedMediaCollector"
for cid in collector_ids):
from ...serverless_bridge.contract import SOCKET_KINDS
prompt_obj = json.loads(json.dumps(prompt_obj))
for cid in collector_ids:
node = prompt_obj.get(cid) or {}
if node.get("class_type") == "DistributedMediaCollector":
inputs = node.setdefault("inputs", {})
inputs.setdefault("expected_media", json.dumps([name for name in SOCKET_KINDS if name in inputs]))
for name in SOCKET_KINDS:
inputs.pop(name, None)
downstream = _find_downstream_nodes(prompt_obj, collector_ids)
nodes_to_keep = set(collector_ids)
nodes_to_keep.update(downstream)
@@ -519,7 +505,6 @@ def generate_job_id_map(prompt_index, prefix):
job_map = {}
distributed_nodes = (
prompt_index.nodes_for_class("DistributedCollector")
+ prompt_index.nodes_for_class("DistributedMediaCollector")
+ prompt_index.nodes_for_class("UltimateSDUpscaleDistributed")
+ prompt_index.nodes_for_class("DistributedImageListStart")
)
@@ -552,10 +537,8 @@ def _override_collector_nodes(
enabled_json,
delegate_master,
):
"""Configure legacy and typed collectors without changing their media."""
collector_ids = (prompt_index.nodes_for_class("DistributedCollector")
+ prompt_index.nodes_for_class("DistributedMediaCollector"))
for node_id in collector_ids:
"""Configure DistributedCollector nodes for master or worker role."""
for node_id in prompt_index.nodes_for_class("DistributedCollector"):
node = prompt_copy.get(node_id)
if not isinstance(node, dict):
continue
@@ -568,9 +551,6 @@ def _override_collector_nodes(
inputs["multi_job_id"] = job_id_map.get(node_id, node_id)
inputs["is_worker"] = not is_master
inputs["enabled_worker_ids"] = enabled_json
if node.get("class_type") == "DistributedMediaCollector":
from ...serverless_bridge.contract import SOCKET_KINDS
inputs["expected_media"] = json.dumps([socket for socket in SOCKET_KINDS if socket in inputs])
if is_master:
inputs["delegate_only"] = bool(delegate_master)
inputs.pop("master_url", None)
+101 -61
View File
@@ -1,8 +1,9 @@
"""Pure compilation of prepared typed-media collectors for serverless workers.
"""Pure compilation of prepared Distributed Collectors for comfy-media-v1 pools.
The legacy image compiler remains separate. This compiler emits one MediaExportV1
execution target per connected socket, retaining only its upstream computation.
It never imports ComfyUI or inspects/executes runtime node classes.
execution target per connected Collector socket (``images`` and/or ``audio``),
retaining only its upstream computation. It never imports ComfyUI or
inspects/executes runtime node classes.
"""
from __future__ import annotations
@@ -12,8 +13,8 @@ import hashlib
import json
from graphlib import CycleError, TopologicalSorter
from ...serverless_bridge.contract import SOCKET_KINDS, identifier, validate_bindings
from ...serverless_bridge.image_transfer import DEFAULT_FORMAT, DEFAULT_QUALITY, validate_options
from ...serverless_bridge.contract import SOCKET_KINDS, VALUE_SOCKETS, identifier, validate_bindings
from ...serverless_bridge.image_transfer import DEFAULT_FORMAT, validate_options
from .serverless_prompt import (
_PRIMITIVE_NODES,
_REMOVED_SCALAR_NODES,
@@ -24,7 +25,24 @@ from .serverless_prompt import (
)
_COLLECTOR = "DistributedMediaCollector"
_COLLECTOR = "DistributedCollector"
# Collector inputs, in output-slot order, that a typed pool returns.
COLLECTOR_SOCKETS = ("images", "audio")
# Collector widget defaults (the saved-workflow contract is legacy_png / 90).
COLLECTOR_DEFAULT_FORMAT = "legacy_png"
COLLECTOR_DEFAULT_QUALITY = 90
def collector_image_policy(inputs):
"""Return the endpoint IMAGE (format, quality) for one Collector.
legacy_png is the Collector's saved default and names a JSON/base64 framing
that only applies to direct workers; serverless returns the same 8-bit
pixels as compressed png. exact stays opt-in. No other format is altered.
"""
fmt, quality = validate_options(inputs.get("transfer_format", COLLECTOR_DEFAULT_FORMAT),
inputs.get("transfer_quality", COLLECTOR_DEFAULT_QUALITY))
return ("png" if fmt == "legacy_png" else fmt), quality
def _validate_identifier(value, context):
@@ -36,7 +54,7 @@ def _validate_identifier(value, context):
raise ValueError(f"{context} must be a valid nonempty media identifier") from None
def _validate_prompt(prepared_prompt):
def _validate_prompt(prepared_prompt, *, require_collectors=True):
"""Validate the entire source graph, including branches later discarded."""
if not isinstance(prepared_prompt, dict) or not prepared_prompt:
raise ValueError("prepared prompt must be a nonempty API graph mapping")
@@ -51,7 +69,7 @@ def _validate_prompt(prepared_prompt):
raise ValueError(f"prompt node {node_id!r} must have a nonempty class_type")
if class_type == _COLLECTOR:
collectors.append(node_id)
elif class_type == "DistributedCollector" or _is_unsupported_distributed(class_type):
elif _is_unsupported_distributed(class_type):
raise ValueError(f"unsupported distributed or batch node: {class_type}")
inputs = node.get("inputs", {})
if not isinstance(inputs, dict):
@@ -59,8 +77,8 @@ def _validate_prompt(prepared_prompt):
if any(not isinstance(name, str) or not name for name in inputs):
raise ValueError(f"prompt node {node_id!r} input names must be nonempty strings")
if not collectors:
raise ValueError("prepared prompt contains no DistributedMediaCollector nodes")
if require_collectors and not collectors:
raise ValueError("prepared prompt contains no DistributedCollector nodes")
dependencies = {}
for node_id, node in prepared_prompt.items():
@@ -81,18 +99,17 @@ def _validate_prompt(prepared_prompt):
if source_class in _REMOVED_SCALAR_NODES or source_class in _PRIMITIVE_NODES:
if slot != 0:
raise ValueError(f"invalid output slot {slot} for scalar node {source_id!r}")
elif source_class == _COLLECTOR and slot >= len(SOCKET_KINDS):
elif source_class == _COLLECTOR and slot >= len(COLLECTOR_SOCKETS):
raise ValueError(f"invalid output slot {slot} for collector {source_id!r}")
parents.add(source_id)
dependencies[node_id] = parents
for collector_id in collectors:
inputs = prepared_prompt[collector_id].get("inputs", {})
validate_options(inputs.get("transfer_format", DEFAULT_FORMAT),
inputs.get("transfer_quality", DEFAULT_QUALITY))
collector_image_policy(inputs)
for field in ("multi_job_id", "worker_id"):
_validate_identifier(inputs.get(field), f"collector {collector_id!r} {field}")
connected = [socket for socket in SOCKET_KINDS if socket in inputs]
connected = [socket for socket in COLLECTOR_SOCKETS if socket in inputs]
if not connected:
raise ValueError(f"collector {collector_id!r} requires at least one connected media socket")
for socket in connected:
@@ -123,61 +140,84 @@ def _validate_prompt(prepared_prompt):
return sorted(collectors)
def _build_exports(source_nodes, collector_ids, request_id):
bindings = []
exports = {}
def _build_explicit_exports(source_nodes, specs, request_id):
bindings, exports = [], {}
reserved = set(source_nodes)
for collector_id in collector_ids:
inputs = source_nodes[collector_id].get("inputs", {})
for socket, kind in SOCKET_KINDS.items():
if socket not in inputs:
continue
# Hashing avoids separator ambiguity and keeps long valid source
# identities within the bridge's identifier length bound. Identity
# is stable across request IDs and dictionary insertion orders.
identity = json.dumps([collector_id, socket], separators=(",", ":"))
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
base_id = f"serverless:media:{digest}"
node_id = base_id
suffix = 0
while node_id in reserved:
suffix += 1
node_id = f"{base_id}:{suffix}"
reserved.add(node_id)
binding = {
"binding_id": f"b:{digest}",
"collector_id": collector_id,
"job_id": inputs["multi_job_id"],
"participant_id": inputs["worker_id"],
"node_id": node_id,
"socket": socket,
"kind": kind,
}
bindings.append(binding)
exports[node_id] = {
"class_type": "MediaExportV1",
"inputs": {
"request_id": request_id,
"binding_json": json.dumps(binding),
socket: copy.deepcopy(inputs[socket]),
},
}
if kind == "IMAGE":
fmt, quality = validate_options(inputs.get("transfer_format", DEFAULT_FORMAT),
inputs.get("transfer_quality", DEFAULT_QUALITY))
if fmt != DEFAULT_FORMAT:
exports[node_id]["inputs"].update(transfer_format=fmt, transfer_quality=quality)
for spec in specs:
if not isinstance(spec, dict) or set(spec) != {
"collector_id", "job_id", "participant_id", "socket", "source",
"transfer_format", "transfer_quality"}:
raise ValueError("Invalid explicit media export specification")
collector_id, socket = spec["collector_id"], spec["socket"]
kind = "VALUE" if socket in VALUE_SOCKETS else SOCKET_KINDS.get(socket)
if kind is None:
raise ValueError("Unsupported explicit media export socket")
link = spec["source"]
if (not _is_link(link) or link[0] not in source_nodes
or type(link[1]) is not int or link[1] < 0):
raise ValueError("Explicit media export requires a valid source link")
fmt, quality = validate_options(spec["transfer_format"], spec["transfer_quality"])
if kind != "IMAGE" and fmt != DEFAULT_FORMAT:
raise ValueError("Image transfer controls apply only to IMAGE exports")
# Same stable identities as Collector compilation, including collisions.
identity = json.dumps([collector_id, socket], separators=(",", ":"))
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
base_id = f"serverless:media:{digest}"
node_id, suffix = base_id, 0
while node_id in reserved:
suffix += 1
node_id = f"{base_id}:{suffix}"
reserved.add(node_id)
binding = {"binding_id": f"b:{digest}", "collector_id": collector_id,
"job_id": spec["job_id"], "participant_id": spec["participant_id"],
"node_id": node_id, "socket": socket, "kind": kind}
bindings.append(binding)
exports[node_id] = {"class_type": "MediaExportV1", "inputs": {
"request_id": request_id, "binding_json": json.dumps(binding),
socket: copy.deepcopy(link)}}
if fmt != DEFAULT_FORMAT:
exports[node_id]["inputs"].update(transfer_format=fmt, transfer_quality=quality)
validate_bindings(bindings)
return bindings, exports
def _build_exports(source_nodes, collector_ids, request_id):
specs = []
for collector_id in collector_ids:
inputs = source_nodes[collector_id].get("inputs", {})
for socket in COLLECTOR_SOCKETS:
if socket not in inputs:
continue
fmt, quality = collector_image_policy(inputs) if socket == "images" else (DEFAULT_FORMAT, 90)
specs.append({"collector_id": collector_id, "job_id": inputs["multi_job_id"],
"participant_id": inputs["worker_id"], "socket": socket,
"source": inputs[socket], "transfer_format": fmt, "transfer_quality": quality})
return _build_explicit_exports(source_nodes, specs, request_id)
def compile_export_prompt(prepared_prompt, request_id, specs):
"""Compile explicit internal exports, without fabricating a Collector node.
Callers supply linked source sockets and literal binding identities; only
exporters and required ancestors survive. This does not add Collector
sockets: its public compiler remains images/audio only.
"""
_validate_identifier(request_id, "request_id")
if _validate_prompt(prepared_prompt, require_collectors=False):
raise ValueError("Explicit exports cannot contain Collector nodes")
bindings, exports = _build_explicit_exports(prepared_prompt, specs, request_id)
compiled = _prune_graph(prepared_prompt, bindings, exports, _make_scalar_resolver(prepared_prompt))
return {node_id: compiled[node_id] for node_id in sorted(compiled)}, bindings
def compile_media_prompt(prepared_prompt: dict, request_id: str) -> tuple[dict, list[dict]]:
"""Return a worker graph and canonical typed-media export bindings.
Each DistributedMediaCollector must have prepared literal ``multi_job_id``
and ``worker_id`` identities and at least one linked media input. Present
media inputs (including texts) must be API links; literal text belongs in a
PrimitiveString node. Nonmedia collector controls are not exported.
Each DistributedCollector must have prepared literal ``multi_job_id`` and
``worker_id`` identities and at least one linked ``images``/``audio`` input.
Present media inputs must be API links. Collector transport controls become
the IMAGE export policy (``legacy_png`` is sent as ``png``); other controls
are not exported.
Only MediaExportV1 targets and their ancestors survive. Required ordinary
output/utility nodes are preserved regardless of their runtime OUTPUT_NODE