Files
ComfyUI_frontend/src/extensions/core/groupNode.ts
AustinMroz f2a0e5102e Cleanup app.graph usage (#7399)
Prior to the release of subgraphs, there was a single graph accessed
through `app.graph`. Now that there's multiple graphs, there's a lot of
code that needs to be reviewed and potentially updated depending on if
it cares about nearby nodes, all nodes, or something else requiring
specific attention.

This was done by simply changing the type of `app.graph` to unknown so
the typechecker will complain about every place it's currently used.
References were then updated to `app.rootGraph` if the previous usage
was correct, or actually rewritten.

By not getting rid of `app.graph`, this change already ensures that
there's no loss of functionality for custom nodes, but the prior typing
of `app.graph` can always be restored if future dissuasion of
`app.graph` usage creates issues.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7399-Cleanup-app-graph-usage-2c76d73d365081178743dfdcf07f44d0)
by [Unito](https://www.unito.io)
2025-12-11 23:37:34 -07:00

1815 lines
58 KiB
TypeScript

import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { t } from '@/i18n'
import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
type ComfyLink,
type ComfyNode,
type ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { type ComfyExtension } from '@/types/comfy'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
import { GROUP } from '@/utils/executableGroupNodeDto'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import { ManageGroupDialog } from './groupNodeManage'
import { mergeIfValid } from './widgetInputs'
type GroupNodeWorkflowData = {
external: ComfyLink[]
links: ComfyLink[]
nodes: ComfyNode[]
}
const Workflow = {
InUse: {
Free: 0,
Registered: 1,
InWorkflow: 2
},
// @ts-expect-error fixme ts strict error
isInUseGroupNode(name) {
const id = `${PREFIX}${SEPARATOR}${name}`
// Check if lready registered/in use in this workflow
// @ts-expect-error fixme ts strict error
if (app.rootGraph.extra?.groupNodes?.[name]) {
if (app.rootGraph.nodes.find((n) => n.type === id)) {
return Workflow.InUse.InWorkflow
} else {
return Workflow.InUse.Registered
}
}
return Workflow.InUse.Free
},
storeGroupNode(name: string, data: GroupNodeWorkflowData) {
let extra = app.rootGraph.extra
if (!extra) app.rootGraph.extra = extra = {}
let groupNodes = extra.groupNodes
if (!groupNodes) extra.groupNodes = groupNodes = {}
// @ts-expect-error fixme ts strict error
groupNodes[name] = data
}
}
class GroupNodeBuilder {
nodes: LGraphNode[]
// @ts-expect-error fixme ts strict error
nodeData: GroupNodeWorkflowData
constructor(nodes: LGraphNode[]) {
this.nodes = nodes
}
async build() {
const name = await 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 }
}
async getName() {
const name = await useDialogService().prompt({
title: t('groupNode.create'),
message: t('groupNode.enterName'),
defaultValue: ''
})
if (!name) return
const used = Workflow.isInUseGroupNode(name)
switch (used) {
case Workflow.InUse.InWorkflow:
useToastStore().addAlert(
'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.rootGraph.computeExecutionOrder(false)
this.nodes = this.nodes
.map((node) => ({ index: nodesInOrder.indexOf(node), node }))
// @ts-expect-error id might be string
.sort((a, b) => a.index - b.index || a.node.id - b.node.id)
.map(({ node }) => node)
}
getNodeData() {
// @ts-expect-error fixme ts strict error
const storeLinkTypes = (config) => {
// Store link types for dynamically typed nodes e.g. reroutes
for (const link of config.links) {
const origin = app.rootGraph.getNodeById(link[4])
// @ts-expect-error fixme ts strict error
const type = origin.outputs[link[1]].type
link.push(type)
}
}
// @ts-expect-error fixme ts strict error
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.rootGraph.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
try {
// @ts-expect-error fixme ts strict error
const serialised = serialise(this.nodes, app.canvas?.graph)
const config = JSON.parse(serialised)
storeLinkTypes(config)
storeExternalLinks(config)
return config
} finally {
}
}
}
export class GroupNodeConfig {
name: string
nodeData: any
inputCount: number
oldToNewOutputMap: {}
newToOldOutputMap: {}
oldToNewInputMap: {}
oldToNewWidgetMap: {}
newToOldWidgetMap: {}
primitiveDefs: {}
widgetToPrimitive: {}
primitiveToWidget: {}
nodeInputs: {}
outputVisibility: any[]
// @ts-expect-error fixme ts strict error
nodeDef: ComfyNodeDef
// @ts-expect-error fixme ts strict error
inputs: any[]
// @ts-expect-error fixme ts strict error
linksFrom: {}
// @ts-expect-error fixme ts strict error
linksTo: {}
// @ts-expect-error fixme ts strict error
externalFrom: {}
// @ts-expect-error fixme ts strict error
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 = PREFIX) {
this.nodeDef = {
output: [],
output_name: [],
output_is_list: [],
// @ts-expect-error Unused, doesn't exist
output_is_hidden: [],
name: source + SEPARATOR + this.name,
display_name: this.name,
category: 'group nodes' + (SEPARATOR + source),
input: { required: {} },
description: `Group node combining ${this.nodeData.nodes
// @ts-expect-error fixme ts strict error
.map((n) => n.type)
.join(', ')}`,
python_module: 'custom_nodes.' + this.name,
[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) {
// @ts-expect-error fixme ts strict error
p()
}
// @ts-expect-error fixme ts strict error
this.#convertedToProcess = null
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
useNodeDefStore().addNodeDef(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
// @ts-expect-error fixme ts strict error
if (!this.linksFrom[sourceNodeId]) {
// @ts-expect-error fixme ts strict error
this.linksFrom[sourceNodeId] = {}
}
// @ts-expect-error fixme ts strict error
if (!this.linksFrom[sourceNodeId][sourceNodeSlot]) {
// @ts-expect-error fixme ts strict error
this.linksFrom[sourceNodeId][sourceNodeSlot] = []
}
// @ts-expect-error fixme ts strict error
this.linksFrom[sourceNodeId][sourceNodeSlot].push(l)
// @ts-expect-error fixme ts strict error
if (!this.linksTo[targetNodeId]) {
// @ts-expect-error fixme ts strict error
this.linksTo[targetNodeId] = {}
}
// @ts-expect-error fixme ts strict error
this.linksTo[targetNodeId][targetNodeSlot] = l
}
if (this.nodeData.external) {
for (const ext of this.nodeData.external) {
// @ts-expect-error fixme ts strict error
if (!this.externalFrom[ext[0]]) {
// @ts-expect-error fixme ts strict error
this.externalFrom[ext[0]] = { [ext[1]]: ext[2] }
} else {
// @ts-expect-error fixme ts strict error
this.externalFrom[ext[0]][ext[1]] = ext[2]
}
}
}
}
// @ts-expect-error fixme ts strict error
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)
}
// @ts-expect-error fixme ts strict error
getNodeDef(node) {
// @ts-expect-error fixme ts strict error
const def = globalDefs[node.type]
if (def) return def
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error fixme ts strict error
const fromType = globalDefs[fromTypeName]
const input =
fromType.input.required[source] ?? fromType.input.optional[source]
type = input[0]
}
// @ts-expect-error fixme ts strict error
const def = (this.primitiveDefs[node.index] = {
input: {
required: {
value: [type, {}]
}
},
output: [type],
output_name: [],
output_is_list: []
})
return def
} else if (node.type === 'Reroute') {
// @ts-expect-error fixme ts strict error
const linksTo = this.linksTo[node.index]
// @ts-expect-error fixme ts strict error
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) {
// @ts-expect-error fixme ts strict error
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(
{
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error fixme ts strict error
const t = this.externalFrom[node.index]?.[0]
if (t) {
rerouteType = t
}
}
}
// @ts-expect-error
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
)
}
// @ts-expect-error fixme ts strict error
getInputConfig(node, inputName, seenInputs, config, extra?) {
const customConfig = this.nodeData.config?.[node.index]?.input?.[inputName]
let name =
customConfig?.name ??
// @ts-expect-error fixme ts strict error
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 =
// @ts-expect-error fixme ts strict error
this.oldToNewWidgetMap[node.index]?.[config[1]?.widget ?? 'image'] ??
'image'
}
if (extra) {
config = [config[0], { ...config[1], ...extra }]
}
return { name, config, customConfig }
}
// @ts-expect-error fixme ts strict error
processWidgetInputs(inputs, node, inputNames, seenInputs) {
const slots = []
const converted = new Map()
// @ts-expect-error fixme ts strict error
const widgetMap = (this.oldToNewWidgetMap[node.index] = {})
for (const inputName of inputNames) {
if (useWidgetStore().inputIsWidget(inputs[inputName])) {
const convertedIndex = node.inputs?.findIndex(
// @ts-expect-error fixme ts strict error
(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)
// @ts-expect-error fixme ts strict error
widgetMap[inputName] = null
} else {
// Normal widget
const { name, config } = this.getInputConfig(
node,
inputName,
seenInputs,
inputs[inputName]
)
// @ts-expect-error fixme ts strict error
this.nodeDef.input.required[name] = config
// @ts-expect-error fixme ts strict error
widgetMap[inputName] = name
// @ts-expect-error fixme ts strict error
this.newToOldWidgetMap[name] = { node, inputName }
}
} else {
// Normal input
slots.push(inputName)
}
}
return { converted, slots }
}
// @ts-expect-error fixme ts strict error
checkPrimitiveConnection(link, inputName, inputs) {
const sourceNode = this.nodeData.nodes[link[0]]
if (sourceNode.type === 'PrimitiveNode') {
// Merge link configurations
const [sourceNodeId, _, targetNodeId, __] = link
// @ts-expect-error fixme ts strict error
const primitiveDef = this.primitiveDefs[sourceNodeId]
const targetWidget = inputs[inputName]
const primitiveConfig = primitiveDef.input.required.value
const output = { widget: primitiveConfig }
const config = mergeIfValid(
// @ts-expect-error invalid slot type
output,
targetWidget,
false,
null,
primitiveConfig
)
primitiveConfig[1] =
(config?.customConfig ?? inputs[inputName][1])
? { ...inputs[inputName][1] }
: {}
// @ts-expect-error fixme ts strict error
let name = this.oldToNewWidgetMap[sourceNodeId]['value']
name = name.substr(0, name.length - 6)
primitiveConfig[1].control_after_generate = true
primitiveConfig[1].control_prefix = name
// @ts-expect-error fixme ts strict error
let toPrimitive = this.widgetToPrimitive[targetNodeId]
if (!toPrimitive) {
// @ts-expect-error fixme ts strict error
toPrimitive = this.widgetToPrimitive[targetNodeId] = {}
}
if (toPrimitive[inputName]) {
toPrimitive[inputName].push(sourceNodeId)
}
toPrimitive[inputName] = sourceNodeId
// @ts-expect-error fixme ts strict error
let toWidget = this.primitiveToWidget[sourceNodeId]
if (!toWidget) {
// @ts-expect-error fixme ts strict error
toWidget = this.primitiveToWidget[sourceNodeId] = []
}
toWidget.push({ nodeId: targetNodeId, inputName })
}
}
// @ts-expect-error fixme ts strict error
processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
// @ts-expect-error fixme ts strict error
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]
)
// @ts-expect-error fixme ts strict error
this.nodeInputs[node.index][inputName] = name
if (customConfig?.visible === false) continue
// @ts-expect-error fixme ts strict error
this.nodeDef.input.required[name] = config
inputMap[i] = this.inputCount++
}
}
processConvertedWidgets(
// @ts-expect-error fixme ts strict error
inputs,
// @ts-expect-error fixme ts strict error
node,
// @ts-expect-error fixme ts strict error
slots,
// @ts-expect-error fixme ts strict error
converted,
// @ts-expect-error fixme ts strict error
linksTo,
// @ts-expect-error fixme ts strict error
inputMap,
// @ts-expect-error fixme ts strict error
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
}
)
// @ts-expect-error fixme ts strict error
this.nodeDef.input.required[name] = config
// @ts-expect-error fixme ts strict error
this.newToOldWidgetMap[name] = { node, inputName }
// @ts-expect-error fixme ts strict error
if (!this.oldToNewWidgetMap[node.index]) {
// @ts-expect-error fixme ts strict error
this.oldToNewWidgetMap[node.index] = {}
}
// @ts-expect-error fixme ts strict error
this.oldToNewWidgetMap[node.index][inputName] = name
inputMap[slots.length + i] = this.inputCount++
}
}
#convertedToProcess = []
// @ts-expect-error fixme ts strict error
processNodeInputs(node, seenInputs, inputs) {
// @ts-expect-error fixme ts strict error
const inputMapping = []
const inputNames = Object.keys(inputs)
if (!inputNames.length) return
const { converted, slots } = this.processWidgetInputs(
inputs,
node,
inputNames,
seenInputs
)
// @ts-expect-error fixme ts strict error
const linksTo = this.linksTo[node.index] ?? {}
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error fixme ts strict error
this.#convertedToProcess.push(() =>
this.processConvertedWidgets(
inputs,
node,
slots,
converted,
linksTo,
inputMap,
seenInputs
)
)
// @ts-expect-error fixme ts strict error
return inputMapping
}
// @ts-expect-error fixme ts strict error
processNodeOutputs(node, seenOutputs, def) {
// @ts-expect-error fixme ts strict error
const oldToNew = (this.oldToNewOutputMap[node.index] = {})
// Add outputs
for (let outputId = 0; outputId < def.output.length; outputId++) {
// @ts-expect-error fixme ts strict error
const linksFrom = this.linksFrom[node.index]
// If this output is linked internally we flag it to hide
const hasLink =
// @ts-expect-error fixme ts strict error
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
}
// @ts-expect-error fixme ts strict error
oldToNew[outputId] = this.nodeDef.output.length
// @ts-expect-error fixme ts strict error
this.newToOldOutputMap[this.nodeDef.output.length] = {
node,
slot: outputId
}
// @ts-expect-error fixme ts strict error
this.nodeDef.output.push(def.output[outputId])
// @ts-expect-error fixme ts strict error
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]
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error fixme ts strict error
this.nodeDef.output_name.push(name)
}
}
// @ts-expect-error fixme ts strict error
static async registerFromWorkflow(groupNodes, missingNodeTypes) {
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 '${PREFIX}${SEPARATOR}${g}')`
})
missingNodeTypes.push({
type: `${PREFIX}${SEPARATOR}` + g,
action: {
text: 'Remove from workflow',
// @ts-expect-error fixme ts strict error
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: LGraphNode
groupData: any
innerNodes: any
constructor(node: LGraphNode) {
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]
innerNode.graph ??= this.node.graph
for (const w of innerNode.widgets ?? []) {
if (w.type === 'converted-widget') {
w.serializeValue = w.origSerializeValue
}
}
innerNode.index = innerNodeIndex
// @ts-expect-error fixme ts strict error
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
}
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error fixme ts strict error
let link = app.rootGraph.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
// @ts-expect-error Can this be removed? Or replaced with: LLink.create(link.asSerialisable())
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
}
/** @internal Used to flatten the subgraph before execution. Recursive; call with no args. */
this.node.getInnerNodes = (
computedNodeDtos: Map<ExecutionId, ExecutableLGraphNode>,
/** The path of subgraph node IDs. */
subgraphNodePath: readonly NodeId[] = [],
/** The list of nodes to add to. */
nodes: ExecutableLGraphNode[] = [],
/** The set of visited nodes. */
visited = new Set<LGraphNode>()
): ExecutableLGraphNode[] => {
if (visited.has(this.node))
throw new Error('RecursionError: while flattening subgraph')
visited.add(this.node)
if (!this.innerNodes) {
// @ts-expect-error fixme ts strict error
this.node.setInnerNodes(
// @ts-expect-error fixme ts strict error
this.groupData.nodeData.nodes.map((n, i) => {
const innerNode = LiteGraph.createNode(n.type)
// @ts-expect-error fixme ts strict error
innerNode.configure(n)
// @ts-expect-error fixme ts strict error
innerNode.id = `${this.node.id}:${i}`
// @ts-expect-error fixme ts strict error
innerNode.graph = this.node.graph
return innerNode
})
)
}
this.updateInnerWidgets()
const subgraphInstanceIdPath = [...subgraphNodePath, this.node.id]
// Assertion: Deprecated, does not matter.
const subgraphNode = (this.node.graph?.getNodeById(
subgraphNodePath.at(-1)
) ?? undefined) as SubgraphNode | undefined
for (const node of this.innerNodes) {
node.graph ??= this.node.graph
// Create minimal DTOs rather than cloning the node
const currentId = String(node.id)
node.id = currentId.split(':').at(-1)
const aVeryRealNode = new ExecutableGroupNodeChildDTO(
node,
subgraphInstanceIdPath,
computedNodeDtos,
subgraphNode
)
node.id = currentId
aVeryRealNode.groupNodeHandler = this
nodes.push(aVeryRealNode)
}
return nodes
}
// @ts-expect-error fixme ts strict error
this.node.recreate = async () => {
const id = this.node.id
const sz = this.node.size
// @ts-expect-error fixme ts strict error
const nodes = this.node.convertToNodes()
const groupNode = LiteGraph.createNode(this.node.type)
// @ts-expect-error fixme ts strict error
groupNode.id = id
// Reuse the existing nodes for this instance
// @ts-expect-error fixme ts strict error
groupNode.setInnerNodes(nodes)
// @ts-expect-error fixme ts strict error
groupNode[GROUP].populateWidgets()
// @ts-expect-error fixme ts strict error
app.rootGraph.add(groupNode)
// @ts-expect-error fixme ts strict error
groupNode.setSize([
// @ts-expect-error fixme ts strict error
Math.max(groupNode.size[0], sz[0]),
// @ts-expect-error fixme ts strict error
Math.max(groupNode.size[1], sz[1])
])
// Remove all converted nodes and relink them
const builder = new GroupNodeBuilder(nodes)
const nodeData = builder.getNodeData()
// @ts-expect-error fixme ts strict error
groupNode[GROUP].groupData.nodeData.links = nodeData.links
// @ts-expect-error fixme ts strict error
groupNode[GROUP].replaceNodes(nodes)
return groupNode
}
// @ts-expect-error fixme ts strict error
this.node.convertToNodes = () => {
const addInnerNodes = () => {
// Clone the node data so we dont mutate it for other nodes
const c = { ...this.groupData.nodeData }
c.nodes = [...c.nodes]
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error id can be string or number
if (id == null || isNaN(id)) {
// @ts-expect-error fixme ts strict error
id = undefined
} else {
ids.push(id)
}
c.nodes[i] = { ...c.nodes[i], id }
}
deserialiseAndCreate(JSON.stringify(c), app.canvas)
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.rootGraph.getNodeById(id)
const innerNode = innerNodes[i]
newNodes.push(newNode)
// @ts-expect-error fixme ts strict error
if (left == null || newNode.pos[0] < left) {
// @ts-expect-error fixme ts strict error
left = newNode.pos[0]
}
// @ts-expect-error fixme ts strict error
if (top == null || newNode.pos[1] < top) {
// @ts-expect-error fixme ts strict error
top = newNode.pos[1]
}
// @ts-expect-error fixme ts strict error
if (!newNode.widgets) continue
// @ts-expect-error fixme ts strict error
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
// @ts-expect-error fixme ts strict error
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') {
// @ts-expect-error fixme ts strict error
for (let i = 0; i < newNode.widgets.length; i++) {
// @ts-expect-error fixme ts strict error
newNode.widgets[i].value =
// @ts-expect-error fixme ts strict error
this.node.widgets[widgetIndex + i].value
}
} else {
// @ts-expect-error fixme ts strict error
const outerWidget = this.node.widgets[widgetIndex]
// @ts-expect-error fixme ts strict error
const newWidget = newNode.widgets.find(
(w) => w.name === oldName
)
if (!newWidget) continue
newWidget.value = outerWidget.value
// @ts-expect-error fixme ts strict error
for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) {
// @ts-expect-error fixme ts strict error
newWidget.linkedWidgets[w].value =
// @ts-expect-error fixme ts strict error
outerWidget.linkedWidgets[w].value
}
}
}
}
}
// Shift each node
for (const newNode of newNodes) {
// @ts-expect-error fixme ts strict error
newNode.pos[0] -= left - x
// @ts-expect-error fixme ts strict error
newNode.pos[1] -= top - y
}
return { newNodes, selectedIds }
}
// @ts-expect-error fixme ts strict error
const reconnectInputs = (selectedIds) => {
for (const innerNodeIndex in this.groupData.oldToNewInputMap) {
const id = selectedIds[innerNodeIndex]
const newNode = app.rootGraph.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.rootGraph.links[slot.link]
if (!link) continue
// connect this node output to the input of another node
const originNode = app.rootGraph.getNodeById(link.origin_id)
// @ts-expect-error fixme ts strict error
originNode.connect(link.origin_slot, newNode, +innerInputId)
}
}
}
// @ts-expect-error fixme ts strict error
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.rootGraph.links[l]
const targetNode = app.rootGraph.getNodeById(link.target_id)
const newNode = app.rootGraph.getNodeById(
selectedIds[slot.node.index]
)
// @ts-expect-error fixme ts strict error
newNode.connect(slot.slot, targetNode, link.target_slot)
}
}
}
app.canvas.emitBeforeChange()
try {
const { newNodes, selectedIds } = addInnerNodes()
reconnectInputs(selectedIds)
reconnectOutputs(selectedIds)
app.rootGraph.remove(this.node)
return newNodes
} finally {
app.canvas.emitAfterChange()
}
}
const getExtraMenuOptions = this.node.getExtraMenuOptions
// @ts-expect-error Should pass patched return value getExtraMenuOptions
this.node.getExtraMenuOptions = function (_, options) {
// @ts-expect-error fixme ts strict error
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',
// @ts-expect-error
callback: () => {
// @ts-expect-error fixme ts strict error
return this.convertToNodes()
}
},
{
content: 'Manage Group Node',
callback: () => manageGroupNodes(this.type)
}
)
}
// Draw custom collapse icon to identity this as a group
const onDrawTitleBox = this.node.onDrawTitleBox
this.node.onDrawTitleBox = function (ctx, height) {
// @ts-expect-error fixme ts strict error
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) {
// @ts-expect-error fixme ts strict error
onDrawForeground?.apply?.(this, arguments)
const progressState = useExecutionStore().nodeProgressStates[this.id]
if (
progressState &&
progressState.state === 'running' &&
this.runningInternalNodeId !== null
) {
// @ts-expect-error fixme ts strict error
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 () {
// @ts-expect-error fixme ts strict error
this.resetExecution = true
// @ts-expect-error fixme ts strict error
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]
}
}
}
}
// @ts-expect-error fixme ts strict error
return onNodeCreated?.apply(this, arguments)
}
// @ts-expect-error fixme ts strict error
function handleEvent(type, getId, getEvent) {
// @ts-expect-error fixme ts strict error
const handler = ({ detail }) => {
const id = getId(detail)
if (!id) return
const node = app.rootGraph.getNodeById(id)
if (node) return
// @ts-expect-error fixme ts strict error
const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == id)
if (innerNodeIndex > -1) {
// @ts-expect-error fixme ts strict error
this.node.runningInternalNodeId = innerNodeIndex
api.dispatchCustomEvent(
type,
// @ts-expect-error fixme ts strict error
getEvent(detail, `${this.node.id}`, this.node)
)
}
}
api.addEventListener(type, handler)
return handler
}
const executing = handleEvent.call(
this,
'executing',
// @ts-expect-error fixme ts strict error
(d) => d,
// @ts-expect-error fixme ts strict error
(_, id) => id
)
const executed = handleEvent.call(
this,
'executed',
// @ts-expect-error fixme ts strict error
(d) => d?.display_node || d?.node,
// @ts-expect-error fixme ts strict error
(d, id, node) => ({
...d,
node: id,
display_node: id,
merge: !node.resetExecution
})
)
const onRemoved = node.onRemoved
this.node.onRemoved = function () {
// @ts-expect-error fixme ts strict error
onRemoved?.apply(this, arguments)
// api.removeEventListener('progress_state', progress_state)
api.removeEventListener('executing', executing)
api.removeEventListener('executed', executed)
}
this.node.refreshComboInNode = (defs) => {
// Update combo widget options
for (const widgetName in this.groupData.newToOldWidgetMap) {
// @ts-expect-error fixme ts strict error
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' &&
// @ts-expect-error Widget values
!widget.options.values.includes(widget.value)
) {
// @ts-expect-error fixme ts strict error
widget.value = widget.options.values[0]
// @ts-expect-error fixme ts strict error
widget.callback(widget.value)
}
}
}
}
}
updateInnerWidgets() {
for (const newWidgetName in this.groupData.newToOldWidgetMap) {
// @ts-expect-error fixme ts strict error
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]
// @ts-expect-error fixme ts strict error
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(
// @ts-expect-error fixme ts strict error
(w) => w.name === input.widget.name
)
if (widget) {
widget.value = newValue
}
}
}
}
}
// @ts-expect-error fixme ts strict error
const widget = innerNode.widgets?.find((w) => w.name === old.inputName)
if (widget) {
widget.value = newValue
}
}
}
// @ts-expect-error fixme ts strict error
populatePrimitive(_node, nodeId, oldName) {
// Converted widget, populate primitive if linked
const primitiveId = this.groupData.widgetToPrimitive[nodeId]?.[oldName]
if (primitiveId == null) return
const targetWidgetName =
this.groupData.oldToNewWidgetMap[primitiveId]['value']
// @ts-expect-error fixme ts strict error
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 !==
// @ts-expect-error fixme ts strict error
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++) {
// @ts-expect-error fixme ts strict error
this.node.widgets[targetWidgetIndex + i].value =
primitiveNode.widgets[i].value
}
}
return true
}
// @ts-expect-error fixme ts strict error
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]
// @ts-expect-error fixme ts strict error
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) ||
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(
// @ts-expect-error fixme ts strict error
(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]
// @ts-expect-error fixme ts strict error
for (let w = 0; w < mainWidget.linkedWidgets?.length; w++) {
this.node.widgets[widgetIndex + w + 1].value =
node.widgets_values[i + ++linkedShift]
}
}
}
}
// @ts-expect-error fixme ts strict error
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.rootGraph.remove(node)
// Set internal ID to what is expected after workflow is reloaded
node.id = `${this.node.id}:${i}`
}
this.linkInputs()
this.node.pos = [left, top]
}
// @ts-expect-error fixme ts strict error
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.rootGraph.links[l]
if (!link) continue
const targetNode = app.rootGraph.getNodeById(link.target_id)
const newSlot =
this.groupData.oldToNewOutputMap[nodeId]?.[link.origin_slot]
if (newSlot != null) {
// @ts-expect-error fixme ts strict error
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.rootGraph.getNodeById(actualOriginId)
if (!originNode) continue // this node is in the group
originNode.connect(
originSlot,
// @ts-expect-error Valid - uses deprecated interface. Required check: if (graph.getNodeById(this.node.id) !== this.node) report()
this.node.id,
this.groupData.oldToNewInputMap[targetId][targetSlot]
)
}
}
// @ts-expect-error fixme ts strict error
static getGroupData(node) {
return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP]
}
static isGroupNode(node: LGraphNode) {
return !!node.constructor?.nodeData?.[GROUP]
}
static async fromNodes(nodes: LGraphNode[]) {
// Process the nodes into the stored workflow group node data
const builder = new GroupNodeBuilder(nodes)
const res = await 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(`${PREFIX}${SEPARATOR}${name}`)
// Reuse the existing nodes for this instance
// @ts-expect-error fixme ts strict error
groupNode.setInnerNodes(builder.nodes)
// @ts-expect-error fixme ts strict error
groupNode[GROUP].populateWidgets()
// @ts-expect-error fixme ts strict error
app.rootGraph.add(groupNode)
// Remove all converted nodes and relink them
// @ts-expect-error fixme ts strict error
groupNode[GROUP].replaceNodes(builder.nodes)
return groupNode
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
node.type = node.type.replace(/^workflow\//, `${PREFIX}${SEPARATOR}`)
}
}
}
/**
* Convert selected nodes to a group node
* @throws {Error} if no nodes are selected
* @throws {Error} if a group node is already selected
* @throws {Error} if a group node is selected
*
* The context menu item should not be available if any of the above conditions are met.
* The error is automatically handled by the commandStore when the command is executed.
*/
async function convertSelectedNodesToGroupNode() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
if (nodes.length === 0) {
throw new Error('No nodes selected')
}
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}
const convertDisabled = (selected: LGraphNode[]) =>
selected.length < 2 || !!selected.find((n) => GroupNodeHandler.isGroupNode(n))
function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
if (GroupNodeHandler.isGroupNode(node)) {
node.convertToNodes?.()
}
}
}
function manageGroupNodes(type?: string) {
new ManageGroupDialog(app).show(type)
}
const id = 'Comfy.GroupNode'
// @ts-expect-error fixme ts strict error
let globalDefs
const ext: ComfyExtension = {
name: id,
commands: [
{
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: () => convertSelectedNodesToGroupNode()
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: () => ungroupSelectedGroupNodes()
},
{
id: 'Comfy.GroupNode.ManageGroupNodes',
label: 'Manage group nodes',
icon: 'pi pi-cog',
versionAdded: '1.3.17',
function: (...args: unknown[]) =>
manageGroupNodes(args[0] as string | undefined)
}
],
keybindings: [
{
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
combo: {
alt: true,
key: 'g'
}
},
{
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
combo: {
alt: true,
shift: true,
key: 'G'
}
}
],
getCanvasMenuItems(canvas): IContextMenuValue[] {
const items: IContextMenuValue[] = []
const selected = Object.values(canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
items.push({
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error fixme ts strict error - async callback
callback: () => convertSelectedNodesToGroupNode()
})
const groups = canvas.graph?.extra?.groupNodes
const manageDisabled = !groups || !Object.keys(groups).length
items.push({
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
})
return items
},
getNodeMenuItems(node): IContextMenuValue[] {
if (GroupNodeHandler.isGroupNode(node)) {
return []
}
const selected = Object.values(app.canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
return [
{
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error fixme ts strict error - async callback
callback: () => convertSelectedNodesToGroupNode()
}
]
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]
) {
const nodes = graphData?.extra?.groupNodes
if (nodes) {
replaceLegacySeparators(graphData.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)) {
// @ts-expect-error fixme ts strict error
node[GROUP] = new GroupNodeHandler(node)
// Ensure group nodes pasted from other workflows are stored
// @ts-expect-error fixme ts strict error
if (node.title && node[GROUP]?.groupData?.nodeData) {
// @ts-expect-error fixme ts strict error
Workflow.storeGroupNode(node.title, node[GROUP].groupData.nodeData)
}
}
},
// @ts-expect-error fixme ts strict error
async refreshComboInNodes(defs) {
// Re-register group nodes so new ones are created with the correct options
// @ts-expect-error fixme ts strict error
Object.assign(globalDefs, defs)
const nodes = app.rootGraph.extra?.groupNodes
if (nodes) {
await GroupNodeConfig.registerFromWorkflow(nodes, {})
}
}
}
app.registerExtension(ext)