[backport core/1.42] fix: resolve nodes in subgraphs for image copy/paste and display (#10184)

Backport of #10009 to `core/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10184-backport-core-1-42-fix-resolve-nodes-in-subgraphs-for-image-copy-paste-and-display-3266d73d365081fca061f93b717adb3e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2026-03-17 23:13:54 +09:00
committed by GitHub
parent ea13c8675d
commit 4b0e37293c
7 changed files with 98 additions and 449 deletions

View File

@@ -80,7 +80,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -101,7 +101,7 @@ if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -4,8 +4,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
type ResizeDirection =
| 'top'
@@ -558,10 +558,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
node.value = resolveNode(nodeId) ?? null
}
updateImageUrl()

View File

@@ -131,8 +131,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
@@ -227,7 +227,7 @@ const handleImageError = () => {
const handleEditMask = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
const node = resolveNode(Number(props.nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
@@ -246,7 +246,7 @@ const handleDownload = () => {
const handleRemove = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
const node = resolveNode(Number(props.nodeId))
nodeOutputStore.removeNodeOutputs(props.nodeId)
if (node) {
node.imgs = undefined

View File

@@ -193,8 +193,8 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -374,7 +374,7 @@ function selectFromGrid(index: number) {
function handleEditMask() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
@@ -395,7 +395,7 @@ function handleDownload() {
function handleRemove() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
nodeOutputStore.removeNodeOutputs(nodeId)
if (node) {
node.imgs = undefined

View File

@@ -9,9 +9,12 @@ import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import * as litegraphUtil from '@/utils/litegraphUtil'
const mockResolveNode = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn(),
isVideoNode: vi.fn()
isVideoNode: vi.fn(),
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
}))
const mockGetNodeById = vi.fn()
@@ -337,7 +340,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg, 0)
@@ -351,7 +354,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg, 0)
@@ -365,7 +368,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 42 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(42, mockImg, 3)
@@ -379,11 +382,11 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 123 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs('123', mockImg, 0)
expect(mockGetNodeById).toHaveBeenCalledWith(123)
expect(mockResolveNode).toHaveBeenCalledWith(123)
expect(mockNode.imgs).toEqual([mockImg])
})
@@ -392,7 +395,7 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const store = useNodeOutputStore()
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(undefined)
mockResolveNode.mockReturnValue(undefined)
expect(() => store.syncLegacyNodeImgs(999, mockImg, 0)).not.toThrow()
})
@@ -403,10 +406,27 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg)
expect(mockNode.imageIndex).toBe(0)
})
it('should sync node.imgs when node is inside a subgraph', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 5 })
const mockImg = document.createElement('img')
// Node NOT in root graph (returns null)
mockGetNodeById.mockReturnValue(null)
// But found by resolveNode (in a subgraph)
mockResolveNode.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(5, mockImg, 0)
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
})

View File

