799 lines
38 KiB
Python
799 lines
38 KiB
Python
"""Pure HTTP executor for media-v1 requests against a local ComfyUI instance.
|
|
|
|
No ComfyUI, tensor, or provider imports. ``download(url, *, max_bytes)`` must
|
|
provide public-HTTPS/redirect/DNS protection and bounded streaming. Optional
|
|
``publish(metadata, payload)`` returns a contract-valid, expiring HTTPS transport.
|
|
|
|
Only identified invocation-owned files are deleted. Optional trusted local
|
|
``temp_dir`` enables exporter cleanup after terminal history and collection.
|
|
Without it, exporter retention belongs to the ComfyUI temp-directory lifecycle.
|
|
The pinned native /interrupt route is not atomic: running-prompt cancellation
|
|
fails closed and retains files instead of risking an unrelated successor prompt.
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import stat
|
|
import uuid
|
|
from pathlib import Path
|
|
from urllib.parse import quote, urlsplit
|
|
|
|
import aiohttp
|
|
|
|
from .contract import (
|
|
BINDING_FIELDS, MAX_ARTIFACT_BYTES, MAX_ARTIFACTS, MAX_TOTAL_BYTES,
|
|
PROFILE, VERSION, artifact_from_payload, identifier, resolve_artifact,
|
|
validate_artifact, validate_bindings, validate_json, validate_response,
|
|
)
|
|
|
|
MAX_RESPONSE_JSON_BYTES = 10_000_000
|
|
MAX_HTTP_JSON_BYTES = 32 * 1024 * 1024
|
|
POLL_INTERVAL_SECONDS = 0.25
|
|
CANCEL_GRACE_SECONDS = 10.0
|
|
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, "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"}
|
|
|
|
|
|
class MediaExecutionUncertain(RuntimeError):
|
|
"""The local prompt may still execute; staged files were deliberately retained."""
|
|
|
|
|
|
class _HTTPError(RuntimeError):
|
|
def __init__(self, status):
|
|
super().__init__(f"Local Comfy HTTP request failed (status {status})")
|
|
self.status = status
|
|
|
|
|
|
def _scope_id(value):
|
|
identifier(value)
|
|
if any(part in ("", ".", "..") for part in value.split("/")):
|
|
raise ValueError("Unsafe media scope identifier")
|
|
return value
|
|
|
|
|
|
def _json_bytes(value):
|
|
return json.dumps(value, allow_nan=False).encode("utf-8")
|
|
|
|
|
|
def _link(value, workflow):
|
|
if (not isinstance(value, list) or len(value) != 2 or
|
|
not isinstance(value[0], str) or value[0] not in workflow or
|
|
type(value[1]) is not int or value[1] < 0):
|
|
raise ValueError("Invalid workflow socket link")
|
|
return value[0]
|
|
|
|
|
|
def _validate_request(input_data):
|
|
if (not isinstance(input_data, dict) or not {"workflow", "media"} <= set(input_data)
|
|
or set(input_data) - {"workflow", "media", "images"}):
|
|
raise ValueError("Invalid media request envelope")
|
|
media = input_data["media"]
|
|
if (not isinstance(media, dict) or set(media) !=
|
|
{"version", "profile", "request_id", "artifacts", "inputs", "outputs"}):
|
|
raise ValueError("Invalid media request controls")
|
|
if type(media["version"]) is not int or media["version"] != VERSION or media["profile"] != PROFILE:
|
|
raise ValueError("Unsupported media request version/profile")
|
|
_scope_id(media["request_id"])
|
|
bindings = validate_bindings(media["outputs"])
|
|
targets = []
|
|
for binding in bindings.values():
|
|
for field in BINDING_FIELDS - {"socket", "kind"}:
|
|
_scope_id(binding[field])
|
|
targets.append(binding["node_id"])
|
|
if len(set(targets)) != len(targets):
|
|
raise ValueError("Duplicate media exporter node")
|
|
|
|
workflow = input_data["workflow"]
|
|
if not isinstance(workflow, dict) or not workflow:
|
|
raise ValueError("Invalid API workflow")
|
|
validate_json(workflow, max_bytes=16 * 1024 * 1024)
|
|
dependencies = {}
|
|
for node_id, node in workflow.items():
|
|
_scope_id(node_id)
|
|
if (not isinstance(node, dict) or not isinstance(node.get("class_type"), str)
|
|
or not node["class_type"] or not isinstance(node.get("inputs"), dict)):
|
|
raise ValueError("Invalid API workflow node")
|
|
if node["class_type"] == "MediaImportV1":
|
|
raise ValueError("Request may not supply its own media import filenames")
|
|
if node["class_type"] == "MediaExportV1" and node_id not in targets:
|
|
raise ValueError("Undeclared media exporter")
|
|
dependencies[node_id] = set()
|
|
for name, value in node["inputs"].items():
|
|
_scope_id(name)
|
|
if isinstance(value, list):
|
|
dependencies[node_id].add(_link(value, workflow))
|
|
for binding in bindings.values():
|
|
node = workflow.get(binding["node_id"])
|
|
if node is None or node["class_type"] != "MediaExportV1":
|
|
raise ValueError("Missing declared MediaExportV1 node")
|
|
controls = node["inputs"]
|
|
from .image_transfer import DEFAULT_FORMAT, DEFAULT_QUALITY, validate_options
|
|
required = {"request_id", "binding_json", binding["socket"]}
|
|
optional = {"transfer_format", "transfer_quality"} if binding["kind"] == "IMAGE" else set()
|
|
if not required <= set(controls) or set(controls) - required - optional:
|
|
raise ValueError("Media exporter must have exactly its declared socket and controls")
|
|
validate_options(controls.get("transfer_format", DEFAULT_FORMAT),
|
|
controls.get("transfer_quality", DEFAULT_QUALITY))
|
|
if controls["request_id"] != media["request_id"]:
|
|
raise ValueError("Media exporter request identity mismatch")
|
|
text = controls["binding_json"]
|
|
if not isinstance(text, str) or len(text) > 128 * 1024:
|
|
raise ValueError("Invalid media exporter binding JSON")
|
|
try:
|
|
declared = json.loads(text)
|
|
except (ValueError, RecursionError):
|
|
raise ValueError("Invalid media exporter binding JSON") from None
|
|
if declared != binding:
|
|
raise ValueError("Media exporter binding identity mismatch")
|
|
_link(controls[binding["socket"]], workflow)
|
|
# The execution roots are exactly the declared exporters, not every class
|
|
# with OUTPUT_NODE=True. Required upstream output utilities may remain.
|
|
visited = set()
|
|
pending = list(targets)
|
|
while pending:
|
|
node_id = pending.pop()
|
|
if node_id not in visited:
|
|
visited.add(node_id)
|
|
pending.extend(dependencies[node_id])
|
|
if visited != set(workflow):
|
|
raise ValueError("Workflow contains nodes outside declared output roots")
|
|
if any(set(targets) & sources for sources in dependencies.values()):
|
|
raise ValueError("A media exporter cannot be an upstream dependency")
|
|
# Kahn traversal bounds cycle checking without relying on recursion depth.
|
|
counts = {node: len(sources) for node, sources in dependencies.items()}
|
|
consumers = {node: [] for node in workflow}
|
|
for node, sources in dependencies.items():
|
|
for source in sources:
|
|
consumers[source].append(node)
|
|
pending = [node for node, count in counts.items() if not count]
|
|
processed = 0
|
|
while pending:
|
|
processed += 1
|
|
for consumer in consumers[pending.pop()]:
|
|
counts[consumer] -= 1
|
|
if not counts[consumer]:
|
|
pending.append(consumer)
|
|
if processed != len(workflow):
|
|
raise ValueError("Workflow contains a dependency cycle")
|
|
|
|
artifacts = media["artifacts"]
|
|
if not isinstance(artifacts, list) or len(artifacts) > MAX_ARTIFACTS:
|
|
raise ValueError("Invalid input artifacts count")
|
|
indexed, total = {}, 0
|
|
for artifact in artifacts:
|
|
validate_artifact(artifact)
|
|
aid = _scope_id(artifact["id"])
|
|
if aid in indexed:
|
|
raise ValueError("Duplicate input artifact")
|
|
indexed[aid] = artifact
|
|
total += artifact["byte_length"]
|
|
inputs = media["inputs"]
|
|
if not isinstance(inputs, list) or len(inputs) > 4096:
|
|
raise ValueError("Invalid input references")
|
|
destinations, used = set(), set()
|
|
exporters = {binding["node_id"]: binding for binding in bindings.values()}
|
|
for ref in inputs:
|
|
if not isinstance(ref, dict) or set(ref) != {"node_id", "input_name", "artifact_id"}:
|
|
raise ValueError("Invalid media input reference")
|
|
for value in ref.values():
|
|
_scope_id(value)
|
|
node_id, name, aid = ref["node_id"], ref["input_name"], ref["artifact_id"]
|
|
if (node_id not in workflow or name not in workflow[node_id]["inputs"]
|
|
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:
|
|
# 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):
|
|
raise ValueError("Every input artifact must be used")
|
|
|
|
images = input_data.get("images", [])
|
|
if not isinstance(images, list) or len(images) > MAX_ARTIFACTS:
|
|
raise ValueError("Invalid legacy images count")
|
|
names = set()
|
|
legacy = []
|
|
for image in images:
|
|
if not isinstance(image, dict) or set(image) != {"name", "image"}:
|
|
raise ValueError("Invalid legacy image")
|
|
name, encoded = image["name"], image["image"]
|
|
if (not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_. -]{0,254}", name)
|
|
or name in names or not isinstance(encoded, str)):
|
|
raise ValueError("Invalid or duplicate legacy image basename")
|
|
names.add(name)
|
|
if len(encoded) > 4 * ((MAX_ARTIFACT_BYTES + 2) // 3):
|
|
raise ValueError("Legacy image exceeds byte limit")
|
|
# Exact decoded length, including padding, before allocating bytes.
|
|
size = (len(encoded) // 4) * 3 - (len(encoded) - len(encoded.rstrip("=")))
|
|
if len(encoded) % 4 or size < 0 or size > MAX_ARTIFACT_BYTES:
|
|
raise ValueError("Invalid legacy image base64 length")
|
|
total += size
|
|
matches = [node_id for node_id, node in workflow.items()
|
|
if node["class_type"] == "LoadImage" and node["inputs"].get("image") == name]
|
|
if not matches or any((node_id, "image") in destinations for node_id in matches):
|
|
raise ValueError("Unused or conflicting legacy image binding")
|
|
legacy.append((encoded, size, matches))
|
|
if total > MAX_TOTAL_BYTES:
|
|
raise ValueError("Media inputs exceed total byte limit")
|
|
return copy.deepcopy(workflow), copy.deepcopy(media), legacy, targets
|
|
|
|
|
|
class _Staging:
|
|
"""Exclusive random files under a hashed namespace; never delete siblings."""
|
|
def __init__(self, input_dir, request_id):
|
|
self.root = Path(input_dir).resolve()
|
|
self.key = hashlib.sha256(request_id.encode()).hexdigest()
|
|
self.relative = f"distributed-media-v1/{self.key}"
|
|
self.names = []
|
|
self.fd = None
|
|
|
|
def _open(self):
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
directory = self.root
|
|
for component in ("distributed-media-v1", self.key):
|
|
directory = directory / component
|
|
if directory.is_symlink():
|
|
raise ValueError("Unsafe media staging directory symlink")
|
|
directory.mkdir(exist_ok=True, mode=0o700)
|
|
if not directory.resolve().is_relative_to(self.root):
|
|
raise ValueError("Unsafe media staging directory")
|
|
self.fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
|
|
def write(self, payload):
|
|
if self.fd is None:
|
|
self._open()
|
|
name = uuid.uuid4().hex + ".bin"
|
|
fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=self.fd)
|
|
self.names.append(name)
|
|
with os.fdopen(fd, "wb") as stream:
|
|
stream.write(payload)
|
|
return f"{self.relative}/{name}"
|
|
|
|
def close(self, *, cleanup):
|
|
if self.fd is not None:
|
|
try:
|
|
if cleanup:
|
|
for name in self.names:
|
|
try:
|
|
os.unlink(name, dir_fd=self.fd)
|
|
except FileNotFoundError:
|
|
pass
|
|
finally:
|
|
os.close(self.fd)
|
|
self.fd = None
|
|
if cleanup:
|
|
# Never recursively remove a shared namespace or another run.
|
|
for directory in (self.root / self.relative, self.root / "distributed-media-v1"):
|
|
try:
|
|
directory.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class _OwnedExports:
|
|
"""Best-effort retirement of validated terminal-history files, never a sweep.
|
|
|
|
Walk every directory component with openat/O_NOFOLLOW, then pin the directory
|
|
fd and each regular file's identity. Missing, linked, replaced, or unidentifiable
|
|
files stay with the endpoint lifecycle. Never use history-supplied path strings.
|
|
"""
|
|
def __init__(self, temp_dir, request_id):
|
|
self.root = Path(temp_dir).absolute() if temp_dir is not None else None
|
|
self.key = hashlib.sha256(request_id.encode()).hexdigest()
|
|
self.fd = None
|
|
self.files = {}
|
|
|
|
def _open(self):
|
|
if self.fd is not None:
|
|
return True
|
|
if self.root is None or ".." in self.root.parts:
|
|
return False
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
fd = None
|
|
try:
|
|
fd = os.open(self.root.anchor, flags)
|
|
for component in (*self.root.parts[1:], "distributed-media-v1", self.key):
|
|
child = os.open(component, flags, dir_fd=fd)
|
|
os.close(fd)
|
|
fd = child
|
|
self.fd, fd = fd, None
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
|
|
def _identity(self, name):
|
|
info = os.stat(name, dir_fd=self.fd, follow_symlinks=False)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
return None
|
|
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
|
|
def identify(self, entry, media):
|
|
if self.root is None or not isinstance(entry.get("outputs"), dict):
|
|
return
|
|
# A failed prompt may contain valid completed exporters alongside missing
|
|
# or malformed ones. Claim only independently validated declared records.
|
|
for binding in media["outputs"]:
|
|
node_id = binding["node_id"]
|
|
try:
|
|
_, files = _manifest({"outputs": {node_id: entry["outputs"].get(node_id)}},
|
|
dict(media, outputs=[binding]))
|
|
except ValueError:
|
|
continue
|
|
if not files or not self._open():
|
|
continue
|
|
for artifact in files:
|
|
name = artifact["id"] + ".bin" # _manifest enforces UUID hex.
|
|
try:
|
|
identity = self._identity(name)
|
|
except OSError:
|
|
continue
|
|
if identity is not None:
|
|
self.files.setdefault(name, identity)
|
|
|
|
def close(self, *, cleanup):
|
|
if self.fd is None:
|
|
return
|
|
try:
|
|
if cleanup:
|
|
for name, identity in self.files.items():
|
|
try:
|
|
if self._identity(name) == identity:
|
|
os.unlink(name, dir_fd=self.fd)
|
|
except OSError:
|
|
pass # Endpoint lifecycle owns inaccessible leftovers.
|
|
finally:
|
|
os.close(self.fd)
|
|
self.fd = None
|
|
# Deliberately retain directories: they may belong to concurrent runs.
|
|
|
|
|
|
async def _body(response, maximum):
|
|
length = response.content_length
|
|
if length is not None and length > maximum:
|
|
raise ValueError("Local Comfy response exceeds declared byte length")
|
|
body = bytearray()
|
|
async for chunk in response.content.iter_chunked(64 * 1024):
|
|
if len(body) + len(chunk) > maximum:
|
|
raise ValueError("Local Comfy response exceeds declared byte length")
|
|
body.extend(chunk)
|
|
return bytes(body)
|
|
|
|
|
|
class _Execution:
|
|
def __init__(self, session, url, stage, media, targets, deadline, exports):
|
|
self.session, self.url, self.stage = session, url, stage
|
|
self.media, self.targets = media, targets
|
|
self.deadline = deadline
|
|
self.run_id = uuid.uuid4().hex
|
|
self.prompt_id = None
|
|
self.attempted = False
|
|
self.terminal = False
|
|
self.exports = exports
|
|
self.interruption_blocked = False
|
|
|
|
def remaining(self):
|
|
remaining = self.deadline - asyncio.get_running_loop().time()
|
|
if remaining <= 0:
|
|
raise asyncio.TimeoutError("Media execution deadline expired")
|
|
return remaining
|
|
|
|
async def json_request(self, method, path, *, body=None, permit_rejection=False):
|
|
async with self.session.request(method, self.url + path, json=body,
|
|
timeout=aiohttp.ClientTimeout(total=min(HTTP_TIMEOUT_SECONDS, self.remaining())),
|
|
allow_redirects=False) as response:
|
|
raw = await _body(response, MAX_HTTP_JSON_BYTES)
|
|
# Native queue-delete and interrupt routes return an empty 200.
|
|
if response.status == 200 and not raw and method == "POST" and path in {"/queue", "/interrupt"}:
|
|
return {}
|
|
try:
|
|
value = json.loads(raw)
|
|
except (ValueError, UnicodeError, RecursionError):
|
|
if response.status != 200:
|
|
raise _HTTPError(response.status) from None
|
|
raise ValueError("Invalid local Comfy JSON response") from None
|
|
if response.status != 200:
|
|
# Native /prompt rejects the entire graph with a 400 error.
|
|
# Other failures may hide acceptance and are never retried.
|
|
if (permit_rejection and response.status == 400 and isinstance(value, dict)
|
|
and value.get("error") and "prompt_id" not in value):
|
|
self.terminal = True
|
|
raise _HTTPError(response.status)
|
|
if not isinstance(value, dict):
|
|
raise ValueError("Invalid local Comfy JSON object")
|
|
return value
|
|
|
|
async def get_json(self, path):
|
|
for attempt in range(GET_ATTEMPTS):
|
|
try:
|
|
return await self.json_request("GET", path)
|
|
except (_HTTPError, aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
if isinstance(exc, _HTTPError) and exc.status != 429 and exc.status < 500:
|
|
raise
|
|
if attempt == GET_ATTEMPTS - 1:
|
|
raise
|
|
await asyncio.sleep(POLL_INTERVAL_SECONDS * (2 ** attempt))
|
|
|
|
async def history(self):
|
|
response = await self.get_json("/history/" + quote(self.prompt_id, safe=""))
|
|
entry = response.get(self.prompt_id)
|
|
if entry is None:
|
|
return None
|
|
if not isinstance(entry, dict) or not isinstance(entry.get("status"), dict):
|
|
raise ValueError("Malformed Comfy prompt history")
|
|
status = entry["status"]
|
|
messages = status.get("messages", [])
|
|
if not isinstance(messages, list):
|
|
raise ValueError("Malformed Comfy history messages")
|
|
failed = status.get("status_str") in {"error", "interrupted"} or any(
|
|
isinstance(message, list) and message and message[0] in {"execution_error", "execution_interrupted"}
|
|
for message in messages)
|
|
if failed or status.get("completed") is True:
|
|
self.terminal = True
|
|
self.exports.identify(entry, self.media)
|
|
if failed or status.get("status_str") != "success":
|
|
raise RuntimeError("Local Comfy prompt execution failed")
|
|
return entry
|
|
return None
|
|
|
|
async def submit_and_wait(self, workflow):
|
|
body = {"prompt": workflow, "client_id": self.run_id,
|
|
"extra_data": {"distributed_media_run_id": self.run_id},
|
|
"partial_execution_targets": list(self.targets)}
|
|
self.remaining() # CPU staging must not spend past the absolute deadline.
|
|
self.attempted = True
|
|
response = await self.json_request("POST", "/prompt", body=body, permit_rejection=True)
|
|
self.prompt_id = _scope_id(response.get("prompt_id"))
|
|
if response.get("node_errors"):
|
|
raise RuntimeError("Local Comfy accepted an incomplete set of execution targets")
|
|
while True:
|
|
entry = await self.history()
|
|
if entry is not None:
|
|
return entry
|
|
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
|
|
|
async def settle(self):
|
|
"""Bounded recovery; caller shields this from task cancellation."""
|
|
deleted = False
|
|
while True:
|
|
if self.prompt_id:
|
|
try:
|
|
await self.history()
|
|
except RuntimeError:
|
|
if not self.terminal:
|
|
raise
|
|
if self.terminal:
|
|
return
|
|
queue = await self.get_json("/queue")
|
|
groups = []
|
|
for key in ("queue_running", "queue_pending"):
|
|
rows = queue.get(key)
|
|
if not isinstance(rows, list):
|
|
raise ValueError("Malformed Comfy queue")
|
|
group = {}
|
|
for row in rows:
|
|
if not isinstance(row, list) or len(row) < 4 or not isinstance(row[3], dict):
|
|
raise ValueError("Malformed Comfy queue entry")
|
|
pid = _scope_id(row[1])
|
|
if pid in group:
|
|
raise ValueError("Duplicate Comfy queue identity")
|
|
group[pid] = row[3]
|
|
groups.append(group)
|
|
running, pending = groups
|
|
if self.prompt_id is None:
|
|
own = [pid for group in groups for pid, extra in group.items()
|
|
if extra.get("distributed_media_run_id") == self.run_id
|
|
and extra.get("client_id") == self.run_id]
|
|
if len(own) > 1:
|
|
raise ValueError("Ambiguous Comfy queue ownership")
|
|
if own:
|
|
self.prompt_id = own[0]
|
|
if self.prompt_id in pending and not deleted:
|
|
await self.json_request("POST", "/queue", body={"delete": [self.prompt_id]})
|
|
deleted = True
|
|
elif self.prompt_id in running:
|
|
# Pinned ComfyUI server.py checks a snapshot then globally
|
|
# interrupts, outside the queue mutex. Supplying prompt_id does
|
|
# NOT close that race. No safe capability negotiation exists on
|
|
# this route; do not invoke it, even after our own queue read.
|
|
self.interruption_blocked = True
|
|
raise RuntimeError("Native prompt-scoped interruption unavailable")
|
|
elif deleted and self.prompt_id not in running and self.prompt_id not in pending:
|
|
self.terminal = True # queue deletion confirmed by exact read-back
|
|
return
|
|
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
|
|
|
async def fetch(self, artifact):
|
|
transport = artifact["transport"]
|
|
params = {"filename": transport["filename"], "subfolder": transport["subfolder"], "type": "temp"}
|
|
for attempt in range(GET_ATTEMPTS):
|
|
try:
|
|
async with self.session.get(self.url + "/view", params=params, allow_redirects=False,
|
|
timeout=aiohttp.ClientTimeout(total=min(HTTP_TIMEOUT_SECONDS, self.remaining()))) as response:
|
|
if response.status != 200:
|
|
raise _HTTPError(response.status)
|
|
payload = await _body(response, artifact["byte_length"])
|
|
break
|
|
except (_HTTPError, aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
if isinstance(exc, _HTTPError) and exc.status != 429 and exc.status < 500:
|
|
raise
|
|
if attempt == GET_ATTEMPTS - 1:
|
|
raise
|
|
await asyncio.sleep(POLL_INTERVAL_SECONDS * (2 ** attempt))
|
|
if len(payload) != artifact["byte_length"]:
|
|
raise ValueError("Media output byte length mismatch")
|
|
if hashlib.sha256(payload).hexdigest() != artifact["sha256"]:
|
|
raise ValueError("Media output checksum mismatch")
|
|
return payload
|
|
|
|
|
|
def _manifest(entry, media):
|
|
outputs = entry.get("outputs")
|
|
if not isinstance(outputs, dict):
|
|
raise ValueError("Missing media history outputs")
|
|
expected_ids = {binding["node_id"] for binding in media["outputs"]}
|
|
if any(node_id not in expected_ids and isinstance(value, dict) and "media_exports" in value
|
|
for node_id, value in outputs.items()):
|
|
raise ValueError("Unexpected media export history")
|
|
response = {"version": VERSION, "profile": PROFILE, "request_id": media["request_id"],
|
|
"bindings": [], "artifacts": [], "items": []}
|
|
files = []
|
|
subfolder = "distributed-media-v1/" + hashlib.sha256(media["request_id"].encode()).hexdigest()
|
|
for binding in media["outputs"]:
|
|
node = outputs.get(binding["node_id"], {})
|
|
records = node.get("media_exports") if isinstance(node, dict) else None
|
|
if not isinstance(records, list) or len(records) != 1:
|
|
raise ValueError("Missing or duplicate media export result")
|
|
record = records[0]
|
|
if (not isinstance(record, dict) or set(record) != {"request_id", "binding", "artifacts", "items"}
|
|
or record["request_id"] != media["request_id"]):
|
|
raise ValueError("Media export request identity mismatch")
|
|
if not isinstance(record["artifacts"], list) or len(record["artifacts"]) > MAX_ARTIFACTS:
|
|
raise ValueError("Invalid exported artifacts count")
|
|
converted = []
|
|
for artifact in record["artifacts"]:
|
|
if not isinstance(artifact, dict) or not isinstance(artifact.get("transport"), dict):
|
|
raise ValueError("Invalid exported artifact")
|
|
transport = artifact["transport"]
|
|
aid = artifact.get("id")
|
|
if (not isinstance(aid, str) or not re.fullmatch(r"[a-f0-9]{32}", aid)
|
|
or transport != {"type": "comfy-file", "filename": aid + ".bin",
|
|
"subfolder": subfolder, "folder_type": "temp"}):
|
|
raise ValueError("Unsafe or unowned exported media file")
|
|
converted.append(dict(artifact, transport=dict(_VALIDATION_TRANSPORT)))
|
|
partial = dict(response, bindings=[record["binding"]], artifacts=converted, items=record["items"])
|
|
validate_response(partial, request_id=media["request_id"], bindings=[binding])
|
|
response["bindings"].append(copy.deepcopy(record["binding"]))
|
|
response["artifacts"].extend(converted)
|
|
response["items"].extend(copy.deepcopy(record["items"]))
|
|
files.extend(copy.deepcopy(record["artifacts"]))
|
|
# Validate the whole manifest BEFORE the first /view or publisher callback.
|
|
validate_response(response, request_id=media["request_id"], bindings=media["outputs"])
|
|
return response, files
|
|
|
|
|
|
def _publication_plan(response, files, publish):
|
|
skeleton = dict(response, artifacts=[dict(a, transport={"type": "inline", "data_base64": ""}) for a in files])
|
|
base_size = len(_json_bytes({"media": skeleton}))
|
|
sizes = {a["id"]: 4 * ((a["byte_length"] + 2) // 3) for a in files}
|
|
predicted = base_size + sum(sizes.values())
|
|
published = set()
|
|
if predicted > MAX_RESPONSE_JSON_BYTES and publish is None:
|
|
raise ValueError("Media response exceeds inline JSON limit; a publisher is required")
|
|
# Reserve enough space for a maximum-size escaped URL, including metadata.
|
|
# Small remaining inline values preserve their exact bytes and ordering.
|
|
for aid in sorted(sizes, key=sizes.get, reverse=True):
|
|
if predicted <= MAX_RESPONSE_JSON_BYTES:
|
|
break
|
|
if sizes[aid] > 65_536:
|
|
published.add(aid)
|
|
predicted -= sizes[aid] - 65_536
|
|
if predicted > MAX_RESPONSE_JSON_BYTES:
|
|
raise ValueError("Media response metadata exceeds JSON limit")
|
|
return published
|
|
|
|
|
|
async def _run(execution, workflow, legacy, download, publish):
|
|
media = execution.media
|
|
imports = {}
|
|
for artifact in media["artifacts"]:
|
|
execution.remaining()
|
|
payload = await resolve_artifact(artifact, download=download)
|
|
execution.remaining()
|
|
filename = execution.stage.write(payload)
|
|
node_id = "distributed-media-import:" + uuid.uuid4().hex
|
|
while node_id in workflow:
|
|
node_id = "distributed-media-import:" + uuid.uuid4().hex
|
|
metadata = {key: value for key, value in artifact.items() if key != "transport"}
|
|
workflow[node_id] = {"class_type": "MediaImportV1", "inputs": {
|
|
"artifact_json": json.dumps(metadata, allow_nan=False), "filename": filename}}
|
|
imports[artifact["id"]] = [node_id, _IMPORT_SLOTS[artifact["kind"]]]
|
|
for ref in media["inputs"]:
|
|
workflow[ref["node_id"]]["inputs"][ref["input_name"]] = list(imports[ref["artifact_id"]])
|
|
for encoded, size, matches in legacy:
|
|
execution.remaining()
|
|
try:
|
|
payload = base64.b64decode(encoded, validate=True)
|
|
except (ValueError, binascii.Error):
|
|
raise ValueError("Invalid legacy image base64") from None
|
|
if len(payload) != size:
|
|
raise ValueError("Legacy image byte length mismatch")
|
|
execution.remaining()
|
|
filename = execution.stage.write(payload)
|
|
for node_id in matches:
|
|
workflow[node_id]["inputs"]["image"] = filename
|
|
# Replacements can disconnect file loaders whose original endpoint-local
|
|
# paths are unavailable. Keep exactly the post-rewrite exporter closure;
|
|
# shared ancestors still needed by another branch must survive.
|
|
reachable, pending = set(), list(execution.targets)
|
|
while pending:
|
|
node_id = pending.pop()
|
|
if node_id in reachable:
|
|
continue
|
|
reachable.add(node_id)
|
|
pending.extend(_link(value, workflow) for value in workflow[node_id]["inputs"].values()
|
|
if isinstance(value, list))
|
|
workflow = {node_id: node for node_id, node in workflow.items() if node_id in reachable}
|
|
entry = await execution.submit_and_wait(workflow)
|
|
response, files = _manifest(entry, media)
|
|
published = _publication_plan(response, files, publish)
|
|
artifacts = []
|
|
for artifact in files:
|
|
payload = await execution.fetch(artifact)
|
|
transport = None
|
|
if artifact["id"] in published:
|
|
metadata = {key: copy.deepcopy(value) for key, value in artifact.items() if key != "transport"}
|
|
transport = await publish(metadata, payload)
|
|
if not isinstance(transport, dict) or transport.get("type") != "url":
|
|
raise ValueError("Publisher must return an expiring HTTPS URL transport")
|
|
artifacts.append(artifact_from_payload(artifact["id"], artifact["kind"], artifact["descriptor"],
|
|
payload, transport=transport))
|
|
response["artifacts"] = artifacts
|
|
validate_response(response, request_id=media["request_id"], bindings=media["outputs"])
|
|
result = {"media": response}
|
|
if len(_json_bytes(result)) > MAX_RESPONSE_JSON_BYTES:
|
|
raise ValueError("Media response exceeds JSON byte limit")
|
|
execution.remaining()
|
|
return result
|
|
|
|
|
|
def _release_uncertain_work(task):
|
|
_UNCERTAIN_WORK.discard(task)
|
|
try:
|
|
task.result()
|
|
except BaseException:
|
|
pass
|
|
|
|
|
|
async def _drain_work(work):
|
|
"""Cancel once; a misbehaving callback cannot make teardown unbounded."""
|
|
if not work.done():
|
|
work.cancel()
|
|
deadline = asyncio.get_running_loop().time() + DRAIN_GRACE_SECONDS
|
|
while not work.done():
|
|
remaining = deadline - asyncio.get_running_loop().time()
|
|
if remaining <= 0:
|
|
# Keep a strong reference until it exits, but never delete its files
|
|
# asynchronously: uncertain ownership belongs to endpoint recovery.
|
|
_UNCERTAIN_WORK.add(work)
|
|
work.add_done_callback(_release_uncertain_work)
|
|
return False
|
|
try:
|
|
# Unlike wait_for, wait's timeout does not wait for cancellation.
|
|
await asyncio.wait({work}, timeout=remaining)
|
|
except asyncio.CancelledError:
|
|
continue
|
|
_release_uncertain_work(work)
|
|
return True
|
|
|
|
|
|
async def execute_media_request(input_data, *, comfy_url, input_dir, temp_dir=None,
|
|
timeout_seconds=1800, session=None, download=None, publish=None) -> dict:
|
|
"""Validate, stage, submit once, collect exact media, and retire owned inputs.
|
|
|
|
``timeout_seconds`` covers staging, HTTP, polling, and publishing. A separate
|
|
bounded cancellation grace is allowed ONLY to establish the local prompt is
|
|
terminal. On uncertain acceptance/termination, raise MediaExecutionUncertain
|
|
and retain input files rather than racing an executing prompt. Never retry an
|
|
ambiguous submission, even when the exception is a timeout.
|
|
Async callback teardown has its own bounded grace; non-cooperative callbacks
|
|
also raise MediaExecutionUncertain and retain files, even after terminal history.
|
|
|
|
``temp_dir`` is a trusted endpoint setting for this ComfyUI's local temp root,
|
|
never a request-supplied path. Only validated exporter UUID.bin files beneath
|
|
distributed-media-v1/sha256(request_id) are retired, after all collection and
|
|
publisher work has unwound. Unidentified files remain lifecycle-owned. With
|
|
``temp_dir=None`` all exporter files remain lifecycle-owned as before.
|
|
|
|
The pinned native /interrupt route has a check/global-interrupt race. Running
|
|
prompts therefore fail closed with MediaExecutionUncertain, without posting
|
|
/interrupt. A prompt-scoped request body alone is not a safety guarantee.
|
|
"""
|
|
if (type(timeout_seconds) not in (int, float) or not math.isfinite(timeout_seconds)
|
|
or timeout_seconds <= 0):
|
|
raise ValueError("Invalid media execution timeout")
|
|
if not isinstance(comfy_url, str):
|
|
raise ValueError("Invalid local Comfy URL")
|
|
try:
|
|
parsed = urlsplit(comfy_url)
|
|
if (parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username
|
|
or parsed.password or parsed.query or parsed.fragment):
|
|
raise ValueError()
|
|
parsed.port
|
|
except ValueError:
|
|
raise ValueError("Invalid local Comfy URL") from None
|
|
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
|
workflow, media, legacy, targets = _validate_request(input_data)
|
|
stage = _Staging(input_dir, media["request_id"])
|
|
exports = _OwnedExports(temp_dir, media["request_id"])
|
|
owned_session = session is None
|
|
if owned_session:
|
|
session = aiohttp.ClientSession()
|
|
execution = _Execution(session, comfy_url.rstrip("/"), stage, media, targets, deadline, exports)
|
|
work = None
|
|
try:
|
|
remaining = execution.remaining()
|
|
work = asyncio.create_task(_run(execution, workflow, legacy, download, publish))
|
|
return await asyncio.wait_for(asyncio.shield(work), remaining)
|
|
except BaseException as exc:
|
|
# Retire files only after readers unwind. Cooperative async callbacks
|
|
# normally drain immediately; an uncooperative one requires quarantine.
|
|
if work is not None and not await _drain_work(work):
|
|
raise MediaExecutionUncertain(
|
|
"Media callback teardown unconfirmed; owned files retained; "
|
|
"quarantine this endpoint before reuse; do not resubmit automatically"
|
|
) from exc
|
|
if execution.attempted and not execution.terminal:
|
|
execution.deadline = asyncio.get_running_loop().time() + CANCEL_GRACE_SECONDS
|
|
recovery = asyncio.create_task(asyncio.wait_for(execution.settle(), CANCEL_GRACE_SECONDS))
|
|
# Shield the bounded recovery even from repeated task.cancel() calls.
|
|
while not recovery.done():
|
|
try:
|
|
await asyncio.shield(recovery)
|
|
except asyncio.CancelledError:
|
|
continue
|
|
except Exception:
|
|
break
|
|
try:
|
|
recovery.result()
|
|
except BaseException:
|
|
pass
|
|
if not execution.terminal:
|
|
raise MediaExecutionUncertain(
|
|
"Local Comfy prompt terminal state unconfirmed; owned staged files retained "
|
|
f"under {stage.relative}; prompt_id={execution.prompt_id or 'unknown'}; "
|
|
"do not resubmit this request automatically"
|
|
+ ("; native prompt-scoped interruption unavailable (non-atomic /interrupt route)"
|
|
if execution.interruption_blocked else "")
|
|
) from exc
|
|
raise
|
|
finally:
|
|
try:
|
|
try:
|
|
exports.close(cleanup=(work is None or work.done()) and execution.terminal)
|
|
finally:
|
|
stage.close(cleanup=(work is None or work.done()) and
|
|
(not execution.attempted or execution.terminal))
|
|
finally:
|
|
if owned_session:
|
|
await session.close()
|