From 1a460f3f1d0694f94f73be086bb0b816792d2716 Mon Sep 17 00:00:00 2001 From: kjqwer <2990346238@qq.com> Date: Thu, 30 Jul 2026 06:39:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 98 ++++ __init__.py | 5 + __pycache__/__init__.cpython-312.pyc | Bin 0 -> 267 bytes __pycache__/nodes.cpython-312.pyc | Bin 0 -> 14481 bytes locales/zh/main.json | 8 + locales/zh/nodeDefs.json | 44 ++ nodes.py | 403 +++++++++++++++ pyproject.toml | 14 + pytest.ini | 3 + .../conftest.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 1956 bytes .../test_nodes.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 51799 bytes tests/conftest.py | 13 + tests/test_compiler.mjs | 386 ++++++++++++++ tests/test_node_actions.mjs | 43 ++ tests/test_nodes.py | 157 ++++++ tests/test_pipeline_config.mjs | 88 ++++ tests/test_pipeline_layout.mjs | 38 ++ web/compiler.mjs | 425 ++++++++++++++++ web/flow_branch.css | 277 +++++++++++ web/flow_branch.js | 469 ++++++++++++++++++ web/node_actions.mjs | 41 ++ web/pipeline_config.mjs | 96 ++++ web/pipeline_editor.js | 417 ++++++++++++++++ 23 files changed, 3025 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 __pycache__/nodes.cpython-312.pyc create mode 100644 locales/zh/main.json create mode 100644 locales/zh/nodeDefs.json create mode 100644 nodes.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_nodes.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_compiler.mjs create mode 100644 tests/test_node_actions.mjs create mode 100644 tests/test_nodes.py create mode 100644 tests/test_pipeline_config.mjs create mode 100644 tests/test_pipeline_layout.mjs create mode 100644 web/compiler.mjs create mode 100644 web/flow_branch.css create mode 100644 web/flow_branch.js create mode 100644 web/node_actions.mjs create mode 100644 web/pipeline_config.mjs create mode 100644 web/pipeline_editor.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c37690 --- /dev/null +++ b/README.md @@ -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 都保存在节点自身的普通工作流数据中。切换工作流、保存后重新打开、复制节点都会保留这些信息,不依赖浏览器缓存或工作流外文件。 + +## 快捷操作 + +- 右键 **发送结果**,选择“创建配对读取”,会在右侧创建一个同名 **读取结果**。 +- 右键 **流程编排器**,选择“创建结果读取节点”,可以直接为任意阶段或最终结果创建读取节点。 +- 右键 **读取结果** 选择“跳转到发送位置”,或直接双击节点,可以返回唯一的发送节点;名称冲突时不会擅自选择。 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ba51731 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3fc826f589e6d79df0adf6cd57fa14fec904a625 GIT binary patch literal 267 zcmX@j%ge<81i`B^v(^FW#~=<2FhLog6@ZNC3@HpLj5!Rsj8TlaOi@gX3@J=e%;^j% z%!?SKSSndGSzj^&m1#2F687_VagBHOaSRTQ_jL>i@bq&Jz9j_~bny%h@NtZc_jB}h zh09g3>gkuKCTTJkF#`=KVgVAjc*9+t;$1w0T%AMwgChMjS#PoC<)@?;Ba{@eg4D6c z$0z3G#K*5>_zdJS{Nj#}&&08uPHxc~qF literal 0 HcmV?d00001 diff --git a/__pycache__/nodes.cpython-312.pyc b/__pycache__/nodes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99ee0551cf86571588f1756bb8035ef016d2bb8f GIT binary patch literal 14481 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"] +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" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6244b45 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = --import-mode=importlib --confcutdir=tests diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ae6f2cfbb77cfac8dd560976e9fd42b977f067c GIT binary patch literal 1956 zcmcgs&u<$=6rQy=UfY{&LrvmZQIa-oQGAG%ln_#AQ9}|SRcOMGT(*{$wP)PyYIkRu zS+}tfRf3R8#i8L;4*UUy{1e={#02Cps1gzqs5ek=oS50o8VkD|S=lr1eeZj3-h1B@;E zt!F|@R?qT1>8S3YJ}7|%K!a%LfS)5Cdpz2^*Mk$qp6Es+8OjAN=))*uPxV^ui@g{e zK*KQAQ~@|hL{>fb4DVvEH_FAC>La&+K8kXB9_5IJa`tR5TQ8sjx990^P=RTf;k731 z`CeXJ;cY-qbBz21vn;ZXlcu{V0zXe2&U0*ypbdm<=BiK(XN;lKpcEMz+w>MU{)>oM-s*4hz>sDr2D(YW1_>ust zrbF2ogKp!x0}X) zN_mTq04*;;*YYeH5HrA}yn?HBS9G_$jNMveYpq*0Ks#l6yU z6??UCAp8bPUy06ojfjt=m}si;^^c?^NsqzQ?*M!Xj=;#zAp01c`7;Gh<@Ogp_uATp zmUiJtd!enBT3YGRhb?VxS3OpS@7>+I+g4^;%FJPD>DyNi-?`aZzImjScje>E=?4?{ zC)%0mR%ZIq^GBJ|Zt5p6wi_IRvAtkleyDz-a&cd6gR#!jSKs7 zhZc*ie@E-)NAeF~?6@$wo4lv)sh_PpSi8U00;9)37J3`xTOj{s>PIk1F7dwn4HhTU A1poj5 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_nodes.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_nodes.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4391226264e60b0846b65f89f0904bbd5f37147e GIT binary patch literal 51799 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 C?0$g& literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..be48693 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_compiler.mjs b/tests/test_compiler.mjs new file mode 100644 index 0000000..e1bdf75 --- /dev/null +++ b/tests/test_compiler.mjs @@ -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]); +}); diff --git a/tests/test_node_actions.mjs b/tests/test_node_actions.mjs new file mode 100644 index 0000000..058de88 --- /dev/null +++ b/tests/test_node_actions.mjs @@ -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); +}); diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..74bd622 --- /dev/null +++ b/tests/test_nodes.py @@ -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 == "流程分支/旧版" diff --git a/tests/test_pipeline_config.mjs b/tests/test_pipeline_config.mjs new file mode 100644 index 0000000..ff2048b --- /dev/null +++ b/tests/test_pipeline_config.mjs @@ -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 }); +}); diff --git a/tests/test_pipeline_layout.mjs b/tests/test_pipeline_layout.mjs new file mode 100644 index 0000000..c35a58c --- /dev/null +++ b/tests/test_pipeline_layout.mjs @@ -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*""/); +}); diff --git a/web/compiler.mjs b/web/compiler.mjs new file mode 100644 index 0000000..a4502f2 --- /dev/null +++ b/web/compiler.mjs @@ -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; +} diff --git a/web/flow_branch.css b/web/flow_branch.css new file mode 100644 index 0000000..c5a7327 --- /dev/null +++ b/web/flow_branch.css @@ -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; +} diff --git a/web/flow_branch.js b/web/flow_branch.js new file mode 100644 index 0000000..8b19a4b --- /dev/null +++ b/web/flow_branch.js @@ -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); + }, +}); diff --git a/web/node_actions.mjs b/web/node_actions.mjs new file mode 100644 index 0000000..bfc545d --- /dev/null +++ b/web/node_actions.mjs @@ -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; +} diff --git a/web/pipeline_config.mjs b/web/pipeline_config.mjs new file mode 100644 index 0000000..ad137b6 --- /dev/null +++ b/web/pipeline_config.mjs @@ -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: [] }); diff --git a/web/pipeline_editor.js b/web/pipeline_editor.js new file mode 100644 index 0000000..f2f2965 --- /dev/null +++ b/web/pipeline_editor.js @@ -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; +}