Files
ComfyUI_frontend/src/extensions/core/rerouteNode.ts
AustinMroz 23b0d2eb7f Add front end support for type matching (#6582)
This PR implements front end logic to handle MatchType inputs and
outputs.
See  comfyanonymous/ComfyUI#10644

This allows for the implementation of nodes such as a "switch node"
where input types change based on the connections made.

![switch-node](https://github.com/user-attachments/assets/090515ba-484c-4295-b7b3-204b0c72fc4a)

As part of this implementation, significant cleanup is being performed
in the reroute code. Extra testing will be required to make sure these
changes don't introduce regressions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6582-Add-front-end-support-for-type-matching-2a16d73d36508189b042cd23f82a332e)
by [Unito](https://www.unito.io)
2025-11-12 13:30:58 -07:00

296 lines
9.5 KiB
TypeScript

import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import {
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import { app } from '../../scripts/app'
import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
// Node that allows you to redirect connections for cleaner graphs
app.registerExtension({
name: 'Comfy.RerouteNode',
registerCustomNodes(app) {
interface RerouteNode extends LGraphNode {
__outputType?: string | number
}
class RerouteNode extends LGraphNode {
static override category: string | undefined
static defaultVisibility = false
constructor(title?: string) {
super(title ?? '')
if (!this.properties) {
this.properties = {}
}
this.properties.showOutputText = RerouteNode.defaultVisibility
this.properties.horizontal = false
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
this.isVirtualNode = true
}
override onAfterGraphConfigured() {
requestAnimationFrame(() => {
this.onConnectionsChange(LiteGraph.INPUT, undefined, true)
})
}
override clone(): LGraphNode | null {
const cloned = super.clone()
if (!cloned) return cloned
cloned.removeOutput(0)
cloned.addOutput(this.properties.showOutputText ? '*' : '', '*')
cloned.setSize(cloned.computeSize())
return cloned
}
override onConnectionsChange(
type: ISlotType,
_index: number | undefined,
connected: boolean
) {
const { graph } = this
if (!graph) return
if (app.configuringGraph) return
// Prevent multiple connections to different types when we have no input
if (connected && type === LiteGraph.OUTPUT) {
// Ignore wildcard nodes as these will be updated to real types
const types = new Set(
this.outputs[0].links
?.map((l) => graph.links[l]?.type)
?.filter((t) => t && t !== '*') ?? []
)
if (types.size > 1) {
const linksToDisconnect = []
for (const linkId of this.outputs[0].links ?? []) {
const link = graph.links[linkId]
linksToDisconnect.push(link)
}
linksToDisconnect.pop()
for (const link of linksToDisconnect) {
const node = graph.getNodeById(link.target_id)
node?.disconnectInput(link.target_slot)
}
}
}
// Find root input
let currentNode: RerouteNode | null = this
let updateNodes: RerouteNode[] = []
let inputType = null
let inputNode = null
while (currentNode) {
updateNodes.unshift(currentNode)
const linkId = currentNode.inputs[0].link
if (linkId !== null) {
const link = graph.links[linkId]
if (!link) return
const node = graph.getNodeById(link.origin_id)
if (!node) return
if (node instanceof RerouteNode) {
if (node === this) {
// We've found a circle
currentNode.disconnectInput(link.target_slot)
currentNode = null
} else {
// Move the previous node
currentNode = node
}
} else {
// We've found the end
inputNode = currentNode
inputType = node.outputs[link.origin_slot]?.type ?? null
break
}
} else {
// This path has no input node
currentNode = null
break
}
}
// Find all outputs
const nodes: RerouteNode[] = [this]
let outputType = null
while (nodes.length) {
currentNode = nodes.pop()!
const outputs = currentNode.outputs?.[0]?.links ?? []
for (const linkId of outputs) {
const link = graph.links[linkId]
// When disconnecting sometimes the link is still registered
if (!link) continue
const node = graph.getNodeById(link.target_id)
if (!node) continue
if (node instanceof RerouteNode) {
// Follow reroute nodes
nodes.push(node)
updateNodes.push(node)
} else {
// We've found an output
const nodeInput = node.inputs[link.target_slot]
const nodeOutType = nodeInput.type
const keep =
!inputType ||
!nodeOutType ||
LiteGraph.isValidConnection(inputType, nodeOutType)
if (!keep) {
// The output doesnt match our input so disconnect it
node.disconnectInput(link.target_slot)
continue
}
node.onConnectionsChange?.(
LiteGraph.INPUT,
link.target_slot,
keep,
link,
nodeInput
)
outputType = node.inputs[link.target_slot].type
}
}
}
const displayType = inputType || outputType || '*'
const color = LGraphCanvas.link_type_colors[displayType]
let widgetConfig
let widgetType
// Update the types of each node
for (const node of updateNodes) {
// If we dont have an input type we are always wildcard but we'll show the output type
// This lets you change the output link to a different type and all nodes will update
node.outputs[0].type = inputType || '*'
node.__outputType = displayType
node.outputs[0].name = node.properties.showOutputText
? `${displayType}`
: ''
node.setSize(node.computeSize())
for (const l of node.outputs[0].links || []) {
const link = graph.links[l]
if (!link) continue
link.color = color
if (app.configuringGraph) continue
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) continue
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const config = getWidgetConfig(targetInput)
if (!widgetConfig) {
widgetConfig = config[1] ?? {}
widgetType = config[0]
}
const merged = mergeIfValid(targetInput, [
config[0],
widgetConfig
])
if (merged.customConfig) {
widgetConfig = merged.customConfig
}
}
}
}
for (const node of updateNodes) {
if (widgetConfig && outputType) {
node.inputs[0].widget = { name: 'value' }
setWidgetConfig(node.inputs[0], [
widgetType ?? `${displayType}`,
widgetConfig
])
} else {
setWidgetConfig(node.inputs[0], undefined)
}
}
if (inputNode?.inputs?.[0]?.link) {
const link = graph.links[inputNode.inputs[0].link]
if (link) {
link.color = color
}
}
}
override getExtraMenuOptions(
_: unknown,
options: (IContextMenuValue | null)[]
): IContextMenuValue[] {
options.unshift(
{
content:
(this.properties.showOutputText ? 'Hide' : 'Show') + ' Type',
callback: () => {
this.properties.showOutputText = !this.properties.showOutputText
if (this.properties.showOutputText) {
this.outputs[0].name = `${this.__outputType || this.outputs[0].type}`
} else {
this.outputs[0].name = ''
}
this.setSize(this.computeSize())
app.canvas.setDirty(true, true)
}
},
{
content:
(RerouteNode.defaultVisibility ? 'Hide' : 'Show') +
' Type By Default',
callback: () => {
RerouteNode.setDefaultTextVisibility(
!RerouteNode.defaultVisibility
)
}
}
)
return []
}
override computeSize(): [number, number] {
return [
this.properties.showOutputText && this.outputs && this.outputs.length
? Math.max(
75,
LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 +
40
)
: 75,
26
]
}
static setDefaultTextVisibility(visible: boolean) {
RerouteNode.defaultVisibility = visible
if (visible) {
localStorage['Comfy.RerouteNode.DefaultVisibility'] = 'true'
} else {
delete localStorage['Comfy.RerouteNode.DefaultVisibility']
}
}
}
// Load default visibility
RerouteNode.setDefaultTextVisibility(
!!localStorage['Comfy.RerouteNode.DefaultVisibility']
)
LiteGraph.registerNodeType(
'Reroute',
Object.assign(RerouteNode, {
title_mode: LiteGraph.NO_TITLE,
title: 'Reroute',
collapsable: false
})
)
RerouteNode.category = 'utils'
}
})