Files

343 lines
14 KiB
Python

import asyncio
import json
import uuid
import aiohttp
from PIL import Image
import io
import hashlib
from typing import Dict, Any, List, Union
class WorkflowExecutionError(Exception):
"""自定义异常,用于表示 ComfyUI 工作流执行期间的错误。"""
def __init__(self, message, node_id, node_type, exception_message):
super().__init__(message)
self.node_id = node_id
self.node_type = node_type
self.exception_message = exception_message
def __str__(self):
return f"{super().__str__()} - Node ID: {self.node_id}, Type: {self.node_type}, Details: {self.exception_message}"
class ComfyClient:
"""
一个用于与 ComfyUI 服务器交互的客户端。
"""
def __init__(self, server_base: str, timeout: int = 600):
"""
初始化 ComfyClient。
:param server_base: ComfyUI 服务器的基础地址 (例如 "http://127.0.0.1:8188")。
:param timeout: WebSocket 接收消息和执行工作流的超时时间(秒)。
"""
self.server_base = server_base
self.timeout = timeout
self.base_url = server_base.rstrip("/")
ws_base = self.base_url.replace("http://", "ws://").replace("https://", "wss://")
self.ws_url = f"{ws_base}/ws"
async def _upload_image(self, image: Image.Image, session: aiohttp.ClientSession) -> str:
"""
将 PIL.Image 对象上传到 ComfyUI 服务器。
:param image: 要上传的 PIL.Image 对象。
:param session: aiohttp.ClientSession 对象。
:return: 服务器上保存的文件名。
"""
image_bytes = io.BytesIO()
image.save(image_bytes, format="PNG")
image_bytes.seek(0)
hash = hashlib.sha256(image_bytes.getvalue()).hexdigest()
filename = f"{hash}.png"
image_bytes.seek(0)
form_data = aiohttp.FormData()
form_data.add_field("image", image_bytes, filename=filename, content_type="image/png")
form_data.add_field("type", "input")
form_data.add_field("overwrite", "true")
async with session.post(f"{self.base_url}/upload/image", data=form_data) as response:
response.raise_for_status()
result = await response.json()
return result["name"]
async def _get_image(self, filename: str, subfolder: str, folder_type: str, session: aiohttp.ClientSession) -> Image.Image:
"""
从 ComfyUI 服务器下载图片。
:param filename: 文件名。
:param subfolder: 子文件夹。
:param folder_type: 文件夹类型 ('input', 'output', 'temp')。
:param session: aiohttp.ClientSession 对象。
:return: 下载的 PIL.Image 对象。
"""
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
async with session.get(f"{self.base_url}/view", params=params) as response:
response.raise_for_status()
image_data = await response.read()
return Image.open(io.BytesIO(image_data))
def _find_nodes(self, workflow: dict) -> tuple[dict, dict]:
"""
在工作流中查找用 % 标记的输入和输出节点。
:param workflow: 工作流字典。
:return: (输入节点映射, 输出节点映射) 元组。
"""
input_map = {} # key: placeholder_name, value: node_id
output_map = {} # key: node_id, value: placeholder_name
for node_id, node_data in workflow.items():
meta_title = node_data.get("_meta", {}).get("title")
if meta_title and meta_title.startswith("%") and meta_title.endswith("%"):
name = meta_title.strip("%")
# 假设所有带 % 标记的节点都可能是输出
output_map[node_id] = name
# 如果节点类型是基本类型或 LoadImage,则认为是输入
if node_data["class_type"] in [
"PrimitiveBoolean",
"PrimitiveFloat",
"PrimitiveInt",
"PrimitiveString",
"PrimitiveStringMultiline",
"LoadImage",
]:
input_map[name] = node_id
return input_map, output_map
async def _prepare_workflow(self, workflow: dict, session: aiohttp.ClientSession, **kwargs) -> tuple[dict, dict]:
"""
准备工作流:验证并注入参数,处理图片上传。
:param workflow: 原始工作流模板。
:param session: aiohttp.ClientSession 对象。
:param kwargs: 用户提供的参数。
:return: (准备好的工作流, 输出节点映射) 元组。
"""
prepared_workflow = json.loads(json.dumps(workflow))
input_map, output_map = self._find_nodes(prepared_workflow)
for name, value in kwargs.items():
if name not in input_map:
raise ValueError(f"输入参数 '{name}' 在工作流中没有找到对应的 %{name}% 节点。")
node_id = input_map[name]
node = prepared_workflow[node_id]
if node["class_type"] == "LoadImage":
if isinstance(value, str): # 路径
with open(value, "rb") as f:
pil_image = Image.open(io.BytesIO(f.read()))
filename = await self._upload_image(pil_image, session)
elif isinstance(value, Image.Image):
filename = await self._upload_image(value, session)
else:
raise TypeError(f"LoadImage 输入 '{name}' 的类型不受支持: {type(value).__name__}。需要 str (路径) 或 PIL.Image 对象。")
node["inputs"]["image"] = filename
else:
if "value" not in node["inputs"]:
raise ValueError(f"节点 '{node_id}' ('{name}') 被标记为输入,但在其 inputs 中没有 'value' 字段。")
default_value = node["inputs"]["value"]
if not isinstance(value, type(default_value)) and default_value is not None:
raise TypeError(
f"输入 '{name}' 的类型不匹配。期望类型: {type(default_value).__name__}, 得到类型: {type(value).__name__}。"
)
node["inputs"]["value"] = value
return prepared_workflow, output_map
async def run_workflow(self, workflow: dict, **kwargs) -> Dict[str, List[Any]]:
"""
异步执行一个 ComfyUI 工作流。
:param workflow: 工作流模板 (Python 字典)。
:param kwargs: 要注入工作流的参数。键对应模板中被 % 包围的节点标题。
:return: 一个字典,键是输出节点的标题,值是输出内容的列表。
"""
client_id = str(uuid.uuid4())
async with aiohttp.ClientSession() as session:
prepared_workflow, output_map = await self._prepare_workflow(workflow, session, **kwargs)
async with session.ws_connect(
f"{self.ws_url}?clientId={client_id}", timeout=self.timeout, max_msg_size=128 * 1024 * 1024
) as ws:
prompt_payload = {"prompt": prepared_workflow, "client_id": client_id}
async with session.post(f"{self.base_url}/prompt", json=prompt_payload) as response:
if response.status != 200:
error_text = await response.text()
raise ConnectionError(f"提交工作流失败: {response.status} {error_text}")
queue_data = await response.json()
prompt_id = queue_data["prompt_id"]
outputs: Dict[str, list] = {name: [] for name in output_map.values()}
image_download_tasks = []
current_executing_node = None
is_done = False
while not is_done:
try:
msg_data = await ws.receive(timeout=self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"工作流执行超时({self.timeout}秒)。")
if msg_data.type == aiohttp.WSMsgType.TEXT:
message = msg_data.json()
msg_type = message.get("type")
data = message.get("data", {})
if data.get("prompt_id") != prompt_id:
continue
# print(f"[*] 收到消息: {msg_type} - {data.get('display_node', '')}")
if msg_type == "execution_error":
raise WorkflowExecutionError(
"工作流执行出错",
node_id=data.get("node_id"),
node_type=data.get("node_type"),
exception_message=data.get("exception_message"),
)
elif msg_type == "execution_success":
is_done = True
elif msg_type == "executing":
current_executing_node = data.get("display_node") or data.get("node")
elif msg_type == "executed":
node_id = data.get("display_node") or data.get("node")
if node_id in output_map:
output_name = output_map[node_id]
output_data = data.get("output", {})
if "text" in output_data:
outputs[output_name].extend(output_data["text"])
if "images" in output_data:
for img_info in output_data["images"]:
task = self._get_image(
img_info["filename"], img_info.get("subfolder", ""), img_info["type"], session
)
image_download_tasks.append((output_name, task))
elif msg_data.type == aiohttp.WSMsgType.BINARY:
if current_executing_node and current_executing_node in output_map:
output_name = output_map[current_executing_node]
image_data = msg_data.data[8:] # 去掉8字节头部
image = Image.open(io.BytesIO(image_data))
outputs[output_name].append(image)
elif msg_data.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
is_done = True
if image_download_tasks:
results = await asyncio.gather(*(task for _, task in image_download_tasks))
for i, (output_name, _) in enumerate(image_download_tasks):
outputs[output_name].append(results[i])
return {k: v for k, v in outputs.items() if v}
def run_workflow_sync(self, workflow: dict, **kwargs) -> Dict[str, List[Any]]:
"""
同步执行一个 ComfyUI 工作流。
这是 run_workflow 的同步版本。
:param workflow: 工作流模板 (Python 字典)。
:param kwargs: 要注入工作流的参数。
:return: 一个字典,键是输出节点的标题,值是输出内容的列表。
"""
return asyncio.run(self.run_workflow(workflow, **kwargs))
def _str_to_primitive(s: str) -> Union[bool, int, float, str]:
"""尝试将字符串转换为布尔值、整数或浮点数。"""
s_lower = s.lower()
if s_lower == "true":
return True
if s_lower == "false":
return False
try:
return int(s)
except ValueError:
try:
return float(s)
except ValueError:
return s
def main_cli():
"""命令行接口的主函数。"""
import argparse
import sys
from pathlib import Path
parser = argparse.ArgumentParser(
description="ComfyUI Python 客户端命令行接口。",
epilog="工作流的输入参数请使用 '--key=value' 的格式,例如: --prompt=\"a cat\" --steps=25",
)
parser.add_argument("workflow_file", help="要执行的工作流 JSON 文件路径。")
parser.add_argument("--output_dir", default="workflow_outputs", help="用于保存输出图片的目录。(默认: workflow_outputs)")
parser.add_argument("--server", default="http://127.0.0.1:8188", help="ComfyUI 服务器地址。(默认: http://127.0.0.1:8188)")
args, unknown_args = parser.parse_known_args()
# --- 解析动态工作流参数 ---
workflow_kwargs = {}
for arg in unknown_args:
if not arg.startswith("--"):
continue
try:
key, value = arg.lstrip("-").split("=", 1)
workflow_kwargs[key] = _str_to_primitive(value)
except ValueError:
parser.error(f"无法解析参数 '{arg}'。请使用 '--key=value' 格式。")
# --- 日志函数,输出到 stderr ---
log = lambda *a, **kw: print(*a, **kw, file=sys.stderr)
try:
# --- 准备和执行工作流 ---
log(f"[*] 正在从 {args.workflow_file} 加载工作流...")
with open(args.workflow_file, "r", encoding="utf-8") as f:
workflow = json.load(f)
client = ComfyClient(args.server)
log(f"[*] 正在连接到 ComfyUI 服务器: {args.server}")
log(f"[*] 使用以下输入参数执行工作流: {workflow_kwargs}")
results = client.run_workflow_sync(workflow, **workflow_kwargs)
log("[*] 工作流执行完毕。")
# --- 处理输出 ---
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
log(f"[*] 正在处理输出结果...")
for output_name, output_items in results.items():
for i, item in enumerate(output_items, 1):
if isinstance(item, Image.Image):
filename = f"{output_name}_{i}.png"
filepath = output_dir / filename
item.save(filepath)
log(f" - 图片已保存到: {filepath}")
else:
# 将文本和其他原始类型输出到 stdout
print(item)
except FileNotFoundError:
log(f"[错误] 工作流文件未找到: '{args.workflow_file}'")
sys.exit(1)
except Exception as e:
log(f"[错误] 执行过程中发生错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main_cli()