mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 22:20:03 +00:00
fix: extract and harden subgraph node ID deduplication (#9510)
## Summary Extract and harden subgraph node ID deduplication to prevent widget store key collisions when multiple subgraph copies share identical node IDs. ## Changes - **What**: Extract `deduplicateSubgraphNodeIds` from `LGraph.ts` into `utils/subgraphDeduplication.ts`, decomposed into focused helpers (`remapNodeIds`, `findNextAvailableId`, `patchSerialisedLinks`, `patchPromotedWidgets`, `patchProxyWidgets`). Clone inputs internally so caller data is never mutated. Add safety limit on ID search to prevent unbounded loops. Add `console.warn` on remapped IDs matching existing `ensureGlobalIdUniqueness` behavior. Add test fixture and 5 behavioral tests covering ID remapping, link patching, promoted widget patching, proxyWidget patching, and no-op when IDs are unique. ## Review Focus - The cloning strategy in `deduplicateSubgraphNodeIds` — it `structuredClone`s subgraphs and rootNodes, returning the clones. The caller uses `effectiveNodesData` to thread the patched root nodes through to node creation. - The `MAX_NODE_ID` safety limit (100M) — is this a reasonable ceiling? ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9510-fix-extract-and-harden-subgraph-node-ID-deduplication-31b6d73d365081f48c7de75e2bfc48b3) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
LiteGraph,
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -17,6 +18,10 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
|
||||
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
|
||||
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
|
||||
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
function swapNodes(nodes: LGraphNode[]) {
|
||||
@@ -656,3 +661,121 @@ describe('Subgraph Unpacking', () => {
|
||||
expect(definitionIds).toContain(subgraph.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
const SUBGRAPH_A = '11111111-1111-4111-8111-111111111111' as UUID
|
||||
const SUBGRAPH_B = '22222222-2222-4222-8222-222222222222' as UUID
|
||||
const SHARED_NODE_IDS = [3, 8, 37]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
LiteGraph.registerNodeType('dummy', DummyNode)
|
||||
})
|
||||
|
||||
function loadFixture(): SerialisableGraph {
|
||||
return structuredClone(duplicateSubgraphNodeIds)
|
||||
}
|
||||
|
||||
function configureFromFixture() {
|
||||
const graphData = loadFixture()
|
||||
const graph = new LGraph()
|
||||
graph.configure(graphData)
|
||||
return { graph, graphData }
|
||||
}
|
||||
|
||||
function nodeIdSet(graph: LGraph, subgraphId: UUID) {
|
||||
return new Set(graph.subgraphs.get(subgraphId)!.nodes.map((n) => n.id))
|
||||
}
|
||||
|
||||
it('remaps duplicate node IDs so subgraphs have no overlap', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
|
||||
const idsA = nodeIdSet(graph, SUBGRAPH_A)
|
||||
const idsB = nodeIdSet(graph, SUBGRAPH_B)
|
||||
|
||||
for (const id of SHARED_NODE_IDS) {
|
||||
expect(idsA.has(id as NodeId)).toBe(true)
|
||||
}
|
||||
for (const id of idsA) {
|
||||
expect(idsB.has(id)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches link references in remapped subgraph', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
const idsB = nodeIdSet(graph, SUBGRAPH_B)
|
||||
|
||||
for (const link of graph.subgraphs.get(SUBGRAPH_B)!.links.values()) {
|
||||
expect(idsB.has(link.origin_id)).toBe(true)
|
||||
expect(idsB.has(link.target_id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches promoted widget references in remapped subgraph', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
const idsB = nodeIdSet(graph, SUBGRAPH_B)
|
||||
|
||||
for (const widget of graph.subgraphs.get(SUBGRAPH_B)!.widgets) {
|
||||
expect(idsB.has(widget.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches proxyWidgets in root-level nodes referencing remapped IDs', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
|
||||
const idsA = new Set(
|
||||
graph.subgraphs.get(SUBGRAPH_A)!.nodes.map((n) => String(n.id))
|
||||
)
|
||||
const idsB = new Set(
|
||||
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
|
||||
)
|
||||
|
||||
const pw102 = graph.getNodeById(102 as NodeId)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(pw102)).toBe(true)
|
||||
for (const entry of pw102 as unknown[][]) {
|
||||
expect(Array.isArray(entry)).toBe(true)
|
||||
expect(idsA.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
const pw103 = graph.getNodeById(103 as NodeId)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(pw103)).toBe(true)
|
||||
for (const entry of pw103 as unknown[][]) {
|
||||
expect(Array.isArray(entry)).toBe(true)
|
||||
expect(idsB.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches proxyWidgets inside nested subgraph nodes', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(nestedSubgraphProxyWidgets))
|
||||
|
||||
const idsB = new Set(
|
||||
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
|
||||
)
|
||||
|
||||
const innerNode = graph.subgraphs
|
||||
.get(SUBGRAPH_A)!
|
||||
.nodes.find((n) => n.id === (50 as NodeId))
|
||||
const pw = innerNode?.properties?.proxyWidgets
|
||||
expect(Array.isArray(pw)).toBe(true)
|
||||
for (const entry of pw as unknown[][]) {
|
||||
expect(Array.isArray(entry)).toBe(true)
|
||||
expect(idsB.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when node ID space is exhausted', () => {
|
||||
expect(() => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(nodeIdSpaceExhausted))
|
||||
}).toThrow('Node ID space exhausted')
|
||||
})
|
||||
|
||||
it('is a no-op when subgraph node IDs are already unique', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(uniqueSubgraphNodeIds))
|
||||
|
||||
expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(new Set([10, 11, 12]))
|
||||
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,7 @@ import type {
|
||||
SerialisableReroute
|
||||
} from './types/serialisation'
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
|
||||
|
||||
export type {
|
||||
LGraphTriggerAction,
|
||||
@@ -2475,19 +2476,40 @@ export class LGraph
|
||||
this[i] = data[i]
|
||||
}
|
||||
|
||||
// Subgraph definitions
|
||||
// Subgraph definitions — deduplicate node IDs before configuring.
|
||||
// deduplicateSubgraphNodeIds clones internally to avoid mutating
|
||||
// the caller's data (e.g. reactive Pinia state).
|
||||
const subgraphs = data.definitions?.subgraphs
|
||||
let effectiveNodesData = nodesData
|
||||
if (subgraphs) {
|
||||
for (const subgraph of subgraphs) this.createSubgraph(subgraph)
|
||||
for (const subgraph of subgraphs)
|
||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||
}
|
||||
const reservedNodeIds = new Set<number>()
|
||||
for (const node of this._nodes) {
|
||||
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
|
||||
}
|
||||
for (const sg of this.subgraphs.values()) {
|
||||
for (const node of sg.nodes) {
|
||||
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
|
||||
}
|
||||
}
|
||||
for (const n of nodesData ?? []) {
|
||||
if (typeof n.id === 'number') reservedNodeIds.add(n.id)
|
||||
}
|
||||
|
||||
if (this.isRootGraph) {
|
||||
const reservedNodeIds = nodesData
|
||||
?.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
this.ensureGlobalIdUniqueness(reservedNodeIds)
|
||||
const deduplicated = this.isRootGraph
|
||||
? deduplicateSubgraphNodeIds(
|
||||
subgraphs,
|
||||
reservedNodeIds,
|
||||
this.state,
|
||||
nodesData
|
||||
)
|
||||
: undefined
|
||||
|
||||
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
|
||||
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
|
||||
|
||||
for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph)
|
||||
for (const subgraph of finalSubgraphs)
|
||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||
}
|
||||
|
||||
let error = false
|
||||
@@ -2495,8 +2517,8 @@ export class LGraph
|
||||
|
||||
// create nodes
|
||||
this._nodes = []
|
||||
if (nodesData) {
|
||||
for (const n_info of nodesData) {
|
||||
if (effectiveNodesData) {
|
||||
for (const n_info of effectiveNodesData) {
|
||||
// stored info
|
||||
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
||||
if (!node) {
|
||||
|
||||
163
src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts
Normal file
163
src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow with two subgraph definitions whose internal nodes share
|
||||
* identical IDs [3, 8, 37]. Reproduces the widget-state collision bug
|
||||
* where copied subgraphs overwrote each other's widget store entries.
|
||||
*
|
||||
* SubgraphA (node 102): widgets reference node 3, link 3→8
|
||||
* SubgraphB (node 103): widgets reference node 8, link 3→37
|
||||
*/
|
||||
export const duplicateSubgraphNodeIds = {
|
||||
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 100,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['3', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 3, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 8,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 8, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 37,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
177
src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts
Normal file
177
src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow where SubgraphA contains a nested SubgraphNode referencing
|
||||
* SubgraphB. Both subgraph definitions share internal node IDs [3, 8, 37].
|
||||
*
|
||||
* The nested SubgraphNode (id 50, inside SubgraphA) has proxyWidgets
|
||||
* pointing at SubgraphB's node 8. After deduplication remaps SubgraphB's
|
||||
* nodes, the nested proxyWidgets must also be patched.
|
||||
*
|
||||
* SubgraphA (node 102): widgets reference node 3, link 3→8,
|
||||
* contains nested SubgraphNode(50) → SubgraphB with proxyWidget ['8']
|
||||
* SubgraphB (node 103): widgets reference node 8, link 3→37
|
||||
*/
|
||||
export const nestedSubgraphProxyWidgets = {
|
||||
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 100,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['3', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 3, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 50,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [200, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 8,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 8, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 37,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
172
src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts
Normal file
172
src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow where lastNodeId is near the MAX_NODE_ID ceiling (100_000_000)
|
||||
* and root node 100_000_000 reserves the only remaining candidate ID.
|
||||
*
|
||||
* Both subgraph definitions share node IDs [3, 8, 37]. When SubgraphB's
|
||||
* duplicates need remapping, candidate 100_000_000 is already reserved,
|
||||
* so the next candidate (100_000_001) exceeds MAX_NODE_ID and must throw.
|
||||
*/
|
||||
export const nodeIdSpaceExhausted = {
|
||||
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 99_999_999,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['3', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
},
|
||||
{
|
||||
id: 100_000_000,
|
||||
type: 'dummy',
|
||||
pos: [600, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 3, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 8,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 8, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 37,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
163
src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts
Normal file
163
src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow with two subgraph definitions whose internal nodes already
|
||||
* have unique IDs. Deduplication should be a no-op — all IDs, links,
|
||||
* widgets, and proxyWidgets pass through unchanged.
|
||||
*
|
||||
* SubgraphA (node 102): nodes [10, 11, 12], link 10→11, widget ref 10
|
||||
* SubgraphB (node 103): nodes [20, 21, 22], link 20→22, widget ref 21
|
||||
*/
|
||||
export const uniqueSubgraphNodeIds = {
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 100,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['10', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['21', 'prompt']] }
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 10, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 10,
|
||||
origin_slot: 0,
|
||||
target_id: 11,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 21, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 20,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 20,
|
||||
origin_slot: 0,
|
||||
target_id: 22,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
164
src/lib/litegraph/src/utils/subgraphDeduplication.ts
Normal file
164
src/lib/litegraph/src/utils/subgraphDeduplication.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { LGraphState } from '../LGraph'
|
||||
import type { NodeId } from '../LGraphNode'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExposedWidget,
|
||||
ISerialisedNode,
|
||||
SerialisableLLink
|
||||
} from '../types/serialisation'
|
||||
|
||||
const MAX_NODE_ID = 100_000_000
|
||||
|
||||
interface DeduplicationResult {
|
||||
subgraphs: ExportedSubgraph[]
|
||||
rootNodes: ISerialisedNode[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-deduplicates node IDs across serialized subgraph definitions before
|
||||
* they are configured. This prevents widget store key collisions when
|
||||
* multiple subgraph copies contain nodes with the same IDs.
|
||||
*
|
||||
* Also patches proxyWidgets in root-level nodes that reference the
|
||||
* remapped inner node IDs.
|
||||
*
|
||||
* Returns deep clones of the inputs — the originals are never mutated.
|
||||
*
|
||||
* @param subgraphs - Serialized subgraph definitions to deduplicate
|
||||
* @param reservedNodeIds - Node IDs already in use by root-level nodes
|
||||
* @param state - Graph state containing the `lastNodeId` counter (mutated)
|
||||
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
|
||||
*/
|
||||
export function deduplicateSubgraphNodeIds(
|
||||
subgraphs: ExportedSubgraph[],
|
||||
reservedNodeIds: Set<number>,
|
||||
state: LGraphState,
|
||||
rootNodes?: ISerialisedNode[]
|
||||
): DeduplicationResult {
|
||||
const clonedSubgraphs = structuredClone(subgraphs)
|
||||
const clonedRootNodes = rootNodes ? structuredClone(rootNodes) : undefined
|
||||
|
||||
const usedNodeIds = new Set(reservedNodeIds)
|
||||
const subgraphIdSet = new Set(clonedSubgraphs.map((sg) => sg.id))
|
||||
const remapBySubgraph = new Map<string, Map<NodeId, NodeId>>()
|
||||
|
||||
for (const subgraph of clonedSubgraphs) {
|
||||
const remappedIds = remapNodeIds(subgraph.nodes ?? [], usedNodeIds, state)
|
||||
|
||||
if (remappedIds.size === 0) continue
|
||||
remapBySubgraph.set(subgraph.id, remappedIds)
|
||||
|
||||
patchSerialisedLinks(subgraph.links ?? [], remappedIds)
|
||||
patchPromotedWidgets(subgraph.widgets ?? [], remappedIds)
|
||||
}
|
||||
|
||||
for (const subgraph of clonedSubgraphs) {
|
||||
patchProxyWidgets(subgraph.nodes ?? [], subgraphIdSet, remapBySubgraph)
|
||||
}
|
||||
|
||||
if (clonedRootNodes) {
|
||||
patchProxyWidgets(clonedRootNodes, subgraphIdSet, remapBySubgraph)
|
||||
}
|
||||
|
||||
return { subgraphs: clonedSubgraphs, rootNodes: clonedRootNodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps duplicate node IDs to unique values, updating `usedNodeIds`
|
||||
* and `state.lastNodeId` as new IDs are allocated.
|
||||
*
|
||||
* @returns A map of old ID → new ID for nodes that were remapped.
|
||||
*/
|
||||
function remapNodeIds(
|
||||
nodes: ISerialisedNode[],
|
||||
usedNodeIds: Set<number>,
|
||||
state: LGraphState
|
||||
): Map<NodeId, NodeId> {
|
||||
const remappedIds = new Map<NodeId, NodeId>()
|
||||
|
||||
for (const node of nodes) {
|
||||
const id = node.id
|
||||
if (typeof id !== 'number') continue
|
||||
|
||||
if (usedNodeIds.has(id)) {
|
||||
const newId = findNextAvailableId(usedNodeIds, state)
|
||||
remappedIds.set(id, newId)
|
||||
node.id = newId
|
||||
usedNodeIds.add(newId as number)
|
||||
console.warn(
|
||||
`LiteGraph: duplicate subgraph node ID ${id} remapped to ${newId}`
|
||||
)
|
||||
} else {
|
||||
usedNodeIds.add(id)
|
||||
if (id > state.lastNodeId) state.lastNodeId = id
|
||||
}
|
||||
}
|
||||
|
||||
return remappedIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next unused node ID by incrementing `state.lastNodeId`.
|
||||
* Throws if the ID space is exhausted.
|
||||
*/
|
||||
function findNextAvailableId(
|
||||
usedNodeIds: Set<number>,
|
||||
state: LGraphState
|
||||
): NodeId {
|
||||
while (true) {
|
||||
const nextId = state.lastNodeId + 1
|
||||
if (nextId > MAX_NODE_ID) {
|
||||
throw new Error('Node ID space exhausted')
|
||||
}
|
||||
state.lastNodeId = nextId
|
||||
if (!usedNodeIds.has(nextId)) return nextId as NodeId
|
||||
}
|
||||
}
|
||||
|
||||
/** Patches origin_id / target_id in serialized links. */
|
||||
function patchSerialisedLinks(
|
||||
links: SerialisableLLink[],
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
): void {
|
||||
for (const link of links) {
|
||||
const newOrigin = remappedIds.get(link.origin_id)
|
||||
if (newOrigin !== undefined) link.origin_id = newOrigin
|
||||
|
||||
const newTarget = remappedIds.get(link.target_id)
|
||||
if (newTarget !== undefined) link.target_id = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
/** Patches promoted widget node references. */
|
||||
function patchPromotedWidgets(
|
||||
widgets: ExposedWidget[],
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
): void {
|
||||
for (const widget of widgets) {
|
||||
const newId = remappedIds.get(widget.id)
|
||||
if (newId !== undefined) widget.id = newId
|
||||
}
|
||||
}
|
||||
|
||||
/** Patches proxyWidgets in root-level SubgraphNode instances. */
|
||||
function patchProxyWidgets(
|
||||
rootNodes: ISerialisedNode[],
|
||||
subgraphIdSet: Set<string>,
|
||||
remapBySubgraph: Map<string, Map<NodeId, NodeId>>
|
||||
): void {
|
||||
for (const node of rootNodes) {
|
||||
if (!subgraphIdSet.has(node.type)) continue
|
||||
const remappedIds = remapBySubgraph.get(node.type)
|
||||
if (!remappedIds) continue
|
||||
|
||||
const proxyWidgets = node.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) continue
|
||||
|
||||
for (const entry of proxyWidgets) {
|
||||
if (!Array.isArray(entry)) continue
|
||||
const oldId = Number(entry[0]) as NodeId
|
||||
const newId = remappedIds.get(oldId)
|
||||
if (newId !== undefined) entry[0] = String(newId)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user