[Reroute] Migrate floating link (#3260)

This commit is contained in:
Chenlei Hu
2025-03-27 22:13:16 -04:00
committed by GitHub
parent 25ce267b2e
commit 690326c374
8 changed files with 749 additions and 188 deletions

View File

@@ -82,7 +82,12 @@ const zReroute = z
id: z.number(),
parentId: z.number().optional(),
pos: zVector2,
linkIds: z.array(z.number()).nullish()
linkIds: z.array(z.number()).nullish(),
floating: z
.object({
slotType: z.enum(['input', 'output'])
})
.optional()
})
.passthrough()
@@ -277,6 +282,7 @@ export type ModelFile = z.infer<typeof zModelFile>
export type NodeInput = z.infer<typeof zNodeInput>
export type NodeOutput = z.infer<typeof zNodeOutput>
export type ComfyLink = z.infer<typeof zComfyLink>
export type ComfyLinkObject = z.infer<typeof zComfyLinkObject>
export type ComfyNode = z.infer<typeof zComfyNode>
export type Reroute = z.infer<typeof zReroute>
export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow>

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'
import type {
ComfyLink,
ComfyLinkObject,
ComfyNode,
NodeId,
Reroute,
@@ -17,11 +17,6 @@ type LinkExtension = {
parentId: number
}
type RerouteEntry = {
reroute: Reroute
rerouteNode: RerouteNode
}
/**
* Identifies all legacy Reroute nodes in a workflow
*/
@@ -38,156 +33,269 @@ function getNodeCenter(node: ComfyNode): [number, number] {
return [node.pos[0] + node.size[0] / 2, node.pos[1] + node.size[1] / 2]
}
/**
* Creates native reroute points from legacy Reroute nodes
*/
export function createReroutePoints(
rerouteNodes: RerouteNode[]
): Map<NodeId, RerouteEntry> {
const rerouteMap = new Map<NodeId, RerouteEntry>()
let rerouteIdCounter = 1
rerouteNodes.forEach((node) => {
const rerouteId = rerouteIdCounter++
rerouteMap.set(node.id, {
reroute: {
id: rerouteId,
pos: getNodeCenter(node),
linkIds: []
},
rerouteNode: node
})
})
return rerouteMap
}
/**
* Creates new links and link extensions for the migrated workflow
*/
export function createNewLinks(
workflow: WorkflowJSON04,
rerouteMap: Map<NodeId, RerouteEntry>
): {
nodes: ComfyNode[]
reroutes: Reroute[]
links: ComfyLink[]
class ConversionContext {
nodeById: Record<NodeId, ComfyNode>
linkById: Record<number, ComfyLinkObject>
rerouteById: Record<number, Reroute>
rerouteByNodeId: Record<NodeId, Reroute>
linkExtensions: LinkExtension[]
} {
const nodeById = _.keyBy(
workflow.nodes.filter((node) => node.type !== 'Reroute').map(_.cloneDeep),
'id'
)
const links: ComfyLink[] = []
const linkExtensions: LinkExtension[] = []
const rerouteMapByRerouteId = new Map<number, RerouteEntry>(
Array.from(rerouteMap.values()).map((entry) => [entry.reroute.id, entry])
)
const linksMap = new Map<number, ComfyLink>(
Array.from(workflow.links).map((link) => [link[0], link])
)
/** Reroutes that has at least a valid link pass through it */
validReroutes: Set<Reroute>
// Process each link in the workflow
for (const link of workflow.links) {
const [
linkId,
sourceNodeId,
_sourceSlot,
targetNodeId,
_targetSlot,
_dataType
] = link
#rerouteIdCounter = 0
// Check if this link connects to or from a reroute node
const sourceEntry = rerouteMap.get(sourceNodeId)
const targetEntry = rerouteMap.get(targetNodeId)
const sourceIsReroute = !!sourceEntry
const targetIsReroute = !!targetEntry
constructor(public workflow: WorkflowJSON04) {
this.nodeById = _.keyBy(workflow.nodes.map(_.cloneDeep), 'id')
this.linkById = _.keyBy(
workflow.links.map((l) => ({
id: l[0],
origin_id: l[1],
origin_slot: l[2],
target_id: l[3],
target_slot: l[4],
type: l[5]
})),
'id'
)
if (!sourceIsReroute && !targetIsReroute) {
// If neither end is a reroute, keep the link as is
links.push(link)
} else if (sourceIsReroute && !targetIsReroute) {
// This is a link from a reroute node to a regular node
linkExtensions.push({
id: linkId,
parentId: sourceEntry.reroute.id
})
} else if (sourceIsReroute && targetIsReroute) {
targetEntry.reroute.parentId = sourceEntry.reroute.id
const reroutes = findLegacyRerouteNodes(workflow).map((node, index) => ({
nodeId: node.id,
id: index + 1,
pos: getNodeCenter(node),
linkIds: []
}))
this.#rerouteIdCounter = reroutes.length + 1
this.rerouteByNodeId = _.keyBy(reroutes, 'nodeId')
this.rerouteById = _.keyBy(reroutes, 'id')
this.linkExtensions = []
this.validReroutes = new Set()
}
/**
* Gets the chain of reroute nodes leading to the given node
*/
#getRerouteChain(node: RerouteNode): RerouteNode[] {
const nodes: RerouteNode[] = []
let currentNode: RerouteNode = node
while (currentNode?.type === 'Reroute') {
nodes.push(currentNode)
const inputLink: ComfyLinkObject | undefined =
this.linkById[currentNode.inputs?.[0]?.link ?? 0]
if (!inputLink) {
break
}
currentNode = this.nodeById[inputLink.origin_id] as RerouteNode
}
return nodes
}
#connectRerouteChain(rerouteNodes: RerouteNode[]): Reroute[] {
const reroutes = rerouteNodes.map((node) => this.rerouteByNodeId[node.id])
for (const reroute of reroutes) {
this.validReroutes.add(reroute)
}
for (let i = 0; i < rerouteNodes.length - 1; i++) {
const to = reroutes[i]
const from = reroutes[i + 1]
to.parentId = from.id
}
return reroutes
}
#createNewLink(
startingLink: ComfyLinkObject,
endingLink: ComfyLinkObject,
rerouteNodes: RerouteNode[]
): ComfyLinkObject {
if (rerouteNodes.length === 0) {
throw new Error('No reroute nodes found')
}
const reroute = this.rerouteByNodeId[rerouteNodes[0].id]
this.linkExtensions.push({
id: endingLink.id,
parentId: reroute.id
})
const reroutes = this.#connectRerouteChain(rerouteNodes)
for (const reroute of reroutes) {
reroute.linkIds ??= []
reroute.linkIds.push(endingLink.id)
delete reroute.floating
}
return {
id: endingLink.id,
origin_id: startingLink.origin_id,
origin_slot: startingLink.origin_slot,
target_id: endingLink.target_id,
target_slot: endingLink.target_slot,
type: endingLink.type
}
}
// Populate linkIds on reroute nodes
// Remove all partially connected reroutes
const validLinkExtensions: LinkExtension[] = []
const validReroutes: Set<Reroute> = new Set()
for (const linkExtension of linkExtensions) {
let entry = rerouteMapByRerouteId.get(linkExtension.parentId)
const chainedReroutes: Reroute[] = []
while (entry) {
const reroute = entry.reroute
reroute.linkIds ??= []
reroute.linkIds.push(linkExtension.id)
chainedReroutes.push(reroute)
if (reroute.parentId) {
entry = rerouteMapByRerouteId.get(reroute.parentId)
} else {
// Last reroute in the chain
const rerouteNode = entry.rerouteNode
const rerouteInputLink = linksMap.get(
rerouteNode?.inputs?.[0]?.link ?? -1
)
const rerouteOutputLink = linksMap.get(linkExtension.id)
if (rerouteInputLink && rerouteOutputLink) {
const [_, sourceNodeId, sourceSlot] = rerouteInputLink
const [linkId, __, ___, targetNodeId, targetSlot, dataType] =
rerouteOutputLink
links.push([
linkId,
sourceNodeId,
sourceSlot,
targetNodeId,
targetSlot,
dataType
])
validLinkExtensions.push(linkExtension)
chainedReroutes.forEach((reroute) => validReroutes.add(reroute))
// Update source node's output slot's link ids to point to the new link.
const sourceNode = nodeById[sourceNodeId]
if (!sourceNode) {
throw new Error(
`Corrupted workflow: Source node ${sourceNodeId} not found`
)
}
const outputSlot = sourceNode.outputs?.[sourceSlot]
if (!outputSlot) {
throw new Error(
`Corrupted workflow: Output slot ${sourceSlot} not found`
)
}
outputSlot.links = outputSlot.links?.map((l) =>
l === rerouteInputLink[0] ? linkId : l
)
#createNewInputFloatingLink(
endingLink: ComfyLinkObject,
rerouteNodes: RerouteNode[]
): ComfyLinkObject {
const reroutes = this.#connectRerouteChain(rerouteNodes)
for (const reroute of reroutes) {
if (!reroute.linkIds?.length) {
reroute.floating = {
slotType: 'input'
}
entry = undefined
}
}
return {
id: this.#rerouteIdCounter++,
origin_id: -1,
origin_slot: -1,
target_id: endingLink.target_id,
target_slot: endingLink.target_slot,
type: endingLink.type,
parentId: reroutes[0].id
}
}
return {
nodes: Object.values(nodeById),
links,
linkExtensions: validLinkExtensions,
reroutes: Array.from(validReroutes)
#createNewOutputFloatingLink(
startingLink: ComfyLinkObject,
rerouteNodes: RerouteNode[]
): ComfyLinkObject {
const reroutes = this.#connectRerouteChain(rerouteNodes)
for (const reroute of reroutes) {
if (!reroute.linkIds?.length) {
reroute.floating = {
slotType: 'output'
}
}
}
return {
id: this.#rerouteIdCounter++,
origin_id: startingLink.origin_id,
origin_slot: startingLink.origin_slot,
target_id: -1,
target_slot: -1,
type: startingLink.type,
parentId: reroutes[0].id
}
}
#reconnectLinks(nodes: ComfyNode[], links: ComfyLinkObject[]): void {
// Remove all existing links on sockets
for (const node of nodes) {
for (const input of node.inputs ?? []) {
input.link = null
}
for (const output of node.outputs ?? []) {
output.links = []
}
}
const nodesById = _.keyBy(nodes, 'id')
// Reconnect the links
for (const link of links) {
const sourceNode = nodesById[link.origin_id]
sourceNode.outputs![link.origin_slot]!.links!.push(link.id)
const targetNode = nodesById[link.target_id]
targetNode.inputs![link.target_slot]!.link = link.id
}
}
migrateReroutes(): WorkflowJSON04 {
const links: ComfyLinkObject[] = []
const floatingLinks: ComfyLinkObject[] = []
const endingLinks: ComfyLinkObject[] = []
for (const link of Object.values(this.linkById)) {
const sourceIsReroute = !!this.rerouteByNodeId[link.origin_id]
const targetIsReroute = !!this.rerouteByNodeId[link.target_id]
// Process links that are not connected to reroute nodes
if (!sourceIsReroute && !targetIsReroute) {
links.push(link)
} else if (sourceIsReroute && !targetIsReroute) {
endingLinks.push(link)
}
}
for (const endingLink of endingLinks) {
const endingRerouteNode = this.nodeById[
endingLink.origin_id
] as RerouteNode
const rerouteNodes = this.#getRerouteChain(endingRerouteNode)
const startingLink =
this.linkById[
rerouteNodes[rerouteNodes.length - 1]?.inputs?.[0]?.link ?? -1
]
if (startingLink) {
// Valid link found, create a new link
links.push(this.#createNewLink(startingLink, endingLink, rerouteNodes))
} else {
// Floating link found, create a new floating link
floatingLinks.push(
this.#createNewInputFloatingLink(endingLink, rerouteNodes)
)
}
}
const floatingEndingRerouteNodes = Object.keys(this.rerouteByNodeId)
.map((nodeId) => this.nodeById[nodeId] as RerouteNode)
.filter((rerouteNode) => {
const output = rerouteNode.outputs?.[0]
if (!output) return false
return !output.links?.length
})
for (const rerouteNode of floatingEndingRerouteNodes) {
const rerouteNodes = this.#getRerouteChain(rerouteNode)
const startingLink =
this.linkById[
rerouteNodes[rerouteNodes.length - 1]?.inputs?.[0]?.link ?? -1
]
if (startingLink) {
floatingLinks.push(
this.#createNewOutputFloatingLink(startingLink, rerouteNodes)
)
}
}
const nodes = Object.values(this.nodeById).filter(
(node) => node.type !== 'Reroute'
)
this.#reconnectLinks(nodes, links)
return {
...this.workflow,
nodes,
links: links.map((link) => [
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot,
link.type
]),
floatingLinks: floatingLinks.length > 0 ? floatingLinks : undefined,
extra: {
...this.workflow.extra,
reroutes: Array.from(this.validReroutes).map(
(reroute) => _.omit(reroute, 'nodeId') as Reroute
),
linkExtensions: this.linkExtensions
}
}
}
}
@@ -213,20 +321,6 @@ export const migrateLegacyRerouteNodes = (
newWorkflow.extra = {}
}
// Create native reroute points
const rerouteMap = createReroutePoints(legacyRerouteNodes)
// Create new links and link extensions
const { nodes, links, linkExtensions, reroutes } = createNewLinks(
workflow,
rerouteMap
)
// Update the workflow
newWorkflow.links = links
newWorkflow.nodes = nodes
newWorkflow.extra.reroutes = reroutes
newWorkflow.extra.linkExtensions = linkExtensions
return newWorkflow
const context = new ConversionContext(newWorkflow)
return context.migrateReroutes()
}

View File

@@ -14,22 +14,24 @@ describe('migrateReroute', () => {
return JSON.parse(fileContent) as WorkflowJSON04
}
it.each(['branching.json', 'single_connected.json', 'floating.json'])(
'should correctly migrate %s',
(fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`workflows/reroute/legacy/${fileName}`
)
it.each([
'branching.json',
'single_connected.json',
'floating.json',
'floating_branch.json'
])('should correctly migrate %s', (fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`workflows/reroute/legacy/${fileName}`
)
// Migrate the workflow
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Migrate the workflow
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Compare with snapshot
expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot(
`workflows/reroute/native/${fileName}`
)
}
)
// Compare with snapshot
expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot(
`workflows/reroute/native/${fileName}`
)
})
})
})

