Files
ComfyUl-FlowBranch/web/pipeline_editor.js
T
2026-07-30 06:39:46 +08:00

418 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}