add remote function call

This commit is contained in:
duanyll
2025-11-13 04:29:15 +00:00
parent 7861d140c3
commit a2fc98293d
6 changed files with 735 additions and 131 deletions
+345
View File
@@ -0,0 +1,345 @@
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()
+67 -57
View File
@@ -7,6 +7,7 @@ from .utils import AnyType, ContainsDynamicDict, Closure, create_graph_from_clos
RECURISON_LIMIT = 50
COROUTINE_LIMIT = 100
class FunctionParam:
@classmethod
def INPUT_TYPES(cls):
@@ -19,16 +20,19 @@ class FunctionParam:
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
CATEGORY = "duanyll/functional"
def run(self, index):
return (ExecutionBlocker(
"All outputs of nodes that directly or indirectly depend on the Function "
"Parameter node must ultimately connect to a Function End node or an Inspect "
"node. An output node is incorrectly connected, as it depends on the Function "
"Parameter node without passing through a Function End or Inspect node. To "
"view temporary results within the function, please use an Inspect node before "
"the output node. Check your node connections and try again."
),)
return (
ExecutionBlocker(
"All outputs of nodes that directly or indirectly depend on the Function "
"Parameter node must ultimately connect to a Function End node or an Inspect "
"node. An output node is incorrectly connected, as it depends on the Function "
"Parameter node without passing through a Function End or Inspect node. To "
"view temporary results within the function, please use an Inspect node before "
"the output node. Check your node connections and try again."
),
)
class FunctionEnd:
@classmethod
@@ -36,27 +40,29 @@ class FunctionEnd:
return {
"required": {
"return_value": (AnyType("*"),),
"capture": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("CLOSURE", )
RETURN_TYPES = ("CLOSURE",)
FUNCTION = "run"
CATEGORY = "duanyll/functional"
def run(self, return_value):
def run(self, return_value, capture):
raise NotImplementedError(
"The Function End node is a marker and should never be executed directly. "
"If you see this error, please file a bug report."
)
class CreateClosure:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"body": ("STRING", {"multiline": True} ),
"output": ("STRING", ),
"side_effects": ("FLOAT", )
"body": ("STRING", {"multiline": True}),
"output": ("STRING",),
"side_effects": ("FLOAT",),
},
"optional": ContainsDynamicDict(
{
@@ -65,7 +71,7 @@ class CreateClosure:
),
}
RETURN_TYPES = ("CLOSURE", )
RETURN_TYPES = ("CLOSURE",)
FUNCTION = "run"
CATEGORY = "duanyll/functional/internal"
@@ -76,7 +82,14 @@ class CreateClosure:
captures.append(kwargs[f"capture_{i}"])
body = json.loads(body)
output = json.loads(output)
return (Closure(body=body, output=output, captures=captures), )
return (
Closure(
body=body,
output=output,
captures=captures,
),
)
class CallClosure:
@classmethod
@@ -90,12 +103,10 @@ class CallClosure:
"param_0": (AnyType("*"), {"_dynamic": "number"}),
}
),
"hidden": {
"unique_id": "UNIQUE_ID"
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = (AnyType("*"), )
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
CATEGORY = "duanyll/functional"
@@ -110,15 +121,14 @@ class CallClosure:
params = []
for i in range(len(kwargs)):
params.append(kwargs[f"param_{i}"])
graph, output = create_graph_from_closure(closure, params, caller_unique_id=unique_id)
graph, output = create_graph_from_closure(
closure, params, caller_unique_id=unique_id
)
if len(graph) == 0:
return (output, )
return {
"result": (output, ),
"expand": graph
}
return (output,)
return {"result": (output,), "expand": graph}
class IntermidiateCoroutine:
@classmethod
def INPUT_TYPES(cls):
@@ -127,28 +137,26 @@ class IntermidiateCoroutine:
"return_value": (AnyType("*"),),
"coroutine": (AnyType("*"),),
},
"hidden": {
"unique_id": "UNIQUE_ID"
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = (AnyType("*"), )
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
CATEGORY = "duanyll/functional/internal"
def run(self, return_value, coroutine, unique_id):
(base_id, index) = unique_id.rsplit("_", 1)
graph = {}
while len(graph) == 0:
try:
(closure, params) = coroutine.send(return_value)
except StopIteration as e:
return (e.value, )
return (e.value,)
index = int(index) + 1
next_node_id = f"{base_id}_{index}"
if index > COROUTINE_LIMIT:
raise RecursionError(
"Coroutine execution limit exceeded. Possible infinite loop "
@@ -157,13 +165,15 @@ class IntermidiateCoroutine:
"in core.py."
)
graph, output = create_graph_from_closure(closure, params, caller_unique_id=unique_id)
graph, output = create_graph_from_closure(
closure, params, caller_unique_id=unique_id
)
if len(graph) == 0:
return_value = output
return {
"result": ([next_node_id, 0], ),
"result": ([next_node_id, 0],),
"expand": {
**graph,
next_node_id: {
@@ -172,33 +182,33 @@ class IntermidiateCoroutine:
"coroutine": coroutine,
},
"class_type": "__IntermidiateCoroutine__",
}
}
},
},
}
class CoroutineNodeBase:
@classmethod
def INPUT_TYPES(cls):
raise NotImplementedError(
"This is an abstract base class and should not be instantiated directly."
)
RETURN_TYPES = (AnyType("*"), )
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
def run_coroutine(self, **kwargs):
raise NotImplementedError(
"Subclasses must implement the run_coroutine method as a generator."
)
yield # This is just to make this function a generator
yield # This is just to make this function a generator
def run(self, unique_id, **kwargs):
if isinstance(unique_id, list):
unique_id = unique_id[0]
coroutine = self.run_coroutine(**kwargs)
return {
"result": ([f"{unique_id}_0", 0], ),
"result": ([f"{unique_id}_0", 0],),
"expand": {
f"{unique_id}_0": {
"inputs": {
@@ -207,20 +217,20 @@ class CoroutineNodeBase:
},
"class_type": "__IntermidiateCoroutine__",
}
}
},
}
class RecoverList:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"values": (AnyType("*"), ),
"values": (AnyType("*"),),
},
}
RETURN_TYPES = (AnyType("*"), )
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
CATEGORY = "duanyll/functional/internal"
+8 -21
View File
@@ -1,26 +1,13 @@
from .core import (NODE_CLASS_MAPPINGS as CORE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as CORE_NODE_DISPLAY_NAME_MAPPINGS)
from .high_level import (NODE_CLASS_MAPPINGS as HIGH_LEVEL_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as HIGH_LEVEL_NODE_DISPLAY_NAME_MAPPINGS)
from .side_effects import (NODE_CLASS_MAPPINGS as SIDE_EFFECTS_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as SIDE_EFFECTS_NODE_DISPLAY_NAME_MAPPINGS)
from .logic import (NODE_CLASS_MAPPINGS as LOGIC_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as LOGIC_NODE_DISPLAY_NAME_MAPPINGS)
from . import core, high_level, logic, side_effects, remote
NODE_CLASS_MAPPINGS = {
**CORE_NODE_CLASS_MAPPINGS,
**HIGH_LEVEL_NODE_CLASS_MAPPINGS,
**SIDE_EFFECTS_NODE_CLASS_MAPPINGS,
**LOGIC_NODE_CLASS_MAPPINGS,
}
NODE_DISPLAY_NAME_MAPPINGS = {
**CORE_NODE_DISPLAY_NAME_MAPPINGS,
**HIGH_LEVEL_NODE_DISPLAY_NAME_MAPPINGS,
**SIDE_EFFECTS_NODE_DISPLAY_NAME_MAPPINGS,
**LOGIC_NODE_DISPLAY_NAME_MAPPINGS,
}
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
for module in [core, high_level, logic, side_effects, remote]:
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
# Mangle ids to avoid conflicts
PREFIX = "duanyll::"
NODE_CLASS_MAPPINGS = {
(k if k.startswith("__") else PREFIX + k): v
+105
View File
@@ -0,0 +1,105 @@
from .utils import (
serialize,
deserialize,
AnyType,
ContainsDynamicDict,
Closure,
create_remote_workflow_from_closure,
)
from .comfy_client import ComfyClient
class Serialize:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"data": (AnyType("*"),),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "run"
CATEGORY = "duanyll/functional/internal"
OUTPUT_NODE = True
@classmethod
def IS_CHANGED(cls, data):
return float("NaN")
def run(self, data):
try:
text = serialize(data)
print(f"Serialized {type(data)} to string of length {len(text)}")
return {
"ui": {"text": [text]},
"result": (text,),
}
except Exception as e:
raise ValueError(
f"Cannot serialize function output. It may contain unserializable types. Error: {e}"
)
class Deserialize:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"data": ("STRING", {"multiline": True}),
}
}
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
CATEGORY = "duanyll/functional/internal"
def run(self, data):
return (deserialize(data),)
class CallClosureRemote:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"closure": ("CLOSURE",),
"base_url": ("STRING",),
"timeout": ("FLOAT", {"default": 600.0, "min": 1.0}),
},
"optional": ContainsDynamicDict(
{
"param_0": (AnyType("*"), {"_dynamic": "number"}),
}
)
}
RETURN_TYPES = (AnyType("*"),)
FUNCTION = "run"
CATEGORY = "duanyll/functional"
async def run(self, closure, base_url, timeout, **kwargs):
params = []
for i in range(len(kwargs)):
params.append(kwargs[f"param_{i}"])
client = ComfyClient(server_base=base_url, timeout=timeout)
workflow = create_remote_workflow_from_closure(closure, params)
client_response = await client.run_workflow(workflow)
output = client_response.get("__output__", None)
if output is None:
raise ValueError("No output received from remote workflow execution.")
return (deserialize(output[0]),)
NODE_CLASS_MAPPINGS = {
"__Serialize__": Serialize,
"__Deserialize__": Deserialize,
"CallClosureRemote": CallClosureRemote
}
NODE_DISPLAY_NAME_MAPPINGS = {
"__Serialize__": "[Internal] Serialize",
"__Deserialize__": "[Internal] Deserialize",
"CallClosureRemote": "Call Remote Function",
}
+8 -3
View File
@@ -108,10 +108,15 @@ def _transform_single_function(end_node_id: str, workflow: Dict, graph: Workflow
"""转换单个 __FunctionEnd__ 节点及其关联的子图。"""
subgraph_nodes = _find_function_subgraph(end_node_id, graph)
original_end_node = workflow[end_node_id]
enable_capture = original_end_node["inputs"].get("capture", True)
param_nodes = _find_param_nodes(subgraph_nodes, workflow, graph)
param_nodes.add(end_node_id)
capture_nodes = subgraph_nodes - param_nodes
if enable_capture:
param_nodes = _find_param_nodes(subgraph_nodes, workflow, graph)
param_nodes.add(end_node_id)
capture_nodes = subgraph_nodes - param_nodes
else:
param_nodes = subgraph_nodes
capture_nodes = set()
# 恢复原始节点类型以便深拷贝
workflow[end_node_id]["class_type"] = original_end_node["class_type"]
+202 -50
View File
@@ -1,5 +1,11 @@
from typing import TypedDict
import copy
import json
import io
import base64
import numpy as np
import torch
from PIL import Image
class AnyType(str):
def __ne__(self, __value: object) -> bool:
@@ -43,63 +49,209 @@ class ContainsDynamicDict(dict):
class Closure(TypedDict):
body: dict
captures: dict
captures: list
output: list
def create_graph_from_closure(self, params, caller_unique_id=None):
body = copy.deepcopy(self.body)
def create_graph_from_closure(closure: Closure, params, caller_unique_id=None):
body = copy.deepcopy(closure["body"])
graph = {}
def add_recover_node(spec):
prefix = f"{caller_unique_id}_" if caller_unique_id is not None else ""
recover_id = f"{prefix}{spec[0]}_{spec[1]}"
if recover_id in graph:
def transform_input(spec):
if not isinstance(spec, list):
return spec
# Return normal spec if not a capture or param
if spec[0] != "__capture" and spec[0] != "__param":
return spec
if spec[0] == "__param":
if spec[1] >= len(params):
raise IndexError(
f"Parameter index {spec[1]} out of range for function call. "
f"Only {len(params)} parameters were provided."
)
value = params[spec[1]]
else:
value = closure["captures"][spec[1]]
if isinstance(value, list):
prefix = f"{caller_unique_id}_" if caller_unique_id is not None else ""
recover_id = f"{prefix}{spec[0]}_{spec[1]}"
if recover_id not in graph:
graph[recover_id] = {
"inputs": {
# Wrap the list in a tuple so it's treated as a literal, not a link
"values": (value,),
},
"class_type": "__RecoverList__",
}
return [recover_id, 0]
value = self.captures[spec[1]] if spec[0] == "__capture" else params[spec[1]]
graph[recover_id] = {
"inputs": {
# Wrap the list in a tuple so it's treated as a literal, not a link
"values": (value,),
},
"class_type": "__RecoverList__",
}
return [recover_id, 0]
else:
return value
for node_id, node_data in body.items():
inputs = node_data["inputs"]
for key in inputs.keys():
spec = inputs[key]
if isinstance(spec, list):
if spec[0] == "__capture":
value = self.captures[spec[1]]
if isinstance(value, list):
inputs[key] = add_recover_node(spec)
else:
inputs[key] = value
elif spec[0] == "__param":
if spec[1] >= len(params):
raise ValueError(f"Parameter index {spec[1]} out of range for provided {len(params)} params.")
value = params[spec[1]]
if isinstance(value, list):
inputs[key] = add_recover_node(spec)
else:
inputs[key] = value
else:
spec[0] = f"{caller_unique_id}_{spec[0]}"
inputs[key] = transform_input(inputs[key])
node_data["override_display_id"] = node_id
graph[f"{caller_unique_id}_{node_id}"] = node_data
spec = self.output
if spec[0] == "__capture":
value = self.captures[spec[1]]
if isinstance(value, list):
return graph, add_recover_node(spec)
return graph, value
elif spec[0] == "__param":
if spec[1] >= len(params):
raise ValueError(f"Parameter index {spec[1]} out of range for provided {len(params)} params.")
value = params[spec[1]]
if isinstance(value, list):
return graph, add_recover_node(spec)
return graph, value
else:
return graph, [f"{caller_unique_id}_{spec[0]}", spec[1]]
output = transform_input(closure["output"])
return graph, output
def create_remote_workflow_from_closure(closure, params):
body = copy.deepcopy(closure["body"])
graph = {}
def transform_input(spec):
if not isinstance(spec, list):
return spec
# Return normal spec if not a capture or param
if spec[0] != "__capture" and spec[0] != "__param":
return spec
# Check if we have already created a deserialize node for this spec
deserialize_node_id = f"deserialize_{spec[0]}_{spec[1]}"
if deserialize_node_id in graph:
return [deserialize_node_id, 0]
# Try to create a deserialize node
if spec[0] == "__param":
if spec[1] >= len(params):
raise IndexError(
f"Parameter index {spec[1]} out of range for function call. "
f"Only {len(params)} parameters were provided."
)
value = params[spec[1]]
else:
value = closure["captures"][spec[1]]
try:
serialized_value = serialize(value)
graph[deserialize_node_id] = {
"inputs": {
"data": serialized_value,
},
"class_type": "__Deserialize__",
}
return [deserialize_node_id, 0]
except TypeError as e:
if spec[0] == "__capture":
raise ValueError(
f"Cannot serialize captured value at index {spec[1]}. "
"Captured values must be serializable to be used in remote execution. "
"Consider disabling capture for this function to initialize the value remotely. "
f"Error: {e}"
)
else:
raise ValueError(
f"Cannot serialize parameter at index {spec[1]}. "
"Parameters must be serializable to be used in remote execution. "
f"Error: {e}"
)
for node_id, node_data in body.items():
inputs = node_data["inputs"]
for key in inputs.keys():
inputs[key] = transform_input(inputs[key])
graph[node_id] = node_data
output = transform_input(closure["output"])
graph["__output__"] = {
"inputs": {
"data": output,
},
"class_type": "__Serialize__",
"_meta": {
"title": "%__output__%"
}
}
return graph
class WhitelistEncoder(json.JSONEncoder):
def default(self, obj):
# 区分 set 和 tuple
if isinstance(obj, set):
return {'__type__': 'set', 'data': list(obj)}
if isinstance(obj, tuple):
return {'__type__': 'tuple', 'data': list(obj)}
if isinstance(obj, bytes):
return {'__type__': 'bytes', 'data': base64.b64encode(obj).decode('utf-8')}
if isinstance(obj, np.ndarray):
buffer = io.BytesIO()
np.save(buffer, obj, allow_pickle=False)
return {
'__type__': 'numpy.ndarray',
'data': base64.b64encode(buffer.getvalue()).decode('utf-8')
}
if isinstance(obj, torch.Tensor):
buffer = io.BytesIO()
torch.save(obj.cpu(), buffer)
return {
'__type__': 'torch.Tensor',
'data': base64.b64encode(buffer.getvalue()).decode('utf-8')
}
if isinstance(obj, Image.Image):
buffer = io.BytesIO()
obj.save(buffer, format='PNG')
return {
'__type__': 'PIL.Image',
'data': base64.b64encode(buffer.getvalue()).decode('utf-8')
}
raise TypeError(
f"Object of type '{type(obj).__name__}' is not serializable. "
"Only basic types, set, tuple, bytes, numpy.ndarray, "
"torch.Tensor, and PIL.Image are allowed."
)
# 2. 自定义解码器 (Deserialization)
def whitelist_decoder(dct):
"""
用于 json.load/loads 的 object_hook,用于恢复自定义类型。
"""
if '__type__' in dct:
obj_type = dct['__type__']
data = dct['data']
if obj_type == 'set':
return set(data)
if obj_type == 'tuple':
return tuple(data)
if obj_type == 'bytes':
return base64.b64decode(data)
if obj_type == 'numpy.ndarray':
buffer = io.BytesIO(base64.b64decode(data))
# allow_pickle=True 是为了恢复可能包含的复杂dtype
return np.load(buffer, allow_pickle=True)
if obj_type == 'torch.Tensor':
buffer = io.BytesIO(base64.b64decode(data))
return torch.load(buffer)
if obj_type == 'PIL.Image':
buffer = io.BytesIO(base64.b64decode(data))
img = Image.open(buffer)
# .copy() 是个好习惯,确保数据已从缓冲区完全加载
return img.copy()
return dct
def serialize(obj):
"""
将对象序列化为 JSON 字符串。
仅支持基本类型、set、tuple、bytes、numpy.ndarray、torch.Tensor 和 PIL.Image。
"""
return json.dumps(obj, cls=WhitelistEncoder)
def deserialize(json_str):
"""
从 JSON 字符串反序列化对象。
"""
return json.loads(json_str, object_hook=whitelist_decoder)