View File

@@ -0,0 +1,253 @@
{
"last_node_id": 36,
"last_link_id": 44,
"nodes": [
{
"id": 33,
"type": "Reroute",
"pos": [
492.768310546875,
274.761962890625
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 41
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
40
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 32,
"type": "Reroute",
"pos": [
362.8304138183594,
275.12872314453125
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 39
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
41,
42
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
-0.6348835229873657,
238.0631866455078
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
39
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
611.6028442382812,
254.6018524169922
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 40
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 34,
"type": "Reroute",
"pos": [
490.8152770996094,
364.4836730957031
],
"size": [
75,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 42
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": null
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
}
],
"links": [
[
39,
4,
2,
32,
0,
"*"
],
[
40,
33,
0,
12,
1,
"VAE"
],
[
41,
32,
0,
33,
0,
"*"
],
[
42,
32,
0,
34,
0,
"*"
]
],
"floatingLinks": [
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*"
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.6672297511789418,
"offset": [
262.0504372113823,
124.35120995663942
]
},
"linkExtensions": []
},
"version": 0.4
}

View File

@@ -35,8 +35,8 @@
"type": "VAE",
"slot_index": 2,
"links": [
13,
21
21,
34
]
}
],
@@ -77,7 +77,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {
@@ -115,7 +115,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {

View File

@@ -34,10 +34,7 @@
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
13,
31
]
"links": []
}
],
"properties": {
@@ -72,14 +69,14 @@
{
"name": "vae",
"type": "VAE",
"link": 21
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {
@@ -101,8 +98,51 @@
200.06933385457722
]
},
"reroutes": [],
"reroutes": [
{
"id": 1,
"pos": [
547.5,
293
],
"linkIds": [],
"floating": {
"slotType": "input"
}
},
{
"id": 2,
"pos": [
438.99267578125,
291.96600341796875
],
"linkIds": [],
"floating": {
"slotType": "output"
}
}
],
"linkExtensions": []
},
"version": 0.4
"version": 0.4,
"floatingLinks": [
{
"id": 7,
"origin_id": -1,
"origin_slot": -1,
"target_id": 12,
"target_slot": 1,
"type": "VAE",
"parentId": 1
},
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*",
"parentId": 2
}
]
}

View File

@@ -0,0 +1,166 @@
{
"last_node_id": 36,
"last_link_id": 44,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
-0.6348835229873657,
238.0631866455078
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
40
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
611.6028442382812,
254.6018524169922
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 40
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [
[
40,
4,
2,
12,
1,
"VAE"
]
],
"floatingLinks": [
{
"id": 4,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*",
"parentId": 3
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.6672297511789418,
"offset": [
262.0504372113823,
124.35120995663942
]
},
"linkExtensions": [
{
"id": 40,
"parentId": 1
}
],
"reroutes": [
{
"id": 1,
"pos": [
530.268310546875,
287.761962890625
],
"linkIds": [
40
],
"parentId": 2
},
{
"id": 2,
"pos": [
400.3304138183594,
288.12872314453125
],
"linkIds": [
40
]
},
{
"id": 3,
"pos": [
528.3152770996094,
377.4836730957031
],
"linkIds": [],
"parentId": 2,
"floating": {
"slotType": "output"
}
}
]
},
"version": 0.4
}

View File

@@ -76,7 +76,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {