feat: initial project structure for ComfyBridgeIO
- ComfyUI custom nodes: BridgeInput (11 types) + BridgeOutput
- AnyType wildcard for universal output capture
- Custom server route: GET /comfybridge/results/{prompt_id}
- Python async client (ComfyBridge) + sync wrapper (ComfyBridgeSync)
- Workflow JSON parser and patcher (deepcopy, original unchanged)
- WebSocket completion detection
- BridgeResult container with PIL/bytes helpers
- Examples: basic_text_to_image.py, agent_loop.py
- Unit tests for workflow parser and client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
outputs/
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
# ComfyBridgeIO 아키텍처 설계
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
### 목적
|
||||
ComfyBridgeIO는 ComfyUI 커스텀 노드 패키지와 Python 클라이언트 래퍼로 구성된다. AI Agent나 외부 스크립트가 ComfyUI 워크플로우를 **런타임에 동적으로 제어**할 수 있도록 표준화된 입출력 브리지를 제공한다.
|
||||
|
||||
### 핵심 동작 원리
|
||||
|
||||
```
|
||||
[AI Agent / 외부 스크립트]
|
||||
│
|
||||
│ bridge.run("workflow.json", inputs={...})
|
||||
▼
|
||||
[ComfyBridge 클라이언트]
|
||||
1. workflow.json 파싱 → BridgeInput 노드 탐색
|
||||
2. 노드 inputs.value 필드 패치 (런타임 값 주입)
|
||||
3. POST /prompt → ComfyUI 서버에 실행 요청
|
||||
4. WebSocket 수신 → 실행 완료 대기
|
||||
5. GET /history/{prompt_id} → BridgeOutput 노드 결과 수집
|
||||
6. GET /view or GET /comfybridge/results/{prompt_id} → 바이너리/스칼라 회수
|
||||
│
|
||||
│ BridgeResult 객체 반환
|
||||
▼
|
||||
[AI Agent]
|
||||
result.get("output_image") → PIL.Image / bytes
|
||||
result.get("caption") → str
|
||||
```
|
||||
|
||||
### 설계 원칙
|
||||
- **YAGNI**: 현재 요구사항 범위 외 기능 추가 금지
|
||||
- **ComfyUI 버전 호환성**: AnyType 트릭을 사용해 와일드카드 타입 안정성 확보
|
||||
- **비동기 우선**: 클라이언트는 `asyncio` 기반, 동기 래퍼 별도 제공
|
||||
- **단방향 패치**: 클라이언트가 워크플로우 JSON을 수정하되 원본 파일은 변경하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 2. 디렉토리 구조
|
||||
|
||||
```
|
||||
ComfyBridgeIO/
|
||||
├── __init__.py # ComfyUI 노드 등록 진입점
|
||||
├── nodes/
|
||||
│ ├── __init__.py
|
||||
│ ├── bridge_input.py # BridgeInput 노드 (타입별 팩토리 생성)
|
||||
│ ├── bridge_output.py # BridgeOutput 노드 (타입별 팩토리 생성)
|
||||
│ ├── any_type.py # AnyType 와일드카드 헬퍼
|
||||
│ └── server_routes.py # 커스텀 API 라우트 (/comfybridge/*)
|
||||
├── client/
|
||||
│ ├── __init__.py # ComfyBridge, BridgeResult export
|
||||
│ ├── bridge.py # ComfyBridge 메인 클래스 (async)
|
||||
│ ├── bridge_sync.py # 동기 래퍼 (asyncio.run 기반)
|
||||
│ ├── workflow.py # 워크플로우 JSON 파서 / 패처
|
||||
│ ├── websocket_client.py # WebSocket 통신 및 완료 대기
|
||||
│ ├── result.py # BridgeResult 클래스
|
||||
│ └── types.py # 타입 상수, 변환 유틸리티
|
||||
├── examples/
|
||||
│ ├── basic_text_to_image.py # 기본 텍스트-이미지 예제
|
||||
│ ├── agent_loop.py # AI Agent 루프 예제
|
||||
│ └── workflows/
|
||||
│ └── sdxl_bridge_template.json
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_workflow_parser.py
|
||||
│ ├── test_bridge_client.py
|
||||
│ └── fixtures/
|
||||
│ └── sample_workflow.json
|
||||
├── pyproject.toml
|
||||
├── requirements.txt # 클라이언트 의존성
|
||||
├── requirements-dev.txt
|
||||
├── README.md
|
||||
└── ARCHITECTURE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 커스텀 노드 설계
|
||||
|
||||
### 3-1. AnyType 와일드카드 (`nodes/any_type.py`)
|
||||
|
||||
ComfyUI의 타입 검증 시스템을 우회하기 위해 AnyType 클래스를 사용한다. `__ne__` 를 항상 `False` 로 반환하여 어떤 타입과도 동등 비교되도록 한다.
|
||||
|
||||
```python
|
||||
class AnyType(str):
|
||||
"""
|
||||
ComfyUI 타입 검증을 통과하는 와일드카드 타입.
|
||||
str을 상속하여 직렬화 호환성 유지.
|
||||
"""
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return False
|
||||
|
||||
ANY = AnyType("*")
|
||||
```
|
||||
|
||||
이 타입은 BridgeOutput 노드의 입력부에 사용하며, 어떤 타입의 ComfyUI 출력도 연결 가능하게 한다.
|
||||
|
||||
---
|
||||
|
||||
### 3-2. BridgeInput 노드 (`nodes/bridge_input.py`)
|
||||
|
||||
#### 책임
|
||||
워크플로우 안에서 **외부에서 주입 가능한 값의 입력 포인트** 역할을 한다. 클라이언트가 이 노드를 이름으로 탐색하여 `value` 위젯 값을 패치한다.
|
||||
|
||||
#### 타입별 팩토리 패턴
|
||||
|
||||
동일한 로직을 반복하지 않기 위해 팩토리 함수로 각 타입별 노드 클래스를 생성한다.
|
||||
|
||||
```python
|
||||
SUPPORTED_TYPES = {
|
||||
"STRING": ("STRING", {"default": "", "multiline": True}),
|
||||
"INT": ("INT", {"default": 0, "min": -2**31, "max": 2**31}),
|
||||
"FLOAT": ("FLOAT", {"default": 0.0, "min": -1e9, "max": 1e9}),
|
||||
"BOOLEAN": ("BOOLEAN", {"default": False}),
|
||||
"IMAGE": ("IMAGE", {}), # 위젯 없음, 입력 포트만
|
||||
"MASK": ("MASK", {}),
|
||||
"LATENT": ("LATENT", {}),
|
||||
"MODEL": ("MODEL", {}),
|
||||
"CLIP": ("CLIP", {}),
|
||||
"VAE": ("VAE", {}),
|
||||
"CONDITIONING": ("CONDITIONING", {}),
|
||||
}
|
||||
|
||||
def make_bridge_input(type_name: str, comfy_type, widget_config: dict):
|
||||
"""
|
||||
타입별 BridgeInput 노드 클래스를 동적으로 생성한다.
|
||||
"""
|
||||
is_primitive = type_name in ("STRING", "INT", "FLOAT", "BOOLEAN")
|
||||
|
||||
class BridgeInputNode:
|
||||
CATEGORY = "ComfyBridgeIO"
|
||||
FUNCTION = "passthrough"
|
||||
RETURN_TYPES = (comfy_type,)
|
||||
RETURN_NAMES = ("value",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
inputs = {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "input_name"}),
|
||||
}
|
||||
}
|
||||
if is_primitive:
|
||||
# 스칼라 타입: 위젯으로 기본값 설정
|
||||
inputs["required"]["value"] = (comfy_type, widget_config)
|
||||
else:
|
||||
# 복합 타입(IMAGE 등): 선택적 연결 포트
|
||||
inputs["optional"] = {
|
||||
"value": (comfy_type, {})
|
||||
}
|
||||
return inputs
|
||||
|
||||
def passthrough(self, name: str, value=None):
|
||||
return (value,)
|
||||
|
||||
BridgeInputNode.__name__ = f"BridgeInput{type_name}"
|
||||
BridgeInputNode.__qualname__ = f"BridgeInput{type_name}"
|
||||
return BridgeInputNode
|
||||
```
|
||||
|
||||
#### 클라이언트와의 계약
|
||||
|
||||
| 노드 필드 | 역할 |
|
||||
|-----------|------|
|
||||
| `class_type` | `"BridgeInputSTRING"`, `"BridgeInputINT"` 등 (패턴: `BridgeInput*`) |
|
||||
| `inputs.name` | 클라이언트 딕셔너리의 키와 매칭되는 식별자 |
|
||||
| `inputs.value` | 클라이언트가 런타임에 패치하는 필드 |
|
||||
|
||||
---
|
||||
|
||||
### 3-3. BridgeOutput 노드 (`nodes/bridge_output.py`)
|
||||
|
||||
#### 책임
|
||||
워크플로우 안에서 **결과를 이름으로 태깅하는 출력 포인트** 역할을 한다. `OUTPUT_NODE = True` 로 선언하여 ComfyUI가 이 노드의 실행을 보장하게 한다. 실행 후 결과를 서버 메모리에 저장하고 커스텀 API 라우트로 노출한다.
|
||||
|
||||
#### 설계
|
||||
|
||||
```python
|
||||
from .any_type import ANY
|
||||
from .server_routes import store_bridge_result
|
||||
|
||||
class BridgeOutput:
|
||||
CATEGORY = "ComfyBridgeIO"
|
||||
FUNCTION = "capture"
|
||||
OUTPUT_NODE = True
|
||||
RETURN_TYPES = () # 터미널 노드: 다운스트림 연결 없음
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "output_name"}),
|
||||
"value": (ANY, {}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt_id": "PROMPT_ID", # ComfyUI가 런타임에 주입
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
def capture(self, name: str, value, prompt_id: str, unique_id: str):
|
||||
store_bridge_result(prompt_id=prompt_id, name=name, value=value)
|
||||
return {}
|
||||
```
|
||||
|
||||
**IMAGE 타입 처리**: BridgeOutput이 IMAGE 값을 받으면 `store_bridge_result` 내부에서 PIL Image로 변환 후 메모리에 보관. 클라이언트는 `/comfybridge/results/{prompt_id}` 로 base64 인코딩된 PNG를 수신.
|
||||
|
||||
---
|
||||
|
||||
### 3-4. 서버 커스텀 라우트 (`nodes/server_routes.py`)
|
||||
|
||||
ComfyUI의 `PromptServer`에 라우트를 추가하여 BridgeOutput 결과를 외부로 노출한다.
|
||||
|
||||
```
|
||||
GET /comfybridge/results/{prompt_id}
|
||||
→ { "outputs": { "output_name": <value>, ... } }
|
||||
→ IMAGE 타입은 base64 PNG 문자열로 직렬화
|
||||
→ 스칼라 타입은 JSON 네이티브 값으로 직렬화
|
||||
```
|
||||
|
||||
결과 저장소: 모듈 레벨 `dict[prompt_id → dict[name → value]]`. 최대 100개 prompt_id 보관 (LRU 방식으로 오래된 항목 제거).
|
||||
|
||||
---
|
||||
|
||||
### 3-5. 노드 등록 (`__init__.py`)
|
||||
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
# BridgeInput 타입별 등록
|
||||
for type_name, (comfy_type, widget_cfg) in SUPPORTED_TYPES.items():
|
||||
cls = make_bridge_input(type_name, comfy_type, widget_cfg)
|
||||
node_id = f"BridgeInput{type_name}"
|
||||
NODE_CLASS_MAPPINGS[node_id] = cls
|
||||
NODE_DISPLAY_NAME_MAPPINGS[node_id] = f"Bridge Input ({type_name})"
|
||||
|
||||
# BridgeOutput 등록
|
||||
NODE_CLASS_MAPPINGS["BridgeOutput"] = BridgeOutput
|
||||
NODE_DISPLAY_NAME_MAPPINGS["BridgeOutput"] = "Bridge Output"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Python 클라이언트 설계
|
||||
|
||||
### 4-1. ComfyBridge 클래스 API (`client/bridge.py`)
|
||||
|
||||
```python
|
||||
class ComfyBridge:
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 8188,
|
||||
timeout: float = 300.0,
|
||||
): ...
|
||||
|
||||
async def run(
|
||||
self,
|
||||
workflow: str | dict, # 파일 경로 또는 파싱된 dict
|
||||
inputs: dict[str, Any] = {},
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
) -> BridgeResult: ...
|
||||
|
||||
async def upload_image(
|
||||
self,
|
||||
image: bytes | str, # bytes 또는 파일 경로
|
||||
filename: str = "input.png",
|
||||
) -> str: ... # ComfyUI 서버 내 파일명 반환
|
||||
|
||||
async def get_queue_status(self) -> dict: ...
|
||||
|
||||
async def cancel(self, prompt_id: str) -> None: ...
|
||||
```
|
||||
|
||||
#### 동기 래퍼 (`client/bridge_sync.py`)
|
||||
|
||||
```python
|
||||
class ComfyBridgeSync:
|
||||
"""asyncio가 없는 환경을 위한 동기 래퍼."""
|
||||
|
||||
def run(self, workflow, inputs={}, **kwargs) -> BridgeResult:
|
||||
return asyncio.run(self._bridge.run(workflow, inputs, **kwargs))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4-2. 워크플로우 파서 / 패처 (`client/workflow.py`)
|
||||
|
||||
#### 파싱 전략
|
||||
|
||||
ComfyUI 워크플로우 JSON (API 포맷)은 노드 ID를 키로 하는 flat dict다.
|
||||
|
||||
```json
|
||||
{
|
||||
"1": { "class_type": "BridgeInputSTRING", "inputs": { "name": "prompt", "value": "default text" } },
|
||||
"2": { "class_type": "KSampler", "inputs": { "text": ["1", 0], ... } },
|
||||
"9": { "class_type": "BridgeOutput", "inputs": { "name": "output_image", "value": ["8", 0] } }
|
||||
}
|
||||
```
|
||||
|
||||
#### BridgeInput 노드 탐색
|
||||
|
||||
```python
|
||||
BRIDGE_INPUT_PREFIX = "BridgeInput"
|
||||
|
||||
def find_bridge_inputs(workflow: dict) -> dict[str, tuple[str, str]]:
|
||||
"""
|
||||
Returns: { "input_name": (node_id, type_name) }
|
||||
예: { "prompt": ("1", "STRING"), "steps": ("3", "INT") }
|
||||
"""
|
||||
result = {}
|
||||
for node_id, node in workflow.items():
|
||||
class_type = node.get("class_type", "")
|
||||
if class_type.startswith(BRIDGE_INPUT_PREFIX):
|
||||
name = node["inputs"].get("name")
|
||||
type_name = class_type[len(BRIDGE_INPUT_PREFIX):]
|
||||
if name:
|
||||
result[name] = (node_id, type_name)
|
||||
return result
|
||||
```
|
||||
|
||||
#### 값 주입 (패치)
|
||||
|
||||
```python
|
||||
def patch_inputs(workflow: dict, inputs: dict[str, Any]) -> dict:
|
||||
"""
|
||||
원본 workflow를 deepcopy 후 BridgeInput 노드의 value를 패치한다.
|
||||
IMAGE 타입: 이미 upload_image()로 업로드된 파일명(str)을 주입.
|
||||
"""
|
||||
patched = copy.deepcopy(workflow)
|
||||
bridge_map = find_bridge_inputs(patched)
|
||||
|
||||
for name, value in inputs.items():
|
||||
if name not in bridge_map:
|
||||
raise ValueError(f"BridgeInput '{name}' not found in workflow")
|
||||
node_id, type_name = bridge_map[name]
|
||||
converted = convert_value(value, type_name)
|
||||
patched[node_id]["inputs"]["value"] = converted
|
||||
|
||||
return patched
|
||||
```
|
||||
|
||||
#### 타입 변환 테이블 (`client/types.py`)
|
||||
|
||||
| type_name | Python 입력 타입 | 변환 |
|
||||
|-----------|-----------------|------|
|
||||
| STRING | str | 그대로 |
|
||||
| INT | int / str | int() 캐스팅 |
|
||||
| FLOAT | float / str | float() 캐스팅 |
|
||||
| BOOLEAN | bool / int | bool() 캐스팅 |
|
||||
| IMAGE | str (파일명) | 업로드 후 반환된 서버 파일명 |
|
||||
| MASK, LATENT, MODEL, CLIP, VAE, CONDITIONING | 노드 링크 `[node_id, idx]` | 그대로 전달 |
|
||||
|
||||
---
|
||||
|
||||
### 4-3. WebSocket 통신 흐름 (`client/websocket_client.py`)
|
||||
|
||||
```
|
||||
클라이언트 ComfyUI 서버
|
||||
│ │
|
||||
├─── GET /ws?clientId={uuid} ──────────►│ WebSocket 연결 수립
|
||||
│ │
|
||||
├─── POST /prompt ──────────────────────►│ { prompt, client_id, extra_data }
|
||||
│◄── { prompt_id, number, ... } ─────────┤ 즉시 응답 (큐에 삽입)
|
||||
│ │
|
||||
│◄── { type: "execution_start" } ────────┤ 실행 시작
|
||||
│◄── { type: "executing", │
|
||||
│ data: { node: "1" } } ───────────┤ 노드별 진행 상황
|
||||
│◄── { type: "progress", │
|
||||
│ data: { value: 5, max: 20 } } ───┤ KSampler 스텝 진행률
|
||||
│◄── { type: "executed", │
|
||||
│ data: { node: "9", ... } } ──────┤ OUTPUT_NODE 결과 (SaveImage 등)
|
||||
│◄── { type: "executing", │
|
||||
│ data: { node: null } } ──────────┤ 완료 신호
|
||||
│ │
|
||||
├─── GET /history/{prompt_id} ──────────►│
|
||||
│◄── { prompt_id: { outputs: {...} } } ──┤
|
||||
│ │
|
||||
├─── GET /comfybridge/results/{id} ─────►│ 비이미지 BridgeOutput 결과
|
||||
│◄── { outputs: { "name": value } } ─────┤
|
||||
```
|
||||
|
||||
#### 완료 감지 조건
|
||||
|
||||
```python
|
||||
# WebSocket 메시지 처리 루프
|
||||
async def wait_for_completion(ws, target_prompt_id: str) -> None:
|
||||
async for message in ws:
|
||||
data = json.loads(message)
|
||||
msg_type = data.get("type")
|
||||
msg_data = data.get("data", {})
|
||||
|
||||
# 다른 prompt의 메시지 무시
|
||||
if msg_data.get("prompt_id") and msg_data["prompt_id"] != target_prompt_id:
|
||||
continue
|
||||
|
||||
if msg_type == "executing" and msg_data.get("node") is None:
|
||||
break # node가 null이면 이 prompt의 실행 완료
|
||||
|
||||
if msg_type == "execution_error":
|
||||
raise ComfyBridgeError(msg_data.get("exception_message"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4-4. 결과 회수 전략 (`client/result.py`)
|
||||
|
||||
#### BridgeResult 클래스
|
||||
|
||||
```python
|
||||
class BridgeResult:
|
||||
def __init__(self, raw_outputs: dict[str, Any]):
|
||||
self._outputs = raw_outputs
|
||||
|
||||
def get(self, name: str, default=None) -> Any:
|
||||
return self._outputs.get(name, default)
|
||||
|
||||
def keys(self) -> list[str]:
|
||||
return list(self._outputs.keys())
|
||||
|
||||
def as_pil(self, name: str) -> "PIL.Image.Image":
|
||||
"""IMAGE 타입 결과를 PIL Image로 반환."""
|
||||
...
|
||||
|
||||
def as_bytes(self, name: str) -> bytes:
|
||||
"""IMAGE 타입 결과를 PNG bytes로 반환."""
|
||||
...
|
||||
```
|
||||
|
||||
#### 결과 수집 흐름
|
||||
|
||||
```python
|
||||
async def collect_results(
|
||||
session: aiohttp.ClientSession,
|
||||
base_url: str,
|
||||
prompt_id: str,
|
||||
workflow: dict,
|
||||
) -> BridgeResult:
|
||||
outputs = {}
|
||||
|
||||
# 1. /history/{prompt_id} 에서 SaveImage / PreviewImage 등 표준 노드 결과 수집
|
||||
history = await fetch_history(session, base_url, prompt_id)
|
||||
node_outputs = history[prompt_id]["outputs"]
|
||||
|
||||
for node_id, node_output in node_outputs.items():
|
||||
if "images" in node_output:
|
||||
for img_info in node_output["images"]:
|
||||
# /view?filename=...&subfolder=...&type=... 로 다운로드
|
||||
img_bytes = await fetch_image(session, base_url, img_info)
|
||||
outputs[f"__node_{node_id}_image"] = img_bytes
|
||||
|
||||
# 2. /comfybridge/results/{prompt_id} 에서 BridgeOutput 노드 결과 수집
|
||||
bridge_outputs = await fetch_bridge_results(session, base_url, prompt_id)
|
||||
outputs.update(bridge_outputs)
|
||||
|
||||
return BridgeResult(outputs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 플로우 다이어그램
|
||||
|
||||
```
|
||||
[AI Agent]
|
||||
│
|
||||
│ 1. bridge.run("workflow.json", inputs={"prompt": "a cat", "steps": 20})
|
||||
▼
|
||||
[ComfyBridge.run()]
|
||||
│
|
||||
├─ 2. workflow.py: JSON 로드 → BridgeInput 노드 탐색
|
||||
├─ 3. workflow.py: inputs dict → 노드 value 필드 패치 (deepcopy, 원본 불변)
|
||||
├─ 4. IMAGE 타입이 있으면: POST /upload/image → 서버 파일명 획득 후 패치
|
||||
│
|
||||
├─ 5. uuid4() → client_id 생성
|
||||
├─ 6. WebSocket 연결 수립: ws://host:port/ws?clientId={client_id}
|
||||
│
|
||||
├─ 7. POST /prompt → { prompt: patched_workflow, client_id }
|
||||
│ ← { prompt_id: "abc-123" }
|
||||
│
|
||||
├─ 8. WebSocket 수신 루프 (완료 대기)
|
||||
│ progress_callback 호출 (선택)
|
||||
│ execution_error 시 ComfyBridgeError raise
|
||||
│
|
||||
├─ 9. GET /history/{prompt_id} → 표준 노드 이미지 결과 수집
|
||||
│ GET /view?filename=... → 이미지 바이너리 다운로드
|
||||
│
|
||||
├─ 10. GET /comfybridge/results/{prompt_id} → BridgeOutput 스칼라 결과 수집
|
||||
│
|
||||
└─ 11. BridgeResult 객체 생성 후 반환
|
||||
│
|
||||
▼
|
||||
[AI Agent]
|
||||
result.get("output_image") # bytes (PNG)
|
||||
result.get("caption") # str
|
||||
result.get("latent_code") # dict (LATENT)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 설치 및 사용법 개요
|
||||
|
||||
### 커스텀 노드 설치
|
||||
|
||||
```bash
|
||||
# ComfyUI 커스텀 노드 디렉토리에 클론
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/your-org/ComfyBridgeIO
|
||||
# ComfyUI 재시작
|
||||
```
|
||||
|
||||
### Python 클라이언트 설치
|
||||
|
||||
```bash
|
||||
pip install comfybridgeio
|
||||
# 또는 로컬 설치
|
||||
pip install -e /path/to/ComfyBridgeIO
|
||||
```
|
||||
|
||||
### 워크플로우 준비
|
||||
|
||||
1. ComfyUI에서 워크플로우 편집
|
||||
2. 외부에서 주입할 값의 위치에 **Bridge Input (STRING)** 등 노드 삽입, `name` 위젯에 식별자 입력
|
||||
3. 외부로 회수할 결과에 **Bridge Output** 노드 연결, `name` 위젯에 식별자 입력
|
||||
4. `Save (API Format)` 으로 JSON 내보내기
|
||||
|
||||
### 클라이언트 사용 예시
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from comfybridgeio import ComfyBridge
|
||||
|
||||
async def main():
|
||||
bridge = ComfyBridge(host="localhost", port=8188)
|
||||
|
||||
result = await bridge.run(
|
||||
workflow="workflow_api.json",
|
||||
inputs={
|
||||
"prompt": "a cat in space, photorealistic",
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"steps": 20,
|
||||
"cfg": 7.5,
|
||||
"seed": 42,
|
||||
},
|
||||
progress_callback=lambda p: print(f"Progress: {p:.0%}"),
|
||||
)
|
||||
|
||||
# 이미지 저장
|
||||
with open("output.png", "wb") as f:
|
||||
f.write(result.as_bytes("output_image"))
|
||||
|
||||
print("Done")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```python
|
||||
# 동기 사용 예시
|
||||
from comfybridgeio import ComfyBridgeSync
|
||||
|
||||
bridge = ComfyBridgeSync(host="localhost", port=8188)
|
||||
result = bridge.run("workflow_api.json", inputs={"prompt": "a dog"})
|
||||
image = result.as_pil("output_image")
|
||||
image.show()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 의존성
|
||||
|
||||
### requirements.txt (클라이언트)
|
||||
|
||||
```
|
||||
aiohttp>=3.9.0
|
||||
websockets>=12.0
|
||||
Pillow>=10.0.0
|
||||
```
|
||||
|
||||
### requirements-dev.txt
|
||||
|
||||
```
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-mock>=3.12.0
|
||||
aioresponses>=0.7.6
|
||||
```
|
||||
|
||||
### pyproject.toml
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "comfybridgeio"
|
||||
version = "0.1.0"
|
||||
description = "ComfyUI dynamic I/O bridge for AI agents"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"aiohttp>=3.9.0",
|
||||
"websockets>=12.0",
|
||||
"Pillow>=10.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"aioresponses>=0.7.6",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 미결 사항 (PM 승인 필요)
|
||||
|
||||
| # | 항목 | 선택지 |
|
||||
|---|------|--------|
|
||||
| 1 | MODEL / CLIP / VAE / CONDITIONING 타입의 BridgeOutput 직렬화 | (A) 미지원 (에러 반환) / (B) JSON-safe repr만 반환 |
|
||||
| 2 | BridgeOutput 결과 서버 메모리 보관 최대 개수 | 기본 100개 LRU / 조정 가능 |
|
||||
| 3 | 클라이언트 패키지 PyPI 배포 여부 | 배포 / GitHub only |
|
||||
| 4 | ComfyUI Manager 연동 (`node_list.json` 포함 여부) | 포함 / 미포함 |
|
||||
@@ -0,0 +1,101 @@
|
||||
# ComfyBridgeIO
|
||||
|
||||
ComfyUI 워크플로우를 AI 에이전트가 동적으로 제어할 수 있는 입출력 브리지.
|
||||
|
||||
## 특징
|
||||
|
||||
- **어떤 입력이든 주입 가능**: STRING, INT, FLOAT, BOOLEAN, IMAGE, MASK, LATENT 등 ComfyUI 기본 타입 전부 지원
|
||||
- **어떤 출력이든 회수 가능**: BridgeOutput 노드로 이름 태깅 후 결과 회수
|
||||
- **Python 클라이언트 포함**: async/sync 모두 지원, AI Agent 프레임워크와 쉽게 통합
|
||||
- **원본 워크플로우 불변**: 클라이언트가 deepcopy 후 값을 주입하므로 원본 JSON 파일 변경 없음
|
||||
|
||||
## 설치
|
||||
|
||||
### 1. ComfyUI 커스텀 노드 설치
|
||||
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/nicekate/ComfyBridgeIO
|
||||
# ComfyUI 재시작
|
||||
```
|
||||
|
||||
### 2. Python 클라이언트 설치
|
||||
|
||||
```bash
|
||||
pip install -e /path/to/ComfyBridgeIO
|
||||
```
|
||||
|
||||
## 사용법
|
||||
|
||||
### 워크플로우 준비
|
||||
|
||||
1. ComfyUI에서 워크플로우 편집
|
||||
2. 외부에서 주입할 값 위치에 **Bridge Input** 노드 삽입 (`name` 위젯에 식별자 입력)
|
||||
3. 외부로 회수할 결과에 **Bridge Output** 노드 연결 (`name` 위젯에 식별자 입력)
|
||||
4. `Save (API Format)` 으로 JSON 내보내기
|
||||
|
||||
### 클라이언트 사용
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from comfybridgeio import ComfyBridge
|
||||
|
||||
async def main():
|
||||
bridge = ComfyBridge(host="localhost", port=8188)
|
||||
|
||||
result = await bridge.run(
|
||||
workflow="workflow_api.json",
|
||||
inputs={
|
||||
"prompt": "a cat in space, photorealistic",
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"steps": 20,
|
||||
"cfg": 7.5,
|
||||
"seed": 42,
|
||||
},
|
||||
progress_callback=lambda p: print(f"Progress: {p:.0%}"),
|
||||
)
|
||||
|
||||
with open("output.png", "wb") as f:
|
||||
f.write(result.as_bytes("output_image"))
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```python
|
||||
# 동기 사용
|
||||
from comfybridgeio import ComfyBridgeSync
|
||||
|
||||
bridge = ComfyBridgeSync()
|
||||
result = bridge.run("workflow_api.json", inputs={"prompt": "a dog"})
|
||||
result.as_pil("output_image").show()
|
||||
```
|
||||
|
||||
## 노드 카탈로그
|
||||
|
||||
| 노드 | 설명 |
|
||||
|------|------|
|
||||
| Bridge Input (STRING) | 텍스트 주입 포인트 |
|
||||
| Bridge Input (INT) | 정수 주입 포인트 |
|
||||
| Bridge Input (FLOAT) | 실수 주입 포인트 |
|
||||
| Bridge Input (BOOLEAN) | 불리언 주입 포인트 |
|
||||
| Bridge Input (IMAGE) | 이미지 주입 포인트 |
|
||||
| Bridge Input (MASK) | 마스크 주입 포인트 |
|
||||
| Bridge Input (LATENT) | 잠재 벡터 주입 포인트 |
|
||||
| Bridge Input (MODEL) | 모델 주입 포인트 |
|
||||
| Bridge Input (CLIP) | CLIP 주입 포인트 |
|
||||
| Bridge Input (VAE) | VAE 주입 포인트 |
|
||||
| Bridge Input (CONDITIONING) | 컨디셔닝 주입 포인트 |
|
||||
| Bridge Output | 결과 태깅 및 회수 포인트 |
|
||||
|
||||
## 커스텀 API
|
||||
|
||||
ComfyUI 서버에 다음 라우트가 추가된다:
|
||||
|
||||
```
|
||||
GET /comfybridge/results/{prompt_id}
|
||||
→ { "outputs": { "name": value, ... } }
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
ComfyBridgeIO — ComfyUI 커스텀 노드 패키지.
|
||||
|
||||
ComfyUI는 이 파일을 로드하여 NODE_CLASS_MAPPINGS와
|
||||
NODE_DISPLAY_NAME_MAPPINGS를 가져간다.
|
||||
"""
|
||||
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
from .nodes.server_routes import register_routes
|
||||
|
||||
register_routes()
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .bridge import ComfyBridge, ComfyBridgeError
|
||||
from .bridge_sync import ComfyBridgeSync
|
||||
from .result import BridgeResult
|
||||
|
||||
__all__ = ["ComfyBridge", "ComfyBridgeSync", "ComfyBridgeError", "BridgeResult"]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""ComfyBridge — ComfyUI 워크플로우 실행 클라이언트 (async)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .result import BridgeResult
|
||||
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: str | dict | Path,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
) -> BridgeResult:
|
||||
"""
|
||||
워크플로우를 실행하고 결과를 반환한다.
|
||||
|
||||
Args:
|
||||
workflow: 워크플로우 파일 경로 또는 파싱된 dict
|
||||
inputs: BridgeInput 노드에 주입할 값 딕셔너리
|
||||
progress_callback: 진행률(0.0~1.0)을 받는 콜백 함수
|
||||
|
||||
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)
|
||||
|
||||
# 결과 수집
|
||||
outputs = await self._collect_outputs(session, prompt_id)
|
||||
|
||||
return BridgeResult(outputs)
|
||||
|
||||
async def upload_image(
|
||||
self,
|
||||
image: 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 _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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""ComfyBridgeSync — asyncio가 없는 환경을 위한 동기 래퍼."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .bridge import ComfyBridge
|
||||
from .result import BridgeResult
|
||||
|
||||
|
||||
class ComfyBridgeSync:
|
||||
"""
|
||||
ComfyBridge의 동기 래퍼.
|
||||
|
||||
사용 예시::
|
||||
|
||||
bridge = ComfyBridgeSync(host="localhost", port=8188)
|
||||
result = bridge.run("workflow_api.json", inputs={"prompt": "a cat"})
|
||||
image = result.as_pil("output_image")
|
||||
image.show()
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "localhost", port: int = 8188, timeout: float = 300.0):
|
||||
self._bridge = ComfyBridge(host=host, port=port, timeout=timeout)
|
||||
|
||||
def run(
|
||||
self,
|
||||
workflow: str | dict | Path,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
) -> BridgeResult:
|
||||
return asyncio.run(self._bridge.run(workflow, inputs, progress_callback))
|
||||
|
||||
def upload_image(self, image: bytes | str | Path, filename: str = "input.png") -> str:
|
||||
return asyncio.run(self._bridge.upload_image(image, filename))
|
||||
@@ -0,0 +1,47 @@
|
||||
"""BridgeResult — 워크플로우 실행 결과 컨테이너."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BridgeResult:
|
||||
"""
|
||||
워크플로우 실행 결과를 담는 컨테이너.
|
||||
|
||||
BridgeOutput 노드에 지정한 이름으로 값을 회수할 수 있다.
|
||||
"""
|
||||
|
||||
def __init__(self, outputs: dict[str, Any]):
|
||||
self._outputs = outputs
|
||||
|
||||
def get(self, name: str, default: Any = None) -> Any:
|
||||
"""이름으로 결과를 반환한다."""
|
||||
return self._outputs.get(name, default)
|
||||
|
||||
def keys(self) -> list[str]:
|
||||
"""사용 가능한 출력 이름 목록을 반환한다."""
|
||||
return list(self._outputs.keys())
|
||||
|
||||
def as_pil(self, name: str):
|
||||
"""IMAGE 타입 결과를 PIL Image로 반환한다."""
|
||||
from PIL import Image
|
||||
value = self._outputs[name]
|
||||
if isinstance(value, bytes):
|
||||
return Image.open(io.BytesIO(value))
|
||||
if isinstance(value, dict) and value.get("__type__") == "image":
|
||||
data = base64.b64decode(value["data"])
|
||||
return Image.open(io.BytesIO(data))
|
||||
raise TypeError(f"'{name}' is not an image type: {type(value)}")
|
||||
|
||||
def as_bytes(self, name: str) -> bytes:
|
||||
"""IMAGE 타입 결과를 PNG bytes로 반환한다."""
|
||||
value = self._outputs[name]
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if isinstance(value, dict) and value.get("__type__") == "image":
|
||||
return base64.b64decode(value["data"])
|
||||
raise TypeError(f"'{name}' is not an image type: {type(value)}")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"BridgeResult(keys={self.keys()})"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""타입 상수 및 Python ↔ ComfyUI 값 변환 유틸리티."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
PRIMITIVE_TYPES = {"STRING", "INT", "FLOAT", "BOOLEAN"}
|
||||
COMPLEX_TYPES = {"IMAGE", "MASK", "LATENT", "MODEL", "CLIP", "VAE", "CONDITIONING"}
|
||||
ALL_TYPES = PRIMITIVE_TYPES | COMPLEX_TYPES
|
||||
|
||||
|
||||
def convert_value(value: Any, type_name: str) -> Any:
|
||||
"""
|
||||
Python 값을 ComfyUI 워크플로우 JSON에 주입 가능한 형태로 변환한다.
|
||||
복합 타입(IMAGE 등)은 업로드 후 파일명(str)을 그대로 전달한다.
|
||||
"""
|
||||
if type_name == "STRING":
|
||||
return str(value)
|
||||
if type_name == "INT":
|
||||
return int(value)
|
||||
if type_name == "FLOAT":
|
||||
return float(value)
|
||||
if type_name == "BOOLEAN":
|
||||
return bool(value)
|
||||
# 복합 타입: 노드 링크 [node_id, idx] 또는 서버 파일명(str)을 그대로 반환
|
||||
return value
|
||||
@@ -0,0 +1,53 @@
|
||||
"""WebSocket 통신 및 워크플로우 완료 대기."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def wait_for_completion(
|
||||
ws,
|
||||
target_prompt_id: str,
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
WebSocket 메시지를 수신하며 워크플로우 완료를 기다린다.
|
||||
|
||||
완료 신호: { "type": "executing", "data": { "node": null } }
|
||||
에러 신호: { "type": "execution_error" }
|
||||
"""
|
||||
async for raw in ws:
|
||||
try:
|
||||
if isinstance(raw, bytes):
|
||||
continue # 바이너리 프리뷰 메시지 무시
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = data.get("type")
|
||||
msg_data = data.get("data", {})
|
||||
|
||||
# 다른 prompt_id 메시지 무시
|
||||
pid = msg_data.get("prompt_id")
|
||||
if pid and pid != target_prompt_id:
|
||||
continue
|
||||
|
||||
if msg_type == "executing":
|
||||
node = msg_data.get("node")
|
||||
if node is None:
|
||||
logger.debug(f"[ComfyBridgeIO] Execution complete: {target_prompt_id}")
|
||||
return # 완료
|
||||
|
||||
elif msg_type == "progress" and progress_callback:
|
||||
value = msg_data.get("value", 0)
|
||||
maximum = msg_data.get("max", 1)
|
||||
if maximum > 0:
|
||||
progress_callback(value / maximum)
|
||||
|
||||
elif msg_type == "execution_error":
|
||||
err = msg_data.get("exception_message", "Unknown error")
|
||||
raise RuntimeError(f"[ComfyBridgeIO] Execution error: {err}")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""워크플로우 JSON 파서 및 BridgeInput 값 패처."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .types import convert_value
|
||||
|
||||
BRIDGE_INPUT_PREFIX = "BridgeInput"
|
||||
|
||||
|
||||
def load_workflow(source: str | dict | Path) -> dict:
|
||||
"""워크플로우를 파일 경로 또는 dict로 로드한다."""
|
||||
if isinstance(source, dict):
|
||||
return source
|
||||
path = Path(source)
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def find_bridge_inputs(workflow: dict) -> dict[str, tuple[str, str]]:
|
||||
"""
|
||||
워크플로우에서 BridgeInput 노드를 탐색한다.
|
||||
|
||||
Returns:
|
||||
{ "input_name": (node_id, type_name) }
|
||||
예: { "prompt": ("1", "STRING"), "steps": ("3", "INT") }
|
||||
"""
|
||||
result: dict[str, tuple[str, str]] = {}
|
||||
for node_id, node in workflow.items():
|
||||
class_type = node.get("class_type", "")
|
||||
if class_type.startswith(BRIDGE_INPUT_PREFIX):
|
||||
name = node.get("inputs", {}).get("name")
|
||||
type_name = class_type[len(BRIDGE_INPUT_PREFIX):]
|
||||
if name:
|
||||
result[name] = (node_id, type_name)
|
||||
return result
|
||||
|
||||
|
||||
def patch_inputs(workflow: dict, inputs: dict[str, Any]) -> dict:
|
||||
"""
|
||||
원본 워크플로우를 deepcopy 후 BridgeInput 노드의 value를 패치한다.
|
||||
원본 파일은 절대 수정하지 않는다.
|
||||
|
||||
Args:
|
||||
workflow: ComfyUI API 포맷 워크플로우 dict
|
||||
inputs: { "name": value } 주입할 값 딕셔너리
|
||||
|
||||
Raises:
|
||||
ValueError: 워크플로우에 해당 이름의 BridgeInput 노드가 없을 때
|
||||
"""
|
||||
patched = copy.deepcopy(workflow)
|
||||
bridge_map = find_bridge_inputs(patched)
|
||||
|
||||
for name, value in inputs.items():
|
||||
if name not in bridge_map:
|
||||
available = list(bridge_map.keys())
|
||||
raise ValueError(
|
||||
f"BridgeInput '{name}' not found in workflow. "
|
||||
f"Available inputs: {available}"
|
||||
)
|
||||
node_id, type_name = bridge_map[name]
|
||||
converted = convert_value(value, type_name)
|
||||
patched[node_id]["inputs"]["value"] = converted
|
||||
|
||||
return patched
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
AI Agent 루프 예제.
|
||||
AI가 여러 번 워크플로우를 호출하며 결과를 평가하는 패턴.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from comfybridgeio import ComfyBridge
|
||||
|
||||
|
||||
async def generate_image(bridge: ComfyBridge, prompt: str, seed: int) -> bytes:
|
||||
result = await bridge.run(
|
||||
workflow="workflows/sdxl_bridge_template.json",
|
||||
inputs={"prompt": prompt, "seed": seed, "steps": 25},
|
||||
)
|
||||
return result.as_bytes("output_image")
|
||||
|
||||
|
||||
async def main():
|
||||
bridge = ComfyBridge(host="localhost", port=8188)
|
||||
prompts = [
|
||||
"a red apple on a wooden table, photorealistic",
|
||||
"a blue ocean at sunset, cinematic",
|
||||
"a futuristic city at night, neon lights",
|
||||
]
|
||||
|
||||
for i, prompt in enumerate(prompts):
|
||||
print(f"Generating [{i+1}/{len(prompts)}]: {prompt[:50]}...")
|
||||
img_bytes = await generate_image(bridge, prompt, seed=i * 1000)
|
||||
out_path = f"output_{i}.png"
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
print(f" Saved: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
기본 텍스트-이미지 예제.
|
||||
|
||||
실행 전: ComfyUI에서 sdxl_bridge_template.json 을 열어
|
||||
Bridge Input / Output 노드가 연결된 상태로 API Format으로 내보낼 것.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from comfybridgeio import ComfyBridge
|
||||
|
||||
|
||||
async def main():
|
||||
bridge = ComfyBridge(host="localhost", port=8188)
|
||||
|
||||
print("Running workflow...")
|
||||
result = await bridge.run(
|
||||
workflow="workflows/sdxl_bridge_template.json",
|
||||
inputs={
|
||||
"prompt": "a majestic cat in outer space, photorealistic, 8k",
|
||||
"negative_prompt": "blurry, low quality, deformed",
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"seed": 12345,
|
||||
},
|
||||
progress_callback=lambda p: print(f" Progress: {p:.0%}", end="\r"),
|
||||
)
|
||||
|
||||
print(f"\nDone! Available outputs: {result.keys()}")
|
||||
|
||||
if result.get("output_image"):
|
||||
with open("output.png", "wb") as f:
|
||||
f.write(result.as_bytes("output_image"))
|
||||
print("Saved: output.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"__comment": "이 파일은 예시 템플릿입니다. ComfyUI에서 실제 워크플로우를 API Format으로 내보내 교체하세요.",
|
||||
"__instructions": "Bridge Input 노드와 Bridge Output 노드를 연결한 뒤 Save (API Format)으로 내보내세요."
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
from .bridge_input import BRIDGE_INPUT_CLASSES, SUPPORTED_TYPES
|
||||
from .bridge_output import BridgeOutput
|
||||
|
||||
NODE_CLASS_MAPPINGS: dict[str, type] = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {}
|
||||
|
||||
# BridgeInput 타입별 등록
|
||||
for node_id, cls in BRIDGE_INPUT_CLASSES.items():
|
||||
type_name = node_id[len("BridgeInput"):]
|
||||
NODE_CLASS_MAPPINGS[node_id] = cls
|
||||
NODE_DISPLAY_NAME_MAPPINGS[node_id] = f"Bridge Input ({type_name})"
|
||||
|
||||
# BridgeOutput 등록
|
||||
NODE_CLASS_MAPPINGS["BridgeOutput"] = BridgeOutput
|
||||
NODE_DISPLAY_NAME_MAPPINGS["BridgeOutput"] = "Bridge Output"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,9 @@
|
||||
class AnyType(str):
|
||||
"""
|
||||
ComfyUI 타입 검증을 통과하는 와일드카드 타입.
|
||||
__ne__를 항상 False로 반환하여 어떤 타입과도 동등 비교된다.
|
||||
"""
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return False
|
||||
|
||||
ANY = AnyType("*")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
BridgeInput 노드 — 워크플로우에서 외부 주입 가능한 입력 포인트.
|
||||
|
||||
클라이언트는 class_type이 "BridgeInput"으로 시작하는 노드를 탐색하고
|
||||
inputs.name 과 입력 딕셔너리 키를 매칭한 뒤 inputs.value를 패치한다.
|
||||
"""
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"STRING": ("STRING", {"default": "", "multiline": True}),
|
||||
"INT": ("INT", {"default": 0, "min": -2**31, "max": 2**31}),
|
||||
"FLOAT": ("FLOAT", {"default": 0.0, "min": -1e9, "max": 1e9}),
|
||||
"BOOLEAN": ("BOOLEAN", {"default": False}),
|
||||
"IMAGE": ("IMAGE", {}),
|
||||
"MASK": ("MASK", {}),
|
||||
"LATENT": ("LATENT", {}),
|
||||
"MODEL": ("MODEL", {}),
|
||||
"CLIP": ("CLIP", {}),
|
||||
"VAE": ("VAE", {}),
|
||||
"CONDITIONING": ("CONDITIONING", {}),
|
||||
}
|
||||
|
||||
_PRIMITIVE_TYPES = {"STRING", "INT", "FLOAT", "BOOLEAN"}
|
||||
|
||||
|
||||
def make_bridge_input(type_name: str, comfy_type: str, widget_config: dict):
|
||||
"""타입별 BridgeInput 노드 클래스를 동적으로 생성한다."""
|
||||
is_primitive = type_name in _PRIMITIVE_TYPES
|
||||
|
||||
class BridgeInputNode:
|
||||
CATEGORY = "ComfyBridgeIO"
|
||||
FUNCTION = "passthrough"
|
||||
RETURN_TYPES = (comfy_type,)
|
||||
RETURN_NAMES = ("value",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
inputs = {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "input_name"}),
|
||||
}
|
||||
}
|
||||
if is_primitive:
|
||||
inputs["required"]["value"] = (comfy_type, widget_config)
|
||||
else:
|
||||
inputs["optional"] = {"value": (comfy_type, {})}
|
||||
return inputs
|
||||
|
||||
def passthrough(self, name: str, value=None):
|
||||
return (value,)
|
||||
|
||||
BridgeInputNode.__name__ = f"BridgeInput{type_name}"
|
||||
BridgeInputNode.__qualname__ = f"BridgeInput{type_name}"
|
||||
return BridgeInputNode
|
||||
|
||||
|
||||
# 모든 타입의 BridgeInput 클래스를 미리 생성
|
||||
BRIDGE_INPUT_CLASSES: dict[str, type] = {}
|
||||
for _type_name, (_comfy_type, _widget_cfg) in SUPPORTED_TYPES.items():
|
||||
_cls = make_bridge_input(_type_name, _comfy_type, _widget_cfg)
|
||||
BRIDGE_INPUT_CLASSES[f"BridgeInput{_type_name}"] = _cls
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
BridgeOutput 노드 — 워크플로우 결과를 이름으로 태깅하는 출력 포인트.
|
||||
|
||||
OUTPUT_NODE = True 로 선언하여 ComfyUI가 실행을 보장.
|
||||
실행 결과는 서버 메모리(server_routes.store_bridge_result)에 저장되며
|
||||
GET /comfybridge/results/{prompt_id} 로 회수 가능.
|
||||
"""
|
||||
|
||||
from .any_type import ANY
|
||||
from .server_routes import store_bridge_result
|
||||
|
||||
|
||||
class BridgeOutput:
|
||||
CATEGORY = "ComfyBridgeIO"
|
||||
FUNCTION = "capture"
|
||||
OUTPUT_NODE = True
|
||||
RETURN_TYPES = ()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "output_name"}),
|
||||
"value": (ANY, {}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt_id": "PROMPT_ID",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
def capture(self, name: str, value, prompt_id: str, unique_id: str):
|
||||
store_bridge_result(prompt_id=prompt_id, name=name, value=value)
|
||||
return {}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
ComfyUI PromptServer에 마운트되는 커스텀 라우트.
|
||||
|
||||
GET /comfybridge/results/{prompt_id}
|
||||
→ BridgeOutput 노드가 저장한 결과를 JSON으로 반환.
|
||||
→ IMAGE 타입은 base64 PNG 문자열로 직렬화.
|
||||
→ 스칼라 타입은 JSON 네이티브 값.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 결과 저장소: { prompt_id: { name: value } }
|
||||
_MAX_ENTRIES = 100
|
||||
_results_store: OrderedDict[str, dict] = OrderedDict()
|
||||
|
||||
|
||||
def store_bridge_result(prompt_id: str, name: str, value) -> None:
|
||||
"""BridgeOutput 노드 실행 시 결과를 저장한다."""
|
||||
if prompt_id not in _results_store:
|
||||
_results_store[prompt_id] = {}
|
||||
# LRU: 최대 개수 초과 시 가장 오래된 항목 제거
|
||||
if len(_results_store) > _MAX_ENTRIES:
|
||||
_results_store.popitem(last=False)
|
||||
_results_store[prompt_id][name] = value
|
||||
logger.debug(f"[ComfyBridgeIO] stored result: prompt_id={prompt_id}, name={name}")
|
||||
|
||||
|
||||
def _serialize_value(value) -> object:
|
||||
"""결과 값을 JSON 직렬화 가능한 형태로 변환한다."""
|
||||
# torch.Tensor (IMAGE/LATENT 등)
|
||||
try:
|
||||
import torch
|
||||
if isinstance(value, torch.Tensor):
|
||||
# IMAGE: [B, H, W, C] float32 → PNG bytes → base64
|
||||
if value.dim() == 4:
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
img_np = (value[0].cpu().numpy() * 255).clip(0, 255).astype("uint8")
|
||||
img = Image.fromarray(img_np)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return {"__type__": "image", "data": base64.b64encode(buf.getvalue()).decode()}
|
||||
return {"__type__": "tensor", "shape": list(value.shape)}
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# PIL Image
|
||||
try:
|
||||
from PIL import Image
|
||||
if isinstance(value, Image.Image):
|
||||
buf = io.BytesIO()
|
||||
value.save(buf, format="PNG")
|
||||
return {"__type__": "image", "data": base64.b64encode(buf.getvalue()).decode()}
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# JSON 네이티브 타입
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
|
||||
# 기타: repr 반환
|
||||
return {"__type__": "unsupported", "repr": repr(value)}
|
||||
|
||||
|
||||
def register_routes():
|
||||
"""PromptServer에 커스텀 라우트를 등록한다."""
|
||||
try:
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
@PromptServer.instance.routes.get("/comfybridge/results/{prompt_id}")
|
||||
async def get_bridge_results(request: web.Request) -> web.Response:
|
||||
prompt_id = request.match_info["prompt_id"]
|
||||
if prompt_id not in _results_store:
|
||||
return web.Response(
|
||||
status=404,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"error": f"No results for prompt_id '{prompt_id}'"}),
|
||||
)
|
||||
outputs = {
|
||||
name: _serialize_value(val)
|
||||
for name, val in _results_store[prompt_id].items()
|
||||
}
|
||||
return web.Response(
|
||||
content_type="application/json",
|
||||
body=json.dumps({"outputs": outputs}),
|
||||
)
|
||||
|
||||
logger.info("[ComfyBridgeIO] Registered route: GET /comfybridge/results/{prompt_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ComfyBridgeIO] Failed to register routes: {e}")
|
||||
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "comfybridgeio"
|
||||
version = "0.1.0"
|
||||
description = "ComfyUI dynamic I/O bridge for AI agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
keywords = ["comfyui", "stable-diffusion", "ai-agent", "workflow-automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"aiohttp>=3.9.0",
|
||||
"websockets>=12.0",
|
||||
"Pillow>=10.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"aioresponses>=0.7.6",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-mock>=3.12.0
|
||||
aioresponses>=0.7.6
|
||||
@@ -0,0 +1,3 @@
|
||||
aiohttp>=3.9.0
|
||||
websockets>=12.0
|
||||
Pillow>=10.0.0
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "BridgeInputSTRING",
|
||||
"inputs": {
|
||||
"name": "prompt",
|
||||
"value": "default prompt"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "BridgeInputINT",
|
||||
"inputs": {
|
||||
"name": "steps",
|
||||
"value": 20
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "BridgeInputFLOAT",
|
||||
"inputs": {
|
||||
"name": "cfg",
|
||||
"value": 7.0
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "BridgeOutput",
|
||||
"inputs": {
|
||||
"name": "output_image",
|
||||
"value": ["8", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import pytest
|
||||
from client.result import BridgeResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_result_get():
|
||||
result = BridgeResult({"caption": "hello", "score": 0.95})
|
||||
assert result.get("caption") == "hello"
|
||||
assert result.get("score") == 0.95
|
||||
assert result.get("missing") is None
|
||||
assert result.keys() == ["caption", "score"]
|
||||
@@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from client.workflow import find_bridge_inputs, patch_inputs, load_workflow
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "sample_workflow.json"
|
||||
|
||||
|
||||
def test_find_bridge_inputs():
|
||||
wf = load_workflow(FIXTURE_PATH)
|
||||
bridge_map = find_bridge_inputs(wf)
|
||||
assert "prompt" in bridge_map
|
||||
assert "steps" in bridge_map
|
||||
assert "cfg" in bridge_map
|
||||
assert bridge_map["prompt"] == ("1", "STRING")
|
||||
assert bridge_map["steps"] == ("2", "INT")
|
||||
|
||||
|
||||
def test_patch_inputs():
|
||||
wf = load_workflow(FIXTURE_PATH)
|
||||
patched = patch_inputs(wf, {"prompt": "hello world", "steps": 30})
|
||||
assert patched["1"]["inputs"]["value"] == "hello world"
|
||||
assert patched["2"]["inputs"]["value"] == 30
|
||||
# 원본 불변 확인
|
||||
original = load_workflow(FIXTURE_PATH)
|
||||
assert original["1"]["inputs"]["value"] == "default prompt"
|
||||
|
||||
|
||||
def test_patch_unknown_input_raises():
|
||||
wf = load_workflow(FIXTURE_PATH)
|
||||
with pytest.raises(ValueError, match="not found in workflow"):
|
||||
patch_inputs(wf, {"nonexistent": "value"})
|
||||
Reference in New Issue
Block a user