初始化

This commit is contained in:
2026-07-30 06:39:46 +08:00
commit 1a460f3f1d
23 changed files with 3025 additions and 0 deletions
+417
View File
@@ -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;
}