Files

372 lines
17 KiB
Python

from __future__ import annotations
import base64
import asyncio
import ipaddress
import json
import mimetypes
import os
import secrets
import socket
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, urlencode, urlsplit
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from workflow import MAX_VIDEO_DURATION_SECONDS, compile_workflow, load_registry, public_model, public_workflow
@dataclass(frozen=True)
class Provider:
id: str
name: str
base_url: str
api_key: str = ""
class JobRequest(BaseModel):
provider_id: str = "default"
workflow_key: str
prompt: str
negative_prompt: str = ""
input_images: list[str] = Field(default_factory=list, max_length=9)
width: int | None = Field(default=None, ge=64, le=8192)
height: int | None = Field(default=None, ge=64, le=8192)
duration: float | None = Field(default=None, gt=0, le=MAX_VIDEO_DURATION_SECONDS)
generate_audio: bool | None = None
seed: int | None = Field(default=None, ge=0)
batch_size: int = Field(default=1, ge=1, le=4)
denoise: float | None = Field(default=None, ge=0, le=1)
metadata: dict[str, Any] = Field(default_factory=dict)
app = FastAPI(title="Canvas ComfyUI Workflow Adapter", version="1.0.0")
provider_selection_lock = asyncio.Lock()
provider_reservations: dict[str, int] = {}
def auto_balance_enabled() -> bool:
return os.getenv("COMFY_AUTO_BALANCE", "").strip().lower() in {"1", "true", "yes", "on"}
def providers() -> dict[str, Provider]:
raw = os.getenv("COMFY_PROVIDERS_JSON", "").strip()
rows: list[dict[str, Any]] = []
if raw:
parsed = json.loads(raw)
rows = parsed if isinstance(parsed, list) else []
elif os.getenv("COMFY_URL", "").strip():
rows = [{"id": "default", "name": "ComfyUI", "base_url": os.getenv("COMFY_URL"), "api_key": os.getenv("COMFY_API_KEY", "")}]
result: dict[str, Provider] = {}
for row in rows:
provider = Provider(id=str(row.get("id") or "").strip(), name=str(row.get("name") or row.get("id") or "ComfyUI"), base_url=str(row.get("base_url") or "").strip().rstrip("/"), api_key=str(row.get("api_key") or ""))
if provider.id and provider.base_url.startswith(("http://", "https://")):
result[provider.id] = provider
return result
def require_token(authorization: str | None = Header(default=None)) -> None:
expected = os.getenv("COMFY_ADAPTER_TOKEN", "").strip()
if expected and not secrets.compare_digest(authorization or "", f"Bearer {expected}"):
raise HTTPException(status_code=401, detail="invalid adapter token")
def provider_headers(provider: Provider) -> dict[str, str]:
return {"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}
async def provider_queue_depth(provider: Provider) -> int | None:
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{provider.base_url}/queue", headers=provider_headers(provider))
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError, TypeError):
return None
return len(payload.get("queue_running") or []) + len(payload.get("queue_pending") or [])
async def select_provider(requested_provider_id: str) -> Provider | None:
configured = providers()
requested = configured.get(requested_provider_id)
if requested is None or requested_provider_id != "default" or not auto_balance_enabled() or len(configured) < 2:
return requested
candidates = list(configured.values())
depths = await asyncio.gather(*(provider_queue_depth(provider) for provider in candidates))
available = [
(depth + provider_reservations.get(provider.id, 0), 0 if provider.id == requested_provider_id else 1, provider)
for provider, depth in zip(candidates, depths)
if depth is not None
]
if not available:
return requested
return min(available, key=lambda item: (item[0], item[1]))[2]
async def reserve_provider(requested_provider_id: str) -> Provider | None:
async with provider_selection_lock:
provider = await select_provider(requested_provider_id)
if provider is not None:
provider_reservations[provider.id] = provider_reservations.get(provider.id, 0) + 1
return provider
async def release_provider(provider: Provider) -> None:
async with provider_selection_lock:
remaining = provider_reservations.get(provider.id, 0) - 1
if remaining > 0:
provider_reservations[provider.id] = remaining
else:
provider_reservations.pop(provider.id, None)
def input_host_allowlist() -> set[str]:
return {value.strip().lower().rstrip(".") for value in os.getenv("COMFY_INPUT_HOST_ALLOWLIST", "").split(",") if value.strip()}
async def validate_input_url(value: str) -> None:
parsed = urlsplit(value)
host = (parsed.hostname or "").lower().rstrip(".")
if parsed.scheme not in {"http", "https"} or not host or parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="reference image URL is invalid")
if host in input_host_allowlist():
return
try:
infos = await asyncio.to_thread(socket.getaddrinfo, host, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
except OSError as exc:
raise HTTPException(status_code=400, detail="reference image host cannot be resolved") from exc
if not infos:
raise HTTPException(status_code=400, detail="reference image host cannot be resolved")
for info in infos:
address = ipaddress.ip_address(info[4][0])
if not address.is_global:
raise HTTPException(status_code=400, detail="reference image URL cannot target a private address")
async def download_input_image(client: httpx.AsyncClient, value: str) -> tuple[bytes, str]:
await validate_input_url(value)
chunks: list[bytes] = []
total = 0
async with client.stream("GET", value, follow_redirects=False) as response:
response.raise_for_status()
if 300 <= response.status_code < 400:
raise HTTPException(status_code=400, detail="reference image redirects are not accepted")
mime_type = response.headers.get("content-type", "image/png").split(";", 1)[0]
if not mime_type.startswith("image/"):
raise HTTPException(status_code=400, detail="reference image URL did not return an image")
async for chunk in response.aiter_bytes():
total += len(chunk)
if total > 30 * 1024 * 1024:
raise HTTPException(status_code=413, detail="reference image exceeds 30 MB")
chunks.append(chunk)
return b"".join(chunks), mime_type
def encode_job(provider_id: str, prompt_id: str) -> str:
return base64.urlsafe_b64encode(f"{provider_id}:{prompt_id}".encode()).decode().rstrip("=")
def decode_job(job_id: str) -> tuple[Provider, str]:
try:
raw = base64.urlsafe_b64decode(job_id + "=" * (-len(job_id) % 4)).decode()
provider_id, prompt_id = raw.split(":", 1)
provider = providers()[provider_id]
except Exception as exc:
raise HTTPException(status_code=404, detail="job not found") from exc
return provider, prompt_id
@app.get("/health")
async def health() -> dict[str, Any]:
return {
"status": "ok",
"providers": len(providers()),
"workflows": len(load_registry()),
"autoBalance": auto_balance_enabled(),
}
@app.get("/v1/providers", dependencies=[Depends(require_token)])
async def list_providers() -> dict[str, Any]:
return {"providers": [{"id": item.id, "name": item.name, "baseUrl": item.base_url} for item in providers().values()]}
@app.get("/v1/workflows", dependencies=[Depends(require_token)])
async def list_workflows() -> dict[str, Any]:
return {"workflows": [public_workflow(item) for item in load_registry().values()]}
@app.get("/v1/models", dependencies=[Depends(require_token)])
async def list_models() -> dict[str, Any]:
return {"object": "list", "data": [public_model(item) for item in load_registry().values()]}
async def upload_image(client: httpx.AsyncClient, provider: Provider, value: str, index: int, client_id: str) -> str:
value = value.strip()
if value.startswith("comfy://"):
return value.removeprefix("comfy://")
if value.startswith("data:"):
header, encoded = value.split(",", 1)
mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
if not mime_type.startswith("image/"):
raise HTTPException(status_code=400, detail="reference data URL must contain an image")
try:
data = base64.b64decode(encoded, validate=True)
except (ValueError, base64.binascii.Error) as exc:
raise HTTPException(status_code=400, detail="reference image data URL is invalid") from exc
elif value.startswith(("http://", "https://")):
data, mime_type = await download_input_image(client, value)
else:
raise HTTPException(status_code=400, detail="input_images must contain http(s), data, or comfy URLs")
if len(data) > 30 * 1024 * 1024:
raise HTTPException(status_code=413, detail="reference image exceeds 30 MB")
extension = mimetypes.guess_extension(mime_type) or ".png"
filename = f"{client_id}-reference-{index}{extension}"
response = await client.post(f"{provider.base_url}/upload/image", files={"image": (filename, data, mime_type)}, data={"overwrite": "true"}, headers=provider_headers(provider))
response.raise_for_status()
payload = response.json()
return str(payload.get("name") or filename)
@app.post("/v1/jobs", dependencies=[Depends(require_token)])
async def create_job(request: JobRequest) -> dict[str, Any]:
registry = load_registry()
spec = registry.get(request.workflow_key)
if spec is None:
raise HTTPException(status_code=400, detail="workflow is not registered")
provider = await reserve_provider(request.provider_id)
if provider is None:
raise HTTPException(status_code=400, detail="provider is not configured")
try:
client_id = f"canvas-{secrets.token_hex(8)}"
async with httpx.AsyncClient(timeout=60) as client:
uploaded = [await upload_image(client, provider, value, index, client_id) for index, value in enumerate(request.input_images, start=1)]
try:
workflow = compile_workflow(spec, request.model_dump(), uploaded, client_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response = await client.post(f"{provider.base_url}/prompt", json={"prompt": workflow, "client_id": client_id}, headers=provider_headers(provider))
response.raise_for_status()
payload = response.json()
finally:
await release_provider(provider)
prompt_id = str(payload.get("prompt_id") or "").strip()
if not prompt_id:
raise HTTPException(status_code=502, detail="ComfyUI did not return prompt_id")
return {"id": encode_job(provider.id, prompt_id), "promptId": prompt_id, "providerId": provider.id, "workflowKey": spec.key, "workflowRevision": spec.revision, "status": "submitted"}
def extract_outputs(entry: dict[str, Any], job_id: str) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for node in (entry.get("outputs") or {}).values():
if not isinstance(node, dict):
continue
for field in ("images", "gifs", "audio"):
for item in node.get(field) or []:
if not isinstance(item, dict) or not item.get("filename"):
continue
filename = str(item["filename"])
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
kind = "video" if mime_type.startswith("video/") or filename.lower().endswith((".gif", ".webp")) and field == "gifs" else "audio" if mime_type.startswith("audio/") else "image"
index = len(result)
result.append({"index": index, "kind": kind, "mimeType": mime_type, "filename": filename, "url": f"/jobs/{quote(job_id, safe='')}/outputs/{index}"})
return result
async def job_state(job_id: str) -> tuple[Provider, str, dict[str, Any], list[dict[str, Any]]]:
provider, prompt_id = decode_job(job_id)
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(f"{provider.base_url}/history/{quote(prompt_id, safe='')}", headers=provider_headers(provider))
response.raise_for_status()
history = response.json()
entry = history.get(prompt_id) or {}
outputs = extract_outputs(entry, job_id)
return provider, prompt_id, entry, outputs
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(require_token)])
async def get_job(job_id: str) -> dict[str, Any]:
try:
provider, prompt_id, entry, outputs = await job_state(job_id)
except httpx.TimeoutException:
provider, prompt_id = decode_job(job_id)
return {
"id": job_id,
"promptId": prompt_id,
"providerId": provider.id,
"status": "running",
"outputs": [],
"error": "",
"pollDeferred": True,
}
status_payload = entry.get("status") or {}
if status_payload.get("status_str") == "error":
status = "failed"
elif outputs:
status = "succeeded"
elif status_payload.get("completed"):
status = "failed"
else:
status = "running"
error = "ComfyUI execution failed" if status == "failed" else ""
if status == "failed":
for event in status_payload.get("messages") or []:
if not isinstance(event, (list, tuple)) or len(event) < 2 or event[0] != "execution_error" or not isinstance(event[1], dict):
continue
# 只映射已识别的内核错误,不向用户返回节点输入、提示词或 traceback。
if "tensor strides exceed int32 range" in str(event[1].get("exception_message") or ""):
error = "当前时长与分辨率组合超出采样内核范围,请缩短时长后重试(最多 15 秒)"
break
return {"id": job_id, "promptId": prompt_id, "providerId": provider.id, "status": status, "outputs": outputs, "error": error}
@app.post("/v1/jobs/{job_id}/cancel", dependencies=[Depends(require_token)])
async def cancel_job(job_id: str) -> dict[str, Any]:
provider, prompt_id = decode_job(job_id)
async with httpx.AsyncClient(timeout=30) as client:
queue = (await client.get(f"{provider.base_url}/queue", headers=provider_headers(provider))).json()
running = {str(item[1]) for item in queue.get("queue_running") or [] if isinstance(item, list) and len(item) > 1}
if prompt_id in running:
await client.post(f"{provider.base_url}/interrupt", json={}, headers=provider_headers(provider))
await client.post(f"{provider.base_url}/queue", json={"delete": [prompt_id]}, headers=provider_headers(provider))
return {"id": job_id, "status": "cancelled"}
@app.get("/v1/jobs/{job_id}/outputs/{index}", dependencies=[Depends(require_token)])
async def get_output(job_id: str, index: int) -> Response:
provider, _, entry, outputs = await job_state(job_id)
if index < 0 or index >= len(outputs):
raise HTTPException(status_code=404, detail="output not found")
wanted = outputs[index]
locator: dict[str, Any] | None = None
for node in (entry.get("outputs") or {}).values():
if not isinstance(node, dict):
continue
for field in ("images", "gifs", "audio"):
for item in node.get(field) or []:
if isinstance(item, dict) and str(item.get("filename")) == wanted["filename"]:
locator = item
break
if locator: break
if locator: break
if locator is None:
raise HTTPException(status_code=404, detail="output locator not found")
query = urlencode({"filename": locator.get("filename", ""), "subfolder": locator.get("subfolder", ""), "type": locator.get("type", "output")})
client = httpx.AsyncClient(timeout=120)
response = await client.send(client.build_request("GET", f"{provider.base_url}/view?{query}", headers=provider_headers(provider)), stream=True)
if response.status_code >= 400:
await response.aclose(); await client.aclose()
raise HTTPException(status_code=502, detail="failed to read ComfyUI output")
async def stream():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose(); await client.aclose()
return StreamingResponse(stream(), media_type=response.headers.get("content-type", wanted["mimeType"]))