Spaces:
Paused
Paused
| import { app } from "../../scripts/app.js"; | |
| import { api } from "../../scripts/api.js"; | |
| import { mergeIfValid } from "./widgetInputs.js"; | |
| import { ManageGroupDialog } from "./groupNodeManage.js"; | |
| const GROUP = Symbol(); | |
| const Workflow = { | |
| InUse: { | |
| Free: 0, | |
| Registered: 1, | |
| InWorkflow: 2, | |
| }, | |
| isInUseGroupNode(name) { | |
| const id = `workflow/${name}`; | |
| // Check if lready registered/in use in this workflow | |
| if (app.graph.extra?.groupNodes?.[name]) { | |
| if (app.graph._nodes.find((n) => n.type === id)) { | |
| return Workflow.InUse.InWorkflow; | |
| } else { | |
| return Workflow.InUse.Registered; | |
| } | |
| } | |
| return Workflow.InUse.Free; | |
| }, | |
| storeGroupNode(name, data) { | |
| let extra = app.graph.extra; | |
| if (!extra) app.graph.extra = extra = {}; | |
| let groupNodes = extra.groupNodes; | |
| if (!groupNodes) extra.groupNodes = groupNodes = {}; | |
| groupNodes[name] = data; | |
| }, | |
| }; | |
| class GroupNodeBuilder { | |
| constructor(nodes) { | |
| this.nodes = nodes; | |
| } | |
| build() { | |
| const name = this.getName(); | |
| if (!name) return; | |
| // Sort the nodes so they are in execution order | |
| // this allows for widgets to be in the correct order when reconstructing | |
| this.sortNodes(); | |
| this.nodeData = this.getNodeData(); | |
| Workflow.storeGroupNode(name, this.nodeData); | |
| return { name, nodeData: this.nodeData }; | |
| } | |
| getName() { | |
| const name = prompt("Enter group name"); | |
| if (!name) return; | |
| const used = Workflow.isInUseGroupNode(name); | |
| switch (used) { | |
| case Workflow.InUse.InWorkflow: | |
| alert( | |
| "An in use group node with this name already exists embedded in this workflow, please remove any instances or use a new name." | |
| ); | |
| return; | |
| case Workflow.InUse.Registered: | |
| if (!confirm("A group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?")) { | |
| return; | |
| } | |
| break; | |
| } | |
| return name; | |
| } | |
| sortNodes() { | |
| // Gets the builders nodes in graph execution order | |
| const nodesInOrder = app.graph.computeExecutionOrder(false); | |
| this.nodes = this.nodes | |
| .map((node) => ({ index: nodesInOrder.indexOf(node), node })) | |
| .sort((a, b) => a.index - b.index || a.node.id - b.node.id) | |
| .map(({ node }) => node); | |
| } | |
| getNodeData() { | |
| const storeLinkTypes = (config) => { | |
| // Store link types for dynamically typed nodes e.g. reroutes | |
| for (const link of config.links) { | |
| const origin = app.graph.getNodeById(link[4]); | |
| const type = origin.outputs[link[1]].type; | |
| link.push(type); | |
| } | |
| }; | |
| const storeExternalLinks = (config) => { | |
| // Store any external links to the group in the config so when rebuilding we add extra slots | |
| config.external = []; | |
| for (let i = 0; i < this.nodes.length; i++) { | |
| const node = this.nodes[i]; | |
| if (!node.outputs?.length) continue; | |
| for (let slot = 0; slot < node.outputs.length; slot++) { | |
| let hasExternal = false; | |
| const output = node.outputs[slot]; | |
| let type = output.type; | |
| if (!output.links?.length) continue; | |
| for (const l of output.links) { | |
| const link = app.graph.links[l]; | |
| if (!link) continue; | |
| if (type === "*") type = link.type; | |
| if (!app.canvas.selected_nodes[link.target_id]) { | |
| hasExternal = true; | |
| break; | |
| } | |
| } | |
| if (hasExternal) { | |
| config.external.push([i, slot, type]); | |
| } | |
| } | |
| } | |
| }; | |
| // Use the built in copyToClipboard function to generate the node data we need | |
| const backup = localStorage.getItem("litegrapheditor_clipboard"); | |
| try { | |
| app.canvas.copyToClipboard(this.nodes); | |
| const config = JSON.parse(localStorage.getItem("litegrapheditor_clipboard")); | |
| storeLinkTypes(config); | |
| storeExternalLinks(config); | |
| return config; | |
| } finally { | |
| localStorage.setItem("litegrapheditor_clipboard", backup); | |
| } | |
| } | |
| } | |
| export class GroupNodeConfig { | |
| constructor(name, nodeData) { | |
| this.name = name; | |
| this.nodeData = nodeData; | |
| this.getLinks(); | |
| this.inputCount = 0; | |
| this.oldToNewOutputMap = {}; | |
| this.newToOldOutputMap = {}; | |
| this.oldToNewInputMap = {}; | |
| this.oldToNewWidgetMap = {}; | |
| this.newToOldWidgetMap = {}; | |
| this.primitiveDefs = {}; | |
| this.widgetToPrimitive = {}; | |
| this.primitiveToWidget = {}; | |
| this.nodeInputs = {}; | |
| this.outputVisibility = []; | |
| } | |
| async registerType(source = "workflow") { | |
| this.nodeDef = { | |
| output: [], | |
| output_name: [], | |
| output_is_list: [], | |
| output_is_hidden: [], | |
| name: source + "/" + this.name, | |
| display_name: this.name, | |
| category: "group nodes" + ("/" + source), | |
| input: { required: {} }, | |
| [GROUP]: this, | |
| }; | |
| this.inputs = []; | |
| const seenInputs = {}; | |
| const seenOutputs = {}; | |
| for (let i = 0; i < this.nodeData.nodes.length; i++) { | |
| const node = this.nodeData.nodes[i]; | |
| node.index = i; | |
| this.processNode(node, seenInputs, seenOutputs); | |
| } | |
| for (const p of this.#convertedToProcess) { | |
| p(); | |
| } | |
| this.#convertedToProcess = null; | |
| await app.registerNodeDef("workflow/" + this.name, this.nodeDef); | |
| } | |
| getLinks() { | |
| this.linksFrom = {}; | |
| this.linksTo = {}; | |
| this.externalFrom = {}; | |
| // Extract links for easy lookup | |
| for (const l of this.nodeData.links) { | |
| const [sourceNodeId, sourceNodeSlot, targetNodeId, targetNodeSlot] = l; | |
| // Skip links outside the copy config | |
| if (sourceNodeId == null) continue; | |
| if (!this.linksFrom[sourceNodeId]) { | |
| this.linksFrom[sourceNodeId] = {}; | |
| } | |
| if (!this.linksFrom[sourceNodeId][sourceNodeSlot]) { | |
| this.linksFrom[sourceNodeId][sourceNodeSlot] = []; | |
| } | |
| this.linksFrom[sourceNodeId][sourceNodeSlot].push(l); | |
| if (!this.linksTo[targetNodeId]) { | |
| this.linksTo[targetNodeId] = {}; | |
| } | |
| this.linksTo[targetNodeId][targetNodeSlot] = l; | |
| } | |
| if (this.nodeData.external) { | |
| for (const ext of this.nodeData.external) { | |
| if (!this.externalFrom[ext[0]]) { | |
| this.externalFrom[ext[0]] = { [ext[1]]: ext[2] }; | |
| } else { | |
| this.externalFrom[ext[0]][ext[1]] = ext[2]; | |
| } | |
| } | |
| } | |
| } | |
| processNode(node, seenInputs, seenOutputs) { | |
| const def = this.getNodeDef(node); | |
| if (!def) return; | |
| const inputs = { ...def.input?.required, ...def.input?.optional }; | |
| this.inputs.push(this.processNodeInputs(node, seenInputs, inputs)); | |
| if (def.output?.length) this.processNodeOutputs(node, seenOutputs, def); | |
| } | |
| getNodeDef(node) { | |
| const def = globalDefs[node.type]; | |
| if (def) return def; | |
| const linksFrom = this.linksFrom[node.index]; | |
| if (node.type === "PrimitiveNode") { | |
| // Skip as its not linked | |
| if (!linksFrom) return; | |
| let type = linksFrom["0"][0][5]; | |
| if (type === "COMBO") { | |
| // Use the array items | |
| const source = node.outputs[0].widget.name; | |
| const fromTypeName = this.nodeData.nodes[linksFrom["0"][0][2]].type; | |
| const fromType = globalDefs[fromTypeName]; | |
| const input = fromType.input.required[source] ?? fromType.input.optional[source]; | |
| type = input[0]; | |
| } | |
| const def = (this.primitiveDefs[node.index] = { | |
| input: { | |
| required: { | |
| value: [type, {}], | |
| }, | |
| }, | |
| output: [type], | |
| output_name: [], | |
| output_is_list: [], | |
| }); | |
| return def; | |
| } else if (node.type === "Reroute") { | |
| const linksTo = this.linksTo[node.index]; | |
| if (linksTo && linksFrom && !this.externalFrom[node.index]?.[0]) { | |
| // Being used internally | |
| return null; | |
| } | |
| let config = {}; | |
| let rerouteType = "*"; | |
| if (linksFrom) { | |
| for (const [, , id, slot] of linksFrom["0"]) { | |
| const node = this.nodeData.nodes[id]; | |
| const input = node.inputs[slot]; | |
| if (rerouteType === "*") { | |
| rerouteType = input.type; | |
| } | |
| if (input.widget) { | |
| const targetDef = globalDefs[node.type]; | |
| const targetWidget = targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name]; | |
| const widget = [targetWidget[0], config]; | |
| const res = mergeIfValid( | |
| { | |
| widget, | |
| }, | |
| targetWidget, | |
| false, | |
| null, | |
| widget | |
| ); | |
| config = res?.customConfig ?? config; | |
| } | |
| } | |
| } else if (linksTo) { | |
| const [id, slot] = linksTo["0"]; | |
| rerouteType = this.nodeData.nodes[id].outputs[slot].type; | |
| } else { | |
| // Reroute used as a pipe | |
| for (const l of this.nodeData.links) { | |
| if (l[2] === node.index) { | |
| rerouteType = l[5]; | |
| break; | |
| } | |
| } | |
| if (rerouteType === "*") { | |
| // Check for an external link | |
| const t = this.externalFrom[node.index]?.[0]; | |
| if (t) { | |
| rerouteType = t; | |
| } | |
| } | |
| } | |
| config.forceInput = true; | |
| return { | |
| input: { | |
| required: { | |
| [rerouteType]: [rerouteType, config], | |
| }, | |
| }, | |
| output: [rerouteType], | |
| output_name: [], | |
| output_is_list: [], | |
| }; | |
| } | |
| console.warn("Skipping virtual node " + node.type + " when building group node " + this.name); | |
| } | |
| getInputConfig(node, inputName, seenInputs, config, extra) { | |
| const customConfig = this.nodeData.config?.[node.index]?.input?.[inputName]; | |
| let name = customConfig?.name ?? node.inputs?.find((inp) => inp.name === inputName)?.label ?? inputName; | |
| let key = name; | |
| let prefix = ""; | |
| // Special handling for primitive to include the title if it is set rather than just "value" | |
| if ((node.type === "PrimitiveNode" && node.title) || name in seenInputs) { | |
| prefix = `${node.title ?? node.type} `; | |
| key = name = `${prefix}${inputName}`; | |
| if (name in seenInputs) { | |
| name = `${prefix}${seenInputs[name]} ${inputName}`; | |
| } | |
| } | |
| seenInputs[key] = (seenInputs[key] ?? 1) + 1; | |
| if (inputName === "seed" || inputName === "noise_seed") { | |
| if (!extra) extra = {}; | |
| extra.control_after_generate = `${prefix}control_after_generate`; | |
| } | |
| if (config[0] === "IMAGEUPLOAD") { | |
| if (!extra) extra = {}; | |
| extra.widget = this.oldToNewWidgetMap[node.index]?.[config[1]?.widget ?? "image"] ?? "image"; | |
| } | |
| if (extra) { | |
| config = [config[0], { ...config[1], ...extra }]; | |
| } | |
| return { name, config, customConfig }; | |
| } | |
| processWidgetInputs(inputs, node, inputNames, seenInputs) { | |
| const slots = []; | |
| const converted = new Map(); | |
| const widgetMap = (this.oldToNewWidgetMap[node.index] = {}); | |
| for (const inputName of inputNames) { | |
| let widgetType = app.getWidgetType(inputs[inputName], inputName); | |
| if (widgetType) { | |
| const convertedIndex = node.inputs?.findIndex((inp) => inp.name === inputName && inp.widget?.name === inputName); | |
| if (convertedIndex > -1) { | |
| // This widget has been converted to a widget | |
| // We need to store this in the correct position so link ids line up | |
| converted.set(convertedIndex, inputName); | |
| widgetMap[inputName] = null; | |
| } else { | |
| // Normal widget | |
| const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]); | |
| this.nodeDef.input.required[name] = config; | |
| widgetMap[inputName] = name; | |
| this.newToOldWidgetMap[name] = { node, inputName }; | |
| } | |
| } else { | |
| // Normal input | |
| slots.push(inputName); | |
| } | |
| } | |
| return { converted, slots }; | |
| } | |
| checkPrimitiveConnection(link, inputName, inputs) { | |
| const sourceNode = this.nodeData.nodes[link[0]]; | |
| if (sourceNode.type === "PrimitiveNode") { | |
| // Merge link configurations | |
| const [sourceNodeId, _, targetNodeId, __] = link; | |
| const primitiveDef = this.primitiveDefs[sourceNodeId]; | |
| const targetWidget = inputs[inputName]; | |
| const primitiveConfig = primitiveDef.input.required.value; | |
| const output = { widget: primitiveConfig }; | |
| const config = mergeIfValid(output, targetWidget, false, null, primitiveConfig); | |
| primitiveConfig[1] = config?.customConfig ?? inputs[inputName][1] ? { ...inputs[inputName][1] } : {}; | |
| let name = this.oldToNewWidgetMap[sourceNodeId]["value"]; | |
| name = name.substr(0, name.length - 6); | |
| primitiveConfig[1].control_after_generate = true; | |
| primitiveConfig[1].control_prefix = name; | |
| let toPrimitive = this.widgetToPrimitive[targetNodeId]; | |
| if (!toPrimitive) { | |
| toPrimitive = this.widgetToPrimitive[targetNodeId] = {}; | |
| } | |
| if (toPrimitive[inputName]) { | |
| toPrimitive[inputName].push(sourceNodeId); | |
| } | |
| toPrimitive[inputName] = sourceNodeId; | |
| let toWidget = this.primitiveToWidget[sourceNodeId]; | |
| if (!toWidget) { | |
| toWidget = this.primitiveToWidget[sourceNodeId] = []; | |
| } | |
| toWidget.push({ nodeId: targetNodeId, inputName }); | |
| } | |
| } | |
| processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) { | |
| this.nodeInputs[node.index] = {}; | |
| for (let i = 0; i < slots.length; i++) { | |
| const inputName = slots[i]; | |
| if (linksTo[i]) { | |
| this.checkPrimitiveConnection(linksTo[i], inputName, inputs); | |
| // This input is linked so we can skip it | |
| continue; | |
| } | |
| const { name, config, customConfig } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]); | |
| this.nodeInputs[node.index][inputName] = name; | |
| if(customConfig?.visible === false) continue; | |
| this.nodeDef.input.required[name] = config; | |
| inputMap[i] = this.inputCount++; | |
| } | |
| } | |
| processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs) { | |
| // Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up | |
| const convertedSlots = [...converted.keys()].sort().map((k) => converted.get(k)); | |
| for (let i = 0; i < convertedSlots.length; i++) { | |
| const inputName = convertedSlots[i]; | |
| if (linksTo[slots.length + i]) { | |
| this.checkPrimitiveConnection(linksTo[slots.length + i], inputName, inputs); | |
| // This input is linked so we can skip it | |
| continue; | |
| } | |
| const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], { | |
| defaultInput: true, | |
| }); | |
| this.nodeDef.input.required[name] = config; | |
| this.newToOldWidgetMap[name] = { node, inputName }; | |
| if (!this.oldToNewWidgetMap[node.index]) { | |
| this.oldToNewWidgetMap[node.index] = {}; | |
| } | |
| this.oldToNewWidgetMap[node.index][inputName] = name; | |
| inputMap[slots.length + i] = this.inputCount++; | |
| } | |
| } | |
| #convertedToProcess = []; | |
| processNodeInputs(node, seenInputs, inputs) { | |
| const inputMapping = []; | |
| const inputNames = Object.keys(inputs); | |
| if (!inputNames.length) return; | |
| const { converted, slots } = this.processWidgetInputs(inputs, node, inputNames, seenInputs); | |
| const linksTo = this.linksTo[node.index] ?? {}; | |
| const inputMap = (this.oldToNewInputMap[node.index] = {}); | |
| this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs); | |
| // Converted inputs have to be processed after all other nodes as they'll be at the end of the list | |
| this.#convertedToProcess.push(() => this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs)); | |
| return inputMapping; | |
| } | |
| processNodeOutputs(node, seenOutputs, def) { | |
| const oldToNew = (this.oldToNewOutputMap[node.index] = {}); | |
| // Add outputs | |
| for (let outputId = 0; outputId < def.output.length; outputId++) { | |
| const linksFrom = this.linksFrom[node.index]; | |
| // If this output is linked internally we flag it to hide | |
| const hasLink = linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId]; | |
| const customConfig = this.nodeData.config?.[node.index]?.output?.[outputId]; | |
| const visible = customConfig?.visible ?? !hasLink; | |
| this.outputVisibility.push(visible); | |
| if (!visible) { | |
| continue; | |
| } | |
| oldToNew[outputId] = this.nodeDef.output.length; | |
| this.newToOldOutputMap[this.nodeDef.output.length] = { node, slot: outputId }; | |
| this.nodeDef.output.push(def.output[outputId]); | |
| this.nodeDef.output_is_list.push(def.output_is_list[outputId]); | |
| let label = customConfig?.name; | |
| if (!label) { | |
| label = def.output_name?.[outputId] ?? def.output[outputId]; | |
| const output = node.outputs.find((o) => o.name === label); | |
| if (output?.label) { | |
| label = output.label; | |
| } | |
| } | |
| let name = label; | |
| if (name in seenOutputs) { | |
| const prefix = `${node.title ?? node.type} `; | |
| name = `${prefix}${label}`; | |
| if (name in seenOutputs) { | |
| name = `${prefix}${node.index} ${label}`; | |
| } | |
| } | |
| seenOutputs[name] = 1; | |
| this.nodeDef.output_name.push(name); | |
| } | |
| } | |
| static async registerFromWorkflow(groupNodes, missingNodeTypes) { | |
| const clean = app.clean; | |
| app.clean = function () { | |
| for (const g in groupNodes) { | |
| try { | |
| LiteGraph.unregisterNodeType("workflow/" + g); | |
| } catch (error) {} | |
| } | |
| app.clean = clean; | |
| }; | |
| for (const g in groupNodes) { | |
| const groupData = groupNodes[g]; | |
| let hasMissing = false; | |
| for (const n of groupData.nodes) { | |
| // Find missing node types | |
| if (!(n.type in LiteGraph.registered_node_types)) { | |
| missingNodeTypes.push({ | |
| type: n.type, | |
| hint: ` (In group node 'workflow/${g}')`, | |
| }); | |
| missingNodeTypes.push({ | |
| type: "workflow/" + g, | |
| action: { | |
| text: "Remove from workflow", | |
| callback: (e) => { | |
| delete groupNodes[g]; | |
| e.target.textContent = "Removed"; | |
| e.target.style.pointerEvents = "none"; | |
| e.target.style.opacity = 0.7; | |
| }, | |
| }, | |
| }); | |
| hasMissing = true; | |
| } | |
| } | |
| if (hasMissing) continue; | |
| const config = new GroupNodeConfig(g, groupData); | |
| await config.registerType(); | |
| } | |
| } | |
| } | |
| export class GroupNodeHandler { | |
| node; | |
| groupData; | |
| constructor(node) { | |
| this.node = node; | |
| this.groupData = node.constructor?.nodeData?.[GROUP]; | |
| this.node.setInnerNodes = (innerNodes) => { | |
| this.innerNodes = innerNodes; | |
| for (let innerNodeIndex = 0; innerNodeIndex < this.innerNodes.length; innerNodeIndex++) { | |
| const innerNode = this.innerNodes[innerNodeIndex]; | |
| for (const w of innerNode.widgets ?? []) { | |
| if (w.type === "converted-widget") { | |
| w.serializeValue = w.origSerializeValue; | |
| } | |
| } | |
| innerNode.index = innerNodeIndex; | |
| innerNode.getInputNode = (slot) => { | |
| // Check if this input is internal or external | |
| const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot]; | |
| if (externalSlot != null) { | |
| return this.node.getInputNode(externalSlot); | |
| } | |
| // Internal link | |
| const innerLink = this.groupData.linksTo[innerNode.index]?.[slot]; | |
| if (!innerLink) return null; | |
| const inputNode = innerNodes[innerLink[0]]; | |
| // Primitives will already apply their values | |
| if (inputNode.type === "PrimitiveNode") return null; | |
| return inputNode; | |
| }; | |
| innerNode.getInputLink = (slot) => { | |
| const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot]; | |
| if (externalSlot != null) { | |
| // The inner node is connected via the group node inputs | |
| const linkId = this.node.inputs[externalSlot].link; | |
| let link = app.graph.links[linkId]; | |
| // Use the outer link, but update the target to the inner node | |
| link = { | |
| ...link, | |
| target_id: innerNode.id, | |
| target_slot: +slot, | |
| }; | |
| return link; | |
| } | |
| let link = this.groupData.linksTo[innerNode.index]?.[slot]; | |
| if (!link) return null; | |
| // Use the inner link, but update the origin node to be inner node id | |
| link = { | |
| origin_id: innerNodes[link[0]].id, | |
| origin_slot: link[1], | |
| target_id: innerNode.id, | |
| target_slot: +slot, | |
| }; | |
| return link; | |
| }; | |
| } | |
| }; | |
| this.node.updateLink = (link) => { | |
| // Replace the group node reference with the internal node | |
| link = { ...link }; | |
| const output = this.groupData.newToOldOutputMap[link.origin_slot]; | |
| let innerNode = this.innerNodes[output.node.index]; | |
| let l; | |
| while (innerNode?.type === "Reroute") { | |
| l = innerNode.getInputLink(0); | |
| innerNode = innerNode.getInputNode(0); | |
| } | |
| if (!innerNode) { | |
| return null; | |
| } | |
| if (l && GroupNodeHandler.isGroupNode(innerNode)) { | |
| return innerNode.updateLink(l); | |
| } | |
| link.origin_id = innerNode.id; | |
| link.origin_slot = l?.origin_slot ?? output.slot; | |
| return link; | |
| }; | |
| this.node.getInnerNodes = () => { | |
| if (!this.innerNodes) { | |
| this.node.setInnerNodes( | |
| this.groupData.nodeData.nodes.map((n, i) => { | |
| const innerNode = LiteGraph.createNode(n.type); | |
| innerNode.configure(n); | |
| innerNode.id = `${this.node.id}:${i}`; | |
| return innerNode; | |
| }) | |
| ); | |
| } | |
| this.updateInnerWidgets(); | |
| return this.innerNodes; | |
| }; | |
| this.node.recreate = async () => { | |
| const id = this.node.id; | |
| const sz = this.node.size; | |
| const nodes = this.node.convertToNodes(); | |
| const groupNode = LiteGraph.createNode(this.node.type); | |
| groupNode.id = id; | |
| // Reuse the existing nodes for this instance | |
| groupNode.setInnerNodes(nodes); | |
| groupNode[GROUP].populateWidgets(); | |
| app.graph.add(groupNode); | |
| groupNode.size = [Math.max(groupNode.size[0], sz[0]), Math.max(groupNode.size[1], sz[1])]; | |
| // Remove all converted nodes and relink them | |
| groupNode[GROUP].replaceNodes(nodes); | |
| return groupNode; | |
| }; | |
| this.node.convertToNodes = () => { | |
| const addInnerNodes = () => { | |
| const backup = localStorage.getItem("litegrapheditor_clipboard"); | |
| // Clone the node data so we dont mutate it for other nodes | |
| const c = { ...this.groupData.nodeData }; | |
| c.nodes = [...c.nodes]; | |
| const innerNodes = this.node.getInnerNodes(); | |
| let ids = []; | |
| for (let i = 0; i < c.nodes.length; i++) { | |
| let id = innerNodes?.[i]?.id; | |
| // Use existing IDs if they are set on the inner nodes | |
| if (id == null || isNaN(id)) { | |
| id = undefined; | |
| } else { | |
| ids.push(id); | |
| } | |
| c.nodes[i] = { ...c.nodes[i], id }; | |
| } | |
| localStorage.setItem("litegrapheditor_clipboard", JSON.stringify(c)); | |
| app.canvas.pasteFromClipboard(); | |
| localStorage.setItem("litegrapheditor_clipboard", backup); | |
| const [x, y] = this.node.pos; | |
| let top; | |
| let left; | |
| // Configure nodes with current widget data | |
| const selectedIds = ids.length ? ids : Object.keys(app.canvas.selected_nodes); | |
| const newNodes = []; | |
| for (let i = 0; i < selectedIds.length; i++) { | |
| const id = selectedIds[i]; | |
| const newNode = app.graph.getNodeById(id); | |
| const innerNode = innerNodes[i]; | |
| newNodes.push(newNode); | |
| if (left == null || newNode.pos[0] < left) { | |
| left = newNode.pos[0]; | |
| } | |
| if (top == null || newNode.pos[1] < top) { | |
| top = newNode.pos[1]; | |
| } | |
| if (!newNode.widgets) continue; | |
| const map = this.groupData.oldToNewWidgetMap[innerNode.index]; | |
| if (map) { | |
| const widgets = Object.keys(map); | |
| for (const oldName of widgets) { | |
| const newName = map[oldName]; | |
| if (!newName) continue; | |
| const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName); | |
| if (widgetIndex === -1) continue; | |
| // Populate the main and any linked widgets | |
| if (innerNode.type === "PrimitiveNode") { | |
| for (let i = 0; i < newNode.widgets.length; i++) { | |
| newNode.widgets[i].value = this.node.widgets[widgetIndex + i].value; | |
| } | |
| } else { | |
| const outerWidget = this.node.widgets[widgetIndex]; | |
| const newWidget = newNode.widgets.find((w) => w.name === oldName); | |
| if (!newWidget) continue; | |
| newWidget.value = outerWidget.value; | |
| for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) { | |
| newWidget.linkedWidgets[w].value = outerWidget.linkedWidgets[w].value; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Shift each node | |
| for (const newNode of newNodes) { | |
| newNode.pos = [newNode.pos[0] - (left - x), newNode.pos[1] - (top - y)]; | |
| } | |
| return { newNodes, selectedIds }; | |
| }; | |
| const reconnectInputs = (selectedIds) => { | |
| for (const innerNodeIndex in this.groupData.oldToNewInputMap) { | |
| const id = selectedIds[innerNodeIndex]; | |
| const newNode = app.graph.getNodeById(id); | |
| const map = this.groupData.oldToNewInputMap[innerNodeIndex]; | |
| for (const innerInputId in map) { | |
| const groupSlotId = map[innerInputId]; | |
| if (groupSlotId == null) continue; | |
| const slot = node.inputs[groupSlotId]; | |
| if (slot.link == null) continue; | |
| const link = app.graph.links[slot.link]; | |
| if (!link) continue; | |
| // connect this node output to the input of another node | |
| const originNode = app.graph.getNodeById(link.origin_id); | |
| originNode.connect(link.origin_slot, newNode, +innerInputId); | |
| } | |
| } | |
| }; | |
| const reconnectOutputs = (selectedIds) => { | |
| for (let groupOutputId = 0; groupOutputId < node.outputs?.length; groupOutputId++) { | |
| const output = node.outputs[groupOutputId]; | |
| if (!output.links) continue; | |
| const links = [...output.links]; | |
| for (const l of links) { | |
| const slot = this.groupData.newToOldOutputMap[groupOutputId]; | |
| const link = app.graph.links[l]; | |
| const targetNode = app.graph.getNodeById(link.target_id); | |
| const newNode = app.graph.getNodeById(selectedIds[slot.node.index]); | |
| newNode.connect(slot.slot, targetNode, link.target_slot); | |
| } | |
| } | |
| }; | |
| const { newNodes, selectedIds } = addInnerNodes(); | |
| reconnectInputs(selectedIds); | |
| reconnectOutputs(selectedIds); | |
| app.graph.remove(this.node); | |
| return newNodes; | |
| }; | |
| const getExtraMenuOptions = this.node.getExtraMenuOptions; | |
| this.node.getExtraMenuOptions = function (_, options) { | |
| getExtraMenuOptions?.apply(this, arguments); | |
| let optionIndex = options.findIndex((o) => o.content === "Outputs"); | |
| if (optionIndex === -1) optionIndex = options.length; | |
| else optionIndex++; | |
| options.splice( | |
| optionIndex, | |
| 0, | |
| null, | |
| { | |
| content: "Convert to nodes", | |
| callback: () => { | |
| return this.convertToNodes(); | |
| }, | |
| }, | |
| { | |
| content: "Manage Group Node", | |
| callback: () => { | |
| new ManageGroupDialog(app).show(this.type); | |
| }, | |
| } | |
| ); | |
| }; | |
| // Draw custom collapse icon to identity this as a group | |
| const onDrawTitleBox = this.node.onDrawTitleBox; | |
| this.node.onDrawTitleBox = function (ctx, height, size, scale) { | |
| onDrawTitleBox?.apply(this, arguments); | |
| const fill = ctx.fillStyle; | |
| ctx.beginPath(); | |
| ctx.rect(11, -height + 11, 2, 2); | |
| ctx.rect(14, -height + 11, 2, 2); | |
| ctx.rect(17, -height + 11, 2, 2); | |
| ctx.rect(11, -height + 14, 2, 2); | |
| ctx.rect(14, -height + 14, 2, 2); | |
| ctx.rect(17, -height + 14, 2, 2); | |
| ctx.rect(11, -height + 17, 2, 2); | |
| ctx.rect(14, -height + 17, 2, 2); | |
| ctx.rect(17, -height + 17, 2, 2); | |
| ctx.fillStyle = this.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR; | |
| ctx.fill(); | |
| ctx.fillStyle = fill; | |
| }; | |
| // Draw progress label | |
| const onDrawForeground = node.onDrawForeground; | |
| const groupData = this.groupData.nodeData; | |
| node.onDrawForeground = function (ctx) { | |
| const r = onDrawForeground?.apply?.(this, arguments); | |
| if (+app.runningNodeId === this.id && this.runningInternalNodeId !== null) { | |
| const n = groupData.nodes[this.runningInternalNodeId]; | |
| if(!n) return; | |
| const message = `Running ${n.title || n.type} (${this.runningInternalNodeId}/${groupData.nodes.length})`; | |
| ctx.save(); | |
| ctx.font = "12px sans-serif"; | |
| const sz = ctx.measureText(message); | |
| ctx.fillStyle = node.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR; | |
| ctx.beginPath(); | |
| ctx.roundRect(0, -LiteGraph.NODE_TITLE_HEIGHT - 20, sz.width + 12, 20, 5); | |
| ctx.fill(); | |
| ctx.fillStyle = "#fff"; | |
| ctx.fillText(message, 6, -LiteGraph.NODE_TITLE_HEIGHT - 6); | |
| ctx.restore(); | |
| } | |
| }; | |
| // Flag this node as needing to be reset | |
| const onExecutionStart = this.node.onExecutionStart; | |
| this.node.onExecutionStart = function () { | |
| this.resetExecution = true; | |
| return onExecutionStart?.apply(this, arguments); | |
| }; | |
| const self = this; | |
| const onNodeCreated = this.node.onNodeCreated; | |
| this.node.onNodeCreated = function () { | |
| if (!this.widgets) { | |
| return; | |
| } | |
| const config = self.groupData.nodeData.config; | |
| if (config) { | |
| for (const n in config) { | |
| const inputs = config[n]?.input; | |
| for (const w in inputs) { | |
| if (inputs[w].visible !== false) continue; | |
| const widgetName = self.groupData.oldToNewWidgetMap[n][w]; | |
| const widget = this.widgets.find((w) => w.name === widgetName); | |
| if (widget) { | |
| widget.type = "hidden"; | |
| widget.computeSize = () => [0, -4]; | |
| } | |
| } | |
| } | |
| } | |
| return onNodeCreated?.apply(this, arguments); | |
| }; | |
| function handleEvent(type, getId, getEvent) { | |
| const handler = ({ detail }) => { | |
| const id = getId(detail); | |
| if (!id) return; | |
| const node = app.graph.getNodeById(id); | |
| if (node) return; | |
| const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == id); | |
| if (innerNodeIndex > -1) { | |
| this.node.runningInternalNodeId = innerNodeIndex; | |
| api.dispatchEvent(new CustomEvent(type, { detail: getEvent(detail, this.node.id + "", this.node) })); | |
| } | |
| }; | |
| api.addEventListener(type, handler); | |
| return handler; | |
| } | |
| const executing = handleEvent.call( | |
| this, | |
| "executing", | |
| (d) => d, | |
| (d, id, node) => id | |
| ); | |
| const executed = handleEvent.call( | |
| this, | |
| "executed", | |
| (d) => d?.node, | |
| (d, id, node) => ({ ...d, node: id, merge: !node.resetExecution }) | |
| ); | |
| const onRemoved = node.onRemoved; | |
| this.node.onRemoved = function () { | |
| onRemoved?.apply(this, arguments); | |
| api.removeEventListener("executing", executing); | |
| api.removeEventListener("executed", executed); | |
| }; | |
| this.node.refreshComboInNode = (defs) => { | |
| // Update combo widget options | |
| for (const widgetName in this.groupData.newToOldWidgetMap) { | |
| const widget = this.node.widgets.find((w) => w.name === widgetName); | |
| if (widget?.type === "combo") { | |
| const old = this.groupData.newToOldWidgetMap[widgetName]; | |
| const def = defs[old.node.type]; | |
| const input = def?.input?.required?.[old.inputName] ?? def?.input?.optional?.[old.inputName]; | |
| if (!input) continue; | |
| widget.options.values = input[0]; | |
| if (old.inputName !== "image" && !widget.options.values.includes(widget.value)) { | |
| widget.value = widget.options.values[0]; | |
| widget.callback(widget.value); | |
| } | |
| } | |
| } | |
| }; | |
| } | |
| updateInnerWidgets() { | |
| for (const newWidgetName in this.groupData.newToOldWidgetMap) { | |
| const newWidget = this.node.widgets.find((w) => w.name === newWidgetName); | |
| if (!newWidget) continue; | |
| const newValue = newWidget.value; | |
| const old = this.groupData.newToOldWidgetMap[newWidgetName]; | |
| let innerNode = this.innerNodes[old.node.index]; | |
| if (innerNode.type === "PrimitiveNode") { | |
| innerNode.primitiveValue = newValue; | |
| const primitiveLinked = this.groupData.primitiveToWidget[old.node.index]; | |
| for (const linked of primitiveLinked ?? []) { | |
| const node = this.innerNodes[linked.nodeId]; | |
| const widget = node.widgets.find((w) => w.name === linked.inputName); | |
| if (widget) { | |
| widget.value = newValue; | |
| } | |
| } | |
| continue; | |
| } else if (innerNode.type === "Reroute") { | |
| const rerouteLinks = this.groupData.linksFrom[old.node.index]; | |
| if (rerouteLinks) { | |
| for (const [_, , targetNodeId, targetSlot] of rerouteLinks["0"]) { | |
| const node = this.innerNodes[targetNodeId]; | |
| const input = node.inputs[targetSlot]; | |
| if (input.widget) { | |
| const widget = node.widgets?.find((w) => w.name === input.widget.name); | |
| if (widget) { | |
| widget.value = newValue; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| const widget = innerNode.widgets?.find((w) => w.name === old.inputName); | |
| if (widget) { | |
| widget.value = newValue; | |
| } | |
| } | |
| } | |
| populatePrimitive(node, nodeId, oldName, i, linkedShift) { | |
| // Converted widget, populate primitive if linked | |
| const primitiveId = this.groupData.widgetToPrimitive[nodeId]?.[oldName]; | |
| if (primitiveId == null) return; | |
| const targetWidgetName = this.groupData.oldToNewWidgetMap[primitiveId]["value"]; | |
| const targetWidgetIndex = this.node.widgets.findIndex((w) => w.name === targetWidgetName); | |
| if (targetWidgetIndex > -1) { | |
| const primitiveNode = this.innerNodes[primitiveId]; | |
| let len = primitiveNode.widgets.length; | |
| if (len - 1 !== this.node.widgets[targetWidgetIndex].linkedWidgets?.length) { | |
| // Fallback handling for if some reason the primitive has a different number of widgets | |
| // we dont want to overwrite random widgets, better to leave blank | |
| len = 1; | |
| } | |
| for (let i = 0; i < len; i++) { | |
| this.node.widgets[targetWidgetIndex + i].value = primitiveNode.widgets[i].value; | |
| } | |
| } | |
| return true; | |
| } | |
| populateReroute(node, nodeId, map) { | |
| if (node.type !== "Reroute") return; | |
| const link = this.groupData.linksFrom[nodeId]?.[0]?.[0]; | |
| if (!link) return; | |
| const [, , targetNodeId, targetNodeSlot] = link; | |
| const targetNode = this.groupData.nodeData.nodes[targetNodeId]; | |
| const inputs = targetNode.inputs; | |
| const targetWidget = inputs?.[targetNodeSlot]?.widget; | |
| if (!targetWidget) return; | |
| const offset = inputs.length - (targetNode.widgets_values?.length ?? 0); | |
| const v = targetNode.widgets_values?.[targetNodeSlot - offset]; | |
| if (v == null) return; | |
| const widgetName = Object.values(map)[0]; | |
| const widget = this.node.widgets.find((w) => w.name === widgetName); | |
| if (widget) { | |
| widget.value = v; | |
| } | |
| } | |
| populateWidgets() { | |
| if (!this.node.widgets) return; | |
| for (let nodeId = 0; nodeId < this.groupData.nodeData.nodes.length; nodeId++) { | |
| const node = this.groupData.nodeData.nodes[nodeId]; | |
| const map = this.groupData.oldToNewWidgetMap[nodeId] ?? {}; | |
| const widgets = Object.keys(map); | |
| if (!node.widgets_values?.length) { | |
| // special handling for populating values into reroutes | |
| // this allows primitives connect to them to pick up the correct value | |
| this.populateReroute(node, nodeId, map); | |
| continue; | |
| } | |
| let linkedShift = 0; | |
| for (let i = 0; i < widgets.length; i++) { | |
| const oldName = widgets[i]; | |
| const newName = map[oldName]; | |
| const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName); | |
| const mainWidget = this.node.widgets[widgetIndex]; | |
| if (this.populatePrimitive(node, nodeId, oldName, i, linkedShift) || widgetIndex === -1) { | |
| // Find the inner widget and shift by the number of linked widgets as they will have been removed too | |
| const innerWidget = this.innerNodes[nodeId].widgets?.find((w) => w.name === oldName); | |
| linkedShift += innerWidget?.linkedWidgets?.length ?? 0; | |
| } | |
| if (widgetIndex === -1) { | |
| continue; | |
| } | |
| // Populate the main and any linked widget | |
| mainWidget.value = node.widgets_values[i + linkedShift]; | |
| for (let w = 0; w < mainWidget.linkedWidgets?.length; w++) { | |
| this.node.widgets[widgetIndex + w + 1].value = node.widgets_values[i + ++linkedShift]; | |
| } | |
| } | |
| } | |
| } | |
| replaceNodes(nodes) { | |
| let top; | |
| let left; | |
| for (let i = 0; i < nodes.length; i++) { | |
| const node = nodes[i]; | |
| if (left == null || node.pos[0] < left) { | |
| left = node.pos[0]; | |
| } | |
| if (top == null || node.pos[1] < top) { | |
| top = node.pos[1]; | |
| } | |
| this.linkOutputs(node, i); | |
| app.graph.remove(node); | |
| } | |
| this.linkInputs(); | |
| this.node.pos = [left, top]; | |
| } | |
| linkOutputs(originalNode, nodeId) { | |
| if (!originalNode.outputs) return; | |
| for (const output of originalNode.outputs) { | |
| if (!output.links) continue; | |
| // Clone the links as they'll be changed if we reconnect | |
| const links = [...output.links]; | |
| for (const l of links) { | |
| const link = app.graph.links[l]; | |
| if (!link) continue; | |
| const targetNode = app.graph.getNodeById(link.target_id); | |
| const newSlot = this.groupData.oldToNewOutputMap[nodeId]?.[link.origin_slot]; | |
| if (newSlot != null) { | |
| this.node.connect(newSlot, targetNode, link.target_slot); | |
| } | |
| } | |
| } | |
| } | |
| linkInputs() { | |
| for (const link of this.groupData.nodeData.links ?? []) { | |
| const [, originSlot, targetId, targetSlot, actualOriginId] = link; | |
| const originNode = app.graph.getNodeById(actualOriginId); | |
| if (!originNode) continue; // this node is in the group | |
| originNode.connect(originSlot, this.node.id, this.groupData.oldToNewInputMap[targetId][targetSlot]); | |
| } | |
| } | |
| static getGroupData(node) { | |
| return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP]; | |
| } | |
| static isGroupNode(node) { | |
| return !!node.constructor?.nodeData?.[GROUP]; | |
| } | |
| static async fromNodes(nodes) { | |
| // Process the nodes into the stored workflow group node data | |
| const builder = new GroupNodeBuilder(nodes); | |
| const res = builder.build(); | |
| if (!res) return; | |
| const { name, nodeData } = res; | |
| // Convert this data into a LG node definition and register it | |
| const config = new GroupNodeConfig(name, nodeData); | |
| await config.registerType(); | |
| const groupNode = LiteGraph.createNode(`workflow/${name}`); | |
| // Reuse the existing nodes for this instance | |
| groupNode.setInnerNodes(builder.nodes); | |
| groupNode[GROUP].populateWidgets(); | |
| app.graph.add(groupNode); | |
| // Remove all converted nodes and relink them | |
| groupNode[GROUP].replaceNodes(builder.nodes); | |
| return groupNode; | |
| } | |
| } | |
| function addConvertToGroupOptions() { | |
| function addConvertOption(options, index) { | |
| const selected = Object.values(app.canvas.selected_nodes ?? {}); | |
| const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n)); | |
| options.splice(index + 1, null, { | |
| content: `Convert to Group Node`, | |
| disabled, | |
| callback: async () => { | |
| return await GroupNodeHandler.fromNodes(selected); | |
| }, | |
| }); | |
| } | |
| function addManageOption(options, index) { | |
| const groups = app.graph.extra?.groupNodes; | |
| const disabled = !groups || !Object.keys(groups).length; | |
| options.splice(index + 1, null, { | |
| content: `Manage Group Nodes`, | |
| disabled, | |
| callback: () => { | |
| new ManageGroupDialog(app).show(); | |
| }, | |
| }); | |
| } | |
| // Add to canvas | |
| const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions; | |
| LGraphCanvas.prototype.getCanvasMenuOptions = function () { | |
| const options = getCanvasMenuOptions.apply(this, arguments); | |
| const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length; | |
| addConvertOption(options, index); | |
| addManageOption(options, index + 1); | |
| return options; | |
| }; | |
| // Add to nodes | |
| const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions; | |
| LGraphCanvas.prototype.getNodeMenuOptions = function (node) { | |
| const options = getNodeMenuOptions.apply(this, arguments); | |
| if (!GroupNodeHandler.isGroupNode(node)) { | |
| const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1; | |
| addConvertOption(options, index); | |
| } | |
| return options; | |
| }; | |
| } | |
| const id = "Comfy.GroupNode"; | |
| let globalDefs; | |
| const ext = { | |
| name: id, | |
| setup() { | |
| addConvertToGroupOptions(); | |
| }, | |
| async beforeConfigureGraph(graphData, missingNodeTypes) { | |
| const nodes = graphData?.extra?.groupNodes; | |
| if (nodes) { | |
| await GroupNodeConfig.registerFromWorkflow(nodes, missingNodeTypes); | |
| } | |
| }, | |
| addCustomNodeDefs(defs) { | |
| // Store this so we can mutate it later with group nodes | |
| globalDefs = defs; | |
| }, | |
| nodeCreated(node) { | |
| if (GroupNodeHandler.isGroupNode(node)) { | |
| node[GROUP] = new GroupNodeHandler(node); | |
| } | |
| }, | |
| async refreshComboInNodes(defs) { | |
| // Re-register group nodes so new ones are created with the correct options | |
| Object.assign(globalDefs, defs); | |
| const nodes = app.graph.extra?.groupNodes; | |
| if (nodes) { | |
| await GroupNodeConfig.registerFromWorkflow(nodes, {}); | |
| } | |
| } | |
| }; | |
| app.registerExtension(ext); | |