1406 lines
67 KiB
JavaScript
1406 lines
67 KiB
JavaScript
// Registered presentation exception: dynamic-pipe-slot-structure.
|
||
// See PRESENTATION_EXCEPTIONS in ui/src/regional/nodePresentation.ts.
|
||
import { app } from "../../scripts/app.js";
|
||
import {duplicateNativeSlotRemovals, installNativeSlotConfigureTransaction, nativeSlotsAreConfiguring, nativeOutputIndexMap, restoreNativeSlotOrder, serializeNativeSlotIdentities, validateConnectedSlotIdentities, validateNativeOutputLinks, validateSavedSlotIdentities} from "./bv_node_slots.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,
|
||
effectiveSmartPipeEdges,
|
||
effectiveDescriptorMode,
|
||
logicalAddress,
|
||
materializeAddressedPipeLinks,
|
||
materializeSmartPipeMergeSources,
|
||
materializeWirelessPipeLinks,
|
||
preferredHostRoutingName,
|
||
predecessorChoiceRoutes,
|
||
promptModeState,
|
||
prunePromptBranches,
|
||
reconcileRouteRegistryDestinations,
|
||
relocateRouteRegistry,
|
||
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();
|
||
let rememberedGraphContext = null;
|
||
const NAVIGATION_CONTEXT_KEY = "bv.smartPipe.navigation.v1";
|
||
|
||
function rememberNavigationContext(targetGraph, context = rememberedGraphContext) {
|
||
if (!context?.rootId || !targetGraph?.id) return;
|
||
try {
|
||
globalThis.sessionStorage?.setItem(NAVIGATION_CONTEXT_KEY, JSON.stringify({
|
||
version: 1, rootId: context.rootId, graphId: targetGraph.id, hostPath: context.hostPath,
|
||
}));
|
||
} catch { /* Browser storage may be disabled; ordinary navigation still works. */ }
|
||
}
|
||
|
||
function restoreNavigationContext(targetGraph) {
|
||
if (rememberedGraphContext || !targetGraph?.id) return;
|
||
try {
|
||
const saved = JSON.parse(globalThis.sessionStorage?.getItem(NAVIGATION_CONTEXT_KEY) || "null");
|
||
if (saved?.version !== 1 || saved.rootId !== app.graph?.extra?.[ROOT_ROUTING_PROPERTY]
|
||
|| saved.graphId !== targetGraph.id || !Array.isArray(saved.hostPath)
|
||
|| !saved.hostPath.length || saved.hostPath.length > 128
|
||
|| !saved.hostPath.every(id => typeof id === "string" && id.length > 0)) return;
|
||
let graph = app.graph;
|
||
for (const id of saved.hostPath) {
|
||
const hosts = graphNodes(graph).filter(node => node.properties?.[HOST_ROUTING_PROPERTY]?.id === id);
|
||
if (hosts.length !== 1) return;
|
||
graph = subgraphFor(hosts[0]);
|
||
if (!graph) return;
|
||
}
|
||
if (graph !== targetGraph) return;
|
||
rememberedGraphContext = { root: app.graph, rootId: saved.rootId, hostPath: saved.hostPath };
|
||
} catch { /* Invalid or unavailable storage cannot select an arbitrary instance. */ }
|
||
}
|
||
|
||
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 graphContexts = new Map();
|
||
const copiedHosts = new Map();
|
||
const walk = (graph, numericPath = [], hostPath = [], hostNames = [], ancestorModes = []) => {
|
||
installRelocationHooks(graph, rootGraph);
|
||
const contexts = graphContexts.get(graph) || [];
|
||
contexts.push([...hostPath]);
|
||
graphContexts.set(graph, contexts);
|
||
const participatingHosts = graphNodes(graph).filter((node) => containsSmartPipe(subgraphFor(node)));
|
||
const usedHostIds = new Set();
|
||
const usedHostNames = 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], ancestorModes: [...ancestorModes] });
|
||
}
|
||
const subgraph = subgraphFor(node);
|
||
if (!subgraph || !participatingHosts.includes(node)) continue;
|
||
const { routing: host, replacedId } = ensureHostRouting(node, usedHostIds, usedHostNames);
|
||
if (replacedId) copiedHosts.set(node, replacedId);
|
||
const copiedFrom = copiedHosts.get(node);
|
||
if (copiedFrom) {
|
||
cloneRouteRegistryPrefix(
|
||
registry,
|
||
logicalAddress(rootId, hostPath, copiedFrom),
|
||
logicalAddress(rootId, hostPath, host.id),
|
||
);
|
||
}
|
||
walk(subgraph, [...numericPath, node.id], [...hostPath, host.id], [...hostNames, host.name], [...ancestorModes, node.mode ?? 0]);
|
||
}
|
||
};
|
||
walk(rootGraph);
|
||
for (const descriptor of descriptors) {
|
||
if (descriptor.kind !== "pipe" || descriptor.node.inputs?.find(input => input.name === "pipe")?.link == null) continue;
|
||
const upstream = upstreamNode(descriptor.node);
|
||
descriptor.physicalPredecessorAddress = descriptors.find(candidate => candidate.node === upstream && samePath(candidate.hostPath, descriptor.hostPath))?.address ?? null;
|
||
}
|
||
return { addressByExecutionId, descriptors, registry, rootId, graphContexts };
|
||
}
|
||
|
||
const relocationHooks = new WeakMap();
|
||
export function restoreSerializedPipeSlots(node, data) {
|
||
restoreNativeSlotOrder(node, data, (field, saved) => {
|
||
const nativeAction = field === "inputs" && ((saved.name === "bv_add_pipe_source" && saved.type === "BV_SMART_PIPE") || (saved.name === "bv_add_slot" && saved.type === "*"));
|
||
if (!nativeAction || typeof node.addInput !== "function") return null;
|
||
return () => {
|
||
const action = node.addInput(saved.name, saved.type);
|
||
action.label = saved.label;
|
||
if (saved.name === "bv_add_slot") action.bvAddSlot = true;
|
||
else action.bvAddPipeSource = true;
|
||
return action;
|
||
};
|
||
});
|
||
for (const input of node.inputs || []) {
|
||
if (input.name === ADD_SLOT_NAME) input.bvAddSlot = true;
|
||
if (input.name === "bv_add_pipe_source") input.bvAddPipeSource = true;
|
||
}
|
||
}
|
||
|
||
export function repairHostOutputBacklinks(graph, host) {
|
||
const restorers = [];
|
||
if (!host || !graphNodes(graph).includes(host)) return () => {};
|
||
const links = [...(graph._links?.values?.() || Object.values(graph.links || {}))];
|
||
for (const [index, slot] of (host.outputs || []).entries()) {
|
||
if (slot.type !== "BV_SMART_PIPE") continue;
|
||
const missing = links.filter(link => {
|
||
if (link.origin_id !== host.id || link.origin_slot !== index || slot.links?.includes(link.id)) return false;
|
||
if (link.target_id === graph.outputNode?.id) return graph.outputs?.[link.target_slot]?.linkIds?.includes(link.id);
|
||
const target = graphNodes(graph).find(node => node.id === link.target_id);
|
||
return target?.inputs?.[link.target_slot]?.link === link.id;
|
||
}).map(link => link.id);
|
||
if (!missing.length) continue;
|
||
const descriptor = Object.getOwnPropertyDescriptor(slot, "links");
|
||
const previous = slot.links;
|
||
const contents = Array.isArray(previous) ? [...previous] : null;
|
||
restorers.push(() => {
|
||
if (contents) previous.splice(0, previous.length, ...contents);
|
||
if (descriptor) Object.defineProperty(slot, "links", descriptor); else delete slot.links;
|
||
});
|
||
if (Array.isArray(previous)) previous.push(...missing);
|
||
else slot.links = missing;
|
||
}
|
||
return () => { for (const restore of restorers.reverse()) restore(); };
|
||
}
|
||
|
||
function installRelocationHooks(graph, rootGraph) {
|
||
if (!graph) return;
|
||
const installed = relocationHooks.get(graph);
|
||
if (installed) { installed.root = rootGraph; return; }
|
||
const state = { root: rootGraph };
|
||
relocationHooks.set(graph, state);
|
||
for (const [method, converting] of [["_convertToSubgraphImpl", true], ["_unpackSubgraphImpl", false]]) {
|
||
const original = graph[method];
|
||
if (typeof original !== "function") {
|
||
const publicName = converting ? "convertToSubgraph" : "unpackSubgraph";
|
||
const nativePublic = graph[publicName];
|
||
if (typeof nativePublic === "function") graph[publicName] = function (...args) {
|
||
if (containsSmartPipe(this)) throw new Error(`SmartPipe: unsupported native ${publicName} lifecycle; operation not started.`);
|
||
return nativePublic.apply(this, args);
|
||
};
|
||
continue;
|
||
}
|
||
// Version-bound native seam: execute before public finally/afterChange records Undo.
|
||
graph[method] = function (...args) {
|
||
const root = state.root;
|
||
const before = collectExpandedPipeAddresses(root);
|
||
// Native UI may invoke this method through a reactive graph facade.
|
||
// Resolve the graph captured when installing this hook, not facade identity.
|
||
const contexts = before.graphContexts.get(graph) || before.graphContexts.get(this);
|
||
if (!contexts?.length) throw new Error("SmartPipe: relocation graph context is unavailable; operation not started.");
|
||
const hostId = converting ? null : args[0]?.properties?.[HOST_ROUTING_PROPERTY]?.id;
|
||
const originalRegistry = structuredClone(before.registry);
|
||
const snapshot = structuredClone(originalRegistry);
|
||
const localEdges = [];
|
||
for (const descriptor of before.descriptors) {
|
||
if (descriptor.kind === "pipe" && descriptor.route.mode === "follow" && !snapshot[descriptor.address]?.predecessorAddress) {
|
||
const source = before.descriptors.find(candidate => candidate.route.nodeId === descriptor.route.predecessorId && samePath(candidate.hostPath, descriptor.hostPath));
|
||
if (source) localEdges.push([descriptor.address, source.address]);
|
||
}
|
||
if (descriptor.kind === "merge" && !snapshot[descriptor.address]) {
|
||
snapshot[descriptor.address] = { kind: "merge", sources: structuredClone(descriptor.node.properties.bvSmartPipeMerge.sources || []) };
|
||
}
|
||
}
|
||
// Unpacking into a graph with the same stable ID cannot be mapped safely.
|
||
if (!converting && hostId) {
|
||
const existingHostIds = new Set(graphNodes(this).filter(node => node !== args[0]).map(node => node.properties?.[HOST_ROUTING_PROPERTY]?.id).filter(Boolean));
|
||
if (graphNodes(subgraphFor(args[0])).some(node => existingHostIds.has(node.properties?.[HOST_ROUTING_PROPERTY]?.id))) {
|
||
throw new Error("SmartPipe: unpack would collide with an existing subgraph routing identity; operation not started.");
|
||
}
|
||
for (const context of contexts) {
|
||
const direct = before.descriptors.filter(d => samePath(d.hostPath, context));
|
||
const incoming = before.descriptors.filter(d => samePath(d.hostPath, [...context, hostId]));
|
||
if (incoming.some(d => direct.some(other => other.kind === d.kind && other.route.nodeId === d.route.nodeId))) {
|
||
throw new Error("SmartPipe: unpack would collide with an existing routing identity; operation not started.");
|
||
}
|
||
}
|
||
}
|
||
const correctedLinks = [];
|
||
if (converting && this.outputNode) {
|
||
const selected = new Set(args[0] || []);
|
||
if (selected.size === 1) {
|
||
const group = [...selected][0];
|
||
if (typeof group.recomputeInsideNodes === "function") {
|
||
group.recomputeInsideNodes();
|
||
for (const child of group.children || []) selected.add(child);
|
||
}
|
||
}
|
||
const selectedById = new Map([...selected].map(node => [node.id, node]));
|
||
const links = this._links?.values?.() || Object.values(this.links || {});
|
||
for (const link of links) {
|
||
if (link.type !== "*" || link.target_id !== this.outputNode.id) continue;
|
||
const output = selectedById.get(link.origin_id)?.outputs?.[link.origin_slot];
|
||
const boundary = this.outputs?.[link.target_slot];
|
||
if (output?.type !== "BV_SMART_PIPE" || boundary?.type !== "BV_SMART_PIPE") continue;
|
||
correctedLinks.push(link);
|
||
link.type = "BV_SMART_PIPE";
|
||
}
|
||
}
|
||
let result;
|
||
const restoreBacklinks = !converting ? repairHostOutputBacklinks(this, args[0]) : () => {};
|
||
const configureRestorers = [];
|
||
const existingNodes = new Set(before.descriptors.map(descriptor => descriptor.node));
|
||
const adaptedNodes = new Set();
|
||
const slotShapeKey = node => `${isSmartPipeMerge(node) ? "merge" : "pipe"}:${routableNodeId(node)}`;
|
||
// multiClone itself configures/serializes a temporary backend-shaped node.
|
||
// Its data is therefore not the original compact slot-index contract.
|
||
const originalSlotShapes = new Map(!converting ? graphNodes(subgraphFor(args[0])).filter(node => isSmartPipe(node) || isSmartPipeMerge(node)).map(node => [slotShapeKey(node), {
|
||
inputs: (node.inputs || []).map(slot => ({ name: slot.name, type: slot.type, label: slot.label })),
|
||
outputs: (node.outputs || []).map(slot => ({ name: slot.name })),
|
||
}]) : []);
|
||
const nativeAdd = this.add;
|
||
const addDescriptor = Object.getOwnPropertyDescriptor(this, "add");
|
||
const originalLinkFields = !converting ? [...(subgraphFor(args[0])?._links?.values?.() || [])].map(link => ({ link, fields: Object.fromEntries(["origin_id", "origin_slot", "target_id", "target_slot", "parentId"].map(key => [key, Object.getOwnPropertyDescriptor(link, key)])) })) : [];
|
||
if (!converting && typeof nativeAdd === "function") {
|
||
this.add = function (node, ...rest) {
|
||
const added = nativeAdd.call(this, node, ...rest);
|
||
if (!existingNodes.has(node) && !adaptedNodes.has(node) && (isSmartPipe(node) || isSmartPipeMerge(node))) {
|
||
adaptedNodes.add(node);
|
||
const configure = node.configure;
|
||
const descriptor = Object.getOwnPropertyDescriptor(node, "configure");
|
||
node.configure = function (data, ...rest) {
|
||
const configured = configure.call(this, data, ...rest);
|
||
const shape = originalSlotShapes.get(slotShapeKey(this));
|
||
if (!shape) throw new Error("SmartPipe unpack clone has no unique original slot shape");
|
||
restoreSerializedPipeSlots(this, shape);
|
||
return configured;
|
||
};
|
||
configureRestorers.push(() => { if (descriptor) Object.defineProperty(node, "configure", descriptor); else delete node.configure; });
|
||
}
|
||
return added;
|
||
};
|
||
}
|
||
try { result = original.apply(this, args); }
|
||
catch (error) {
|
||
restoreBacklinks();
|
||
for (const link of correctedLinks) link.type = "*";
|
||
throw error;
|
||
}
|
||
finally {
|
||
if (!converting && typeof nativeAdd === "function") {
|
||
if (addDescriptor) Object.defineProperty(this, "add", addDescriptor); else delete this.add;
|
||
}
|
||
for (const restore of configureRestorers.reverse()) restore();
|
||
for (const { link, fields } of originalLinkFields) for (const [key, descriptor] of Object.entries(fields)) {
|
||
if (descriptor) Object.defineProperty(link, key, descriptor); else delete link[key];
|
||
}
|
||
}
|
||
try {
|
||
if (converting) repairHostOutputBacklinks(this, result?.node);
|
||
const after = collectExpandedPipeAddresses(root);
|
||
const newHostId = converting ? result?.node?.properties?.[HOST_ROUTING_PROPERTY]?.id : null;
|
||
const live = new Set(after.descriptors.map(d => d.address));
|
||
const mapping = new Map();
|
||
for (const descriptor of before.descriptors) {
|
||
if (live.has(descriptor.address)) continue;
|
||
const context = contexts.find(path => path.every((part, index) => descriptor.hostPath[index] === part)
|
||
&& (converting || descriptor.hostPath[path.length] === hostId));
|
||
if (!context) continue;
|
||
const path = converting
|
||
? [...context, newHostId, ...descriptor.hostPath.slice(context.length)]
|
||
: [...context, ...descriptor.hostPath.slice(context.length + 1)];
|
||
const candidates = after.descriptors.filter(d => d.kind === descriptor.kind && d.route.nodeId === descriptor.route.nodeId && samePath(d.hostPath, path));
|
||
if (candidates.length !== 1) throw new Error(`Cannot uniquely relocate ${descriptor.address}`);
|
||
mapping.set(descriptor.address, candidates[0].address);
|
||
}
|
||
const prepared = relocateRouteRegistry(snapshot, mapping);
|
||
for (const [target, source] of localEdges) {
|
||
const newTarget = mapping.get(target) || target, newSource = mapping.get(source) || source;
|
||
if (newTarget.slice(0, newTarget.lastIndexOf("/")) !== newSource.slice(0, newSource.lastIndexOf("/"))) {
|
||
prepared[newTarget] = { ...prepared[newTarget], predecessorAddress: newSource };
|
||
}
|
||
}
|
||
root.extra[ROUTE_REGISTRY_PROPERTY] = prepared;
|
||
const cleanup = registryCleanupStates.get(root);
|
||
for (const address of [...mapping.keys(), ...mapping.values()]) {
|
||
cleanup?.missingSince?.delete(address);
|
||
cleanup?.backups?.delete(address);
|
||
}
|
||
} catch (error) {
|
||
root.extra[ROUTE_REGISTRY_PROPERTY] = originalRegistry;
|
||
throw new Error(`SmartPipe routing relocation failed after native topology changed. Use Undo. ${error.message}`, { cause: error });
|
||
}
|
||
return result;
|
||
};
|
||
}
|
||
}
|
||
|
||
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 indexMap = nativeOutputIndexMap(descriptor.node.outputs, schema.map(slot => ({id: slot.id, name: `out_${String(slot.ordinal).padStart(3, "0")}`, executionIndex: slot.ordinal})));
|
||
outputIndexMaps[descriptor.executionId] = indexMap;
|
||
}
|
||
validateNativeOutputLinks(apiPrompt, outputIndexMaps);
|
||
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)) {
|
||
if (node.graph === app.canvas?.graph) rebindActiveGraphContext(node.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;
|
||
}
|
||
|
||
// Undo replaces graph objects: recover only the exact remembered host instance.
|
||
function rebindActiveGraphContext(targetGraph) {
|
||
if (!targetGraph || targetGraph === app.graph) return;
|
||
restoreNavigationContext(targetGraph);
|
||
const remembered = rememberedGraphContext;
|
||
if (!remembered || remembered.root !== app.graph || remembered.rootId !== app.graph?.extra?.[ROOT_ROUTING_PROPERTY]) {
|
||
activeGraphContexts.delete(targetGraph);
|
||
return;
|
||
}
|
||
let graph = app.graph;
|
||
const hostPath = [], hostNames = [];
|
||
for (const id of remembered.hostPath) {
|
||
const hosts = graphNodes(graph).filter(node => node.properties?.[HOST_ROUTING_PROPERTY]?.id === id);
|
||
if (hosts.length !== 1) break;
|
||
const host = hosts[0];
|
||
graph = subgraphFor(host);
|
||
if (!graph) break;
|
||
hostPath.push(id);
|
||
hostNames.push(host.properties[HOST_ROUTING_PROPERTY].name);
|
||
if (graph === targetGraph) {
|
||
activeGraphContexts.set(targetGraph, { hostPath, hostNames });
|
||
return;
|
||
}
|
||
}
|
||
activeGraphContexts.delete(targetGraph);
|
||
}
|
||
|
||
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() {
|
||
const target = app.canvas?.canvas ?? app.canvas;
|
||
rebindActiveGraphContext(app.canvas?.graph);
|
||
if (app.__bvSmartPipeContextTracking === target || !target?.addEventListener) return;
|
||
app.__bvSmartPipeContextTracking = target;
|
||
if (app.graph) activeGraphContexts.set(app.graph, { hostPath: [], hostNames: [] });
|
||
target.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],
|
||
});
|
||
rememberedGraphContext = { root: app.graph, rootId: app.graph.extra?.[ROOT_ROUTING_PROPERTY], hostPath: [...parent.hostPath, host.id] };
|
||
rememberNavigationContext(detail.subgraph);
|
||
requestAnimationFrame(() => propagateGraph(detail.subgraph));
|
||
});
|
||
target.addEventListener("litegraph:set-graph", (event) => {
|
||
const detail = event.detail || event;
|
||
if (detail.newGraph === app.graph) activeGraphContexts.set(app.graph, { hostPath: [], hostNames: [] });
|
||
rebindActiveGraphContext(detail.newGraph);
|
||
const current = activeGraphContexts.get(detail.newGraph);
|
||
if (current?.hostPath?.length) rememberNavigationContext(detail.newGraph, { rootId: app.graph?.extra?.[ROOT_ROUTING_PROPERTY], hostPath: current.hostPath });
|
||
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) {
|
||
// Validate connected identity collisions before any structural mutation.
|
||
for (const [field, linked] of [["inputs", port => port.link != null], ["outputs", port => Boolean(port.links?.length)]]) {
|
||
validateConnectedSlotIdentities(node[field], linked, port => port.name === "pipe" || port.bvAddSlot);
|
||
}
|
||
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 removals = duplicateNativeSlotRemovals(ports, port => kind === "input" ? port.link != null : Boolean(port.links?.length), port => port.name === "pipe" || port.bvAddSlot);
|
||
for (const index of removals) 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.__bvOptionValues = [`Wired: ${routingFor(wired).name}`];
|
||
widget.value = widget.__bvOptionValues[0];
|
||
widget.disabled = false;
|
||
widget.__bvWired = true;
|
||
return;
|
||
}
|
||
widget.__bvWired = false;
|
||
widget.__bvChoices = choices;
|
||
widget.__bvOptionValues = 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) || nativeSlotsAreConfiguring(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);
|
||
const presentation = globalThis.__bvNodePresentationBridge;
|
||
if (presentation?.applyClassic) {
|
||
presentation.applyClassic(node, NODE_CLASS);
|
||
const projectNodes2 = () => node.__bvApplyNodes2Presentation?.();
|
||
requestAnimationFrame(() => {
|
||
projectNodes2();
|
||
requestAnimationFrame(projectNodes2);
|
||
setTimeout(() => presentation.applyClassic(node, NODE_CLASS), 100);
|
||
setTimeout(() => presentation.applyClassic(node, NODE_CLASS), 500);
|
||
});
|
||
}
|
||
else 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;
|
||
const originalRemoved = node.onRemoved;
|
||
node.onRemoved = function () {
|
||
globalThis.__bvNodePresentationBridge?.remove?.(this);
|
||
return originalRemoved?.apply(this, arguments);
|
||
};
|
||
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));
|
||
}, {
|
||
// Nodes 2.0 may retain the original options object; keep its supplier stable.
|
||
// Read the propagated snapshot only: opening a menu must not run discovery.
|
||
values: () => node.__bvPredecessorWidget?.__bvOptionValues || ["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";
|
||
setTimeout(() => globalThis.__bvNodePresentationBridge?.applyClassic?.(node, NODE_CLASS), 500);
|
||
const originalConnectionsChange = node.onConnectionsChange;
|
||
node.onConnectionsChange = function (type, index, connected, linkInfo) {
|
||
originalConnectionsChange?.apply(this, arguments);
|
||
// Native configure replays connection notifications before onConfigure.
|
||
// These are restoration events, not user edits of schema visibility/types.
|
||
if (nativeSlotsAreConfiguring(this)) return;
|
||
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 projectionRoot = app.graph;
|
||
const revision = projectionRevision;
|
||
try {
|
||
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, routing.registry);
|
||
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" && effectiveDescriptorMode(descriptor) === 4)
|
||
.map((descriptor) => `${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`)),
|
||
mutedScopedNodeIds: new Set(routing.descriptors.filter((descriptor) => effectiveDescriptorMode(descriptor) === 2)
|
||
.map((descriptor) => `${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`)),
|
||
prunedExecutionIds,
|
||
bypassPredecessorsByScopedNodeId: new Map(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && effectiveDescriptorMode(descriptor) === 4)
|
||
.map((descriptor) => {
|
||
const predecessor = routing.descriptors.find(candidate => candidate.address === modeState.bypassPredecessorAddresses.get(descriptor.address));
|
||
return predecessor ? [`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`, {
|
||
executionId: predecessor.executionId,
|
||
kind: predecessor.kind,
|
||
route: predecessor.route,
|
||
scope: executionScope(predecessor.executionId),
|
||
}] : [`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`, null];
|
||
}).filter(Boolean)),
|
||
};
|
||
const result = await original.apply(this, arguments);
|
||
localModeState.activeExecutionIds = new Set(Object.keys(result?.output || {}));
|
||
for (const descriptor of routing.descriptors) {
|
||
const entry = result?.output?.[descriptor.executionId];
|
||
if (!entry || !Object.hasOwn(descriptor, "physicalPredecessorAddress") || entry.inputs?.pipe != null) continue;
|
||
const physical = routing.descriptors.find(candidate => candidate.address === descriptor.physicalPredecessorAddress);
|
||
const input = descriptor.node.inputs?.find(slot => slot.name === "pipe");
|
||
const link = linkAt(descriptor.node.graph, input?.link);
|
||
const origin = descriptor.node.graph?.getNodeById?.(link?.origin_id);
|
||
if (physical && effectiveDescriptorMode(physical) === 2 || origin?.mode === 2) prunedExecutionIds.add(descriptor.executionId);
|
||
else throw new Error(`BV Smart Pipe "${descriptor.route.name}": Physical predecessor was removed by the native compiler; refusing wireless fallback.`);
|
||
}
|
||
const beforeNativePrune = new Set(Object.keys(result?.output || {}));
|
||
prunePromptBranches(result?.output, prunedExecutionIds);
|
||
// Keep removed physical dependants as tombstones for subsequent wireless
|
||
// resolution; absence alone would incorrectly mean a broken selection.
|
||
for (const descriptor of routing.descriptors) {
|
||
if (!beforeNativePrune.has(descriptor.executionId) || result?.output?.[descriptor.executionId]) continue;
|
||
prunedExecutionIds.add(descriptor.executionId);
|
||
modeState.mutedAddresses.add(descriptor.address);
|
||
localModeState.mutedScopedNodeIds.add(`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`);
|
||
}
|
||
applyInstanceSchemaProjections(result?.output, routing);
|
||
applyMergeConfigurations(result?.output, routing);
|
||
remapSmartPipeOutputLinks(result?.output, routing);
|
||
const wiredInputs = new Map(routing.descriptors.map(descriptor => [descriptor.executionId, new Set([
|
||
...Object.entries(result?.output?.[descriptor.executionId]?.inputs || {}).filter(([, value]) => Array.isArray(value)).map(([name]) => name),
|
||
...(descriptor.node.inputs || []).filter(slot => slot.link != null).map(slot => slot.name),
|
||
])]));
|
||
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);
|
||
try { if (app.graph === projectionRoot && projectionRevision === revision) {
|
||
const bindings = new Map(routing.descriptors.map((descriptor) => {
|
||
let graph = projectionRoot;
|
||
const hosts = [];
|
||
for (const [index, id] of descriptor.executionId.split(":").slice(0, -1).entries()) {
|
||
const node = graphNodes(graph).find((item) => String(item.id) === id);
|
||
graph = subgraphFor(node);
|
||
hosts.push({ id: descriptor.hostPath[index], node, graph });
|
||
}
|
||
return [descriptor.address, { node: descriptor.node, hosts }];
|
||
}));
|
||
globalThis.__bvNodePresentationBridge?.publishInstanceDgProjection?.(
|
||
app.canvas, projectionRoot, effectiveSmartPipeEdges(result?.output, routing.descriptors, wiredInputs), bindings,
|
||
() => {
|
||
const graph = app.canvas?.graph;
|
||
rebindActiveGraphContext(graph);
|
||
const context = activeGraphContexts.get(graph);
|
||
if (context) return context.hostPath;
|
||
const paths = [...bindings.values()].flatMap(({ hosts }) => hosts.flatMap((host, index) => host.graph === graph ? [hosts.slice(0, index + 1).map((item) => item.id)] : []));
|
||
const unique = new Map(paths.map((path) => [JSON.stringify(path), path]));
|
||
return unique.size === 1 ? [...unique.values()][0] : null;
|
||
},
|
||
);
|
||
} } catch { clearDgProjection(); }
|
||
return result;
|
||
} catch (error) {
|
||
if (app.graph === projectionRoot && projectionRevision === revision) clearDgProjection();
|
||
throw error;
|
||
}
|
||
};
|
||
}
|
||
|
||
// Projection refresh is export-only, coalesced after a changed routing snapshot;
|
||
// it never queues execution or calls export from a draw callback.
|
||
let projectionRevision = 0;
|
||
let projectionRoot = null;
|
||
let projectionSignature = "";
|
||
let projectionPending = false;
|
||
function clearDgProjection() {
|
||
try { globalThis.__bvNodePresentationBridge?.clearInstanceDgProjection?.(app.canvas); } catch { /* Presentation must never break execution. */ }
|
||
}
|
||
function refreshDgProjection(routing) {
|
||
const bridge = globalThis.__bvNodePresentationBridge;
|
||
const signature = bridge?.dgDebugVisible?.() ? JSON.stringify([
|
||
routing.descriptors.map((d) => {
|
||
let graph = app.graph;
|
||
const hostModes = [];
|
||
for (const id of d.executionId.split(":").slice(0, -1)) {
|
||
const host = graphNodes(graph).find((node) => String(node.id) === id);
|
||
hostModes.push(host?.mode); graph = subgraphFor(host);
|
||
}
|
||
return [d.executionId, d.address, d.route, d.node.mode, hostModes, (d.node.inputs || []).map((slot) => slot.link)];
|
||
}),
|
||
routing.registry,
|
||
]) : "";
|
||
if (projectionRoot === app.graph && projectionSignature === signature) return;
|
||
projectionRoot = app.graph; projectionSignature = signature; projectionRevision++;
|
||
clearDgProjection();
|
||
if (projectionPending || !signature || !routing.descriptors.length) return;
|
||
projectionPending = true;
|
||
const scheduledRevision = projectionRevision;
|
||
Promise.resolve().then(() => app.graphToPrompt()).catch(() => {
|
||
// The ordinary Run path still reports the compiler error. Do not show stale edges.
|
||
}).finally(() => { projectionPending = false; if (scheduledRevision !== projectionRevision) projectionSignature = ""; });
|
||
}
|
||
|
||
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;
|
||
refreshDgProjection(routing);
|
||
}
|
||
|
||
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()) => {
|
||
installSubgraphContextTracking();
|
||
globalThis.__bvNodePresentationBridge?.tickInstanceDgProjection?.(app.canvas);
|
||
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;
|
||
installNativeSlotConfigureTransaction(nodeType, data => {
|
||
const schema = data?.properties?.bvSmartPipe?.resolvedSlots;
|
||
if (Array.isArray(schema) && schema.length) {
|
||
validateSavedSlotIdentities(data.outputs, schema.map(slot => ({id: slot.id, name: `out_${String(slot.ordinal).padStart(3, "0")}`})));
|
||
validateSavedSlotIdentities(data.inputs, schema.map(slot => ({id: slot.id, name: `v_${String(slot.ordinal).padStart(3, "0")}`})));
|
||
}
|
||
});
|
||
const originalConfigure = nodeType.prototype.onConfigure;
|
||
nodeType.prototype.onConfigure = function (data) {
|
||
const result = originalConfigure?.apply(this, arguments);
|
||
if (data) restoreSerializedPipeSlots(this, data);
|
||
setupNode(this);
|
||
requestAnimationFrame(() => propagate(this));
|
||
return result;
|
||
};
|
||
const originalSerialize = nodeType.prototype.onSerialize;
|
||
nodeType.prototype.onSerialize = function (data) {
|
||
const result = originalSerialize?.apply(this, arguments);
|
||
serializeNativeSlotIdentities(this, data);
|
||
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) });
|
||
};
|
||
},
|
||
});
|