diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate.tsx index 75d658fdb21..c24af5869dc 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate.tsx @@ -3,6 +3,8 @@ import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocati import { useInputFieldInstanceExists } from 'features/nodes/hooks/useInputFieldInstanceExists'; import { useInputFieldNameSafe } from 'features/nodes/hooks/useInputFieldNameSafe'; import { useInputFieldTemplateExists } from 'features/nodes/hooks/useInputFieldTemplateExists'; +import { useNodeType } from 'features/nodes/hooks/useNodeType'; +import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { PropsWithChildren, ReactNode } from 'react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,8 +19,14 @@ type Props = PropsWithChildren<{ export const InputFieldGate = memo(({ nodeId, fieldName, children, fallback, formatLabel }: Props) => { const hasInstance = useInputFieldInstanceExists(fieldName); const hasTemplate = useInputFieldTemplateExists(fieldName); + const nodeType = useNodeType(); if (!hasTemplate || !hasInstance) { + // Backend nodes with `extra='allow'` (e.g. core_metadata) accept inputs that aren't declared + // in the OpenAPI schema. Render nothing rather than an "unexpected field" warning. + if (hasInstance && !hasTemplate && nodeAcceptsExtraInputs(nodeType)) { + return null; + } // fallback may be null, indicating we should render nothing at all - must check for undefined explicitly if (fallback !== undefined) { return fallback; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts index 85738b357c9..fd52550b89f 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts @@ -5,6 +5,7 @@ import { debounce } from 'es-toolkit'; import { $templates } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { NodesState, Templates } from 'features/nodes/store/types'; +import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { FieldInputInstance, FieldInputTemplate, @@ -275,6 +276,11 @@ export const getInvocationNodeErrors = ( const fieldTemplate = nodeTemplate.inputs[fieldName]; if (!fieldTemplate) { + // Backend nodes with `extra='allow'` accept inputs that aren't declared in the OpenAPI + // schema; these carry recall metadata and don't need template-based validation. + if (nodeAcceptsExtraInputs(node.data.type)) { + continue; + } errors.push({ type: 'node-error', nodeId, issue: t('parameters.invoke.missingFieldTemplate') }); continue; } @@ -310,6 +316,9 @@ const syncNodeErrors = (nodesState: NodesState, templates: Templates) => { const fieldTemplate = nodeTemplate.inputs[fieldName]; if (!fieldTemplate) { + if (nodeAcceptsExtraInputs(node.data.type)) { + continue; + } errors.push({ type: 'node-error', nodeId: node.id, issue: t('parameters.invoke.missingFieldTemplate') }); $nodeErrors.setKey(node.id, errors); continue; diff --git a/invokeai/frontend/web/src/features/nodes/types/extraInputs.ts b/invokeai/frontend/web/src/features/nodes/types/extraInputs.ts new file mode 100644 index 00000000000..4291dbd411a --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/types/extraInputs.ts @@ -0,0 +1,16 @@ +/** + * Some backend nodes are configured with pydantic `extra='allow'` and accept inputs that aren't + * declared in the OpenAPI schema. The most important case is `core_metadata`, which collects + * generation-mode-specific recall data (`z_image_seed_variance_*`, `dype_preset`, `ref_images`, ...). + * + * The frontend must round-trip those extras intact: + * - `graphToWorkflow` synthesizes a `MetadataExtraField` template for them + * - `buildNodesGraph` forwards their values verbatim + * - `fieldValidators` does not treat them as errors + * - `InputFieldGate` does not render an "unexpected field" warning + * + * See: https://github.com/invoke-ai/InvokeAI/issues/9151 + */ +const NODES_ACCEPTING_EXTRA_INPUTS: ReadonlySet = new Set(['core_metadata']); + +export const nodeAcceptsExtraInputs = (nodeType: string): boolean => NODES_ACCEPTING_EXTRA_INPUTS.has(nodeType); diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index ffd87ae3984..14f82cc293a 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -204,6 +204,32 @@ const zImageGeneratorFieldType = zFieldTypeBase.extend({ name: z.literal('ImageGeneratorField'), originalType: zStatelessFieldType.optional(), }); +/** + * Synthetic field type used for `core_metadata` extra fields that are not declared in the backend + * schema (e.g. `z_image_seed_variance_*`, `dype_preset`, `ref_images`, ...). The backend's + * `CoreMetadataInvocation` is configured with `extra='allow'`, so these are valid metadata to + * round-trip even though there is no OpenAPI template for them. + */ +const zMetadataExtraFieldType = zFieldTypeBase.extend({ + name: z.literal('MetadataExtraField'), + originalType: zStatelessFieldType.optional(), +}); +const zLoRAMetadataFieldType = zFieldTypeBase.extend({ + name: z.literal('LoRAMetadataField'), + originalType: zStatelessFieldType.optional(), +}); +const zControlNetMetadataFieldType = zFieldTypeBase.extend({ + name: z.literal('ControlNetMetadataField'), + originalType: zStatelessFieldType.optional(), +}); +const zIPAdapterMetadataFieldType = zFieldTypeBase.extend({ + name: z.literal('IPAdapterMetadataField'), + originalType: zStatelessFieldType.optional(), +}); +const zT2IAdapterMetadataFieldType = zFieldTypeBase.extend({ + name: z.literal('T2IAdapterMetadataField'), + originalType: zStatelessFieldType.optional(), +}); const zStatefulFieldType = z.union([ zIntegerFieldType, zFloatFieldType, @@ -220,6 +246,11 @@ const zStatefulFieldType = z.union([ zIntegerGeneratorFieldType, zStringGeneratorFieldType, zImageGeneratorFieldType, + zLoRAMetadataFieldType, + zControlNetMetadataFieldType, + zIPAdapterMetadataFieldType, + zT2IAdapterMetadataFieldType, + zMetadataExtraFieldType, ]); export type StatefulFieldType = z.infer; const statefulFieldTypeNames = zStatefulFieldType.options.map((o) => o.shape.name.value); @@ -1228,6 +1259,93 @@ export const getImageGeneratorDefaults = (type: ImageGeneratorFieldValue['type'] }; // #endregion +// #region Metadata pass-through fields +/** + * The `core_metadata` node carries metadata lists that are not edited via the UI - they are set by + * the Generate-mode graph builder (or by an edge) and must survive the workflow roundtrip verbatim + * so that the resulting image retains its recall metadata. Modeled as opaque object lists. + * + * See: https://github.com/invoke-ai/InvokeAI/issues/9151 + */ +// `z.any()` (not `z.unknown()`) so the inferred type stays JSON-assignable for logging/serialization. +const zMetadataPassthroughValue = z.array(z.record(z.string(), z.any())).nullish(); + +const zLoRAMetadataFieldValue = zMetadataPassthroughValue; +const zLoRAMetadataFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zLoRAMetadataFieldValue, +}); +const zLoRAMetadataFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zLoRAMetadataFieldType, + originalType: zFieldType.optional(), + default: zLoRAMetadataFieldValue, +}); +const zLoRAMetadataFieldOutputTemplate = zFieldOutputTemplateBase.extend({ + type: zLoRAMetadataFieldType, +}); +export type LoRAMetadataFieldInputTemplate = z.infer; + +const zControlNetMetadataFieldValue = zMetadataPassthroughValue; +const zControlNetMetadataFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zControlNetMetadataFieldValue, +}); +const zControlNetMetadataFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zControlNetMetadataFieldType, + originalType: zFieldType.optional(), + default: zControlNetMetadataFieldValue, +}); +const zControlNetMetadataFieldOutputTemplate = zFieldOutputTemplateBase.extend({ + type: zControlNetMetadataFieldType, +}); +export type ControlNetMetadataFieldInputTemplate = z.infer; + +const zIPAdapterMetadataFieldValue = zMetadataPassthroughValue; +const zIPAdapterMetadataFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zIPAdapterMetadataFieldValue, +}); +const zIPAdapterMetadataFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zIPAdapterMetadataFieldType, + originalType: zFieldType.optional(), + default: zIPAdapterMetadataFieldValue, +}); +const zIPAdapterMetadataFieldOutputTemplate = zFieldOutputTemplateBase.extend({ + type: zIPAdapterMetadataFieldType, +}); +export type IPAdapterMetadataFieldInputTemplate = z.infer; + +const zT2IAdapterMetadataFieldValue = zMetadataPassthroughValue; +const zT2IAdapterMetadataFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zT2IAdapterMetadataFieldValue, +}); +const zT2IAdapterMetadataFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zT2IAdapterMetadataFieldType, + originalType: zFieldType.optional(), + default: zT2IAdapterMetadataFieldValue, +}); +const zT2IAdapterMetadataFieldOutputTemplate = zFieldOutputTemplateBase.extend({ + type: zT2IAdapterMetadataFieldType, +}); +export type T2IAdapterMetadataFieldInputTemplate = z.infer; + +/** + * `MetadataExtraField` carries arbitrary JSON values for `core_metadata` extras that are not in the + * OpenAPI schema (e.g. `z_image_seed_variance_*`, `dype_preset`). Synthesized only by + * `graphToWorkflow` for `core_metadata` nodes; never produced by `parseSchema`. + */ +const zMetadataExtraFieldValue = z.any(); +const zMetadataExtraFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zMetadataExtraFieldValue, +}); +const zMetadataExtraFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zMetadataExtraFieldType, + originalType: zFieldType.optional(), + default: zMetadataExtraFieldValue, +}); +const zMetadataExtraFieldOutputTemplate = zFieldOutputTemplateBase.extend({ + type: zMetadataExtraFieldType, +}); +export type MetadataExtraFieldInputTemplate = z.infer; +// #endregion + // #region StatelessField /** * StatelessField is a catchall for stateless fields with no UI input components. They do not @@ -1294,6 +1412,11 @@ export const zStatefulFieldValue = z.union([ zIntegerGeneratorFieldValue, zStringGeneratorFieldValue, zImageGeneratorFieldValue, + zLoRAMetadataFieldValue, + zControlNetMetadataFieldValue, + zIPAdapterMetadataFieldValue, + zT2IAdapterMetadataFieldValue, + zMetadataExtraFieldValue, ]); export type StatefulFieldValue = z.infer; @@ -1322,6 +1445,11 @@ const zStatefulFieldInputInstance = z.union([ zIntegerGeneratorFieldInputInstance, zStringGeneratorFieldInputInstance, zImageGeneratorFieldInputInstance, + zLoRAMetadataFieldInputInstance, + zControlNetMetadataFieldInputInstance, + zIPAdapterMetadataFieldInputInstance, + zT2IAdapterMetadataFieldInputInstance, + zMetadataExtraFieldInputInstance, ]); export const zFieldInputInstance = z.union([zStatefulFieldInputInstance, zStatelessFieldInputInstance]); @@ -1350,6 +1478,11 @@ const zStatefulFieldInputTemplate = z.union([ zIntegerGeneratorFieldInputTemplate, zStringGeneratorFieldInputTemplate, zImageGeneratorFieldInputTemplate, + zLoRAMetadataFieldInputTemplate, + zControlNetMetadataFieldInputTemplate, + zIPAdapterMetadataFieldInputTemplate, + zT2IAdapterMetadataFieldInputTemplate, + zMetadataExtraFieldInputTemplate, ]); export const zFieldInputTemplate = z.union([zStatefulFieldInputTemplate, zStatelessFieldInputTemplate]); @@ -1377,6 +1510,11 @@ const zStatefulFieldOutputTemplate = z.union([ zIntegerGeneratorFieldOutputTemplate, zStringGeneratorFieldOutputTemplate, zImageGeneratorFieldOutputTemplate, + zLoRAMetadataFieldOutputTemplate, + zControlNetMetadataFieldOutputTemplate, + zIPAdapterMetadataFieldOutputTemplate, + zT2IAdapterMetadataFieldOutputTemplate, + zMetadataExtraFieldOutputTemplate, ]); export const zFieldOutputTemplate = z.union([zStatefulFieldOutputTemplate, zStatelessFieldOutputTemplate]); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts index 50052c806ce..b9b25a9ff7b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts @@ -6,6 +6,7 @@ import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { Templates } from 'features/nodes/store/types'; import { resolveConnectorSource } from 'features/nodes/store/util/connectorTopology'; import type { BoardField } from 'features/nodes/types/common'; +import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; import type { BoardFieldInputInstance } from 'features/nodes/types/field'; import { isBoardFieldInputInstance, isBoardFieldInputTemplate } from 'features/nodes/types/field'; import { isConnectorNode, isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation'; @@ -61,7 +62,11 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require (inputsAccumulator, input, name) => { const fieldTemplate = nodeTemplate.inputs[name]; if (!fieldTemplate) { - log.warn({ id, name }, 'Field template not found!'); + if (nodeAcceptsExtraInputs(type) && input.value !== undefined) { + inputsAccumulator[name] = input.value; + } else { + log.warn({ id, name }, 'Field template not found!'); + } return inputsAccumulator; } if (isBoardFieldInputTemplate(fieldTemplate) && isBoardFieldInputInstance(input)) { diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts index ef7b92efdd8..566194fe7d1 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts @@ -17,6 +17,11 @@ const FIELD_VALUE_FALLBACK_MAP: Record = IntegerGeneratorField: undefined, StringGeneratorField: undefined, ImageGeneratorField: undefined, + LoRAMetadataField: undefined, + ControlNetMetadataField: undefined, + IPAdapterMetadataField: undefined, + T2IAdapterMetadataField: undefined, + MetadataExtraField: undefined, }; export const buildFieldInputInstance = (id: string, template: FieldInputTemplate): FieldInputInstance => { diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts index adaa3f413ce..6795388c6c2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts @@ -4,6 +4,7 @@ import type { BoardFieldInputTemplate, BooleanFieldInputTemplate, ColorFieldInputTemplate, + ControlNetMetadataFieldInputTemplate, EnumFieldInputTemplate, FieldInputTemplate, FieldType, @@ -16,6 +17,9 @@ import type { IntegerFieldCollectionInputTemplate, IntegerFieldInputTemplate, IntegerGeneratorFieldInputTemplate, + IPAdapterMetadataFieldInputTemplate, + LoRAMetadataFieldInputTemplate, + MetadataExtraFieldInputTemplate, ModelIdentifierFieldInputTemplate, SchedulerFieldInputTemplate, StatefulFieldType, @@ -24,6 +28,7 @@ import type { StringFieldInputTemplate, StringGeneratorFieldInputTemplate, StylePresetFieldInputTemplate, + T2IAdapterMetadataFieldInputTemplate, } from 'features/nodes/types/field'; import { getFloatGeneratorArithmeticSequenceDefaults, @@ -464,6 +469,53 @@ const buildImageGeneratorFieldInputTemplate: FieldInputTemplateBuilder = ({ + baseField, + fieldType, +}) => ({ + ...baseField, + type: fieldType, + default: undefined, +}); + +const buildControlNetMetadataFieldInputTemplate: FieldInputTemplateBuilder = ({ + baseField, + fieldType, +}) => ({ + ...baseField, + type: fieldType, + default: undefined, +}); + +const buildIPAdapterMetadataFieldInputTemplate: FieldInputTemplateBuilder = ({ + baseField, + fieldType, +}) => ({ + ...baseField, + type: fieldType, + default: undefined, +}); + +const buildT2IAdapterMetadataFieldInputTemplate: FieldInputTemplateBuilder = ({ + baseField, + fieldType, +}) => ({ + ...baseField, + type: fieldType, + default: undefined, +}); + +// MetadataExtraField is synthesized by graphToWorkflow for `core_metadata` extras and never +// produced by parseSchema. This builder exists only to satisfy the StatefulFieldType['name'] record. +const buildMetadataExtraFieldInputTemplate: FieldInputTemplateBuilder = ({ + baseField, + fieldType, +}) => ({ + ...baseField, + type: fieldType, + default: undefined, +}); + const TEMPLATE_BUILDER_MAP: Record = { BoardField: buildBoardFieldInputTemplate, BooleanField: buildBooleanFieldInputTemplate, @@ -480,6 +532,11 @@ const TEMPLATE_BUILDER_MAP: Record { + it('produces a LoRAMetadataField template (not stateless)', () => { + const templates = parseSchema(minimalSchema); + const coreMetadata = templates.core_metadata; + expect(coreMetadata).toBeDefined(); + + const lorasInput = coreMetadata?.inputs.loras; + expect(lorasInput).toBeDefined(); + expect(lorasInput?.type.name).toBe('LoRAMetadataField'); + expect(lorasInput?.type.cardinality).toBe('COLLECTION'); + // Stateless inputs only accept 'connection'. If our template is correctly + // stateful, this should be 'any' (the input mode declared in the schema). + expect(lorasInput?.input).toBe('any'); + }); +}); 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..7e66d2974aa --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.test.ts @@ -0,0 +1,189 @@ +/** + * Reproduction of issue #9151 using the user's actual first-image graph + * extracted from a real generation. Verifies the LoRA metadata survives + * the graph -> workflow roundtrip. + */ + +import { $templates } from 'features/nodes/store/nodesSlice'; +import type { Templates } from 'features/nodes/store/types'; +import type { NonNullableGraph } from 'services/api/types'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { graphToWorkflow } from './graphToWorkflow'; +import { parseAndMigrateWorkflow } from './migrations'; + +// Minimal templates needed to render the user's graph. We use the same shape +// the real parseSchema would produce for these nodes' fields - only the +// `core_metadata.loras` and `lora_selector.{lora,weight}` templates need to +// be accurate enough for the roundtrip; other inputs are mocked permissively. +const looseStringInput = { + name: '', + title: '', + description: '', + ui_hidden: false, + fieldKind: 'input' as const, + input: 'any' as const, + required: false, + default: undefined, +}; +const passthroughTemplate = (typeName: string) => ({ + type: 'invocation', + title: typeName, + version: '1.0.0', + tags: [], + description: '', + outputType: `${typeName}_output`, + inputs: new Proxy( + {}, + { + get: (_, prop: string) => ({ + ...looseStringInput, + name: prop, + title: prop, + type: { name: 'StringField', cardinality: 'SINGLE', batch: false }, + }), + has: () => true, + } + ), + outputs: {}, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'misc', +}); + +const templates: Templates = new Proxy( + {}, + { + get: (_, prop: string) => { + if (prop === 'core_metadata') { + return { + ...passthroughTemplate('core_metadata'), + inputs: new Proxy( + {}, + { + get: (_, key: string) => ({ + ...looseStringInput, + name: key, + title: key, + type: + key === 'loras' + ? { name: 'LoRAMetadataField', cardinality: 'COLLECTION', batch: false } + : { name: 'StringField', cardinality: 'SINGLE', batch: false }, + }), + has: () => true, + } + ), + classification: 'internal', + }; + } + if (prop === 'lora_selector') { + return { + ...passthroughTemplate('lora_selector'), + version: '1.0.3', + inputs: new Proxy( + {}, + { + get: (_, key: string) => ({ + ...looseStringInput, + name: key, + title: key, + type: + key === 'lora' + ? { name: 'ModelIdentifierField', cardinality: 'SINGLE', batch: false } + : { name: 'FloatField', cardinality: 'SINGLE', batch: false }, + }), + has: () => true, + } + ), + }; + } + return passthroughTemplate(typeof prop === 'string' ? prop : 'unknown'); + }, + } +) as unknown as Templates; + +const expectedLorasValue = [ + { + model: { + key: '1bce536a-a770-49bd-a58e-c6576a7fe41d', + hash: 'blake3:5dc8b2234366702e22410e5f2de079d24c13eb58aa9597fa6a4d6688ce9f1da2', + name: 'hina_zImageTurbo_anime_v2.52-dream', + base: 'z-image', + type: 'lora', + submodel_type: null, + }, + weight: 0.75, + }, +]; + +const userFirstGraph = { + id: 'z_image_graph:EnnoIJwImk', + nodes: { + 'core_metadata:sbmUlPbCpY': { + id: 'core_metadata:sbmUlPbCpY', + type: 'core_metadata', + is_intermediate: true, + use_cache: true, + generation_mode: 'z_image_txt2img', + loras: expectedLorasValue, + // Backend `extra='allow'` extras - not declared in OpenAPI schema: + z_image_seed_variance_enabled: false, + z_image_seed_variance_strength: 0.1, + z_image_seed_variance_randomize_percent: 50, + }, + 'lora_selector:lXZkTpWiQQ': { + id: 'lora_selector:lXZkTpWiQQ', + type: 'lora_selector', + is_intermediate: true, + use_cache: true, + lora: expectedLorasValue[0]?.model, + weight: 0.75, + }, + }, + edges: [], +} as unknown as NonNullableGraph; + +const findInput = (workflow: ReturnType, nodeId: string, fieldName: string) => { + const node = workflow.nodes.find((n) => n.id === nodeId); + if (!node || node.type !== 'invocation') { + return undefined; + } + return node.data.inputs[fieldName]; +}; + +describe('issue #9151: graphToWorkflow + zod validation roundtrip', () => { + beforeAll(() => { + $templates.set(templates); + }); + + it('graphToWorkflow preserves core_metadata.loras value', () => { + const workflow = graphToWorkflow(userFirstGraph, false); + const lorasInput = findInput(workflow, 'core_metadata:sbmUlPbCpY', 'loras'); + expect(lorasInput).toBeDefined(); + expect(lorasInput?.value).toEqual(expectedLorasValue); + }); + + it('parseAndMigrateWorkflow does not strip core_metadata.loras', () => { + const workflow = graphToWorkflow(userFirstGraph, false); + const migrated = parseAndMigrateWorkflow(workflow); + const lorasInput = findInput(migrated, 'core_metadata:sbmUlPbCpY', 'loras'); + expect(lorasInput).toBeDefined(); + expect(lorasInput?.value).toEqual(expectedLorasValue); + }); + + it('graphToWorkflow preserves core_metadata extra fields (z_image_seed_variance_*)', () => { + const workflow = graphToWorkflow(userFirstGraph, false); + expect(findInput(workflow, 'core_metadata:sbmUlPbCpY', 'z_image_seed_variance_enabled')?.value).toBe(false); + expect(findInput(workflow, 'core_metadata:sbmUlPbCpY', 'z_image_seed_variance_strength')?.value).toBe(0.1); + expect(findInput(workflow, 'core_metadata:sbmUlPbCpY', 'z_image_seed_variance_randomize_percent')?.value).toBe(50); + }); + + it('parseAndMigrateWorkflow does not strip core_metadata extra fields', () => { + const workflow = graphToWorkflow(userFirstGraph, false); + const migrated = parseAndMigrateWorkflow(workflow); + expect(findInput(migrated, 'core_metadata:sbmUlPbCpY', 'z_image_seed_variance_enabled')?.value).toBe(false); + expect(findInput(migrated, 'core_metadata:sbmUlPbCpY', 'z_image_seed_variance_strength')?.value).toBe(0.1); + expect(findInput(migrated, 'core_metadata:sbmUlPbCpY', 'z_image_seed_variance_randomize_percent')?.value).toBe(50); + }); +}); 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..d80bdc1dfc8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts @@ -3,7 +3,8 @@ import { logger } from 'app/logging/logger'; import { forEach } from 'es-toolkit/compat'; import { $templates } from 'features/nodes/store/nodesSlice'; import { NODE_WIDTH } from 'features/nodes/types/constants'; -import type { FieldInputInstance } from 'features/nodes/types/field'; +import { nodeAcceptsExtraInputs } from 'features/nodes/types/extraInputs'; +import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { getDefaultForm } from 'features/nodes/types/workflow'; import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; @@ -12,6 +13,20 @@ import { v4 as uuidv4 } from 'uuid'; const log = logger('workflows'); +// Synthesize a MetadataExtraField template so values for `extra='allow'` keys round-trip through +// the workflow when the OpenAPI schema does not declare them. +const buildMetadataExtraFieldTemplate = (name: string): FieldInputTemplate => ({ + name, + title: name, + description: '', + ui_hidden: true, + fieldKind: 'input', + input: 'any', + required: false, + default: undefined, + type: { name: 'MetadataExtraField', cardinality: 'SINGLE', batch: false }, +}); + /** * Converts a graph to a workflow. This is a best-effort conversion and may not be perfect. * For example, if a graph references an unknown node type, that node will be skipped. @@ -60,12 +75,17 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor return; } - const inputTemplate = template.inputs[key]; + let inputTemplate = template.inputs[key]; - // Skip missing input templates if (!inputTemplate) { - log.warn(`Input ${key} not found in template for node type ${node.type}`); - return; + if (nodeAcceptsExtraInputs(node.type)) { + // The backend node accepts extras (pydantic `extra='allow'`); preserve the value so + // recall metadata survives the round-trip. + inputTemplate = buildMetadataExtraFieldTemplate(key); + } else { + log.warn(`Input ${key} not found in template for node type ${node.type}`); + return; + } } // This _should_ be all we need to do! diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/loraMetadataRoundtrip.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/loraMetadataRoundtrip.test.ts new file mode 100644 index 00000000000..d92aae17aa7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/loraMetadataRoundtrip.test.ts @@ -0,0 +1,112 @@ +/** + * Regression tests for issue #9151: + * "Lora collection not firing during generation on workflow" + * + * Actual root cause: the LoRA *is* applied during the second run (verified + * by the user's generation logs and by inspecting the second-run graph — + * lora_selector / collect / z_image_lora_collection_loader nodes and edges + * are identical to the first run). What was broken: `core_metadata.loras` + * and the sibling metadata lists were dropped during the graph -> workflow + * -> graph roundtrip, so the resulting image had no LoRA in its metadata + * and Recall found nothing. + * + * Cause: `LoRAMetadataField` (and the ControlNet/IPAdapter/T2IAdapter + * metadata field types) were not registered as `zStatefulFieldType`, so + * inputs holding them fell through to `zStatelessFieldInputInstance`, + * whose `value` is `z.undefined().catch(undefined)` — coercing the value + * to undefined. + */ + +import { zFieldInputInstance } from 'features/nodes/types/field'; +import { describe, expect, it } from 'vitest'; + +describe('issue #9151: metadata-field roundtrip', () => { + it('preserves a LoRAMetadataField[] value', () => { + const inputInstance = { + name: 'loras', + label: '', + description: '', + value: [ + { + model: { + key: 'lora-key-1', + hash: 'hash1', + name: 'My Z-Image LoRA', + base: 'z-image', + type: 'lora', + submodel_type: null, + }, + weight: 0.75, + }, + ], + }; + + const parsed = zFieldInputInstance.parse(inputInstance); + expect(parsed.value).toEqual(inputInstance.value); + }); + + it('preserves a ControlNetMetadataField[] value', () => { + const inputInstance = { + name: 'controlnets', + label: '', + description: '', + value: [ + { + image: { image_name: 'in.png' }, + control_model: { key: 'cn', hash: 'h', name: 'cn', base: 'sdxl', type: 'controlnet' }, + control_weight: 0.5, + }, + ], + }; + + expect(zFieldInputInstance.parse(inputInstance).value).toEqual(inputInstance.value); + }); + + it('preserves an IPAdapterMetadataField[] value', () => { + const inputInstance = { + name: 'ipAdapters', + label: '', + description: '', + value: [ + { + image: { image_name: 'in.png' }, + ip_adapter_model: { key: 'ip', hash: 'h', name: 'ip', base: 'sdxl', type: 'ip_adapter' }, + clip_vision_model: 'ViT-H', + method: 'full', + weight: 0.6, + begin_step_percent: 0, + end_step_percent: 1, + }, + ], + }; + + expect(zFieldInputInstance.parse(inputInstance).value).toEqual(inputInstance.value); + }); + + it('preserves a T2IAdapterMetadataField[] value', () => { + const inputInstance = { + name: 't2iAdapters', + label: '', + description: '', + value: [ + { + image: { image_name: 'in.png' }, + t2i_adapter_model: { key: 't2i', hash: 'h', name: 't2i', base: 'sdxl', type: 't2i_adapter' }, + }, + ], + }; + + expect(zFieldInputInstance.parse(inputInstance).value).toEqual(inputInstance.value); + }); + + it('null is still accepted (no loras applied case)', () => { + const inputInstance = { + name: 'loras', + label: '', + description: '', + value: null, + }; + + expect(zFieldInputInstance.parse(inputInstance).value).toBeNull(); + }); +});