Files
2026-07-30 06:39:46 +08:00

470 lines
16 KiB
JavaScript
Raw Permalink 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 { 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);
},
});