Files
cbpark84 1c919f1106 feat: batch/chain/schema/cache/preview/MCP/seed
- run_batch(): sequential|concurrent|auto
- chain(): 파이프라인 체이닝
- schema(): 스키마 자동 추출
- ResultCache: LRU+TTL 캐시
- preview_callback: 실시간 프리뷰
- upload_file/audio/video()
- BridgeInputSEED: random/increment/fixed
- MCPServer: MCP Tool 자동 생성
2026-07-16 15:39:45 +09:00

340 lines
12 KiB
Python

"""ComfyBridge — ComfyUI 워크플로우 실행 클라이언트 (async)."""
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import Any, Dict, Optional, Union
import aiohttp
from .cache import ResultCache
from .result import BridgeResult
from .schema import extract_schema
from .websocket_client import wait_for_completion
from .workflow import load_workflow, patch_inputs
logger = logging.getLogger(__name__)
class ComfyBridgeError(Exception):
"""ComfyBridge 관련 에러."""
class ComfyBridge:
"""
ComfyUI 워크플로우 실행 클라이언트.
사용 예시::
bridge = ComfyBridge(host="localhost", port=8188)
result = await bridge.run(
"workflow_api.json",
inputs={"prompt": "a cat", "steps": 20},
)
image = result.as_pil("output_image")
"""
def __init__(
self,
host: str = "localhost",
port: int = 8188,
timeout: float = 300.0,
):
self.base_url = f"http://{host}:{port}"
self.ws_url = f"ws://{host}:{port}/ws"
self.timeout = aiohttp.ClientTimeout(total=timeout)
async def run(
self,
workflow: Union[str, dict, Path],
inputs: Optional[Dict[str, Any]] = None,
progress_callback: Optional[Callable[[float], None]] = None,
preview_callback: Optional[Callable[[bytes, int, int], None]] = None,
) -> BridgeResult:
"""
워크플로우를 실행하고 결과를 반환한다.
Args:
workflow: 워크플로우 파일 경로 또는 파싱된 dict
inputs: BridgeInput 노드에 주입할 값 딕셔너리
progress_callback: 진행률(0.0~1.0)을 받는 콜백 함수
preview_callback: 프리뷰 이미지 (bytes, step, total_steps)를 받는 콜백
Returns:
BridgeResult 객체
"""
wf = load_workflow(workflow)
patched = patch_inputs(wf, inputs or {})
client_id = str(uuid.uuid4())
async with aiohttp.ClientSession(timeout=self.timeout) as session:
# WebSocket 연결
ws_url = f"{self.ws_url}?clientId={client_id}"
async with session.ws_connect(ws_url) as ws:
# /prompt API 호출
prompt_id = await self._post_prompt(session, patched, client_id)
logger.info(f"[ComfyBridgeIO] Queued prompt_id={prompt_id}")
# 완료 대기
await wait_for_completion(ws, prompt_id, progress_callback, preview_callback)
# 결과 수집
outputs = await self._collect_outputs(session, prompt_id)
return BridgeResult(outputs, base_url=self.base_url, timeout=self.timeout.total or 300.0)
async def upload_image(
self,
image: Union[bytes, str, Path],
filename: str = "input.png",
) -> str:
"""
이미지를 ComfyUI 서버에 업로드하고 서버 파일명을 반환한다.
Returns:
서버 내 파일명 (BridgeInputIMAGE value에 주입할 값)
"""
if isinstance(image, (str, Path)):
with open(image, "rb") as f:
image = f.read()
async with aiohttp.ClientSession(timeout=self.timeout) as session:
form = aiohttp.FormData()
form.add_field("image", image, filename=filename, content_type="image/png")
async with session.post(f"{self.base_url}/upload/image", data=form) as resp:
resp.raise_for_status()
data = await resp.json()
return data["name"]
async def get_queue_status(self) -> Dict:
"""현재 큐 상태를 반환한다."""
async with aiohttp.ClientSession(timeout=self.timeout) as session:
async with session.get(f"{self.base_url}/queue") as resp:
resp.raise_for_status()
return await resp.json()
async def _post_prompt(
self,
session: aiohttp.ClientSession,
workflow: dict,
client_id: str,
) -> str:
payload = {"prompt": workflow, "client_id": client_id}
async with session.post(
f"{self.base_url}/prompt",
data=json.dumps(payload),
headers={"Content-Type": "application/json"},
) as resp:
resp.raise_for_status()
data = await resp.json()
return data["prompt_id"]
async def run_batch(
self,
workflow: Union[str, dict, Path],
inputs: list[Dict[str, Any]],
mode: str = "auto",
max_concurrent: int = 4,
) -> list[BridgeResult]:
"""
여러 입력 셋을 배치로 실행한다.
Args:
workflow: 워크플로우 파일 경로 또는 dict
inputs: 입력 딕셔너리 리스트
mode: "sequential" (순차), "concurrent" (병렬), "auto" (영상이면 순차)
max_concurrent: 동시 실행 수
Returns:
BridgeResult 리스트
"""
if mode == "auto":
wf = load_workflow(workflow)
# 영상 출력이 있으면 순차 모드로
mode = "sequential" if self._has_video_output(wf) else "concurrent"
if mode == "sequential":
return await self._run_batch_sequential(workflow, inputs)
else:
return await self._run_batch_concurrent(workflow, inputs, max_concurrent)
async def chain(
self,
workflows: list[tuple[Union[str, dict, Path], Dict[str, Any]]],
progress_callback: Optional[Callable[[float], None]] = None,
) -> BridgeResult:
"""
여러 워크플로우를 순차적으로 체이닝한다.
Args:
workflows: (workflow_path, inputs) 튜플 리스트
progress_callback: 진행률 콜백
Returns:
마지막 워크플로우 결과
"""
result = None
for i, (wf, inputs) in enumerate(workflows):
logger.info(f"[ComfyBridgeIO] Chain step {i+1}/{len(workflows)}")
if progress_callback:
progress_callback(i / len(workflows))
result = await self.run(wf, inputs, progress_callback)
if progress_callback:
progress_callback(1.0)
return result
async def upload_file(
self,
file_path: Union[str, Path],
upload_type: str = "input",
) -> str:
"""
범용 파일 업로드 (이미지/오디오/비디오).
Args:
file_path: 파일 경로
upload_type: "input" | "temp" 등
Returns:
서버 내 파일명
"""
path = Path(file_path)
suffix = path.suffix.lower()
if suffix in (".png", ".jpg", ".jpeg", ".webp"):
return await self.upload_image(str(path))
elif suffix in (".wav", ".mp3", ".aac", ".flac"):
return await self.upload_audio(str(path))
elif suffix in (".mp4", ".webm", ".mov", ".avi"):
return await self.upload_video(str(path))
else:
raise ValueError(f"Unsupported file type: {suffix}")
async def upload_audio(
self,
audio: Union[bytes, str, Path],
filename: str = "input.wav",
) -> str:
"""오디오 파일을 업로드한다."""
if isinstance(audio, (str, Path)):
with open(audio, "rb") as f:
audio = f.read()
async with aiohttp.ClientSession(timeout=self.timeout) as session:
form = aiohttp.FormData()
form.add_field("audio", audio, filename=filename, content_type="audio/wav")
async with session.post(f"{self.base_url}/upload/audio", data=form) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("name", filename)
async def upload_video(
self,
video: Union[bytes, str, Path],
filename: str = "input.mp4",
) -> str:
"""비디오 파일을 업로드한다."""
if isinstance(video, (str, Path)):
with open(video, "rb") as f:
video = f.read()
async with aiohttp.ClientSession(timeout=self.timeout) as session:
form = aiohttp.FormData()
form.add_field("video", video, filename=filename, content_type="video/mp4")
async with session.post(f"{self.base_url}/upload/video", data=form) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("name", filename)
def get_schema(self, workflow: Union[str, dict, Path]) -> Dict[str, Any]:
"""워크플로우 스키마를 추출한다."""
wf = load_workflow(workflow)
return extract_schema(wf)
def new_cache(self, max_size: int = 100, ttl: float = 3600.0) -> ResultCache:
"""새로운 ResultCache를 생성한다."""
return ResultCache(max_size=max_size, ttl=ttl)
# ── Private 메서드 ──────────────────────────────────────────
async def _run_batch_sequential(
self,
workflow: Union[str, dict, Path],
inputs: list[Dict[str, Any]],
) -> list[BridgeResult]:
"""배치를 순차적으로 실행한다."""
results = []
for i, input_dict in enumerate(inputs):
logger.info(f"[ComfyBridgeIO] Batch {i+1}/{len(inputs)}")
result = await self.run(workflow, input_dict)
results.append(result)
return results
async def _run_batch_concurrent(
self,
workflow: Union[str, dict, Path],
inputs: list[Dict[str, Any]],
max_concurrent: int,
) -> list[BridgeResult]:
"""배치를 병렬로 실행한다."""
import asyncio
semaphore = asyncio.Semaphore(max_concurrent)
async def _run_with_semaphore(input_dict):
async with semaphore:
return await self.run(workflow, input_dict)
tasks = [_run_with_semaphore(inp) for inp in inputs]
return await asyncio.gather(*tasks)
def _has_video_output(self, workflow: dict) -> bool:
"""워크플로우에 비디오 출력이 있는지 확인한다."""
for node in workflow.values():
if not isinstance(node, dict):
continue
class_type = node.get("class_type", "").lower()
if "video" in class_type or "vhs" in class_type:
return True
return False
async def _collect_outputs(
self,
session: aiohttp.ClientSession,
prompt_id: str,
) -> Dict[str, Any]:
outputs: Dict[str, Any] = {}
# 1. /history에서 표준 이미지 결과 수집
async with session.get(f"{self.base_url}/history/{prompt_id}") as resp:
if resp.status == 200:
history = await resp.json()
node_outputs = history.get(prompt_id, {}).get("outputs", {})
for node_id, node_out in node_outputs.items():
if "images" in node_out:
for i, img_info in enumerate(node_out["images"]):
params = {
"filename": img_info["filename"],
"subfolder": img_info.get("subfolder", ""),
"type": img_info.get("type", "output"),
}
async with session.get(
f"{self.base_url}/view", params=params
) as img_resp:
if img_resp.status == 200:
key = f"__node_{node_id}_image_{i}"
outputs[key] = await img_resp.read()
# 2. /comfybridge/results에서 BridgeOutput 결과 수집
async with session.get(
f"{self.base_url}/comfybridge/results/{prompt_id}"
) as resp:
if resp.status == 200:
data = await resp.json()
outputs.update(data.get("outputs", {}))
return outputs