初始化

This commit is contained in:
2026-07-30 06:39:46 +08:00
commit 1a460f3f1d
23 changed files with 3025 additions and 0 deletions
+425
View File
@@ -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;
}
+277
View File
@@ -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;
}
+469
View File
@@ -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);
},
});
+41
View File
@@ -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;
}
+96
View File
@@ -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: [] });
+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;
}