diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 21938c6003..cc1e17cf51 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -111,6 +111,7 @@ "roarr": "^7.15.1", "serialize-error": "^11.0.1", "socket.io-client": "^4.7.2", + "type-fest": "^4.2.0", "use-debounce": "^9.0.4", "use-image": "^1.1.1", "uuid": "^9.0.0", diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx b/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx index 7e42bcea5f..97f2cea77b 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx +++ b/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx @@ -3,7 +3,7 @@ import { useLogger } from 'app/logging/useLogger'; import { useAppDispatch } from 'app/store/storeHooks'; import { parseify } from 'common/util/serialize'; import { workflowLoaded } from 'features/nodes/store/nodesSlice'; -import { zWorkflow } from 'features/nodes/types/types'; +import { zValidatedWorkflow } from 'features/nodes/types/types'; import { addToast } from 'features/system/store/systemSlice'; import { makeToast } from 'features/system/util/makeToast'; import { memo, useCallback } from 'react'; @@ -24,52 +24,65 @@ export const useLoadWorkflowFromFile = () => { try { const parsedJSON = JSON.parse(String(rawJSON)); - const result = zWorkflow.safeParse(parsedJSON); + const result = zValidatedWorkflow.safeParse(parsedJSON); if (!result.success) { - const message = fromZodError(result.error, { + const { message } = fromZodError(result.error, { prefix: 'Workflow Validation Error', - }).toString(); + }); + logger.error({ error: parseify(result.error) }, message); dispatch( addToast( makeToast({ title: 'Unable to Validate Workflow', - description: ( - <WorkflowValidationErrorContent error={result.error} /> - ), status: 'error', duration: 5000, }) ) ); + reader.abort(); return; } + dispatch(workflowLoaded(result.data.workflow)); - dispatch(workflowLoaded(result.data)); + if (!result.data.warnings.length) { + dispatch( + addToast( + makeToast({ + title: 'Workflow Loaded', + status: 'success', + }) + ) + ); + reader.abort(); + return; + } dispatch( addToast( makeToast({ - title: 'Workflow Loaded', - status: 'success', + title: 'Workflow Loaded with Warnings', + status: 'warning', }) ) ); + result.data.warnings.forEach(({ message, ...rest }) => { + logger.warn(rest, message); + }); + reader.abort(); - } catch (error) { + } catch { // file reader error - if (error) { - dispatch( - addToast( - makeToast({ - title: 'Unable to Load Workflow', - status: 'error', - }) - ) - ); - } + dispatch( + addToast( + makeToast({ + title: 'Unable to Load Workflow', + status: 'error', + }) + ) + ); } }; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index bfa658a8b5..c061e35c50 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -589,7 +589,7 @@ const nodesSlice = createSlice({ nodeEditorReset: (state) => { state.nodes = []; state.edges = []; - state.workflow.exposedFields = []; + state.workflow = cloneDeep(initialWorkflow); }, shouldValidateGraphChanged: (state, action: PayloadAction<boolean>) => { state.shouldValidateGraph = action.payload; diff --git a/invokeai/frontend/web/src/features/nodes/types/types.ts b/invokeai/frontend/web/src/features/nodes/types/types.ts index f6ff91b5ef..394351c720 100644 --- a/invokeai/frontend/web/src/features/nodes/types/types.ts +++ b/invokeai/frontend/web/src/features/nodes/types/types.ts @@ -1,3 +1,4 @@ +import { store } from 'app/store/store'; import { SchedulerParam, zBaseModel, @@ -5,9 +6,11 @@ import { zSDXLRefinerModel, zScheduler, } from 'features/parameters/types/parameterSchemas'; +import { keyBy } from 'lodash-es'; import { OpenAPIV3 } from 'openapi-types'; import { RgbaColor } from 'react-colorful'; import { Node } from 'reactflow'; +import { JsonObject } from 'type-fest'; import { Graph, ImageDTO, _InputField, _OutputField } from 'services/api/types'; import { AnyInvocationType, @@ -224,7 +227,7 @@ export type DenoiseMaskFieldValue = z.infer<typeof zDenoiseMaskField>; export const zIntegerInputFieldValue = zInputFieldValueBase.extend({ type: z.literal('integer'), - value: z.number().optional(), + value: z.number().int().optional(), }); export type IntegerInputFieldValue = z.infer<typeof zIntegerInputFieldValue>; @@ -825,28 +828,38 @@ export const zNotesNodeData = z.object({ export type NotesNodeData = z.infer<typeof zNotesNodeData>; +const zPosition = z + .object({ + x: z.number(), + y: z.number(), + }) + .default({ x: 0, y: 0 }); + +const zDimension = z.number().gt(0).nullish(); + export const zWorkflowInvocationNode = z.object({ id: z.string().trim().min(1), type: z.literal('invocation'), data: zInvocationNodeData, - width: z.number().gt(0), - height: z.number().gt(0), - position: z.object({ - x: z.number(), - y: z.number(), - }), + width: zDimension, + height: zDimension, + position: zPosition, }); +export type WorkflowInvocationNode = z.infer<typeof zWorkflowInvocationNode>; + +export const isWorkflowInvocationNode = ( + val: unknown +): val is WorkflowInvocationNode => + zWorkflowInvocationNode.safeParse(val).success; + export const zWorkflowNotesNode = z.object({ id: z.string().trim().min(1), type: z.literal('notes'), data: zNotesNodeData, - width: z.number().gt(0), - height: z.number().gt(0), - position: z.object({ - x: z.number(), - y: z.number(), - }), + width: zDimension, + height: zDimension, + position: zPosition, }); export const zWorkflowNode = z.discriminatedUnion('type', [ @@ -886,20 +899,75 @@ export const zSemVer = z.string().refine((val) => { export type SemVer = z.infer<typeof zSemVer>; +export type WorkflowWarning = { + message: string; + issues: string[]; + data: JsonObject; +}; + export const zWorkflow = z.object({ - name: z.string(), - author: z.string(), - description: z.string(), - version: z.string(), - contact: z.string(), - tags: z.string(), - notes: z.string(), - nodes: z.array(zWorkflowNode), - edges: z.array(zWorkflowEdge), - exposedFields: z.array(zFieldIdentifier), - meta: z.object({ - version: zSemVer, - }), + name: z.string().default(''), + author: z.string().default(''), + description: z.string().default(''), + version: z.string().default(''), + contact: z.string().default(''), + tags: z.string().default(''), + notes: z.string().default(''), + nodes: z.array(zWorkflowNode).default([]), + edges: z.array(zWorkflowEdge).default([]), + exposedFields: z.array(zFieldIdentifier).default([]), + meta: z + .object({ + version: zSemVer, + }) + .default({ version: '1.0.0' }), +}); + +export const zValidatedWorkflow = zWorkflow.transform((workflow) => { + const nodeTemplates = store.getState().nodes.nodeTemplates; + const { nodes, edges } = workflow; + const warnings: WorkflowWarning[] = []; + const invocationNodes = nodes.filter(isWorkflowInvocationNode); + const keyedNodes = keyBy(invocationNodes, 'id'); + invocationNodes.forEach((node, i) => { + const nodeTemplate = nodeTemplates[node.data.type]; + if (!nodeTemplate) { + warnings.push({ + message: `Node "${node.data.label || node.data.id}" skipped`, + issues: [`Unable to find template for type "${node.data.type}"`], + data: node, + }); + delete nodes[i]; + } + }); + edges.forEach((edge, i) => { + const sourceNode = keyedNodes[edge.source]; + const targetNode = keyedNodes[edge.target]; + const issues: string[] = []; + if (!sourceNode) { + issues.push(`Output node ${edge.source} does not exist`); + } else if (!(edge.sourceHandle in sourceNode.data.outputs)) { + issues.push( + `Output field "${edge.source}.${edge.sourceHandle}" does not exist` + ); + } + if (!targetNode) { + issues.push(`Input node ${edge.target} does not exist`); + } else if (!(edge.targetHandle in targetNode.data.inputs)) { + issues.push( + `Input field "${edge.target}.${edge.targetHandle}" does not exist` + ); + } + if (issues.length) { + delete edges[i]; + warnings.push({ + message: `Edge "${edge.sourceHandle} -> ${edge.targetHandle}" skipped`, + issues, + data: edge, + }); + } + }); + return { workflow, warnings }; }); export type Workflow = z.infer<typeof zWorkflow>; diff --git a/invokeai/frontend/web/src/features/nodes/util/getMetadataAndWorkflowFromImageBlob.ts b/invokeai/frontend/web/src/features/nodes/util/getMetadataAndWorkflowFromImageBlob.ts index e093dafa62..d44b4a7db1 100644 --- a/invokeai/frontend/web/src/features/nodes/util/getMetadataAndWorkflowFromImageBlob.ts +++ b/invokeai/frontend/web/src/features/nodes/util/getMetadataAndWorkflowFromImageBlob.ts @@ -1,5 +1,4 @@ import * as png from '@stevebel/png'; -import { logger } from 'app/logging/logger'; import { ImageMetadataAndWorkflow, zCoreMetadata, @@ -11,27 +10,24 @@ export const getMetadataAndWorkflowFromImageBlob = async ( image: Blob ): Promise<ImageMetadataAndWorkflow> => { const data: ImageMetadataAndWorkflow = {}; - try { - const buffer = await image.arrayBuffer(); - const text = png.decode(buffer).text; - const rawMetadata = get(text, 'invokeai_metadata'); - const rawWorkflow = get(text, 'invokeai_workflow'); - if (rawMetadata) { - try { - data.metadata = zCoreMetadata.parse(JSON.parse(rawMetadata)); - } catch { - // no-op - } + const buffer = await image.arrayBuffer(); + const text = png.decode(buffer).text; + + const rawMetadata = get(text, 'invokeai_metadata'); + if (rawMetadata) { + const metadataResult = zCoreMetadata.safeParse(JSON.parse(rawMetadata)); + if (metadataResult.success) { + data.metadata = metadataResult.data; } - if (rawWorkflow) { - try { - data.workflow = zWorkflow.parse(JSON.parse(rawWorkflow)); - } catch { - // no-op - } - } - } catch { - logger('nodes').warn('Unable to parse image'); } + + const rawWorkflow = get(text, 'invokeai_workflow'); + if (rawWorkflow) { + const workflowResult = zWorkflow.safeParse(JSON.parse(rawWorkflow)); + if (workflowResult.success) { + data.workflow = workflowResult.data; + } + } + return data; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts index 845d399a58..78e7495481 100644 --- a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts +++ b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts @@ -68,7 +68,7 @@ export const parseSchema = ( const invocations = filteredSchemas.reduce< Record<string, InvocationTemplate> - >((acc, schema) => { + >((invocationsAccumulator, schema) => { const type = schema.properties.type.default; const title = schema.title.replace('Invocation', ''); const tags = schema.tags ?? []; @@ -133,7 +133,7 @@ export const parseSchema = ( ); if (!field) { - logger('nodes').warn( + logger('nodes').debug( { node: type, fieldName: propertyName, @@ -154,17 +154,17 @@ export const parseSchema = ( const outputSchemaName = schema.output.$ref.split('/').pop(); if (!outputSchemaName) { - logger('nodes').error( + logger('nodes').warn( { outputRefObject: parseify(schema.output) }, 'No output schema name found in ref object' ); - throw 'No output schema name found in ref object'; + return invocationsAccumulator; } const outputSchema = openAPI.components?.schemas?.[outputSchemaName]; if (!outputSchema) { - logger('nodes').error({ outputSchemaName }, 'Output schema not found'); - throw 'Output schema not found'; + logger('nodes').warn({ outputSchemaName }, 'Output schema not found'); + return invocationsAccumulator; } if (!isInvocationOutputSchemaObject(outputSchema)) { @@ -172,7 +172,7 @@ export const parseSchema = ( { outputSchema: parseify(outputSchema) }, 'Invalid output schema' ); - throw 'Invalid output schema'; + return invocationsAccumulator; } const outputType = outputSchema.properties.type.default; @@ -203,19 +203,20 @@ export const parseSchema = ( { fieldName: propertyName, fieldType, field: parseify(property) }, 'Skipping unknown output field type' ); - } else { - outputsAccumulator[propertyName] = { - fieldKind: 'output', - name: propertyName, - title: property.title ?? '', - description: property.description ?? '', - type: fieldType, - ui_hidden: property.ui_hidden ?? false, - ui_type: property.ui_type, - ui_order: property.ui_order, - }; + return outputsAccumulator; } + outputsAccumulator[propertyName] = { + fieldKind: 'output', + name: propertyName, + title: property.title ?? '', + description: property.description ?? '', + type: fieldType, + ui_hidden: property.ui_hidden ?? false, + ui_type: property.ui_type, + ui_order: property.ui_order, + }; + return outputsAccumulator; }, {} as Record<string, OutputFieldTemplate> @@ -231,9 +232,9 @@ export const parseSchema = ( outputType, }; - Object.assign(acc, { [type]: invocation }); + Object.assign(invocationsAccumulator, { [type]: invocation }); - return acc; + return invocationsAccumulator; }, {}); return invocations; diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock index 91c21101be..2c3c9ae88f 100644 --- a/invokeai/frontend/web/yarn.lock +++ b/invokeai/frontend/web/yarn.lock @@ -6668,6 +6668,11 @@ type-fest@^2.12.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== +type-fest@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.2.0.tgz#e259430307710e77721ecf6f545840acad72195f" + integrity sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w== + typed-array-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz#18de3e7ed7974b0a729d3feecb94338d1472cd60"