418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
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;
|
||
}
|