初始化
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# ComfyUI-FlowBranch
|
||||
|
||||
用于多阶段图像处理的无线结果与惰性流程编排节点。节点位于 **流程分支** 分类。
|
||||
|
||||
## 三个节点
|
||||
|
||||
- **发送结果**:给一份数据起一个可读名称,例如“原始图像”。
|
||||
- **流程编排器**:按顺序管理所有处理阶段;阶段和每个阶段的方案数量都没有固定上限。
|
||||
- **读取结果**:在任意位置读取“原始图像”“修脸后”“放大后”或“最终图像”等命名结果。
|
||||
|
||||
旧版“阶段开关 / 多路方案 / 条件选择”只为兼容已经保存的工作流保留,已标记为弃用,不会出现在新增节点菜单中。
|
||||
|
||||
## 示例:生图、可选修脸、三种可选放大
|
||||
|
||||
### 1. 发布起点
|
||||
|
||||
把生图结果连接到 **发送结果** 的“数据”,结果名称填写:
|
||||
|
||||
```text
|
||||
原始图像
|
||||
```
|
||||
|
||||
### 2. 添加修脸阶段
|
||||
|
||||
在 **流程编排器** 中设置:
|
||||
|
||||
```text
|
||||
起点结果:原始图像
|
||||
最终发布为:最终图像
|
||||
```
|
||||
|
||||
点击“添加阶段”,把阶段结果名称改为“修脸后”,把方案名称改为“FaceDetailer”。
|
||||
|
||||
修脸处理链必须这样开始和结束:
|
||||
|
||||
```text
|
||||
读取结果(原始图像) -> FaceDetailer -> 流程编排器的 FaceDetailer 方案插槽
|
||||
```
|
||||
|
||||
### 3. 添加放大阶段
|
||||
|
||||
再次点击“添加阶段”,把阶段结果名称改为“放大后”。使用“添加方案”加入任意数量的放大方法,例如:
|
||||
|
||||
```text
|
||||
读取结果(修脸后) -> 放大方法 A -> 方案 A 插槽
|
||||
读取结果(修脸后) -> 放大方法 B -> 方案 B 插槽
|
||||
读取结果(修脸后) -> 放大方法 C -> 方案 C 插槽
|
||||
```
|
||||
|
||||
有两种选择方式:
|
||||
|
||||
- 手动模式:用每行左侧的单选按钮选择本次运行的方案。
|
||||
- 自动模式:开启“自动选择可用方案”,按从上到下的顺序选择第一个未被 Bypass 且确实有结果的方案。
|
||||
|
||||
两种模式都只会把最终选中的方案接入执行依赖,其余方案不会由流程编排器触发。
|
||||
|
||||
### 4. 使用最终结果
|
||||
|
||||
可以直接使用流程编排器的“流程结果”输出,也可以在其他位置添加:
|
||||
|
||||
```text
|
||||
读取结果(最终图像) -> 保存图片
|
||||
```
|
||||
|
||||
## 阶段行为
|
||||
|
||||
| 操作 | 结果 |
|
||||
| --- | --- |
|
||||
| 关闭阶段开关 | 不执行该阶段的任何方案,直接沿用上一阶段 |
|
||||
| 选择“跳过本阶段” | 阶段保持启用,但本次不执行处理方案 |
|
||||
| 选中的方案没有连接 | 自动沿用上一阶段,并显示黄色状态 |
|
||||
| 选中的方案已经连接 | 只执行这个方案 |
|
||||
| 开启“自动选择可用方案” | 从上到下选择第一个可用方案;全部不可用时沿用上一阶段 |
|
||||
| 自动模式发现多个可用方案 | 只选择最上面的方案并给出提示 |
|
||||
| 起点或全部输入被 Bypass | 缺失连线按“未连接”处理;使用它的可选端口不会阻止节点继续运行 |
|
||||
| 删除方案或阶段 | 对应动态插槽和连线一并安全移除 |
|
||||
|
||||
## 顺序保护
|
||||
|
||||
每个阶段标题都会显示它应该读取的上一结果,例如“读取:修脸后”。选中的处理方案必须从同名 **读取结果** 节点开始。
|
||||
|
||||
使用组 Bypass(`Ctrl+B`)管理自动方案时,应把该方案末端的“发送结果”也放进同一个组。这样整组被 Bypass 后,对应命名结果会从队列 Prompt 中消失,自动模式才能准确判定它不可用。
|
||||
|
||||
如果“放大后”方案错误地读取了“原始图像”,编译器会明确报错并停止该流程,防止跳过修脸后仍继续放大和保存。
|
||||
|
||||
“读取结果”找不到同名发布位置且没有回退输入时,排队前会移除对应连线,效果与该端口从未连接完全相同。普通节点的可选输入缺失时仍可继续运行;必填输入缺失时则显示 ComfyUI 原生的缺少输入错误。名称重复、循环依赖和阶段顺序错误仍然是明确的配置错误。
|
||||
|
||||
排队时,可见流程会展开成真正的 ComfyUI 懒执行依赖。实现不使用 Python 全局变量,不依赖节点摆放顺序,也不会重新序列化工作流或修改其他节点的随机种子。
|
||||
|
||||
## 保存与复制
|
||||
|
||||
阶段、方案、名称、顺序、开关、当前选择和动态输入 ID 都保存在节点自身的普通工作流数据中。切换工作流、保存后重新打开、复制节点都会保留这些信息,不依赖浏览器缓存或工作流外文件。
|
||||
|
||||
## 快捷操作
|
||||
|
||||
- 右键 **发送结果**,选择“创建配对读取”,会在右侧创建一个同名 **读取结果**。
|
||||
- 右键 **流程编排器**,选择“创建结果读取节点”,可以直接为任意阶段或最终结果创建读取节点。
|
||||
- 右键 **读取结果** 选择“跳转到发送位置”,或直接双击节点,可以返回唯一的发送节点;名称冲突时不会擅自选择。
|
||||
@@ -0,0 +1,5 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"FlowBranchPublish": "发送结果",
|
||||
"FlowBranchGet": "读取结果",
|
||||
"FlowBranchPipeline": "流程编排器",
|
||||
"FlowBranchStage": "[旧版] 阶段开关",
|
||||
"FlowBranchRoute": "[旧版] 多路方案",
|
||||
"FlowBranchIf": "[旧版] 条件选择"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"FlowBranchPublish": {
|
||||
"display_name": "发送结果",
|
||||
"description": "给数据一个可读名称,供流程中的其他位置读取",
|
||||
"inputs": {
|
||||
"channel": {"name": "结果名称", "tooltip": "例如:原始图像、修脸后、最终图像"},
|
||||
"value": {"name": "数据", "tooltip": "要发布的数据"}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {"name": "数据"}
|
||||
}
|
||||
},
|
||||
"FlowBranchGet": {
|
||||
"display_name": "读取结果",
|
||||
"description": "读取发送结果或流程阶段发布的同名结果",
|
||||
"inputs": {
|
||||
"channel": {"name": "结果名称"},
|
||||
"fallback": {"name": "找不到时使用", "tooltip": "没有同名结果时使用这个输入"}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {"name": "数据"}
|
||||
}
|
||||
},
|
||||
"FlowBranchPipeline": {
|
||||
"display_name": "流程编排器",
|
||||
"description": "可无限添加阶段,每个阶段可无限添加处理方案,并且只执行选中的方案",
|
||||
"inputs": {
|
||||
"input_channel": {"name": "起点结果", "tooltip": "第一个阶段要读取的结果名称"},
|
||||
"output_channel": {"name": "最终发布为", "tooltip": "整个流程完成后使用的结果名称"}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {"name": "流程结果"}
|
||||
}
|
||||
},
|
||||
"FlowBranchStage": {
|
||||
"display_name": "[旧版] 阶段开关"
|
||||
},
|
||||
"FlowBranchRoute": {
|
||||
"display_name": "[旧版] 多路方案"
|
||||
},
|
||||
"FlowBranchIf": {
|
||||
"display_name": "[旧版] 条件选择"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from comfy_execution.graph_utils import ExecutionBlocker
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
ANY = AnyType("*")
|
||||
MISSING = object()
|
||||
CATEGORY = "流程分支"
|
||||
LEGACY_CATEGORY = "流程分支/旧版"
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Allow frontend-defined lazy inputs while keeping known internal inputs typed."""
|
||||
|
||||
def __init__(self, dynamic_spec, data=None):
|
||||
super().__init__(data or {})
|
||||
self.dynamic_spec = dynamic_spec
|
||||
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key) if dict.__contains__(self, key) else self.dynamic_spec
|
||||
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
|
||||
|
||||
def _blocked(message: str):
|
||||
return (ExecutionBlocker(f"[流程分支] {message}"),)
|
||||
|
||||
|
||||
def _compile_error(error: str):
|
||||
return _blocked(error) if error else None
|
||||
|
||||
|
||||
class FlowPublish:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"channel": ("STRING", {
|
||||
"default": "原始图像",
|
||||
"multiline": False,
|
||||
"tooltip": "结果名称。同一工作流中每个名称只能有一个发布位置。",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"value": (ANY, {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("值",)
|
||||
FUNCTION = "publish"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "给任意类型数据一个可读名称;只有被读取时才参与执行。"
|
||||
|
||||
@classmethod
|
||||
def publish(cls, channel: str, value=MISSING):
|
||||
if value is MISSING:
|
||||
return _blocked(f"发送结果“{channel.strip() or '(空名称)'}”没有输入数据。")
|
||||
return (value,)
|
||||
|
||||
|
||||
class FlowGet:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"channel": ("STRING", {
|
||||
"default": "原始图像",
|
||||
"multiline": False,
|
||||
"tooltip": "读取同名发送结果或流程阶段结果。",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"fallback": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"source": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"compile_error": ("STRING", {"default": "", "multiline": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("值",)
|
||||
FUNCTION = "get"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "读取命名结果;找不到时可使用回退输入。"
|
||||
|
||||
@classmethod
|
||||
def check_lazy_status(cls, channel: str, fallback=MISSING, source=MISSING, compile_error: str = ""):
|
||||
if compile_error:
|
||||
return []
|
||||
if source is not MISSING:
|
||||
return ["source"] if source is None else []
|
||||
if fallback is not MISSING:
|
||||
return ["fallback"] if fallback is None else []
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get(cls, channel: str, fallback=MISSING, source=MISSING, compile_error: str = ""):
|
||||
error = _compile_error(compile_error)
|
||||
if error:
|
||||
return error
|
||||
if source is not MISSING:
|
||||
return (source,)
|
||||
if fallback is not MISSING:
|
||||
return (fallback,)
|
||||
return _blocked(f"读取结果“{channel.strip() or '(空名称)'}”找不到发布位置,也没有连接回退输入。")
|
||||
|
||||
|
||||
class FlowPipeline:
|
||||
EMPTY_CONFIG = json.dumps({"version": 2, "stages": []}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
known_optional = {
|
||||
"source": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"pipeline_result": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"selected_value": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"compile_error": ("STRING", {"default": "", "multiline": False}),
|
||||
"stage_name": ("STRING", {"default": "", "multiline": False}),
|
||||
"selected_name": ("STRING", {"default": "", "multiline": False}),
|
||||
"__stage_internal": ("BOOLEAN", {"default": False}),
|
||||
"__flow_generated": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"input_channel": ("STRING", {
|
||||
"default": "原始图像",
|
||||
"multiline": False,
|
||||
"tooltip": "第一个阶段读取的结果名称。",
|
||||
}),
|
||||
"output_channel": ("STRING", {
|
||||
"default": "最终图像",
|
||||
"multiline": False,
|
||||
"tooltip": "整个流程完成后发布的结果名称。",
|
||||
}),
|
||||
"pipeline_config": ("STRING", {
|
||||
"default": cls.EMPTY_CONFIG,
|
||||
"multiline": False,
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(
|
||||
(ANY, {"forceInput": True, "lazy": True}),
|
||||
known_optional,
|
||||
),
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("流程结果",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "可无限添加阶段和方案的惰性流程编排器。"
|
||||
|
||||
@classmethod
|
||||
def check_lazy_status(cls, input_channel: str, output_channel: str, pipeline_config: str,
|
||||
source=MISSING, pipeline_result=MISSING, selected_value=MISSING,
|
||||
compile_error: str = "", **kwargs):
|
||||
if compile_error:
|
||||
return []
|
||||
if kwargs.get("__stage_internal", False):
|
||||
if selected_value is not MISSING:
|
||||
return ["selected_value"] if selected_value is None else []
|
||||
if source is not MISSING:
|
||||
return ["source"] if source is None else []
|
||||
return []
|
||||
if pipeline_result is not MISSING:
|
||||
return ["pipeline_result"] if pipeline_result is None else []
|
||||
if source is not MISSING:
|
||||
return ["source"] if source is None else []
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def select(cls, input_channel: str, output_channel: str, pipeline_config: str,
|
||||
source=MISSING, pipeline_result=MISSING, selected_value=MISSING,
|
||||
compile_error: str = "", **kwargs):
|
||||
error = _compile_error(compile_error)
|
||||
if error:
|
||||
return error
|
||||
if kwargs.get("__stage_internal", False):
|
||||
if selected_value is not MISSING:
|
||||
return (selected_value,)
|
||||
if source is not MISSING:
|
||||
return (source,)
|
||||
stage_name = kwargs.get("stage_name") or output_channel or "未命名阶段"
|
||||
return _blocked(f"阶段“{stage_name}”找不到上一阶段结果。")
|
||||
if pipeline_result is not MISSING:
|
||||
return (pipeline_result,)
|
||||
if source is not MISSING:
|
||||
return (source,)
|
||||
return _blocked(f"流程找不到起点结果“{input_channel.strip() or '(空名称)'}”。")
|
||||
|
||||
|
||||
class FlowStage:
|
||||
DEPRECATED = True
|
||||
CATEGORY = LEGACY_CATEGORY
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_channel": ("STRING", {
|
||||
"default": "上一阶段",
|
||||
"multiline": False,
|
||||
"tooltip": "上一步成功结果的通道。",
|
||||
}),
|
||||
"output_channel": ("STRING", {
|
||||
"default": "本阶段",
|
||||
"multiline": False,
|
||||
"tooltip": "本阶段结果发布到这个通道。",
|
||||
}),
|
||||
"enabled": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"label_on": "启用",
|
||||
"label_off": "旁路",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"processed": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"source": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"compile_error": ("STRING", {"default": "", "multiline": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("阶段结果",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = LEGACY_CATEGORY
|
||||
DESCRIPTION = "启用时采用处理结果,关闭或处理结果未连接时传递上一步,并发布输出通道。"
|
||||
|
||||
@classmethod
|
||||
def check_lazy_status(cls, input_channel: str, output_channel: str, enabled: bool,
|
||||
processed=MISSING, source=MISSING, compile_error: str = ""):
|
||||
if compile_error:
|
||||
return []
|
||||
if enabled and processed is not MISSING:
|
||||
return ["processed"] if processed is None else []
|
||||
if source is not MISSING:
|
||||
return ["source"] if source is None else []
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def select(cls, input_channel: str, output_channel: str, enabled: bool,
|
||||
processed=MISSING, source=MISSING, compile_error: str = ""):
|
||||
error = _compile_error(compile_error)
|
||||
if error:
|
||||
return error
|
||||
if enabled and processed is not MISSING:
|
||||
return (processed,)
|
||||
if source is not MISSING:
|
||||
return (source,)
|
||||
state = "启用但处理结果未连接" if enabled else "处于旁路状态"
|
||||
return _blocked(
|
||||
f"阶段“{input_channel} → {output_channel}”{state},但输入通道“{input_channel}”没有可用数据。"
|
||||
)
|
||||
|
||||
|
||||
class FlowRoute:
|
||||
DEPRECATED = True
|
||||
CATEGORY = LEGACY_CATEGORY
|
||||
ROUTES = ("旁路", "方案 1", "方案 2", "方案 3")
|
||||
OPTION_NAMES = {
|
||||
"方案 1": "option_1",
|
||||
"方案 2": "option_2",
|
||||
"方案 3": "option_3",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_channel": ("STRING", {
|
||||
"default": "上一阶段",
|
||||
"multiline": False,
|
||||
"tooltip": "旁路或方案缺失时使用的上一步通道。",
|
||||
}),
|
||||
"output_channel": ("STRING", {
|
||||
"default": "本阶段",
|
||||
"multiline": False,
|
||||
"tooltip": "最终选择结果发布到这个通道。",
|
||||
}),
|
||||
"route": (cls.ROUTES, {"default": "旁路"}),
|
||||
},
|
||||
"optional": {
|
||||
"option_1": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"option_2": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"option_3": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"source": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"compile_error": ("STRING", {"default": "", "multiline": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("选择结果",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = LEGACY_CATEGORY
|
||||
DESCRIPTION = "仅执行选中的方案;旁路或所选方案未连接时返回输入通道,并发布输出通道。"
|
||||
|
||||
@classmethod
|
||||
def check_lazy_status(cls, input_channel: str, output_channel: str, route: str,
|
||||
option_1=MISSING, option_2=MISSING, option_3=MISSING,
|
||||
source=MISSING, compile_error: str = ""):
|
||||
if compile_error:
|
||||
return []
|
||||
option_name = cls.OPTION_NAMES.get(route)
|
||||
options = {
|
||||
"option_1": option_1,
|
||||
"option_2": option_2,
|
||||
"option_3": option_3,
|
||||
}
|
||||
option_value = options.get(option_name, MISSING)
|
||||
if option_name and option_value is not MISSING:
|
||||
return [option_name] if option_value is None else []
|
||||
if source is not MISSING:
|
||||
return ["source"] if source is None else []
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def select(cls, input_channel: str, output_channel: str, route: str,
|
||||
option_1=MISSING, option_2=MISSING, option_3=MISSING,
|
||||
source=MISSING, compile_error: str = ""):
|
||||
error = _compile_error(compile_error)
|
||||
if error:
|
||||
return error
|
||||
options = {"方案 1": option_1, "方案 2": option_2, "方案 3": option_3}
|
||||
selected = options.get(route, MISSING)
|
||||
if selected is not MISSING:
|
||||
return (selected,)
|
||||
if source is not MISSING:
|
||||
return (source,)
|
||||
return _blocked(
|
||||
f"多路方案“{input_channel} → {output_channel}”无法使用“{route}”,"
|
||||
f"且输入通道“{input_channel}”没有可回退的数据。"
|
||||
)
|
||||
|
||||
|
||||
class FlowIf:
|
||||
DEPRECATED = True
|
||||
CATEGORY = LEGACY_CATEGORY
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"condition": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"label_on": "真",
|
||||
"label_off": "假",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"on_true": (ANY, {"forceInput": True, "lazy": True}),
|
||||
"on_false": (ANY, {"forceInput": True, "lazy": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("选择结果",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = LEGACY_CATEGORY
|
||||
DESCRIPTION = "惰性布尔分支;只执行选中分支,选中分支未连接时自动使用另一分支。"
|
||||
|
||||
@classmethod
|
||||
def check_lazy_status(cls, condition: bool, on_true=MISSING, on_false=MISSING):
|
||||
primary_name, primary = ("on_true", on_true) if condition else ("on_false", on_false)
|
||||
fallback_name, fallback = ("on_false", on_false) if condition else ("on_true", on_true)
|
||||
if primary is not MISSING:
|
||||
return [primary_name] if primary is None else []
|
||||
if fallback is not MISSING:
|
||||
return [fallback_name] if fallback is None else []
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def select(cls, condition: bool, on_true=MISSING, on_false=MISSING):
|
||||
primary = on_true if condition else on_false
|
||||
fallback = on_false if condition else on_true
|
||||
if primary is not MISSING:
|
||||
return (primary,)
|
||||
if fallback is not MISSING:
|
||||
return (fallback,)
|
||||
return _blocked("条件选择的两个分支都没有连接。")
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FlowBranchPublish": FlowPublish,
|
||||
"FlowBranchGet": FlowGet,
|
||||
"FlowBranchPipeline": FlowPipeline,
|
||||
"FlowBranchStage": FlowStage,
|
||||
"FlowBranchRoute": FlowRoute,
|
||||
"FlowBranchIf": FlowIf,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FlowBranchPublish": "发送结果",
|
||||
"FlowBranchGet": "读取结果",
|
||||
"FlowBranchPipeline": "流程编排器",
|
||||
"FlowBranchStage": "[旧版] 阶段开关",
|
||||
"FlowBranchRoute": "[旧版] 多路方案",
|
||||
"FlowBranchIf": "[旧版] 条件选择",
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=70"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ComfyUI-FlowBranch"
|
||||
version = "2.0.0"
|
||||
description = "Robust wireless channels and lazy branches for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "local"
|
||||
DisplayName = "Flow Branch"
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = --import-mode=importlib --confcutdir=tests
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("flow_branch_nodes", PLUGIN_ROOT / "nodes.py")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
@@ -0,0 +1,386 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { compileFlowPrompt, generatedStageNodeId } from "../web/compiler.mjs";
|
||||
import { branchInputName } from "../web/pipeline_config.mjs";
|
||||
|
||||
function node(classType, inputs) {
|
||||
return { class_type: classType, inputs: { ...inputs } };
|
||||
}
|
||||
|
||||
function stage(id, name, branches = [], selected = null, enabled = true, autoSelect = false) {
|
||||
return { id, name, enabled, autoSelect, selected, branches };
|
||||
}
|
||||
|
||||
function branch(id, name) {
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
function config(stages) {
|
||||
return JSON.stringify({ version: 2, stages });
|
||||
}
|
||||
|
||||
test("an unlimited pipeline compiles into real ordered dependencies", () => {
|
||||
const face = branch("face", "修脸");
|
||||
const upscale = branch("upscale", "高清放大");
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "原始图像", value: ["100", 0] }),
|
||||
"10": node("FlowBranchGet", { channel: "原始图像" }),
|
||||
"20": node("FaceDetailer", { image: ["10", 0] }),
|
||||
"11": node("FlowBranchGet", { channel: "修脸后" }),
|
||||
"21": node("Upscaler", { image: ["11", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "原始图像",
|
||||
output_channel: "最终图像",
|
||||
pipeline_config: config([
|
||||
stage("face-stage", "修脸后", [face], face.id),
|
||||
stage("upscale-stage", "放大后", [upscale], upscale.id),
|
||||
]),
|
||||
[branchInputName(face.id)]: ["20", 0],
|
||||
[branchInputName(upscale.id)]: ["21", 0],
|
||||
}),
|
||||
"3": node("FlowBranchGet", { channel: "最终图像" }),
|
||||
} };
|
||||
|
||||
const diagnostics = compileFlowPrompt(prompt);
|
||||
const firstStage = generatedStageNodeId("2", 0);
|
||||
const secondStage = generatedStageNodeId("2", 1);
|
||||
|
||||
assert.deepEqual(prompt.output["10"].inputs.source, ["1", 0]);
|
||||
assert.deepEqual(prompt.output[firstStage].inputs.source, ["1", 0]);
|
||||
assert.deepEqual(prompt.output[firstStage].inputs.selected_value, ["20", 0]);
|
||||
assert.deepEqual(prompt.output["11"].inputs.source, [firstStage, 0]);
|
||||
assert.deepEqual(prompt.output[secondStage].inputs.source, [firstStage, 0]);
|
||||
assert.deepEqual(prompt.output[secondStage].inputs.selected_value, ["21", 0]);
|
||||
assert.deepEqual(prompt.output["2"].inputs.pipeline_result, [secondStage, 0]);
|
||||
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
|
||||
assert.equal(prompt.output["2"].inputs[branchInputName(face.id)], undefined);
|
||||
assert.equal(diagnostics.some((item) => item.level === "error"), false);
|
||||
});
|
||||
|
||||
test("one stage accepts more than three branches", () => {
|
||||
const branches = Array.from({ length: 12 }, (_, index) => branch(`b${index + 1}`, `方案 ${index + 1}`));
|
||||
const selected = branches.at(-1);
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入图像", value: ["100", 0] }),
|
||||
"10": node("FlowBranchGet", { channel: "输入图像" }),
|
||||
"20": node("SelectedProcessor", { image: ["10", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入图像",
|
||||
output_channel: "输出图像",
|
||||
pipeline_config: config([stage("s1", "处理后", branches, selected.id)]),
|
||||
[branchInputName(selected.id)]: ["20", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.deepEqual(
|
||||
prompt.output[generatedStageNodeId("2", 0)].inputs.selected_value,
|
||||
["20", 0],
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic selection picks the first branch whose named result survived bypass", () => {
|
||||
const basic = branch("basic", "基础放大");
|
||||
const sd = branch("sd", "SD 放大");
|
||||
const gpu = branch("gpu", "GPU 放大");
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "修脸结果", value: ["100", 0] }),
|
||||
"10": node("FlowBranchGet", { channel: "基础放大" }),
|
||||
"11": node("FlowBranchGet", { channel: "SD 放大" }),
|
||||
"12": node("FlowBranchGet", { channel: "GPU 放大" }),
|
||||
"20": node("FlowBranchGet", { channel: "修脸结果" }),
|
||||
"21": node("GpuUpscaler", { image: ["20", 0] }),
|
||||
"22": node("FlowBranchPublish", { channel: "GPU 放大", value: ["21", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "修脸结果",
|
||||
output_channel: "最终图像",
|
||||
pipeline_config: config([
|
||||
stage("upscale", "放大结果", [basic, sd, gpu], null, true, true),
|
||||
]),
|
||||
[branchInputName(basic.id)]: ["10", 0],
|
||||
[branchInputName(sd.id)]: ["11", 0],
|
||||
[branchInputName(gpu.id)]: ["12", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
const generated = prompt.output[generatedStageNodeId("2", 0)];
|
||||
assert.deepEqual(generated.inputs.selected_value, ["12", 0]);
|
||||
assert.equal(generated.inputs.selected_name, "GPU 放大");
|
||||
assert.deepEqual(prompt.output["12"].inputs.source, ["22", 0]);
|
||||
assert.equal(prompt.output["10"].inputs.source, undefined);
|
||||
assert.equal(prompt.output["11"].inputs.source, undefined);
|
||||
});
|
||||
|
||||
test("automatic selection ignores a bypassed direct branch that became the previous result", () => {
|
||||
const bypassed = branch("bypassed", "已旁路方案");
|
||||
const active = branch("active", "有效方案");
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
|
||||
"10": node("FlowBranchGet", { channel: "输入" }),
|
||||
"20": node("ActiveProcessor", { image: ["10", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入",
|
||||
output_channel: "输出",
|
||||
pipeline_config: config([
|
||||
stage("auto", "处理结果", [bypassed, active], null, true, true),
|
||||
]),
|
||||
[branchInputName(bypassed.id)]: ["10", 0],
|
||||
[branchInputName(active.id)]: ["20", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
const generated = prompt.output[generatedStageNodeId("2", 0)];
|
||||
assert.deepEqual(generated.inputs.selected_value, ["20", 0]);
|
||||
assert.equal(generated.inputs.selected_name, "有效方案");
|
||||
});
|
||||
|
||||
test("one pipeline accepts far more than three stages", () => {
|
||||
const stages = Array.from({ length: 20 }, (_, index) => (
|
||||
stage(`s${index}`, `阶段 ${index + 1} 后`, [], null, false)
|
||||
));
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入",
|
||||
output_channel: "输出",
|
||||
pipeline_config: config(stages),
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
for (let index = 0; index < stages.length; index += 1) {
|
||||
const generatedId = generatedStageNodeId("2", index);
|
||||
assert.ok(prompt.output[generatedId]);
|
||||
const expectedSource = index === 0 ? ["1", 0] : [generatedStageNodeId("2", index - 1), 0];
|
||||
assert.deepEqual(prompt.output[generatedId].inputs.source, expectedSource);
|
||||
}
|
||||
assert.deepEqual(prompt.output["2"].inputs.pipeline_result, [generatedStageNodeId("2", 19), 0]);
|
||||
});
|
||||
|
||||
test("a selected branch must read the immediately previous result", () => {
|
||||
const selected = branch("wrong", "错误方案");
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "原始图像", value: ["100", 0] }),
|
||||
"10": node("FlowBranchGet", { channel: "另一个结果" }),
|
||||
"20": node("Processor", { image: ["10", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "原始图像",
|
||||
output_channel: "最终图像",
|
||||
pipeline_config: config([stage("s1", "处理后", [selected], selected.id)]),
|
||||
[branchInputName(selected.id)]: ["20", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
const generated = prompt.output[generatedStageNodeId("2", 0)];
|
||||
|
||||
assert.match(generated.inputs.compile_error, /必须读取上一阶段.*原始图像/);
|
||||
assert.equal(generated.inputs.selected_value, undefined);
|
||||
});
|
||||
|
||||
test("disabled and unconnected stages bypass to the previous result", () => {
|
||||
const ignored = branch("ignored", "不会执行");
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入",
|
||||
output_channel: "输出",
|
||||
pipeline_config: config([
|
||||
stage("disabled", "阶段一", [ignored], ignored.id, false),
|
||||
stage("empty", "阶段二", [], null, true),
|
||||
]),
|
||||
[branchInputName(ignored.id)]: ["99", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
const firstStage = prompt.output[generatedStageNodeId("2", 0)];
|
||||
const secondStage = prompt.output[generatedStageNodeId("2", 1)];
|
||||
|
||||
assert.equal(firstStage.inputs.selected_value, undefined);
|
||||
assert.deepEqual(firstStage.inputs.source, ["1", 0]);
|
||||
assert.equal(secondStage.inputs.selected_value, undefined);
|
||||
assert.deepEqual(secondStage.inputs.source, [generatedStageNodeId("2", 0), 0]);
|
||||
});
|
||||
|
||||
test("a pipeline whose entire input chain was bypassed compiles as an empty branch", () => {
|
||||
const missing = branch("missing", "已绕过方案");
|
||||
const prompt = { output: {
|
||||
"10": node("FlowBranchGet", { channel: "已绕过方案" }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "已绕过起点",
|
||||
output_channel: "最终结果",
|
||||
pipeline_config: config([
|
||||
stage("s1", "处理结果", [missing], null, true, true),
|
||||
]),
|
||||
[branchInputName(missing.id)]: ["10", 0],
|
||||
}),
|
||||
"3": node("FlowBranchGet", { channel: "最终结果" }),
|
||||
"4": node("OptionalImageConsumer", {
|
||||
text: "仍然执行",
|
||||
optional_image: ["3", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
const diagnostics = compileFlowPrompt(prompt);
|
||||
const generatedId = generatedStageNodeId("2", 0);
|
||||
|
||||
assert.equal(prompt.output[generatedId].inputs.source, undefined);
|
||||
assert.equal(prompt.output[generatedId].inputs.selected_value, undefined);
|
||||
assert.equal(prompt.output[generatedId].inputs.compile_error, "");
|
||||
assert.equal(prompt.output["2"].inputs.compile_error, "");
|
||||
assert.equal(prompt.output["2"].inputs.pipeline_result, undefined);
|
||||
assert.equal(prompt.output["3"].inputs.source, undefined);
|
||||
assert.equal(prompt.output["4"].inputs.optional_image, undefined);
|
||||
assert.equal(prompt.output["4"].inputs.text, "仍然执行");
|
||||
assert.equal(diagnostics.some((item) => item.level === "error"), false);
|
||||
});
|
||||
|
||||
test("a missing named result is removed exactly like an unconnected optional input", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchGet", { channel: "参考图像" }),
|
||||
"274": node("TextEncodeQwenImageEditPlus", {
|
||||
prompt: "保留这个输入",
|
||||
reference_image: ["1", 0],
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.equal(prompt.output["274"].inputs.reference_image, undefined);
|
||||
assert.equal(prompt.output["274"].inputs.prompt, "保留这个输入");
|
||||
});
|
||||
|
||||
test("a missing named source activates a connected fallback instead of pruning the reader", () => {
|
||||
const prompt = { output: {
|
||||
"9": node("FallbackImage", { value: "fallback" }),
|
||||
"1": node("FlowBranchGet", { channel: "参考图像", fallback: ["9", 0] }),
|
||||
"274": node("OptionalImageConsumer", { image: ["1", 0] }),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.deepEqual(prompt.output["1"].inputs.fallback, ["9", 0]);
|
||||
assert.deepEqual(prompt.output["274"].inputs.image, ["1", 0]);
|
||||
});
|
||||
|
||||
test("an empty sender connected directly to an optional input is pruned", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "空结果" }),
|
||||
"2": node("OptionalConsumer", { optional_value: ["1", 0], keep: 42 }),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.equal(prompt.output["2"].inputs.optional_value, undefined);
|
||||
assert.equal(prompt.output["2"].inputs.keep, 42);
|
||||
});
|
||||
|
||||
test("a pipeline with no stages is a named passthrough", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入",
|
||||
output_channel: "输出",
|
||||
pipeline_config: config([]),
|
||||
}),
|
||||
"3": node("FlowBranchGet", { channel: "输出" }),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.deepEqual(prompt.output["2"].inputs.pipeline_result, ["1", 0]);
|
||||
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
|
||||
});
|
||||
|
||||
test("duplicate stage result names become clear blockers", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入",
|
||||
output_channel: "输出",
|
||||
pipeline_config: config([
|
||||
stage("s1", "重复名称"),
|
||||
stage("s2", "重复名称"),
|
||||
]),
|
||||
}),
|
||||
"3": node("FlowBranchGet", { channel: "输出" }),
|
||||
"4": node("OptionalConsumer", { value: ["3", 0] }),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.match(prompt.output["2"].inputs.compile_error, /阶段结果名称.*重复/);
|
||||
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
|
||||
assert.deepEqual(prompt.output["4"].inputs.value, ["3", 0]);
|
||||
});
|
||||
|
||||
test("an empty sender is unavailable and compilation does not throw", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入" }),
|
||||
"2": node("FlowBranchGet", { channel: "输入" }),
|
||||
} };
|
||||
|
||||
assert.doesNotThrow(() => compileFlowPrompt(prompt));
|
||||
assert.equal(prompt.output["2"].inputs.source, undefined);
|
||||
assert.equal(prompt.output["2"].inputs.compile_error, "");
|
||||
});
|
||||
|
||||
test("compilation never mutates unrelated nodes or random seed widgets", () => {
|
||||
const samplerInputs = {
|
||||
noise_seed: -1,
|
||||
control_after_generate: "randomize",
|
||||
steps: 20,
|
||||
};
|
||||
const prompt = { output: {
|
||||
"10": node("KSamplerAdvEfficient", samplerInputs),
|
||||
"11": node("FlowBranchGet", { channel: "missing" }),
|
||||
} };
|
||||
const before = structuredClone(prompt.output["10"]);
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.deepEqual(prompt.output["10"], before);
|
||||
});
|
||||
|
||||
test("compiling the same queued prompt twice does not duplicate generated stages", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
|
||||
"2": node("FlowBranchPipeline", {
|
||||
input_channel: "输入",
|
||||
output_channel: "输出",
|
||||
pipeline_config: config([stage("s1", "处理后")]),
|
||||
}),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
const firstIds = Object.keys(prompt.output).filter((id) => id.startsWith("__flowbranch_stage__"));
|
||||
compileFlowPrompt(prompt);
|
||||
const secondIds = Object.keys(prompt.output).filter((id) => id.startsWith("__flowbranch_stage__"));
|
||||
|
||||
assert.deepEqual(secondIds, firstIds);
|
||||
});
|
||||
|
||||
test("saved workflows using legacy stage nodes still compile", () => {
|
||||
const prompt = { output: {
|
||||
"1": node("FlowBranchPublish", { channel: "原始图像", value: ["100", 0] }),
|
||||
"2": node("FlowBranchStage", {
|
||||
input_channel: "原始图像",
|
||||
output_channel: "旧版阶段结果",
|
||||
enabled: false,
|
||||
}),
|
||||
"3": node("FlowBranchGet", { channel: "旧版阶段结果" }),
|
||||
} };
|
||||
|
||||
compileFlowPrompt(prompt);
|
||||
|
||||
assert.deepEqual(prompt.output["2"].inputs.source, ["1", 0]);
|
||||
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
nextAvailablePairedPosition,
|
||||
pairedNodePosition,
|
||||
uniquePublisherNode,
|
||||
} from "../web/node_actions.mjs";
|
||||
|
||||
test("paired reader is placed to the right without mutating its source", () => {
|
||||
const source = { pos: [100, 80], size: [250, 120] };
|
||||
|
||||
assert.deepEqual(pairedNodePosition(source), [390, 80]);
|
||||
assert.deepEqual(source, { pos: [100, 80], size: [250, 120] });
|
||||
});
|
||||
|
||||
test("additional paired readers avoid occupied node rectangles", () => {
|
||||
const source = { pos: [100, 80], size: [250, 120] };
|
||||
const reader = { size: [250, 80] };
|
||||
const occupied = { pos: [390, 80], size: [250, 80] };
|
||||
|
||||
assert.deepEqual(
|
||||
nextAvailablePairedPosition(source, reader, [source, occupied]),
|
||||
[390, 178],
|
||||
);
|
||||
});
|
||||
|
||||
test("navigation accepts repeated entries from the same pipeline node", () => {
|
||||
const pipeline = { id: 7 };
|
||||
|
||||
assert.equal(uniquePublisherNode([
|
||||
{ node: pipeline, key: "stage" },
|
||||
{ node: pipeline, key: "final" },
|
||||
]), pipeline);
|
||||
});
|
||||
|
||||
test("navigation refuses ambiguous publisher nodes", () => {
|
||||
assert.equal(uniquePublisherNode([
|
||||
{ node: { id: 1 } },
|
||||
{ node: { id: 2 } },
|
||||
]), null);
|
||||
assert.equal(uniquePublisherNode([]), null);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
from comfy_execution.graph_utils import ExecutionBlocker
|
||||
|
||||
from flow_branch_nodes import (
|
||||
MISSING,
|
||||
FlowGet,
|
||||
FlowIf,
|
||||
FlowPipeline,
|
||||
FlowPublish,
|
||||
FlowRoute,
|
||||
FlowStage,
|
||||
)
|
||||
|
||||
|
||||
def blocker_message(result):
|
||||
assert isinstance(result[0], ExecutionBlocker)
|
||||
return result[0].message
|
||||
|
||||
|
||||
def test_publish_without_input_is_a_clear_safety_error_if_compilation_was_skipped():
|
||||
assert "没有输入数据" in blocker_message(FlowPublish.publish("g1"))
|
||||
|
||||
|
||||
def test_get_uses_source_then_fallback():
|
||||
assert FlowGet.get("g1", fallback="old", source="new") == ("new",)
|
||||
assert FlowGet.get("g1", fallback="old") == ("old",)
|
||||
assert "找不到发布位置" in blocker_message(FlowGet.get("g1"))
|
||||
|
||||
|
||||
def test_stage_disabled_only_requests_source():
|
||||
needed = FlowStage.check_lazy_status("g1", "g2", False, processed=None, source=None)
|
||||
|
||||
assert needed == ["source"]
|
||||
assert FlowStage.select("g1", "g2", False, processed="face", source="base") == ("base",)
|
||||
|
||||
|
||||
def test_stage_enabled_requests_processed_and_falls_back_when_unconnected():
|
||||
assert FlowStage.check_lazy_status("g1", "g2", True, processed=None, source=None) == ["processed"]
|
||||
assert FlowStage.select("g1", "g2", True, processed="face", source="base") == ("face",)
|
||||
assert FlowStage.select("g1", "g2", True, processed=MISSING, source="base") == ("base",)
|
||||
|
||||
|
||||
def test_route_only_requests_selected_option():
|
||||
needed = FlowRoute.check_lazy_status(
|
||||
"g2", "g3", "方案 2", option_1=None, option_2=None, option_3=None, source=None,
|
||||
)
|
||||
|
||||
assert needed == ["option_2"]
|
||||
assert FlowRoute.select(
|
||||
"g2", "g3", "方案 2", option_1="a", option_2="b", option_3="c", source="base",
|
||||
) == ("b",)
|
||||
|
||||
|
||||
def test_route_bypass_and_unconnected_option_use_source():
|
||||
assert FlowRoute.check_lazy_status("g2", "g3", "旁路", source=None) == ["source"]
|
||||
assert FlowRoute.select("g2", "g3", "旁路", source="base") == ("base",)
|
||||
assert FlowRoute.select("g2", "g3", "方案 1", source="base") == ("base",)
|
||||
|
||||
|
||||
def test_if_is_lazy_and_allows_one_missing_branch():
|
||||
assert FlowIf.check_lazy_status(True, on_true=None, on_false=None) == ["on_true"]
|
||||
assert FlowIf.select(True, on_false="fallback") == ("fallback",)
|
||||
assert "两个分支都没有连接" in blocker_message(FlowIf.select(True))
|
||||
|
||||
|
||||
def test_legacy_missing_inputs_remain_clear_safety_errors_if_compilation_was_skipped():
|
||||
assert "没有可用数据" in blocker_message(FlowStage.select("g1", "g2", True))
|
||||
assert "没有可回退的数据" in blocker_message(FlowRoute.select("g1", "g2", "方案 1"))
|
||||
|
||||
|
||||
def test_compile_error_blocks_without_requesting_expensive_inputs():
|
||||
message = "通道存在循环依赖"
|
||||
|
||||
assert FlowStage.check_lazy_status("g1", "g2", True, processed=None, source=None, compile_error=message) == []
|
||||
assert message in blocker_message(
|
||||
FlowStage.select("g1", "g2", True, processed=MISSING, source=MISSING, compile_error=message)
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_visible_node_requests_only_the_compiled_final_result():
|
||||
config = FlowPipeline.EMPTY_CONFIG
|
||||
|
||||
assert FlowPipeline.check_lazy_status(
|
||||
"原始图像", "最终图像", config, pipeline_result=None, source=None,
|
||||
) == ["pipeline_result"]
|
||||
assert FlowPipeline.select(
|
||||
"原始图像", "最终图像", config, pipeline_result="finished", source="original",
|
||||
) == ("finished",)
|
||||
|
||||
|
||||
def test_pipeline_internal_stage_is_lazy_and_bypasses_when_no_plan_is_selected():
|
||||
internal = {"__stage_internal": True, "stage_name": "修脸后"}
|
||||
|
||||
assert FlowPipeline.check_lazy_status(
|
||||
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
|
||||
selected_value=None, source=None, **internal,
|
||||
) == ["selected_value"]
|
||||
assert FlowPipeline.select(
|
||||
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
|
||||
selected_value="repaired", source="original", **internal,
|
||||
) == ("repaired",)
|
||||
assert FlowPipeline.select(
|
||||
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
|
||||
source="original", **internal,
|
||||
) == ("original",)
|
||||
|
||||
|
||||
def test_pipeline_without_compiled_inputs_remains_a_clear_safety_error():
|
||||
assert "找不到起点结果" in blocker_message(FlowPipeline.select(
|
||||
"原始图像", "最终图像", FlowPipeline.EMPTY_CONFIG,
|
||||
))
|
||||
assert "找不到上一阶段结果" in blocker_message(FlowPipeline.select(
|
||||
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
|
||||
**{"__stage_internal": True, "stage_name": "修脸后"},
|
||||
))
|
||||
|
||||
|
||||
def test_pipeline_accepts_unlimited_frontend_defined_lazy_inputs():
|
||||
optional = FlowPipeline.INPUT_TYPES()["optional"]
|
||||
dynamic = optional["branch_any_stable_id"]
|
||||
|
||||
assert "branch_any_stable_id" in optional
|
||||
assert dynamic[0] == "*"
|
||||
assert dynamic[1]["lazy"] is True
|
||||
assert dynamic[1]["forceInput"] is True
|
||||
|
||||
|
||||
def test_pipeline_compile_error_blocks_before_selected_plan_runs():
|
||||
message = "方案没有读取上一阶段"
|
||||
|
||||
assert FlowPipeline.check_lazy_status(
|
||||
"原始图像", "最终图像", FlowPipeline.EMPTY_CONFIG,
|
||||
selected_value=None, source=None, compile_error=message,
|
||||
**{"__stage_internal": True},
|
||||
) == []
|
||||
assert message in blocker_message(
|
||||
FlowPipeline.select(
|
||||
"原始图像", "最终图像", FlowPipeline.EMPTY_CONFIG,
|
||||
compile_error=message, **{"__stage_internal": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_public_defaults_use_readable_names_instead_of_g_numbers():
|
||||
publish_default = FlowPublish.INPUT_TYPES()["required"]["channel"][1]["default"]
|
||||
get_default = FlowGet.INPUT_TYPES()["required"]["channel"][1]["default"]
|
||||
pipeline_required = FlowPipeline.INPUT_TYPES()["required"]
|
||||
|
||||
assert publish_default == "原始图像"
|
||||
assert get_default == "原始图像"
|
||||
assert pipeline_required["input_channel"][1]["default"] == "原始图像"
|
||||
assert pipeline_required["output_channel"][1]["default"] == "最终图像"
|
||||
|
||||
|
||||
def test_fixed_legacy_nodes_are_hidden_from_the_new_node_menu():
|
||||
for node_class in (FlowStage, FlowRoute, FlowIf):
|
||||
assert node_class.DEPRECATED is True
|
||||
assert node_class.CATEGORY == "流程分支/旧版"
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
branchInputName,
|
||||
createBranch,
|
||||
createStage,
|
||||
normalizePipelineConfig,
|
||||
parsePipelineConfig,
|
||||
serializePipelineConfig,
|
||||
} from "../web/pipeline_config.mjs";
|
||||
|
||||
test("configuration keeps an unlimited number of stages and branches", () => {
|
||||
const stages = Array.from({ length: 20 }, (_, stageIndex) => ({
|
||||
id: `s${stageIndex}`,
|
||||
name: `阶段 ${stageIndex + 1} 后`,
|
||||
enabled: true,
|
||||
autoSelect: stageIndex % 2 === 0,
|
||||
selected: `s${stageIndex}_b29`,
|
||||
branches: Array.from({ length: 30 }, (_, branchIndex) => ({
|
||||
id: `s${stageIndex}_b${branchIndex}`,
|
||||
name: `方案 ${branchIndex + 1}`,
|
||||
})),
|
||||
}));
|
||||
|
||||
const normalized = normalizePipelineConfig({ version: 2, stages });
|
||||
|
||||
assert.equal(normalized.stages.length, 20);
|
||||
assert.equal(normalized.stages[19].branches.length, 30);
|
||||
assert.equal(normalized.stages[19].selected, "s19_b29");
|
||||
assert.equal(normalized.stages[18].autoSelect, true);
|
||||
assert.equal(normalized.stages[19].autoSelect, false);
|
||||
});
|
||||
|
||||
test("malformed configuration becomes a safe empty pipeline", () => {
|
||||
assert.deepEqual(parsePipelineConfig("not json").stages, []);
|
||||
assert.deepEqual(parsePipelineConfig(null).stages, []);
|
||||
});
|
||||
|
||||
test("new rows use stable ids and readable names", () => {
|
||||
const first = createStage(1);
|
||||
const second = createStage(2);
|
||||
const option = createBranch(4);
|
||||
|
||||
assert.notEqual(first.id, second.id);
|
||||
assert.equal(first.name, "阶段 1 结果");
|
||||
assert.equal(first.autoSelect, false);
|
||||
assert.equal(option.name, "方案 4");
|
||||
assert.match(branchInputName(option.id), /^branch_[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
test("an explicitly cleared stage name stays empty for validation", () => {
|
||||
const normalized = normalizePipelineConfig({
|
||||
stages: [{ id: "s1", name: "", enabled: true, selected: null, branches: [] }],
|
||||
});
|
||||
|
||||
assert.equal(normalized.stages[0].name, "");
|
||||
});
|
||||
|
||||
test("copy and workflow reload preserve ids order switches and selection", () => {
|
||||
const original = {
|
||||
stages: [
|
||||
{
|
||||
id: "face-stage",
|
||||
name: "修脸后",
|
||||
enabled: false,
|
||||
autoSelect: false,
|
||||
selected: "codeformer",
|
||||
branches: [
|
||||
{ id: "facedetailer", name: "FaceDetailer" },
|
||||
{ id: "codeformer", name: "CodeFormer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "upscale-stage",
|
||||
name: "放大后",
|
||||
enabled: true,
|
||||
autoSelect: true,
|
||||
selected: null,
|
||||
branches: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const restored = parsePipelineConfig(serializePipelineConfig(original));
|
||||
|
||||
assert.deepEqual(restored, { version: 2, ...original });
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
calculateSlotCanvasY,
|
||||
calculateStackContentHeight,
|
||||
} from "../web/pipeline_editor.js";
|
||||
|
||||
test("pipeline panel height is based only on children, gaps and padding", () => {
|
||||
const height = calculateStackContentHeight([28, 116, 28], 8, 5, 9);
|
||||
|
||||
assert.equal(height, 202);
|
||||
});
|
||||
|
||||
test("pipeline layout never feeds the allocated container height back into sizing", async () => {
|
||||
const source = await readFile(new URL("../web/pipeline_editor.js", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /root\.scrollHeight/);
|
||||
});
|
||||
|
||||
test("custom branch slot positions cannot push the DOM widget down every frame", async () => {
|
||||
const source = await readFile(new URL("../web/pipeline_editor.js", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /node\.widgets_start_y\s*=\s*PIPELINE_WIDGET_START_Y/);
|
||||
assert.doesNotMatch(source, /node\.computeSize\?\.\(\)/);
|
||||
});
|
||||
|
||||
test("branch sockets include the DOM widget margin when aligning to rows", () => {
|
||||
assert.equal(calculateSlotCanvasY(4, 120, 10), 134);
|
||||
});
|
||||
|
||||
test("dynamic socket labels do not expose internal branch ids", async () => {
|
||||
const source = await readFile(new URL("../web/pipeline_editor.js", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /input\.label\s*=\s*" "/);
|
||||
assert.doesNotMatch(source, /input\.label\s*=\s*""/);
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import { branchInputName, parsePipelineConfig } from "./pipeline_config.mjs";
|
||||
|
||||
export const NODE_TYPES = Object.freeze({
|
||||
publish: "FlowBranchPublish",
|
||||
get: "FlowBranchGet",
|
||||
pipeline: "FlowBranchPipeline",
|
||||
legacyStage: "FlowBranchStage",
|
||||
legacyRoute: "FlowBranchRoute",
|
||||
legacyIf: "FlowBranchIf",
|
||||
});
|
||||
|
||||
const LEGACY_PUBLISHERS = new Set([NODE_TYPES.legacyStage, NODE_TYPES.legacyRoute]);
|
||||
const LEGACY_CONSUMERS = new Set([NODE_TYPES.legacyStage, NODE_TYPES.legacyRoute]);
|
||||
const COMPILED_PROMPTS = new WeakMap();
|
||||
|
||||
export function normalizeChannel(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function generatedStageNodeId(pipelineId, stageIndex) {
|
||||
return `__flowbranch_stage__${String(pipelineId)}__${stageIndex}`;
|
||||
}
|
||||
|
||||
function isLink(value) {
|
||||
return Array.isArray(value) && value.length === 2;
|
||||
}
|
||||
|
||||
function outputChannel(node) {
|
||||
if (node.class_type === NODE_TYPES.publish) return normalizeChannel(node.inputs?.channel);
|
||||
if (LEGACY_PUBLISHERS.has(node.class_type)) return normalizeChannel(node.inputs?.output_channel);
|
||||
if (node.class_type === NODE_TYPES.pipeline) return normalizeChannel(node.inputs?.output_channel);
|
||||
return "";
|
||||
}
|
||||
|
||||
function inputChannel(node) {
|
||||
if (node.class_type === NODE_TYPES.get) return normalizeChannel(node.inputs?.channel);
|
||||
if (LEGACY_CONSUMERS.has(node.class_type)) return normalizeChannel(node.inputs?.input_channel);
|
||||
if (node.class_type === NODE_TYPES.pipeline) return normalizeChannel(node.inputs?.input_channel);
|
||||
return "";
|
||||
}
|
||||
|
||||
function publisherHasValue(node) {
|
||||
return node.class_type !== NODE_TYPES.publish
|
||||
|| Object.prototype.hasOwnProperty.call(node.inputs || {}, "value");
|
||||
}
|
||||
|
||||
function addPublisher(publishers, channel, publisher) {
|
||||
if (!channel) return;
|
||||
const matches = publishers.get(channel) || [];
|
||||
matches.push(publisher);
|
||||
publishers.set(channel, matches);
|
||||
}
|
||||
|
||||
function channelConflictMessage(channel, matches) {
|
||||
const ids = matches.map((item) => `#${item.id}`).join("、");
|
||||
return `结果名称“${channel}”存在多个发布位置(${ids}),请改成唯一名称。`;
|
||||
}
|
||||
|
||||
function resolvePublisher(publishers, channel, consumerId, excludedPipelineId = null) {
|
||||
const matches = (publishers.get(channel) || []).filter((item) => {
|
||||
if (String(item.id) === String(consumerId)) return false;
|
||||
if (excludedPipelineId !== null && String(item.pipelineId) === String(excludedPipelineId)) return false;
|
||||
return true;
|
||||
});
|
||||
if (matches.length === 1) return { link: [matches[0].id, 0], matches };
|
||||
if (matches.length > 1) return { error: channelConflictMessage(channel, matches), matches };
|
||||
return { matches };
|
||||
}
|
||||
|
||||
function collectUpstreamFlowChannels(output, startLink) {
|
||||
const channels = new Set();
|
||||
const pending = isLink(startLink) ? [String(startLink[0])] : [];
|
||||
const visited = new Set();
|
||||
while (pending.length) {
|
||||
const nodeId = pending.pop();
|
||||
if (visited.has(nodeId)) continue;
|
||||
visited.add(nodeId);
|
||||
const node = output[nodeId];
|
||||
if (!node) continue;
|
||||
if (node.class_type === NODE_TYPES.get) channels.add(normalizeChannel(node.inputs?.channel));
|
||||
if (node.class_type === NODE_TYPES.publish) channels.add(normalizeChannel(node.inputs?.channel));
|
||||
if (node.class_type === NODE_TYPES.pipeline || LEGACY_PUBLISHERS.has(node.class_type)) {
|
||||
channels.add(outputChannel(node));
|
||||
}
|
||||
for (const value of Object.values(node.inputs || {})) {
|
||||
if (isLink(value)) pending.push(String(value[0]));
|
||||
}
|
||||
}
|
||||
channels.delete("");
|
||||
return channels;
|
||||
}
|
||||
|
||||
function branchLinkIsAvailable(output, publishers, link, expectedInput) {
|
||||
if (!isLink(link)) return false;
|
||||
const source = output[String(link[0])];
|
||||
if (!source) return false;
|
||||
if (source.class_type === NODE_TYPES.get) {
|
||||
const channel = inputChannel(source);
|
||||
if (channel === expectedInput) return false;
|
||||
if (isLink(source.inputs?.fallback)) return true;
|
||||
return Boolean(resolvePublisher(publishers, channel, String(link[0])).link);
|
||||
}
|
||||
if (source.class_type === NODE_TYPES.publish) return publisherHasValue(source);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flowNodeIsUnavailable(node) {
|
||||
const inputs = node?.inputs || {};
|
||||
if (normalizeChannel(inputs.compile_error)) return false;
|
||||
if (node.class_type === NODE_TYPES.publish) return !publisherHasValue(node);
|
||||
if (node.class_type === NODE_TYPES.get) {
|
||||
return !isLink(inputs.source) && !isLink(inputs.fallback);
|
||||
}
|
||||
if (node.class_type === NODE_TYPES.pipeline) {
|
||||
return inputs.__stage_internal
|
||||
? !isLink(inputs.selected_value) && !isLink(inputs.source)
|
||||
: !isLink(inputs.pipeline_result) && !isLink(inputs.source);
|
||||
}
|
||||
if (node.class_type === NODE_TYPES.legacyStage) {
|
||||
if (inputs.enabled !== false && isLink(inputs.processed)) return false;
|
||||
return !isLink(inputs.source);
|
||||
}
|
||||
if (node.class_type === NODE_TYPES.legacyRoute) {
|
||||
const optionName = {
|
||||
"方案 1": "option_1",
|
||||
"方案 2": "option_2",
|
||||
"方案 3": "option_3",
|
||||
}[inputs.route];
|
||||
if (optionName && isLink(inputs[optionName])) return false;
|
||||
return !isLink(inputs.source);
|
||||
}
|
||||
if (node.class_type === NODE_TYPES.legacyIf) {
|
||||
return !isLink(inputs.on_true) && !isLink(inputs.on_false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pruneUnavailableFlowLinks(output) {
|
||||
const unavailable = new Set();
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [nodeId, node] of Object.entries(output)) {
|
||||
if (!unavailable.has(nodeId) && flowNodeIsUnavailable(node)) {
|
||||
unavailable.add(nodeId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const node of Object.values(output)) {
|
||||
for (const [name, value] of Object.entries(node.inputs || {})) {
|
||||
if (!isLink(value)) continue;
|
||||
const sourceId = String(value[0]);
|
||||
if (unavailable.has(sourceId)) {
|
||||
delete node.inputs[name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return unavailable;
|
||||
}
|
||||
|
||||
function findLegacyCycleErrors(publishers) {
|
||||
const dependencyByOutput = new Map();
|
||||
for (const [channel, matches] of publishers) {
|
||||
if (matches.length !== 1) continue;
|
||||
const publisher = matches[0];
|
||||
if (!publisher.node || !LEGACY_PUBLISHERS.has(publisher.node.class_type)) continue;
|
||||
const dependency = inputChannel(publisher.node);
|
||||
if (dependency) dependencyByOutput.set(channel, { dependency, publisher });
|
||||
}
|
||||
|
||||
const errors = new Map();
|
||||
for (const start of dependencyByOutput.keys()) {
|
||||
const path = [];
|
||||
const positions = new Map();
|
||||
let channel = start;
|
||||
while (dependencyByOutput.has(channel)) {
|
||||
if (positions.has(channel)) {
|
||||
const cycle = path.slice(positions.get(channel));
|
||||
const message = `结果名称存在循环依赖:${[...cycle, channel].join(" → ")}`;
|
||||
for (const item of cycle) errors.set(dependencyByOutput.get(item).publisher.id, message);
|
||||
break;
|
||||
}
|
||||
positions.set(channel, path.length);
|
||||
path.push(channel);
|
||||
channel = dependencyByOutput.get(channel).dependency;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function cleanCompilerInputs(node) {
|
||||
if (!node?.inputs) return;
|
||||
if (node.class_type === NODE_TYPES.get || LEGACY_CONSUMERS.has(node.class_type)) {
|
||||
delete node.inputs.source;
|
||||
node.inputs.compile_error = "";
|
||||
}
|
||||
if (node.class_type === NODE_TYPES.pipeline && !node.inputs.__stage_internal) {
|
||||
delete node.inputs.source;
|
||||
delete node.inputs.pipeline_result;
|
||||
node.inputs.compile_error = "";
|
||||
}
|
||||
}
|
||||
|
||||
function makeGeneratedStage(pipeline, stage, stageIndex, expectedInput, sourceLink) {
|
||||
const id = generatedStageNodeId(pipeline.id, stageIndex);
|
||||
const inputs = {
|
||||
input_channel: expectedInput,
|
||||
output_channel: stage.name,
|
||||
pipeline_config: "{\"version\":2,\"stages\":[]}",
|
||||
__stage_internal: true,
|
||||
__flow_generated: true,
|
||||
stage_name: stage.name,
|
||||
compile_error: "",
|
||||
};
|
||||
if (sourceLink) inputs.source = sourceLink;
|
||||
return {
|
||||
id,
|
||||
node: {
|
||||
class_type: NODE_TYPES.pipeline,
|
||||
inputs,
|
||||
_meta: { title: `${pipeline.node._meta?.title || "流程编排器"} / ${stage.name}` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerPublishers(nodes, pipelines) {
|
||||
const publishers = new Map();
|
||||
for (const item of nodes) {
|
||||
if (item.node.class_type === NODE_TYPES.publish && publisherHasValue(item.node)) {
|
||||
addPublisher(publishers, outputChannel(item.node), { ...item, pipelineId: null });
|
||||
} else if (LEGACY_PUBLISHERS.has(item.node.class_type)) {
|
||||
addPublisher(publishers, outputChannel(item.node), { ...item, pipelineId: null });
|
||||
}
|
||||
}
|
||||
for (const pipeline of pipelines) {
|
||||
pipeline.config.stages.forEach((stage, stageIndex) => {
|
||||
addPublisher(publishers, normalizeChannel(stage.name), {
|
||||
id: generatedStageNodeId(pipeline.id, stageIndex),
|
||||
node: null,
|
||||
pipelineId: pipeline.id,
|
||||
stageIndex,
|
||||
});
|
||||
});
|
||||
const finalChannel = outputChannel(pipeline.node);
|
||||
addPublisher(publishers, finalChannel, {
|
||||
id: pipeline.id,
|
||||
node: pipeline.node,
|
||||
pipelineId: pipeline.id,
|
||||
stageIndex: null,
|
||||
});
|
||||
}
|
||||
return publishers;
|
||||
}
|
||||
|
||||
function compilePipeline(output, pipeline, publishers, diagnostics) {
|
||||
const inputs = pipeline.node.inputs || (pipeline.node.inputs = {});
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const stageNames = new Set();
|
||||
const branchLinks = new Map();
|
||||
for (const [name, value] of Object.entries(inputs)) {
|
||||
if (name.startsWith("branch_") && isLink(value)) branchLinks.set(name, value);
|
||||
}
|
||||
|
||||
const startChannel = inputChannel(pipeline.node);
|
||||
const finalChannel = outputChannel(pipeline.node);
|
||||
if (!startChannel) errors.push("起点结果名称不能为空。");
|
||||
if (!finalChannel) errors.push("最终发布名称不能为空。");
|
||||
|
||||
for (const stage of pipeline.config.stages) {
|
||||
const stageName = normalizeChannel(stage.name);
|
||||
if (!stageName) errors.push("阶段结果名称不能为空。");
|
||||
else if (stageNames.has(stageName)) errors.push(`阶段结果名称“${stageName}”重复。`);
|
||||
stageNames.add(stageName);
|
||||
}
|
||||
|
||||
for (const channel of [...stageNames, finalChannel].filter(Boolean)) {
|
||||
const matches = publishers.get(channel) || [];
|
||||
if (matches.length > 1) errors.push(channelConflictMessage(channel, matches));
|
||||
}
|
||||
|
||||
const start = resolvePublisher(publishers, startChannel, pipeline.id, pipeline.id);
|
||||
if (start.error) errors.push(start.error);
|
||||
else if (!start.link && startChannel) {
|
||||
warnings.push(`起点结果“${startChannel}”本次没有可用数据,流程将保持为空。`);
|
||||
}
|
||||
|
||||
let previousLink = start.link;
|
||||
let previousAvailable = Boolean(start.link);
|
||||
let expectedInput = startChannel;
|
||||
for (const [stageIndex, stage] of pipeline.config.stages.entries()) {
|
||||
const generated = makeGeneratedStage(
|
||||
pipeline,
|
||||
stage,
|
||||
stageIndex,
|
||||
expectedInput,
|
||||
previousAvailable ? previousLink : null,
|
||||
);
|
||||
const stageErrors = [...errors];
|
||||
if (stage.enabled && previousAvailable) {
|
||||
let selectedBranch = null;
|
||||
let selectedLink = null;
|
||||
if (stage.autoSelect) {
|
||||
const available = stage.branches.flatMap((branch) => {
|
||||
const link = branchLinks.get(branchInputName(branch.id));
|
||||
return branchLinkIsAvailable(output, publishers, link, expectedInput)
|
||||
? [{ branch, link }]
|
||||
: [];
|
||||
});
|
||||
if (available.length) {
|
||||
({ branch: selectedBranch, link: selectedLink } = available[0]);
|
||||
if (available.length > 1) {
|
||||
warnings.push(
|
||||
`阶段“${stage.name}”检测到多个可用方案,已按从上到下选择“${selectedBranch.name}”。`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`阶段“${stage.name}”没有可用方案,将直接沿用上一阶段。`);
|
||||
}
|
||||
} else if (stage.selected) {
|
||||
selectedBranch = stage.branches.find((item) => item.id === stage.selected) || null;
|
||||
selectedLink = selectedBranch ? branchLinks.get(branchInputName(selectedBranch.id)) : null;
|
||||
}
|
||||
|
||||
if (selectedBranch && selectedLink) {
|
||||
const upstreamChannels = collectUpstreamFlowChannels(output, selectedLink);
|
||||
if (!upstreamChannels.has(expectedInput)) {
|
||||
stageErrors.push(
|
||||
`阶段“${stage.name}”的方案“${selectedBranch.name}”必须读取上一阶段“${expectedInput}”,`
|
||||
+ "否则会跳过前面的处理。",
|
||||
);
|
||||
} else {
|
||||
generated.node.inputs.selected_value = selectedLink;
|
||||
generated.node.inputs.selected_name = selectedBranch.name;
|
||||
}
|
||||
} else if (!stage.autoSelect && stage.selected && !selectedLink) {
|
||||
warnings.push(`阶段“${stage.name}”选中的方案未连接,将直接沿用上一阶段。`);
|
||||
}
|
||||
}
|
||||
if (stageErrors.length) generated.node.inputs.compile_error = [...new Set(stageErrors)].join(" ");
|
||||
output[generated.id] = generated.node;
|
||||
previousLink = [generated.id, 0];
|
||||
expectedInput = normalizeChannel(stage.name);
|
||||
}
|
||||
|
||||
for (const name of [...Object.keys(inputs)]) {
|
||||
if (name.startsWith("branch_")) delete inputs[name];
|
||||
}
|
||||
|
||||
if (previousAvailable && previousLink) inputs.pipeline_result = previousLink;
|
||||
if (errors.length) inputs.compile_error = [...new Set(errors)].join(" ");
|
||||
|
||||
if (errors.length) {
|
||||
diagnostics.push({ nodeId: pipeline.id, level: "error", message: inputs.compile_error });
|
||||
} else if (warnings.length || pipeline.config.stages.length === 0) {
|
||||
diagnostics.push({
|
||||
nodeId: pipeline.id,
|
||||
level: "warning",
|
||||
message: warnings[0] || "尚未添加阶段,将直接传递起点结果。",
|
||||
});
|
||||
} else {
|
||||
diagnostics.push({
|
||||
nodeId: pipeline.id,
|
||||
level: "ok",
|
||||
message: `已编排 ${pipeline.config.stages.length} 个阶段。`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function compileNamedConsumers(nodes, publishers, diagnostics) {
|
||||
const cycleErrors = findLegacyCycleErrors(publishers);
|
||||
for (const consumer of nodes) {
|
||||
if (consumer.node.class_type !== NODE_TYPES.get && !LEGACY_CONSUMERS.has(consumer.node.class_type)) {
|
||||
continue;
|
||||
}
|
||||
const inputs = consumer.node.inputs || (consumer.node.inputs = {});
|
||||
if (cycleErrors.has(consumer.id)) {
|
||||
inputs.compile_error = cycleErrors.get(consumer.id);
|
||||
diagnostics.push({ nodeId: consumer.id, level: "error", message: inputs.compile_error });
|
||||
continue;
|
||||
}
|
||||
const channel = inputChannel(consumer.node);
|
||||
if (!channel) {
|
||||
diagnostics.push({ nodeId: consumer.id, level: "warning", message: "读取结果名称为空。" });
|
||||
continue;
|
||||
}
|
||||
const resolved = resolvePublisher(publishers, channel, consumer.id);
|
||||
if (resolved.link) {
|
||||
inputs.source = resolved.link;
|
||||
diagnostics.push({ nodeId: consumer.id, level: "ok", message: `已读取“${channel}”。` });
|
||||
} else if (resolved.error) {
|
||||
inputs.compile_error = resolved.error;
|
||||
diagnostics.push({ nodeId: consumer.id, level: "error", message: resolved.error });
|
||||
} else {
|
||||
diagnostics.push({ nodeId: consumer.id, level: "warning", message: `找不到结果“${channel}”,运行时尝试回退。` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectFlowPrompt(output) {
|
||||
const nodes = Object.entries(output || {}).map(([id, node]) => ({ id: String(id), node }));
|
||||
const pipelines = nodes
|
||||
.filter((item) => item.node.class_type === NODE_TYPES.pipeline && !item.node.inputs?.__stage_internal)
|
||||
.map((item) => ({ ...item, config: parsePipelineConfig(item.node.inputs?.pipeline_config) }));
|
||||
return { nodes, pipelines };
|
||||
}
|
||||
|
||||
export function compileFlowPrompt(prompt) {
|
||||
if (!prompt || typeof prompt !== "object") return [];
|
||||
if (COMPILED_PROMPTS.has(prompt)) return COMPILED_PROMPTS.get(prompt);
|
||||
const output = prompt.output || (prompt.output = {});
|
||||
if (Object.values(output).some((node) => node?.inputs?.__flow_generated)) return [];
|
||||
|
||||
const { nodes, pipelines } = inspectFlowPrompt(output);
|
||||
for (const item of nodes) cleanCompilerInputs(item.node);
|
||||
const publishers = registerPublishers(nodes, pipelines);
|
||||
const diagnostics = [];
|
||||
compileNamedConsumers(nodes, publishers, diagnostics);
|
||||
for (const pipeline of pipelines) compilePipeline(output, pipeline, publishers, diagnostics);
|
||||
pruneUnavailableFlowLinks(output);
|
||||
COMPILED_PROMPTS.set(prompt, diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
.fb-pipeline-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 5px 9px 9px 20px;
|
||||
box-sizing: border-box;
|
||||
color: var(--input-text, #e6e6e6);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.fb-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color, #4a4f58);
|
||||
border-left: 3px solid #5aa37a;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--comfy-input-bg, #222) 88%, transparent);
|
||||
}
|
||||
|
||||
.fb-stage-disabled {
|
||||
border-left-color: #7b8492;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.fb-stage-header {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fb-stage-toggle,
|
||||
.fb-branch-radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: #5aa37a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fb-stage-names {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(78px, 0.75fr) minmax(96px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fb-stage-source {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-stage-index {
|
||||
flex: 0 0 auto;
|
||||
color: var(--input-text, #e6e6e6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fb-stage-path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--descrip-text, #aeb7c3);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fb-name-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border-color, #4a4f58);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background: var(--comfy-input-bg, #222);
|
||||
color: var(--input-text, #e6e6e6);
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.fb-name-input:focus {
|
||||
border-color: #67a8d8;
|
||||
}
|
||||
|
||||
.fb-stage-actions {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.fb-icon-button {
|
||||
display: inline-flex;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #4a4f58) 65%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--descrip-text, #b8c0cc);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fb-icon-button:hover {
|
||||
border-color: var(--border-color, #4a4f58);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--input-text, #fff);
|
||||
}
|
||||
|
||||
.fb-icon-button:disabled {
|
||||
border-color: transparent;
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fb-icon-button:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--descrip-text, #b8c0cc);
|
||||
}
|
||||
|
||||
.fb-delete-button:hover {
|
||||
border-color: #b85d66;
|
||||
background: rgba(184, 93, 102, 0.12);
|
||||
color: #ef9aa2;
|
||||
}
|
||||
|
||||
.fb-branch-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 48px 26px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.fb-bypass-row {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--descrip-text, #b8c0cc);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fb-bypass-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.fb-bypass-row.fb-branch-selected {
|
||||
border-color: color-mix(in srgb, #5aa37a 48%, transparent);
|
||||
background: rgba(90, 163, 122, 0.08);
|
||||
color: var(--input-text, #e6e6e6);
|
||||
}
|
||||
|
||||
.fb-bypass-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-auto-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--descrip-text, #b8c0cc);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fb-auto-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.fb-auto-enabled {
|
||||
border-color: color-mix(in srgb, #67a8d8 52%, transparent);
|
||||
background: rgba(103, 168, 216, 0.09);
|
||||
color: var(--input-text, #e6e6e6);
|
||||
}
|
||||
|
||||
.fb-auto-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: #67a8d8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fb-auto-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-auto-hint {
|
||||
color: var(--descrip-text, #9aa4b2);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-stage-auto {
|
||||
border-left-color: #67a8d8;
|
||||
}
|
||||
|
||||
.fb-branch-selected .fb-branch-name {
|
||||
border-color: #5aa37a;
|
||||
box-shadow: inset 2px 0 0 #5aa37a;
|
||||
}
|
||||
|
||||
.fb-connection-status {
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-is-connected {
|
||||
color: #65bd8b;
|
||||
}
|
||||
|
||||
.fb-is-unconnected {
|
||||
color: #d3a340;
|
||||
}
|
||||
|
||||
.fb-add-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color, #4a4f58);
|
||||
border-radius: 4px;
|
||||
background: var(--comfy-input-bg, #222);
|
||||
color: var(--input-text, #e6e6e6);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.fb-add-button:hover {
|
||||
border-color: #67a8d8;
|
||||
background: rgba(103, 168, 216, 0.1);
|
||||
}
|
||||
|
||||
.fb-add-branch {
|
||||
height: 26px;
|
||||
color: var(--descrip-text, #b8c0cc);
|
||||
}
|
||||
|
||||
.fb-empty-state {
|
||||
padding: 8px 4px;
|
||||
color: var(--descrip-text, #9aa4b2);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { compileFlowPrompt, NODE_TYPES, normalizeChannel } from "./compiler.mjs";
|
||||
import { nextAvailablePairedPosition, uniquePublisherNode } from "./node_actions.mjs";
|
||||
import { parsePipelineConfig } from "./pipeline_config.mjs";
|
||||
import { PIPELINE_NODE_MIN_WIDTH, setupPipelineEditor } from "./pipeline_editor.js";
|
||||
|
||||
const FLOW_TYPES = new Set(Object.values(NODE_TYPES));
|
||||
const LEGACY_PUBLISHERS = new Set([NODE_TYPES.legacyStage, NODE_TYPES.legacyRoute]);
|
||||
const LEGACY_CONSUMERS = new Set([NODE_TYPES.legacyStage, NODE_TYPES.legacyRoute]);
|
||||
const COLORS = { ok: "#56b889", warning: "#d9a441", error: "#df6666", idle: "#7b8492" };
|
||||
const STATUS_PRIORITY = { idle: 0, ok: 1, warning: 2, error: 3 };
|
||||
const INACTIVE_MODES = new Set([2, 4]);
|
||||
const INTERNAL_INPUTS = ["source", "pipeline_result", "selected_value"];
|
||||
const INTERNAL_WIDGETS = [
|
||||
"compile_error",
|
||||
"pipeline_config",
|
||||
"stage_name",
|
||||
"selected_name",
|
||||
"__stage_internal",
|
||||
"__flow_generated",
|
||||
];
|
||||
const LABELS = {
|
||||
channel: "结果名称",
|
||||
value: "数据",
|
||||
fallback: "找不到时使用",
|
||||
input_channel: "起点结果",
|
||||
output_channel: "最终发布为",
|
||||
enabled: "启用阶段",
|
||||
processed: "处理结果",
|
||||
route: "选择方案",
|
||||
option_1: "方案 1 结果",
|
||||
option_2: "方案 2 结果",
|
||||
option_3: "方案 3 结果",
|
||||
condition: "条件",
|
||||
on_true: "为真时",
|
||||
on_false: "为假时",
|
||||
};
|
||||
const OUTPUT_LABELS = {
|
||||
[NODE_TYPES.publish]: "数据",
|
||||
[NODE_TYPES.get]: "数据",
|
||||
[NODE_TYPES.pipeline]: "流程结果",
|
||||
[NODE_TYPES.legacyStage]: "阶段结果",
|
||||
[NODE_TYPES.legacyRoute]: "选择结果",
|
||||
[NODE_TYPES.legacyIf]: "选择结果",
|
||||
};
|
||||
let refreshPending = false;
|
||||
|
||||
function ensureStyles() {
|
||||
const id = "flow-branch-styles";
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = new URL("./flow_branch.css", import.meta.url).href;
|
||||
document.head.append(link);
|
||||
}
|
||||
|
||||
function nodeType(node) {
|
||||
return node?.comfyClass || node?.type || "";
|
||||
}
|
||||
|
||||
function widget(node, name) {
|
||||
return node.widgets?.find((item) => item.name === name);
|
||||
}
|
||||
|
||||
function input(node, name) {
|
||||
return node.inputs?.find((item) => item.name === name);
|
||||
}
|
||||
|
||||
function isConnected(slot) {
|
||||
return slot?.link !== null && slot?.link !== undefined;
|
||||
}
|
||||
|
||||
function channelFor(node, direction) {
|
||||
const type = nodeType(node);
|
||||
const name = direction === "out"
|
||||
? (type === NODE_TYPES.publish ? "channel" : "output_channel")
|
||||
: (type === NODE_TYPES.get ? "channel" : "input_channel");
|
||||
return normalizeChannel(widget(node, name)?.value);
|
||||
}
|
||||
|
||||
function removeInputByName(node, name) {
|
||||
const index = node.inputs?.findIndex((item) => item.name === name) ?? -1;
|
||||
if (index >= 0) node.removeInput(index);
|
||||
}
|
||||
|
||||
function hideWidget(item) {
|
||||
if (!item) return;
|
||||
item.hidden = true;
|
||||
item.computeSize = () => [0, -4];
|
||||
item.options = { ...(item.options || {}), serialize: true };
|
||||
}
|
||||
|
||||
function hideInternalControls(node) {
|
||||
for (const name of INTERNAL_INPUTS) removeInputByName(node, name);
|
||||
for (const name of INTERNAL_WIDGETS) hideWidget(widget(node, name));
|
||||
}
|
||||
|
||||
function applyChineseLabels(node) {
|
||||
for (const item of node.widgets || []) {
|
||||
if (LABELS[item.name]) item.label = LABELS[item.name];
|
||||
}
|
||||
for (const item of node.inputs || []) {
|
||||
if (LABELS[item.name]) item.label = LABELS[item.name];
|
||||
}
|
||||
const outputLabel = OUTPUT_LABELS[nodeType(node)];
|
||||
if (outputLabel && node.outputs?.[0]) node.outputs[0].label = outputLabel;
|
||||
}
|
||||
|
||||
function setStatus(node, level, text, detail = text) {
|
||||
node.__flowBranchStatus = { level, text, detail };
|
||||
node.boxcolor = COLORS[level] || COLORS.idle;
|
||||
}
|
||||
|
||||
function setHigherPriorityStatus(node, level, text, detail = text) {
|
||||
const current = node.__flowBranchStatus;
|
||||
if (!current || STATUS_PRIORITY[level] >= STATUS_PRIORITY[current.level]) {
|
||||
setStatus(node, level, text, detail);
|
||||
}
|
||||
}
|
||||
|
||||
function addPublisher(publishers, channel, node, key = channel) {
|
||||
if (!channel) return;
|
||||
const matches = publishers.get(channel) || [];
|
||||
matches.push({ node, key });
|
||||
publishers.set(channel, matches);
|
||||
}
|
||||
|
||||
function publisherHasValue(node) {
|
||||
return nodeType(node) !== NODE_TYPES.publish || isConnected(input(node, "value"));
|
||||
}
|
||||
|
||||
function registerPublishers(nodes, requireValue = true) {
|
||||
const publishers = new Map();
|
||||
for (const node of nodes) {
|
||||
const type = nodeType(node);
|
||||
if (type === NODE_TYPES.publish) {
|
||||
const channel = channelFor(node, "out");
|
||||
if (!requireValue || publisherHasValue(node)) addPublisher(publishers, channel, node);
|
||||
} else if (LEGACY_PUBLISHERS.has(type)) {
|
||||
addPublisher(publishers, channelFor(node, "out"), node);
|
||||
} else if (type === NODE_TYPES.pipeline) {
|
||||
const config = parsePipelineConfig(widget(node, "pipeline_config")?.value);
|
||||
for (const stage of config.stages) addPublisher(publishers, normalizeChannel(stage.name), node, stage.id);
|
||||
addPublisher(publishers, channelFor(node, "out"), node, "final");
|
||||
}
|
||||
}
|
||||
return publishers;
|
||||
}
|
||||
|
||||
function registerLivePublishers(nodes) {
|
||||
return registerPublishers(nodes, true);
|
||||
}
|
||||
|
||||
function graphPublishers(graph) {
|
||||
return registerPublishers(graph?._nodes || [], false);
|
||||
}
|
||||
|
||||
function createReaderNode(sourceNode, channel) {
|
||||
const normalized = normalizeChannel(channel);
|
||||
const graph = sourceNode?.graph || app.graph;
|
||||
const reader = globalThis.LiteGraph?.createNode?.(NODE_TYPES.get);
|
||||
if (!normalized || !graph || !reader) return null;
|
||||
|
||||
reader.pos = nextAvailablePairedPosition(sourceNode, reader, graph._nodes || []);
|
||||
graph.add(reader);
|
||||
const channelWidget = widget(reader, "channel");
|
||||
if (channelWidget) {
|
||||
channelWidget.value = normalized;
|
||||
channelWidget.callback?.(normalized);
|
||||
}
|
||||
if (app.canvas?.graph === graph) app.canvas.selectNode?.(reader, false);
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
scheduleDiagnostics();
|
||||
return reader;
|
||||
}
|
||||
|
||||
function publisherForReader(node) {
|
||||
const channel = channelFor(node, "in");
|
||||
if (!channel) return null;
|
||||
return uniquePublisherNode(graphPublishers(node.graph || app.graph).get(channel));
|
||||
}
|
||||
|
||||
function jumpToNode(target) {
|
||||
if (!target || app.canvas?.graph !== target.graph) return;
|
||||
app.canvas.centerOnNode?.(target);
|
||||
app.canvas.selectNode?.(target, false);
|
||||
app.canvas.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
function pipelineReaderMenu(node) {
|
||||
const config = parsePipelineConfig(widget(node, "pipeline_config")?.value);
|
||||
const options = [];
|
||||
for (const [index, stage] of config.stages.entries()) {
|
||||
const channel = normalizeChannel(stage.name);
|
||||
if (!channel) continue;
|
||||
options.push({
|
||||
content: `阶段 ${index + 1}:${channel}`,
|
||||
callback: () => createReaderNode(node, channel),
|
||||
});
|
||||
}
|
||||
const finalChannel = channelFor(node, "out");
|
||||
if (finalChannel) {
|
||||
if (options.length) options.push(null);
|
||||
options.push({
|
||||
content: `最终结果:${finalChannel}`,
|
||||
callback: () => createReaderNode(node, finalChannel),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function flowNodeMenuItems(node) {
|
||||
const type = nodeType(node);
|
||||
if (type === NODE_TYPES.publish) {
|
||||
const channel = channelFor(node, "out");
|
||||
return channel ? [{
|
||||
content: `创建配对读取:${channel}`,
|
||||
callback: () => createReaderNode(node, channel),
|
||||
}] : [];
|
||||
}
|
||||
if (type === NODE_TYPES.pipeline) {
|
||||
const options = pipelineReaderMenu(node);
|
||||
return options.length ? [{
|
||||
content: "创建结果读取节点",
|
||||
has_submenu: true,
|
||||
submenu: { options },
|
||||
}] : [];
|
||||
}
|
||||
if (type === NODE_TYPES.get) {
|
||||
const publisher = publisherForReader(node);
|
||||
return publisher ? [{
|
||||
content: "跳转到发送位置",
|
||||
callback: () => jumpToNode(publisher),
|
||||
}] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function diagnosePublishers(nodes, publishers) {
|
||||
for (const node of nodes) {
|
||||
const type = nodeType(node);
|
||||
if (type === NODE_TYPES.publish) {
|
||||
const channel = channelFor(node, "out");
|
||||
if (!channel) setStatus(node, "warning", "结果名称为空");
|
||||
else if (!publisherHasValue(node)) setStatus(node, "warning", `未接数据 ${channel}`);
|
||||
else {
|
||||
const matches = publishers.get(channel) || [];
|
||||
setStatus(
|
||||
node,
|
||||
matches.length > 1 ? "error" : "ok",
|
||||
matches.length > 1 ? `名称冲突 ${channel}` : `发布 ${channel}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosePipeline(node, publishers) {
|
||||
const config = parsePipelineConfig(widget(node, "pipeline_config")?.value);
|
||||
const start = channelFor(node, "in");
|
||||
const finalName = channelFor(node, "out");
|
||||
const names = config.stages.map((stage) => normalizeChannel(stage.name));
|
||||
const duplicate = names.find((name, index) => name && names.indexOf(name) !== index);
|
||||
if (!start) setStatus(node, "error", "起点名称为空");
|
||||
else if (!finalName) setStatus(node, "error", "最终名称为空");
|
||||
else if (names.some((name) => !name)) setStatus(node, "error", "阶段名称为空");
|
||||
else if (duplicate) setStatus(node, "error", `阶段重名 ${duplicate}`);
|
||||
else {
|
||||
const ownNames = new Set([...names, finalName]);
|
||||
const conflict = [...ownNames].find((name) => (publishers.get(name) || []).length > 1);
|
||||
const sources = (publishers.get(start) || []).filter((item) => item.node !== node);
|
||||
if (conflict) setStatus(node, "error", `名称冲突 ${conflict}`);
|
||||
else if (sources.length === 0) setStatus(node, "warning", `缺少起点 ${start}`);
|
||||
else if (sources.length > 1) setStatus(node, "error", `起点冲突 ${start}`);
|
||||
else if (config.stages.length === 0) setStatus(node, "warning", "尚未添加阶段");
|
||||
else setStatus(node, "ok", `${config.stages.length} 个阶段`);
|
||||
}
|
||||
}
|
||||
|
||||
function diagnoseConsumer(node, publishers) {
|
||||
const type = nodeType(node);
|
||||
if (type !== NODE_TYPES.get && !LEGACY_CONSUMERS.has(type)) return;
|
||||
const channel = channelFor(node, "in");
|
||||
const matches = (publishers.get(channel) || []).filter((item) => item.node !== node);
|
||||
if (!channel) setHigherPriorityStatus(node, "warning", "读取名称为空");
|
||||
else if (matches.length === 1) setHigherPriorityStatus(node, "ok", `读取 ${channel}`);
|
||||
else if (matches.length > 1) setHigherPriorityStatus(node, "error", `名称冲突 ${channel}`);
|
||||
else setHigherPriorityStatus(node, "warning", `未找到 ${channel}`);
|
||||
}
|
||||
|
||||
function refreshDiagnostics() {
|
||||
refreshPending = false;
|
||||
const allNodes = (app.graph?._nodes || []).filter((node) => FLOW_TYPES.has(nodeType(node)));
|
||||
const nodes = allNodes.filter((node) => !INACTIVE_MODES.has(node.mode));
|
||||
for (const node of allNodes) setStatus(node, "idle", "已停用");
|
||||
for (const node of nodes) setStatus(node, "idle", "待检查");
|
||||
const publishers = registerLivePublishers(nodes);
|
||||
diagnosePublishers(nodes, publishers);
|
||||
for (const node of nodes) {
|
||||
if (nodeType(node) === NODE_TYPES.pipeline) diagnosePipeline(node, publishers);
|
||||
diagnoseConsumer(node, publishers);
|
||||
if (nodeType(node) === NODE_TYPES.legacyIf) setStatus(node, "idle", "旧版节点");
|
||||
}
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function scheduleDiagnostics() {
|
||||
if (refreshPending) return;
|
||||
refreshPending = true;
|
||||
requestAnimationFrame(refreshDiagnostics);
|
||||
}
|
||||
|
||||
function wrapWidget(widgetItem, node) {
|
||||
if (!widgetItem || widgetItem.__flowBranchWrapped) return;
|
||||
widgetItem.__flowBranchWrapped = true;
|
||||
const original = widgetItem.callback;
|
||||
widgetItem.callback = function () {
|
||||
const result = original?.apply(this, arguments);
|
||||
if (nodeType(node) === NODE_TYPES.pipeline && widgetItem.name === "input_channel") {
|
||||
node.__flowPipelineEditor?.render();
|
||||
}
|
||||
scheduleDiagnostics();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function fitStatusText(ctx, text, maxWidth) {
|
||||
if (maxWidth <= 0) return "";
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let shortened = text;
|
||||
while (shortened.length > 1 && ctx.measureText(`${shortened}...`).width > maxWidth) {
|
||||
shortened = shortened.slice(0, -1);
|
||||
}
|
||||
return shortened.length ? `${shortened}...` : "";
|
||||
}
|
||||
|
||||
function setupNode(node, reload = false) {
|
||||
hideInternalControls(node);
|
||||
applyChineseLabels(node);
|
||||
const minWidth = nodeType(node) === NODE_TYPES.pipeline ? PIPELINE_NODE_MIN_WIDTH : 250;
|
||||
node.setSize([Math.max(node.size[0], minWidth), node.size[1]]);
|
||||
|
||||
if (node.__flowBranchSetup) {
|
||||
if (reload) node.__flowPipelineEditor?.reload();
|
||||
scheduleDiagnostics();
|
||||
return;
|
||||
}
|
||||
node.__flowBranchSetup = true;
|
||||
|
||||
for (const name of ["channel", "input_channel", "output_channel"]) {
|
||||
wrapWidget(widget(node, name), node);
|
||||
}
|
||||
|
||||
if (nodeType(node) === NODE_TYPES.pipeline) {
|
||||
setupPipelineEditor(node, app, scheduleDiagnostics);
|
||||
}
|
||||
|
||||
if (nodeType(node) === NODE_TYPES.get) {
|
||||
const originalDoubleClick = node.onDblClick;
|
||||
node.onDblClick = function () {
|
||||
const publisher = publisherForReader(this);
|
||||
if (publisher) {
|
||||
jumpToNode(publisher);
|
||||
return;
|
||||
}
|
||||
return originalDoubleClick?.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
const originalConnectionsChange = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function () {
|
||||
const result = originalConnectionsChange?.apply(this, arguments);
|
||||
this.__flowPipelineEditor?.refreshConnections();
|
||||
scheduleDiagnostics();
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalSerialize = node.onSerialize;
|
||||
node.onSerialize = function () {
|
||||
this.__flowPipelineEditor?.persist();
|
||||
return originalSerialize?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const originalRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
this.__flowPipelineEditor?.destroy();
|
||||
const result = originalRemoved?.apply(this, arguments);
|
||||
scheduleDiagnostics();
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalModeChange = node.onModeChange;
|
||||
node.onModeChange = function () {
|
||||
const result = originalModeChange?.apply(this, arguments);
|
||||
scheduleDiagnostics();
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalDraw = node.onDrawForeground;
|
||||
node.onDrawForeground = function (ctx) {
|
||||
originalDraw?.apply(this, arguments);
|
||||
this.__flowPipelineEditor?.updateCanvasPositions();
|
||||
const status = this.__flowBranchStatus;
|
||||
if (!status || this.flags?.collapsed) return;
|
||||
ctx.save();
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = COLORS[status.level] || COLORS.idle;
|
||||
const title = String(this.getTitle?.() || this.title || "");
|
||||
const titleWidth = ctx.measureText(title).width;
|
||||
const maxWidth = Math.max(0, this.size[0] - titleWidth - 48);
|
||||
const text = fitStatusText(ctx, status.text, maxWidth);
|
||||
if (text) ctx.fillText(text, this.size[0] - 10, -15);
|
||||
ctx.restore();
|
||||
};
|
||||
scheduleDiagnostics();
|
||||
}
|
||||
|
||||
function installPromptCompiler() {
|
||||
if (api.__flowBranchCompilerInstalled) return;
|
||||
api.__flowBranchCompilerInstalled = true;
|
||||
const originalQueuePrompt = api.queuePrompt;
|
||||
api.queuePrompt = async function () {
|
||||
try {
|
||||
const diagnostics = compileFlowPrompt(arguments[1]);
|
||||
const warnings = diagnostics.filter((item) => item.level !== "ok");
|
||||
if (warnings.length) console.warn("[FlowBranch]", warnings);
|
||||
} catch (error) {
|
||||
console.error("[FlowBranch] 编译流程失败", error);
|
||||
}
|
||||
return originalQueuePrompt.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.FlowBranch",
|
||||
init() {
|
||||
ensureStyles();
|
||||
},
|
||||
setup() {
|
||||
installPromptCompiler();
|
||||
},
|
||||
getNodeMenuItems(node) {
|
||||
const items = flowNodeMenuItems(node);
|
||||
return items.length ? [null, ...items] : [];
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (!FLOW_TYPES.has(nodeData.name)) return;
|
||||
const originalCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
originalCreated?.apply(this, arguments);
|
||||
setupNode(this);
|
||||
};
|
||||
if (nodeData.name === NODE_TYPES.pipeline) {
|
||||
const originalConfigure = nodeType.prototype.configure;
|
||||
nodeType.prototype.configure = function () {
|
||||
const result = originalConfigure?.apply(this, arguments);
|
||||
setupNode(this, true);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
},
|
||||
loadedGraphNode(node) {
|
||||
if (FLOW_TYPES.has(nodeType(node))) setupNode(node, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export function pairedNodePosition(sourceNode, gap = 40) {
|
||||
const x = Number(sourceNode?.pos?.[0]) || 0;
|
||||
const y = Number(sourceNode?.pos?.[1]) || 0;
|
||||
const width = Number(sourceNode?.size?.[0]) || 0;
|
||||
return [x + width + gap, y];
|
||||
}
|
||||
|
||||
function nodeRect(node, fallbackWidth = 250, fallbackHeight = 80) {
|
||||
const x = Number(node?.pos?.[0]) || 0;
|
||||
const y = Number(node?.pos?.[1]) || 0;
|
||||
const width = Number(node?.size?.[0]) || fallbackWidth;
|
||||
const height = Number(node?.size?.[1]) || fallbackHeight;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function overlaps(left, right, padding) {
|
||||
return left.x < right.x + right.width + padding
|
||||
&& left.x + left.width + padding > right.x
|
||||
&& left.y < right.y + right.height + padding
|
||||
&& left.y + left.height + padding > right.y;
|
||||
}
|
||||
|
||||
export function nextAvailablePairedPosition(sourceNode, newNode, nodes = [], gap = 40, padding = 18) {
|
||||
const [x, startY] = pairedNodePosition(sourceNode, gap);
|
||||
const newSize = nodeRect(newNode);
|
||||
let y = startY;
|
||||
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const candidate = { ...newSize, x, y };
|
||||
const collision = nodes.find((node) => node !== sourceNode && overlaps(candidate, nodeRect(node), padding));
|
||||
if (!collision) return [x, y];
|
||||
const occupied = nodeRect(collision);
|
||||
y = Math.max(y + newSize.height + padding, occupied.y + occupied.height + padding);
|
||||
}
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
export function uniquePublisherNode(entries) {
|
||||
const nodes = [...new Set((entries || []).map((entry) => entry?.node).filter(Boolean))];
|
||||
return nodes.length === 1 ? nodes[0] : null;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export const PIPELINE_CONFIG_VERSION = 2;
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
function makeId(prefix) {
|
||||
idCounter += 1;
|
||||
const randomPart = globalThis.crypto?.randomUUID?.().replaceAll("-", "")
|
||||
|| `${Date.now().toString(36)}${idCounter.toString(36)}`;
|
||||
return `${prefix}_${randomPart}`;
|
||||
}
|
||||
|
||||
function cleanId(value, prefix, used) {
|
||||
let id = String(value ?? "").trim().replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
if (!id || used.has(id)) id = makeId(prefix);
|
||||
used.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function cleanName(value, fallback) {
|
||||
return String(value ?? "").trim() || fallback;
|
||||
}
|
||||
|
||||
function cleanStageName(value, fallback) {
|
||||
return value === undefined || value === null ? fallback : String(value).trim();
|
||||
}
|
||||
|
||||
export function createBranch(index = 1) {
|
||||
return {
|
||||
id: makeId("b"),
|
||||
name: `方案 ${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStage(index = 1) {
|
||||
return {
|
||||
id: makeId("s"),
|
||||
name: `阶段 ${index} 结果`,
|
||||
enabled: true,
|
||||
autoSelect: false,
|
||||
selected: null,
|
||||
branches: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function branchInputName(branchId) {
|
||||
const safeId = String(branchId ?? "").replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
return `branch_${safeId}`;
|
||||
}
|
||||
|
||||
export function normalizePipelineConfig(value) {
|
||||
const source = value && typeof value === "object" ? value : {};
|
||||
const rawStages = Array.isArray(source.stages) ? source.stages : [];
|
||||
const usedStageIds = new Set();
|
||||
const usedBranchIds = new Set();
|
||||
const stages = rawStages.map((rawStage, stageIndex) => {
|
||||
const stageSource = rawStage && typeof rawStage === "object" ? rawStage : {};
|
||||
const stageId = cleanId(stageSource.id, "s", usedStageIds);
|
||||
const rawBranches = Array.isArray(stageSource.branches) ? stageSource.branches : [];
|
||||
const branches = rawBranches.map((rawBranch, branchIndex) => {
|
||||
const branchSource = rawBranch && typeof rawBranch === "object" ? rawBranch : {};
|
||||
return {
|
||||
id: cleanId(branchSource.id, "b", usedBranchIds),
|
||||
name: cleanName(branchSource.name, `方案 ${branchIndex + 1}`),
|
||||
};
|
||||
});
|
||||
const selected = branches.some((item) => item.id === stageSource.selected)
|
||||
? stageSource.selected
|
||||
: null;
|
||||
return {
|
||||
id: stageId,
|
||||
name: cleanStageName(stageSource.name, `阶段 ${stageIndex + 1} 结果`),
|
||||
enabled: stageSource.enabled !== false,
|
||||
autoSelect: stageSource.autoSelect === true,
|
||||
selected,
|
||||
branches,
|
||||
};
|
||||
});
|
||||
return { version: PIPELINE_CONFIG_VERSION, stages };
|
||||
}
|
||||
|
||||
export function parsePipelineConfig(value) {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return normalizePipelineConfig(JSON.parse(value));
|
||||
} catch {
|
||||
return normalizePipelineConfig(null);
|
||||
}
|
||||
}
|
||||
return normalizePipelineConfig(value);
|
||||
}
|
||||
|
||||
export function serializePipelineConfig(value) {
|
||||
return JSON.stringify(normalizePipelineConfig(value));
|
||||
}
|
||||
|
||||
export const EMPTY_PIPELINE_CONFIG = serializePipelineConfig({ stages: [] });
|
||||
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
branchInputName,
|
||||
createBranch,
|
||||
createStage,
|
||||
parsePipelineConfig,
|
||||
serializePipelineConfig,
|
||||
} from "./pipeline_config.mjs";
|
||||
|
||||
let editorCounter = 0;
|
||||
export const PIPELINE_NODE_MIN_WIDTH = 430;
|
||||
const PIPELINE_WIDGET_START_Y = 4;
|
||||
const PIPELINE_NODE_BOTTOM_PADDING = 8;
|
||||
|
||||
export function calculateStackContentHeight(
|
||||
childHeights,
|
||||
gap = 0,
|
||||
paddingTop = 0,
|
||||
paddingBottom = 0,
|
||||
) {
|
||||
const heights = childHeights.filter((height) => Number.isFinite(height) && height > 0);
|
||||
return Math.ceil(
|
||||
heights.reduce((total, height) => total + height, 0)
|
||||
+ gap * Math.max(heights.length - 1, 0)
|
||||
+ paddingTop
|
||||
+ paddingBottom,
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateSlotCanvasY(widgetY, rowOffsetY, widgetMargin = 0) {
|
||||
return widgetY + rowOffsetY + widgetMargin;
|
||||
}
|
||||
|
||||
function stackContentHeight(root) {
|
||||
const style = getComputedStyle(root);
|
||||
const gap = Number.parseFloat(style.rowGap || style.gap) || 0;
|
||||
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
|
||||
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
|
||||
return calculateStackContentHeight(
|
||||
[...root.children].map((child) => child.offsetHeight),
|
||||
gap,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
);
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const item = document.createElement(tag);
|
||||
if (className) item.className = className;
|
||||
if (text !== undefined) item.textContent = text;
|
||||
return item;
|
||||
}
|
||||
|
||||
function iconButton(symbol, title, onClick) {
|
||||
const button = element("button", "fb-icon-button", symbol);
|
||||
button.type = "button";
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.addEventListener("click", onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
function nodeInput(node, name) {
|
||||
return node.inputs?.find((item) => item.name === name);
|
||||
}
|
||||
|
||||
function isConnected(input) {
|
||||
return input?.link !== null && input?.link !== undefined;
|
||||
}
|
||||
|
||||
function removeInputByName(node, name) {
|
||||
const index = node.inputs?.findIndex((item) => item.name === name) ?? -1;
|
||||
if (index >= 0) node.removeInput(index);
|
||||
}
|
||||
|
||||
function stopCanvasGestures(root) {
|
||||
for (const eventName of ["pointerdown", "mousedown", "wheel"]) {
|
||||
root.addEventListener(eventName, (event) => event.stopPropagation());
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPipelineEditor(node, app, onStateChanged) {
|
||||
if (node.__flowPipelineEditor) return node.__flowPipelineEditor;
|
||||
const configWidget = node.widgets?.find((item) => item.name === "pipeline_config");
|
||||
if (!configWidget) return null;
|
||||
|
||||
editorCounter += 1;
|
||||
const editorId = `flow-pipeline-${editorCounter}`;
|
||||
const root = element("div", "fb-pipeline-editor");
|
||||
stopCanvasGestures(root);
|
||||
let config = parsePipelineConfig(configWidget.value);
|
||||
let rowElements = new Map();
|
||||
let destroyed = false;
|
||||
let syncingInputs = false;
|
||||
let fittingNode = false;
|
||||
|
||||
// Branch sockets are positioned beside DOM rows. A fixed widget origin keeps
|
||||
// LiteGraph's slot bounds from pushing the widget down again on every frame.
|
||||
node.widgets_start_y = PIPELINE_WIDGET_START_Y;
|
||||
|
||||
function desiredEditorHeight() {
|
||||
return Math.max(52, stackContentHeight(root) + 8);
|
||||
}
|
||||
|
||||
const domWidget = node.addDOMWidget("flow_pipeline_editor", "flow-pipeline-editor", root, {
|
||||
serialize: false,
|
||||
getMinHeight() {
|
||||
return desiredEditorHeight();
|
||||
},
|
||||
getMaxHeight() {
|
||||
return desiredEditorHeight();
|
||||
},
|
||||
});
|
||||
domWidget.serialize = false;
|
||||
|
||||
function persist() {
|
||||
configWidget.value = serializePipelineConfig(config);
|
||||
}
|
||||
|
||||
function desiredBranchInputs() {
|
||||
return config.stages.flatMap((stage) => stage.branches.map((branch) => ({
|
||||
name: branchInputName(branch.id),
|
||||
label: branch.name,
|
||||
})));
|
||||
}
|
||||
|
||||
function syncInputs() {
|
||||
syncingInputs = true;
|
||||
try {
|
||||
const desired = desiredBranchInputs();
|
||||
const desiredNames = new Set(desired.map((item) => item.name));
|
||||
const obsolete = (node.inputs || [])
|
||||
.filter((item) => item.name.startsWith("branch_") && !desiredNames.has(item.name))
|
||||
.map((item) => item.name);
|
||||
for (const name of obsolete) removeInputByName(node, name);
|
||||
for (const item of desired) {
|
||||
let input = nodeInput(node, item.name);
|
||||
if (!input) input = node.addInput(item.name, "*");
|
||||
input.label = " ";
|
||||
input.localized_name = " ";
|
||||
input.__flowBranchLabel = item.label;
|
||||
}
|
||||
} finally {
|
||||
syncingInputs = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSlotOffsets() {
|
||||
if (destroyed || !root.isConnected) return;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const scale = root.offsetHeight > 0 ? rootRect.height / root.offsetHeight : 1;
|
||||
for (const [inputName, row] of rowElements) {
|
||||
const input = nodeInput(node, inputName);
|
||||
if (!input || !row.isConnected) continue;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
input.__flowBranchOffsetY = (rowRect.top - rootRect.top + rowRect.height / 2) / (scale || 1);
|
||||
}
|
||||
updateCanvasPositions();
|
||||
}
|
||||
|
||||
function updateCanvasPositions() {
|
||||
for (const input of node.inputs || []) {
|
||||
if (!input.name.startsWith("branch_") || input.__flowBranchOffsetY === undefined) continue;
|
||||
input.pos = [
|
||||
0,
|
||||
calculateSlotCanvasY(domWidget.y, input.__flowBranchOffsetY, domWidget.margin),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function fitNode() {
|
||||
if (destroyed || fittingNode) return;
|
||||
fittingNode = true;
|
||||
try {
|
||||
const width = Math.max(PIPELINE_NODE_MIN_WIDTH, node.size?.[0] || 0);
|
||||
const height = Math.max(
|
||||
150,
|
||||
PIPELINE_WIDGET_START_Y + desiredEditorHeight() + PIPELINE_NODE_BOTTOM_PADDING,
|
||||
);
|
||||
if (Math.abs((node.size?.[1] || 0) - height) > 2 || (node.size?.[0] || 0) < width) {
|
||||
node.setSize([width, height]);
|
||||
}
|
||||
updateSlotOffsets();
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
} finally {
|
||||
fittingNode = false;
|
||||
}
|
||||
}
|
||||
|
||||
function notify({ render = true } = {}) {
|
||||
persist();
|
||||
syncInputs();
|
||||
if (render) renderEditor();
|
||||
onStateChanged?.();
|
||||
requestAnimationFrame(fitNode);
|
||||
}
|
||||
|
||||
function makeStageHeader(stage, stageIndex, previousName) {
|
||||
const header = element("div", "fb-stage-header");
|
||||
const enabled = element("input", "fb-stage-toggle");
|
||||
enabled.type = "checkbox";
|
||||
enabled.checked = stage.enabled;
|
||||
enabled.title = stage.enabled ? "阶段已启用" : "阶段已旁路";
|
||||
enabled.addEventListener("change", () => {
|
||||
stage.enabled = enabled.checked;
|
||||
notify();
|
||||
});
|
||||
|
||||
const names = element("div", "fb-stage-names");
|
||||
const source = element("div", "fb-stage-source");
|
||||
source.append(
|
||||
element("span", "fb-stage-index", `阶段 ${stageIndex + 1}`),
|
||||
element("span", "fb-stage-path", `来自 ${previousName || "未命名结果"}`),
|
||||
);
|
||||
const name = element("input", "fb-name-input");
|
||||
name.type = "text";
|
||||
name.value = stage.name;
|
||||
name.placeholder = "阶段结果名称";
|
||||
name.title = "该阶段完成后发布的结果名称";
|
||||
name.addEventListener("input", () => {
|
||||
stage.name = name.value;
|
||||
persist();
|
||||
onStateChanged?.();
|
||||
});
|
||||
name.addEventListener("change", () => notify());
|
||||
names.append(source, name);
|
||||
|
||||
const actions = element("div", "fb-stage-actions");
|
||||
const moveUp = iconButton("↑", "阶段上移", () => {
|
||||
[config.stages[stageIndex - 1], config.stages[stageIndex]] = [
|
||||
config.stages[stageIndex], config.stages[stageIndex - 1],
|
||||
];
|
||||
notify();
|
||||
});
|
||||
moveUp.disabled = stageIndex <= 0;
|
||||
const moveDown = iconButton("↓", "阶段下移", () => {
|
||||
[config.stages[stageIndex + 1], config.stages[stageIndex]] = [
|
||||
config.stages[stageIndex], config.stages[stageIndex + 1],
|
||||
];
|
||||
notify();
|
||||
});
|
||||
moveDown.disabled = stageIndex >= config.stages.length - 1;
|
||||
const removeStage = iconButton("×", "删除阶段", () => {
|
||||
config.stages.splice(stageIndex, 1);
|
||||
notify();
|
||||
});
|
||||
removeStage.classList.add("fb-delete-button");
|
||||
actions.append(moveUp, moveDown, removeStage);
|
||||
header.append(enabled, names, actions);
|
||||
return header;
|
||||
}
|
||||
|
||||
function makeBypassRow(stage) {
|
||||
const label = element("label", "fb-branch-row fb-bypass-row");
|
||||
if (!stage.autoSelect && stage.selected === null) label.classList.add("fb-branch-selected");
|
||||
const radio = element("input", "fb-branch-radio");
|
||||
radio.type = "radio";
|
||||
radio.name = `${editorId}-${stage.id}`;
|
||||
radio.checked = stage.selected === null;
|
||||
radio.disabled = !stage.enabled || stage.autoSelect;
|
||||
radio.addEventListener("change", () => {
|
||||
if (!radio.checked) return;
|
||||
stage.selected = null;
|
||||
notify();
|
||||
});
|
||||
label.title = "不执行本阶段,直接使用上一阶段结果";
|
||||
label.append(radio, element("span", "fb-bypass-label", "跳过本阶段"));
|
||||
return label;
|
||||
}
|
||||
|
||||
function makeAutoSelectRow(stage) {
|
||||
const label = element("label", "fb-auto-row");
|
||||
if (stage.autoSelect) label.classList.add("fb-auto-enabled");
|
||||
const toggle = element("input", "fb-auto-toggle");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = stage.autoSelect;
|
||||
toggle.title = "运行时按从上到下的顺序选择第一个可用方案";
|
||||
toggle.addEventListener("change", () => {
|
||||
stage.autoSelect = toggle.checked;
|
||||
notify();
|
||||
});
|
||||
label.append(
|
||||
toggle,
|
||||
element("span", "fb-auto-label", "自动选择可用方案"),
|
||||
element("span", "fb-auto-hint", "从上到下"),
|
||||
);
|
||||
return label;
|
||||
}
|
||||
|
||||
function makeBranchRow(stage, branch, branchIndex) {
|
||||
const inputName = branchInputName(branch.id);
|
||||
const row = element("div", "fb-branch-row");
|
||||
row.dataset.inputName = inputName;
|
||||
if (!stage.autoSelect && stage.selected === branch.id) row.classList.add("fb-branch-selected");
|
||||
if (isConnected(nodeInput(node, inputName))) row.classList.add("fb-branch-connected");
|
||||
|
||||
const radio = element("input", "fb-branch-radio");
|
||||
radio.type = "radio";
|
||||
radio.name = `${editorId}-${stage.id}`;
|
||||
radio.checked = stage.selected === branch.id;
|
||||
radio.disabled = !stage.enabled || stage.autoSelect;
|
||||
radio.title = "选择这个方案";
|
||||
radio.addEventListener("change", () => {
|
||||
if (!radio.checked) return;
|
||||
stage.selected = branch.id;
|
||||
notify();
|
||||
});
|
||||
|
||||
const name = element("input", "fb-name-input fb-branch-name");
|
||||
name.type = "text";
|
||||
name.value = branch.name;
|
||||
name.placeholder = `方案 ${branchIndex + 1}`;
|
||||
name.title = "方案名称";
|
||||
name.addEventListener("input", () => {
|
||||
branch.name = name.value;
|
||||
const input = nodeInput(node, inputName);
|
||||
if (input) input.__flowBranchLabel = branch.name;
|
||||
persist();
|
||||
});
|
||||
name.addEventListener("change", () => notify());
|
||||
|
||||
const connected = isConnected(nodeInput(node, inputName));
|
||||
const status = element(
|
||||
"span",
|
||||
`fb-connection-status ${connected ? "fb-is-connected" : "fb-is-unconnected"}`,
|
||||
connected ? "已连接" : "未连接",
|
||||
);
|
||||
status.title = connected ? "方案结果已连接" : "方案结果未连接,将沿用上一阶段";
|
||||
|
||||
const remove = iconButton("×", "删除方案", () => {
|
||||
stage.branches.splice(branchIndex, 1);
|
||||
if (stage.selected === branch.id) stage.selected = null;
|
||||
notify();
|
||||
});
|
||||
remove.classList.add("fb-delete-button");
|
||||
row.append(radio, name, status, remove);
|
||||
rowElements.set(inputName, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function makeStage(stage, stageIndex, previousName) {
|
||||
const section = element("section", "fb-stage");
|
||||
if (!stage.enabled) section.classList.add("fb-stage-disabled");
|
||||
if (stage.autoSelect) section.classList.add("fb-stage-auto");
|
||||
section.append(
|
||||
makeStageHeader(stage, stageIndex, previousName),
|
||||
makeAutoSelectRow(stage),
|
||||
makeBypassRow(stage),
|
||||
);
|
||||
for (const [branchIndex, branch] of stage.branches.entries()) {
|
||||
section.append(makeBranchRow(stage, branch, branchIndex));
|
||||
}
|
||||
const addBranch = element("button", "fb-add-button fb-add-branch", "+ 添加方案");
|
||||
addBranch.type = "button";
|
||||
addBranch.addEventListener("click", () => {
|
||||
const branch = createBranch(stage.branches.length + 1);
|
||||
stage.branches.push(branch);
|
||||
stage.selected = branch.id;
|
||||
notify();
|
||||
});
|
||||
section.append(addBranch);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
rowElements = new Map();
|
||||
root.replaceChildren();
|
||||
if (config.stages.length === 0) {
|
||||
root.append(element("div", "fb-empty-state", "尚未添加处理阶段"));
|
||||
}
|
||||
let previousName = node.widgets?.find((item) => item.name === "input_channel")?.value || "原始图像";
|
||||
for (const [stageIndex, stage] of config.stages.entries()) {
|
||||
root.append(makeStage(stage, stageIndex, previousName));
|
||||
previousName = stage.name;
|
||||
}
|
||||
const addStage = element("button", "fb-add-button fb-add-stage", "+ 添加阶段");
|
||||
addStage.type = "button";
|
||||
addStage.addEventListener("click", () => {
|
||||
const stage = createStage(config.stages.length + 1);
|
||||
const branch = createBranch(1);
|
||||
stage.branches.push(branch);
|
||||
stage.selected = branch.id;
|
||||
config.stages.push(stage);
|
||||
notify();
|
||||
});
|
||||
root.append(addStage);
|
||||
requestAnimationFrame(fitNode);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => requestAnimationFrame(updateSlotOffsets));
|
||||
observer.observe(root);
|
||||
|
||||
const editor = {
|
||||
get config() {
|
||||
return config;
|
||||
},
|
||||
persist,
|
||||
render: renderEditor,
|
||||
reload() {
|
||||
config = parsePipelineConfig(configWidget.value);
|
||||
syncInputs();
|
||||
renderEditor();
|
||||
},
|
||||
refreshConnections() {
|
||||
if (syncingInputs) return;
|
||||
renderEditor();
|
||||
},
|
||||
updateCanvasPositions,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
observer.disconnect();
|
||||
},
|
||||
};
|
||||
node.__flowPipelineEditor = editor;
|
||||
syncInputs();
|
||||
renderEditor();
|
||||
return editor;
|
||||
}
|
||||
Reference in New Issue
Block a user