From 9a2bcdd4fa652d66348fe0225bff39d09c469f34 Mon Sep 17 00:00:00 2001 From: cbpark84 Date: Thu, 16 Jul 2026 10:45:26 +0900 Subject: [PATCH] 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 --- .gitignore | 15 + ARCHITECTURE.md | 623 +++++++++++++++++++ README.md | 101 +++ __init__.py | 13 + client/__init__.py | 5 + client/bridge.py | 165 +++++ client/bridge_sync.py | 36 ++ client/result.py | 47 ++ client/types.py | 24 + client/websocket_client.py | 53 ++ client/workflow.py | 67 ++ examples/__init__.py | 0 examples/agent_loop.py | 36 ++ examples/basic_text_to_image.py | 37 ++ examples/workflows/sdxl_bridge_template.json | 4 + nodes/__init__.py | 17 + nodes/any_type.py | 9 + nodes/bridge_input.py | 60 ++ nodes/bridge_output.py | 34 + nodes/server_routes.py | 97 +++ pyproject.toml | 33 + requirements-dev.txt | 5 + requirements.txt | 3 + tests/__init__.py | 0 tests/fixtures/sample_workflow.json | 30 + tests/test_bridge_client.py | 11 + tests/test_workflow_parser.py | 31 + 27 files changed, 1556 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 __init__.py create mode 100644 client/__init__.py create mode 100644 client/bridge.py create mode 100644 client/bridge_sync.py create mode 100644 client/result.py create mode 100644 client/types.py create mode 100644 client/websocket_client.py create mode 100644 client/workflow.py create mode 100644 examples/__init__.py create mode 100644 examples/agent_loop.py create mode 100644 examples/basic_text_to_image.py create mode 100644 examples/workflows/sdxl_bridge_template.json create mode 100644 nodes/__init__.py create mode 100644 nodes/any_type.py create mode 100644 nodes/bridge_input.py create mode 100644 nodes/bridge_output.py create mode 100644 nodes/server_routes.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/fixtures/sample_workflow.json create mode 100644 tests/test_bridge_client.py create mode 100644 tests/test_workflow_parser.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29368aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.so +.env +.venv/ +venv/ +dist/ +build/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +*.png +*.jpg +*.jpeg +outputs/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..473fa7a --- /dev/null +++ b/ARCHITECTURE.md @@ -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": , ... } } + → 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` 포함 여부) | 포함 / 미포함 | diff --git a/README.md b/README.md new file mode 100644 index 0000000..cabacca --- /dev/null +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..275c7b4 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/client/__init__.py b/client/__init__.py new file mode 100644 index 0000000..fa8e911 --- /dev/null +++ b/client/__init__.py @@ -0,0 +1,5 @@ +from .bridge import ComfyBridge, ComfyBridgeError +from .bridge_sync import ComfyBridgeSync +from .result import BridgeResult + +__all__ = ["ComfyBridge", "ComfyBridgeSync", "ComfyBridgeError", "BridgeResult"] diff --git a/client/bridge.py b/client/bridge.py new file mode 100644 index 0000000..cf11bc5 --- /dev/null +++ b/client/bridge.py @@ -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 diff --git a/client/bridge_sync.py b/client/bridge_sync.py new file mode 100644 index 0000000..0acfbe0 --- /dev/null +++ b/client/bridge_sync.py @@ -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)) diff --git a/client/result.py b/client/result.py new file mode 100644 index 0000000..25fd44d --- /dev/null +++ b/client/result.py @@ -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()})" diff --git a/client/types.py b/client/types.py new file mode 100644 index 0000000..8f01616 --- /dev/null +++ b/client/types.py @@ -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 diff --git a/client/websocket_client.py b/client/websocket_client.py new file mode 100644 index 0000000..c2e407d --- /dev/null +++ b/client/websocket_client.py @@ -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}") diff --git a/client/workflow.py b/client/workflow.py new file mode 100644 index 0000000..3211fed --- /dev/null +++ b/client/workflow.py @@ -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 diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/agent_loop.py b/examples/agent_loop.py new file mode 100644 index 0000000..f335ef7 --- /dev/null +++ b/examples/agent_loop.py @@ -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()) diff --git a/examples/basic_text_to_image.py b/examples/basic_text_to_image.py new file mode 100644 index 0000000..16efa32 --- /dev/null +++ b/examples/basic_text_to_image.py @@ -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()) diff --git a/examples/workflows/sdxl_bridge_template.json b/examples/workflows/sdxl_bridge_template.json new file mode 100644 index 0000000..3c3b5ab --- /dev/null +++ b/examples/workflows/sdxl_bridge_template.json @@ -0,0 +1,4 @@ +{ + "__comment": "이 파일은 예시 템플릿입니다. ComfyUI에서 실제 워크플로우를 API Format으로 내보내 교체하세요.", + "__instructions": "Bridge Input 노드와 Bridge Output 노드를 연결한 뒤 Save (API Format)으로 내보내세요." +} diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..fb4a237 --- /dev/null +++ b/nodes/__init__.py @@ -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"] diff --git a/nodes/any_type.py b/nodes/any_type.py new file mode 100644 index 0000000..88296de --- /dev/null +++ b/nodes/any_type.py @@ -0,0 +1,9 @@ +class AnyType(str): + """ + ComfyUI 타입 검증을 통과하는 와일드카드 타입. + __ne__를 항상 False로 반환하여 어떤 타입과도 동등 비교된다. + """ + def __ne__(self, other: object) -> bool: + return False + +ANY = AnyType("*") diff --git a/nodes/bridge_input.py b/nodes/bridge_input.py new file mode 100644 index 0000000..3fa9183 --- /dev/null +++ b/nodes/bridge_input.py @@ -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 diff --git a/nodes/bridge_output.py b/nodes/bridge_output.py new file mode 100644 index 0000000..6f33e44 --- /dev/null +++ b/nodes/bridge_output.py @@ -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 {} diff --git a/nodes/server_routes.py b/nodes/server_routes.py new file mode 100644 index 0000000..1a92dca --- /dev/null +++ b/nodes/server_routes.py @@ -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}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ceb5d7 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..620376e --- /dev/null +++ b/requirements-dev.txt @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..922caac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiohttp>=3.9.0 +websockets>=12.0 +Pillow>=10.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/sample_workflow.json b/tests/fixtures/sample_workflow.json new file mode 100644 index 0000000..5158a91 --- /dev/null +++ b/tests/fixtures/sample_workflow.json @@ -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] + } + } +} diff --git a/tests/test_bridge_client.py b/tests/test_bridge_client.py new file mode 100644 index 0000000..ec5225e --- /dev/null +++ b/tests/test_bridge_client.py @@ -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"] diff --git a/tests/test_workflow_parser.py b/tests/test_workflow_parser.py new file mode 100644 index 0000000..ff0f12a --- /dev/null +++ b/tests/test_workflow_parser.py @@ -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"})