From 3a2d420e2c933ebbadc0ff7bcc279258c9921d12 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 14 May 2026 12:12:01 -0500 Subject: [PATCH 1/5] Improve image collection chaining --- invokeai/app/invocations/primitives.py | 19 ++- .../panels/TopPanel/UpdateNodesButton.tsx | 10 +- .../nodes/util/node/nodeUpdate.test.ts | 122 ++++++++++++++++++ .../features/nodes/util/node/nodeUpdate.ts | 31 ++++- .../nodes/util/workflow/validateWorkflow.ts | 7 +- .../frontend/web/src/services/api/schema.ts | 8 +- tests/app/invocations/test_image.py | 32 +++++ 7 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index 7ec6c3dc149..6249de0cd8e 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -279,15 +279,28 @@ def invoke(self, context: InvocationContext) -> ImageOutput: title="Image Collection Primitive", tags=["primitives", "image", "collection"], category="primitives", - version="1.0.1", + version="1.0.2", ) class ImageCollectionInvocation(BaseInvocation): """A collection of image primitive values""" - collection: list[ImageField] = InputField(description="The collection of image values") + collection: Optional[list[ImageField]] = InputField( + default=None, + description="An optional image collection to append to", + input=Input.Connection, + title="Collection", + ui_order=0, + ) + images: Optional[list[ImageField]] = InputField( + default=None, + description="The images to append to the collection", + input=Input.Direct, + title="Images", + ui_order=1, + ) def invoke(self, context: InvocationContext) -> ImageCollectionOutput: - return ImageCollectionOutput(collection=self.collection) + return ImageCollectionOutput(collection=[*(self.collection or []), *(self.images or [])]) # endregion diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx index 5072d3ae7f3..007cdcf9c6e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx @@ -3,7 +3,7 @@ import { logger } from 'app/logging/logger'; import { useAppStore } from 'app/store/storeHooks'; import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; -import { selectNodes } from 'features/nodes/store/selectors'; +import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; import { NodeUpdateError } from 'features/nodes/types/error'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate'; @@ -20,6 +20,7 @@ const useUpdateNodes = () => { const updateNodes = useCallback(() => { const nodes = selectNodes(store.getState()); + const edges = selectEdges(store.getState()); const templates = $templates.get(); let unableToUpdateCount = 0; @@ -35,7 +36,12 @@ const useUpdateNodes = () => { return; } try { - const updatedNode = updateNode(node, template); + const connectedInputNames = new Set( + edges.flatMap((edge) => + edge.type === 'default' && edge.target === node.id && edge.targetHandle ? [edge.targetHandle] : [] + ) + ); + const updatedNode = updateNode(node, template, { connectedInputNames }); store.dispatch( nodesChanged([ { type: 'remove', id: updatedNode.id }, diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts new file mode 100644 index 00000000000..94c62b6b768 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts @@ -0,0 +1,122 @@ +import type { InvocationTemplate } from 'features/nodes/types/invocation'; +import { buildInvocationNode } from 'features/nodes/util/node/buildInvocationNode'; +import { updateNode } from 'features/nodes/util/node/nodeUpdate'; +import { describe, expect, it } from 'vitest'; + +const imageCollectionOutput = { + collection: { + fieldKind: 'output', + name: 'collection', + title: 'Collection', + description: 'The output images', + type: { + name: 'ImageField', + cardinality: 'COLLECTION', + batch: false, + }, + ui_hidden: false, + }, +} satisfies InvocationTemplate['outputs']; + +const oldImageCollectionTemplate = { + title: 'Image Collection Primitive', + type: 'image_collection', + version: '1.0.1', + tags: ['primitives', 'image', 'collection'], + description: 'A collection of image primitive values', + outputType: 'image_collection_output', + inputs: { + collection: { + name: 'collection', + title: 'Collection', + required: false, + description: 'The collection of image values', + fieldKind: 'input', + input: 'any', + ui_hidden: false, + type: { + name: 'ImageField', + cardinality: 'COLLECTION', + batch: false, + }, + default: undefined, + }, + }, + outputs: imageCollectionOutput, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'primitives', +} satisfies InvocationTemplate; + +const currentImageCollectionTemplate = { + ...oldImageCollectionTemplate, + version: '1.0.2', + inputs: { + collection: { + name: 'collection', + title: 'Collection', + required: false, + description: 'An optional image collection to append to', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + type: { + name: 'ImageField', + cardinality: 'COLLECTION', + batch: false, + }, + default: [], + }, + images: { + name: 'images', + title: 'Images', + required: false, + description: 'The images to append to the collection', + fieldKind: 'input', + input: 'direct', + ui_hidden: false, + type: { + name: 'ImageField', + cardinality: 'COLLECTION', + batch: false, + }, + default: undefined, + }, + }, +} satisfies InvocationTemplate; + +describe('updateNode', () => { + it('moves old image_collection direct collection values to the new images field', () => { + const node = buildInvocationNode({ x: 0, y: 0 }, oldImageCollectionTemplate); + const images = [{ image_name: 'first' }, { image_name: 'second' }]; + const collectionInput = node.data.inputs.collection; + if (!collectionInput) { + throw new Error('Expected collection input'); + } + collectionInput.value = images; + + const updated = updateNode(node, currentImageCollectionTemplate); + + expect(updated.data.version).toBe('1.0.2'); + expect(updated.data.inputs.images?.value).toEqual(images); + expect(updated.data.inputs.collection?.value).toEqual([]); + }); + + it('does not move old image_collection direct collection values when collection is connected', () => { + const node = buildInvocationNode({ x: 0, y: 0 }, oldImageCollectionTemplate); + const images = [{ image_name: 'stale' }]; + const collectionInput = node.data.inputs.collection; + if (!collectionInput) { + throw new Error('Expected collection input'); + } + collectionInput.value = images; + + const updated = updateNode(node, currentImageCollectionTemplate, { + connectedInputNames: new Set(['collection']), + }); + + expect(updated.data.inputs.images?.value).toBeUndefined(); + expect(updated.data.inputs.collection?.value).toEqual([]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts index 59eac81b33b..157295dfe2e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts @@ -7,6 +7,10 @@ import { zParsedSemver } from 'features/nodes/types/semver'; import { buildInvocationNode } from './buildInvocationNode'; +type UpdateNodeOptions = { + connectedInputNames?: Set; +}; + export const getNeedsUpdate = (data: InvocationNodeData, template: InvocationTemplate): boolean => { if (data.type !== template.type) { return true; @@ -29,6 +33,26 @@ const getMayUpdateNode = (node: InvocationNode, template: InvocationTemplate): b return satisfies(node.data.version, `^${templateMajor}`); }; +const migrateImageCollectionInputValues = (node: InvocationNode, options?: UpdateNodeOptions) => { + if (node.data.type !== 'image_collection') { + return; + } + + const collection = node.data.inputs.collection; + const images = node.data.inputs.images; + if (!collection || !images || !Array.isArray(collection.value)) { + return; + } + if (Array.isArray(images.value) && images.value.length > 0) { + return; + } + + if (!options?.connectedInputNames?.has('collection')) { + images.value = collection.value; + } + collection.value = []; +}; + /** * Updates a node to the latest version of its template: * - Create a new node data object with the latest version of the template. @@ -40,7 +64,11 @@ const getMayUpdateNode = (node: InvocationNode, template: InvocationTemplate): b * @param template The invocation template to update to. * @throws {NodeUpdateError} If the node is not an invocation node. */ -export const updateNode = (node: InvocationNode, template: InvocationTemplate): InvocationNode => { +export const updateNode = ( + node: InvocationNode, + template: InvocationTemplate, + options?: UpdateNodeOptions +): InvocationNode => { const mayUpdate = getMayUpdateNode(node, template); if (!mayUpdate || node.data.type !== template.type) { @@ -56,6 +84,7 @@ export const updateNode = (node: InvocationNode, template: InvocationTemplate): const clone = deepClone(node); clone.data.version = template.version; defaultsDeep(clone, defaults); // mutates! + migrateImageCollectionInputValues(clone, options); // Remove any fields that are not in the template clone.data.inputs = pick(clone.data.inputs, keys(defaults.data.inputs)); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts index 448214defe3..d5f0737d336 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -81,7 +81,12 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise + edge.type === 'default' && edge.target === node.id && edge.targetHandle ? [edge.targetHandle] : [] + ) + ); + const updatedNode = updateNode(node, template, { connectedInputNames }); node.data = updatedNode.data; } catch { const message = t('nodes.unableToUpdateNode', { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 79462d702ce..375b4957e69 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -13818,10 +13818,16 @@ export type components = { use_cache?: boolean; /** * Collection - * @description The collection of image values + * @description An optional image collection to append to * @default null */ collection?: components["schemas"]["ImageField"][] | null; + /** + * Images + * @description The images to append to the collection + * @default null + */ + images?: components["schemas"]["ImageField"][] | null; /** * type * @default image_collection diff --git a/tests/app/invocations/test_image.py b/tests/app/invocations/test_image.py index 7c0036d3db3..05d2cd32b76 100644 --- a/tests/app/invocations/test_image.py +++ b/tests/app/invocations/test_image.py @@ -8,6 +8,7 @@ from PIL import Image, ImageFilter from invokeai.app.invocations.image import ImageField, OklabUnsharpMaskInvocation, OklchImageHueAdjustmentInvocation +from invokeai.app.invocations.primitives import ImageCollectionInvocation from invokeai.backend.image_util.color_conversion import ( linear_srgb_from_oklab, linear_srgb_from_oklch, @@ -47,6 +48,37 @@ def _max_abs_diff_uint8(left: Image.Image, right: Image.Image) -> int: return int(numpy.abs(left_arr - right_arr).max()) +def test_image_collection_invocation_preserves_existing_collection_values() -> None: + images = [ImageField(image_name="first"), ImageField(image_name="second")] + + output = ImageCollectionInvocation(collection=images).invoke(MagicMock()) + + assert output.collection == images + + +def test_image_collection_invocation_appends_direct_images_after_chained_collection() -> None: + chained_images = [ImageField(image_name="chained")] + direct_images = [ImageField(image_name="direct_1"), ImageField(image_name="direct_2")] + + output = ImageCollectionInvocation(collection=chained_images, images=direct_images).invoke(MagicMock()) + + assert output.collection == [*chained_images, *direct_images] + + +def test_image_collection_invocation_supports_empty_direct_images() -> None: + chained_images = [ImageField(image_name="chained")] + + output = ImageCollectionInvocation(collection=chained_images, images=None).invoke(MagicMock()) + + assert output.collection == chained_images + + +def test_image_collection_invocation_outputs_empty_collection_when_inputs_are_empty() -> None: + output = ImageCollectionInvocation(collection=None, images=None).invoke(MagicMock()) + + assert output.collection == [] + + def test_oklab_unsharp_mask_invocation_preserves_alpha_and_sharpens_lightness_only() -> None: input_image = Image.new("RGBA", (3, 1)) input_image.putdata( From 7ee2ca0fb3083a1793ec1f685d0329275d4d92db Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Fri, 15 May 2026 05:33:25 -0500 Subject: [PATCH 2/5] chore: openapi.json --- invokeai/frontend/web/openapi.json | 33 +++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index bd39ee6ffa8..0c8d8dc51c2 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -32518,11 +32518,34 @@ } ], "default": null, - "description": "The collection of image values", + "description": "An optional image collection to append to", "field_kind": "input", - "input": "any", - "orig_required": true, - "title": "Collection" + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "Collection", + "ui_order": 0 + }, + "images": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ImageField" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The images to append to the collection", + "field_kind": "input", + "input": "direct", + "orig_default": null, + "orig_required": false, + "title": "Images", + "ui_order": 1 }, "type": { "const": "image_collection", @@ -32536,7 +32559,7 @@ "tags": ["primitives", "image", "collection"], "title": "Image Collection Primitive", "type": "object", - "version": "1.0.1", + "version": "1.0.2", "output": { "$ref": "#/components/schemas/ImageCollectionOutput" } From a9f2dd05d97795e0388fc12ede518dfefb51511f Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 5 Jul 2026 11:14:34 -0500 Subject: [PATCH 3/5] Fix image collection migration paths --- .../panels/TopPanel/UpdateNodesButton.tsx | 15 +- .../nodes/util/node/nodeUpdate.test.ts | 8 +- .../features/nodes/util/node/nodeUpdate.ts | 39 +++- .../util/workflow/graphToWorkflow.test.ts | 122 +++++++++++ .../nodes/util/workflow/graphToWorkflow.ts | 18 +- .../util/workflow/validateWorkflow.test.ts | 158 +++++++++++++- .../nodes/util/workflow/validateWorkflow.ts | 201 ++++++++++-------- 7 files changed, 446 insertions(+), 115 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx index 007cdcf9c6e..23fa81a7fdd 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx @@ -4,9 +4,8 @@ import { useAppStore } from 'app/store/storeHooks'; import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; -import { NodeUpdateError } from 'features/nodes/types/error'; import { isInvocationNode } from 'features/nodes/types/invocation'; -import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate'; +import { getConnectedInputNames, getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,11 +35,7 @@ const useUpdateNodes = () => { return; } try { - const connectedInputNames = new Set( - edges.flatMap((edge) => - edge.type === 'default' && edge.target === node.id && edge.targetHandle ? [edge.targetHandle] : [] - ) - ); + const connectedInputNames = getConnectedInputNames(node.id, edges); const updatedNode = updateNode(node, template, { connectedInputNames }); store.dispatch( nodesChanged([ @@ -48,10 +43,8 @@ const useUpdateNodes = () => { { type: 'add', item: updatedNode }, ]) ); - } catch (e) { - if (e instanceof NodeUpdateError) { - unableToUpdateCount++; - } + } catch { + unableToUpdateCount++; } }); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts index 94c62b6b768..7fbc3a78b3f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts @@ -66,7 +66,7 @@ const currentImageCollectionTemplate = { cardinality: 'COLLECTION', batch: false, }, - default: [], + default: undefined, }, images: { name: 'images', @@ -96,14 +96,14 @@ describe('updateNode', () => { } collectionInput.value = images; - const updated = updateNode(node, currentImageCollectionTemplate); + const updated = updateNode(node, currentImageCollectionTemplate, { connectedInputNames: new Set() }); expect(updated.data.version).toBe('1.0.2'); expect(updated.data.inputs.images?.value).toEqual(images); expect(updated.data.inputs.collection?.value).toEqual([]); }); - it('does not move old image_collection direct collection values when collection is connected', () => { + it('preserves old image_collection direct collection values when collection is connected', () => { const node = buildInvocationNode({ x: 0, y: 0 }, oldImageCollectionTemplate); const images = [{ image_name: 'stale' }]; const collectionInput = node.data.inputs.collection; @@ -117,6 +117,6 @@ describe('updateNode', () => { }); expect(updated.data.inputs.images?.value).toBeUndefined(); - expect(updated.data.inputs.collection?.value).toEqual([]); + expect(updated.data.inputs.collection?.value).toEqual(images); }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts index 157295dfe2e..1cb63b5a5c4 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts @@ -7,8 +7,24 @@ import { zParsedSemver } from 'features/nodes/types/semver'; import { buildInvocationNode } from './buildInvocationNode'; -type UpdateNodeOptions = { - connectedInputNames?: Set; +export type ConnectedInputEdge = { type?: string; target: string; targetHandle?: string | null }; + +export type UpdateNodeOptions = { + connectedInputNames: Set; +}; + +export const getConnectedInputNames = (nodeId: string, edges: ConnectedInputEdge[]): Set => + new Set( + edges.flatMap((edge) => + edge.type === 'default' && edge.target === nodeId && edge.targetHandle ? [edge.targetHandle] : [] + ) + ); + +export const getUpdatedFieldName = (node: InvocationNode, fieldName: string): string => { + if (node.data.type === 'image_collection' && fieldName === 'collection' && node.data.inputs.images) { + return 'images'; + } + return fieldName; }; export const getNeedsUpdate = (data: InvocationNodeData, template: InvocationTemplate): boolean => { @@ -33,10 +49,16 @@ const getMayUpdateNode = (node: InvocationNode, template: InvocationTemplate): b return satisfies(node.data.version, `^${templateMajor}`); }; -const migrateImageCollectionInputValues = (node: InvocationNode, options?: UpdateNodeOptions) => { +export const migrateImageCollectionInputValues = ( + node: InvocationNode, + options: UpdateNodeOptions & { sourceVersion?: string } +) => { if (node.data.type !== 'image_collection') { return; } + if (options.sourceVersion && options.sourceVersion !== '1.0.1') { + return; + } const collection = node.data.inputs.collection; const images = node.data.inputs.images; @@ -47,9 +69,11 @@ const migrateImageCollectionInputValues = (node: InvocationNode, options?: Updat return; } - if (!options?.connectedInputNames?.has('collection')) { - images.value = collection.value; + if (options.connectedInputNames.has('collection')) { + return; } + + images.value = collection.value; collection.value = []; }; @@ -67,7 +91,7 @@ const migrateImageCollectionInputValues = (node: InvocationNode, options?: Updat export const updateNode = ( node: InvocationNode, template: InvocationTemplate, - options?: UpdateNodeOptions + options: UpdateNodeOptions ): InvocationNode => { const mayUpdate = getMayUpdateNode(node, template); @@ -82,9 +106,10 @@ export const updateNode = ( // being valid. We rely on the template's major version to be majorly incremented if this kind of // merge would result in an invalid node. const clone = deepClone(node); + const sourceVersion = clone.data.version; clone.data.version = template.version; defaultsDeep(clone, defaults); // mutates! - migrateImageCollectionInputValues(clone, options); + migrateImageCollectionInputValues(clone, { ...options, sourceVersion }); // Remove any fields that are not in the template clone.data.inputs = pick(clone.data.inputs, keys(defaults.data.inputs)); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts new file mode 100644 index 00000000000..d4f0f75a575 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts @@ -0,0 +1,122 @@ +import { $templates } from 'features/nodes/store/nodesSlice'; +import type { InvocationTemplate } from 'features/nodes/types/invocation'; +import { isWorkflowInvocationNode } from 'features/nodes/types/workflow'; +import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; +import type { NonNullableGraph } from 'services/api/types'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const imageCollectionTemplate = { + title: 'Image Collection Primitive', + type: 'image_collection', + version: '1.0.2', + tags: ['primitives', 'image', 'collection'], + description: 'A collection of image primitive values', + outputType: 'image_collection_output', + inputs: { + collection: { + name: 'collection', + title: 'Collection', + required: false, + description: 'An optional image collection to append to', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + type: { name: 'ImageField', cardinality: 'COLLECTION', batch: false }, + default: undefined, + }, + images: { + name: 'images', + title: 'Images', + required: false, + description: 'The images to append to the collection', + fieldKind: 'input', + input: 'direct', + ui_hidden: false, + type: { name: 'ImageField', cardinality: 'COLLECTION', batch: false }, + default: undefined, + }, + }, + outputs: { + collection: { + fieldKind: 'output', + name: 'collection', + title: 'Collection', + description: 'The output images', + type: { name: 'ImageField', cardinality: 'COLLECTION', batch: false }, + ui_hidden: false, + }, + }, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'primitives', +} satisfies InvocationTemplate; + +describe('graphToWorkflow', () => { + const originalTemplates = $templates.get(); + + beforeEach(() => { + $templates.set({ image_collection: imageCollectionTemplate }); + }); + + afterEach(() => { + $templates.set(originalTemplates); + }); + + it('moves legacy image_collection graph collection values to the visible images field', () => { + const images = [{ image_name: 'legacy.png' }]; + const graph: NonNullableGraph = { + id: 'graph', + nodes: { + image_collection: { + id: 'image_collection', + type: 'image_collection', + collection: images, + }, + }, + edges: [], + }; + + const workflow = graphToWorkflow(graph, false); + const node = workflow.nodes[0]; + + if (!node || !isWorkflowInvocationNode(node)) { + throw new Error('Expected an image_collection workflow node'); + } + expect(node.data.inputs.images?.value).toEqual(images); + expect(node.data.inputs.collection?.value).toEqual([]); + }); + + it('preserves legacy image_collection graph collection values when collection is connected', () => { + const images = [{ image_name: 'shadowed.png' }]; + const graph: NonNullableGraph = { + id: 'graph', + nodes: { + source: { + id: 'source', + type: 'image_collection', + }, + target: { + id: 'target', + type: 'image_collection', + collection: images, + }, + }, + edges: [ + { + source: { node_id: 'source', field: 'collection' }, + destination: { node_id: 'target', field: 'collection' }, + }, + ], + }; + + const workflow = graphToWorkflow(graph, false); + const node = workflow.nodes[1]; + + if (!node || !isWorkflowInvocationNode(node)) { + throw new Error('Expected an image_collection workflow node'); + } + expect(node.data.inputs.images?.value).toBeUndefined(); + expect(node.data.inputs.collection?.value).toEqual(images); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts index c09f4e1729d..0dc24db323f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts @@ -5,7 +5,8 @@ import { $templates } from 'features/nodes/store/nodesSlice'; import { NODE_WIDTH } from 'features/nodes/types/constants'; import type { FieldInputInstance } from 'features/nodes/types/field'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; -import { getDefaultForm } from 'features/nodes/types/workflow'; +import { getDefaultForm, isWorkflowInvocationNode } from 'features/nodes/types/workflow'; +import { getConnectedInputNames, migrateImageCollectionInputValues } from 'features/nodes/util/node/nodeUpdate'; import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; import type { NonNullableGraph } from 'services/api/types'; import { v4 as uuidv4 } from 'uuid'; @@ -104,6 +105,21 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor }); }); + workflow.nodes.filter(isWorkflowInvocationNode).forEach((node) => { + if ( + node.data.type === 'image_collection' && + node.data.inputs.collection && + !node.data.inputs.images && + templates.image_collection?.inputs.images + ) { + node.data.inputs.images = buildFieldInputInstance(node.id, templates.image_collection.inputs.images); + } + + migrateImageCollectionInputValues(node, { + connectedInputNames: getConnectedInputNames(node.id, workflow.edges), + }); + }); + if (autoLayout) { // Best-effort auto layout via dagre - not perfect but better than nothing const dagreGraph = new dagre.graphlib.Graph(); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts index c1d08588315..b7ead12fa2f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts @@ -1,11 +1,60 @@ import { get } from 'es-toolkit/compat'; +import { addElement } from 'features/nodes/components/sidePanel/builder/form-manipulation'; import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology'; import { img_resize, main_model_loader } from 'features/nodes/store/util/testUtils'; +import type { InvocationTemplate } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; -import { getDefaultForm } from 'features/nodes/types/workflow'; +import { buildNodeFieldElement, getDefaultForm, isNodeFieldElement } from 'features/nodes/types/workflow'; import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'; import { describe, expect, it } from 'vitest'; +const imageCollectionTemplate = { + title: 'Image Collection Primitive', + type: 'image_collection', + version: '1.0.2', + tags: ['primitives', 'image', 'collection'], + description: 'A collection of image primitive values', + outputType: 'image_collection_output', + inputs: { + collection: { + name: 'collection', + title: 'Collection', + required: false, + description: 'An optional image collection to append to', + fieldKind: 'input', + input: 'connection', + ui_hidden: false, + type: { name: 'ImageField', cardinality: 'COLLECTION', batch: false }, + default: undefined, + }, + images: { + name: 'images', + title: 'Images', + required: false, + description: 'The images to append to the collection', + fieldKind: 'input', + input: 'direct', + ui_hidden: false, + type: { name: 'ImageField', cardinality: 'COLLECTION', batch: false }, + default: undefined, + }, + }, + outputs: { + collection: { + fieldKind: 'output', + name: 'collection', + title: 'Collection', + description: 'The output images', + type: { name: 'ImageField', cardinality: 'COLLECTION', batch: false }, + ui_hidden: false, + }, + }, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'primitives', +} satisfies InvocationTemplate; + //TODO(psyche): Test workflow validation for form builder fields describe('validateWorkflow', () => { const buildConnectorNode = (id: string) => ({ @@ -106,6 +155,32 @@ describe('validateWorkflow', () => { new Promise((resolve) => { resolve(false); }); + const addLegacyImageCollectionNode = (workflow: WorkflowV3, id: string, images = [{ image_name: `${id}.png` }]) => { + workflow.nodes.push({ + id, + type: 'invocation', + data: { + id, + type: 'image_collection', + version: '1.0.1', + label: '', + notes: '', + isOpen: true, + isIntermediate: true, + useCache: true, + nodePack: 'invokeai', + inputs: { + collection: { + name: 'collection', + label: '', + description: '', + value: images, + }, + }, + }, + position: { x: 0, y: 0 }, + }); + }; it('should reset images that are inaccessible', async () => { const validationResult = await validateWorkflow({ workflow: getWorkflow(), @@ -274,4 +349,85 @@ describe('validateWorkflow', () => { expect(validationResult.workflow.edges).toEqual([unresolvedEdge]); expect(validationResult.warnings).toEqual([]); }); + + it('should migrate image_collection direct values when its old collection edge is invalid', async () => { + const workflow = getWorkflow(); + const images = [{ image_name: 'legacy.png' }]; + addLegacyImageCollectionNode(workflow, 'image-collection', images); + workflow.edges.push({ + id: 'missing-source-edge', + type: 'default', + source: 'missing-node', + sourceHandle: 'collection', + target: 'image-collection', + targetHandle: 'collection', + }); + + const validationResult = await validateWorkflow({ + workflow, + templates: { img_resize, main_model_loader, image_collection: imageCollectionTemplate }, + checkImageAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.workflow.edges).toEqual([]); + expect(get(validationResult.workflow, 'nodes[2].data.inputs.images.value')).toEqual(images); + expect(get(validationResult.workflow, 'nodes[2].data.inputs.collection.value')).toEqual([]); + }); + + it('should preserve image_collection collection values when its old collection edge is valid', async () => { + const workflow = getWorkflow(); + const images = [{ image_name: 'shadowed.png' }]; + addLegacyImageCollectionNode(workflow, 'source-collection', []); + addLegacyImageCollectionNode(workflow, 'target-collection', images); + workflow.edges.push({ + id: 'valid-edge', + type: 'default', + source: 'source-collection', + sourceHandle: 'collection', + target: 'target-collection', + targetHandle: 'collection', + }); + + const validationResult = await validateWorkflow({ + workflow, + templates: { img_resize, main_model_loader, image_collection: imageCollectionTemplate }, + checkImageAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.workflow.edges).toHaveLength(1); + expect(get(validationResult.workflow, 'nodes[3].data.inputs.images.value')).toBeUndefined(); + expect(get(validationResult.workflow, 'nodes[3].data.inputs.collection.value')).toEqual(images); + }); + + it('should remap image_collection form and exposed collection fields to images', async () => { + const workflow = getWorkflow(); + const images = [{ image_name: 'legacy.png' }]; + addLegacyImageCollectionNode(workflow, 'image-collection', images); + workflow.exposedFields = [{ nodeId: 'image-collection', fieldName: 'collection' }]; + const element = buildNodeFieldElement('image-collection', 'collection', { + name: 'ImageField', + cardinality: 'COLLECTION', + batch: false, + }); + addElement({ form: workflow.form, element, parentId: workflow.form.rootElementId }); + + const validationResult = await validateWorkflow({ + workflow, + templates: { img_resize, main_model_loader, image_collection: imageCollectionTemplate }, + checkImageAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.workflow.exposedFields).toEqual([{ nodeId: 'image-collection', fieldName: 'images' }]); + const updatedElement = validationResult.workflow.form.elements[element.id]; + if (!updatedElement || !isNodeFieldElement(updatedElement)) { + throw new Error('Expected a node field form element'); + } + expect(updatedElement.data.fieldIdentifier.fieldName).toBe('images'); + }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts index d5f0737d336..741591dcdf2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -16,7 +16,12 @@ import { isNodeFieldElement, isWorkflowInvocationNode, } from 'features/nodes/types/workflow'; -import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate'; +import { + getConnectedInputNames, + getNeedsUpdate, + getUpdatedFieldName, + updateNode, +} from 'features/nodes/util/node/nodeUpdate'; import { t } from 'i18next'; import type { JsonObject } from 'type-fest'; @@ -58,6 +63,96 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === edge.source); + const targetNode = nodes.find(({ id }) => id === edge.target); + const sourceTemplate = sourceNode ? templates[sourceNode.data.type] : undefined; + const targetTemplate = targetNode ? templates[targetNode.data.type] : undefined; + const issues: string[] = []; + + if (!sourceNode) { + // The edge's source/output node does not exist + issues.push( + t('nodes.sourceNodeDoesNotExist', { + node: edge.source, + }) + ); + } + + if (sourceNode?.type === 'invocation' && !sourceTemplate) { + // The edge's source/output node template does not exist + issues.push( + t('nodes.missingTemplate', { + node: edge.source, + type: sourceNode?.data.type, + }) + ); + } + + if (sourceNode && sourceTemplate && edge.type === 'default' && !(edge.sourceHandle in sourceTemplate.outputs)) { + // The edge's source/output node field does not exist + issues.push( + t('nodes.sourceNodeFieldDoesNotExist', { + node: edge.source, + field: edge.sourceHandle, + }) + ); + } + + if (!targetNode) { + // The edge's target/input node does not exist + issues.push( + t('nodes.targetNodeDoesNotExist', { + node: edge.target, + }) + ); + } + + if (targetNode?.type === 'invocation' && !targetTemplate) { + // The edge's target/input node template does not exist + issues.push( + t('nodes.missingTemplate', { + node: edge.target, + type: targetNode?.data.type, + }) + ); + } + + if (targetNode && targetTemplate && edge.type === 'default' && !(edge.targetHandle in targetTemplate.inputs)) { + // The edge's target/input node field does not exist + issues.push( + t('nodes.targetNodeFieldDoesNotExist', { + node: edge.target, + field: edge.targetHandle, + }) + ); + } + + if (!issues.length && edge.type === 'default') { + const connectionError = validateConnection(edge, nodes, validEdges, templates, null, true); + if (connectionError) { + issues.push(connectionError); + } + } + + if (issues.length) { + const source = edge.type === 'default' ? `${edge.source}.${edge.sourceHandle}` : edge.source; + const target = edge.type === 'default' ? `${edge.target}.${edge.targetHandle}` : edge.target; + warnings.push({ + message: t('nodes.deletedInvalidEdge', { source, target }), + issues, + data: edge, + }); + } else { + validEdges.push(edge); + } + } + + // Remove invalid edges before node updates so migrations only trust surviving connections. + _workflow.edges = validEdges; for (const node of nodes) { if (!isWorkflowInvocationNode(node)) { @@ -81,11 +176,7 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise - edge.type === 'default' && edge.target === node.id && edge.targetHandle ? [edge.targetHandle] : [] - ) - ); + const connectedInputNames = getConnectedInputNames(node.id, validEdges); const updatedNode = updateNode(node, template, { connectedInputNames }); node.data = updatedNode.data; } catch { @@ -155,97 +246,25 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === edge.source); - const targetNode = nodes.find(({ id }) => id === edge.target); - const sourceTemplate = sourceNode ? templates[sourceNode.data.type] : undefined; - const targetTemplate = targetNode ? templates[targetNode.data.type] : undefined; - const issues: string[] = []; - - if (!sourceNode) { - // The edge's source/output node does not exist - issues.push( - t('nodes.sourceNodeDoesNotExist', { - node: edge.source, - }) - ); - } - - if (sourceNode?.type === 'invocation' && !sourceTemplate) { - // The edge's source/output node template does not exist - issues.push( - t('nodes.missingTemplate', { - node: edge.source, - type: sourceNode?.data.type, - }) - ); - } - - if (sourceNode && sourceTemplate && edge.type === 'default' && !(edge.sourceHandle in sourceTemplate.outputs)) { - // The edge's source/output node field does not exist - issues.push( - t('nodes.sourceNodeFieldDoesNotExist', { - node: edge.source, - field: edge.sourceHandle, - }) - ); + _workflow.exposedFields = _workflow.exposedFields.map((fieldIdentifier) => { + const node = nodes.filter(isWorkflowInvocationNode).find(({ id }) => id === fieldIdentifier.nodeId); + if (!node) { + return fieldIdentifier; } + return { ...fieldIdentifier, fieldName: getUpdatedFieldName(node, fieldIdentifier.fieldName) }; + }); - if (!targetNode) { - // The edge's target/input node does not exist - issues.push( - t('nodes.targetNodeDoesNotExist', { - node: edge.target, - }) - ); - } - - if (targetNode?.type === 'invocation' && !targetTemplate) { - // The edge's target/input node template does not exist - issues.push( - t('nodes.missingTemplate', { - node: edge.target, - type: targetNode?.data.type, - }) - ); - } - - if (targetNode && targetTemplate && edge.type === 'default' && !(edge.targetHandle in targetTemplate.inputs)) { - // The edge's target/input node field does not exist - issues.push( - t('nodes.targetNodeFieldDoesNotExist', { - node: edge.target, - field: edge.targetHandle, - }) - ); - } - - if (!issues.length && edge.type === 'default') { - const connectionError = validateConnection(edge, nodes, validEdges, templates, null, true); - if (connectionError) { - issues.push(connectionError); - } + for (const element of Object.values(_workflow.form.elements)) { + if (!isNodeFieldElement(element)) { + continue; } - - if (issues.length) { - const source = edge.type === 'default' ? `${edge.source}.${edge.sourceHandle}` : edge.source; - const target = edge.type === 'default' ? `${edge.source}.${edge.targetHandle}` : edge.target; - warnings.push({ - message: t('nodes.deletedInvalidEdge', { source, target }), - issues, - data: edge, - }); - } else { - validEdges.push(edge); + const node = nodes.filter(isWorkflowInvocationNode).find(({ id }) => id === element.data.fieldIdentifier.nodeId); + if (!node) { + continue; } + element.data.fieldIdentifier.fieldName = getUpdatedFieldName(node, element.data.fieldIdentifier.fieldName); } - // Remove invalid edges - _workflow.edges = validEdges; - // Migrated exposed fields to form elements if they exist and the form does not // Note: If the form is invalid per its zod schema, it will be reset to a default, empty form! if (_workflow.exposedFields.length > 0 && getIsFormEmpty(_workflow.form)) { From b9c84c7bcc077470e67117b26fd55140d1aa1ecb Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 5 Jul 2026 11:18:57 -0500 Subject: [PATCH 4/5] Fix node update knip exports --- .../frontend/web/src/features/nodes/util/node/nodeUpdate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts index 1cb63b5a5c4..9e55f71f858 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts @@ -7,9 +7,9 @@ import { zParsedSemver } from 'features/nodes/types/semver'; import { buildInvocationNode } from './buildInvocationNode'; -export type ConnectedInputEdge = { type?: string; target: string; targetHandle?: string | null }; +type ConnectedInputEdge = { type?: string; target: string; targetHandle?: string | null }; -export type UpdateNodeOptions = { +type UpdateNodeOptions = { connectedInputNames: Set; }; From 3a3ff585dbfe03384e0907d7a35b3a411f87a1e3 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 5 Jul 2026 13:03:45 -0500 Subject: [PATCH 5/5] Fix image collection version migration gate --- .../nodes/util/node/nodeUpdate.test.ts | 21 +++++++++++++++++++ .../features/nodes/util/node/nodeUpdate.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts index 7fbc3a78b3f..07d826c078d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.test.ts @@ -49,6 +49,11 @@ const oldImageCollectionTemplate = { category: 'primitives', } satisfies InvocationTemplate; +const oldestImageCollectionTemplate = { + ...oldImageCollectionTemplate, + version: '1.0.0', +} satisfies InvocationTemplate; + const currentImageCollectionTemplate = { ...oldImageCollectionTemplate, version: '1.0.2', @@ -103,6 +108,22 @@ describe('updateNode', () => { expect(updated.data.inputs.collection?.value).toEqual([]); }); + it('moves 1.0.0 image_collection direct collection values to the new images field', () => { + const node = buildInvocationNode({ x: 0, y: 0 }, oldestImageCollectionTemplate); + const images = [{ image_name: 'first' }]; + const collectionInput = node.data.inputs.collection; + if (!collectionInput) { + throw new Error('Expected collection input'); + } + collectionInput.value = images; + + const updated = updateNode(node, currentImageCollectionTemplate, { connectedInputNames: new Set() }); + + expect(updated.data.version).toBe('1.0.2'); + expect(updated.data.inputs.images?.value).toEqual(images); + expect(updated.data.inputs.collection?.value).toEqual([]); + }); + it('preserves old image_collection direct collection values when collection is connected', () => { const node = buildInvocationNode({ x: 0, y: 0 }, oldImageCollectionTemplate); const images = [{ image_name: 'stale' }]; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts index 9e55f71f858..00c19a0cf3c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/nodeUpdate.ts @@ -1,5 +1,5 @@ import { deepClone } from 'common/util/deepClone'; -import { satisfies } from 'compare-versions'; +import { compare, satisfies } from 'compare-versions'; import { defaultsDeep, keys, pick } from 'es-toolkit/compat'; import { NodeUpdateError } from 'features/nodes/types/error'; import type { InvocationNode, InvocationNodeData, InvocationTemplate } from 'features/nodes/types/invocation'; @@ -56,7 +56,7 @@ export const migrateImageCollectionInputValues = ( if (node.data.type !== 'image_collection') { return; } - if (options.sourceVersion && options.sourceVersion !== '1.0.1') { + if (options.sourceVersion && compare(options.sourceVersion, '1.0.2', '>=')) { return; }