Files

125 lines
5.5 KiB
Python

"""Async HTTP client for the ComfyUI server (official endpoints only)."""
import asyncio
import json
import secrets
import time
import uuid
from typing import Any
import httpx
from .errors import ComfyUIError
class ComfyClient:
def __init__(self, base_url: str, timeout: float = 30.0):
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
async def aclose(self) -> None:
await self._client.aclose()
async def submit_prompt(self, graph: dict[str, Any]) -> str:
"""Queue a graph; returns prompt_id. ComfyUI validates synchronously."""
payload = {"prompt": graph, "client_id": str(uuid.uuid4())}
try:
resp = await self._client.post("/prompt", json=payload)
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code == 400:
detail = _validation_detail(resp.json())
raise ComfyUIError(f"ComfyUI rejected workflow: {detail}", code="comfyui_validation_failed")
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /prompt returned HTTP {resp.status_code}")
prompt_id = resp.json().get("prompt_id")
if not prompt_id:
raise ComfyUIError("ComfyUI /prompt response missing prompt_id")
return prompt_id
async def upload_image(self, data: bytes, content_type: str = "image/png") -> str:
"""Upload an input image; returns the server-side filename."""
filename = f"adapter_{uuid.uuid4().hex[:8]}_{secrets.token_hex(4)}.png"
try:
resp = await self._client.post(
"/upload/image",
files={"image": (filename, data, content_type)},
)
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /upload/image returned HTTP {resp.status_code}")
name = resp.json().get("name")
if not name:
raise ComfyUIError("ComfyUI /upload/image response missing name")
return name
async def wait_for_result(self, prompt_id: str, timeout: float, interval: float) -> dict:
"""Poll /history/{prompt_id} until the entry appears; returns the history entry."""
deadline = time.monotonic() + timeout
while True:
try:
resp = await self._client.get(f"/history/{prompt_id}")
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /history returned HTTP {resp.status_code}")
entry = resp.json().get(prompt_id)
if entry is not None:
status = entry.get("status", {})
if status.get("status_str") == "error" or status.get("status_str") == "execution_failed":
raise ComfyUIError(f"ComfyUI execution failed for prompt {prompt_id}", code="comfyui_execution_failed")
return entry
if time.monotonic() >= deadline:
raise ComfyUIError(
f"Timed out after {timeout:.0f}s waiting for prompt {prompt_id}",
code="comfyui_timeout",
)
await asyncio.sleep(interval)
async def fetch_view(self, filename: str, subfolder: str = "") -> bytes:
"""Download a produced image from the output folder."""
try:
resp = await self._client.get(
"/view", params={"filename": filename, "subfolder": subfolder, "type": "output"}
)
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /view returned HTTP {resp.status_code} for '{filename}'")
return resp.content
async def validate_graph(self, graph: dict[str, Any]) -> None:
"""Validate a graph without queueing (POST /prompt?front=false)."""
try:
resp = await self._client.post("/prompt", params={"front": "false"}, json={"prompt": graph})
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code == 200:
return
if resp.status_code == 400:
raise ComfyUIError(
f"ComfyUI rejected workflow: {_validation_detail(resp.json())}",
code="comfyui_validation_failed",
)
raise ComfyUIError(f"ComfyUI /prompt?front=false returned HTTP {resp.status_code}")
def extract_output_image(history_entry: dict) -> tuple[str, str]:
"""(filename, subfolder) of the first output image, preferring SaveImage nodes."""
outputs = history_entry.get("outputs", {})
images: list[dict] = []
for node_output in outputs.values():
images.extend(img for img in node_output.get("images", []) if img.get("type") == "output")
if not images:
raise ComfyUIError("ComfyUI produced no output images", code="comfyui_no_output")
return images[0]["filename"], images[0].get("subfolder", "")
def _validation_detail(body: Any) -> str:
if isinstance(body, dict) and "node_errors" in body:
parts = []
for node_id, node_err in body["node_errors"].items():
for err in node_err.get("errors", []):
parts.append(f"node {node_id}: {err.get('message')} {err.get('details', '')}".strip())
return "; ".join(parts) or "validation failed"
return json.dumps(body)[:300] if body else "validation failed"