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:
cbpark84
2026-07-16 10:45:26 +09:00
co-authored by Claude Sonnet 4.6
commit 9a2bcdd4fa
27 changed files with 1556 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.so
.env
.venv/
venv/
dist/
build/
*.egg-info/
.pytest_cache/
.ruff_cache/
*.png
*.jpg
*.jpeg
outputs/
+623
View File
@@ -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` 포함 여부) | 포함 / 미포함 |
+101
View File
@@ -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
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
from .bridge import ComfyBridge, ComfyBridgeError
from .bridge_sync import ComfyBridgeSync
from .result import BridgeResult
__all__ = ["ComfyBridge", "ComfyBridgeSync", "ComfyBridgeError", "BridgeResult"]
+165
View File
@@ -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
+36
View File
@@ -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))
+47
View File
@@ -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()})"
+24
View File
@@ -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
+53
View File
@@ -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}")
+67
View File
@@ -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
View File
+36
View File
@@ -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())
+37
View File
@@ -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)으로 내보내세요."
}
+17
View File
@@ -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"]
+9
View File
@@ -0,0 +1,9 @@
class AnyType(str):
"""
ComfyUI 타입 검증을 통과하는 와일드카드 타입.
__ne__를 항상 False로 반환하여 어떤 타입과도 동등 비교된다.
"""
def __ne__(self, other: object) -> bool:
return False
ANY = AnyType("*")
+60
View File
@@ -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
+34
View File
@@ -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 {}
+97
View File
@@ -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}")
+33
View File
@@ -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"
+5
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
aiohttp>=3.9.0
websockets>=12.0
Pillow>=10.0.0
View File
+30
View File
@@ -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]
}
}
}
+11
View File
@@ -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"]
+31
View File
@@ -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"})