@@ -15,7 +15,11 @@ import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil'
import {
isAnimatedOutput,
isVideoNode,
resolveNode
} from '@/utils/litegraphUtil'
import {
releaseSharedObjectUrl,
retainSharedObjectUrl
@@ -464,7 +468,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
if (!LiteGraph.vueNodesMode) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
if (!node) return
node.imgs = [element]

View File

@@ -1,442 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
compressWidgetInputSlots,
createNode,
isAnimatedOutput,
isVideoOutput,
migrateWidgetsValues,
resolveNode
} from '@/utils/litegraphUtil'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => ({
...(await importOriginal()),
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null }
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
function createMockCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
const mockGraph = {
add: vi.fn((node) => node),
change: vi.fn()
} as Partial<LGraph> as LGraph
const mockCanvas: Partial<LGraphCanvas> = {
graph_mouse: [100, 200],
graph: mockGraph,
...overrides
}
return mockCanvas as LGraphCanvas
}
describe('createNode', () => {
beforeEach(vi.clearAllMocks)
it('should create a node successfully', async () => {
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, 'LoadImage')
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(result).toBe(mockNode)
})
it('should return null when name is empty', async () => {
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, '')
expect(LiteGraph.createNode).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('should handle graph being null', async () => {
const mockNode = { pos: [0, 0] }
const mockCanvas = createMockCanvas({ graph: null })
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const result = await createNode(mockCanvas, 'LoadImage')
expect(mockNode.pos).toEqual([0, 0])
expect(result).toBeNull()
})
it('should set position based on canvas graph_mouse', async () => {
const mockCanvas = createMockCanvas({ graph_mouse: [250, 350] })
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
await createNode(mockCanvas, 'LoadAudio')
expect(mockNode.pos).toEqual([250, 350])
})
})
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {
normalInput: {
type: 'INT',
name: 'normalInput'
},
forceInputField: {
type: 'STRING',
name: 'forceInputField',
forceInput: true
},
anotherNormal: {
type: 'FLOAT',
name: 'anotherNormal'
}
}
const widgets = [
{ name: 'normalInput', type: 'number' },
{ name: 'anotherNormal', type: 'number' }
] as Partial<IWidget>[] as IWidget[]
const widgetValues = [42, 'dummy value', 3.14]
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([42, 3.14])
})
it('should return original values if lengths do not match', () => {
const inputDefs: Record<string, InputSpec> = {
input1: {
type: 'INT',
name: 'input1',
forceInput: true
}
}
const widgets: IWidget[] = []
const widgetValues = [42, 'extra value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual(widgetValues)
})
it('should handle empty widgets and values', () => {
const inputDefs: Record<string, InputSpec> = {}
const widgets: IWidget[] = []
const widgetValues: unknown[] = []
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([])
})
it('should preserve order of non-forceInput widget values', () => {
const inputDefs: Record<string, InputSpec> = {
first: {
type: 'INT',
name: 'first'
},
forced: {
type: 'STRING',
name: 'forced',
forceInput: true
},
last: {
type: 'FLOAT',
name: 'last'
}
}
const widgets = [
{ name: 'first', type: 'number' },
{ name: 'last', type: 'number' }
] as Partial<IWidget>[] as IWidget[]
const widgetValues = ['first value', 'dummy', 'last value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual(['first value', 'last value'])
})
it('should correctly handle seed with unexpected value', () => {
const inputDefs: Record<string, InputSpec> = {
normalInput: {
type: 'INT',
name: 'normalInput',
control_after_generate: true
},
forceInputField: {
type: 'STRING',
name: 'forceInputField',
forceInput: true
}
}
const widgets = [
{ name: 'normalInput', type: 'number' },
{ name: 'control_after_generate', type: 'string' }
] as Partial<IWidget>[] as IWidget[]
const widgetValues = [42, 'fixed', 'unexpected widget value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([42, 'fixed'])
})
})
function createOutput(
overrides: Partial<ExecutedWsMessage['output']> = {}
): ExecutedWsMessage['output'] {
return { ...overrides }
}
describe('isAnimatedOutput', () => {
it('returns false for undefined output', () => {
expect(isAnimatedOutput(undefined)).toBe(false)
})
it('returns false when animated array is missing', () => {
expect(isAnimatedOutput(createOutput())).toBe(false)
})
it('returns false when all animated values are false', () => {
expect(isAnimatedOutput(createOutput({ animated: [false, false] }))).toBe(
false
)
})
it('returns true when any animated value is true', () => {
expect(isAnimatedOutput(createOutput({ animated: [false, true] }))).toBe(
true
)
})
})
describe('isVideoOutput', () => {
it('returns false for non-animated output', () => {
expect(
isVideoOutput(
createOutput({
animated: [false],
images: [{ filename: 'video.webm' }]
})
)
).toBe(false)
})
it('returns false for animated webp output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'anim.webp' }]
})
)
).toBe(false)
})
it('returns false for animated png output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'anim.png' }]
})
)
).toBe(false)
})
it('returns true for animated webm output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'output.webm' }]
})
)
).toBe(true)
})
it('returns true for animated mp4 output', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'output.mp4' }]
})
)
).toBe(true)
})
it('returns true for animated output with no images array', () => {
expect(isVideoOutput(createOutput({ animated: [true] }))).toBe(true)
})
it('does not false-positive on filenames containing webp as substring', () => {
expect(
isVideoOutput(
createOutput({
animated: [true],
images: [{ filename: 'my_webp_file.mp4' }]
})
)
).toBe(true)
})
})
describe('compressWidgetInputSlots', () => {
it('should remove unconnected widget input slots', () => {
// Using partial mock - only including properties needed for test
const graph = {
nodes: [
{
id: 1,
type: 'foo',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: null, type: 'INT', name: 'baz' }
],
outputs: []
}
],
links: [[2, 1, 0, 1, 0, 'INT']]
} as Partial<ISerialisedGraph> as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs).toEqual([
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' }
])
})
it('should update link target slots correctly', () => {
const graph = {
nodes: [
{
id: 1,
type: 'foo',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
],
outputs: []
}
],
links: [
[2, 1, 0, 1, 1, 'INT'],
[3, 1, 0, 1, 2, 'INT']
]
} as Partial<ISerialisedGraph> as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs).toEqual([
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
])
expect(graph.links).toEqual([
[2, 1, 0, 1, 0, 'INT'],
[3, 1, 0, 1, 1, 'INT']
])
})
it('should handle graphs with no nodes gracefully', () => {
// Using partial mock - only including properties needed for test
const graph = {
nodes: [],
links: []
} as Partial<ISerialisedGraph> as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes).toEqual([])
expect(graph.links).toEqual([])
})
})
import { resolveNode } from './litegraphUtil'
describe('resolveNode', () => {
function mockGraph(
nodeList: Partial<LGraphNode>[],
subgraphs?: Map<string, LGraph>
) {
const nodesById: Record<string, LGraphNode> = {}
for (const n of nodeList) {
nodesById[String(n.id)] = n as LGraphNode
}
return {
nodes: nodeList as LGraphNode[],
getNodeById(id: unknown) {
return id != null ? (nodesById[String(id)] ?? null) : null
},
subgraphs: subgraphs ?? new Map()
} as unknown as LGraph
}
it('returns undefined when graph is nullish', () => {
it('returns undefined when graph is null', () => {
expect(resolveNode(1, null)).toBeUndefined()
})
it('returns undefined when graph is undefined', () => {
expect(resolveNode(1, undefined)).toBeUndefined()
})
it('finds a node in the main graph', () => {
const node = { id: 5 } as LGraphNode
const graph = mockGraph([node])
expect(resolveNode(5, graph)).toBe(node)
it('finds a node in the root graph', () => {
const graph = new LGraph()
const node = new LGraphNode('TestNode')
graph.add(node)
expect(resolveNode(node.id, graph)).toBe(node)
})
it('finds a node in a subgraph', () => {
const subNode = { id: 10 } as LGraphNode
const subgraph = mockGraph([subNode])
const graph = mockGraph([], new Map([['sg-1', subgraph]]))
expect(resolveNode(10, graph)).toBe(subNode)
})
it('returns undefined when node does not exist anywhere', () => {
const graph = new LGraph()
it('returns undefined when node is not found anywhere', () => {
const graph = mockGraph([{ id: 1 } as LGraphNode])
expect(resolveNode(999, graph)).toBeUndefined()
})
it('prefers main graph over subgraph', () => {
const mainNode = { id: 1, title: 'main' } as LGraphNode
const subNode = { id: 1, title: 'sub' } as LGraphNode
const subgraph = mockGraph([subNode])
const graph = mockGraph([mainNode], new Map([['sg-1', subgraph]]))
expect(resolveNode(1, graph)).toBe(mainNode)
it('finds a node inside a subgraph', () => {
const subgraph = createTestSubgraph({ nodeCount: 1 })
const rootGraph = subgraph.rootGraph
rootGraph._subgraphs.set(subgraph.id, subgraph)
const subgraphNode = subgraph._nodes[0]
// Node should NOT be found directly on root graph
expect(rootGraph.getNodeById(subgraphNode.id)).toBeFalsy()
// But resolveNode should find it via subgraph search
expect(resolveNode(subgraphNode.id, rootGraph)).toBe(subgraphNode)
})
it('prefers root graph node over subgraph node with same id', () => {
const subgraph = createTestSubgraph()
const rootGraph = subgraph.rootGraph
const rootNode = new LGraphNode('RootNode')
rootGraph.add(rootNode)
// Add a different node to the subgraph
const sgNode = new LGraphNode('SubgraphNode')
subgraph.add(sgNode)
// resolveNode should return the root graph node first
expect(resolveNode(rootNode.id, rootGraph)).toBe(rootNode)
})
it('searches across multiple subgraphs', () => {
const sg1 = createTestSubgraph({ name: 'SG1' })
const rootGraph = sg1.rootGraph
const sg2 = createTestSubgraph({ name: 'SG2', nodeCount: 1 })
// Put sg2 under the same root graph
rootGraph._subgraphs.set(sg2.id, sg2)
const targetNode = sg2._nodes[0]
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
})
})