diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts index 238efbb4b3d..3ed03b960b2 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts @@ -21,7 +21,7 @@ import { type WorkflowGeneratorResolutions, } from './batch'; import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from './callSavedWorkflow'; -import { createWorkflowId } from './document'; +import { createWorkflowId, nodeAcceptsExtraInputs } from './document'; import { getWorkflowFieldInvalidReason, isDirectInputField, isWorkflowCollectionItemValid } from './fields'; import { createForLoopValidationReason, @@ -285,7 +285,27 @@ export const compileProjectGraph = ( const workflowInputs: Record = {}; for (const instance of Object.values(node.data.inputs)) { - const inputTemplate = node.data.dynamicInputTemplates?.[instance.name] ?? template.inputs[instance.name]; + const dynamicInputs = node.data.dynamicInputTemplates; + const inputTemplate = + dynamicInputs && Object.hasOwn(dynamicInputs, instance.name) + ? dynamicInputs[instance.name] + : Object.hasOwn(template.inputs, instance.name) + ? template.inputs[instance.name] + : undefined; + + if ( + !inputTemplate && + nodeAcceptsExtraInputs(node.data.type) && + !['id', 'type', 'is_intermediate', 'use_cache'].includes(instance.name) && + instance.value !== undefined + ) { + Object.defineProperty(graphNode, instance.name, { + configurable: true, + enumerable: true, + value: structuredClone(instance.value), + writable: true, + }); + } // An unset board shows as Auto, so it resolves like one. if (!inputTemplate || (instance.value === undefined && inputTemplate.type.name !== 'BoardField')) { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index 96f334d17f5..f4720c456b5 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -443,6 +443,8 @@ export const getNodeUpdateStatus = ( /** Node types whose undeclared inputs the backend accepts (`extra='allow'`), so an update must carry them over. */ const EXTRA_INPUT_NODE_TYPES = new Set(['core_metadata']); +export const nodeAcceptsExtraInputs = (type: string): boolean => EXTRA_INPUT_NODE_TYPES.has(type); + interface InvocationNodeUpdate { node: WorkflowInvocationNode; droppedInputNames: string[]; @@ -450,6 +452,44 @@ interface InvocationNodeUpdate { renamedInputs: Record; } +/** Unversioned execution graphs need the same value migration as older authored workflows. */ +export const migrateImageCollectionInputValues = ( + inputs: Record, + template: InvocationTemplate, + isCollectionConnected: boolean +): void => { + const collection = inputs.collection; + const images = inputs.images; + + if ( + images && + collection && + Array.isArray(collection.value) && + !(Array.isArray(images.value) && images.value.length > 0) && + !isCollectionConnected + ) { + inputs.images = { ...images, value: collection.value }; + inputs.collection = { ...collection, value: structuredClone(template.inputs.collection?.default) }; + } +}; + +export const migrateMiniMaxH3NumFramesValue = ( + inputs: Record, + template: InvocationTemplate +): void => { + const frames = inputs.num_frames; + const frameTemplate = template.inputs.num_frames; + + if (frames && frameTemplate?.options && typeof frames.value === 'number') { + const choice = String(frames.value); + + inputs.num_frames = { + ...frames, + value: frameTemplate.options.includes(choice) ? choice : frameTemplate.default, + }; + } +}; + /** * Fresh template defaults underneath the node's own values: existing labels, values, and node settings win, new * inputs start at their defaults, inputs the template no longer declares are dropped (dynamic and extra inputs @@ -479,7 +519,7 @@ const updateInvocationNodeToTemplate = ( } if ( - EXTRA_INPUT_NODE_TYPES.has(node.data.type) || + nodeAcceptsExtraInputs(node.data.type) || node.data.dynamicInputTemplates?.[name] !== undefined || name.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX) ) { @@ -494,41 +534,16 @@ const updateInvocationNodeToTemplate = ( // `image_collection` 1.0.2 moved its direct list from `collection` to `images`; a connected `collection` keeps // feeding the node and is left alone. if (node.data.type === 'image_collection' && sourceVersion && compareVersions(sourceVersion, [1, 0, 2]) < 0) { - const collection = node.data.inputs.collection; - const images = inputs.images; - - if (images) { + if (inputs.images) { renamedInputs.collection = 'images'; - - if ( - collection && - Array.isArray(collection.value) && - !(Array.isArray(images.value) && images.value.length > 0) && - !connectedInputNames.has('collection') - ) { - inputs.images = { ...images, value: collection.value }; - - if (inputs.collection) { - inputs.collection = { ...inputs.collection, value: fresh.data.inputs.collection?.value }; - } - } } + migrateImageCollectionInputValues(inputs, template, connectedInputNames.has('collection')); } // `minimax_h3_denoise` 1.3.0 turned the free frame count into a choice list; a count still on the grid becomes // that choice, anything else falls back to the default. if (node.data.type === 'minimax_h3_denoise' && sourceVersion && compareVersions(sourceVersion, [1, 3, 0]) < 0) { - const frames = inputs.num_frames; - const frameTemplate = template.inputs.num_frames; - - if (frames && frameTemplate?.options && typeof frames.value === 'number') { - const choice = String(frames.value); - - inputs.num_frames = { - ...frames, - value: frameTemplate.options.includes(choice) ? choice : frameTemplate.default, - }; - } + migrateMiniMaxH3NumFramesValue(inputs, template); } return { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts index c783613afc7..94b45d876c2 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'; import type { FieldInputTemplate, InvocationTemplate } from './types'; -import { compileProjectGraph } from './buildGraph'; -import { previewGraphToDocument, type PreviewGraphLike } from './graphToDocument'; +import { compileProjectGraph, getProjectGraphReadiness } from './buildGraph'; +import { updateWorkflowNodes } from './document'; +import { previewGraphToDocument, storedGraphToDocument, type PreviewGraphLike } from './graphToDocument'; import { isInvocationNode } from './types'; +import { parseWorkflowJson, serializeWorkflowJson } from './workflowJson'; const input = (name: string, overrides: Partial = {}): FieldInputTemplate => ({ default: undefined, @@ -87,7 +89,186 @@ const graph: PreviewGraphLike = { ], }; +describe('storedGraphToDocument', () => { + it('loads unversioned frame counts as current editable choices', () => { + const frameTemplates = { + minimax_h3_denoise: { + ...template('minimax_h3_denoise', { + num_frames: input('num_frames', { + default: '124', + options: ['90', '124'], + type: { batch: false, cardinality: 'SINGLE', name: 'EnumField' }, + }), + }), + version: '1.4.0', + }, + }; + const { document } = storedGraphToDocument( + { + nodes: { + valid: { id: 'valid', type: 'minimax_h3_denoise', num_frames: 90 }, + invalid: { id: 'invalid', type: 'minimax_h3_denoise', num_frames: 100 }, + current: { id: 'current', type: 'minimax_h3_denoise', num_frames: '90' }, + }, + edges: [], + }, + frameTemplates + ); + const loaded = updateWorkflowNodes(parseWorkflowJson(serializeWorkflowJson(document)).document, frameTemplates); + expect( + getProjectGraphReadiness(loaded.document, { status: 'loaded', error: null, templates: frameTemplates }) + ).toMatchObject({ canInvoke: true, reasons: [] }); + const compiled = compileProjectGraph(loaded.document, frameTemplates).backendGraph; + expect(compiled.nodes.valid?.num_frames).toBe('90'); + expect(compiled.nodes.invalid?.num_frames).toBe('124'); + expect(compiled.nodes.current?.num_frames).toBe('90'); + }); + + it('loads old image lists as editable images without replacing current lists or connected collections', () => { + const imageList = { batch: false, cardinality: 'COLLECTION' as const, name: 'ImageField' }; + const collectionTemplates = { + image_collection: { + ...template('image_collection', { + collection: input('collection', { input: 'connection', type: imageList }), + images: input('images', { default: [], type: imageList }), + }), + outputs: { collection: { description: '', name: 'collection', title: 'Collection', type: imageList } }, + version: '1.0.2', + }, + }; + const oldImages = [{ image_name: 'old.png' }]; + const currentImages = [{ image_name: 'current.png' }]; + const edge = { + source: { node_id: 'legacy', field: 'collection' }, + destination: { node_id: 'connected', field: 'collection' }, + }; + const { document } = storedGraphToDocument( + { + nodes: { + legacy: { id: 'legacy', type: 'image_collection', collection: oldImages }, + current: { id: 'current', type: 'image_collection', collection: oldImages, images: currentImages }, + connected: { id: 'connected', type: 'image_collection', collection: oldImages }, + }, + edges: [edge], + }, + collectionTemplates + ); + const loaded = updateWorkflowNodes( + parseWorkflowJson(serializeWorkflowJson(document)).document, + collectionTemplates + ); + expect( + getProjectGraphReadiness(loaded.document, { status: 'loaded', error: null, templates: collectionTemplates }) + ).toMatchObject({ canInvoke: true, reasons: [] }); + const compiled = compileProjectGraph(loaded.document, collectionTemplates).backendGraph; + expect(compiled.nodes.legacy?.images).toEqual(oldImages); + expect(compiled.nodes.legacy).not.toHaveProperty('collection'); + expect(compiled.nodes.current?.images).toEqual(currentImages); + expect(compiled.nodes.connected?.images).toEqual([]); + expect(compiled.nodes.connected).not.toHaveProperty('collection'); + expect(compiled.edges).toEqual([{ ...edge, type: 'default' }]); + }); + + it('recalls generation values, flags, metadata extras and edges through serialization and compilation', () => { + const storedTemplates = { ...templates, core_metadata: template('core_metadata', {}) }; + const stored = { + id: 'generated', + nodes: { + seed: { id: 'seed', type: 'integer', value: 42, use_cache: false }, + denoise: { id: 'denoise', type: 'denoise', steps: 12, is_intermediate: false, unrelated: 'drop' }, + metadata: { + id: 'metadata', + type: 'core_metadata', + is_intermediate: true, + loras: [{ model: { key: 'lora-1' }, weight: 0.75 }], + z_image_seed_variance_enabled: true, + }, + }, + edges: [ + { source: { node_id: 'seed', field: 'value' }, destination: { node_id: 'denoise', field: 'seed' } }, + { source: { node_id: 'seed', field: 'value' }, destination: { node_id: 'metadata', field: 'custom_seed' } }, + ], + }; + const { document, skippedNodeTypes } = storedGraphToDocument(stored, storedTemplates); + expect(skippedNodeTypes).toEqual([]); + const recalled = parseWorkflowJson(serializeWorkflowJson(document)).document; + const compiled = compileProjectGraph(recalled, storedTemplates).backendGraph; + + expect(compiled.nodes.seed).toMatchObject({ value: 42, use_cache: false }); + expect(compiled.nodes.denoise).toMatchObject({ steps: 12, is_intermediate: false }); + expect(compiled.nodes.denoise).not.toHaveProperty('unrelated'); + expect(compiled.nodes.metadata).toMatchObject({ + loras: [{ model: { key: 'lora-1' }, weight: 0.75 }], + z_image_seed_variance_enabled: true, + is_intermediate: true, + }); + expect(compiled.edges).toEqual(stored.edges.map((edge) => ({ ...edge, type: 'default' }))); + expect(recalled.nodes[0]?.position).not.toEqual(recalled.nodes[1]?.position); + + const metadata = recalled.nodes.find((node) => node.id === 'metadata'); + if (!metadata || !isInvocationNode(metadata)) { + throw new Error('Missing metadata node'); + } + for (const name of ['id', 'type', 'is_intermediate', 'use_cache']) { + metadata.data.inputs[name] = { name, label: '', value: 'injected' }; + } + expect(compileProjectGraph(recalled, storedTemplates).backendGraph.nodes.metadata).toMatchObject({ + id: 'metadata', + type: 'core_metadata', + is_intermediate: true, + use_cache: true, + }); + }); + + it('reports unknown nodes and omits their connections', () => { + const { document, skippedNodeTypes } = storedGraphToDocument( + { + nodes: { seed: { id: 'seed', type: 'integer', value: 42 }, absent: { id: 'absent', type: 'missing' } }, + edges: [{ source: { node_id: 'seed', field: 'value' }, destination: { node_id: 'absent', field: 'seed' } }], + }, + templates + ); + expect(skippedNodeTypes).toEqual(['missing']); + expect(document.nodes.map((node) => node.id)).toEqual(['seed']); + expect(document.edges).toEqual([]); + }); + + it.each([null, {}, { nodes: [], edges: [] }, { nodes: { seed: { type: 'integer' } }, edges: [] }])( + 'rejects malformed stored graphs: %j', + (raw) => { + expect(() => storedGraphToDocument(raw, templates)).toThrow(); + } + ); +}); + describe('previewGraphToDocument', () => { + it('treats prototype-named metadata fields as own values without changing object prototypes', () => { + const metadataTemplates = { core_metadata: template('core_metadata', {}) }; + const prototypeBefore = Object.getOwnPropertyDescriptors(Object.prototype); + const { document } = previewGraphToDocument( + { + nodes: [ + { + id: 'metadata', + type: 'core_metadata', + inputs: JSON.parse('{"__proto__":{"marker":42},"constructor":"custom"}'), + }, + ], + edges: [], + }, + metadataTemplates + ); + const node = document.nodes[0]; + expect(node && isInvocationNode(node) && Object.hasOwn(node.data.inputs, '__proto__')).toBe(true); + + const compiled = compileProjectGraph(document, metadataTemplates).backendGraph.nodes.metadata; + expect(JSON.parse(JSON.stringify(compiled))).toMatchObject( + JSON.parse('{"__proto__":{"marker":42},"constructor":"custom"}') + ); + expect(Object.getPrototypeOf(compiled)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptors(Object.prototype)).toEqual(prototypeBefore); + }); + it('converts a preview graph into an editable document, skipping unknown node types', () => { const { document, skippedNodeTypes } = previewGraphToDocument(graph, templates); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts index 14d7119b66e..3ed756b8381 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts @@ -1,7 +1,17 @@ +import { z } from 'zod'; + import type { InvocationTemplates, ProjectGraphState, WorkflowEdge, WorkflowInvocationNode } from './types'; -import { buildInvocationNode, createProjectGraph, createWorkflowId } from './document'; +import { + buildInvocationNode, + createProjectGraph, + createWorkflowId, + migrateImageCollectionInputValues, + migrateMiniMaxH3NumFramesValue, + nodeAcceptsExtraInputs, +} from './document'; import { getLayeredPositions } from './graphLayout'; +import { isInvocationNode } from './types'; /** Use a structural preview shape so core does not import UI contracts. */ export interface PreviewGraphLike { @@ -38,7 +48,7 @@ export const previewGraphToDocument = ( const nodes: WorkflowInvocationNode[] = []; for (const graphNode of graph.nodes) { - const template = templates[graphNode.type]; + const template = Object.hasOwn(templates, graphNode.type) ? templates[graphNode.type] : undefined; if (!template) { if (!seenSkippedTypes.has(graphNode.type)) { @@ -64,17 +74,26 @@ export const previewGraphToDocument = ( node.data.useCache = useCache; } - for (const key of Object.keys(template.inputs)) { - const value = graphNode.inputs[key]; + for (const [key, value] of Object.entries(graphNode.inputs)) { + if (key === 'id' || key === 'type' || key === 'is_intermediate' || key === 'use_cache') { + continue; + } if (value === undefined) { continue; } - const instance = node.data.inputs[key]; + const instance = Object.hasOwn(node.data.inputs, key) ? node.data.inputs[key] : undefined; if (instance) { instance.value = structuredClone(value); + } else if (nodeAcceptsExtraInputs(graphNode.type)) { + Object.defineProperty(node.data.inputs, key, { + configurable: true, + enumerable: true, + value: { label: '', name: key, value: structuredClone(value) }, + writable: true, + }); } } @@ -92,7 +111,10 @@ export const previewGraphToDocument = ( const targetNode = nodes.find((node) => node.id === graphEdge.targetNodeId); const targetTemplate = targetNode ? templates[targetNode.data.type] : undefined; - if (!targetTemplate || !(graphEdge.targetField in targetTemplate.inputs)) { + if ( + !targetTemplate || + (!Object.hasOwn(targetTemplate.inputs, graphEdge.targetField) && !nodeAcceptsExtraInputs(targetTemplate.type)) + ) { continue; } @@ -110,7 +132,10 @@ export const previewGraphToDocument = ( // value can't shadow the edge during compilation. for (const edge of edges) { const targetNode = nodes.find((node) => node.id === edge.target); - const instance = targetNode?.data.inputs[edge.targetHandle]; + const instance = + targetNode && Object.hasOwn(targetNode.data.inputs, edge.targetHandle) + ? targetNode.data.inputs[edge.targetHandle] + : undefined; if (instance) { instance.value = undefined; @@ -124,3 +149,52 @@ export const previewGraphToDocument = ( return { document, skippedNodeTypes }; }; + +/** Images made by Generate store an execution graph instead of an authored workflow. */ +export const storedGraphToDocument = (raw: unknown, templates: InvocationTemplates): PreviewGraphDocumentResult => { + const zGraphEndpoint = z.object({ field: z.string().min(1), node_id: z.string().min(1) }); + const zStoredGraph = z.object({ + nodes: z.record(z.string(), z.looseObject({ id: z.string().min(1), type: z.string().min(1) })), + edges: z.array( + z.object({ + source: zGraphEndpoint, + destination: zGraphEndpoint, + type: z.enum(['default', 'loop_linkage']).default('default'), + }) + ), + }); + const graph = zStoredGraph.parse(raw); + + const result = previewGraphToDocument( + { + nodes: Object.values(graph.nodes).map(({ id, type, ...inputs }) => ({ id, inputs, type })), + edges: graph.edges.map((edge, index) => ({ + id: String(index), + sourceField: edge.source.field, + sourceNodeId: edge.source.node_id, + targetField: edge.destination.field, + targetNodeId: edge.destination.node_id, + type: edge.type, + })), + }, + templates + ); + + // Graphs carry no source versions. Recognize legacy values before labeling the recalled nodes current. + // Preview conversion has already cleared connected input literals, so they cannot be mistaken for old lists. + for (const node of result.document.nodes) { + if (!isInvocationNode(node)) { + continue; + } + + const template = templates[node.data.type]!; + + if (node.data.type === 'image_collection') { + migrateImageCollectionInputValues(node.data.inputs, template, false); + } else if (node.data.type === 'minimax_h3_denoise') { + migrateMiniMaxH3NumFramesValue(node.data.inputs, template); + } + } + + return result; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/preview.ts b/invokeai/frontend/webv2/src/features/workflow/preview.ts index 5cf7a725823..f8197754e4c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/preview.ts +++ b/invokeai/frontend/webv2/src/features/workflow/preview.ts @@ -1,3 +1,4 @@ +export { storedGraphToDocument } from './core/graphToDocument'; export { documentToPreviewGraph, GraphPreviewDialog, GraphPreviewFlow } from './ui/graph-preview'; export { getWorkflowNodeBodyProps, diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index 913d5e46ba6..d70411da8ad 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -19,7 +19,12 @@ import { isVaeModelConfig, normalizeGenerateWidgetValues, } from '@features/generation/settings'; -import { requestWorkflowDocumentLoad } from '@features/workflow/react'; +import { + getInvocationTemplatesSnapshot, + refreshInvocationTemplates, + requestWorkflowDocumentLoad, +} from '@features/workflow/react'; +import { serializeWorkflowJson } from '@features/workflow/utility'; import { assertAccountScopeCurrent, captureAccountScope, @@ -77,8 +82,8 @@ const fetchEmbeddedWorkflow = (media: WorkflowMediaRef, signal: AbortSignal) => media.kind === 'image' ? galleryImages.workflow(media.name, signal) : galleryVideos.workflow(media.name, signal); /** - * Open the workflow editor before requesting replacement with the workflow embedded in an image or video; false - * from `openWorkflowEditor` means no editor can consume it. + * Load the media's authored workflow, or reconstruct its generation graph, then open the workflow editor. False + * from `openWorkflowEditor` means no editor can consume the load request. */ export const executeLoadMediaWorkflow = async ({ isProjectActive, @@ -98,15 +103,64 @@ export const executeLoadMediaWorkflow = async ({ const suffix = media.kind === 'video' ? 'Video' : ''; try { - const { workflow } = await fetchEmbeddedWorkflow(media, owner.signal); + const { workflow, graph } = await fetchEmbeddedWorkflow(media, owner.signal); assertAccountScopeCurrent(owner); - if (!workflow) { + if (!isProjectActive()) { + return; + } + + if (!workflow && !graph) { notifications.add({ kind: 'info', title: t(`widgets.gallery.itemActions.loadWorkflow.missing${suffix}`) }); return; } + let raw: unknown; + + if (workflow) { + raw = JSON.parse(workflow); + } else { + const { storedGraphToDocument } = await import('@features/workflow/preview'); + + assertAccountScopeCurrent(owner); + + if (!isProjectActive()) { + return; + } + + if (getInvocationTemplatesSnapshot().status !== 'loaded') { + await refreshInvocationTemplates(); + } + + assertAccountScopeCurrent(owner); + + if (!isProjectActive()) { + return; + } + + const snapshot = getInvocationTemplatesSnapshot(); + + if (snapshot.status !== 'loaded') { + throw new Error(snapshot.error || t('graphPreview.editInEditorFailed')); + } + + const { document, skippedNodeTypes } = storedGraphToDocument(JSON.parse(graph!), snapshot.templates); + + if (document.nodes.length === 0) { + throw new Error(t('graphPreview.editInEditorFailed')); + } + + raw = serializeWorkflowJson(document); + + if (skippedNodeTypes.length > 0) { + notifications.add({ + kind: 'info', + title: t('graphPreview.nodesSkipped', { count: skippedNodeTypes.length, types: skippedNodeTypes.join(', ') }), + }); + } + } + if (!isProjectActive()) { return; } @@ -115,7 +169,6 @@ export const executeLoadMediaWorkflow = async ({ throw new Error(t('widgets.gallery.itemActions.loadWorkflow.editorUnavailable')); } - const raw: unknown = JSON.parse(workflow); const name = typeof raw === 'object' && raw !== null && typeof (raw as { name?: unknown }).name === 'string' ? (raw as { name: string }).name diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx index 53351c96bed..b81a2f27494 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx @@ -1,4 +1,6 @@ import type { GalleryImage, GalleryItem, GalleryItemKey, GalleryItemRef } from '@features/gallery'; +import type { InvocationTemplatesSnapshot } from '@features/workflow/contracts'; +import type * as WorkflowReact from '@features/workflow/react'; import type { CreateCanvasFromImagesResult } from '@workbench/canvas-operations/api'; import { @@ -9,6 +11,7 @@ import { architectureCapabilitiesFixture, seedArchitectureCapabilities, } from '@features/generation/core/architectureCapabilities.testing'; +import { parseOpenApiToTemplates } from '@features/workflow/react'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, createRef, type Ref, useImperativeHandle } from 'react'; @@ -36,6 +39,8 @@ const mocks = vi.hoisted(() => ({ getItemStarred: vi.fn((..._args: unknown[]) => new Map()), getProject: vi.fn(), getSnapshot: vi.fn(), + getInvocationTemplatesSnapshot: vi.fn<() => InvocationTemplatesSnapshot>(), + refreshInvocationTemplates: vi.fn<() => Promise>(), gallerySelectItem: vi.fn(), gallerySetItemMultiSelection: vi.fn(), imageMetadata: vi.fn(), @@ -105,10 +110,19 @@ vi.mock('@features/gallery', () => ({ toGalleryItemRef: ({ kind, name }: { kind: 'image' | 'video'; name: string }) => ({ kind, name }), })); -vi.mock('@features/workflow/react', () => ({ +vi.mock('@features/workflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + getInvocationTemplatesSnapshot: () => mocks.getInvocationTemplatesSnapshot(), + refreshInvocationTemplates: () => mocks.refreshInvocationTemplates(), requestWorkflowDocumentLoad: (...args: unknown[]) => mocks.requestWorkflowDocumentLoad(...args), })); +vi.mock('@features/workflow/preview', async () => { + // Exercise the real conversion without mounting the preview UI's unrelated dependencies. + const { storedGraphToDocument } = await import('@features/workflow/core/graphToDocument'); + return { storedGraphToDocument }; +}); + vi.mock('@features/gallery/queries', () => ({ getGalleryItemBoardIdsFromCaches: (...args: unknown[]) => mocks.getItemBoardIds(...args), getGalleryItemStarredFromCaches: (...args: unknown[]) => mocks.getItemStarred(...args), @@ -295,6 +309,29 @@ beforeEach(() => { }); mocks.isActiveProject.mockImplementation(() => true); accountLifecycle.activate('user-a'); + mocks.refreshInvocationTemplates.mockReset().mockResolvedValue(undefined); + mocks.getInvocationTemplatesSnapshot.mockReturnValue({ + error: null, + status: 'loaded', + templates: parseOpenApiToTemplates({ + components: { + schemas: { + IntegerInvocation: { + class: 'invocation', + properties: { + type: { default: 'integer' }, + value: { type: 'integer', default: 0, input: 'any', field_kind: 'input' }, + }, + output: { $ref: '#/components/schemas/IntegerOutput' }, + version: '1.0.0', + }, + IntegerOutput: { + properties: { type: { default: 'integer_output' }, value: { type: 'integer', field_kind: 'output' } }, + }, + }, + }, + }), + }); currentItemActionContext = null; }); @@ -567,7 +604,7 @@ describe('load workflow from image', () => { it('hands the embedded workflow to the editor and opens it', async () => { mocks.imageWorkflow.mockResolvedValueOnce({ - graph: null, + graph: 'invalid graph ignored when an authored workflow exists', workflow: JSON.stringify({ name: 'Embedded', nodes: [] }), }); @@ -583,6 +620,110 @@ describe('load workflow from image', () => { { name: 'Embedded', nodes: [] }, 'widgets.gallery.itemActions.loadWorkflow.loadedLabel:Embedded' ); + expect(mocks.getInvocationTemplatesSnapshot).not.toHaveBeenCalled(); + }); + + it('loads the stored generation graph when the image has no authored workflow', async () => { + mocks.getInvocationTemplatesSnapshot.mockReturnValueOnce({ error: null, status: 'idle', templates: {} }); + mocks.imageWorkflow.mockResolvedValueOnce({ + graph: JSON.stringify({ + id: 'generated', + nodes: { seed: { id: 'seed', type: 'integer', value: 42 } }, + edges: [], + }), + workflow: null, + }); + + await act(async () => { + await actionsRef.current?.loadImageWorkflow({ ...image, hasWorkflow: true }); + }); + + expect(mocks.notificationsAdd.mock.calls).toEqual([]); + expect(mocks.requestWorkflowDocumentLoad).toHaveBeenCalledWith( + expect.objectContaining({ + nodes: [ + expect.objectContaining({ + id: 'seed', + data: expect.objectContaining({ + type: 'integer', + inputs: { value: expect.objectContaining({ value: 42 }) }, + }), + }), + ], + }), + expect.any(String) + ); + expect(mocks.openWorkbenchWidget).toHaveBeenCalled(); + expect(mocks.refreshInvocationTemplates).toHaveBeenCalledOnce(); + }); + + it.each(['{', JSON.stringify({ nodes: [], edges: [] }), JSON.stringify({ nodes: {}, edges: [] })])( + 'reports an unusable generation graph without opening the editor: %s', + async (graph) => { + mocks.imageWorkflow.mockResolvedValueOnce({ graph, workflow: null }); + + await act(async () => { + await actionsRef.current?.loadImageWorkflow(image); + }); + + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.openWorkbenchWidget).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'error', title: 'widgets.gallery.itemActions.loadWorkflow.failed' }) + ); + } + ); + + it.each(['account', 'project'] as const)( + 'does not load the graph after the %s changes while templates load', + async (changedOwner) => { + mocks.imageWorkflow.mockResolvedValueOnce({ + graph: JSON.stringify({ nodes: { seed: { id: 'seed', type: 'integer', value: 42 } }, edges: [] }), + workflow: null, + }); + mocks.getInvocationTemplatesSnapshot.mockReturnValueOnce({ status: 'idle', templates: {}, error: null }); + let finishLoading!: () => void; + mocks.refreshInvocationTemplates.mockImplementationOnce( + () => + new Promise((resolve) => { + finishLoading = resolve; + }) + ); + + const pendingLoad = actionsRef.current!.loadImageWorkflow(image); + await vi.waitFor(() => expect(mocks.refreshInvocationTemplates).toHaveBeenCalled()); + + await act(async () => { + if (changedOwner === 'account') { + accountLifecycle.activate('user-b'); + } else { + mocks.isActiveProject.mockReturnValue(false); + } + finishLoading(); + await pendingLoad; + }); + + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.openWorkbenchWidget).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).not.toHaveBeenCalled(); + } + ); + + it('reports a template load failure instead of opening an empty workflow', async () => { + mocks.imageWorkflow.mockResolvedValueOnce({ + graph: JSON.stringify({ nodes: { seed: { id: 'seed', type: 'integer', value: 42 } }, edges: [] }), + workflow: null, + }); + mocks.getInvocationTemplatesSnapshot.mockReturnValue({ status: 'error', templates: {}, error: 'Offline' }); + + await act(async () => { + await actionsRef.current?.loadImageWorkflow(image); + }); + + expect(mocks.refreshInvocationTemplates).toHaveBeenCalledOnce(); + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.openWorkbenchWidget).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error', message: 'Offline' })); }); it('says so, and leaves the editor alone, when the image embeds no workflow', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 7bfbf18a13a..a5a3b86bfec 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -118,13 +118,13 @@ export interface ImageActions extends GalleryItemActions { downloadImage: (image: GalleryImage) => Promise; downloadImages: (imageNames: string[]) => Promise; getImageRecallCapabilities: (image: GalleryImage, signal?: AbortSignal) => Promise; - /** Replaces the project graph with the workflow embedded in the image and opens the editor on it. */ + /** Opens the image's embedded workflow or generation graph in the workflow editor. */ loadImageWorkflow: (image: GalleryImage) => Promise; /** Recall availability for a gallery video, from its recorded core_metadata. */ getVideoRecallCapabilities: (item: GalleryVideoItem, signal?: AbortSignal) => Promise; /** Whether the video embeds a workflow; the listing does not say, so an unknown answer reads the record. */ hasVideoWorkflow: (item: GalleryVideoItem, signal?: AbortSignal) => Promise; - /** Replaces the project graph with the workflow embedded in the video and opens the editor on it. */ + /** Opens the video's embedded workflow or generation graph in the workflow editor. */ loadVideoWorkflow: (item: GalleryVideoItem) => Promise; moveImagesToBoard: (imageNames: string[], boardId: string) => Promise; openImageInPreview: (image: GalleryImage) => void;