Files
BlackVortexAI-bv_nodepack/js/bv_smart_pipe.js
T

1007 lines
45 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 { app } from "../../scripts/app.js";
import { moveMarkedPortToEnd, nextFreeOrdinal, promoteConnectedInheritedSlots, promoteInheritedSlot, resolveLocalSlotNameCollisions, retainedMissingSlots, reusableSmartPipePortIndex, smartPipeSlotName, uniqueSmartPipeSlotName, updateSmartPipePort } from "./bv_smart_pipe_slots.js";
import { mergePipeSchemas, SMART_PIPE_DEFAULT_TITLE } from "./bv_smart_pipe_merge_model.js";
import {
HOST_ROUTING_PROPERTY,
cloneRouteRegistryPrefix,
compareRoutingNumbers,
crossScopeDescriptors,
detachedHostRouting,
executionScope,
logicalAddress,
materializeAddressedPipeLinks,
materializeSmartPipeMergeSources,
materializeWirelessPipeLinks,
preferredHostRoutingName,
predecessorChoiceRoutes,
promptModeState,
prunePromptBranches,
reconcileRouteRegistryDestinations,
ROOT_ROUTING_PROPERTY,
remapPromptOutputLinks,
ROUTE_INPUT,
ROUTE_REGISTRY_PROPERTY,
routingChoiceLabel,
routingDisplayNumbers,
routingCollisionOwner,
uniqueHostName,
uniqueRoutingName,
validateMaterializedPipeGraph,
} from "./bv_smart_pipe_routing.js";
const NODE_CLASS = "BV Smart Pipe";
const MAX_SLOTS = 100;
const ADD_SLOT_NAME = "bv_add_slot";
const activeGraphContexts = new WeakMap();
export function isSmartPipe(node) {
return node?.comfyClass === NODE_CLASS || node?.type === NODE_CLASS;
}
function stateFor(node) {
node.properties ??= {};
node.properties.bvSmartPipe ??= { version: 1, localSlots: [], inheritedSlots: [] };
const state = node.properties.bvSmartPipe;
if (!state.nextOrdinal) {
const slots = [...(state.inheritedSlots || []), ...(state.localSlots || []), ...(state.resolvedSlots || [])];
state.nextOrdinal = Math.max(0, ...slots.map((slot) => slot.ordinal || 0)) + 1;
}
return state;
}
function newId() {
return globalThis.crypto?.randomUUID?.() ?? `bv-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function pipeNodes(graph) {
return (graph?._nodes || graph?.nodes || []).filter(isSmartPipe);
}
function propagateGraph(graph) {
pipeNodes(graph).forEach((candidate) => propagate(candidate));
}
function subgraphFor(node) {
return node?.subgraph || node?.getSubgraph?.() || null;
}
function graphNodes(graph) {
return graph?._nodes || graph?.nodes || [];
}
function containsSmartPipe(graph, visited = new Set()) {
if (!graph || visited.has(graph)) return false;
visited.add(graph);
return graphNodes(graph).some((node) => isSmartPipe(node) || isSmartPipeMerge(node) || containsSmartPipe(subgraphFor(node), visited));
}
function rootRoutingState(graph) {
graph.extra ??= {};
graph.extra[ROOT_ROUTING_PROPERTY] ??= newId();
graph.extra[ROUTE_REGISTRY_PROPERTY] ??= {};
return {
rootId: graph.extra[ROOT_ROUTING_PROPERTY],
registry: graph.extra[ROUTE_REGISTRY_PROPERTY],
};
}
function ensureHostRouting(host, usedIds, usedNames) {
host.properties ??= {};
const existing = detachedHostRouting(host.properties[HOST_ROUTING_PROPERTY]);
host.properties[HOST_ROUTING_PROPERTY] = existing;
let replacedId = null;
if (!existing.id || usedIds.has(existing.id)) {
replacedId = existing.id || null;
existing.id = newId();
}
const definitionName = subgraphFor(host)?.name;
const requestedName = preferredHostRoutingName({
storedName: existing.name,
storedDefinitionName: existing.definitionName,
title: host.title,
lastAppliedTitle: host.__bvLastHostRoutingTitle,
definitionName,
});
existing.name = uniqueHostName(requestedName, usedNames);
if (definitionName) existing.definitionName = definitionName;
host.title = existing.name;
host.__bvLastHostRoutingTitle = existing.name;
usedIds.add(existing.id);
usedNames.add(existing.name);
return { routing: existing, replacedId };
}
function isSmartPipeMerge(node) {
return node?.comfyClass === "BV Smart Pipe Merge" || node?.type === "BV Smart Pipe Merge";
}
function routableNodeId(node) {
if (isSmartPipe(node)) return routingFor(node).nodeId;
node.properties ??= {};
node.properties.bvSmartPipeMerge ??= { version: 1, nodeId: newId(), sources: [] };
const state = node.properties.bvSmartPipeMerge;
if (!state.nodeId) state.nodeId = newId();
return state.nodeId;
}
export function collectExpandedPipeAddresses(rootGraph) {
const { rootId, registry } = rootRoutingState(rootGraph);
const addressByExecutionId = {};
const descriptors = [];
const walk = (graph, numericPath = [], hostPath = [], hostNames = []) => {
const participatingHosts = graphNodes(graph).filter((node) => containsSmartPipe(subgraphFor(node)));
const usedHostIds = new Set();
const usedHostNames = new Set();
const seenDefinitions = new Set();
for (const node of graphNodes(graph)) {
const executionId = [...numericPath, node.id].join(":");
if (isSmartPipe(node) || isSmartPipeMerge(node)) {
const route = isSmartPipe(node) ? routingFor(node) : { nodeId: routableNodeId(node), name: node.title || "Pipe Merge" };
const address = logicalAddress(rootId, hostPath, route.nodeId);
addressByExecutionId[executionId] = address;
descriptors.push({ address, executionId, node, route, kind: isSmartPipeMerge(node) ? "merge" : "pipe", hostPath: [...hostPath], hostNames: [...hostNames] });
}
const subgraph = subgraphFor(node);
if (!subgraph || !participatingHosts.includes(node)) continue;
const definitionAlreadySeen = seenDefinitions.has(subgraph);
seenDefinitions.add(subgraph);
const { routing: host, replacedId } = ensureHostRouting(node, usedHostIds, usedHostNames);
if (!definitionAlreadySeen && subgraph.name && subgraph.name !== host.name) {
subgraph.name = host.name;
host.definitionName = host.name;
}
if (replacedId) {
cloneRouteRegistryPrefix(
registry,
logicalAddress(rootId, hostPath, replacedId),
logicalAddress(rootId, hostPath, host.id),
);
}
walk(subgraph, [...numericPath, node.id], [...hostPath, host.id], [...hostNames, host.name]);
}
};
walk(rootGraph);
return { addressByExecutionId, descriptors, registry, rootId };
}
function applyInstanceSchemaProjections(apiPrompt, routing) {
for (const descriptor of routing.descriptors) {
if (!isSmartPipe(descriptor.node)) continue;
const projection = routing.registry[descriptor.address]?.projection;
if (!projection?.resolvedSlots?.length) continue;
const entry = apiPrompt?.[descriptor.executionId];
if (!entry || entry.class_type !== NODE_CLASS) continue;
entry.inputs.bv_smart_pipe_schema_json = JSON.stringify(projection.resolvedSlots.map((slot) => {
const input = descriptor.node.inputs?.find((item) => item.bvSlotId === slot.id);
const output = descriptor.node.outputs?.find((item) => item.bvSlotId === slot.id);
return { ...slot, connected: input?.link != null || Boolean(output?.links?.length) };
}));
}
return apiPrompt;
}
function remapSmartPipeOutputLinks(apiPrompt, routing) {
const outputIndexMaps = {};
for (const descriptor of routing.descriptors) {
const schema = routing.registry[descriptor.address]?.projection?.resolvedSlots || stateFor(descriptor.node).resolvedSlots || [];
const ordinalById = new Map(schema.map((slot) => [slot.id, slot.ordinal]));
const indexMap = { 0: 0 };
for (let index = 1; index < (descriptor.node.outputs?.length || 0); index++) {
const slotId = descriptor.node.outputs[index]?.bvSlotId;
const ordinal = ordinalById.get(slotId);
if (ordinal) indexMap[index] = ordinal;
}
outputIndexMaps[descriptor.executionId] = indexMap;
}
return remapPromptOutputLinks(apiPrompt, outputIndexMaps);
}
function samePath(left, right) {
return left?.length === right?.length && left.every((part, index) => part === right[index]);
}
export function activeDescriptorFor(node, routing = collectExpandedPipeAddresses(app.graph)) {
const hostPath = node.graph === app.graph ? [] : activeGraphContexts.get(node.graph)?.hostPath;
if (hostPath) return routing.descriptors.find((descriptor) => descriptor.node === node && samePath(descriptor.hostPath, hostPath)) || null;
const candidates = routing.descriptors.filter((descriptor) => descriptor.node === node);
return candidates.length === 1 ? candidates[0] : null;
}
function applyMergeConfigurations(apiPrompt, routing) {
for (const descriptor of routing.descriptors) {
if (descriptor.kind !== "merge") continue;
const entry = apiPrompt?.[descriptor.executionId];
if (!entry || entry.class_type !== "BV Smart Pipe Merge") continue;
const sources = routing.registry[descriptor.address]?.sources || descriptor.node.properties?.bvSmartPipeMerge?.sources || [];
entry.inputs.bv_smart_pipe_merge_json = JSON.stringify(sources);
}
}
function installSubgraphContextTracking() {
if (app.__bvSmartPipeContextTracking || !app.canvas?.addEventListener) return;
app.__bvSmartPipeContextTracking = true;
if (app.graph) activeGraphContexts.set(app.graph, { hostPath: [], hostNames: [] });
app.canvas.addEventListener("subgraph-opened", (event) => {
const detail = event.detail || event;
const parent = detail.closingGraph === app.graph
? { hostPath: [], hostNames: [] }
: activeGraphContexts.get(detail.closingGraph);
if (!parent || !detail.fromNode || !detail.subgraph) return;
collectExpandedPipeAddresses(app.graph);
const host = detail.fromNode.properties?.[HOST_ROUTING_PROPERTY];
if (!host) return;
activeGraphContexts.set(detail.subgraph, {
hostPath: [...parent.hostPath, host.id],
hostNames: [...parent.hostNames, host.name],
});
requestAnimationFrame(() => propagateGraph(detail.subgraph));
});
app.canvas.addEventListener("litegraph:set-graph", (event) => {
const detail = event.detail || event;
if (detail.newGraph === app.graph) activeGraphContexts.set(app.graph, { hostPath: [], hostNames: [] });
requestAnimationFrame(() => propagateGraph(detail.newGraph));
});
}
function routingFor(node) {
const state = stateFor(node);
state.routing ??= { version: 1, nodeId: newId(), name: !node.title || node.title === "Pipe" ? SMART_PIPE_DEFAULT_TITLE : node.title, mode: "root", predecessorId: null };
const route = state.routing;
const siblings = pipeNodes(node.graph).filter((candidate) => candidate !== node);
const duplicateIdOwner = siblings.find((candidate) => stateFor(candidate).routing?.nodeId === route.nodeId);
if (duplicateIdOwner && routingCollisionOwner(node, duplicateIdOwner) !== node) route.nodeId = newId();
const requestedName = node.__bvLastRoutingTitle && node.title !== node.__bvLastRoutingTitle ? node.title : route.name;
const usedNames = new Set(siblings
.filter((candidate) => {
const siblingName = stateFor(candidate).routing?.name;
return siblingName !== requestedName || routingCollisionOwner(node, candidate) !== node;
})
.map((candidate) => stateFor(candidate).routing?.name)
.filter(Boolean));
route.name = uniqueRoutingName(requestedName, usedNames);
node.title = route.name;
node.__bvLastRoutingTitle = route.name;
if (!route.predecessorId) route.mode = "root";
return route;
}
function linkAt(graph, id) {
return graph?.links?.get?.(id) ?? graph?.links?.[id];
}
function upstreamNode(node) {
const input = node.inputs?.find((slot) => slot.name === "pipe");
if (input?.link == null) return null;
const link = linkAt(node.graph, input.link);
return link ? node.graph?.getNodeById?.(link.origin_id) : null;
}
function wirelessUpstreamNode(node) {
const route = routingFor(node);
if (route.mode !== "follow" || !route.predecessorId) return null;
return pipeNodes(node.graph).find((candidate) => routingFor(candidate).nodeId === route.predecessorId) || null;
}
function crossScopeRouteFor(node, routing = collectExpandedPipeAddresses(app.graph), descriptorOverride = null) {
const destination = descriptorOverride || activeDescriptorFor(node, routing);
if (!destination) return null;
const edge = routing.registry[destination.address];
if (!edge?.predecessorAddress) return null;
const predecessor = routing.descriptors.find((descriptor) => descriptor.address === edge.predecessorAddress);
return predecessor ? { destination, predecessor, edge, routing } : null;
}
function crossScopeUpstreamNode(node) {
return crossScopeRouteFor(node)?.predecessor?.node || null;
}
function effectiveUpstreamNode(node) {
return upstreamNode(node) || crossScopeUpstreamNode(node) || wirelessUpstreamNode(node);
}
function registerUpstream(node, upstream) {
if (node.__bvSmartPipeUpstream === upstream) return;
node.__bvSmartPipeUpstream?.__bvSmartPipeTargets?.delete(node);
node.__bvSmartPipeUpstream = upstream;
if (!upstream) return;
upstream.__bvSmartPipeTargets ??= new Set();
upstream.__bvSmartPipeTargets.add(node);
}
function copySlot(slot, missing = slot.missing ?? false) {
return {
id: slot.id,
name: slot.name,
ordinal: slot.ordinal,
type: slot.type || "*",
showInput: slot.showInput ?? false,
showOutput: slot.showOutput ?? false,
missing,
dormant: slot.dormant ?? false,
};
}
function slotHasConnections(node, slotId) {
const input = node.inputs?.find((item) => item.bvSlotId === slotId);
const output = node.outputs?.find((item) => item.bvSlotId === slotId);
return input?.link != null || Boolean(output?.links?.length);
}
function slotHasInputConnection(node, slotId) {
return node.inputs?.some((item) => item.bvSlotId === slotId && item.link != null) ?? false;
}
function downstreamPipeNodes(node) {
return pipeNodes(node.graph).filter((candidate) => candidate !== node && effectiveUpstreamNode(candidate) === node);
}
function slotUsedDownstream(node, slotId, visited = new Set()) {
if (!node || visited.has(node)) return false;
visited.add(node);
for (const target of downstreamPipeNodes(node)) {
if (slotHasConnections(target, slotId) || slotUsedDownstream(target, slotId, visited)) return true;
}
return false;
}
function schemaProjectionFor(node, descriptorOverride = null, routing = collectExpandedPipeAddresses(app.graph)) {
const definition = stateFor(node);
const crossRoute = crossScopeRouteFor(node, routing, descriptorOverride);
if (!crossRoute) return { definition, projection: definition };
crossRoute.edge.projection ??= { version: 1, inheritedSlots: [], resolvedSlots: [] };
return { definition, projection: crossRoute.edge.projection };
}
function descriptorForWiredMergeInput(mergeDescriptor, input, routing) {
if (input?.link == null) return null;
const link = linkAt(mergeDescriptor.node.graph, input.link);
const sourceNode = link ? mergeDescriptor.node.graph?.getNodeById?.(link.origin_id) : null;
return routing.descriptors.find((candidate) => candidate.node === sourceNode && samePath(candidate.hostPath, mergeDescriptor.hostPath)) || null;
}
function resolveDescriptorSchema(descriptor, resolving, routing) {
if (!descriptor) return null;
if (descriptor.kind === "pipe") return resolveSchema(descriptor.node, resolving, descriptor);
const resolvingKey = descriptor.address;
if (resolving.has(resolvingKey)) return [];
resolving.add(resolvingKey);
const sources = routing.registry[descriptor.address]?.sources || descriptor.node.properties?.bvSmartPipeMerge?.sources || [];
const schemas = sources.map((source) => {
const sourceDescriptor = source.mode === "wireless"
? routing.descriptors.find((candidate) => candidate.address === source.address)
: descriptorForWiredMergeInput(descriptor, descriptor.node.inputs?.find((input) => input.name === source.key), routing);
return resolveDescriptorSchema(sourceDescriptor, resolving, routing) || [];
});
resolving.delete(resolvingKey);
return mergePipeSchemas(schemas);
}
function resolveSchema(node, resolving = new Set(), descriptorOverride = null) {
const routing = collectExpandedPipeAddresses(app.graph);
const descriptor = descriptorOverride || activeDescriptorFor(node, routing);
const { definition, projection } = schemaProjectionFor(node, descriptor, routing);
const resolvingKey = descriptor?.address || node;
if (resolving.has(resolvingKey)) return projection.resolvedSlots || [];
resolving.add(resolvingKey);
const physicalUpstream = upstreamNode(node);
const crossRoute = physicalUpstream ? null : crossScopeRouteFor(node, routing, descriptor);
const localUpstream = physicalUpstream || (!crossRoute ? wirelessUpstreamNode(node) : null);
const upstream = physicalUpstream || crossRoute?.predecessor?.node || localUpstream;
const upstreamDescriptor = crossRoute?.predecessor || (descriptor && upstream
? routing.descriptors.find((candidate) => candidate.node === upstream && samePath(candidate.hostPath, descriptor.hostPath))
: null);
const upstreamSchema = upstreamDescriptor
? resolveDescriptorSchema(upstreamDescriptor, resolving, routing)
: isSmartPipe(upstream) ? resolveSchema(upstream, resolving, upstreamDescriptor) : null;
let previous = projection.inheritedSlots || [];
if (!upstreamSchema) {
const connectedSlotIds = new Set((node.inputs || []).filter((input) => input.link != null && input.bvSlotId).map((input) => input.bvSlotId));
const promotionState = { inheritedSlots: projection.inheritedSlots || [], localSlots: definition.localSlots };
promoteConnectedInheritedSlots(promotionState, connectedSlotIds);
projection.inheritedSlots = promotionState.inheritedSlots;
definition.localSlots = promotionState.localSlots;
previous = projection.inheritedSlots || [];
}
if (upstreamSchema) {
const upstreamIds = new Set(upstreamSchema.map((slot) => slot.id));
const adopted = definition.localSlots.filter((slot) => upstreamIds.has(slot.id));
if (adopted.length) {
const projections = new Map(previous.map((slot) => [slot.id, slot]));
for (const slot of adopted) {
const projection = projections.get(slot.id);
projections.set(slot.id, {
...copySlot(slot),
showInput: projection?.showInput ?? slot.showInput ?? false,
showOutput: projection?.showOutput ?? slot.showOutput ?? false,
});
}
previous = [...projections.values()];
definition.localSlots = definition.localSlots.filter((slot) => !upstreamIds.has(slot.id));
}
const renamed = resolveLocalSlotNameCollisions(definition.localSlots, upstreamSchema.map((slot) => slot.name));
if (renamed.length) definition.renameNotices = renamed;
}
const inherited = upstreamSchema
? [
...upstreamSchema.map((slot) => {
const projection = previous.find((item) => item.id === slot.id);
const hasInput = slotHasInputConnection(node, slot.id);
const directlyUsed = slotHasConnections(node, slot.id);
const usedDownstream = slotUsedDownstream(node, slot.id);
return {
...copySlot(slot, Boolean(slot.missing) && !hasInput),
showInput: projection?.showInput ?? false,
showOutput: projection?.showOutput ?? false,
dormant: Boolean(slot.missing) && !hasInput && !directlyUsed && usedDownstream,
};
}),
...retainedMissingSlots(previous, new Set(upstreamSchema.map((slot) => slot.id)), (slot) => slotHasConnections(node, slot.id) || slotUsedDownstream(node, slot.id))
.map((slot) => ({ ...copySlot(slot, true), dormant: !slotHasConnections(node, slot.id) })),
]
: retainedMissingSlots(previous, new Set(), (slot) => slotHasConnections(node, slot.id) || slotUsedDownstream(node, slot.id))
.map((slot) => ({ ...copySlot(slot, true), dormant: !slotHasConnections(node, slot.id) }));
const usedOrdinals = new Set(inherited.map((slot) => slot.ordinal));
for (const slot of definition.localSlots) {
if (!slot.ordinal || usedOrdinals.has(slot.ordinal)) {
slot.ordinal = nextFreeOrdinal(usedOrdinals, definition.nextOrdinal, MAX_SLOTS);
if (slot.ordinal) definition.nextOrdinal = slot.ordinal + 1;
}
if (slot.ordinal) definition.nextOrdinal = Math.max(definition.nextOrdinal, slot.ordinal + 1);
usedOrdinals.add(slot.ordinal);
}
projection.inheritedSlots = inherited;
projection.resolvedSlots = [...inherited, ...definition.localSlots.map((slot) => copySlot(slot))]
.filter((slot) => slot.ordinal)
.sort((left, right) => left.ordinal - right.ordinal);
resolving.delete(resolvingKey);
return projection.resolvedSlots;
}
function removeSlot(node, kind, index) {
if (kind === "input") node.removeInput(index);
else node.removeOutput(index);
}
function typesAreCompatible(left, right) {
const leftTypes = String(left || "*").split(",").map((value) => value.trim()).filter(Boolean);
const rightTypes = String(right || "*").split(",").map((value) => value.trim()).filter(Boolean);
return leftTypes.includes("*") || rightTypes.includes("*") || leftTypes.some((type) => rightTypes.includes(type));
}
function temporarilyRevealOutput(node, slot) {
return node.__bvSmartPipeRevealTypes?.some((type) => typesAreCompatible(type, slot.type));
}
function reconcilePorts(node, schema) {
const desiredInputs = new Set(schema.filter((slot) => !slot.dormant && (slot.showInput || slot.missing)).map((slot) => `v_${String(slot.ordinal).padStart(3, "0")}`));
const desiredOutputs = new Set(schema.filter((slot) => !slot.dormant && (slot.showOutput || slot.missing || temporarilyRevealOutput(node, slot))).map((slot) => `out_${String(slot.ordinal).padStart(3, "0")}`));
for (let index = (node.inputs?.length || 0) - 1; index >= 0; index--) {
const slot = node.inputs[index];
if (slot.bvAddSlot) continue;
if (slot.name !== "pipe" && !desiredInputs.has(slot.name) && slot.link == null) removeSlot(node, "input", index);
}
for (let index = (node.outputs?.length || 0) - 1; index >= 0; index--) {
const slot = node.outputs[index];
if (slot.name !== "pipe" && !desiredOutputs.has(slot.name) && !(slot.links?.length)) removeSlot(node, "output", index);
}
for (const slot of schema) {
const inputName = `v_${String(slot.ordinal).padStart(3, "0")}`;
const outputName = `out_${String(slot.ordinal).padStart(3, "0")}`;
const label = `${slot.missing ? "⚠ Missing: " : ""}${slot.name}`;
let inputIndex = reusableSmartPipePortIndex(node.inputs, { slotId: slot.id, portName: inputName, label: slot.name, type: slot.type });
let input = inputIndex >= 0 ? node.inputs[inputIndex] : null;
if (!slot.dormant && (slot.showInput || slot.missing) && !input) input = node.addInput(inputName, slot.type || "*");
if (input) {
inputIndex = node.inputs.indexOf(input);
node.inputs[inputIndex] = updateSmartPipePort(input, label, slot.type || "*", slot.id);
node.inputs[inputIndex].name = inputName;
}
let outputIndex = reusableSmartPipePortIndex(node.outputs, { slotId: slot.id, portName: outputName, label: slot.name, type: slot.type });
let output = outputIndex >= 0 ? node.outputs[outputIndex] : null;
if (!slot.dormant && (slot.showOutput || slot.missing || temporarilyRevealOutput(node, slot)) && !output) output = node.addOutput(outputName, slot.type || "*");
if (output) {
outputIndex = node.outputs.indexOf(output);
node.outputs[outputIndex] = updateSmartPipePort(output, label, slot.type || "*", slot.id);
node.outputs[outputIndex].name = outputName;
}
}
for (const kind of ["input", "output"]) {
const ports = kind === "input" ? node.inputs || [] : node.outputs || [];
const indexesByName = new Map();
ports.forEach((port, index) => {
if (port.name === "pipe" || port.bvAddSlot) return;
const indexes = indexesByName.get(port.name) || [];
indexes.push(index);
indexesByName.set(port.name, indexes);
});
for (const indexes of indexesByName.values()) {
if (indexes.length < 2) continue;
const connected = indexes.find((index) => kind === "input" ? ports[index]?.link != null : Boolean(ports[index]?.links?.length));
const keep = connected ?? indexes[0];
for (const index of [...indexes].sort((left, right) => right - left)) {
if (index !== keep) removeSlot(node, kind, index);
}
}
}
const addSlots = (node.inputs || []).filter((input) => input.bvAddSlot);
const keepAddSlot = schema.length < MAX_SLOTS;
for (let index = addSlots.length - 1; index >= (keepAddSlot ? 1 : 0); index--) removeSlot(node, "input", node.inputs.indexOf(addSlots[index]));
if (keepAddSlot && !addSlots.length) {
const addSlot = node.addInput(ADD_SLOT_NAME, "*");
addSlot.label = "+ Add Slot";
addSlot.localized_name = "+ Add Slot";
addSlot.bvAddSlot = true;
}
moveMarkedPortToEnd(node.inputs, (input) => input.bvAddSlot);
node.inputs = [...(node.inputs || [])];
node.outputs = [...(node.outputs || [])];
}
function sourceOutput(node, linkInfo) {
const source = node.graph?.getNodeById?.(linkInfo?.origin_id);
return source?.outputs?.[linkInfo?.origin_slot] || null;
}
function convertAddSlot(node, index, linkInfo) {
const input = node.inputs?.[index];
if (!input?.bvAddSlot) return false;
const state = stateFor(node);
const schema = resolveSchema(node);
const usedOrdinals = new Set(schema.map((slot) => slot.ordinal));
const ordinal = nextFreeOrdinal(usedOrdinals, state.nextOrdinal, MAX_SLOTS);
if (!ordinal) return false;
const output = sourceOutput(node, linkInfo);
const baseName = smartPipeSlotName(output?.localized_name || output?.label || output?.name);
const name = uniqueSmartPipeSlotName(baseName, new Set(schema.map((slot) => slot.name)));
const inferredType = linkInfo?.type || linkInfo?.origin_type || output?.type;
const type = inferredType && inferredType !== "*" ? inferredType : "*";
const slot = { id: newId(), name, ordinal, type, showInput: true, showOutput: false };
state.localSlots.push(slot);
state.nextOrdinal = ordinal + 1;
const converted = updateSmartPipePort(input, name, type, slot.id);
converted.name = `v_${String(ordinal).padStart(3, "0")}`;
delete converted.bvAddSlot;
node.inputs[index] = converted;
return true;
}
function updateSchemaWidget(node, schema) {
const widget = node.widgets?.find((item) => item.name === "bv_smart_pipe_schema_json");
if (!widget) return;
const serialized = schema.map((slot) => {
const input = node.inputs?.find((item) => item.bvSlotId === slot.id);
const output = node.outputs?.find((item) => item.bvSlotId === slot.id);
return { ...slot, connected: input?.link != null || Boolean(output?.links?.length) };
});
widget.value = JSON.stringify(serialized);
widget.computeSize = () => [0, -4];
widget.type = "converted-widget";
widget.draw = () => {};
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
if (widget.element) widget.element.style.display = "none";
}
function updateRouteWidget(node) {
const route = routingFor(node);
const widget = node.widgets?.find((item) => item.name === ROUTE_INPUT);
if (!widget) return;
widget.value = JSON.stringify(route);
widget.computeSize = () => [0, -4];
widget.type = "converted-widget";
widget.draw = () => {};
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
if (widget.element) widget.element.style.display = "none";
}
function wirelessChoices(node) {
const nodes = pipeNodes(node.graph);
const routing = collectExpandedPipeAddresses(app.graph);
const ownDescriptor = activeDescriptorFor(node, routing);
const scopeDescriptors = ownDescriptor
? routing.descriptors.filter((descriptor) => samePath(descriptor.hostPath, ownDescriptor.hostPath))
: [];
const descriptorByAddress = new Map(scopeDescriptors.map((descriptor) => [descriptor.address, descriptor]));
const displayRoutes = scopeDescriptors.length
? scopeDescriptors.map((descriptor) => {
if (descriptor.kind === "merge") {
const sources = routing.registry[descriptor.address]?.sources
|| descriptor.node.properties?.bvSmartPipeMerge?.sources
|| [];
return {
nodeId: descriptor.route.nodeId,
name: descriptor.route.name,
mode: "merge",
sourceIds: sources.map((source) => descriptorByAddress.get(source.address)?.route.nodeId).filter(Boolean),
};
}
const predecessor = effectiveUpstreamNode(descriptor.node);
const predecessorId = predecessor ? routableNodeId(predecessor) : null;
return {
...descriptor.route,
mode: predecessorId ? "follow" : "root",
predecessorId,
};
})
: nodes.map(routingFor);
const numbers = routingDisplayNumbers(predecessorChoiceRoutes(displayRoutes, ownDescriptor?.route.nodeId));
const choices = [];
for (const candidate of nodes) {
if (candidate === node) continue;
const visited = new Set();
let cursor = candidate;
let createsCycle = false;
while (cursor && !visited.has(cursor)) {
if (cursor === node) {
createsCycle = true;
break;
}
visited.add(cursor);
cursor = effectiveUpstreamNode(cursor);
}
if (createsCycle) continue;
const route = routingFor(candidate);
const number = numbers.get(route.nodeId);
choices.push({ label: routingChoiceLabel(number, route.name), id: route.nodeId, address: null, number, scope: "local" });
}
if (ownDescriptor) {
const sameScopeDescriptors = routing.descriptors
.filter((descriptor) => descriptor.address !== ownDescriptor.address && samePath(descriptor.hostPath, ownDescriptor.hostPath));
for (const descriptor of sameScopeDescriptors.filter((candidate) => candidate.kind === "merge")) {
const number = numbers.get(descriptor.route.nodeId);
choices.push({ label: routingChoiceLabel(number, descriptor.route.name), id: null, address: descriptor.address, number, scope: "local" });
}
choices.sort((left, right) => compareRoutingNumbers(left.number, right.number) || left.label.localeCompare(right.label));
const crossChoices = crossScopeDescriptors(routing.descriptors, ownDescriptor)
.map((descriptor) => {
const scope = descriptor.hostNames.length ? descriptor.hostNames.join(" › ") : "Root";
return { label: `↗ ${scope} › ${descriptor.route.name}`, id: null, address: descriptor.address, number: "", scope };
})
.sort((left, right) => left.scope.localeCompare(right.scope) || left.label.localeCompare(right.label));
choices.push(...crossChoices);
} else {
choices.sort((left, right) => compareRoutingNumbers(left.number, right.number) || left.label.localeCompare(right.label));
}
return [{ label: "Start new pipe", id: null, address: null, number: "0", scope: "local" }, ...choices];
}
function updatePredecessorWidget(node) {
const widget = node.__bvPredecessorWidget;
if (!widget) return;
const wired = upstreamNode(node);
const route = routingFor(node);
const choices = wirelessChoices(node);
if (wired) {
widget.options.values = [`Wired: ${routingFor(wired).name}`];
widget.value = widget.options.values[0];
widget.disabled = false;
widget.__bvWired = true;
return;
}
widget.__bvWired = false;
widget.__bvChoices = choices;
widget.options.values = choices.map((choice) => choice.label);
const crossRoute = crossScopeRouteFor(node);
widget.value = crossRoute
? choices.find((choice) => choice.address === crossRoute.predecessor.address)?.label || `⚠ Missing: ${crossRoute.edge.predecessorAddress}`
: choices.find((choice) => choice.id === route.predecessorId)?.label || "Start new pipe";
}
export function propagate(node, visited = new Set()) {
if (!node || visited.has(node)) return;
visited.add(node);
registerUpstream(node, effectiveUpstreamNode(node));
const schema = resolveSchema(node);
reconcilePorts(node, schema);
updateSchemaWidget(node, schema);
updateRouteWidget(node);
updatePredecessorWidget(node);
const visibleSlots = Math.max((node.inputs?.length || 1) - 1, (node.outputs?.length || 1) - 1);
node.setSize?.([Math.max(260, node.size?.[0] || 0), 90 + visibleSlots * 22]);
node.setDirtyCanvas?.(true, true);
for (const target of node.__bvSmartPipeTargets || []) propagate(target, visited);
}
function validateSlots(node) {
const state = stateFor(node);
const names = new Set();
for (const slot of resolveSchema(node)) {
const local = state.localSlots.find((item) => item.id === slot.id);
if (local) {
local.name = local.name.trim();
local.showInput = true;
}
const name = local?.name ?? slot.name;
if (!name) throw new Error("Jeder lokale Slot benötigt einen Namen.");
if (names.has(name)) throw new Error(`Duplicate slot name: ${name}`);
names.add(name);
}
}
function openEditor(node) {
window.dispatchEvent(new CustomEvent("bv-open-smart-pipe-editor", { detail: { node, kind: "pipe" } }));
}
globalThis.__bvSmartPipeEditorBridge ??= {};
globalThis.__bvSmartPipeEditorBridge.pipe = {
read(node) {
const { definition, projection } = schemaProjectionFor(node);
return structuredClone({ definition, projection, slots: resolveSchema(node) });
},
predecessor(node) {
const widget = node.__bvPredecessorWidget;
return { value: String(widget?.value ?? "Start new pipe"), options: (widget?.__bvChoices ?? []).map((choice) => ({ value: choice.label, label: choice.label })) };
},
validation(node, draft) {
return (draft.definition?.localSlots ?? []).filter((slot) => !slotHasInputConnection(node, slot.id)).map((slot) => ({ id: slot.id, message: `Local slot “${slot.name || slot.id}” requires a connected input.` }));
},
setPredecessor(node, value) {
const widget = node.__bvPredecessorWidget;
if (!widget || widget.__bvWired) return;
widget.value = value;
widget.callback?.(value);
node.graph?.setDirtyCanvas?.(true, true);
},
save(node, draft) {
const { definition, projection } = schemaProjectionFor(node);
definition.localSlots = structuredClone(draft.definition?.localSlots ?? []);
definition.nextOrdinal = Number(draft.definition?.nextOrdinal ?? definition.nextOrdinal);
projection.inheritedSlots = structuredClone(draft.projection?.inheritedSlots ?? []);
validateSlots(node);
propagate(node);
node.graph?.setDirtyCanvas?.(true, true);
},
};
function setupNode(node) {
if (node.__bvSmartPipeReady) return;
node.__bvSmartPipeReady = true;
stateFor(node);
routingFor(node);
const predecessor = node.addWidget?.("combo", "Pipe predecessor", "Start new pipe", (label) => {
if (upstreamNode(node) || predecessor.__bvWired) return;
const route = routingFor(node);
const selected = predecessor.__bvChoices?.find((choice) => choice.label === label);
const routing = collectExpandedPipeAddresses(app.graph);
const destination = activeDescriptorFor(node, routing);
if (destination) delete routing.registry[destination.address];
if (selected?.address && destination) {
routing.registry[destination.address] = { version: 1, predecessorAddress: selected.address };
route.mode = "root";
route.predecessorId = null;
} else {
route.mode = selected?.id ? "follow" : "root";
route.predecessorId = selected?.id || null;
}
requestAnimationFrame(() => propagateGraph(node.graph));
}, { values: ["Start new pipe"], serialize: false });
if (predecessor) {
predecessor.label = "Pipe predecessor";
predecessor.serialize = false;
node.__bvPredecessorWidget = predecessor;
}
const button = node.addWidget?.("button", "Configure", null, () => openEditor(node), { serialize: false });
if (button) button.label = "Configure Smart Pipe";
const originalConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (type, index, connected, linkInfo) {
originalConnectionsChange?.apply(this, arguments);
if (type === 1 && connected) {
if (convertAddSlot(this, index, linkInfo)) {
requestAnimationFrame(() => propagate(this));
return;
}
if (index <= 0) return;
const input = this.inputs?.[index];
const slot = resolveSchema(this).find((item) => item.id === input?.bvSlotId);
const inferred = linkInfo?.type || linkInfo?.origin_type;
if (slot?.missing) {
const { definition, projection } = schemaProjectionFor(this);
const promotionState = { inheritedSlots: projection.inheritedSlots || [], localSlots: definition.localSlots };
promoteInheritedSlot(promotionState, slot.id, inferred);
projection.inheritedSlots = promotionState.inheritedSlots;
definition.localSlots = promotionState.localSlots;
}
if (slot && slot.type === "*" && inferred && inferred !== "*") {
const local = stateFor(this).localSlots.find((item) => item.id === slot.id);
if (local) local.type = inferred;
}
}
if (type === 1 && index === 0 && !connected) {
const route = routingFor(this);
route.mode = "root";
route.predecessorId = null;
}
if (type === 2 && connected) {
const output = this.outputs?.[index];
const slot = resolveSchema(this).find((item) => item.id === output?.bvSlotId);
const projection = schemaProjectionFor(this).projection;
const target = stateFor(this).localSlots.find((item) => item.id === slot?.id)
|| projection.inheritedSlots.find((item) => item.id === slot?.id);
if (target) target.showOutput = true;
}
requestAnimationFrame(() => propagate(this));
};
requestAnimationFrame(() => propagate(node));
}
function installPromptMaterializer() {
if (app.__bvSmartPipePromptMaterializer || typeof app.graphToPrompt !== "function") return;
app.__bvSmartPipePromptMaterializer = true;
const original = app.graphToPrompt;
app.graphToPrompt = async function () {
const seenGraphs = new Set();
const refreshGraph = (graph) => {
if (!graph || seenGraphs.has(graph)) return;
seenGraphs.add(graph);
for (const node of graph._nodes || graph.nodes || []) {
if (isSmartPipe(node)) propagate(node);
const subgraph = subgraphFor(node);
if (subgraph) refreshGraph(subgraph);
}
};
refreshGraph(app.graph);
const routing = collectExpandedPipeAddresses(app.graph);
const modeState = promptModeState(routing.descriptors);
const { prunedExecutionIds } = modeState;
const localModeState = {
routesByScopedNodeId: new Map(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe")
.map((descriptor) => [`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`, descriptor.route])),
bypassedScopedNodeIds: new Set(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && descriptor.node?.mode === 4)
.map((descriptor) => `${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`)),
mutedScopedNodeIds: new Set(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && descriptor.node?.mode === 2)
.map((descriptor) => `${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`)),
prunedExecutionIds,
bypassPredecessorsByScopedNodeId: new Map(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && descriptor.node?.mode === 4)
.map((descriptor) => {
const edge = routing.registry[descriptor.address];
const predecessor = edge?.predecessorAddress
? routing.descriptors.find((candidate) => candidate.address === edge.predecessorAddress)
: routing.descriptors.find((candidate) => candidate.hostPath.length === descriptor.hostPath.length
&& candidate.hostPath.every((part, index) => part === descriptor.hostPath[index])
&& candidate.route.nodeId === descriptor.route.predecessorId);
return predecessor ? [`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`, {
executionId: predecessor.executionId,
route: predecessor.route,
scope: executionScope(predecessor.executionId),
}] : null;
}).filter(Boolean)),
};
const result = await original.apply(this, arguments);
applyInstanceSchemaProjections(result?.output, routing);
applyMergeConfigurations(result?.output, routing);
remapSmartPipeOutputLinks(result?.output, routing);
materializeAddressedPipeLinks(result?.output, routing.addressByExecutionId, routing.registry, modeState);
materializeSmartPipeMergeSources(result?.output, routing.addressByExecutionId, routing.registry, modeState);
materializeWirelessPipeLinks(result?.output, localModeState);
prunePromptBranches(result?.output, prunedExecutionIds);
validateMaterializedPipeGraph(result?.output);
return result;
};
}
let revealMonitorStarted = false;
const routingSignatures = new WeakMap();
let lastHostRoutingCheck = 0;
let hostRoutingSignature = "";
const registryCleanupStates = new WeakMap();
function monitorHostRoutingNames(timestamp) {
if (!app.graph || timestamp - lastHostRoutingCheck < 250) return;
lastHostRoutingCheck = timestamp;
const routing = collectExpandedPipeAddresses(app.graph);
let cleanupState = registryCleanupStates.get(app.graph);
if (!cleanupState) {
cleanupState = { missingSince: new Map(), backups: new Map() };
registryCleanupStates.set(app.graph, cleanupState);
}
reconcileRouteRegistryDestinations(
routing.registry,
new Set(routing.descriptors.map((descriptor) => descriptor.address)),
cleanupState,
timestamp,
);
const nextSignature = routing.descriptors
.map((descriptor) => `${descriptor.address}\u0000${descriptor.hostNames.join("\u0000")}`)
.sort()
.join("\u0001");
if (hostRoutingSignature && hostRoutingSignature !== nextSignature) {
const graphs = new Set(routing.descriptors.map((descriptor) => descriptor.node.graph));
graphs.forEach((graph) => propagateGraph(graph));
}
hostRoutingSignature = nextSignature;
}
function monitorRoutingNames(graph, visited = new Set()) {
if (!graph || visited.has(graph)) return;
visited.add(graph);
const nodes = pipeNodes(graph);
const signature = nodes.map((node) => {
const route = routingFor(node);
return `${route.nodeId}\u0000${route.name}\u0000${node.title}\u0000${route.mode}\u0000${route.predecessorId || ""}`;
}).join("\u0001");
if (routingSignatures.has(graph) && routingSignatures.get(graph) !== signature) {
propagateGraph(graph);
}
routingSignatures.set(graph, nodes.map((node) => {
const route = routingFor(node);
return `${route.nodeId}\u0000${route.name}\u0000${node.title}\u0000${route.mode}\u0000${route.predecessorId || ""}`;
}).join("\u0001"));
for (const node of graphNodes(graph)) monitorRoutingNames(subgraphFor(node), visited);
}
function startTypedOutputRevealMonitor() {
if (revealMonitorStarted) return;
revealMonitorStarted = true;
let previousKey = "";
let previousGraph = null;
const monitor = (timestamp = performance.now()) => {
monitorHostRoutingNames(timestamp);
monitorRoutingNames(app.graph);
const connector = app.canvas?.linkConnector;
const renderLinks = connector?.renderLinks || [];
const types = connector?.state?.connectingTo === "output"
? [...new Set(renderLinks.map((link) => link.fromSlot?.type).filter(Boolean).map(String))]
: [];
const key = types.slice().sort().join("|");
const currentGraph = app.canvas?.getCurrentGraph?.() || app.canvas?.graph || app.graph;
if (key !== previousKey || currentGraph !== previousGraph) {
previousKey = key;
previousGraph = currentGraph;
for (const node of graphNodes(currentGraph)) {
if (!isSmartPipe(node)) continue;
node.__bvSmartPipeRevealTypes = types;
propagate(node);
}
}
requestAnimationFrame(monitor);
};
requestAnimationFrame(monitor);
}
app.registerExtension({
name: "bv_nodepack.smart_pipe",
init() {
installSubgraphContextTracking();
startTypedOutputRevealMonitor();
installPromptMaterializer();
if (document.getElementById("bv-internal-widget-style")) return;
const style = document.createElement("style");
style.id = "bv-internal-widget-style";
style.textContent = `
.dom-widget:has(textarea[placeholder="bv_smart_pipe_schema_json"]), .dom-widget:has(textarea[placeholder="bv_smart_pipe_route_json"]), .dom-widget:has(textarea[placeholder="bv_control_config_json"]) { display: none !important; }
`;
document.head.append(style);
},
async nodeCreated(node) {
if (isSmartPipe(node)) {
setupNode(node);
requestAnimationFrame(() => pipeNodes(node.graph).forEach((candidate) => propagate(candidate)));
}
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_CLASS) return;
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = originalConfigure?.apply(this, arguments);
setupNode(this);
requestAnimationFrame(() => propagate(this));
return result;
};
const originalMenu = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
originalMenu?.apply(this, arguments);
options.unshift({ content: "Configure Smart Pipe", callback: () => openEditor(this) });
};
},
});